From 5c071ecad58e20063b2f073045ca2461a0431926 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 2 Jul 2026 13:45:20 -0400 Subject: [PATCH] feat(plugins): plugin contract + first-party Nature pack (trees/flowers/grass) (#457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * feat(plugins): merge plugin panels into the v2 sidebar layout too The community shell renders , a separate rail from AppSidebar. Merge registry panels into the v2 tabMap + tab bar (and thus mobile) so plugin panels show up there as well, not only in the v1 AppSidebar. Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) * 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) * 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 cards instead of gradient swatches - credit footer linking @dgreenheck/ez-tree (Daniel Greenheck, MIT) Co-Authored-By: Claude Opus 4.8 * 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 * 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 * 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 * feat(plugin-trees): rename rail panel Trees -> Nature (covers trees/flowers/grass) Co-Authored-By: Claude Opus 4.8 * feat(plugin-trees): rename panel header Plant -> Nature to match the rail Co-Authored-By: Claude Opus 4.8 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * perf(plugin-trees): NO_RAYCAST on KindStatic meshes — cheap hover over a forest The baked scene's 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 * 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, 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 * 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 ; 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * docs: link plugin-authoring guide from README + CONTRIBUTING, fix stale wiki path Co-Authored-By: Claude Fable 5 * 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 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CONTRIBUTING.md | 4 + README.md | 9 + apps/editor/lib/bootstrap.ts | 7 + apps/editor/next.config.ts | 2 + apps/editor/package.json | 1 + bun.lock | 37 +++ packages/core/src/registry/index.ts | 7 + packages/core/src/registry/registry.ts | 112 ++++++- packages/core/src/registry/types.ts | 62 ++++ .../src/components/editor/bake-exporter.tsx | 17 + .../editor/src/components/editor/index.tsx | 35 +- .../src/components/ui/sidebar/app-sidebar.tsx | 6 +- .../ui/sidebar/use-plugin-panels.tsx | 97 ++++++ packages/editor/src/lib/glb-export.ts | 15 +- packages/editor/src/store/use-editor.tsx | 10 +- packages/nodes/src/guide/definition.ts | 3 + packages/nodes/src/scan/definition.ts | 3 + packages/plugin-trees/README.md | 92 ++++++ packages/plugin-trees/package.json | 56 ++++ packages/plugin-trees/src/art.ts | 48 +++ packages/plugin-trees/src/assets.d.ts | 8 + packages/plugin-trees/src/assets/ash.webp | Bin 0 -> 9848 bytes packages/plugin-trees/src/assets/aspen.webp | Bin 0 -> 4442 bytes packages/plugin-trees/src/assets/bush.webp | Bin 0 -> 5888 bytes packages/plugin-trees/src/assets/daisy.webp | Bin 0 -> 1892 bytes packages/plugin-trees/src/assets/fescue.webp | Bin 0 -> 4508 bytes .../plugin-trees/src/assets/lavender.webp | Bin 0 -> 3808 bytes packages/plugin-trees/src/assets/meadow.webp | Bin 0 -> 3838 bytes .../plugin-trees/src/assets/nature-icon.webp | Bin 0 -> 3582 bytes packages/plugin-trees/src/assets/oak.webp | Bin 0 -> 8688 bytes packages/plugin-trees/src/assets/pine.webp | Bin 0 -> 5568 bytes packages/plugin-trees/src/assets/reed.webp | Bin 0 -> 2684 bytes packages/plugin-trees/src/assets/trellis.webp | Bin 0 -> 9718 bytes packages/plugin-trees/src/assets/tulip.webp | Bin 0 -> 1960 bytes packages/plugin-trees/src/definition.ts | 144 ++++++++ packages/plugin-trees/src/find-sync.ts | 40 +++ packages/plugin-trees/src/floorplan.ts | 125 +++++++ .../plugin-trees/src/flower-definition.ts | 83 +++++ packages/plugin-trees/src/flower-geometry.ts | 115 +++++++ .../plugin-trees/src/flower-parametrics.ts | 22 ++ packages/plugin-trees/src/flower-presets.ts | 54 +++ packages/plugin-trees/src/flower-preview.tsx | 51 +++ .../src/flower-proxy-renderer.tsx | 13 + packages/plugin-trees/src/flower-schema.ts | 23 ++ .../src/flower-static-renderer.tsx | 16 + packages/plugin-trees/src/flower-system.tsx | 20 ++ packages/plugin-trees/src/flower-tool.tsx | 57 ++++ packages/plugin-trees/src/geometry.ts | 148 +++++++++ packages/plugin-trees/src/grass-definition.ts | 83 +++++ packages/plugin-trees/src/grass-geometry.ts | 55 ++++ .../plugin-trees/src/grass-parametrics.ts | 22 ++ packages/plugin-trees/src/grass-presets.ts | 50 +++ packages/plugin-trees/src/grass-preview.tsx | 51 +++ .../plugin-trees/src/grass-proxy-renderer.tsx | 13 + packages/plugin-trees/src/grass-schema.ts | 23 ++ .../src/grass-static-renderer.tsx | 15 + packages/plugin-trees/src/grass-system.tsx | 19 ++ packages/plugin-trees/src/grass-tool.tsx | 57 ++++ packages/plugin-trees/src/index.ts | 43 +++ packages/plugin-trees/src/instanced.tsx | 308 ++++++++++++++++++ packages/plugin-trees/src/parametrics.ts | 91 ++++++ packages/plugin-trees/src/placement.tsx | 81 +++++ packages/plugin-trees/src/presets-panel.tsx | 267 +++++++++++++++ packages/plugin-trees/src/presets.ts | 111 +++++++ packages/plugin-trees/src/preview.tsx | 44 +++ packages/plugin-trees/src/proxy-renderer.tsx | 16 + packages/plugin-trees/src/schema.ts | 57 ++++ packages/plugin-trees/src/static-renderer.tsx | 20 ++ packages/plugin-trees/src/store.ts | 80 +++++ packages/plugin-trees/src/system.tsx | 25 ++ packages/plugin-trees/src/tool.tsx | 74 +++++ packages/plugin-trees/src/variant-utils.ts | 27 ++ packages/plugin-trees/src/wind-node.ts | 132 ++++++++ packages/plugin-trees/tsconfig.json | 10 + .../components/viewer/glb-reference-nodes.tsx | 75 ++++- .../viewer/glb-replace-instances.tsx | 79 +++++ .../src/components/viewer/glb-scene.tsx | 57 +++- packages/viewer/src/index.ts | 5 +- packages/viewer/src/store/use-viewer.ts | 11 + wiki/architecture/plugin-authoring.md | 42 ++- 80 files changed, 3549 insertions(+), 36 deletions(-) create mode 100644 packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx create mode 100644 packages/plugin-trees/README.md create mode 100644 packages/plugin-trees/package.json create mode 100644 packages/plugin-trees/src/art.ts create mode 100644 packages/plugin-trees/src/assets.d.ts create mode 100644 packages/plugin-trees/src/assets/ash.webp create mode 100644 packages/plugin-trees/src/assets/aspen.webp create mode 100644 packages/plugin-trees/src/assets/bush.webp create mode 100644 packages/plugin-trees/src/assets/daisy.webp create mode 100644 packages/plugin-trees/src/assets/fescue.webp create mode 100644 packages/plugin-trees/src/assets/lavender.webp create mode 100644 packages/plugin-trees/src/assets/meadow.webp create mode 100644 packages/plugin-trees/src/assets/nature-icon.webp create mode 100644 packages/plugin-trees/src/assets/oak.webp create mode 100644 packages/plugin-trees/src/assets/pine.webp create mode 100644 packages/plugin-trees/src/assets/reed.webp create mode 100644 packages/plugin-trees/src/assets/trellis.webp create mode 100644 packages/plugin-trees/src/assets/tulip.webp create mode 100644 packages/plugin-trees/src/definition.ts create mode 100644 packages/plugin-trees/src/find-sync.ts create mode 100644 packages/plugin-trees/src/floorplan.ts create mode 100644 packages/plugin-trees/src/flower-definition.ts create mode 100644 packages/plugin-trees/src/flower-geometry.ts create mode 100644 packages/plugin-trees/src/flower-parametrics.ts create mode 100644 packages/plugin-trees/src/flower-presets.ts create mode 100644 packages/plugin-trees/src/flower-preview.tsx create mode 100644 packages/plugin-trees/src/flower-proxy-renderer.tsx create mode 100644 packages/plugin-trees/src/flower-schema.ts create mode 100644 packages/plugin-trees/src/flower-static-renderer.tsx create mode 100644 packages/plugin-trees/src/flower-system.tsx create mode 100644 packages/plugin-trees/src/flower-tool.tsx create mode 100644 packages/plugin-trees/src/geometry.ts create mode 100644 packages/plugin-trees/src/grass-definition.ts create mode 100644 packages/plugin-trees/src/grass-geometry.ts create mode 100644 packages/plugin-trees/src/grass-parametrics.ts create mode 100644 packages/plugin-trees/src/grass-presets.ts create mode 100644 packages/plugin-trees/src/grass-preview.tsx create mode 100644 packages/plugin-trees/src/grass-proxy-renderer.tsx create mode 100644 packages/plugin-trees/src/grass-schema.ts create mode 100644 packages/plugin-trees/src/grass-static-renderer.tsx create mode 100644 packages/plugin-trees/src/grass-system.tsx create mode 100644 packages/plugin-trees/src/grass-tool.tsx create mode 100644 packages/plugin-trees/src/index.ts create mode 100644 packages/plugin-trees/src/instanced.tsx create mode 100644 packages/plugin-trees/src/parametrics.ts create mode 100644 packages/plugin-trees/src/placement.tsx create mode 100644 packages/plugin-trees/src/presets-panel.tsx create mode 100644 packages/plugin-trees/src/presets.ts create mode 100644 packages/plugin-trees/src/preview.tsx create mode 100644 packages/plugin-trees/src/proxy-renderer.tsx create mode 100644 packages/plugin-trees/src/schema.ts create mode 100644 packages/plugin-trees/src/static-renderer.tsx create mode 100644 packages/plugin-trees/src/store.ts create mode 100644 packages/plugin-trees/src/system.tsx create mode 100644 packages/plugin-trees/src/tool.tsx create mode 100644 packages/plugin-trees/src/variant-utils.ts create mode 100644 packages/plugin-trees/src/wind-node.ts create mode 100644 packages/plugin-trees/tsconfig.json create mode 100644 packages/viewer/src/components/viewer/glb-replace-instances.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b3039b2..89866a7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,10 @@ bun check:fix # Auto-fix issues A key rule: **`packages/viewer` must never import from `apps/editor`**. The viewer is a standalone component; editor-specific behavior is injected via props/children. +### Building a plugin + +New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md) for the contract, and copy [`packages/plugin-trees`](packages/plugin-trees) as a worked example. + ## Submitting a PR 1. **Fork the repo** and create a branch from `main` diff --git a/README.md b/README.md index 10832baa..3ba4f6f2 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,15 @@ Clears dirty flag --- +## Building a Plugin + +The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same `Plugin` manifest the built-ins use — there is no separate internal API. + +- **Contract reference** — [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md): the `Plugin` shape, panel contributions, discovery (`setPluginDiscovery`), lifecycle, and what's in/out of v1. +- **Worked example** — [`packages/plugin-trees`](packages/plugin-trees): a first-party plugin (procedural trees, flowers, grass + a presets panel) structurally identical to a third-party pack. Copy it as a starting point. + +--- + ## Technology Stack - **React 19** + **Next.js 16** diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 10788771..3238d804 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -4,8 +4,10 @@ import { loadPlugin, nodeRegistry, registerNode, + setPluginDiscovery, } from '@pascal-app/core' import { builtinPlugin } from '@pascal-app/nodes' +import { treesPlugin } from '@pascal-app/plugin-trees' // Idempotency guards: HMR can reload this module, but `registerNode` // throws on duplicate kinds. Flags live in the module closure so they @@ -77,5 +79,10 @@ export async function loadExternalPlugins(): Promise { } } +// Register the first-party example plugin (trees node + presets rail panel) +// through the same discovery hook a third-party pack would use. Must be set +// before `loadExternalPlugins()` reads it below. +setPluginDiscovery(async () => [treesPlugin]) + loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 0120eaf4..18fb1820 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -13,6 +13,8 @@ const nextConfig: NextConfig = { '@pascal-app/core', '@pascal-app/editor', '@pascal-app/mcp', + '@pascal-app/plugin-trees', + '@dgreenheck/ez-tree', ], turbopack: { resolveAlias: { diff --git a/apps/editor/package.json b/apps/editor/package.json index 5af99975..4f4b18b3 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -17,6 +17,7 @@ "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", + "@pascal-app/plugin-trees": "*", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", diff --git a/bun.lock b/bun.lock index 3eb9cb4e..f402fa78 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", + "@pascal-app/plugin-trees": "*", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", @@ -257,6 +258,38 @@ "zustand": "^5", }, }, + "packages/plugin-trees": { + "name": "@pascal-app/plugin-trees", + "version": "0.1.0", + "dependencies": { + "@dgreenheck/ez-tree": "^1.1.0", + }, + "devDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@pascal/typescript-config": "*", + "@react-three/fiber": "^9", + "@types/node": "^22.19.12", + "@types/react": "^19.2.2", + "@types/three": "^0.184.0", + "react": "^19", + "three": "^0.184", + "typescript": "6.0.3", + "zod": "^4", + "zustand": "^5", + }, + "peerDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@react-three/fiber": "^9", + "react": "^18 || ^19", + "three": "^0.184", + "zod": "^4", + "zustand": "^5", + }, + }, "packages/typescript-config": { "name": "@repo/typescript-config", "version": "0.0.0", @@ -371,6 +404,8 @@ "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], + "@dgreenheck/ez-tree": ["@dgreenheck/ez-tree@1.1.0", "", { "peerDependencies": { "three": ">=0.167" } }, "sha512-6pvS6hD6B6h00dm0SnkgYeT4ABU5Y1Z9M44p1tXiV5C0eKrQy2sKECXshoaUv0qAOqYVL68w/PwadUxDFDiHUg=="], + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], @@ -683,6 +718,8 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], + "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@workspace:packages/plugin-trees"], + "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], "@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"], diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 62107fc2..d239acd8 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -16,6 +16,7 @@ export type { TranslateHandle, } from './handles' export { + bakePolicyOf, discoverPlugins, getHostRefFields, getSelectableKinds, @@ -26,10 +27,12 @@ export { isPresettableKind, isRegistryMovable, isRegistrySelectable, + kindsWithBakePolicy, kindsWithFloorplanScope, loadPlugin, nodeRegistry, type PluginDiscovery, + panelRegistry, registerNode, resolveFacingIndicator, setPluginDiscovery, @@ -55,6 +58,8 @@ export type { AlignmentFootprintConfig, AnyNodeDefinition, AssetRef, + BakePolicy, + BakeReplaceRenderer, Capabilities, CapabilityCtx, CuttableConfig, @@ -95,11 +100,13 @@ export type { PaintPatchArgs, PaintPreviewArgs, PaintResolveArgs, + PanelWorkspace, ParamAction, ParametricDescriptor, ParamField, ParamGroup, Plugin, + PluginPanel, Presentation, Relations, RendererSource, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 2455c967..093a9df3 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -1,5 +1,5 @@ import type { ZodObject } from 'zod' -import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types' +import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin, PluginPanel } from './types' const HOST_API_VERSION = 1 as const @@ -86,6 +86,78 @@ export function registerNode(def: AnyNodeDefinition): void { nodeRegistry._register(def) } +/** + * Registry of left-rail panels contributed by plugins. Same add-only, + * duplicate-id-throws semantics as the node registry, with one difference: it + * is *observable*. Plugin discovery runs asynchronously after the first React + * render (see `loadExternalPlugins`), so the sidebar subscribes via + * {@link PanelRegistryImpl.subscribe} / {@link PanelRegistryImpl.getSnapshot} + * (the `useSyncExternalStore` contract) and re-renders when a panel lands. + * `getSnapshot` returns a stable array reference between registrations so the + * subscribing component doesn't re-render in a loop. + */ +class PanelRegistryImpl { + private readonly panels = new Map() + private readonly listeners = new Set<() => void>() + private cached: PluginPanel[] = [] + // node kind → the (namespaced) panel id of the plugin that shipped it. + // Populated by `loadPlugin`; lets a host's "find in catalog" open the + // panel that places a kind without hardcoding per-plugin knowledge. + private readonly kindPanels = new Map() + + subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange) + return () => { + this.listeners.delete(onChange) + } + } + + getSnapshot = (): PluginPanel[] => this.cached + + _register(panel: PluginPanel): void { + if (typeof panel.id !== 'string' || panel.id.length === 0) { + throw new Error('[registry] PluginPanel.id must be a non-empty string') + } + if (this.panels.has(panel.id)) { + if (isDevMode()) { + console.warn(`[registry] re-registering plugin panel "${panel.id}" (HMR)`) + } else { + throw new Error(`[registry] duplicate plugin panel id: "${panel.id}" already registered`) + } + } + this.panels.set(panel.id, panel) + this.emit() + } + + _associateKind(kind: string, panelId: string): void { + this.kindPanels.set(kind, panelId) + } + + /** The (namespaced) panel id of the plugin that registered `kind`, if any. */ + panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind) + + // Test-only — clears the registry. Not exported from the package barrel. + _reset(): void { + this.panels.clear() + this.kindPanels.clear() + this.emit() + } + + private emit(): void { + this.cached = Array.from(this.panels.values()) + for (const fn of this.listeners) fn() + } +} + +export const panelRegistry: { + subscribe: (onChange: () => void) => () => void + getSnapshot: () => PluginPanel[] + panelForKind: (kind: string) => string | undefined + _register: (panel: PluginPanel) => void + _associateKind: (kind: string, panelId: string) => void + _reset: () => void +} = new PanelRegistryImpl() + /** * Returns the set of registered kinds whose definition declares the * `selectable` capability. Callers that maintain hardcoded "selectable kinds" @@ -130,6 +202,26 @@ export function kindsWithFloorplanScope(scope: 'level' | 'building'): string[] { return result } +/** + * A kind's {@link BakePolicy} from the registry, defaulting to `'static'` for + * kinds that don't declare one (or aren't registered). The bake and the baked + * `/viewer` consult this instead of hardcoding kind names — see + * plans/editor-plugin-trees-example.md → Part D. + */ +export function bakePolicyOf(kind: string): BakePolicy { + return nodeRegistry.get(kind)?.bake ?? 'static' +} + +/** Registered kinds whose {@link BakePolicy} matches `policy`. `'static'` is the + * default, so `kindsWithBakePolicy('static')` includes kinds that didn't set it. */ +export function kindsWithBakePolicy(policy: BakePolicy): string[] { + const result: string[] = [] + for (const [kind, def] of nodeRegistry.entries()) { + if ((def.bake ?? 'static') === policy) result.push(kind) + } + return result +} + /** * Returns true when the kind is movable from a 2D floor-plan handle — * either via `capabilities.movable`, an explicit @@ -225,6 +317,20 @@ export async function loadPlugin(plugin: Plugin): Promise { for (const def of plugin.nodes ?? []) { registerNode(def) } + for (const panel of plugin.panels ?? []) { + // Namespace the panel id by its owning plugin so two plugins can each + // ship a panel id of `'main'`. The namespaced id is what the sidebar + // uses as `activeSidebarPanel`. + panelRegistry._register({ ...panel, id: `${plugin.id}:${panel.id}` }) + } + // Associate every node kind with the plugin's first panel — the panel a + // host's "find in catalog" should open to place more of that kind. + const firstPanel = plugin.panels?.[0] + if (firstPanel) { + for (const def of plugin.nodes ?? []) { + panelRegistry._associateKind(def.kind, `${plugin.id}:${firstPanel.id}`) + } + } } /** @@ -235,8 +341,8 @@ export async function loadPlugin(plugin: Plugin): Promise { * {@link setPluginDiscovery} before the bootstrap module runs. * * Kept async so a future loader can fetch over the network without - * changing the contract. See `wiki/editor-plugin-authoring.md` for the - * plugin author surface this enables. + * changing the contract. See `wiki/architecture/plugin-authoring.md` for + * the plugin author surface this enables. */ export type PluginDiscovery = () => Promise diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index db3b10ef..4cd973b6 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -695,10 +695,36 @@ export type FloorplanMoveTarget = (args: { // ─── Plugin manifest ───────────────────────────────────────────────── +/** + * A left-rail panel contributed by a plugin. The host adds `icon` to the + * sidebar icon rail; clicking it mounts `component` (lazy-loaded, behind a + * per-panel error boundary) in the sidebar content area. `id` is namespaced + * by the owning plugin id at load time, so two plugins may each declare a + * panel id of `'main'` without colliding. `icon` reuses {@link IconRef} so a + * plugin contributes a mark the same way a node's presentation does, with no + * React rendering in core. + */ +/** Host workspace a panel belongs to — `edit` (the build workspace) or + * `studio` (the render workspace). Manifest metadata, not core logic: the + * host's sidebar filters by its current workspace mode. */ +export type PanelWorkspace = 'edit' | 'studio' + +export type PluginPanel = { + id: string + label: string + icon: IconRef + component: LazyComponent + /** Workspaces that surface this panel. Default `['edit']` — a placement / + * authoring panel has no business in the clean render workspace; a plugin + * that ships studio tooling opts in explicitly. */ + workspaces?: readonly PanelWorkspace[] +} + export type Plugin = { id: string apiVersion: 1 nodes?: AnyNodeDefinition[] + panels?: PluginPanel[] } // ─── NodeDefinition ────────────────────────────────────────────────── @@ -726,6 +752,19 @@ export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment' */ export type SnapProfile = 'item' | 'structural' +/** + * How a kind is treated by the GLB bake and the baked `/viewer`. See + * plans/editor-plugin-trees-example.md → Part D. + * - `'static'` (default) — baked as geometry; the viewer shows the baked mesh. + * - `'strip'` — excluded from the bake; the viewer rebuilds it live from + * `scene_graph` via the registry renderer (heavy reference assets: scans, guides). + * - `'replace'` — baked as *static* geometry (a plain glTF viewer still shows it), + * but our viewer removes the baked meshes for this kind and re-renders the node + * live from `scene_graph` — for dynamic content whose runtime look differs from a + * frozen snapshot (shader wind, interactivity), rendered through its own path. + */ +export type BakePolicy = 'static' | 'strip' | 'replace' + export type NodeDefinition> = { kind: string schemaVersion: number @@ -785,6 +824,9 @@ export type NodeDefinition> = { */ dirtyTracking?: boolean + /** GLB bake treatment for this kind (default `'static'`). See {@link BakePolicy}. */ + bake?: BakePolicy + /** * Renderer for this kind. Optional under the three-checkbox composition * model (see `wiki/architecture/node-definitions.md`): when omitted, the @@ -797,6 +839,17 @@ export type NodeDefinition> = { * already null-guard on `def.renderer` so omitting it is safe. */ renderer?: RendererSource> + /** + * Collective renderer the baked `/viewer` uses to re-render this kind live when + * `bake === 'replace'`. It receives every node of this kind under one baked + * level and is portaled into that level's `Object3D`, so an instanced kind can + * draw them as instanced meshes in level-local space (riding level stacking for + * free) instead of the frozen baked meshes (which the viewer hides). Needed when + * the normal per-node `renderer` can't stand alone in a baked scene (e.g. an + * instanced kind whose `renderer` is an invisible selection proxy and whose real + * geometry comes from a `system`). See plans/editor-plugin-trees-example.md → Part D. + */ + bakeReplaceRenderer?: BakeReplaceRenderer> /** * Pure geometry builder. When set, the framework's generic * `` calls this on every dirty mark — `nodes` keyed by @@ -1136,6 +1189,15 @@ export type RendererSource = | { kind: 'glb'; getAsset: (n: N) => AssetRef } | { kind: 'instanced-glb'; getAsset: (n: N) => AssetRef } +/** + * A collective renderer for the baked `/viewer` (see `NodeDefinition.bakeReplaceRenderer`): + * a lazy module whose default export takes all of one level's `replace` nodes and + * is portaled into that baked level. Three-free indirection, same as `system`. + */ +export type BakeReplaceRenderer = { + module: () => Promise<{ default: ComponentType<{ nodes: N[] }> }> +} + export type AssetRef = { id: string src: string diff --git a/packages/editor/src/components/editor/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx index 5771adee..1a188af9 100644 --- a/packages/editor/src/components/editor/bake-exporter.tsx +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -1,10 +1,20 @@ 'use client' import { useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { exportSceneToGlb } from '../../lib/glb-export' +/** Resolve after the next couple of animation frames, giving React/R3F time to + * commit and mount export-only geometry (e.g. instanced kinds' real meshes) + * before the exporter clones the scene graph. */ +function nextFrames(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) +} + export function BakeExporter({ active, onComplete, @@ -21,12 +31,19 @@ export function BakeExporter({ doneRef.current = true const run = async () => { try { + // Signal export so instanced kinds (trees/flowers/grass) swap their + // invisible proxy for real, exportable geometry, then wait for the + // commit before cloning the scene graph. + useViewer.getState().setExporting(true) + await nextFrames() const sceneGroup = scene.getObjectByName('scene-renderer') if (!sceneGroup) throw new Error('scene-renderer group not found') const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) onComplete(buffer) } catch (err) { onError(err instanceof Error ? err.message : String(err)) + } finally { + useViewer.getState().setExporting(false) } } void run() diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 55fb152b..d3a19d67 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -55,6 +55,7 @@ import type { ExtraPanel } from '../ui/sidebar/icon-rail' import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel' import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel' import type { SidebarTab } from '../ui/sidebar/tab-bar' +import { usePluginPanels } from '../ui/sidebar/use-plugin-panels' import { CustomCameraControls } from './custom-camera-controls' import { EditorLayoutV2 } from './editor-layout-v2' import { ExportManager } from './export-manager' @@ -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( + 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 } - const tabBarTabs = - sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({ + const tabBarTabs = [ + ...(sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({ id, label, mobileDefaultSnap, mobileIcon, icon, - })) ?? [] + })) ?? []), + // Plugin panels appear after the host's tabs in the rail. The icon + // doubles as the mobile icon; a half-height sheet is a sensible default. + ...pluginRailPanels.map((p) => ({ + id: p.id, + label: p.label, + mobileDefaultSnap: 0.5, + mobileIcon: p.icon, + icon: p.icon, + })), + ] return ( <> diff --git a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx index 383d2867..99f756e0 100644 --- a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx +++ b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx @@ -16,6 +16,7 @@ import useEditor from './../../../store/use-editor' import { type ExtraPanel, IconRail } from './icon-rail' import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel' import { SitePanel, type SitePanelProps } from './panels/site-panel' +import { usePluginPanels } from './use-plugin-panels' interface AppSidebarProps { appMenuButton?: ReactNode @@ -31,9 +32,12 @@ export function AppSidebar({ sidebarTop, settingsPanelProps, sitePanelProps, - extraPanels, + extraPanels: hostExtraPanels, commandPaletteEmptyAction, }: AppSidebarProps) { + // Host-provided panels merged with plugin-contributed panels from the + // registry — the icon rail and content area treat both identically. + const extraPanels = usePluginPanels(hostExtraPanels) const activePanel = useEditor((s) => s.activeSidebarPanel) const setActivePanel = useEditor((s) => s.setActiveSidebarPanel) const hasActivePanel = diff --git a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx new file mode 100644 index 00000000..f009b72d --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx @@ -0,0 +1,97 @@ +'use client' + +import { Icon } from '@iconify/react' +import { type IconRef, panelRegistry, type PluginPanel } from '@pascal-app/core' +import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react' +import useEditor from '../../../store/use-editor' +import { ErrorBoundary } from '../primitives/error-boundary' +import type { ExtraPanel } from './icon-rail' + +/** Resolve a plugin's {@link IconRef} into a rail-sized React node. Mirrors the + * inspector's `renderIcon`, sized for the 24px icon-rail button. */ +function renderIconRef(ref: IconRef): ReactNode { + if (ref.kind === 'url') { + return + } + if (ref.kind === 'iconify') { + return + } + if (ref.kind === 'svg') { + return ( + + + + ) + } + const LazyIcon = lazy(ref.module) + return ( + + + + ) +} + +function PluginPanelCrashed({ label }: { label: string }) { + return ( +
+

"{label}" plugin crashed

+

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

+
+ ) +} + +// `React.lazy` must be called once per loader so the resolved component keeps a +// stable identity across renders (otherwise switching panels remounts the tree +// every time the sidebar re-renders). Cache the wrapped component by its loader. +const wrappedPanelCache = new WeakMap() + +function resolvePanelComponent(panel: PluginPanel): ComponentType { + const cached = wrappedPanelCache.get(panel.component) + if (cached) return cached + const Lazy = lazy(panel.component) + const Wrapped: ComponentType = () => ( + }> + Loading…}> + + + + ) + Wrapped.displayName = `PluginPanel(${panel.id})` + wrappedPanelCache.set(panel.component, Wrapped) + return Wrapped +} + +/** + * Merge plugin-contributed panels (from the observable {@link panelRegistry}) + * with the host's `extraPanels`, returning the combined list the icon rail and + * panel-content area render. Host panels keep their leading order and win on id + * collisions. Subscribes to the registry so a panel that registers after the + * first render (plugin discovery is async) makes the rail re-render. + * + * Panels are filtered by the current workspace: a panel surfaces only in the + * workspaces it declares (`PluginPanel.workspaces`, default `['edit']`), so an + * authoring panel like Nature doesn't ride into the studio rail. + */ +export function usePluginPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { + const registered = useSyncExternalStore( + panelRegistry.subscribe, + panelRegistry.getSnapshot, + panelRegistry.getSnapshot, + ) + const workspaceMode = useEditor((s) => s.workspaceMode) + const hostIds = new Set(hostPanels?.map((p) => p.id)) + const fromRegistry = registered + .filter((p) => !hostIds.has(p.id) && (p.workspaces ?? ['edit']).includes(workspaceMode)) + .map( + (p): ExtraPanel => ({ + id: p.id, + label: p.label, + icon: renderIconRef(p.icon), + component: resolvePanelComponent(p), + }), + ) + return [...(hostPanels ?? []), ...fromRegistry] +} diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 4d58f1c5..b0093104 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -1,5 +1,6 @@ import { type AnyNode, + bakePolicyOf, type DoorNode, emitter, getLevelDisplayName, @@ -104,14 +105,16 @@ export function prepareSceneForExport( const scene = source.clone(true) const cloneByOriginal = pairClones(source, scene) - // Scans (LiDAR meshes) and guides (floorplan images) are heavy reference - // assets stored elsewhere and aren't part of the compiled building. Drop them - // from the artifact entirely — `/viewer` re-adds them from the scene graph, - // gated by the project's public-visibility flags, so they never bloat the - // shared GLB nor slip past those flags into a static public file. + // Kinds with `def.bake === 'strip'` (scans/LiDAR, guides/floorplan) are heavy + // reference assets stored elsewhere and aren't part of the compiled building. + // Drop them from the artifact entirely — `/viewer` re-adds them from the scene + // graph, gated by the project's public-visibility flags, so they never bloat + // the shared GLB nor slip past those flags into a static public file. + // (`'replace'` kinds are *kept*: static for portability, the viewer swaps them + // for a live render.) for (const [id, original] of sceneRegistry.nodes) { const node = nodes[id] - if (node?.type === 'scan' || node?.type === 'guide') { + if (node && bakePolicyOf(node.type) === 'strip') { cloneByOriginal.get(original)?.removeFromParent() } } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 9d73d96c..437eb765 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -172,8 +172,14 @@ export type NavigationSyncPose = { export type NavigationSyncPoseInput = Omit -// Combined tool type -export type Tool = SiteTool | StructureTool | FurnishTool +// Combined tool type. Known literals keep autocomplete; the `(string & {})` +// arm lets plugin-contributed tool ids (e.g. `'trees:tree'`) typecheck without +// the host enumerating every plugin kind. The runtime dispatch is already +// registry-first — `tool-manager` resolves `nodeRegistry.get(tool)?.tool` — so +// an unknown-to-the-host tool string flows straight through to the plugin's +// placement component. +export type KnownTool = SiteTool | StructureTool | FurnishTool +export type Tool = KnownTool | (string & {}) /** * Starting parameters seeded into a draw tool before it mints a node. diff --git a/packages/nodes/src/guide/definition.ts b/packages/nodes/src/guide/definition.ts index cd2c546b..a60c5126 100644 --- a/packages/nodes/src/guide/definition.ts +++ b/packages/nodes/src/guide/definition.ts @@ -10,6 +10,9 @@ import { GuideNode } from './schema' */ export const guideDefinition: NodeDefinition = { kind: 'guide', + // Floorplan image reference: stripped from the bake, re-added live from + // scene_graph in the viewer (see plans → Part D; glb-reference-nodes.tsx). + bake: 'strip', schemaVersion: 1, schema: GuideNode, category: 'site', diff --git a/packages/nodes/src/scan/definition.ts b/packages/nodes/src/scan/definition.ts index 64f1557e..bc6cc2f7 100644 --- a/packages/nodes/src/scan/definition.ts +++ b/packages/nodes/src/scan/definition.ts @@ -9,6 +9,9 @@ import { ScanNode } from './schema' */ export const scanDefinition: NodeDefinition = { kind: 'scan', + // Heavy LiDAR asset: stripped from the bake, re-added live from scene_graph + // in the viewer (see plans → Part D; glb-reference-nodes.tsx). + bake: 'strip', schemaVersion: 1, schema: ScanNode, category: 'site', diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md new file mode 100644 index 00000000..100fea15 --- /dev/null +++ b/packages/plugin-trees/README.md @@ -0,0 +1,92 @@ +# @pascal-app/plugin-trees + +The first-party **example plugin** for the Pascal editor. It contributes a +procedural _trees_ node and a left-rail _presets_ panel, and exists to prove — +and document — the minimal host surface every future plugin reuses. + +It is structurally identical to a third-party plugin: it peer-depends on +`@pascal-app/{core,viewer,editor}` (plus `react`/`three`/`@react-three/fiber`/ +`zustand`) and bundles `@dgreenheck/ez-tree` for the geometry. It imports +nothing private. Copy this folder as the starting point for a new plugin. + +## What it demonstrates + +The contribution paths a plugin has: + +1. **Left-rail panel** — `panels` in the manifest. `presets-panel.tsx` is a + plain React component the host mounts behind an error boundary. +2. **Right inspector for free** — `def.parametrics` (`parametrics.ts`). The host + renders the preset/height/seed controls + the Randomize action with zero + tree-specific code. +3. **Placement** — `def.tool`/`def.preview` (`tool.tsx`, `preview.tsx`). The + tool respects the active snapping mode (`isGridSnapActive()` + `gridSnapStep`) + exactly like the built-in item/shelf tools. +4. **Instanced rendering** (the generic core in `instanced.tsx`, shared by both + kinds) — instead of the per-node `def.geometry` path, plants render via two + pieces: + - `def.system` — a collective renderer mounted once that groups every node of + the kind by its geometry variant and draws each variant as one + `InstancedMesh` per sub-mesh. A forest of N is a handful of draw calls. + Variant geometry is generated once and cached. + - `def.renderer` — a featherweight per-node proxy: a stable invisible box + collider (the raycast target) in an outer group, plus the real geometry + (invisible, mounted only while hovered/selected) in an inner *registered* + group. So the host's outline pass traces the **true silhouette**, picking + stays on the box, and selection / outline / zone machinery works unchanged + with no instanceId bookkeeping. + +### Three kinds + +- **`trees:tree`** — ez-tree geometry (`geometry.ts`); species presets Oak / + Pine / Aspen / Ash / Bush / Trellis × a Small/Medium/Large **size** (all of + ez-tree's built-in presets), a Deciduous/Evergreen **type**, curated params + (foliage density, trunk thickness, leafless), and leaf/branch **colour tints** — + all folded into the variant key. Colours are edit-only (inspector), not on the + placement brush. +- **`trees:flower`** — simple procedural geometry (`flower-geometry.ts`, merged + per material); presets daisy / tulip / lavender, with a per-flower petal colour. +- **`trees:grass`** — procedural blade tufts (`grass-geometry.ts`); presets + meadow / fescue / reed, with a per-tuft blade colour. + +Flowers and grass are sibling kinds that reuse the exact same instanced core + +placement helper (`instanced.tsx` / `placement.tsx`) and the shared procedural +RNG (`mulberry32` in `geometry.ts`) — the template for adding more plant kinds. + +It also shows the communication triangle: `presets-panel` → plugin store +(`store.ts`) → `def.tool` → `SceneApi` → scene → reactive `useScene` read-back +(the "N planted" counter in the panel). + +`@dgreenheck/ez-tree` ships its bark/leaf textures inlined as base64, so there +are no assets to host. Placement seeds are drawn from a small bounded pool +(`TREE_SEED_POOL`) so trees share variants — that sharing is what makes the +instancing pay off; a unique inspector seed just renders as its own variant. + +## Manifest + +```ts +import { treesPlugin } from '@pascal-app/plugin-trees' +// host: +setPluginDiscovery(async () => [treesPlugin]) +``` + +`treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`, +`trees:grass`) and one panel (`Trees`), loaded through the same `loadPlugin` +path as the built-ins. + +## Notes / known gaps + +- `createNode` and the `floorPlaced.footprint` callback are typed against the + host's hand-maintained `AnyNode` union, so the node is cast (`as AnyNode` / + `as TreeNode`). The registry derives `AnyNode` post-migration. +- The placement tool re-derives level-local conversion from the public + `sceneRegistry` because the built-in `floor-placement` helpers aren't part of + the public `@pascal-app/*` surface yet — a candidate for a future + `@pascal-app/plugin-api` re-export package. +- The instance matrices fold in the parent level's world transform; a building + move while plants are static won't refresh until a node of that kind next + changes. +- Heavy *per-node* tweaking of geometry params (or unique seeds) erodes + instancing batching — but it degrades gracefully: such a node just becomes its + own single-instance variant, never worse than the non-instanced path. + +See `wiki/architecture/plugin-authoring.md` for the full contract. diff --git a/packages/plugin-trees/package.json b/packages/plugin-trees/package.json new file mode 100644 index 00000000..e1a6a714 --- /dev/null +++ b/packages/plugin-trees/package.json @@ -0,0 +1,56 @@ +{ + "name": "@pascal-app/plugin-trees", + "version": "0.1.0", + "description": "First-party example Pascal editor plugin — a procedural trees node with a presets rail panel. Structurally identical to a third-party plugin (peer-deps on @pascal-app/*).", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "files": [ + "src", + "README.md" + ], + "scripts": { + "check-types": "tsgo --noEmit" + }, + "dependencies": { + "@dgreenheck/ez-tree": "^1.1.0" + }, + "peerDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@react-three/fiber": "^9", + "react": "^18 || ^19", + "three": "^0.184", + "zod": "^4", + "zustand": "^5" + }, + "devDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@pascal/typescript-config": "*", + "@react-three/fiber": "^9", + "@types/node": "^22.19.12", + "@types/react": "^19.2.2", + "@types/three": "^0.184.0", + "react": "^19", + "three": "^0.184", + "typescript": "6.0.3", + "zod": "^4", + "zustand": "^5" + }, + "keywords": [ + "pascal", + "3d", + "editor", + "plugin" + ], + "license": "MIT" +} diff --git a/packages/plugin-trees/src/art.ts b/packages/plugin-trees/src/art.ts new file mode 100644 index 00000000..43b3edeb --- /dev/null +++ b/packages/plugin-trees/src/art.ts @@ -0,0 +1,48 @@ +import ash from './assets/ash.webp' +import aspen from './assets/aspen.webp' +import bush from './assets/bush.webp' +import daisy from './assets/daisy.webp' +import fescue from './assets/fescue.webp' +import lavender from './assets/lavender.webp' +import meadow from './assets/meadow.webp' +import natureIcon from './assets/nature-icon.webp' +import oak from './assets/oak.webp' +import pine from './assets/pine.webp' +import reed from './assets/reed.webp' +import trellis from './assets/trellis.webp' +import tulip from './assets/tulip.webp' +import type { FlowerPreset } from './flower-schema' +import type { GrassPreset } from './grass-schema' +import type { TreePreset } from './schema' + +/** + * Bundled preset artwork. The webp live in `./assets` and travel with the + * package — no CDN, no per-app `public/` mirroring. Both consumers are Next, so + * `transpilePackages` runs these imports through the image pipeline and `.src` + * is the hashed, cached URL. The panel renders each as an ``. + */ +const url = (asset: { src: string }): string => asset.src + +export const TREE_ART: Record = { + oak: url(oak), + pine: url(pine), + aspen: url(aspen), + ash: url(ash), + bush: url(bush), + trellis: url(trellis), +} + +export const FLOWER_ART: Record = { + daisy: url(daisy), + tulip: url(tulip), + lavender: url(lavender), +} + +export const GRASS_ART: Record = { + meadow: url(meadow), + fescue: url(fescue), + reed: url(reed), +} + +/** The Nature panel / section icon. */ +export const NATURE_ICON = url(natureIcon) diff --git a/packages/plugin-trees/src/assets.d.ts b/packages/plugin-trees/src/assets.d.ts new file mode 100644 index 00000000..ea56a5b5 --- /dev/null +++ b/packages/plugin-trees/src/assets.d.ts @@ -0,0 +1,8 @@ +// Bundled image assets. Both consumers (community + apps/editor) are Next, whose +// static image import returns a StaticImageData-shaped object; `.src` is the +// hashed, cached URL the bundler emits. Declared locally so the package needs no +// `next` type dependency. See `art.ts`. +declare module '*.webp' { + const asset: { src: string; height: number; width: number; blurDataURL?: string } + export default asset +} diff --git a/packages/plugin-trees/src/assets/ash.webp b/packages/plugin-trees/src/assets/ash.webp new file mode 100644 index 0000000000000000000000000000000000000000..42a4289f3a0789a74b78596ae8191ad1280ebba5 GIT binary patch literal 9848 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 literal 0 HcmV?d00001 diff --git a/packages/plugin-trees/src/assets/aspen.webp b/packages/plugin-trees/src/assets/aspen.webp new file mode 100644 index 0000000000000000000000000000000000000000..4fb0f79df4d60f1d87fd5ef4015934ef91fa236f GIT binary patch literal 4442 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 literal 0 HcmV?d00001 diff --git a/packages/plugin-trees/src/assets/bush.webp b/packages/plugin-trees/src/assets/bush.webp new file mode 100644 index 0000000000000000000000000000000000000000..237258109dc444e90c59b3adb409aa4a9823789b GIT binary patch literal 5888 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 NV5 zmZ7Y`$LaPgmr1oZ6LC>)%x45#)~>^5_3-yu2NBj(D}xKc%-KH7TuWK1!zUNPh0_#fYC-Z=(g6KPw#4@y4y=KCq|T0>41< zFw&uTV)o}Ep%C?d*QWhw#NjAMJ)M|zU8rL8js|r#o>;r@o5q}V>%j4wJ%fJr4Y{Sx9x$BP)g0-k zd+pD!tF5v_KOF>v(U0}1L2uZ`vEH2q>eI8Yd8RVqw#85(GX->F2Cz^sbT7m^MH%gX z1Tg+u)Fs*UKqeRCVUjVUhje)rhy0d6suc!87sJ3{4?gNRb{-?AXW4lthRp+NG96P6 z_>K!|rxJ|&DSz~%S~m4~!ErZxE+n#W3RZf8a9}ZTIJG*964Ev{fss1@Soqx;qw#iHguZWhOy5;%EGG?#Q0oAnYCFj_CPHQH z19nQchhYo1R>wJby{)HY)ME@k*fY8vlqvqPMd`F8nW~QM*rK5QdFs)T-qLo8lk|Th zy?ZX+XU>^q@Z{gu*nWCo0d(4Lu})TqJQu7+PSg}L?fM__O7!niG@F=+%ob#i&P_h8 z+~K1$k?F};kMJF(ZD76(vB{bgxSquWcxz{DUweN^*HX2~dqbY9L+n7Bx`J8fU zKLBIE53&l)bh2PdFUbbnUchzUvLj$?7(|g)qznXqp59gH?1-U(dtxES0*MC%-dJK` z9`o2O$G!RC?o-cW)}s7k!G5S1(sFe;31wylIx;JJdF*{i_F*xsaK^zuoQXe zgTIs2ioyj}H%B95cD6vHIIDW(1^t-tP%WIoXhUiYvyUW*!^eLWA@7U z+&4uKtMusbmik?hnHRlpB=T1EHm@&U4HwcEgITnZ|4i=UrBRVi2TaR;G%VNJ<9KJ} z3@3CtR}HxoIyG`z#crcpAl7(-e}0sPsieb6z!J|AgsoZ}a67g`W)6kAEj6JAJMZuD za&Q}zf`ZA0b&^Qv@L|OX_p{zpwSUyE47e07IQoXj_~adb5@K(?9Z-||Rq8TA071{{ z?mJeXsrjT6@nxtd_^qn2O>@5m+IwN^^v^!h+52ZG5LK|o(ZG28Uf}0KS(1LF+gL0Y zae-)A$oWUdY3R_R=0N{if~FNHmw}%;NLy!6A_^<1miD!5bc5>(PqsU^-CnU5#17iH zdT4@Olg+6UCuTt$KIw+&zHdxKMa?X16XGET=AuPRr2e;irQ29yVg`ep;cQ4m{y$`9 zaR3ix`+sKk7)pjAcNjeL8?ou&y=TR!vW}_xieR0 z6M_D`Rv0DilXA8+RNRuypu8L(_bB}KBbq``3zk_in#~{9o(hg2UBiGnpg{zH>Heen zlQ}CEmj72$`qS}c%2fh`?LY1qq`8EBlYBBuh=$;1N*msEHyeL~L+eJ@N4%BqFhOgY zSKniul0cY_)YOZ;{n89+fhnK9lf#aI8plasT!EQ&MG|1@9bnxgX`;KOp)!}?uHW?@ z-_Cr*pkC0Cv(fvjXno)dw>uE@fqP$kX7e0l2ARz|`cJFD@Ox}q$eqVnghQLz^+F2*9G0^TifV-{_9dnVW0JM6hX0LiC^+Z&>{*@SC8P-1d z>{$&5@FGe4X>^)41ZHR#UjPIby7m`}1K2tPK{YRkrS2$+$PDmDcp#JR@oI9Kp1QbW7X>Fh6WyI!0=%LvGlr W(ur`gpBX>_bE5yX>;M1&0002T@rKd> literal 0 HcmV?d00001 diff --git a/packages/plugin-trees/src/assets/daisy.webp b/packages/plugin-trees/src/assets/daisy.webp new file mode 100644 index 0000000000000000000000000000000000000000..ce1f2524089758b8e857cba2413612b5c1c65b8b GIT binary patch literal 1892 zcmV-q2b=g(Nk&Fo2LJ$9MM6+kP&gn^2LJ%@F#w$bDgXfh0X}Usmq{cdq9GwM$v}V& ziDz!%beh}yo#Q*48MY2R7yrTO`RF?pgVsCwzp4kF_pDY!miejG5{5kLYH&g_c9?{Rw-7k=`&e#9|{{L0b0025dU7iI^%Xy7sziUMx$+oGDkqxJ1XioNu z$!-#$ag9&j_3Kz&gaQ}Bu#ft*GBTFIh>>ZU{(ZN+SE#wAO~ruN%7^pp$JmWRu;~W% zRgmdjPjX?!f92nAw;$#u2zp_2L%wT7m~0jF(F-8NF)I>Ni4Rh6?`3|Svsv&G_q6=Y zir=c>>Z``Gq2NCMk~XDt4!9(fO{b&AG`SZJBe<*dh*o?}p?Tj-IWN#JRBzz7%{PMs zMS9`f04ajk6A5=e0bYT8z6~yjb5g3AdiQH5)A=}unc@gl^g)tX9@wNjc(pc+9hAR>R@L z37YRsU8T-G@HSp`K0h{a{tsf7aHv_20&8knuW;tGp{grHL9zp1BvQXq&wM~eoGO&TI0m$j?>Ft3EpOVY@v_S{QZ4|Ej$wo2;#fB zHZJ+owNPQYY}5Q=Pv`ePf!Kh(q4-iSV~|9M(?7I#$RdvpQM|?)Kp<)(k#d9t--Md) zXCWz2|47I$;4*!p4p4iIYUu-+c-$pR9zx^twq9$ojEME3HhNebRcGyGr&jf`I4<6Y zH|>LGUm9%2o$%kVX@ez-dJP=g6%CGkU&!5M{&qCR)fcUEm{tq4B>`Qpxe<;{7Eto8 z*3QWJLs)!h_sh0Mf6gB6n8eu+_qus^_iIGdqD}xd(jHuD3vTKZiU2(`3>-3PNGZSv zNyY~{zy$p5#@cb!BAzCBePH?B;fNS>(*;s*i{O?QL9HA1e5&4cJkSpvCTy`Kijr+d z_uro=$N_UlYQtISoOjw&)B5r7*x&~|g_j1}Al_Cox#S-*O8cEAea5PI+Lnm630|q^ zwEAUZP|-V8DQ@;`@$DM}ck0DQoA@qzzPd#3b!n+&!G{~29SXhCHRiADzEv$={koF*SF%d7Noh)V__YLDWXPOCu>9&YtKZlE!aWpU znJtgRVskrv^-ZtadXyPDm05gDJDy6eVcJ%tY{tYWlV`iU1to9WTujC!ZR@RPOOsKR z*G&Dbc=MXa%jo(7#cuAMXRW6ww_s++4&Yf>HHgWieSJ>QSva%U>!N|;9Pxl!ItOX2 zKjQI)W9ob%QpA323^L@fR-t+$%j5+#WG6zA>qZ`k<1PaU3g$okj z=x^y86HWA&T);Vk>Y#|A3DzZ!SHtQG(+-GyB7vtso83{DZB$BZ@@73mmykVSa*eyi z0EruBiuanZik+h*$k_ZiU+P5i)-v_iS5~MQWf)5>RaDhK2@+ zxyaHGSwHL=U>1=+4ivC=JwIyv;Y%s=bCddlPE}{F08q+bNHS5WW!ufC1$J|ke+>~- erwYJ;6$GYW)C=w{q$9jTH=GUd145tx0000P6RuML literal 0 HcmV?d00001 diff --git a/packages/plugin-trees/src/assets/fescue.webp b/packages/plugin-trees/src/assets/fescue.webp new file mode 100644 index 0000000000000000000000000000000000000000..0c8c1969b501fa4b9906879129fac06acf964529 GIT binary patch literal 4508 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 WP{Adz;s2!kWubxvSk?dl0002^E>w*G literal 0 HcmV?d00001 diff --git a/packages/plugin-trees/src/assets/meadow.webp b/packages/plugin-trees/src/assets/meadow.webp new file mode 100644 index 0000000000000000000000000000000000000000..056a463ccbdd3c3fa80f4405973842ea8f2cb516 GIT binary patch literal 3838 zcmV$BrMo`J?$~_8%8J|DY_?jcM76{&9kD^lJ7R;9cEtutZ4&o|q^ z3e>ES2Qs<9SeYyg0TtUSvpyuW`t_7q9e&fO8kw_b%4Q(|4G2#!wQ829>lP7n2Ee)K zqrV6l1&2jgwVt28*X*j$t=Al&Ws6ATCyCqs4^XgX$B;BHLY*uGNRc7KV-N4N_0;G& zUU_Z3#J@hc++n<7Y^Kza{5+DWOE!SXu=cnM6TE=a{@25PgIhbNLTT?#J0n%8t7g)h z@51Qt?J!L6Z^DkGOqgF5Yr{V*sYenYP_6dwuEd3H%NQkVmLK>sF7@L7dg#YZ2z@qe zkGyyAH5L=690`7_L9-Os=BJ&r(pVcx_FC=c^_xylpn9~prtG-8WTV=Hy!isbFRZ=Y zO&c({{OLtQm4Qtmu$&1Q!!s%Ly)v5>Pexn~s)^)L+#R`(sWH$bEc~pG3N{}>2XS)! z1(`v`gK!7HHdnT>@TTDo-%v)N=M-#?FU?RVx6g1VA4|C2gqE+bwxnGze@urp7m_`f zgOn+Uzb}#pJo_!M@k49uzakXC0W(C+T9)tvwJqQUYFoZQ0RI0G&JBP705DDBHT42& z4hjkbL4b4*chFeA{Zlsy}$mF_PVBy>T?OKGpGU14)$XI)XMGaj!Zbmd0BLMy_0a2SC zTw71M2PXuJMvjn}RT0{_uCZl9F^fRy?KgiKX|gQZauBF&h+EjuOitZgG?K!kjnj!_ ztDS>mcX}!TWk<}F+NR#3 zqsUT%WtpLlof~ouatja_IreihJ&LJX*ULWHL5{-3#7?hPrFxHnOYCdK2pVNV!EB2$MHnwZEDM7vhpsb6W$Vl_AwI9G&KbI zg8+o`=ru8XGh6^rp+=0w{2*$b#3Ug-O-{hK`O_k~JS?N?W2r4h(`Xqu{7UtXwPX|P zf?!68r%O`<3H9z(2VUSO%{fGHdv*^Os}T?e5R@>;_YmdNs18o?Tl5%7zAxLwda1*cPWz+SdTn(H;6_@5nU*k{Qa zYyp!#DfOYCI<}k>?QqDrF`K6P@AQ4pJ^To>*%FaS$J#E%p`c4k0B#?Wg*X7!-|^?< ztA&h>DC~!D-VKCJ2IvIUY)M$zRK2zJVp`|Cqmqg4K=v{@s;e?#HdtpELuwu%yphegx(9mC0`Gw{1op=u9s$_%#;y6pjd@RS1jMQ+5e}79fdt(WP1k>}uc@_%tF5^kpjuQ_RWD^R>+&D{B0CFeA>iWSaqyDvmwMu% z2=VNhvP`6R;w)zgDrDY*Ve=-l?@$1R-Kx0aZu(}*?LvBS6@V_T>;CBSkAgheJuTHJ09u9?*!D()@yt?VBaB*r^C6hMC2@0&kwu|5Wg&`u3|;#ybRu2BJuq1 zrIkW8X55IPFd;p2;>DEysrVXJ&r!*3uroPFIS8Q{`_Rn>JkNSiZN$Z7c4Sc8NtMX7 zUzhJfsnttt4W&EowPWm#VAC=u4Bbrj=+;POm=9L4bUGVWIay#7j#>AnNOZ4@Er)sT z0=g7P(>a3i0n&L=tNaz9L?hu=GnH@IQxcRQLsh!@;du3WJ@Pe?!5xT~`nwd} zBQ+k(4FC(gTK(Mb7q8b+>i_{2*#z>>>T|6r4p+GXti9~*Aysn#PSKI)gH}1BW=^|+ z3|SElwd=>cmTck2i zFT1#`dEwN1e#5+Xo6zy<*sKE(s4aPfQ-eUDKmbFLdi+w>* zQ{G=-;9_-RzZTi6{Cl0V{;A+RE#h4xd6Xk$;Pbb&9NppU57V#VECQiDa)=crSTzS{ zH@7rS)cY)`1JLAadt{wmmV+O+H5CatX-9aoV4J-mm_AhFo9&svSj+C949W3(f)o5y zgVDg(+|vQ|QJ+AT;(v+5R@J8wE(F}UF=S?dwl7#T^^{_)q-eqt)JinKb$XC?m}%&M zc%uRjMlpbDR83UHr2oRg|5-Yx$iu#`4mHlNRrftI{5-ABfdh-&>9kJPg~9nP!DCgI zQq|MBY_614d|jzy{{+LwsZk973D0;8PT|k$pa26L>AUv_^KT_0v|xsKZXjOi_|%4q zTG&c_Z;Xb5c<0l)Detb{Udy~sV6ggP`o(?k;=);mN3aYT;H@A)?G83^0Hkyw6+ zc>&Z%$Y0M}O~}j6!Am;1L~r(3m$=h2m7th*w|o!v7S1dHXQ_z zOn(T=W+96pw5SGcyj95XS?&mM6u8+MM}fP)dLQ)?xr4M(9%o{4XImt=n5n%iAO|+m z?O?SSvU*8so7F(2`SeL%+5p34ip11*m^hs4FdQAfDmPXp*mFxLna#K(k$1OFfw1#y z+4Jao@5B14cAY|n&KVyce{#^h6jQ#7SuWbI6OgbU{WV*txLa&R?X2UF@8-(VdyIa; z8>iLDo1l5D%m>8W(wiyfje4gZk0{(HA=y#oyw%4zE|`ue9)df74G(@1vRcHfrvmFj zTdcyrL>Gu`Ur3-?c(y==wMeb5bIrQk)dWW^#L*S#cuuF`32ZawxwL%lg*b+Xgcicw z7seQ<$4xIhjcKJFcQ|t6gN^@?2*dXyFNXX5be>ZlDK-3*w+60{; zm}22DXAc|xFcLXOM^68m0vMPm7by`ljcA`_A?chQdASF}lMh71>hS%$N+Lz(Ia|Xo z##POMEN0b#${YYpPkQ_d*7W$BuEpEiEa%F0Fn_Bje!9pd|Ew{^kUDu2N_pqQ7n4A5|8h-BJ%B zg24co`wIc9`=ttF#QyX{%b46lU`M8ABLpZ|h+e}miNG;4Nd+UOi=y9dG6rr9t`bWH zC)4BoyDWgv;9VDZuV1H6#%BCMC$;kzY*C5&6E@*)F*f(yNX%)-waS>0wDlnkE98u% z5SDx%d%feLI{dyRKH3Dj_~$yE42XJ#;g!jZ9IfVR5?7~Vd~989^s?^8?^QRd&v;bh zXr^eMf3j@mkY$ORpsT|j#|@kWOAOl+D*h3t7$9Hbp9w+2iw)wo=R&)5)d$)re) zg=<+?;jP>MzxMI{H_sO7HhQMr`Zn_65lN!zA_pJllCPFRkiVtcuf5!%e9kck>SHIb zQPY3^t5!0;U4}yNfZ_=EQ{urAlKI5UQ-*rn#K76#C*3em$Y(YP=SR{42z z7vJ6b0VvUJsfsJdU|J|1-Zq}T6B&dm-?5h}%!oK0gQ@OpW@{t(qmt1L#l+tuh;@^p zn|r9-vfk{tU6piAg&`{O^tvZZ_#xtTfaJGQ7g-n=6Qy0F!c`>cv6nM>8eaALpz4bp zXkC2dkN^njKe2omHHg!tk?Kfx`&~R)DR&oy$fSS*FCt(7000000G6MM ANdN!< literal 0 HcmV?d00001 diff --git a/packages/plugin-trees/src/assets/nature-icon.webp b/packages/plugin-trees/src/assets/nature-icon.webp new file mode 100644 index 0000000000000000000000000000000000000000..89772af4d34dd766f6c79381e4f1f5e3a339e3fa GIT binary patch literal 3582 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 E05O2+ZvX%Q literal 0 HcmV?d00001 diff --git a/packages/plugin-trees/src/assets/oak.webp b/packages/plugin-trees/src/assets/oak.webp new file mode 100644 index 0000000000000000000000000000000000000000..5be2a12638b15d5b8243d5d9c688cec8c38c9317 GIT binary patch literal 8688 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%0A5r{yf{5O!to=tD*dkA-N*RIY>Up+c*bLLEyQml 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 OSW$T2SReoZ00017YXMaN literal 0 HcmV?d00001 diff --git a/packages/plugin-trees/src/assets/reed.webp b/packages/plugin-trees/src/assets/reed.webp new file mode 100644 index 0000000000000000000000000000000000000000..4fcbb04ab43652672c1be03a72454d44dd01d1d7 GIT binary patch literal 2684 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 { + const ringRadius = (n: TreeNode) => treeTrunkRadius(n) + ROTATE_RING_OFFSET + const ringY = () => ROTATE_RING_Y + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => { + const r = initial.rotation ?? [0, 0, 0] + // Negate to match three.js Y-rotation handedness (same as shelf). + return { rotation: [r[0], (r[1] ?? 0) - delta, r[2]] as [number, number, number] } + }, + placement: { + position: (n) => { + const r = ringRadius(n) + return [r * Math.SQRT1_2, ringY(), r * Math.SQRT1_2] + }, + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: ringRadius, + y: ringY, + }, + } +} + +/** + * The tree node definition. Rendering uses the instanced path rather than the + * per-node `def.geometry`: a collective `def.system` batches every tree into + * `InstancedMesh`es (forest-scale draw calls), while a featherweight + * `def.renderer` mounts an invisible per-node proxy so the host's selection / + * outline / zone machinery works unchanged. `parametrics` gives the inspector + * for free; `tool`/`preview` drive placement. No host dispatch code per kind. + */ +export const treeDefinition: NodeDefinition = { + kind: 'trees:tree', + // Static in the bake for portability; our viewer removes the baked meshes and + // re-renders live (wind, LODs) via this def's own path. See plans → Part D. + bake: 'replace', + schemaVersion: 1, + schema: TreeNode, + category: 'furnish', + snapProfile: 'item', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + preset: 'oak', + size: 'medium', + treeType: 'deciduous', + height: 7, + seed: 1, + foliageDensity: 1, + trunkThickness: 1, + leafless: false, + leafColor: '#ffffff', + branchColor: '#ffffff', + }), + + capabilities: { + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { + axes: ['y'], + snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4), + }, + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + groupable: true, + snappable: {}, + // The auto-measured drag box would wrap the whole canopy (the proxy shows + // the real geometry while selected) — declare trunk-sized bounds instead. + dragBounds: (node) => { + const tree = node as unknown as TreeNode + const radius = treeTrunkRadius(tree) + return { size: [radius * 2, tree.height ?? 7, radius * 2] } + }, + floorPlaced: { + // `footprint` receives the host's `AnyNode`; cast to our schema type the + // same way built-in kinds do (`node as ShelfNode`). Trunk-sized, not + // canopy-sized — the drag/placement box should hug where the tree + // actually plants, not span the whole crown. + footprint: (node) => { + const tree = node as unknown as TreeNode + const radius = treeTrunkRadius(tree) + return { + dimensions: [radius * 2, tree.height, radius * 2] as [number, number, number], + rotation: tree.rotation, + } + }, + collides: false, + }, + }, + + parametrics: treeParametrics, + // 2D plan symbol: dashed canopy ring + trunk dot (see floorplan.ts). + floorplan: buildTreeFloorplan, + handles: [treeRotateHandle()], + + // Instanced rendering: an invisible per-node proxy for selection/outline... + renderer: { kind: 'parametric', module: () => import('./proxy-renderer') }, + // ...and a collective system that batches every tree into InstancedMeshes. + system: { module: () => import('./system'), priority: 3 }, + // Baked `/viewer` re-render for `bake: 'replace'` — collective, instanced. + bakeReplaceRenderer: { module: () => import('./static-renderer') }, + + preview: () => import('./preview'), + tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Plant tree' }, + { key: 'Esc', label: 'Stop' }, + ], + + presentation: { + label: 'Tree', + description: 'A procedural ez-tree. Oak, pine, aspen, ash, bush, or trellis.', + icon: { kind: 'iconify', name: 'lucide:trees' }, + paletteSection: 'furnish', + hidden: true, + }, + + mcp: { + description: + 'A procedural ez-tree (example plugin node). Species presets (oak/pine/aspen/ash/bush/trellis) × size, deciduous/evergreen type, adjustable height, foliage/trunk, leaf & branch tint, and a seed for variation.', + }, +} diff --git a/packages/plugin-trees/src/find-sync.ts b/packages/plugin-trees/src/find-sync.ts new file mode 100644 index 00000000..2b867052 --- /dev/null +++ b/packages/plugin-trees/src/find-sync.ts @@ -0,0 +1,40 @@ +import { type AnyNode, emitter } from '@pascal-app/core' +import type { FlowerNode } from './flower-schema' +import type { GrassNode } from './grass-schema' +import type { TreeNode } from './schema' +import { type TreesPanelMode, useTreesStore } from './store' + +/** + * "Find in catalog" sync. The editor's node action menu emits + * `selection:find-node`; the host opens the panel that owns the kind (via + * `panelRegistry.panelForKind`) — but which *section* of the Nature panel to + * show is plugin knowledge, so the plugin listens too and points its own store + * at the found node's section + preset. Module-level (imported by the plugin + * manifest) so the listener is live from plugin load, even while the panel has + * never been mounted. + */ + +const MODE_BY_KIND: Record = { + 'trees:tree': 'trees', + 'trees:flower': 'flowers', + 'trees:grass': 'grass', +} + +emitter.on( + 'selection:find-node' as never, + ((node: AnyNode) => { + const mode = MODE_BY_KIND[node.type as string] + if (!mode) return + const store = useTreesStore.getState() + store.setMode(mode) + if (mode === 'trees') { + const tree = node as unknown as TreeNode + store.setPreset(tree.preset ?? 'oak') + store.setSize(tree.size ?? 'medium') + } else if (mode === 'flowers') { + store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy') + } else { + store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow') + } + }) as never, +) diff --git a/packages/plugin-trees/src/floorplan.ts b/packages/plugin-trees/src/floorplan.ts new file mode 100644 index 00000000..fed32969 --- /dev/null +++ b/packages/plugin-trees/src/floorplan.ts @@ -0,0 +1,125 @@ +import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core' +import { flowerPetalColor } from './flower-geometry' +import { FLOWER_PRESETS } from './flower-presets' +import type { FlowerNode } from './flower-schema' +import { GRASS_PRESETS } from './grass-presets' +import type { GrassNode } from './grass-schema' +import { TREE_PRESETS } from './presets' +import type { TreeNode } from './schema' + +/** + * 2D plan builders for the plant kinds (`def.floorplan`) — the registry + * floor-plan layer renders any kind that provides one, so this is all it takes + * for plugin nodes to appear in the 2D view. Classic architect symbols: + * a dashed canopy circle (dashed = overhead element, like a roof overhang) + * with a solid trunk dot for trees; small colour dots for flowers/grass. + */ + +/** Trunk radius in plan — also the selection footprint, so the move box hugs + * the trunk instead of the whole canopy. */ +export function treeTrunkRadius(tree: TreeNode): number { + return Math.max(0.15, (tree.height ?? 7) * 0.025 * (tree.trunkThickness ?? 1)) +} + +/** Approximate canopy radius in plan (matches the old whole-tree footprint). */ +export function treeCanopyRadius(tree: TreeNode): number { + return Math.max(0.5, (tree.height ?? 7) * 0.28) +} + +type ViewChrome = { stroke: string | null; selected: boolean } + +/** Selection/hover stroke override shared by the three builders. */ +function chromeOf(ctx: GeometryContext): ViewChrome { + const view = ctx.viewState + const palette = view?.palette + if ((view?.selected || view?.highlighted) && palette) + return { stroke: palette.selectedStroke, selected: view?.selected ?? false } + if (view?.hovered && palette) return { stroke: palette.wallHoverStroke, selected: false } + return { stroke: null, selected: false } +} + +export function buildTreeFloorplan(node: TreeNode, ctx: GeometryContext): FloorplanGeometry { + const [x, , z] = node.position ?? [0, 0, 0] + const swatch = (TREE_PRESETS[node.preset] ?? TREE_PRESETS.oak).swatch + const chrome = chromeOf(ctx) + const stroke = chrome.stroke ?? swatch + + const children: FloorplanGeometry[] = [ + // Canopy ring — pointer-events on the stroke only, so the (large) disc + // doesn't steal clicks from whatever sits under the canopy in plan. + { + kind: 'circle', + cx: x, + cy: z, + r: treeCanopyRadius(node), + stroke, + strokeWidth: 0.03, + strokeDasharray: '0.18 0.12', + fill: swatch, + fillOpacity: 0.06, + pointerEvents: 'stroke', + }, + // Trunk dot — the solid, always-clickable core of the symbol. + { + kind: 'circle', + cx: x, + cy: z, + r: treeTrunkRadius(node), + fill: chrome.stroke ?? '#6b4f2e', + stroke, + strokeWidth: 0.02, + opacity: 0.95, + }, + ] + if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] }) + return { kind: 'group', children } +} + +export function buildFlowerFloorplan(node: FlowerNode, ctx: GeometryContext): FloorplanGeometry { + const [x, , z] = node.position ?? [0, 0, 0] + const preset = FLOWER_PRESETS[node.preset] ?? FLOWER_PRESETS.daisy + const chrome = chromeOf(ctx) + const children: FloorplanGeometry[] = [ + { + kind: 'circle', + cx: x, + cy: z, + r: 0.1, + fill: flowerPetalColor(node), + stroke: chrome.stroke ?? preset.stemColor, + strokeWidth: 0.02, + }, + { + kind: 'circle', + cx: x, + cy: z, + r: 0.035, + fill: preset.centerColor, + pointerEvents: 'none', + }, + ] + if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] }) + return { kind: 'group', children } +} + +export function buildGrassFloorplan(node: GrassNode, ctx: GeometryContext): FloorplanGeometry { + const [x, , z] = node.position ?? [0, 0, 0] + const preset = GRASS_PRESETS[node.preset] ?? GRASS_PRESETS.meadow + const blade = node.bladeColor ?? preset.bladeColor + const chrome = chromeOf(ctx) + const children: FloorplanGeometry[] = [ + { + kind: 'circle', + cx: x, + cy: z, + r: 0.12, + fill: blade, + fillOpacity: 0.5, + stroke: chrome.stroke ?? blade, + strokeWidth: 0.02, + strokeDasharray: '0.06 0.05', + }, + ] + if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] }) + return { kind: 'group', children } +} diff --git a/packages/plugin-trees/src/flower-definition.ts b/packages/plugin-trees/src/flower-definition.ts new file mode 100644 index 00000000..4c699b1f --- /dev/null +++ b/packages/plugin-trees/src/flower-definition.ts @@ -0,0 +1,83 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildFlowerFloorplan } from './floorplan' +import { flowerParametrics } from './flower-parametrics' +import { FlowerNode } from './flower-schema' + +/** + * The flower node definition — a sibling instanced kind to the tree. Same + * composition: a `def.system` batches every flower into InstancedMeshes, a + * featherweight `def.renderer` proxy keeps selection working, `parametrics` + * gives the inspector, `tool`/`preview` drive placement. + */ +export const flowerDefinition: NodeDefinition = { + kind: 'trees:flower', + bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D + schemaVersion: 1, + schema: FlowerNode, + category: 'furnish', + snapProfile: 'item', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + preset: 'daisy', + height: 0.5, + seed: 1, + petalColor: '#fcfcf2', + }), + + capabilities: { + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { + axes: ['y'], + snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4), + }, + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + groupable: true, + snappable: {}, + floorPlaced: { + footprint: (node) => { + const flower = node as unknown as FlowerNode + const radius = Math.max(0.1, flower.height * 0.25) + return { + dimensions: [radius * 2, flower.height, radius * 2] as [number, number, number], + rotation: flower.rotation, + } + }, + collides: false, + }, + }, + + parametrics: flowerParametrics, + floorplan: buildFlowerFloorplan, + + renderer: { kind: 'parametric', module: () => import('./flower-proxy-renderer') }, + system: { module: () => import('./flower-system'), priority: 3 }, + bakeReplaceRenderer: { module: () => import('./flower-static-renderer') }, + + preview: () => import('./flower-preview'), + tool: () => import('./flower-tool'), + toolHints: [ + { key: 'Left click', label: 'Plant flower' }, + { key: 'Esc', label: 'Stop' }, + ], + + presentation: { + label: 'Flower', + description: 'A procedural flower. Daisy, tulip, or lavender.', + icon: { kind: 'iconify', name: 'lucide:flower-2' }, + paletteSection: 'furnish', + hidden: true, + }, + + mcp: { + description: + 'A procedural flower (example plugin node) — daisy, tulip, or lavender, instanced like the trees.', + }, +} diff --git a/packages/plugin-trees/src/flower-geometry.ts b/packages/plugin-trees/src/flower-geometry.ts new file mode 100644 index 00000000..56960b69 --- /dev/null +++ b/packages/plugin-trees/src/flower-geometry.ts @@ -0,0 +1,115 @@ +import { + type BufferGeometry, + ConeGeometry, + CylinderGeometry, + Group, + Mesh, + SphereGeometry, +} from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { FLOWER_PRESETS } from './flower-presets' +import type { FlowerNode, FlowerPreset } from './flower-schema' +import type { SubMesh, VariantData } from './instanced' +import { mulberry32, naturalHeight } from './variant-utils' +import { windStandardMaterial } from './wind-node' + +export function flowerVariantKey(preset: FlowerPreset, seed: number, petalColor: string): string { + return `${preset}:${seed}:${petalColor}` +} + +/** Petal colour with fallbacks — nodes persisted before the field existed load + * without it, so fall back to the preset colour rather than crash/blank. */ +export function flowerPetalColor(node: FlowerNode): string { + return node.petalColor ?? FLOWER_PRESETS[node.preset]?.petalColor ?? '#fcfcf2' +} + +const variantCache = new Map() + +/** Cached procedural flower geometry for a (preset, seed, petalColor). Like the + * trees, one generation per variant is shared across every instance. Built + * merged per material so each variant is ~3 InstancedMeshes (stem/petals/center). */ +export function getFlowerVariant(node: FlowerNode): VariantData { + const petalColor = flowerPetalColor(node) + const key = flowerVariantKey(node.preset, node.seed, petalColor) + const cached = variantCache.get(key) + if (cached) return cached + const group = buildFlower(node.preset, node.seed, petalColor) + const subMeshes: SubMesh[] = group.children + .filter((c): c is Mesh => (c as Mesh).isMesh) + .map((mesh) => ({ geometry: mesh.geometry, material: mesh.material })) + const data: VariantData = { subMeshes, naturalHeight: naturalHeight(group) } + variantCache.set(key, data) + return data +} + +function buildFlower(preset: FlowerPreset, seed: number, petalColor: string): Group { + const spec = FLOWER_PRESETS[preset] ?? FLOWER_PRESETS.daisy + const rng = mulberry32(seed >>> 0) + const group = new Group() + const stemMat = windStandardMaterial({ color: spec.stemColor, roughness: 0.85 }) + const petalMat = windStandardMaterial({ color: petalColor, roughness: 0.7 }) + const centerMat = windStandardMaterial({ color: spec.centerColor, roughness: 0.6 }) + const stemH = spec.defaultHeight + + const stem = new CylinderGeometry(0.008, 0.015, stemH, 5) + stem.translate(0, stemH / 2, 0) + group.add(new Mesh(stem, stemMat)) + + if (preset === 'lavender') { + // A spike of small florets along the top ~45% of the stem. + const florets: BufferGeometry[] = [] + const count = 26 + const spikeBase = stemH * 0.55 + for (let i = 0; i < count; i++) { + const t = i / count + const y = spikeBase + t * (stemH - spikeBase) + const angle = i * 2.4 + rng() * 0.5 + const r = (1 - t) * 0.035 + 0.008 + const f = new SphereGeometry(0.016 * (1 - t * 0.4), 5, 4) + f.translate(Math.cos(angle) * r, y, Math.sin(angle) * r) + florets.push(f) + } + group.add(new Mesh(mergeGeometries(florets, false) ?? florets[0], petalMat)) + return group + } + + if (preset === 'tulip') { + // Six petals forming an upward cup. + const petals: BufferGeometry[] = [] + const n = 6 + for (let i = 0; i < n; i++) { + const angle = (i / n) * Math.PI * 2 + rng() * 0.1 + const p = new ConeGeometry(0.045, 0.16, 4) + p.translate(0, 0.08, 0) + p.rotateZ(0.45) + p.rotateY(-angle) + p.translate(Math.cos(angle) * 0.03, stemH, Math.sin(angle) * 0.03) + petals.push(p) + } + group.add(new Mesh(mergeGeometries(petals, false) ?? petals[0], petalMat)) + const core = new ConeGeometry(0.02, 0.1, 4) + core.translate(0, stemH + 0.06, 0) + group.add(new Mesh(core, centerMat)) + return group + } + + // daisy — a yellow disc with a ring of white petals. + const center = new SphereGeometry(0.035, 8, 6) + center.scale(1, 0.6, 1) + center.translate(0, stemH, 0) + group.add(new Mesh(center, centerMat)) + + const petals: BufferGeometry[] = [] + const n = 12 + for (let i = 0; i < n; i++) { + const angle = (i / n) * Math.PI * 2 + rng() * 0.08 + const p = new ConeGeometry(0.02, 0.08, 3) + p.rotateZ(Math.PI / 2) + p.translate(0.07, 0, 0) + p.rotateY(-angle) + p.translate(0, stemH + 0.005, 0) + petals.push(p) + } + group.add(new Mesh(mergeGeometries(petals, false) ?? petals[0], petalMat)) + return group +} diff --git a/packages/plugin-trees/src/flower-parametrics.ts b/packages/plugin-trees/src/flower-parametrics.ts new file mode 100644 index 00000000..b81dda98 --- /dev/null +++ b/packages/plugin-trees/src/flower-parametrics.ts @@ -0,0 +1,22 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { FlowerNode } from './flower-schema' + +/** Inspector for a placed flower — rendered for free by the host's + * `ParametricInspector` from this descriptor. */ +export const flowerParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Flower', + fields: [ + { key: 'preset', kind: 'enum', options: ['daisy', 'tulip', 'lavender'] }, + { key: 'height', kind: 'number', unit: 'm', min: 0.2, max: 2, step: 0.05 }, + { key: 'petalColor', kind: 'color' }, + { key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], +} diff --git a/packages/plugin-trees/src/flower-presets.ts b/packages/plugin-trees/src/flower-presets.ts new file mode 100644 index 00000000..b7fb7e3d --- /dev/null +++ b/packages/plugin-trees/src/flower-presets.ts @@ -0,0 +1,54 @@ +import { FLOWER_ART } from './art' +import type { FlowerPreset } from './flower-schema' + +/** Per-flower colours, default height (metres), and a card `thumbnail` (a + * replaceable placeholder image — see `thumbnails.ts`). Pure data shared by the + * geometry builder and the panel. */ +export type FlowerPresetSpec = { + id: FlowerPreset + label: string + petalColor: string + centerColor: string + stemColor: string + defaultHeight: number + swatch: string + thumbnail: string +} + +export const FLOWER_PRESETS: Record = { + daisy: { + id: 'daisy', + label: 'Daisy', + petalColor: '#fcfcf2', + centerColor: '#f4c430', + stemColor: '#4f7942', + defaultHeight: 0.5, + swatch: '#f4c430', + thumbnail: FLOWER_ART.daisy, + }, + tulip: { + id: 'tulip', + label: 'Tulip', + petalColor: '#e0457b', + centerColor: '#c43160', + stemColor: '#3f7a3a', + defaultHeight: 0.45, + swatch: '#e0457b', + thumbnail: FLOWER_ART.tulip, + }, + lavender: { + id: 'lavender', + label: 'Lavender', + petalColor: '#9b6fd4', + centerColor: '#7d52b8', + stemColor: '#5a7a4a', + defaultHeight: 0.6, + swatch: '#9b6fd4', + thumbnail: FLOWER_ART.lavender, + }, +} + +export const FLOWER_PRESET_LIST: FlowerPresetSpec[] = Object.values(FLOWER_PRESETS) + +/** Bounded seed pool so flowers share instancing variants (see trees). */ +export const FLOWER_SEED_POOL = [1, 7, 13, 21, 34, 55] diff --git a/packages/plugin-trees/src/flower-preview.tsx b/packages/plugin-trees/src/flower-preview.tsx new file mode 100644 index 00000000..9bb01f33 --- /dev/null +++ b/packages/plugin-trees/src/flower-preview.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useEffect, useMemo } from 'react' +import type { Material, MeshStandardMaterial } from 'three' +import { getFlowerVariant } from './flower-geometry' +import type { FlowerNode } from './flower-schema' + +const NO_RAYCAST = () => {} + +/** Translucent placement ghost for a flower — clones the variant materials so + * the cursor preview is see-through without mutating the cached originals. */ +export default function FlowerPreview({ node }: { node: FlowerNode }) { + const data = useMemo(() => getFlowerVariant(node), [node]) + const scale = node.height / data.naturalHeight + + const ghosts = useMemo( + () => + data.subMeshes.map((sub) => { + const base = ( + Array.isArray(sub.material) ? sub.material[0] : sub.material + ) as MeshStandardMaterial + const clone = base.clone() + clone.transparent = true + clone.opacity = 0.55 + clone.depthWrite = false + return clone + }), + [data], + ) + + useEffect( + () => () => { + for (const m of ghosts as Material[]) m.dispose() + }, + [ghosts], + ) + + return ( + + {data.subMeshes.map((sub, i) => ( + + ))} + + ) +} diff --git a/packages/plugin-trees/src/flower-proxy-renderer.tsx b/packages/plugin-trees/src/flower-proxy-renderer.tsx new file mode 100644 index 00000000..27eb5e33 --- /dev/null +++ b/packages/plugin-trees/src/flower-proxy-renderer.tsx @@ -0,0 +1,13 @@ +'use client' + +import { getFlowerVariant } from './flower-geometry' +import type { FlowerNode } from './flower-schema' +import { KindProxy } from './instanced' + +const getVariant = (node: FlowerNode) => getFlowerVariant(node) +const colliderRadius = (node: FlowerNode) => Math.max(0.06, (node.height ?? 0.5) * 0.22) + +/** Per-node selection proxy for the instanced flowers. */ +export default function FlowerProxyRenderer({ node }: { node: FlowerNode }) { + return +} diff --git a/packages/plugin-trees/src/flower-schema.ts b/packages/plugin-trees/src/flower-schema.ts new file mode 100644 index 00000000..0edc0b0e --- /dev/null +++ b/packages/plugin-trees/src/flower-schema.ts @@ -0,0 +1,23 @@ +import { BaseNode, nodeType, objectId } from '@pascal-app/core' +import { z } from 'zod' + +/** Flower silhouettes the plugin can place. The string persists in scene JSON. */ +export const FlowerPreset = z.enum(['daisy', 'tulip', 'lavender']) +export type FlowerPreset = z.infer + +/** A placed flower — a sibling instanced kind to the tree, sharing the same + * instanced renderer + selection proxy via the generic `instanced` core. */ +export const FlowerNode = BaseNode.extend({ + id: objectId('flower'), + type: nodeType('trees:flower'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + preset: FlowerPreset.default('daisy'), + height: z.number().positive().default(0.5), + seed: z.number().int().default(1), + /** Petal colour (hex). Baked from the preset at placement; recolour per-flower + * in the inspector (the flower analog of the tree's leaf tint). */ + petalColor: z.string().default('#fcfcf2'), +}) + +export type FlowerNode = z.infer diff --git a/packages/plugin-trees/src/flower-static-renderer.tsx b/packages/plugin-trees/src/flower-static-renderer.tsx new file mode 100644 index 00000000..99394a9b --- /dev/null +++ b/packages/plugin-trees/src/flower-static-renderer.tsx @@ -0,0 +1,16 @@ +'use client' + +import { flowerPetalColor, flowerVariantKey, getFlowerVariant } from './flower-geometry' +import type { FlowerNode } from './flower-schema' +import { InstancedNodes } from './instanced' + +const variantKeyOf = (node: FlowerNode) => + flowerVariantKey(node.preset, node.seed, flowerPetalColor(node)) +const getVariant = (node: FlowerNode) => getFlowerVariant(node) + +/** Collective baked-`/viewer` renderer for one level's flowers (`bakeReplaceRenderer`). */ +export default function FlowerReplaceInstances({ nodes }: { nodes: FlowerNode[] }) { + return ( + + ) +} diff --git a/packages/plugin-trees/src/flower-system.tsx b/packages/plugin-trees/src/flower-system.tsx new file mode 100644 index 00000000..0572acc2 --- /dev/null +++ b/packages/plugin-trees/src/flower-system.tsx @@ -0,0 +1,20 @@ +'use client' + +import { flowerPetalColor, flowerVariantKey, getFlowerVariant } from './flower-geometry' +import type { FlowerNode } from './flower-schema' +import { InstancedKindSystem } from './instanced' + +const variantKeyOf = (node: FlowerNode) => + flowerVariantKey(node.preset, node.seed, flowerPetalColor(node)) +const getVariant = (node: FlowerNode) => getFlowerVariant(node) + +/** Collective instanced renderer for every placed flower (`def.system`). */ +export default function FlowersSystem() { + return ( + + getVariant={getVariant} + kind="trees:flower" + variantKeyOf={variantKeyOf} + /> + ) +} diff --git a/packages/plugin-trees/src/flower-tool.tsx b/packages/plugin-trees/src/flower-tool.tsx new file mode 100644 index 00000000..3538d2ff --- /dev/null +++ b/packages/plugin-trees/src/flower-tool.tsx @@ -0,0 +1,57 @@ +'use client' + +import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useMemo } from 'react' +import { FLOWER_PRESETS, FLOWER_SEED_POOL } from './flower-presets' +import FlowerPreview from './flower-preview' +import { FlowerNode } from './flower-schema' +import { usePlacement } from './placement' +import { useTreesStore } from './store' + +/** The flowers placement tool — mirrors the trees tool, reading the flower + * brush from the shared store. Petal colour is baked from the preset at + * placement, then editable per-flower in the inspector. */ +export default function FlowerTool() { + const activeLevelId = useViewer((s) => s.selection.levelId) + const preset = useTreesStore((s) => s.flowerPreset) + const height = useTreesStore((s) => s.flowerHeight) + + const previewNode = useMemo( + () => + FlowerNode.parse({ + preset, + height, + petalColor: FLOWER_PRESETS[preset].petalColor, + seed: 1, + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + [preset, height], + ) + + const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => { + if (!activeLevelId) return + const s = useTreesStore.getState() + const flower = FlowerNode.parse({ + preset: s.flowerPreset, + height: s.flowerHeight, + petalColor: FLOWER_PRESETS[s.flowerPreset].petalColor, + seed: FLOWER_SEED_POOL[Math.floor(Math.random() * FLOWER_SEED_POOL.length)] ?? 1, + position, + rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0], + }) + useScene.getState().createNode(flower as unknown as AnyNode, activeLevelId as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [flower.id as AnyNodeId] }) + triggerSFX('sfx:item-place') + }) + + if (!activeLevelId) return null + + return ( + + + + ) +} diff --git a/packages/plugin-trees/src/geometry.ts b/packages/plugin-trees/src/geometry.ts new file mode 100644 index 00000000..f559986a --- /dev/null +++ b/packages/plugin-trees/src/geometry.ts @@ -0,0 +1,148 @@ +// ez-tree loads its inlined textures at module scope (needs `document`), so +// this module must only be imported from lazy client modules (renderers, +// systems, tools, previews) — never from `index.ts`, a definition, or +// `floorplan.ts`, or SSR/prerender crashes. Pure helpers shared with the +// flower/grass builders live in `variant-utils.ts` for that reason. +import { Tree } from '@dgreenheck/ez-tree' +import type { BufferGeometry, Material, Mesh, Object3D } from 'three' +import { ezPresetOf } from './presets' +import type { TreeNode } from './schema' +import { naturalHeight } from './variant-utils' +import { toWindMaterial } from './wind-node' + +/** The geometry-affecting fields of a tree. Two trees with the same spec share + * one generated variant (and thus one InstancedMesh set). Per-instance fields + * (position/rotation/height) are deliberately NOT here — they're cheap matrix + * work, not geometry. */ +export type TreeSpec = Pick< + TreeNode, + | 'preset' + | 'size' + | 'treeType' + | 'seed' + | 'foliageDensity' + | 'trunkThickness' + | 'leafless' + | 'leafColor' + | 'branchColor' +> + +export function treeSpecOf(node: TreeNode): TreeSpec { + // Default the non-override fields (nodes persisted before a field existed load + // without it). The four overrides are left as-is — `undefined` means "inherit + // the ez-tree preset" (its own seed/type/tints), resolved in `generateTree`. + return { + preset: node.preset ?? 'oak', + size: node.size ?? 'medium', + treeType: node.treeType, + seed: node.seed, + foliageDensity: node.foliageDensity ?? 1, + trunkThickness: node.trunkThickness ?? 1, + leafless: node.leafless ?? false, + leafColor: node.leafColor, + branchColor: node.branchColor, + } +} + +/** Stable variant id. Trees with the same key share one set of InstancedMeshes. */ +export function treeVariantKey(spec: TreeSpec): string { + return [ + spec.preset, + spec.size, + spec.treeType, + spec.seed, + spec.foliageDensity, + spec.trunkThickness, + spec.leafless, + spec.leafColor, + spec.branchColor, + ].join(':') +} + +/** `#rrggbb` → 0xrrggbb, defaulting to white on anything missing/unparseable. */ +function hexToInt(hex: string | undefined): number { + const n = Number.parseInt((hex ?? '').replace('#', ''), 16) + return Number.isFinite(n) ? n : 0xffffff +} + +/** + * Generate an ez-tree for a spec. ez-tree's `Tree` is a `THREE.Group`; textures + * are inlined in the library (no asset hosting). `loadPreset` owns the full look + * (seed, growth model, tints, branch/leaf structure); the curated params then + * apply *on top* — but only where the node actually set them, so an unset field + * keeps the preset's value (its canonical silhouette/colours). `trunkThickness` + * and `foliageDensity` are multipliers (1 = preset default). Pure given its + * inputs — same spec ⇒ same tree — which lets the renderer cache one generation + * per variant. + */ +export function generateTree(spec: TreeSpec): Tree { + const tree = new Tree() + tree.loadPreset(ezPresetOf(spec.preset, spec.size)) + if (spec.seed != null) tree.options.seed = spec.seed + if (spec.treeType != null) (tree.options as { type: string }).type = spec.treeType + + const radius = tree.options.branch.radius as unknown as Record + for (const level of Object.keys(radius)) { + const value = radius[level] + if (value !== undefined) radius[level] = value * spec.trunkThickness + } + + const leaves = tree.options.leaves as { count: number; tint: number } + leaves.count = spec.leafless ? 0 : Math.round(leaves.count * spec.foliageDensity) + if (spec.leafColor != null) leaves.tint = hexToInt(spec.leafColor) + if (spec.branchColor != null) + (tree.options.bark as { tint: number }).tint = hexToInt(spec.branchColor) + + tree.generate() + return tree +} + +/** A renderable sub-mesh of a tree: geometry (baked into tree-local space) + + * its material. The instanced renderer builds one InstancedMesh per sub-mesh + * per variant. */ +export type TreeSubMesh = { geometry: BufferGeometry; material: Material | Material[] } + +/** Geometry + height for one tree variant, generated once and shared across + * every instance of that spec. */ +export type TreeVariantData = { subMeshes: TreeSubMesh[]; naturalHeight: number } + +const variantCache = new Map() + +/** + * Cached geometry for a spec. ez-tree's `generate()` is heavy, so it runs once + * per variant; the resulting geometries/materials are retained here and shared + * by every instance. The renderer must NOT dispose them (it sets `dispose={null}` + * on the InstancedMesh). + */ +export function getVariantData(spec: TreeSpec): TreeVariantData { + const key = treeVariantKey(spec) + const cached = variantCache.get(key) + if (cached) return cached + const tree = generateTree(spec) + const data: TreeVariantData = { + subMeshes: extractSubMeshes(tree), + naturalHeight: naturalHeight(tree), + } + variantCache.set(key, data) + return data +} + +/** Extract the leaf/bark sub-meshes, baking each mesh's local transform into a + * cloned geometry so instance matrices only carry the node's own transform. */ +export function extractSubMeshes(tree: Object3D): TreeSubMesh[] { + const out: TreeSubMesh[] = [] + tree.traverse((child) => { + const mesh = child as Partial + if (mesh.isMesh && mesh.geometry && mesh.material) { + const geometry = mesh.geometry.clone() + ;(child as Mesh).updateMatrix() + geometry.applyMatrix4((child as Mesh).matrix) + // Swap ez-tree's plain materials for swaying node materials (WebGPU/TSL). + const material = Array.isArray(mesh.material) + ? mesh.material.map(toWindMaterial) + : toWindMaterial(mesh.material) + out.push({ geometry, material }) + } + }) + return out +} diff --git a/packages/plugin-trees/src/grass-definition.ts b/packages/plugin-trees/src/grass-definition.ts new file mode 100644 index 00000000..ff48957c --- /dev/null +++ b/packages/plugin-trees/src/grass-definition.ts @@ -0,0 +1,83 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildGrassFloorplan } from './floorplan' +import { grassParametrics } from './grass-parametrics' +import { GrassNode } from './grass-schema' + +/** + * The grass node definition — a third instanced kind alongside trees & flowers. + * Same composition: a `def.system` batches every tuft into InstancedMeshes, a + * featherweight `def.renderer` proxy keeps selection working, `parametrics` + * gives the inspector, `tool`/`preview` drive placement. + */ +export const grassDefinition: NodeDefinition = { + kind: 'trees:grass', + bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D + schemaVersion: 1, + schema: GrassNode, + category: 'furnish', + snapProfile: 'item', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + preset: 'meadow', + height: 0.4, + seed: 1, + bladeColor: '#5a8f3c', + }), + + capabilities: { + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { + axes: ['y'], + snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4), + }, + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + groupable: true, + snappable: {}, + floorPlaced: { + footprint: (node) => { + const grass = node as unknown as GrassNode + const radius = Math.max(0.1, grass.height * 0.3) + return { + dimensions: [radius * 2, grass.height, radius * 2] as [number, number, number], + rotation: grass.rotation, + } + }, + collides: false, + }, + }, + + parametrics: grassParametrics, + floorplan: buildGrassFloorplan, + + renderer: { kind: 'parametric', module: () => import('./grass-proxy-renderer') }, + system: { module: () => import('./grass-system'), priority: 3 }, + bakeReplaceRenderer: { module: () => import('./grass-static-renderer') }, + + preview: () => import('./grass-preview'), + tool: () => import('./grass-tool'), + toolHints: [ + { key: 'Left click', label: 'Plant grass' }, + { key: 'Esc', label: 'Stop' }, + ], + + presentation: { + label: 'Grass', + description: 'A procedural grass tuft. Meadow, fescue, or reed.', + icon: { kind: 'iconify', name: 'lucide:wheat' }, + paletteSection: 'furnish', + hidden: true, + }, + + mcp: { + description: + 'A procedural grass tuft (example plugin node) — meadow, fescue, or reed, instanced like the trees.', + }, +} diff --git a/packages/plugin-trees/src/grass-geometry.ts b/packages/plugin-trees/src/grass-geometry.ts new file mode 100644 index 00000000..6bca0aef --- /dev/null +++ b/packages/plugin-trees/src/grass-geometry.ts @@ -0,0 +1,55 @@ +import { type BufferGeometry, ConeGeometry, DoubleSide, Group, Mesh } from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { GRASS_PRESETS } from './grass-presets' +import type { GrassNode, GrassPreset } from './grass-schema' +import type { SubMesh, VariantData } from './instanced' +import { mulberry32, naturalHeight } from './variant-utils' +import { windStandardMaterial } from './wind-node' + +export function grassVariantKey(preset: GrassPreset, seed: number, bladeColor: string): string { + return `${preset}:${seed}:${bladeColor}` +} + +const variantCache = new Map() + +/** Cached procedural grass geometry for a (preset, seed, bladeColor). One + * generation per variant is shared across every instance — a whole lawn of the + * same tuft is a single InstancedMesh. */ +export function getGrassVariant(node: GrassNode): VariantData { + const key = grassVariantKey(node.preset, node.seed, node.bladeColor) + const cached = variantCache.get(key) + if (cached) return cached + const group = buildGrass(node.preset, node.seed, node.bladeColor) + const subMeshes: SubMesh[] = group.children + .filter((c): c is Mesh => (c as Mesh).isMesh) + .map((mesh) => ({ geometry: mesh.geometry, material: mesh.material })) + const data: VariantData = { subMeshes, naturalHeight: naturalHeight(group) } + variantCache.set(key, data) + return data +} + +/** A tuft of flattened, leaning blades merged into one geometry (one draw per + * instance). Deterministic in `seed` so the same variant renders identically. */ +function buildGrass(preset: GrassPreset, seed: number, bladeColor: string): Group { + const spec = GRASS_PRESETS[preset] ?? GRASS_PRESETS.meadow + const rng = mulberry32(seed >>> 0) + const group = new Group() + const mat = windStandardMaterial({ color: bladeColor, roughness: 0.9, side: DoubleSide }) + const h = spec.defaultHeight + + const blades: BufferGeometry[] = [] + for (let i = 0; i < spec.blades; i++) { + const bh = h * (0.6 + rng() * 0.6) + const blade = new ConeGeometry(0.02, bh, 3) + blade.scale(1, 1, 0.3) // flatten the cone into a blade + blade.translate(0, bh / 2, 0) + blade.rotateZ((rng() - 0.5) * 0.7) // lean + const angle = rng() * Math.PI * 2 + blade.rotateY(angle) + const r = rng() * 0.07 + blade.translate(Math.cos(angle) * r, 0, Math.sin(angle) * r) + blades.push(blade) + } + group.add(new Mesh(mergeGeometries(blades, false) ?? blades[0], mat)) + return group +} diff --git a/packages/plugin-trees/src/grass-parametrics.ts b/packages/plugin-trees/src/grass-parametrics.ts new file mode 100644 index 00000000..8b8b3ece --- /dev/null +++ b/packages/plugin-trees/src/grass-parametrics.ts @@ -0,0 +1,22 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { GrassNode } from './grass-schema' + +/** Inspector for a placed grass tuft — rendered for free by the host's + * `ParametricInspector` from this descriptor. */ +export const grassParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Grass', + fields: [ + { key: 'preset', kind: 'enum', options: ['meadow', 'fescue', 'reed'] }, + { key: 'height', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.05 }, + { key: 'bladeColor', kind: 'color' }, + { key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], +} diff --git a/packages/plugin-trees/src/grass-presets.ts b/packages/plugin-trees/src/grass-presets.ts new file mode 100644 index 00000000..cf5b6c0d --- /dev/null +++ b/packages/plugin-trees/src/grass-presets.ts @@ -0,0 +1,50 @@ +import { GRASS_ART } from './art' +import type { GrassPreset } from './grass-schema' + +/** Per-grass config: blade colour, blade count per tuft, default height (metres), + * a swatch, and a replaceable card `thumbnail` (see `thumbnails.ts`). Pure data + * shared by the geometry builder and the panel. */ +export type GrassPresetSpec = { + id: GrassPreset + label: string + bladeColor: string + blades: number + defaultHeight: number + swatch: string + thumbnail: string +} + +export const GRASS_PRESETS: Record = { + meadow: { + id: 'meadow', + label: 'Meadow', + bladeColor: '#5a8f3c', + blades: 10, + defaultHeight: 0.4, + swatch: '#5a8f3c', + thumbnail: GRASS_ART.meadow, + }, + fescue: { + id: 'fescue', + label: 'Fescue', + bladeColor: '#7fae55', + blades: 8, + defaultHeight: 0.7, + swatch: '#7fae55', + thumbnail: GRASS_ART.fescue, + }, + reed: { + id: 'reed', + label: 'Reed', + bladeColor: '#4a7d63', + blades: 6, + defaultHeight: 1.1, + swatch: '#4a7d63', + thumbnail: GRASS_ART.reed, + }, +} + +export const GRASS_PRESET_LIST: GrassPresetSpec[] = Object.values(GRASS_PRESETS) + +/** Bounded seed pool so grass tufts share instancing variants (see trees). */ +export const GRASS_SEED_POOL = [1, 7, 13, 21, 34] diff --git a/packages/plugin-trees/src/grass-preview.tsx b/packages/plugin-trees/src/grass-preview.tsx new file mode 100644 index 00000000..fece97ec --- /dev/null +++ b/packages/plugin-trees/src/grass-preview.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useEffect, useMemo } from 'react' +import type { Material, MeshStandardMaterial } from 'three' +import { getGrassVariant } from './grass-geometry' +import type { GrassNode } from './grass-schema' + +const NO_RAYCAST = () => {} + +/** Translucent placement ghost for a grass tuft — clones the variant materials + * so the cursor preview is see-through without mutating the cached originals. */ +export default function GrassPreview({ node }: { node: GrassNode }) { + const data = useMemo(() => getGrassVariant(node), [node]) + const scale = node.height / data.naturalHeight + + const ghosts = useMemo( + () => + data.subMeshes.map((sub) => { + const base = ( + Array.isArray(sub.material) ? sub.material[0] : sub.material + ) as MeshStandardMaterial + const clone = base.clone() + clone.transparent = true + clone.opacity = 0.55 + clone.depthWrite = false + return clone + }), + [data], + ) + + useEffect( + () => () => { + for (const m of ghosts as Material[]) m.dispose() + }, + [ghosts], + ) + + return ( + + {data.subMeshes.map((sub, i) => ( + + ))} + + ) +} diff --git a/packages/plugin-trees/src/grass-proxy-renderer.tsx b/packages/plugin-trees/src/grass-proxy-renderer.tsx new file mode 100644 index 00000000..1fa1c5ba --- /dev/null +++ b/packages/plugin-trees/src/grass-proxy-renderer.tsx @@ -0,0 +1,13 @@ +'use client' + +import { getGrassVariant } from './grass-geometry' +import type { GrassNode } from './grass-schema' +import { KindProxy } from './instanced' + +const getVariant = (node: GrassNode) => getGrassVariant(node) +const colliderRadius = (node: GrassNode) => Math.max(0.08, (node.height ?? 0.4) * 0.3) + +/** Per-node selection proxy for the instanced grass tufts. */ +export default function GrassProxyRenderer({ node }: { node: GrassNode }) { + return +} diff --git a/packages/plugin-trees/src/grass-schema.ts b/packages/plugin-trees/src/grass-schema.ts new file mode 100644 index 00000000..e6cf9ee5 --- /dev/null +++ b/packages/plugin-trees/src/grass-schema.ts @@ -0,0 +1,23 @@ +import { BaseNode, nodeType, objectId } from '@pascal-app/core' +import { z } from 'zod' + +/** Grass tufts the plugin can place. The string persists in scene JSON. */ +export const GrassPreset = z.enum(['meadow', 'fescue', 'reed']) +export type GrassPreset = z.infer + +/** A placed grass tuft — a third instanced kind alongside trees & flowers, + * sharing the same instanced renderer + selection proxy via `instanced`. */ +export const GrassNode = BaseNode.extend({ + id: objectId('grass'), + type: nodeType('trees:grass'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + preset: GrassPreset.default('meadow'), + height: z.number().positive().default(0.4), + seed: z.number().int().default(1), + /** Blade colour (hex). Baked from the preset at placement; recolour per-tuft + * in the inspector. */ + bladeColor: z.string().default('#5a8f3c'), +}) + +export type GrassNode = z.infer diff --git a/packages/plugin-trees/src/grass-static-renderer.tsx b/packages/plugin-trees/src/grass-static-renderer.tsx new file mode 100644 index 00000000..0962aa26 --- /dev/null +++ b/packages/plugin-trees/src/grass-static-renderer.tsx @@ -0,0 +1,15 @@ +'use client' + +import { getGrassVariant, grassVariantKey } from './grass-geometry' +import type { GrassNode } from './grass-schema' +import { InstancedNodes } from './instanced' + +const variantKeyOf = (node: GrassNode) => grassVariantKey(node.preset, node.seed, node.bladeColor) +const getVariant = (node: GrassNode) => getGrassVariant(node) + +/** Collective baked-`/viewer` renderer for one level's grass (`bakeReplaceRenderer`). */ +export default function GrassReplaceInstances({ nodes }: { nodes: GrassNode[] }) { + return ( + + ) +} diff --git a/packages/plugin-trees/src/grass-system.tsx b/packages/plugin-trees/src/grass-system.tsx new file mode 100644 index 00000000..918885ee --- /dev/null +++ b/packages/plugin-trees/src/grass-system.tsx @@ -0,0 +1,19 @@ +'use client' + +import { getGrassVariant, grassVariantKey } from './grass-geometry' +import type { GrassNode } from './grass-schema' +import { InstancedKindSystem } from './instanced' + +const variantKeyOf = (node: GrassNode) => grassVariantKey(node.preset, node.seed, node.bladeColor) +const getVariant = (node: GrassNode) => getGrassVariant(node) + +/** Collective instanced renderer for every placed grass tuft (`def.system`). */ +export default function GrassSystem() { + return ( + + getVariant={getVariant} + kind="trees:grass" + variantKeyOf={variantKeyOf} + /> + ) +} diff --git a/packages/plugin-trees/src/grass-tool.tsx b/packages/plugin-trees/src/grass-tool.tsx new file mode 100644 index 00000000..f04f8669 --- /dev/null +++ b/packages/plugin-trees/src/grass-tool.tsx @@ -0,0 +1,57 @@ +'use client' + +import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useMemo } from 'react' +import { GRASS_PRESETS, GRASS_SEED_POOL } from './grass-presets' +import GrassPreview from './grass-preview' +import { GrassNode } from './grass-schema' +import { usePlacement } from './placement' +import { useTreesStore } from './store' + +/** The grass placement tool — mirrors the trees/flowers tools, reading the grass + * brush from the shared store. Blade colour is baked from the preset at + * placement, then editable per-tuft in the inspector. */ +export default function GrassTool() { + const activeLevelId = useViewer((s) => s.selection.levelId) + const preset = useTreesStore((s) => s.grassPreset) + const height = useTreesStore((s) => s.grassHeight) + + const previewNode = useMemo( + () => + GrassNode.parse({ + preset, + height, + bladeColor: GRASS_PRESETS[preset].bladeColor, + seed: 1, + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + [preset, height], + ) + + const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => { + if (!activeLevelId) return + const s = useTreesStore.getState() + const grass = GrassNode.parse({ + preset: s.grassPreset, + height: s.grassHeight, + bladeColor: GRASS_PRESETS[s.grassPreset].bladeColor, + seed: GRASS_SEED_POOL[Math.floor(Math.random() * GRASS_SEED_POOL.length)] ?? 1, + position, + rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0], + }) + useScene.getState().createNode(grass as unknown as AnyNode, activeLevelId as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [grass.id as AnyNodeId] }) + triggerSFX('sfx:item-place') + }) + + if (!activeLevelId) return null + + return ( + + + + ) +} diff --git a/packages/plugin-trees/src/index.ts b/packages/plugin-trees/src/index.ts new file mode 100644 index 00000000..07a6f3a9 --- /dev/null +++ b/packages/plugin-trees/src/index.ts @@ -0,0 +1,43 @@ +import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' +// Side-effect: subscribes the panel store to `selection:find-node` so the +// host's "find in catalog" lands on the right Nature section (see find-sync.ts). +import './find-sync' +import { NATURE_ICON } from './art' +import { treeDefinition } from './definition' +import { flowerDefinition } from './flower-definition' +import { grassDefinition } from './grass-definition' + +/** + * The trees plugin manifest — the entire public surface of this package. A host + * loads it through the same `loadPlugin` path the built-ins use: three node kinds + * (`trees:tree`, `trees:flower`, `trees:grass`) and one left-rail panel + * (`Trees`). Cast mirrors the built-in bundle: `AnyNodeDefinition` is the + * hand-maintained union today; the registry derives it post-migration. + */ +export const treesPlugin: Plugin = { + id: 'pascal:trees', + apiVersion: 1, + nodes: [ + treeDefinition as unknown as AnyNodeDefinition, + flowerDefinition as unknown as AnyNodeDefinition, + grassDefinition as unknown as AnyNodeDefinition, + ], + panels: [ + { + id: 'trees', + label: 'Nature', + icon: { kind: 'url', src: NATURE_ICON }, + component: () => import('./presets-panel'), + }, + ], +} + +// NOTE: no re-export from './geometry' — it imports ez-tree, which touches +// `document` at module scope and would crash SSR (this barrel is eagerly +// imported by host bootstraps). Lazy client modules import it directly. +export { treeDefinition } from './definition' +export { flowerDefinition } from './flower-definition' +export { FlowerNode, FlowerPreset } from './flower-schema' +export { grassDefinition } from './grass-definition' +export { GrassNode, GrassPreset } from './grass-schema' +export { TreeNode, TreePreset } from './schema' diff --git a/packages/plugin-trees/src/instanced.tsx b/packages/plugin-trees/src/instanced.tsx new file mode 100644 index 00000000..511bdf2e --- /dev/null +++ b/packages/plugin-trees/src/instanced.tsx @@ -0,0 +1,308 @@ +'use client' + +import { + type AnyNodeId, + sceneRegistry, + useLiveNodeOverrides, + useLiveTransforms, + useRegistry, + useScene, +} from '@pascal-app/core' +import { useNodeEvents, useViewer } from '@pascal-app/viewer' +import { useLayoutEffect, useMemo, useRef } from 'react' +import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three' +import { toStaticMaterial } from './wind-node' + +/** + * Generic instanced-rendering core shared by every plant kind (trees, flowers, + * …). A kind plugs in two pure functions — `variantKeyOf` (how to bucket nodes + * that can share geometry) and `getVariant` (cached geometry for a node) — and + * gets forest-scale instancing plus true-silhouette selection for free. + */ + +export type SubMesh = { geometry: BufferGeometry; material: Material | Material[] } +export type VariantData = { subMeshes: SubMesh[]; naturalHeight: number } + +/** The shape every placeable plant node shares. */ +export interface Placeable { + id: string + type: string + parentId: string | null + position: [number, number, number] + rotation: [number, number, number] + height: number + visible?: boolean +} + +const DUMMY = new Object3D() +const INSTANCE_MATRIX = new Matrix4() +const NO_RAYCAST = () => {} + +// Wind is a TSL vertex bend baked into the variant materials (see `wind-node.ts`) +// — animated on the GPU, so the instance matrices here stay static. + +// ── Collective instanced renderer (a `def.system`) ─────────────────────────── + +export function InstancedKindSystem({ + kind, + variantKeyOf, + getVariant, +}: { + kind: string + variantKeyOf: (node: N) => string + getVariant: (node: N) => VariantData +}) { + const scene = useScene((s) => s.nodes) + const hoveredId = useViewer((s) => s.hoveredId) + const selectedIds = useViewer((s) => s.selection.selectedIds) + // Hovered/selected plants render through their proxy instead (real geometry, + // static materials) so the outline matches the visible mesh and a move drag + // animates in realtime — skip them here to avoid a double draw. Keyed by the + // *relevant* ids only, so hovering unrelated kinds doesn't churn matrices. + const activeKey = useMemo(() => { + const ids: string[] = [] + if (hoveredId && (scene[hoveredId as AnyNodeId]?.type as string) === kind) ids.push(hoveredId) + for (const id of selectedIds) { + if ((scene[id as AnyNodeId]?.type as string) === kind) ids.push(id) + } + return ids.sort().join('|') + }, [scene, kind, hoveredId, selectedIds]) + const nodes = useMemo(() => { + const active = new Set(activeKey ? activeKey.split('|') : []) + return Object.values(scene).filter( + (n) => (n.type as string) === kind && !active.has(n.id as string), + ) as unknown as N[] + }, [scene, kind, activeKey]) + return +} + +/** + * Instance a given set of nodes, bucketed by geometry variant. Two callers: + * - `InstancedKindSystem` (editor `def.system`) passes every node of a kind with + * `localSpace={false}` — instances live at the scene root, so each matrix folds + * in the parent level's world matrix (positions are stored level-local). + * - the baked `/viewer` (`bakeReplaceRenderer`) passes one level's nodes with + * `localSpace` — the meshes are portaled into that baked level (which supplies + * the level transform), so instance matrices stay level-local, and the meshes + * are `NO_RAYCAST` (scenery; a pick would resolve to the level anyway). + * + * Instancing carries the per-tree wind phase for free via `instanceIndex`; a + * per-node render (one mesh each) would give every tree phase 0 → a whole + * variant sways in unison. + */ +export function InstancedNodes({ + nodes, + variantKeyOf, + getVariant, + localSpace = false, +}: { + nodes: N[] + variantKeyOf: (node: N) => string + getVariant: (node: N) => VariantData + localSpace?: boolean +}) { + const buckets = useMemo(() => { + const map = new Map() + for (const node of nodes) { + const key = variantKeyOf(node) + const bucket = map.get(key) + if (bucket) bucket.nodes.push(node) + else map.set(key, { sample: node, nodes: [node] }) + } + return Array.from(map, ([key, value]) => ({ key, ...value })) + }, [nodes, variantKeyOf]) + + return ( + <> + {buckets.map((bucket) => ( + + ))} + + ) +} + +function Variant({ + sample, + nodes, + getVariant, + localSpace, +}: { + sample: N + nodes: N[] + getVariant: (node: N) => VariantData + localSpace: boolean +}) { + const data = useMemo(() => getVariant(sample), [sample, getVariant]) + return ( + <> + {data.subMeshes.map((subMesh, i) => ( + + ))} + + ) +} + +function InstancedSubMesh({ + subMesh, + nodes, + naturalHeight, + localSpace, +}: { + subMesh: SubMesh + nodes: N[] + naturalHeight: number + localSpace: boolean +}) { + const ref = useRef(null) + // Round capacity up so the InstancedMesh isn't recreated on every placement — + // only when crossing a 32-instance boundary. `dispose={null}` keeps the shared + // (cached) geometry/material alive across any recreation. + const capacity = Math.max(16, Math.ceil(nodes.length / 32) * 32) + + useLayoutEffect(() => { + const mesh = ref.current + if (!mesh) return + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i] + if (!node) continue + const scale = node.height / naturalHeight + DUMMY.position.set(node.position[0], node.position[1], node.position[2]) + DUMMY.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2]) + DUMMY.scale.set(scale, scale, scale) + DUMMY.updateMatrix() + // `localSpace`: portaled into the parent level, which supplies the level + // transform — matrices stay level-local. Otherwise instances live at the + // scene root, so fold in the parent level's world matrix. + const parent = + !localSpace && node.parentId ? sceneRegistry.nodes.get(node.parentId) : undefined + if (parent) { + parent.updateWorldMatrix(true, false) + INSTANCE_MATRIX.multiplyMatrices(parent.matrixWorld, DUMMY.matrix) + mesh.setMatrixAt(i, INSTANCE_MATRIX) + } else { + mesh.setMatrixAt(i, DUMMY.matrix) + } + } + mesh.count = nodes.length + mesh.instanceMatrix.needsUpdate = true + mesh.computeBoundingSphere() + }, [nodes, naturalHeight, localSpace]) + + return ( + + ) +} + +// ── Per-node selection proxy (a `def.renderer`) ────────────────────────────── + +const toStatic = (material: Material | Material[]) => + Array.isArray(material) ? material.map(toStaticMaterial) : toStaticMaterial(material) + +/** + * Per-node proxy that keeps the host's selection machinery working for an + * instanced kind. The registered group carries the node transform (host + * contract: move tools drive `sceneRegistry.nodes.get(id)` imperatively with + * absolute level-local positions and mirror them via `useLiveTransforms` — + * see `ParametricNodeRenderer`), so registering a nested child would apply + * drag deltas in the node's rotated frame. The box collider is a positioned + * sibling: the raycast target, kept out of the registered group so the + * outline pass (which traces the registered object) shows the true + * silhouette, not a box. + * + * While hovered/selected the collective system skips this node and the proxy + * mounts the real geometry with **static twins** of the wind materials — the + * outline mask renders with an override material and can't follow GPU sway, + * so the plant holds still while outlined and the silhouette matches exactly. + * During a GLB export the geometry mounts with the real materials instead, so + * the exporter (which clones only the `scene-renderer` subtree, not the + * collective InstancedMesh) captures each plant; the collider is dropped so it + * doesn't bake as a phantom solid. + */ +export function KindProxy({ + node, + getVariant, + colliderRadius, +}: { + node: N + getVariant: (node: N) => VariantData + colliderRadius: (node: N) => number +}) { + const registeredRef = useRef(null!) + const handlers = useNodeEvents(node as never, node.type as never) + useRegistry(node.id as AnyNodeId, node.type, registeredRef) + + const isExporting = useViewer((s) => s.isExporting) + const active = useViewer( + (s) => s.hoveredId === node.id || s.selection.selectedIds.includes(node.id as never), + ) + const showGeometry = active || isExporting + + // Live drag transform — the move tool writes the same absolute position + // imperatively to the registered group; applying it React-side too keeps the + // two in agreement (and moves the collider along with the drag). The rotate / + // resize gizmos publish through `useLiveNodeOverrides` instead — fold that in + // too (mirrors ParametricNodeRenderer) so the plant turns live mid-drag + // rather than snapping on commit. + const live = useLiveTransforms((s) => s.get(node.id)) + const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(node.id)) + const overridePosition = liveOverride?.position as [number, number, number] | undefined + const overrideRotation = liveOverride?.rotation as [number, number, number] | undefined + const position = live?.position ?? overridePosition ?? node.position ?? [0, 0, 0] + const baseRotation = overrideRotation ?? node.rotation ?? [0, 0, 0] + const rotation: [number, number, number] = live + ? [baseRotation[0], live.rotation, baseRotation[2]] + : baseRotation + + const height = Math.max(0.2, node.height ?? 1) + const radius = colliderRadius(node) + const variant = useMemo( + () => (showGeometry ? getVariant(node) : null), + [showGeometry, node, getVariant], + ) + const geometryScale = variant ? height / variant.naturalHeight : 1 + + return ( + + {!isExporting && ( + + + + + )} + + {variant && ( + + {variant.subMeshes.map((subMesh, i) => ( + + ))} + + )} + + + ) +} diff --git a/packages/plugin-trees/src/parametrics.ts b/packages/plugin-trees/src/parametrics.ts new file mode 100644 index 00000000..75ebc27c --- /dev/null +++ b/packages/plugin-trees/src/parametrics.ts @@ -0,0 +1,91 @@ +import { type AnyNodeId, type ParametricDescriptor, useScene } from '@pascal-app/core' +import { defaultHeightOf, TREE_SEED_POOL } from './presets' +import type { TreeNode } from './schema' + +/** + * The tree's right-hand inspector. This descriptor is the entire inspector — + * the host's `ParametricInspector` renders every control (selects, sliders, + * segmented switches, the native colour pickers, the vec3, and the Randomize + * action) with zero tree-specific code in the editor. Demonstrates the "right + * inspector comes free from `def.parametrics`" leg of the plugin surface. + * + * Colours (`leafColor`/`branchColor`) are edit-only — they're not on the + * placement brush, so a planted tree starts neutral (texture colours) and is + * tinted here per-tree. + */ +export const treeParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Tree', + fields: [ + { + key: 'preset', + kind: 'enum', + options: ['oak', 'pine', 'aspen', 'ash', 'bush', 'trellis'], + }, + { + key: 'size', + kind: 'enum', + options: ['small', 'medium', 'large'], + display: 'segmented', + visibleIf: (n) => n.preset !== 'trellis', + }, + { + key: 'treeType', + kind: 'enum', + options: ['deciduous', 'evergreen'], + display: 'segmented', + }, + { key: 'height', kind: 'number', unit: 'm', min: 1, max: 15, step: 0.5 }, + { key: 'branchColor', kind: 'color' }, + { key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 }, + ], + }, + { + label: 'Foliage', + fields: [ + { key: 'leafless', kind: 'boolean' }, + { + key: 'foliageDensity', + kind: 'number', + min: 0, + max: 1.5, + step: 0.1, + visibleIf: (n) => !n.leafless, + }, + { key: 'trunkThickness', kind: 'number', min: 0.3, max: 2.5, step: 0.1 }, + { key: 'leafColor', kind: 'color', visibleIf: (n) => !n.leafless }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], + actions: [ + { + label: 'Randomize', + // The action receives the live node; writing a new seed re-generates the + // tree. Pick from the bounded pool so the result stays an instancing + // variant shared with other trees, not a one-off mesh. + onClick: (n) => + useScene.getState().updateNode( + n.id as AnyNodeId, + { + seed: TREE_SEED_POOL[Math.floor(Math.random() * TREE_SEED_POOL.length)] ?? 1, + } as Partial as never, + ), + }, + { + label: 'Reset height', + // Snap height back to the preset+size default (handy after changing size). + onClick: (n) => + useScene + .getState() + .updateNode( + n.id as AnyNodeId, + { height: defaultHeightOf(n.preset, n.size) } as Partial as never, + ), + }, + ], +} diff --git a/packages/plugin-trees/src/placement.tsx b/packages/plugin-trees/src/placement.tsx new file mode 100644 index 00000000..fc64fd52 --- /dev/null +++ b/packages/plugin-trees/src/placement.tsx @@ -0,0 +1,81 @@ +'use client' + +import { emitter, type GridEvent, sceneRegistry, snapPointToGrid } from '@pascal-app/core' +import { isGridSnapActive, useEditor } from '@pascal-app/editor' +import { useEffect, useRef, useState } from 'react' +import { type Group, Vector3 } from 'three' + +const worldVec = new Vector3() + +/** Snap a planar position to the grid when grid snapping is the active mode — + * reading the same `isGridSnapActive()` toggle + `gridSnapStep` the built-in + * item/shelf tools use, so plants honour the snap mode like every other item. */ +export function snapXZ(x: number, z: number): readonly [number, number] { + if (!isGridSnapActive()) return [x, z] + return snapPointToGrid([x, z], useEditor.getState().gridSnapStep) +} + +/** + * Convert a world-space grid hit into the active level's local frame, the way + * the host stores node positions. Re-derived from the public `sceneRegistry` + * because the built-in `floor-placement` helpers aren't part of the public + * `@pascal-app/*` surface yet — a candidate for a future `@pascal-app/plugin-api`. + */ +export function toLevelLocal( + levelId: string, + world: [number, number, number], +): [number, number, number] { + const levelObject = sceneRegistry.nodes.get(levelId) + if (!levelObject) return [world[0], 0, world[2]] + worldVec.set(world[0], world[1], world[2]) + levelObject.updateWorldMatrix(true, false) + levelObject.worldToLocal(worldVec) + return [worldVec.x, 0, worldVec.z] +} + +/** + * Shared placement wiring for any plant tool: ghosts a preview at the snapped + * cursor on `grid:move`, and calls `onCommit` with the snapped level-local + * position on `grid:click`. Returns the cursor group ref + visibility for the + * tool to attach its preview to. `onCommit` is read through a ref so a tool can + * close over live brush state without re-subscribing every render. + */ +export function usePlacement( + activeLevelId: string | null, + onCommit: (levelLocalPosition: [number, number, number]) => void, +) { + const cursorRef = useRef(null) + const [cursorVisible, setCursorVisible] = useState(false) + const commitRef = useRef(onCommit) + commitRef.current = onCommit + + useEffect(() => { + if (!activeLevelId) return + setCursorVisible(false) + let lastWorld: [number, number, number] | null = null + + const onMove = (event: GridEvent) => { + setCursorVisible(true) + const [lx, , lz] = event.localPosition + const [sx, sz] = snapXZ(lx, lz) + cursorRef.current?.position.set(sx, 0, sz) + lastWorld = event.position + } + + const onClick = (event: GridEvent) => { + const world = lastWorld ?? event.position + const [lx, , lz] = toLevelLocal(activeLevelId, world) + const [sx, sz] = snapXZ(lx, lz) + commitRef.current([sx, 0, sz]) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + } + }, [activeLevelId]) + + return { cursorRef, cursorVisible } +} diff --git a/packages/plugin-trees/src/presets-panel.tsx b/packages/plugin-trees/src/presets-panel.tsx new file mode 100644 index 00000000..b095d43c --- /dev/null +++ b/packages/plugin-trees/src/presets-panel.tsx @@ -0,0 +1,267 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { SegmentedControl, SliderControl, ToggleControl, useEditor } from '@pascal-app/editor' +import { FLOWER_PRESET_LIST } from './flower-presets' +import type { FlowerPreset } from './flower-schema' +import { GRASS_PRESET_LIST } from './grass-presets' +import type { GrassPreset } from './grass-schema' +import { TREE_PRESET_LIST } from './presets' +import type { TreePreset } from './schema' +import { type TreesPanelMode as Mode, useTreesStore } from './store' + +const KIND: Record = { + trees: 'trees:tree', + flowers: 'trees:flower', + grass: 'trees:grass', +} +const NOUN: Record = { trees: 'tree', flowers: 'flower', grass: 'grass' } + +/** + * The plugin's left-rail panel. A Trees / Flowers / Grass segmented control + * switches the brush; picking a preset arms placement for that kind + * (`setTool('trees:*')` + build mode). The count chip reads the scene reactively, + * closing the triangle: panel → store → tool → scene → panel. It composes the + * host's exported controls (`SegmentedControl`/`SliderControl`/`ToggleControl`) + * so the brush matches the right-hand inspector pixel-for-pixel. + */ +export default function TreesPanel() { + // Section lives in the plugin store (not local state) so "find in catalog" + // can point the panel at the found node's section — see find-sync.ts. + const mode = useTreesStore((s) => s.mode) + const setMode = useTreesStore((s) => s.setMode) + const activeTool = useEditor((s) => s.tool) + const count = useScene( + (s) => Object.values(s.nodes).filter((n) => (n.type as string) === KIND[mode]).length, + ) + + const arming = activeTool === KIND[mode] + + return ( +
+
+
+

Nature

+ + {count} planted + +
+ +

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

+
+ + {mode === 'trees' && } + {mode === 'flowers' && } + {mode === 'grass' && } + + +
+ ) +} + +function TreesSection({ arming }: { arming: boolean }) { + const selected = useTreesStore((s) => s.preset) + const size = useTreesStore((s) => s.size) + const height = useTreesStore((s) => s.height) + const foliageDensity = useTreesStore((s) => s.foliageDensity) + const trunkThickness = useTreesStore((s) => s.trunkThickness) + const leafless = useTreesStore((s) => s.leafless) + + const activate = (preset: TreePreset) => { + useTreesStore.getState().setPreset(preset) + useEditor.getState().setTool('trees:tree') + useEditor.getState().setMode('build') + } + + return ( + <> + + {selected !== 'trellis' && ( +
+ +
+ )} +
+ + {!leafless && ( + + )} + + +
+ + ) +} + +function FlowersSection({ arming }: { arming: boolean }) { + const selected = useTreesStore((s) => s.flowerPreset) + const height = useTreesStore((s) => s.flowerHeight) + + const activate = (preset: FlowerPreset) => { + useTreesStore.getState().setFlowerPreset(preset) + useEditor.getState().setTool('trees:flower') + useEditor.getState().setMode('build') + } + + return ( + <> + + + + ) +} + +function GrassSection({ arming }: { arming: boolean }) { + const selected = useTreesStore((s) => s.grassPreset) + const height = useTreesStore((s) => s.grassHeight) + + const activate = (preset: GrassPreset) => { + useTreesStore.getState().setGrassPreset(preset) + useEditor.getState().setTool('trees:grass') + useEditor.getState().setMode('build') + } + + return ( + <> + + + + ) +} + +function PresetGrid({ + items, + selected, + onPick, +}: { + items: ReadonlyArray<{ id: T; label: string; thumbnail: string }> + selected: T | null + onPick: (id: T) => void +}) { + return ( +
+ {items.map((item) => { + const isSelected = selected === item.id + return ( + + ) + })} +
+ ) +} diff --git a/packages/plugin-trees/src/presets.ts b/packages/plugin-trees/src/presets.ts new file mode 100644 index 00000000..a43fe588 --- /dev/null +++ b/packages/plugin-trees/src/presets.ts @@ -0,0 +1,111 @@ +import { TREE_ART } from './art' +import type { TreePreset, TreeSize } from './schema' + +/** + * Per-species config: the ez-tree preset name for each size, a default placement + * height per size (metres), a swatch colour, and a card `thumbnail` (a + * replaceable placeholder image — see `thumbnails.ts`). Pure data — no three.js, + * no React — shared by the panel grid and the instanced renderer so they stay in + * lockstep. `ez[size]` is the exact ez-tree preset name passed to + * `tree.loadPreset(...)`, exposing all of ez-tree's built-in presets through a + * clean species × size model. `trellis` has a single preset (size ignored). + */ +export type TreePresetSpec = { + id: TreePreset + label: string + /** ez-tree preset name keyed by size. */ + ez: Record + /** Default placement height keyed by size. */ + height: Record + /** Whether the size control applies (false for `trellis`). */ + sized: boolean + swatch: string + thumbnail: string +} + +function ezSizes(family: string): Record { + return { small: `${family} Small`, medium: `${family} Medium`, large: `${family} Large` } +} + +export const TREE_PRESETS: Record = { + oak: { + id: 'oak', + label: 'Oak', + ez: ezSizes('Oak'), + height: { small: 5, medium: 7, large: 11 }, + sized: true, + swatch: '#4f7942', + thumbnail: TREE_ART.oak, + }, + pine: { + id: 'pine', + label: 'Pine', + ez: ezSizes('Pine'), + height: { small: 6, medium: 9, large: 14 }, + sized: true, + swatch: '#2f5d3a', + thumbnail: TREE_ART.pine, + }, + aspen: { + id: 'aspen', + label: 'Aspen', + ez: ezSizes('Aspen'), + height: { small: 5, medium: 8, large: 12 }, + sized: true, + swatch: '#8fae5d', + thumbnail: TREE_ART.aspen, + }, + ash: { + id: 'ash', + label: 'Ash', + ez: ezSizes('Ash'), + height: { small: 5, medium: 8, large: 12 }, + sized: true, + swatch: '#6f9457', + thumbnail: TREE_ART.ash, + }, + bush: { + id: 'bush', + label: 'Bush', + ez: { small: 'Bush 1', medium: 'Bush 2', large: 'Bush 3' }, + height: { small: 1.2, medium: 1.5, large: 1.8 }, + sized: true, + swatch: '#5c8a4a', + thumbnail: TREE_ART.bush, + }, + trellis: { + id: 'trellis', + label: 'Trellis', + ez: { small: 'Trellis', medium: 'Trellis', large: 'Trellis' }, + height: { small: 3, medium: 3, large: 3 }, + sized: false, + swatch: '#8b6b45', + thumbnail: TREE_ART.trellis, + }, +} + +export const TREE_PRESET_LIST: TreePresetSpec[] = Object.values(TREE_PRESETS) + +/** The ez-tree preset name for a species + size (size ignored for `trellis`). */ +export function ezPresetOf(preset: TreePreset, size: TreeSize): string { + return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).ez[size] +} + +/** Default placement height for a species + size. */ +export function defaultHeightOf(preset: TreePreset, size: TreeSize): number { + return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).height[size] +} + +/** + * Bounded seed pool. The placement tool and the Randomize action pick from + * this set so trees share geometry variants — that sharing is what makes + * instancing pay off. A power user can still type an arbitrary seed in the + * inspector; that tree just renders as its own single-instance variant. + */ +export const TREE_SEED_POOL = [1, 7, 13, 21, 34, 55, 89, 144] + +/** Pick a seed from the pool, varied by an index so it stays deterministic + * (no Math.random in schema-importable code paths). */ +export function seedFromPool(index: number): number { + return TREE_SEED_POOL[Math.abs(index) % TREE_SEED_POOL.length] ?? 1 +} diff --git a/packages/plugin-trees/src/preview.tsx b/packages/plugin-trees/src/preview.tsx new file mode 100644 index 00000000..7992d9d2 --- /dev/null +++ b/packages/plugin-trees/src/preview.tsx @@ -0,0 +1,44 @@ +'use client' + +import { useEffect, useMemo } from 'react' +import type { Material } from 'three' +import { generateTree, treeSpecOf } from './geometry' +import type { TreeNode } from './schema' +import { naturalHeight } from './variant-utils' + +/** + * Translucent placement ghost — a single ez-tree (not instanced) scaled to the + * node's height, following the cursor. Clones each material for the see-through + * look and disables raycast so the ghost never intercepts the cursor ray (which + * would freeze `grid:move`). + */ +export default function TreePreview({ node }: { node: TreeNode }) { + const built = useMemo(() => { + const tree = generateTree(treeSpecOf(node)) + tree.scale.setScalar(node.height / naturalHeight(tree)) + return tree + }, [node]) + + useEffect(() => { + const cloned: Material[] = [] + built.traverse((obj) => { + ;(obj as unknown as { raycast: () => void }).raycast = () => {} + const mesh = obj as { material?: Material | Material[] } + if (!mesh.material) return + const ghost = (mat: Material): Material => { + const c = mat.clone() + c.transparent = true + c.opacity = 0.5 + c.depthWrite = false + cloned.push(c) + return c + } + mesh.material = Array.isArray(mesh.material) ? mesh.material.map(ghost) : ghost(mesh.material) + }) + return () => { + for (const c of cloned) c.dispose() + } + }, [built]) + + return +} diff --git a/packages/plugin-trees/src/proxy-renderer.tsx b/packages/plugin-trees/src/proxy-renderer.tsx new file mode 100644 index 00000000..18f8f9c0 --- /dev/null +++ b/packages/plugin-trees/src/proxy-renderer.tsx @@ -0,0 +1,16 @@ +'use client' + +import { getVariantData, treeSpecOf } from './geometry' +import { KindProxy } from './instanced' +import type { TreeNode } from './schema' + +const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node)) +const colliderRadius = (node: TreeNode) => Math.max(0.4, (node.height ?? 5) * 0.18) + +/** + * Per-node selection proxy for the instanced trees — a thin binding of the + * generic {@link KindProxy} to this kind's geometry + collider size. + */ +export default function TreeProxyRenderer({ node }: { node: TreeNode }) { + return +} diff --git a/packages/plugin-trees/src/schema.ts b/packages/plugin-trees/src/schema.ts new file mode 100644 index 00000000..bf09708b --- /dev/null +++ b/packages/plugin-trees/src/schema.ts @@ -0,0 +1,57 @@ +import { BaseNode, nodeType, objectId } from '@pascal-app/core' +import { z } from 'zod' + +/** Tree species the plugin can place, each backed by an ez-tree preset family. + * The string persists in scene JSON. */ +export const TreePreset = z.enum(['oak', 'pine', 'aspen', 'ash', 'bush', 'trellis']) +export type TreePreset = z.infer + +/** Preset size variant. Maps to ez-tree's Small/Medium/Large presets (and + * Bush 1/2/3); ignored for `trellis`, which has a single preset. */ +export const TreeSize = z.enum(['small', 'medium', 'large']) +export type TreeSize = z.infer + +/** ez-tree's two growth models — deciduous (spreading) vs evergreen (conical). */ +export const TreeType = z.enum(['deciduous', 'evergreen']) +export type TreeType = z.infer + +/** + * Schema for a placed tree. Composed from the public `BaseNode` exactly the way + * built-in node kinds are — `objectId`/`nodeType` come from `@pascal-app/core`, + * so a plugin needs no private host internals to mint a persistable node. + * + * `type` is the namespaced kind `trees:tree`. Every geometry-relevant field + * (preset/size/treeType/seed/foliage/trunk/leafless/leafColor/branchColor) is + * folded into the instancing variant key; `height`/`position`/`rotation` are + * cheap per-instance transforms. + */ +export const TreeNode = BaseNode.extend({ + id: objectId('tree'), + type: nodeType('trees:tree'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + preset: TreePreset.default('oak'), + size: TreeSize.default('medium'), + // Overrides — all optional so an unset field inherits the ez-tree preset (its + // own seed/type/tints), exactly like flower `petalColor`. Placing a tree stores + // none of these, so a fresh tree is the pure preset; the inspector sets them. + /** Growth-model override. Unset ⇒ inherit the preset (oak → deciduous, pine → evergreen). */ + treeType: TreeType.optional(), + height: z.number().positive().default(7), + /** Geometry-seed override. Unset ⇒ the preset's own seed (its canonical silhouette); + * set (e.g. via Randomize) to vary the tree. */ + seed: z.number().int().optional(), + // Curated geometry params (folded into the instancing variant key): + /** Leaf-count multiplier vs the preset (1 = preset default). */ + foliageDensity: z.number().min(0).max(1.5).default(1), + /** Branch-radius multiplier (1 = preset default). */ + trunkThickness: z.number().min(0.3).max(2.5).default(1), + /** Strip all leaves — a bare winter silhouette. */ + leafless: z.boolean().default(false), + /** Leaf tint override (hex). Unset ⇒ the preset's leaf tint. */ + leafColor: z.string().optional(), + /** Bark/branch tint override (hex). Unset ⇒ the preset's bark tint. */ + branchColor: z.string().optional(), +}) + +export type TreeNode = z.infer diff --git a/packages/plugin-trees/src/static-renderer.tsx b/packages/plugin-trees/src/static-renderer.tsx new file mode 100644 index 00000000..36d65724 --- /dev/null +++ b/packages/plugin-trees/src/static-renderer.tsx @@ -0,0 +1,20 @@ +'use client' + +import { getVariantData, treeSpecOf, treeVariantKey } from './geometry' +import { InstancedNodes } from './instanced' +import type { TreeNode } from './schema' + +const variantKeyOf = (node: TreeNode) => treeVariantKey(treeSpecOf(node)) +const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node)) + +/** + * Collective renderer for the baked `/viewer` (`bakeReplaceRenderer`): one baked + * level's trees, instanced in level-local space (the viewer portals this into + * that level's `Object3D`). Same instancing as the editor `system`, so wind + * phase varies per tree via `instanceIndex` and a forest is a few draw calls. + */ +export default function TreeReplaceInstances({ nodes }: { nodes: TreeNode[] }) { + return ( + + ) +} diff --git a/packages/plugin-trees/src/store.ts b/packages/plugin-trees/src/store.ts new file mode 100644 index 00000000..f07e4c9a --- /dev/null +++ b/packages/plugin-trees/src/store.ts @@ -0,0 +1,80 @@ +import { create } from 'zustand' +import { FLOWER_PRESETS } from './flower-presets' +import type { FlowerPreset } from './flower-schema' +import { GRASS_PRESETS } from './grass-presets' +import type { GrassPreset } from './grass-schema' +import { defaultHeightOf, TREE_PRESETS } from './presets' +import type { TreePreset, TreeSize } from './schema' + +/** + * The plugin's own module-level state — the example of "plugins self-manage + * runtime state with module-level stores" from the plugin-authoring contract. + * It holds the placement "brush": what the next planted tree/flower looks like. + * The presets panel writes it; the placement tool reads it. No host lifecycle + * slot. Colours are intentionally absent — they're edit-only (inspector). + */ +/** Which section of the Nature panel is showing. */ +export type TreesPanelMode = 'trees' | 'flowers' | 'grass' + +type TreesStore = { + /** Active panel section — in the store (not panel-local state) so the host's + * "find in catalog" can land on the right section (see `find-sync.ts`). */ + mode: TreesPanelMode + setMode: (mode: TreesPanelMode) => void + preset: TreePreset + size: TreeSize + /** Height (m) of the next tree — a per-instance scale, never affects placed trees. */ + height: number + /** Leaf-count multiplier vs the preset (folded into the instancing variant). */ + foliageDensity: number + /** Branch-radius multiplier (folded into the instancing variant). */ + trunkThickness: number + /** Plant bare (leafless) trees. */ + leafless: boolean + setPreset: (preset: TreePreset) => void + setSize: (size: TreeSize) => void + setHeight: (height: number) => void + setFoliageDensity: (value: number) => void + setTrunkThickness: (value: number) => void + setLeafless: (value: boolean) => void + // Flower brush (sibling kind). + flowerPreset: FlowerPreset + flowerHeight: number + setFlowerPreset: (preset: FlowerPreset) => void + setFlowerHeight: (height: number) => void + // Grass brush (sibling kind). + grassPreset: GrassPreset + grassHeight: number + setGrassPreset: (preset: GrassPreset) => void + setGrassHeight: (height: number) => void +} + +export const useTreesStore = create((set, get) => ({ + mode: 'trees', + setMode: (mode) => set({ mode }), + preset: 'oak', + size: 'medium', + height: TREE_PRESETS.oak.height.medium, + foliageDensity: 1, + trunkThickness: 1, + leafless: false, + // Switching preset/size re-seeds the height to that combo's natural default; + // the foliage/trunk brush settings carry over. Growth model comes from the + // preset (oak → deciduous, pine → evergreen); override per-tree in the inspector. + setPreset: (preset) => set({ preset, height: defaultHeightOf(preset, get().size) }), + setSize: (size) => set({ size, height: defaultHeightOf(get().preset, size) }), + setHeight: (height) => set({ height }), + setFoliageDensity: (foliageDensity) => set({ foliageDensity }), + setTrunkThickness: (trunkThickness) => set({ trunkThickness }), + setLeafless: (leafless) => set({ leafless }), + flowerPreset: 'daisy', + flowerHeight: FLOWER_PRESETS.daisy.defaultHeight, + setFlowerPreset: (flowerPreset) => + set({ flowerPreset, flowerHeight: FLOWER_PRESETS[flowerPreset].defaultHeight }), + setFlowerHeight: (flowerHeight) => set({ flowerHeight }), + grassPreset: 'meadow', + grassHeight: GRASS_PRESETS.meadow.defaultHeight, + setGrassPreset: (grassPreset) => + set({ grassPreset, grassHeight: GRASS_PRESETS[grassPreset].defaultHeight }), + setGrassHeight: (grassHeight) => set({ grassHeight }), +})) diff --git a/packages/plugin-trees/src/system.tsx b/packages/plugin-trees/src/system.tsx new file mode 100644 index 00000000..cbdfa475 --- /dev/null +++ b/packages/plugin-trees/src/system.tsx @@ -0,0 +1,25 @@ +'use client' + +import { getVariantData, treeSpecOf, treeVariantKey } from './geometry' +import { InstancedKindSystem } from './instanced' +import type { TreeNode } from './schema' + +// Module-scope so identities stay stable (the system memoises on them). +const variantKeyOf = (node: TreeNode) => treeVariantKey(treeSpecOf(node)) +const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node)) + +/** + * Collective instanced renderer for every placed tree — contributed via + * `def.system`. Buckets trees by their geometry variant and draws each variant + * as one InstancedMesh per ez-tree sub-mesh, so a forest is a handful of draw + * calls. Selection/outline come from the per-node proxy renderer. + */ +export default function TreesSystem() { + return ( + + getVariant={getVariant} + kind="trees:tree" + variantKeyOf={variantKeyOf} + /> + ) +} diff --git a/packages/plugin-trees/src/tool.tsx b/packages/plugin-trees/src/tool.tsx new file mode 100644 index 00000000..02089da4 --- /dev/null +++ b/packages/plugin-trees/src/tool.tsx @@ -0,0 +1,74 @@ +'use client' + +import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useMemo } from 'react' +import { usePlacement } from './placement' +import TreePreview from './preview' +import { TreeNode } from './schema' +import { useTreesStore } from './store' + +/** + * The trees placement tool. Mounted by the host's registry-first `ToolManager` + * whenever `tool === 'trees:tree'` — no host edit per kind. Reads the panel + * brush from the plugin store, ghosts a preview at the snapped cursor, and + * commits a tree on click. Snapping + level conversion live in `usePlacement`. + */ +export default function TreeTool() { + const activeLevelId = useViewer((s) => s.selection.levelId) + const brush = useTreesStore() + + const previewNode = useMemo( + () => + TreeNode.parse({ + preset: brush.preset, + size: brush.size, + height: brush.height, + foliageDensity: brush.foliageDensity, + trunkThickness: brush.trunkThickness, + leafless: brush.leafless, + // seed/treeType left unset → the ghost shows the pure preset, as placed. + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + [ + brush.preset, + brush.size, + brush.height, + brush.foliageDensity, + brush.trunkThickness, + brush.leafless, + ], + ) + + const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => { + if (!activeLevelId) return + const s = useTreesStore.getState() + const tree = TreeNode.parse({ + preset: s.preset, + size: s.size, + height: s.height, + foliageDensity: s.foliageDensity, + trunkThickness: s.trunkThickness, + leafless: s.leafless, + // seed/treeType unset → the pure ez-tree preset (its canonical seed + type). + // All same-preset trees then share one instancing variant; a random Y + // rotation keeps a planted row from looking cloned. Use Randomize (inspector) + // to vary a tree's seed. + position, + rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0], + }) + useScene.getState().createNode(tree as unknown as AnyNode, activeLevelId as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [tree.id as AnyNodeId] }) + triggerSFX('sfx:item-place') + }) + + if (!activeLevelId) return null + + return ( + + + + ) +} diff --git a/packages/plugin-trees/src/variant-utils.ts b/packages/plugin-trees/src/variant-utils.ts new file mode 100644 index 00000000..b46c7cb3 --- /dev/null +++ b/packages/plugin-trees/src/variant-utils.ts @@ -0,0 +1,27 @@ +import { Box3, type Object3D } from 'three' + +// Pure helpers shared by the tree/flower/grass variant builders. They live +// apart from `geometry.ts` because that module imports ez-tree, which loads +// its inlined textures at module scope (needs `document`) and therefore must +// never sit on an eagerly-imported path (index → definitions → floorplan) — +// SSR/prerender would crash. Only lazy client modules may import `geometry.ts`. + +/** Natural (unscaled) height of a generated plant, so the renderer can scale + * each instance to the node's `height`. */ +export function naturalHeight(obj: Object3D): number { + const box = new Box3().setFromObject(obj) + return Math.max(0.001, box.max.y - box.min.y) +} + +/** Deterministic 32-bit RNG (mulberry32) — same seed ⇒ same geometry. Shared by + * the procedural flower/grass builders so a variant is stable across instances. */ +export function mulberry32(seed: number): () => number { + let a = seed || 1 + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} diff --git a/packages/plugin-trees/src/wind-node.ts b/packages/plugin-trees/src/wind-node.ts new file mode 100644 index 00000000..4b4a0fb1 --- /dev/null +++ b/packages/plugin-trees/src/wind-node.ts @@ -0,0 +1,132 @@ +import type { Color, Material, Side, Texture } from 'three' +import { cos, Fn, float, instanceIndex, positionLocal, sin, time, uv } from 'three/tsl' +import { MeshStandardNodeMaterial } from 'three/webgpu' + +/** + * Always-on wind for the plant kinds, done in TSL so it runs on the editor's + * WebGPU renderer (which ignores WebGL's `onBeforeCompile`). Two motions: + * + * - **Leaf flutter** (`LEAF_FLUTTER`) — ez-tree's own approach: the sway scales + * with the leaf card's `uv.y`, so each leaf swings from its attachment while + * the trunk and branches stay put. A whole-tree height-based bend, by contrast, + * is a rigid rotation about the base and reads as the tree spinning in place. + * Multi-frequency for a natural gust; phased per instance + per leaf. + * - **Stem bend** (`STEM_BEND`) — for the small procedural kinds (flowers, + * grass), a gentle whole-plant lean proportional to height reads fine. + * + * ez-tree isn't touched: its generated materials are re-created as node materials + * carrying the texture/tint (`toWindMaterial`) — only the `leaves` material gets + * the flutter node; bark stays static. `time` is advanced by the renderer. + */ + +// ── Leaf flutter (tree leaves) ─────────────────────────────────────────────── +const LEAF_FREQUENCY = 1.2 +const LEAF_STRENGTH = 0.3 + +const leafFlutter = Fn(() => { + const p = positionLocal.toVar() + const offset = float(instanceIndex).mul(0.7).add(p.x.add(p.z).mul(0.3)) + const t = time.mul(LEAF_FREQUENCY) + const wave = sin(t.add(offset)) + .mul(0.5) + .add(sin(t.mul(2).add(offset.mul(1.3))).mul(0.3)) + .add(sin(t.mul(5).add(offset.mul(1.5))).mul(0.2)) + const sway = uv().y.mul(LEAF_STRENGTH).mul(wave) + p.x.addAssign(sway) + p.z.addAssign(sway) + return p +}) +const LEAF_FLUTTER = leafFlutter() + +// ── Stem bend (flowers, grass) ─────────────────────────────────────────────── +const STEM_FREQUENCY = 1.3 +const STEM_STRENGTH = 0.05 + +const stemBend = Fn(() => { + const p = positionLocal.toVar() + const h = p.y.max(0) + const phase = float(instanceIndex).mul(0.618) + const t = time.mul(STEM_FREQUENCY).add(phase) + p.x.addAssign(h.mul(STEM_STRENGTH).mul(sin(t))) + p.z.addAssign(h.mul(STEM_STRENGTH).mul(cos(t.mul(1.1)))) + return p +}) +const STEM_BEND = stemBend() + +/** The classic-material fields we carry over — enough to reproduce ez-tree's + * bark/leaf look (textured, tinted, alpha-cut billboards). */ +type ClassicMaterial = Material & { + map?: Texture | null + alphaMap?: Texture | null + color?: Color + side?: Side + alphaTest?: number + opacity?: number + transparent?: boolean + depthWrite?: boolean +} + +const cache = new WeakMap() + +/** Re-create a generated (ez-tree) material as a `MeshStandardNodeMaterial`, + * transferring its texture/tint explicitly (node materials don't pick these up + * via `Material.copy()`). Only the `leaves` material flutters; bark stays static. + * Cached per source so shared variant materials convert once. */ +export function toWindMaterial(material: Material): MeshStandardNodeMaterial { + const cached = cache.get(material) + if (cached) return cached + const src = material as ClassicMaterial + const node = new MeshStandardNodeMaterial({ + map: src.map ?? null, + alphaMap: src.alphaMap ?? null, + color: src.color, + side: src.side, + alphaTest: src.alphaTest ?? 0, + transparent: src.transparent ?? false, + opacity: src.opacity ?? 1, + depthWrite: src.depthWrite ?? true, + roughness: 1, + metalness: 0, + }) + if (material.name === 'leaves') node.positionNode = LEAF_FLUTTER + cache.set(material, node) + return node +} + +/** Build a swaying node material for the procedural kinds (flowers/grass). */ +export function windStandardMaterial( + params: ConstructorParameters[0], +): MeshStandardNodeMaterial { + const material = new MeshStandardNodeMaterial(params) + material.positionNode = STEM_BEND + return material +} + +const staticCache = new WeakMap() + +/** Windless twin of a wind material — same look, no `positionNode`. The outline + * mask pass renders outlined meshes with a shared override material, so an + * outline can never follow the GPU sway; the selection proxy renders this twin + * instead, so the outlined silhouette and the visible mesh match exactly (the + * plant simply holds still while hovered/selected). Built by explicit property + * transfer, not `.clone()` — node-material clone drops `map`/`color` (same + * pitfall as `toWindMaterial`). Cached per source. */ +export function toStaticMaterial(material: Material): Material { + const cached = staticCache.get(material) + if (cached) return cached + const src = material as MeshStandardNodeMaterial + const twin = new MeshStandardNodeMaterial({ + map: src.map ?? null, + alphaMap: src.alphaMap ?? null, + color: src.color, + side: src.side, + alphaTest: src.alphaTest ?? 0, + transparent: src.transparent ?? false, + opacity: src.opacity ?? 1, + depthWrite: src.depthWrite ?? true, + roughness: src.roughness, + metalness: src.metalness, + }) + staticCache.set(material, twin) + return twin +} diff --git a/packages/plugin-trees/tsconfig.json b/packages/plugin-trees/tsconfig.json new file mode 100644 index 00000000..7eb9c7ec --- /dev/null +++ b/packages/plugin-trees/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@pascal/typescript-config/react-library.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": true, + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/viewer/src/components/viewer/glb-reference-nodes.tsx b/packages/viewer/src/components/viewer/glb-reference-nodes.tsx index f1814a2d..3937affd 100644 --- a/packages/viewer/src/components/viewer/glb-reference-nodes.tsx +++ b/packages/viewer/src/components/viewer/glb-reference-nodes.tsx @@ -1,19 +1,25 @@ 'use client' -import { type AnyNode, nodeRegistry, type RendererSource, type SceneGraph } from '@pascal-app/core' +import { + type AnyNode, + bakePolicyOf, + nodeRegistry, + type RendererSource, + type SceneGraph, +} from '@pascal-app/core' import { createPortal } from '@react-three/fiber' -import { Suspense } from 'react' +import { memo, Suspense } from 'react' import type { Object3D } from 'three' import { getRegistryRenderer } from '../renderers/node-renderer' /** - * Scans (LiDAR meshes) and guides (floorplan images) are stripped from the - * baked GLB — they're heavy reference assets stored elsewhere. The GLB viewer - * re-adds them at runtime from the scene graph, portaled into their parent + * Kinds with `def.bake === 'strip'` (scans/LiDAR, guides/floorplan images) are + * excluded from the baked GLB — heavy reference assets stored elsewhere. The GLB + * viewer re-adds them at runtime from the scene graph, portaled into their parent * level's baked node so they ride level stacking, using the same registry - * renderers as the parametric viewer. Privacy is enforced upstream: the page - * only includes nodes whose `show_*_public` flag (or owner/admin) allows it, so - * a disallowed asset is never even fetched. + * renderers as the parametric viewer. Selection is registry-driven; scan/guide + * privacy is enforced upstream (`show_*_public`), so a disallowed asset is never + * even fetched — we still honour the flags here as a second gate. */ export function buildGlbReferenceNodes( sceneGraph: SceneGraph | null | undefined, @@ -24,13 +30,40 @@ export function buildGlbReferenceNodes( const out: AnyNode[] = [] for (const raw of Object.values(nodes)) { const node = raw as AnyNode - if (node.type === 'scan' && allow.scans) out.push(node) - else if (node.type === 'guide' && allow.guides) out.push(node) + if (bakePolicyOf(node.type) !== 'strip') continue + if (node.type === 'scan' && !allow.scans) continue + if (node.type === 'guide' && !allow.guides) continue + out.push(node) } return out } -export function GlbReferenceNodes({ +/** + * Kinds with `def.bake === 'replace'` — baked as static geometry (so plain glTF + * viewers still show them) but re-rendered live here, since their runtime look + * differs from a frozen snapshot (shader wind, interactivity). `GlbScene` hides + * the baked meshes for these kinds; this feeds them back through the same + * portal-into-level path as reference nodes. Registry-driven; no privacy gate + * (dynamic scene content, not user uploads). + */ +export function buildGlbReplaceNodes(sceneGraph: SceneGraph | null | undefined): AnyNode[] { + const nodes = sceneGraph?.nodes + if (!nodes) return [] + const out: AnyNode[] = [] + for (const raw of Object.values(nodes)) { + const node = raw as AnyNode + if (bakePolicyOf(node.type) === 'replace') out.push(node) + } + return out +} + +// Memoized: `GlbScene` re-renders every frame during camera movement (hover +// raycast, walkthrough HUD). Without memo, all rebuilt nodes reconcile each +// frame — negligible for one or two scans/guides, but a `replace` kind can put +// dozens of nodes here (e.g. a forest), so each frame reconciles dozens of +// portals + submeshes. `nodes` and `identity` are stable references (page-level +// `useState` / `useMemo([gltf.scene])`), so memo short-circuits cleanly. +export const GlbReferenceNodes = memo(function GlbReferenceNodes({ nodes, identity, }: { @@ -45,12 +78,20 @@ export function GlbReferenceNodes({ })} ) -} +}) -/** Render one scan/guide via its registry renderer, portaled into its parent - * level's baked Object3D (so the node's level-local transform resolves to the - * same world pose as the parametric scene). */ -function GlbReferenceNode({ node, anchor }: { node: AnyNode; anchor: Object3D }) { +/** Render one `strip` node (scan/guide) via its registry `renderer`, portaled + * into its parent level's baked Object3D (so the node's level-local transform + * resolves to the same world pose as the parametric scene). Memoized so an + * unchanged `(node, anchor)` skips the subtree when the parent re-renders on + * camera move. (`replace` kinds use the collective `GlbReplaceInstances` path.) */ +const GlbReferenceNode = memo(function GlbReferenceNode({ + node, + anchor, +}: { + node: AnyNode + anchor: Object3D +}) { const source = nodeRegistry.get(node.type)?.renderer const Renderer = source ? getRegistryRenderer(source as RendererSource) : null if (!Renderer) return null @@ -60,4 +101,4 @@ function GlbReferenceNode({ node, anchor }: { node: AnyNode; anchor: Object3D }) , anchor, ) -} +}) diff --git a/packages/viewer/src/components/viewer/glb-replace-instances.tsx b/packages/viewer/src/components/viewer/glb-replace-instances.tsx new file mode 100644 index 00000000..7223b2ef --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-replace-instances.tsx @@ -0,0 +1,79 @@ +'use client' + +import { type AnyNode, type BakeReplaceRenderer, nodeRegistry } from '@pascal-app/core' +import { createPortal } from '@react-three/fiber' +import { type ComponentType, Fragment, lazy, memo, type ReactNode, Suspense, useMemo } from 'react' +import type { Object3D } from 'three' + +// Lazy components cached by their source so React.lazy isn't re-invoked per render. +const lazyCache = new WeakMap, ComponentType<{ nodes: AnyNode[] }>>() + +function getReplaceRenderer( + source: BakeReplaceRenderer, +): ComponentType<{ nodes: AnyNode[] }> { + const cached = lazyCache.get(source) + if (cached) return cached + const Comp = lazy(source.module) as unknown as ComponentType<{ nodes: AnyNode[] }> + lazyCache.set(source, Comp) + return Comp +} + +/** + * Re-renders `bake: 'replace'` kinds (e.g. plugin trees) live over the baked GLB. + * The baked static meshes are hidden by `GlbScene`; these nodes are grouped by + * `(parent level, kind)` and each group is handed to the kind's collective + * `bakeReplaceRenderer` (an instanced renderer), portaled into that level's baked + * `Object3D`. Local-space instances therefore ride level stacking/explode for + * free, and a forest stays a few instanced draw calls. + * + * Memoized: `GlbScene` re-renders each frame on camera move; `nodes` and + * `identity` are stable refs, so this whole subtree short-circuits. + */ +export const GlbReplaceInstances = memo(function GlbReplaceInstances({ + nodes, + identity, +}: { + nodes: AnyNode[] + identity: Map +}) { + const byLevel = useMemo(() => { + const levels = new Map>() + for (const node of nodes) { + const parentId = node.parentId + if (!parentId) continue + let byKind = levels.get(parentId) + if (!byKind) { + byKind = new Map() + levels.set(parentId, byKind) + } + const list = byKind.get(node.type) + if (list) list.push(node) + else byKind.set(node.type, [node]) + } + return levels + }, [nodes]) + + const portals: ReactNode[] = [] + byLevel.forEach((byKind, parentId) => { + const anchor = identity.get(parentId) + if (!anchor) return + byKind.forEach((kindNodes, kind) => { + const source = nodeRegistry.get(kind)?.bakeReplaceRenderer as + | BakeReplaceRenderer + | undefined + if (!source) return + const Renderer = getReplaceRenderer(source) + portals.push( + + {createPortal( + + + , + anchor, + )} + , + ) + }) + }) + return <>{portals} +}) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 3383b599..72ffa64b 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -1,6 +1,6 @@ 'use client' -import type { AnyNode, SurfaceRole } from '@pascal-app/core' +import { type AnyNode, bakePolicyOf, type SurfaceRole } from '@pascal-app/core' import { Html, useAnimations } from '@react-three/drei' import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -8,12 +8,14 @@ import * as THREE from 'three' import { lerp } from 'three/src/math/MathUtils.js' import { color, float, uniform, uv } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { ZONE_LAYER } from '../../lib/layers' import { createSurfaceRoleMaterial } from '../../lib/materials' import useViewer from '../../store/use-viewer' import { GlbInteractive, type GlbInteractiveItem } from './glb-interactive' import { GlbReferenceNodes } from './glb-reference-nodes' +import { GlbReplaceInstances } from './glb-replace-instances' /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */ const EXPLODED_GAP = 5 @@ -283,6 +285,7 @@ export function GlbScene({ url, interactiveItems, referenceNodes, + replaceNodes, onLevelsChange, onIdentityChange, onHoverChange, @@ -295,6 +298,9 @@ export function GlbScene({ /** Scan / guide nodes from the scene graph, re-added at runtime (they're * stripped from the bake). Already filtered by the privacy flags upstream. */ referenceNodes?: AnyNode[] + /** `bake: 'replace'` nodes (e.g. plugin trees): baked static but re-rendered + * live here via their `bakeReplaceRenderer`; the baked meshes are hidden. */ + replaceNodes?: AnyNode[] onLevelsChange?: (levels: GlbLevel[]) => void onIdentityChange?: (identity: GlbIdentity) => void onHoverChange?: (hover: GlbHover) => void @@ -333,6 +339,45 @@ export function GlbScene({ }) }, [gltf.scene, textures, sceneTheme]) + // The baked scene isn't wrapped in (the community viewer runs with + // useBvh={false}), so hover/pick raycasts against the baked building were + // brute-force triangle tests — dozens of ms per pointer move on a dense scene, + // and far worse once a `replace` forest is portaled in. Give each baked mesh a + // BVH so those raycasts are accelerated. `replace` instances are NO_RAYCAST, so + // the `raycast === Mesh.prototype.raycast` guard skips them (mirrors SceneBvh). + useEffect(() => { + const accelerated = new Set() + const computed = new Set() + gltf.scene.traverse((child) => { + const mesh = child as THREE.Mesh + if (!mesh.isMesh || mesh.raycast !== THREE.Mesh.prototype.raycast) return + mesh.raycast = acceleratedRaycast + accelerated.add(mesh) + const geometry = mesh.geometry + if (geometry.boundsTree || !geometry.getAttribute('position')) return + try { + // three-mesh-bvh + @types/three disagree on the helper signatures; cast + // through unknown like SceneBvh does — the runtime call is correct. + ;(geometry as { computeBoundsTree?: unknown }).computeBoundsTree = + computeBoundsTree as unknown as typeof geometry.computeBoundsTree + ;(geometry as { disposeBoundsTree?: unknown }).disposeBoundsTree = + disposeBoundsTree as unknown as typeof geometry.disposeBoundsTree + geometry.computeBoundsTree() + computed.add(geometry) + } catch (error) { + console.warn('[viewer] skipping BVH for baked mesh geometry', error) + } + }) + return () => { + for (const geometry of computed) { + if (geometry.boundsTree) geometry.disposeBoundsTree() + } + for (const mesh of accelerated) { + if (mesh.raycast === acceleratedRaycast) mesh.raycast = THREE.Mesh.prototype.raycast + } + } + }, [gltf.scene]) + // One pass over the artifact: identity objects (id → Object3D), ordered floors, // and zone polygons. Levels stay out of `sceneRegistry` so the parametric // LevelSystem never re-stacks them. @@ -357,6 +402,11 @@ export function GlbScene({ } if (!extras.pascalId) return objects.set(extras.pascalId, object) + // `bake: 'replace'` kinds are baked as static geometry (portable), but a + // loaded plugin re-renders them live from the scene graph — hide the frozen + // baked mesh so only the live one shows. Gated on the registry, so with no + // plugin loaded the policy is `'static'` and the baked mesh stays. + if (bakePolicyOf(extras.kind ?? '') === 'replace') object.visible = false if (extras.kind === 'building') buildingNode = object else if (extras.kind === 'site') siteNode = object if (extras.kind === 'ceiling' || extras.kind === 'roof') occluderNodes.push(object) @@ -1011,6 +1061,11 @@ export function GlbScene({ {referenceNodes?.length ? ( ) : null} + {/* `bake: 'replace'` nodes (plugin trees): baked meshes hidden above, the + live instanced render portaled per level via each kind's bakeReplaceRenderer. */} + {replaceNodes?.length ? ( + + ) : null} {/* Floating room labels. Each group's matrix is synced to its zone node every frame (above) so the label rides level stacking; the div fades with the room fill via a CSS transition. */} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 5dc8e3b2..fa786bb9 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -23,7 +23,10 @@ export { GlbInteractive, type GlbInteractiveItem, } from './components/viewer/glb-interactive' -export { buildGlbReferenceNodes } from './components/viewer/glb-reference-nodes' +export { + buildGlbReferenceNodes, + buildGlbReplaceNodes, +} from './components/viewer/glb-reference-nodes' export { type GlbHover, type GlbIdentity, diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 58e0d052..ade93265 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -41,6 +41,14 @@ type ViewerState = { renderContext: RenderContext setRenderContext: (context: RenderContext) => void + /** True during a GLB bake/export pass. Renderers that normally draw via a + * collective InstancedMesh (`def.system`) and mount only an invisible per-node + * proxy can watch this to emit real, visible geometry so the exporter — which + * clones only the `scene-renderer` subtree — captures them. Transient (never + * persisted). */ + isExporting: boolean + setExporting: (value: boolean) => void + shading: RenderShading shadingByContext: Partial> setShading: (shading: RenderShading) => void @@ -222,6 +230,9 @@ const useViewer = create()( renderContext: 'editor', setRenderContext: (context) => set({ renderContext: context }), + isExporting: false, + setExporting: (value) => set({ isExporting: value }), + shading: 'rendered', shadingByContext: {}, setShading: (shading) => diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index 41abc1b4..79518bb1 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -21,6 +21,10 @@ export const myPlugin: Plugin = { armchairDefinition, // ... ], + panels: [ + { id: 'catalog', label: 'Catalog', icon: { kind: 'iconify', name: 'lucide:sofa' }, + component: () => import('./catalog-panel') }, + ], } ``` @@ -28,7 +32,10 @@ export const myPlugin: Plugin = { |---|---|---| | `id` | yes | Globally unique. Use `vendor:pack-name` to avoid collisions. The host treats it as opaque. | | `apiVersion` | yes | Currently `1`. The host throws on mismatch — bumping breaks plugins, intentionally. | -| `nodes` | optional | Array of `AnyNodeDefinition`. May be empty for a pure-resource plugin (future). | +| `nodes` | optional | Array of `AnyNodeDefinition`. May be empty for a pure-panel plugin. | +| `panels` | optional | Array of `PluginPanel` — left-rail panels (see [Panel contributions](#panel-contributions)). | + +The first-party [`@pascal-app/plugin-trees`](../../packages/plugin-trees) package is the worked example: one node kind + one panel. Copy it as a starting point. The same shape powers the built-in `pascal:core` plugin in `@pascal-app/nodes` — there's no "internal" plugin format. Whatever works for built-ins works for third parties. @@ -51,6 +58,39 @@ A plugin's `nodes` array is the only meaningful contribution point in v1. Each e See [`node-definitions.md`](node-definitions.md) for the three-checkbox composition model that ties these together. +## Panel contributions + +A plugin can add its own panel to the sidebar **icon rail** via `plugin.panels`. Each entry: + +```ts +export type PluginPanel = { + id: string // unique within the plugin; host namespaces it as `${plugin.id}:${id}` + label: string // rail tooltip + panel header + icon: IconRef // same icon union node presentation uses (iconify / url / svg / component) + component: LazyComponent // () => import('./panel') — a default-exported React component +} +``` + +Behaviour: + +- **In-process React, no iframe.** The component mounts directly in the host React tree, lazily on first open. It can use the host stores (`useScene`, `useEditor`, `useViewer`) and its own Zustand stores freely. +- **Error-isolated.** Every plugin panel is wrapped in an error boundary with a "this plugin crashed" fallback. A throwing panel degrades to that fallback for the session instead of taking down the sidebar — other panels keep working. +- **Namespaced.** `loadPlugin` rewrites the panel id to `${plugin.id}:${panel.id}`, so two plugins can both ship `id: 'main'`. Host-provided panels (the app's own `extraSidebarPanels`) keep precedence on an id collision. +- **Styling.** Scope your CSS — no globals. Use the host sidebar CSS variables (`--sidebar`, `--sidebar-foreground`, `--sidebar-accent`, `--sidebar-border`, `--sidebar-ring`) so the panel reads as native in light/dark. + +The registry that backs this (`panelRegistry` in `@pascal-app/core`) is observable — discovery is async, so the sidebar subscribes and the rail icon appears as soon as the plugin loads. + +### Driving a tool from a panel + +A panel typically writes a choice into the plugin's own store, then arms placement: + +```ts +useEditor.getState().setTool('trees:tree') // a plugin tool id +useEditor.getState().setMode('build') +``` + +`Tool` is `KnownTool | (string & {})`, so a plugin's namespaced tool id (the node `kind`) typechecks without the host enumerating it. Dispatch is registry-first — `ToolManager` resolves `nodeRegistry.get(tool)?.tool` — so an unknown-to-the-host tool string flows straight through to the plugin's `def.tool` component. The placement tool reads the plugin store for *what* to place. That round-trip — panel → store → `def.tool` → `SceneApi` → scene → reactive `useScene` read-back in the panel — is the full plugin communication path; `plugin-trees` demonstrates it end to end. + ## Importing host packages A plugin imports from the published `@pascal-app/*` packages — same surface the built-ins use, peer-dependency-style: