feat(plugins): plugin contract + first-party Nature pack (trees/flowers/grass) (#457)
* feat(plugins): trees tracer-bullet plugin + minimal host panel surface
Ship one real first-party plugin and only the host surface it forces
into existence, proving the plugin contribution paths every future
plugin (mint.gg generator, Home Assistant, environments, room volume)
will reuse.
Host surface (core + editor):
- core: Plugin.panels + observable panelRegistry; loadPlugin routes
panels (namespaced by plugin id, dup-throws like nodes).
- editor: AppSidebar merges panelRegistry into the icon rail via
useSyncExternalStore; each plugin panel lazy-loaded behind an error
boundary; host extraPanels keep precedence.
- editor: widen Tool to `KnownTool | (string & {})` so plugin tool ids
(e.g. 'trees:tree') typecheck; dispatch already registry-first.
Trees plugin (packages/plugin-trees), structurally a third-party pack
(peer-deps on @pascal-app/*):
- trees:tree node — procedural low-poly geometry (oak/pine/birch/palm),
free parametric inspector (preset/height/seed + Randomize), placement
tool + ghost preview built from public primitives only.
- presets rail panel — panel -> plugin store -> def.tool -> SceneApi ->
scene -> reactive read-back ("N planted").
Loading + docs:
- apps/editor: setPluginDiscovery([treesPlugin]) in bootstrap;
transpilePackages + dep.
- wiki/architecture/plugin-authoring.md: panels field, error-boundary
contract, styling, tool-from-panel note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugins): merge plugin panels into the v2 sidebar layout too
The community shell renders <Editor layoutVersion="v2" sidebarTabs={...}>,
a separate rail from AppSidebar. Merge registry panels into the v2
tabMap + tab bar (and thus mobile) so plugin panels show up there as
well, not only in the v1 AppSidebar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): ez-tree geometry + instanced rendering + snapping
Replace the placeholder low-poly geometry with dgreenheck/ez-tree
(self-contained — bark/leaf textures inlined as base64, no assets to
host) and render the forest with true GPU instancing.
Rendering:
- def.system (system.tsx): one collective renderer that groups every
trees:tree node by (preset, seed) variant and draws each variant as
one InstancedMesh per ez-tree sub-mesh, composing the parent level's
world matrix into per-instance matrices. ~1 draw call per variant.
- def.renderer (proxy-renderer.tsx): an invisible, raycastable per-node
proxy so the host's existing selection / outline / zone machinery
keeps working — no instanceId bookkeeping. (Outline highlights the
proxy bbox; documented.)
- geometry per variant generated once by ez-tree and cached; seeds drawn
from a bounded pool so trees actually share variants.
Presets remapped to ez-tree built-ins (oak/pine/aspen/ash/bush).
Placement now respects the active snap mode — isGridSnapActive() +
gridSnapStep + snapPointToGrid, like the built-in item/shelf tools.
transpilePackages += @dgreenheck/ez-tree in the editor app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): true-silhouette hover/select + richer presets panel
Selection/hover now outlines the real tree shape, not the bbox. The
proxy splits into an outer group (stable invisible box collider +
pointer handlers, the raycast target) and an inner registered group that
mounts the real ez-tree geometry (invisible, non-raycasting) only while
the node is hovered or selected. The host outline pass reads the
registered inner group, so it traces the true silhouette; picking stays
on the steady box. Geometry for the highlight reuses the cached variant.
Panel: redesigned cards with gradient swatches + selected state, a
"planted" count chip, and a height slider that seeds the next tree's
height (a per-instance scale — never touches placed trees or instancing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): curated tree params + procedural flowers (sibling kind)
Tree params: expose foliageDensity, trunkThickness, and a leafless toggle
in the inspector and the panel brush. Each is folded into the instancing
variant key and mapped onto ez-tree options (radius scale, leaf count),
so editing one tree only re-buckets that tree — instancing degrades
gracefully, never worse than per-node.
Flowers: add a `trees:flower` sibling kind — simple procedural geometry
(stem + petals + center, merged per material), presets daisy / tulip /
lavender, instanced + selectable exactly like trees.
Refactor: extract the instanced renderer + selection proxy into a generic
`instanced.tsx` (InstancedKindSystem + KindProxy) and the snap/level/grid
placement wiring into `placement.tsx` (usePlacement). Trees and flowers
are now thin bindings — the template for future plant kinds.
Panel: a Trees / Flowers toggle, gradient preset cards, per-kind planted
count, and brush sliders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): native inspector controls in panel + preset thumbnails + ez-tree credit
- swap the panel's custom slider/checkbox/toggle for the host's exported
SliderControl/ToggleControl/SegmentedControl so the brush matches the
right-hand inspector pixel-for-pixel
- replaceable preset thumbnails (inline SVG data URIs, no asset hosting) render
as <img> cards instead of gradient swatches
- credit footer linking @dgreenheck/ez-tree (Daniel Greenheck, MIT)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): all ez-tree presets + type + leaf/branch colours, flower petal colour, grass kind
- tree presets now cover all of ez-tree's built-ins via species × size
(Small/Medium/Large + Bush 1/2/3 + Trellis), plus a Deciduous/Evergreen type
- edit-only leaf & branch colour tints (leaves.tint / bark.tint), all folded
into the instancing variant key
- flowers gain a per-flower petal colour (baked from preset at placement)
- new trees:grass instanced kind — procedural blade tufts (meadow/fescue/reed)
with a per-tuft blade colour, reusing the generic instanced + placement core
- panel: Trees/Flowers/Grass segmented switch, size + type controls, native
host controls throughout; credit links Daniel Greenheck's X
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): tolerate nodes persisted before new fields existed
treeSpecOf/flowerPetalColor now default every geometry field, and hexToInt
guards undefined — trees/flowers placed before size/type/colour fields were
added load with neutral defaults instead of crashing the instanced renderer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): shared always-on wind + pin credits to panel bottom
- wind.ts injects a sway into each material's begin_vertex driven by one shared
uTime uniform (advanced per frame in the instanced system); USE_INSTANCING
guards keep the same material valid for the non-instanced placement ghost.
Applied to tree, flower, and grass materials — a whole scene sways like ez-tree.
- credits footer is now sticky to the bottom of the panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): rename rail panel Trees -> Nature (covers trees/flowers/grass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): rename panel header Plant -> Nature to match the rail
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: bake-exportable instanced plants + renderer-agnostic wind
Bake (GLB export): add a transient useViewer.isExporting flag. BakeExporter
flips it, waits for the commit, then exports. KindProxy watches it and, during
export, emits each plant's REAL visible geometry under scene-renderer (which the
exporter clones) instead of the invisible collider — so instanced kinds
(def.system) that live outside that subtree are captured. The collider box is
dropped during export so it doesn't bake as a phantom solid.
Wind: the previous onBeforeCompile wind was a no-op under the editor's WebGPU
renderer (WebGL-only hook). Replace it with a renderer-agnostic per-instance
base-pivot tilt animated in useFrame (which FrameLimiter drives continuously).
Reads as wind under any renderer; removes the dead wind.ts + applyWind calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): TSL vertex-bend wind (WebGPU), replacing CPU tilt
wind-node.ts adds a TSL positionNode that bends each plant proportional to
height above its base, phased per-instance (instanceIndex) and per-vertex, so
tips sway and roots stay planted — animated on the GPU via the renderer's time
node. ez-tree is untouched: its materials are copied into MeshStandardNodeMaterial
(toWindMaterial); flowers/grass build node materials directly (windStandardMaterial).
Instance matrices go back to static.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): tree materials render black under wind (Phong not Standard)
ez-tree's bark/leaves are MeshPhongMaterial; copying them into a
MeshStandardNodeMaterial swapped the shading model (specular/shininess ->
roughness/metalness) and rendered black. Convert into the matching node
material type (Phong->Phong, Lambert->Lambert, else Standard) so map/color/
alphaTest/side are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): trees still black — build node material from map/color explicitly
Material.copy() doesn't transfer a classic material's map/color onto a node
material, so textured trees rendered black (flowers/grass were fine — they carry
no map). Re-create the ez-tree materials as MeshStandardNodeMaterial with map,
alphaMap, color, side, alphaTest passed explicitly in the constructor — the same
proven path the flower/grass materials use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): tree wind — per-leaf flutter (ez-tree style), static trunk
The whole-tree height-based bend was a rigid rotation about the base ('rotating
in place'). Replicate ez-tree's actual approach: scale sway by the leaf card's
uv.y and apply it ONLY to the 'leaves' material (multi-frequency wave, phased per
instance + per leaf) so leaves flutter from their attachment while bark/branches
stay static. Flowers/grass keep the gentle whole-plant stem bend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugins): Plugin.onSceneLoad hook + viewer application; trees wind via it
Adds a broad, plugin-declared post-load scene hook so effects survive GLB export:
- core: Plugin.onSceneLoad (lazy, three-free) + observable sceneHookRegistry + loadPlugin routing.
- viewer: applyPluginSceneHooks util; PluginSceneHooksSystem re-runs it on the live
scene as nodes change; GlbScene runs it on each loaded baked GLB.
- plugin-trees: declare onSceneLoad; wind is no longer welded into the materials —
the hook re-attaches LEAF_FLUTTER/STEM_BEND by material name (leaves / flower-* /
grass-*), so it now animates in the baked /viewer too, not just live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): scale-invariant leaf wind (÷ modelScale) so baked GLB matches editor
The bake's quantiser renormalises each plant mesh to [-1,1] and moves real size
into a large node scale (~3 on trees). LEAF_FLUTTER added a constant local offset,
so in the baked GLB it was multiplied by that scale → ~3x over-swing. Divide the
sway by modelScale.x to keep a constant world-space amplitude; editor (scale 1)
is unchanged, baked now matches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* revert: onSceneLoad wind approach → back to wind-in-materials
Reverts the Plugin.onSceneLoad hook + viewer application + modelScale compensation
(commits 893ca839, 6c9ee003). Superseded by the bake-policy 'replace' model
(plans/editor-plugin-trees-example.md Part D): trees render live in the viewer via
their own path, so wind is a plain positionNode on the material again — no hook,
no baked-geometry decoration, no coordinate-space fights.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(bake): per-kind bake policy (static/strip/replace)
Adds NodeDefinition.bake ('static' default | 'strip' | 'replace') + registry
helpers bakePolicyOf/kindsWithBakePolicy in core. Generalises the previously
hardcoded scan/guide handling:
- glb-export strips kinds with bake==='strip' from the artifact (was a
scan/guide type check); 'replace' kinds stay baked (portable static snapshot).
- glb-reference-nodes selects rebuild candidates by policy instead of type.
- scan/guide declare bake:'strip'; trees/flower/grass declare bake:'replace'.
Viewer-side 'replace' swap (strip baked meshes + live-rebuild) is the next slice.
Part of plans/editor-plugin-trees-example.md Part D.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(bake): viewer 'replace' swap — hide baked meshes, re-render live
Adds NodeDefinition.bakeReplaceRenderer (falls back to renderer). The baked
/viewer now, for kinds with bake:'replace' and a loaded plugin:
- hides the static baked meshes (bakePolicyOf(kind)==='replace') in GlbScene's
identity pass — capability-gated, so with no plugin the meshes stay;
- re-renders each node live via buildGlbReplaceNodes + the shared portal-into-
baked-level path (GlbReferenceNode now prefers bakeReplaceRenderer), so trees
ride level stacking automatically and wind runs in real coordinate space.
plugin-trees ships a hookless KindStatic (real geometry + wind materials, no
collider/selection) and per-kind static-renderer binds; tree/flower/grass point
bakeReplaceRenderer at them. Completes plans/editor-plugin-trees-example.md Part D.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(viewer): memoize GlbReferenceNode(s) — skip forest re-render on camera move
GlbScene re-renders per frame during camera movement (hover raycast / walkthrough
HUD). The rebuilt nodes reconciled every frame — fine for 1-2 scans/guides, but a
bake:'replace' forest puts dozens of nodes here (profiler: KindStatic x56 6ms,
GlbReferenceNode x57 4ms per frame). memo at the node boundary skips the whole
subtree (incl. the plugin renderer) when (node, anchor) are unchanged; both props
are stable refs, so it short-circuits cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(plugin-trees): NO_RAYCAST on KindStatic meshes — cheap hover over a forest
The baked scene's <primitive> has pointer handlers, so R3F recursively raycasts
every descendant on each pointer move (incl. orbit drags). The live replace trees
had fully-raycastable dense ez-tree geometry, so hover cost ~400ms/frame over a
forest (profiler: 'JavaScript' 413ms during camera move). They carry no pascalId
(a hit resolves to the level, not the tree), so they're scenery, not pick targets
— mirror KindProxy, which already NO_RAYCASTs its real geometry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(bake): viewer 'replace' renders instanced per level (was per-node)
Replaces the per-node KindStatic viewer path with a collective instanced one,
mirroring the editor's system instead of diverging from it:
- extract InstancedNodes from InstancedKindSystem (shared by both) with a
localSpace flag: editor folds parent world matrix (root instances); viewer uses
level-local matrices + NO_RAYCAST, portaled into the baked level.
- bakeReplaceRenderer is now a collective renderer (BakeReplaceRenderer<N>,
receives {nodes}); GlbReplaceInstances groups replace nodes by (level, kind) and
portals each kind's instanced renderer into that baked level. GlbReferenceNode
reverts to per-node renderer (strip kinds only).
Fixes: forest hover cost (100+ tree meshes → a few instanced draws, R3F walks far
fewer objects) and per-tree wind phase (instanceIndex varies again; per-node
KindStatic gave every tree phase 0 → unison sway). Trees still ride level stacking
(portaled) and stay static in a plain glTF viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(viewer): BVH the baked scene — accelerate hover/pick raycasts
The baked /viewer runs with useBvh={false}, so GlbScene's hover/pick raycasts
against the baked building were brute-force triangle intersection (profiler:
_computeIntersections + intersectTriangle ~30%). The parametric viewer wraps its
scene in <SceneBvh>; the baked one never did. SceneBvh's effect is one-shot on
mount and can't catch the async-loaded GLB, so compute a per-mesh BVH inside
GlbScene keyed on gltf.scene instead. 'replace' instances are NO_RAYCAST, so the
raycast===Mesh.prototype.raycast guard skips them (mirrors SceneBvh).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): inherit ez-tree preset by default (seed/type/tints)
Our node defaults (seed:1, treeType:deciduous, colors:#ffffff) were applied as
*overrides* on top of loadPreset, discarding each preset's tuned seed, growth
model, and tints — so our trees looked nothing like eztree.dev (pine grew
deciduous, foliage washed white, every silhouette off).
Mirror the flower petalColor pattern: seed/treeType/leafColor/branchColor are now
optional; generateTree only overrides an ez-tree option when the node set it,
otherwise loadPreset's value stands. Placement stores none of them (fresh tree =
pure preset; all same-preset trees share one instancing variant); the deciduous/
evergreen brush toggle is gone (growth model comes from the preset). The inspector
keeps them as per-tree overrides, and Randomize still varies the seed.
Verified against ez-tree: fresh Oak Medium → seed 35729 + preset tints; Pine
Medium → evergreen + seed 13977; overrides apply when set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): bundle real preset artwork (webp, in-package)
Replaces the placeholder SVG thumbnails + lucide panel icon with the real nature
art. 13 webp (~126KB total, 256px cards / 128px icon) live in src/assets and are
imported via art.ts — both consumers are Next, so transpilePackages runs them
through the image pipeline (hashed, cached /_next/static/media URLs). No CDN, no
per-app public/ mirroring; the assets travel with the package.
- art.ts: central webp imports → TREE_ART / FLOWER_ART / GRASS_ART / NATURE_ICON
- presets/flower-presets/grass-presets: thumbnail ← bundled art (was *Thumbnail())
- index.ts: Nature panel icon ← NATURE_ICON (was lucide:leaf)
- assets.d.ts: ambient *.webp decl (no next type dep)
- remove thumbnails.ts (placeholder generators, now dead)
Verified on the running dev server: webp emitted + served (HTTP 200, image/webp).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): square preset cards on light-gray flattened thumbnails
Regenerate the 12 thumbnails with the transparency flattened onto gray-100
(#f3f4f6) and render the card square (aspect-square, was h-16 crop). The
nature-icon keeps its transparency for the icon rail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): correct move-drag + outline behavior for instanced plants
Two selection defects, one root cause each:
1. Outline moved mirror-wise during a move drag. The host move tool drives the
registered Object3D imperatively with absolute level-local positions (and
mirrors them via useLiveTransforms) — the contract ParametricNodeRenderer
satisfies by putting position + registration on the same group. KindProxy
registered a child nested inside the transform-carrying group, so drag
positions landed in the node's rotated frame (placement gives every plant a
random Y rotation → deltas rotated up to 180°). Restructure: the registered
group now carries position/rotation (live-transform aware); the box collider
is a positioned sibling, staying out of the outline mask.
2. Outline froze while the mesh swayed. The outline mask pass renders with a
shared override material, so it can never follow the material positionNode
wind. Instead, while hovered/selected the collective system skips the node
and the proxy mounts the real geometry with static twins of the wind
materials (toStaticMaterial — explicit property transfer; node-material
clone() drops map/color). Outlined mesh == visible mesh, both still.
Bonus from the same restructure: a move drag now animates the actual plant in
realtime (the proxy is what the tool drives), not just the drag box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(plugin-trees): 2D plan symbols + trunk-sized footprint
- def.floorplan for the three kinds — the registry floor-plan layer renders any
kind that provides one, so this is all plugin nodes need to appear in 2D.
Tree: dashed canopy ring (dashed = overhead element) in the preset swatch +
solid trunk dot; ring is pointer-events:stroke so the large disc doesn't
steal clicks from what's under the canopy. Flower/grass: small colour dots.
Selected → palette stroke + move-handle; hovered → hover stroke.
- Tree floorPlaced.footprint is trunk-sized (treeTrunkRadius) instead of
canopy-sized, so the move/placement drag box hugs where the tree actually
plants instead of spanning the crown. The invisible hover collider keeps its
larger radius — only the displayed box shrinks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin-trees): trunk-sized drag box, 45°-aligned rotation, rotate gizmo
- The move drag box ignored floorPlaced.footprint: with collides:false the tool
auto-measures the rendered mesh — which, since the selection swap-out, is the
whole canopy. Declare capabilities.dragBounds (trunk-sized, node-local) so the
box hugs the trunk and rotates with the node instead of wrapping the crown as
a world AABB.
- Placement wrote a fully random Y rotation, so the box/gizmo never sat on a 45°
step. Snap the random rotation to 45° increments (variety preserved, alignment
restored) and widen rotatable.snapAngles to 8×45° on all three kinds.
- Add the standard rotate gizmo (def.handles, shelf-style arc handle): ring
around the trunk near the ground — not the canopy, which would put the handle
meters out on a large oak.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin-trees): live rotate preview + ground-level rotate ring
- KindProxy now folds useLiveNodeOverrides into its transform (mirrors
ParametricNodeRenderer) — the rotate/resize gizmos publish mid-drag patches
there, so the plant turns in realtime instead of snapping on commit. The
snap-on-commit (with the arc delta wrapping to ±180°) is also what made long
drags land 'the wrong way'; with live feedback the direction reads correctly
and matches the item gizmo pipeline exactly.
- Rotate ring drops from mid-trunk to 0.25m — a floor affordance like the item
gizmo, not a waist-height hoop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(registry): panelForKind — 'find in catalog' opens a plugin kind's panel
loadPlugin now associates every node kind with its plugin's first (namespaced)
panel id; panelRegistry.panelForKind(kind) exposes it. A host's find-node
handler can open the right panel for any plugin kind with zero per-plugin
knowledge.
plugin-trees uses it end-to-end: a module-level selection:find-node listener
(find-sync.ts, imported by the manifest so it's live from plugin load) points
the panel store at the found node's section + preset — panel section state
moved from panel-local useState into the store to make it addressable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(registry): plugin panels declare their workspaces (default edit-only)
Plugin panels rode into the studio rail: the host swaps its own sidebar tabs by
workspaceMode, but usePluginPanels appended registry panels unconditionally.
PluginPanel gains workspaces?: readonly ('edit'|'studio')[] — manifest metadata,
default ['edit'] (an authoring panel has no business in the clean render
workspace; a plugin shipping studio tooling opts in explicitly). usePluginPanels
filters by the current workspaceMode, covering both the v1 AppSidebar and v2 tab
bar paths. Nature declares nothing and disappears from studio via the default;
the v2 layout's existing active-tab fallback handles a panel vanishing mid-use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: link plugin-authoring guide from README + CONTRIBUTING, fix stale wiki path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin-trees): keep ez-tree off the eager SSR path
ez-tree loads its inlined textures at module scope (needs document), so any
eager import chain reaching geometry.ts crashed Next prerender
(ReferenceError: document is not defined on /_not-found). Move the pure
helpers (mulberry32, naturalHeight) to variant-utils.ts so the
flower/grass builders and floorplan no longer pull ez-tree, and drop the
generateTree re-export from the package barrel (no external consumers).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,48 @@
|
||||
import ash from './assets/ash.webp'
|
||||
import aspen from './assets/aspen.webp'
|
||||
import bush from './assets/bush.webp'
|
||||
import daisy from './assets/daisy.webp'
|
||||
import fescue from './assets/fescue.webp'
|
||||
import lavender from './assets/lavender.webp'
|
||||
import meadow from './assets/meadow.webp'
|
||||
import natureIcon from './assets/nature-icon.webp'
|
||||
import oak from './assets/oak.webp'
|
||||
import pine from './assets/pine.webp'
|
||||
import reed from './assets/reed.webp'
|
||||
import trellis from './assets/trellis.webp'
|
||||
import tulip from './assets/tulip.webp'
|
||||
import type { FlowerPreset } from './flower-schema'
|
||||
import type { GrassPreset } from './grass-schema'
|
||||
import type { TreePreset } from './schema'
|
||||
|
||||
/**
|
||||
* Bundled preset artwork. The webp live in `./assets` and travel with the
|
||||
* package — no CDN, no per-app `public/` mirroring. Both consumers are Next, so
|
||||
* `transpilePackages` runs these imports through the image pipeline and `.src`
|
||||
* is the hashed, cached URL. The panel renders each as an `<img src>`.
|
||||
*/
|
||||
const url = (asset: { src: string }): string => asset.src
|
||||
|
||||
export const TREE_ART: Record<TreePreset, string> = {
|
||||
oak: url(oak),
|
||||
pine: url(pine),
|
||||
aspen: url(aspen),
|
||||
ash: url(ash),
|
||||
bush: url(bush),
|
||||
trellis: url(trellis),
|
||||
}
|
||||
|
||||
export const FLOWER_ART: Record<FlowerPreset, string> = {
|
||||
daisy: url(daisy),
|
||||
tulip: url(tulip),
|
||||
lavender: url(lavender),
|
||||
}
|
||||
|
||||
export const GRASS_ART: Record<GrassPreset, string> = {
|
||||
meadow: url(meadow),
|
||||
fescue: url(fescue),
|
||||
reed: url(reed),
|
||||
}
|
||||
|
||||
/** The Nature panel / section icon. */
|
||||
export const NATURE_ICON = url(natureIcon)
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,144 @@
|
||||
import type { HandleDescriptor, NodeDefinition } from '@pascal-app/core'
|
||||
import { buildTreeFloorplan, treeTrunkRadius } from './floorplan'
|
||||
import { treeParametrics } from './parametrics'
|
||||
import { TreeNode } from './schema'
|
||||
|
||||
const ROTATE_RING_OFFSET = 0.35
|
||||
/** Ring hugs the ground like the item gizmo — high enough to clear the grass,
|
||||
* low enough to read as a floor affordance. */
|
||||
const ROTATE_RING_Y = 0.25
|
||||
|
||||
/** Whole-tree Y-rotation gizmo (same rig as shelf/item): a ring around the
|
||||
* trunk near the ground — not the canopy, which would put the handle meters
|
||||
* from the trunk on a large oak. */
|
||||
function treeRotateHandle(): HandleDescriptor<TreeNode> {
|
||||
const ringRadius = (n: TreeNode) => treeTrunkRadius(n) + ROTATE_RING_OFFSET
|
||||
const ringY = () => ROTATE_RING_Y
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => {
|
||||
const r = initial.rotation ?? [0, 0, 0]
|
||||
// Negate to match three.js Y-rotation handedness (same as shelf).
|
||||
return { rotation: [r[0], (r[1] ?? 0) - delta, r[2]] as [number, number, number] }
|
||||
},
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const r = ringRadius(n)
|
||||
return [r * Math.SQRT1_2, ringY(), r * Math.SQRT1_2]
|
||||
},
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: ringRadius,
|
||||
y: ringY,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree node definition. Rendering uses the instanced path rather than the
|
||||
* per-node `def.geometry`: a collective `def.system` batches every tree into
|
||||
* `InstancedMesh`es (forest-scale draw calls), while a featherweight
|
||||
* `def.renderer` mounts an invisible per-node proxy so the host's selection /
|
||||
* outline / zone machinery works unchanged. `parametrics` gives the inspector
|
||||
* for free; `tool`/`preview` drive placement. No host dispatch code per kind.
|
||||
*/
|
||||
export const treeDefinition: NodeDefinition<typeof TreeNode> = {
|
||||
kind: 'trees:tree',
|
||||
// Static in the bake for portability; our viewer removes the baked meshes and
|
||||
// re-renders live (wind, LODs) via this def's own path. See plans → Part D.
|
||||
bake: 'replace',
|
||||
schemaVersion: 1,
|
||||
schema: TreeNode,
|
||||
category: 'furnish',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
preset: 'oak',
|
||||
size: 'medium',
|
||||
treeType: 'deciduous',
|
||||
height: 7,
|
||||
seed: 1,
|
||||
foliageDensity: 1,
|
||||
trunkThickness: 1,
|
||||
leafless: false,
|
||||
leafColor: '#ffffff',
|
||||
branchColor: '#ffffff',
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
rotatable: {
|
||||
axes: ['y'],
|
||||
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
|
||||
},
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
groupable: true,
|
||||
snappable: {},
|
||||
// The auto-measured drag box would wrap the whole canopy (the proxy shows
|
||||
// the real geometry while selected) — declare trunk-sized bounds instead.
|
||||
dragBounds: (node) => {
|
||||
const tree = node as unknown as TreeNode
|
||||
const radius = treeTrunkRadius(tree)
|
||||
return { size: [radius * 2, tree.height ?? 7, radius * 2] }
|
||||
},
|
||||
floorPlaced: {
|
||||
// `footprint` receives the host's `AnyNode`; cast to our schema type the
|
||||
// same way built-in kinds do (`node as ShelfNode`). Trunk-sized, not
|
||||
// canopy-sized — the drag/placement box should hug where the tree
|
||||
// actually plants, not span the whole crown.
|
||||
footprint: (node) => {
|
||||
const tree = node as unknown as TreeNode
|
||||
const radius = treeTrunkRadius(tree)
|
||||
return {
|
||||
dimensions: [radius * 2, tree.height, radius * 2] as [number, number, number],
|
||||
rotation: tree.rotation,
|
||||
}
|
||||
},
|
||||
collides: false,
|
||||
},
|
||||
},
|
||||
|
||||
parametrics: treeParametrics,
|
||||
// 2D plan symbol: dashed canopy ring + trunk dot (see floorplan.ts).
|
||||
floorplan: buildTreeFloorplan,
|
||||
handles: [treeRotateHandle()],
|
||||
|
||||
// Instanced rendering: an invisible per-node proxy for selection/outline...
|
||||
renderer: { kind: 'parametric', module: () => import('./proxy-renderer') },
|
||||
// ...and a collective system that batches every tree into InstancedMeshes.
|
||||
system: { module: () => import('./system'), priority: 3 },
|
||||
// Baked `/viewer` re-render for `bake: 'replace'` — collective, instanced.
|
||||
bakeReplaceRenderer: { module: () => import('./static-renderer') },
|
||||
|
||||
preview: () => import('./preview'),
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Plant tree' },
|
||||
{ key: 'Esc', label: 'Stop' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Tree',
|
||||
description: 'A procedural ez-tree. Oak, pine, aspen, ash, bush, or trellis.',
|
||||
icon: { kind: 'iconify', name: 'lucide:trees' },
|
||||
paletteSection: 'furnish',
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A procedural ez-tree (example plugin node). Species presets (oak/pine/aspen/ash/bush/trellis) × size, deciduous/evergreen type, adjustable height, foliage/trunk, leaf & branch tint, and a seed for variation.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type AnyNode, emitter } from '@pascal-app/core'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import type { TreeNode } from './schema'
|
||||
import { type TreesPanelMode, useTreesStore } from './store'
|
||||
|
||||
/**
|
||||
* "Find in catalog" sync. The editor's node action menu emits
|
||||
* `selection:find-node`; the host opens the panel that owns the kind (via
|
||||
* `panelRegistry.panelForKind`) — but which *section* of the Nature panel to
|
||||
* show is plugin knowledge, so the plugin listens too and points its own store
|
||||
* at the found node's section + preset. Module-level (imported by the plugin
|
||||
* manifest) so the listener is live from plugin load, even while the panel has
|
||||
* never been mounted.
|
||||
*/
|
||||
|
||||
const MODE_BY_KIND: Record<string, TreesPanelMode> = {
|
||||
'trees:tree': 'trees',
|
||||
'trees:flower': 'flowers',
|
||||
'trees:grass': 'grass',
|
||||
}
|
||||
|
||||
emitter.on(
|
||||
'selection:find-node' as never,
|
||||
((node: AnyNode) => {
|
||||
const mode = MODE_BY_KIND[node.type as string]
|
||||
if (!mode) return
|
||||
const store = useTreesStore.getState()
|
||||
store.setMode(mode)
|
||||
if (mode === 'trees') {
|
||||
const tree = node as unknown as TreeNode
|
||||
store.setPreset(tree.preset ?? 'oak')
|
||||
store.setSize(tree.size ?? 'medium')
|
||||
} else if (mode === 'flowers') {
|
||||
store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy')
|
||||
} else {
|
||||
store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow')
|
||||
}
|
||||
}) as never,
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
|
||||
import { flowerPetalColor } from './flower-geometry'
|
||||
import { FLOWER_PRESETS } from './flower-presets'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import { GRASS_PRESETS } from './grass-presets'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import { TREE_PRESETS } from './presets'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
/**
|
||||
* 2D plan builders for the plant kinds (`def.floorplan`) — the registry
|
||||
* floor-plan layer renders any kind that provides one, so this is all it takes
|
||||
* for plugin nodes to appear in the 2D view. Classic architect symbols:
|
||||
* a dashed canopy circle (dashed = overhead element, like a roof overhang)
|
||||
* with a solid trunk dot for trees; small colour dots for flowers/grass.
|
||||
*/
|
||||
|
||||
/** Trunk radius in plan — also the selection footprint, so the move box hugs
|
||||
* the trunk instead of the whole canopy. */
|
||||
export function treeTrunkRadius(tree: TreeNode): number {
|
||||
return Math.max(0.15, (tree.height ?? 7) * 0.025 * (tree.trunkThickness ?? 1))
|
||||
}
|
||||
|
||||
/** Approximate canopy radius in plan (matches the old whole-tree footprint). */
|
||||
export function treeCanopyRadius(tree: TreeNode): number {
|
||||
return Math.max(0.5, (tree.height ?? 7) * 0.28)
|
||||
}
|
||||
|
||||
type ViewChrome = { stroke: string | null; selected: boolean }
|
||||
|
||||
/** Selection/hover stroke override shared by the three builders. */
|
||||
function chromeOf(ctx: GeometryContext): ViewChrome {
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
if ((view?.selected || view?.highlighted) && palette)
|
||||
return { stroke: palette.selectedStroke, selected: view?.selected ?? false }
|
||||
if (view?.hovered && palette) return { stroke: palette.wallHoverStroke, selected: false }
|
||||
return { stroke: null, selected: false }
|
||||
}
|
||||
|
||||
export function buildTreeFloorplan(node: TreeNode, ctx: GeometryContext): FloorplanGeometry {
|
||||
const [x, , z] = node.position ?? [0, 0, 0]
|
||||
const swatch = (TREE_PRESETS[node.preset] ?? TREE_PRESETS.oak).swatch
|
||||
const chrome = chromeOf(ctx)
|
||||
const stroke = chrome.stroke ?? swatch
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
// Canopy ring — pointer-events on the stroke only, so the (large) disc
|
||||
// doesn't steal clicks from whatever sits under the canopy in plan.
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: treeCanopyRadius(node),
|
||||
stroke,
|
||||
strokeWidth: 0.03,
|
||||
strokeDasharray: '0.18 0.12',
|
||||
fill: swatch,
|
||||
fillOpacity: 0.06,
|
||||
pointerEvents: 'stroke',
|
||||
},
|
||||
// Trunk dot — the solid, always-clickable core of the symbol.
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: treeTrunkRadius(node),
|
||||
fill: chrome.stroke ?? '#6b4f2e',
|
||||
stroke,
|
||||
strokeWidth: 0.02,
|
||||
opacity: 0.95,
|
||||
},
|
||||
]
|
||||
if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] })
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
export function buildFlowerFloorplan(node: FlowerNode, ctx: GeometryContext): FloorplanGeometry {
|
||||
const [x, , z] = node.position ?? [0, 0, 0]
|
||||
const preset = FLOWER_PRESETS[node.preset] ?? FLOWER_PRESETS.daisy
|
||||
const chrome = chromeOf(ctx)
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: 0.1,
|
||||
fill: flowerPetalColor(node),
|
||||
stroke: chrome.stroke ?? preset.stemColor,
|
||||
strokeWidth: 0.02,
|
||||
},
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: 0.035,
|
||||
fill: preset.centerColor,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]
|
||||
if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] })
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
export function buildGrassFloorplan(node: GrassNode, ctx: GeometryContext): FloorplanGeometry {
|
||||
const [x, , z] = node.position ?? [0, 0, 0]
|
||||
const preset = GRASS_PRESETS[node.preset] ?? GRASS_PRESETS.meadow
|
||||
const blade = node.bladeColor ?? preset.bladeColor
|
||||
const chrome = chromeOf(ctx)
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: 0.12,
|
||||
fill: blade,
|
||||
fillOpacity: 0.5,
|
||||
stroke: chrome.stroke ?? blade,
|
||||
strokeWidth: 0.02,
|
||||
strokeDasharray: '0.06 0.05',
|
||||
},
|
||||
]
|
||||
if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] })
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildFlowerFloorplan } from './floorplan'
|
||||
import { flowerParametrics } from './flower-parametrics'
|
||||
import { FlowerNode } from './flower-schema'
|
||||
|
||||
/**
|
||||
* The flower node definition — a sibling instanced kind to the tree. Same
|
||||
* composition: a `def.system` batches every flower into InstancedMeshes, a
|
||||
* featherweight `def.renderer` proxy keeps selection working, `parametrics`
|
||||
* gives the inspector, `tool`/`preview` drive placement.
|
||||
*/
|
||||
export const flowerDefinition: NodeDefinition<typeof FlowerNode> = {
|
||||
kind: 'trees:flower',
|
||||
bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D
|
||||
schemaVersion: 1,
|
||||
schema: FlowerNode,
|
||||
category: 'furnish',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
preset: 'daisy',
|
||||
height: 0.5,
|
||||
seed: 1,
|
||||
petalColor: '#fcfcf2',
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
rotatable: {
|
||||
axes: ['y'],
|
||||
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
|
||||
},
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
groupable: true,
|
||||
snappable: {},
|
||||
floorPlaced: {
|
||||
footprint: (node) => {
|
||||
const flower = node as unknown as FlowerNode
|
||||
const radius = Math.max(0.1, flower.height * 0.25)
|
||||
return {
|
||||
dimensions: [radius * 2, flower.height, radius * 2] as [number, number, number],
|
||||
rotation: flower.rotation,
|
||||
}
|
||||
},
|
||||
collides: false,
|
||||
},
|
||||
},
|
||||
|
||||
parametrics: flowerParametrics,
|
||||
floorplan: buildFlowerFloorplan,
|
||||
|
||||
renderer: { kind: 'parametric', module: () => import('./flower-proxy-renderer') },
|
||||
system: { module: () => import('./flower-system'), priority: 3 },
|
||||
bakeReplaceRenderer: { module: () => import('./flower-static-renderer') },
|
||||
|
||||
preview: () => import('./flower-preview'),
|
||||
tool: () => import('./flower-tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Plant flower' },
|
||||
{ key: 'Esc', label: 'Stop' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Flower',
|
||||
description: 'A procedural flower. Daisy, tulip, or lavender.',
|
||||
icon: { kind: 'iconify', name: 'lucide:flower-2' },
|
||||
paletteSection: 'furnish',
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A procedural flower (example plugin node) — daisy, tulip, or lavender, instanced like the trees.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
type BufferGeometry,
|
||||
ConeGeometry,
|
||||
CylinderGeometry,
|
||||
Group,
|
||||
Mesh,
|
||||
SphereGeometry,
|
||||
} from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { FLOWER_PRESETS } from './flower-presets'
|
||||
import type { FlowerNode, FlowerPreset } from './flower-schema'
|
||||
import type { SubMesh, VariantData } from './instanced'
|
||||
import { mulberry32, naturalHeight } from './variant-utils'
|
||||
import { windStandardMaterial } from './wind-node'
|
||||
|
||||
export function flowerVariantKey(preset: FlowerPreset, seed: number, petalColor: string): string {
|
||||
return `${preset}:${seed}:${petalColor}`
|
||||
}
|
||||
|
||||
/** Petal colour with fallbacks — nodes persisted before the field existed load
|
||||
* without it, so fall back to the preset colour rather than crash/blank. */
|
||||
export function flowerPetalColor(node: FlowerNode): string {
|
||||
return node.petalColor ?? FLOWER_PRESETS[node.preset]?.petalColor ?? '#fcfcf2'
|
||||
}
|
||||
|
||||
const variantCache = new Map<string, VariantData>()
|
||||
|
||||
/** Cached procedural flower geometry for a (preset, seed, petalColor). Like the
|
||||
* trees, one generation per variant is shared across every instance. Built
|
||||
* merged per material so each variant is ~3 InstancedMeshes (stem/petals/center). */
|
||||
export function getFlowerVariant(node: FlowerNode): VariantData {
|
||||
const petalColor = flowerPetalColor(node)
|
||||
const key = flowerVariantKey(node.preset, node.seed, petalColor)
|
||||
const cached = variantCache.get(key)
|
||||
if (cached) return cached
|
||||
const group = buildFlower(node.preset, node.seed, petalColor)
|
||||
const subMeshes: SubMesh[] = group.children
|
||||
.filter((c): c is Mesh => (c as Mesh).isMesh)
|
||||
.map((mesh) => ({ geometry: mesh.geometry, material: mesh.material }))
|
||||
const data: VariantData = { subMeshes, naturalHeight: naturalHeight(group) }
|
||||
variantCache.set(key, data)
|
||||
return data
|
||||
}
|
||||
|
||||
function buildFlower(preset: FlowerPreset, seed: number, petalColor: string): Group {
|
||||
const spec = FLOWER_PRESETS[preset] ?? FLOWER_PRESETS.daisy
|
||||
const rng = mulberry32(seed >>> 0)
|
||||
const group = new Group()
|
||||
const stemMat = windStandardMaterial({ color: spec.stemColor, roughness: 0.85 })
|
||||
const petalMat = windStandardMaterial({ color: petalColor, roughness: 0.7 })
|
||||
const centerMat = windStandardMaterial({ color: spec.centerColor, roughness: 0.6 })
|
||||
const stemH = spec.defaultHeight
|
||||
|
||||
const stem = new CylinderGeometry(0.008, 0.015, stemH, 5)
|
||||
stem.translate(0, stemH / 2, 0)
|
||||
group.add(new Mesh(stem, stemMat))
|
||||
|
||||
if (preset === 'lavender') {
|
||||
// A spike of small florets along the top ~45% of the stem.
|
||||
const florets: BufferGeometry[] = []
|
||||
const count = 26
|
||||
const spikeBase = stemH * 0.55
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = i / count
|
||||
const y = spikeBase + t * (stemH - spikeBase)
|
||||
const angle = i * 2.4 + rng() * 0.5
|
||||
const r = (1 - t) * 0.035 + 0.008
|
||||
const f = new SphereGeometry(0.016 * (1 - t * 0.4), 5, 4)
|
||||
f.translate(Math.cos(angle) * r, y, Math.sin(angle) * r)
|
||||
florets.push(f)
|
||||
}
|
||||
group.add(new Mesh(mergeGeometries(florets, false) ?? florets[0], petalMat))
|
||||
return group
|
||||
}
|
||||
|
||||
if (preset === 'tulip') {
|
||||
// Six petals forming an upward cup.
|
||||
const petals: BufferGeometry[] = []
|
||||
const n = 6
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = (i / n) * Math.PI * 2 + rng() * 0.1
|
||||
const p = new ConeGeometry(0.045, 0.16, 4)
|
||||
p.translate(0, 0.08, 0)
|
||||
p.rotateZ(0.45)
|
||||
p.rotateY(-angle)
|
||||
p.translate(Math.cos(angle) * 0.03, stemH, Math.sin(angle) * 0.03)
|
||||
petals.push(p)
|
||||
}
|
||||
group.add(new Mesh(mergeGeometries(petals, false) ?? petals[0], petalMat))
|
||||
const core = new ConeGeometry(0.02, 0.1, 4)
|
||||
core.translate(0, stemH + 0.06, 0)
|
||||
group.add(new Mesh(core, centerMat))
|
||||
return group
|
||||
}
|
||||
|
||||
// daisy — a yellow disc with a ring of white petals.
|
||||
const center = new SphereGeometry(0.035, 8, 6)
|
||||
center.scale(1, 0.6, 1)
|
||||
center.translate(0, stemH, 0)
|
||||
group.add(new Mesh(center, centerMat))
|
||||
|
||||
const petals: BufferGeometry[] = []
|
||||
const n = 12
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = (i / n) * Math.PI * 2 + rng() * 0.08
|
||||
const p = new ConeGeometry(0.02, 0.08, 3)
|
||||
p.rotateZ(Math.PI / 2)
|
||||
p.translate(0.07, 0, 0)
|
||||
p.rotateY(-angle)
|
||||
p.translate(0, stemH + 0.005, 0)
|
||||
petals.push(p)
|
||||
}
|
||||
group.add(new Mesh(mergeGeometries(petals, false) ?? petals[0], petalMat))
|
||||
return group
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
|
||||
/** Inspector for a placed flower — rendered for free by the host's
|
||||
* `ParametricInspector` from this descriptor. */
|
||||
export const flowerParametrics: ParametricDescriptor<FlowerNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Flower',
|
||||
fields: [
|
||||
{ key: 'preset', kind: 'enum', options: ['daisy', 'tulip', 'lavender'] },
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 0.2, max: 2, step: 0.05 },
|
||||
{ key: 'petalColor', kind: 'color' },
|
||||
{ key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Position',
|
||||
fields: [{ key: 'position', kind: 'vec3' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FLOWER_ART } from './art'
|
||||
import type { FlowerPreset } from './flower-schema'
|
||||
|
||||
/** Per-flower colours, default height (metres), and a card `thumbnail` (a
|
||||
* replaceable placeholder image — see `thumbnails.ts`). Pure data shared by the
|
||||
* geometry builder and the panel. */
|
||||
export type FlowerPresetSpec = {
|
||||
id: FlowerPreset
|
||||
label: string
|
||||
petalColor: string
|
||||
centerColor: string
|
||||
stemColor: string
|
||||
defaultHeight: number
|
||||
swatch: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export const FLOWER_PRESETS: Record<FlowerPreset, FlowerPresetSpec> = {
|
||||
daisy: {
|
||||
id: 'daisy',
|
||||
label: 'Daisy',
|
||||
petalColor: '#fcfcf2',
|
||||
centerColor: '#f4c430',
|
||||
stemColor: '#4f7942',
|
||||
defaultHeight: 0.5,
|
||||
swatch: '#f4c430',
|
||||
thumbnail: FLOWER_ART.daisy,
|
||||
},
|
||||
tulip: {
|
||||
id: 'tulip',
|
||||
label: 'Tulip',
|
||||
petalColor: '#e0457b',
|
||||
centerColor: '#c43160',
|
||||
stemColor: '#3f7a3a',
|
||||
defaultHeight: 0.45,
|
||||
swatch: '#e0457b',
|
||||
thumbnail: FLOWER_ART.tulip,
|
||||
},
|
||||
lavender: {
|
||||
id: 'lavender',
|
||||
label: 'Lavender',
|
||||
petalColor: '#9b6fd4',
|
||||
centerColor: '#7d52b8',
|
||||
stemColor: '#5a7a4a',
|
||||
defaultHeight: 0.6,
|
||||
swatch: '#9b6fd4',
|
||||
thumbnail: FLOWER_ART.lavender,
|
||||
},
|
||||
}
|
||||
|
||||
export const FLOWER_PRESET_LIST: FlowerPresetSpec[] = Object.values(FLOWER_PRESETS)
|
||||
|
||||
/** Bounded seed pool so flowers share instancing variants (see trees). */
|
||||
export const FLOWER_SEED_POOL = [1, 7, 13, 21, 34, 55]
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { Material, MeshStandardMaterial } from 'three'
|
||||
import { getFlowerVariant } from './flower-geometry'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
|
||||
const NO_RAYCAST = () => {}
|
||||
|
||||
/** Translucent placement ghost for a flower — clones the variant materials so
|
||||
* the cursor preview is see-through without mutating the cached originals. */
|
||||
export default function FlowerPreview({ node }: { node: FlowerNode }) {
|
||||
const data = useMemo(() => getFlowerVariant(node), [node])
|
||||
const scale = node.height / data.naturalHeight
|
||||
|
||||
const ghosts = useMemo(
|
||||
() =>
|
||||
data.subMeshes.map((sub) => {
|
||||
const base = (
|
||||
Array.isArray(sub.material) ? sub.material[0] : sub.material
|
||||
) as MeshStandardMaterial
|
||||
const clone = base.clone()
|
||||
clone.transparent = true
|
||||
clone.opacity = 0.55
|
||||
clone.depthWrite = false
|
||||
return clone
|
||||
}),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const m of ghosts as Material[]) m.dispose()
|
||||
},
|
||||
[ghosts],
|
||||
)
|
||||
|
||||
return (
|
||||
<group scale={scale}>
|
||||
{data.subMeshes.map((sub, i) => (
|
||||
<mesh
|
||||
dispose={null}
|
||||
geometry={sub.geometry}
|
||||
key={i}
|
||||
material={ghosts[i]}
|
||||
raycast={NO_RAYCAST}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { getFlowerVariant } from './flower-geometry'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import { KindProxy } from './instanced'
|
||||
|
||||
const getVariant = (node: FlowerNode) => getFlowerVariant(node)
|
||||
const colliderRadius = (node: FlowerNode) => Math.max(0.06, (node.height ?? 0.5) * 0.22)
|
||||
|
||||
/** Per-node selection proxy for the instanced flowers. */
|
||||
export default function FlowerProxyRenderer({ node }: { node: FlowerNode }) {
|
||||
return <KindProxy colliderRadius={colliderRadius} getVariant={getVariant} node={node} />
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseNode, nodeType, objectId } from '@pascal-app/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Flower silhouettes the plugin can place. The string persists in scene JSON. */
|
||||
export const FlowerPreset = z.enum(['daisy', 'tulip', 'lavender'])
|
||||
export type FlowerPreset = z.infer<typeof FlowerPreset>
|
||||
|
||||
/** A placed flower — a sibling instanced kind to the tree, sharing the same
|
||||
* instanced renderer + selection proxy via the generic `instanced` core. */
|
||||
export const FlowerNode = BaseNode.extend({
|
||||
id: objectId('flower'),
|
||||
type: nodeType('trees:flower'),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
preset: FlowerPreset.default('daisy'),
|
||||
height: z.number().positive().default(0.5),
|
||||
seed: z.number().int().default(1),
|
||||
/** Petal colour (hex). Baked from the preset at placement; recolour per-flower
|
||||
* in the inspector (the flower analog of the tree's leaf tint). */
|
||||
petalColor: z.string().default('#fcfcf2'),
|
||||
})
|
||||
|
||||
export type FlowerNode = z.infer<typeof FlowerNode>
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { flowerPetalColor, flowerVariantKey, getFlowerVariant } from './flower-geometry'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import { InstancedNodes } from './instanced'
|
||||
|
||||
const variantKeyOf = (node: FlowerNode) =>
|
||||
flowerVariantKey(node.preset, node.seed, flowerPetalColor(node))
|
||||
const getVariant = (node: FlowerNode) => getFlowerVariant(node)
|
||||
|
||||
/** Collective baked-`/viewer` renderer for one level's flowers (`bakeReplaceRenderer`). */
|
||||
export default function FlowerReplaceInstances({ nodes }: { nodes: FlowerNode[] }) {
|
||||
return (
|
||||
<InstancedNodes getVariant={getVariant} localSpace nodes={nodes} variantKeyOf={variantKeyOf} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { flowerPetalColor, flowerVariantKey, getFlowerVariant } from './flower-geometry'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import { InstancedKindSystem } from './instanced'
|
||||
|
||||
const variantKeyOf = (node: FlowerNode) =>
|
||||
flowerVariantKey(node.preset, node.seed, flowerPetalColor(node))
|
||||
const getVariant = (node: FlowerNode) => getFlowerVariant(node)
|
||||
|
||||
/** Collective instanced renderer for every placed flower (`def.system`). */
|
||||
export default function FlowersSystem() {
|
||||
return (
|
||||
<InstancedKindSystem<FlowerNode>
|
||||
getVariant={getVariant}
|
||||
kind="trees:flower"
|
||||
variantKeyOf={variantKeyOf}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { FLOWER_PRESETS, FLOWER_SEED_POOL } from './flower-presets'
|
||||
import FlowerPreview from './flower-preview'
|
||||
import { FlowerNode } from './flower-schema'
|
||||
import { usePlacement } from './placement'
|
||||
import { useTreesStore } from './store'
|
||||
|
||||
/** The flowers placement tool — mirrors the trees tool, reading the flower
|
||||
* brush from the shared store. Petal colour is baked from the preset at
|
||||
* placement, then editable per-flower in the inspector. */
|
||||
export default function FlowerTool() {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const preset = useTreesStore((s) => s.flowerPreset)
|
||||
const height = useTreesStore((s) => s.flowerHeight)
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
FlowerNode.parse({
|
||||
preset,
|
||||
height,
|
||||
petalColor: FLOWER_PRESETS[preset].petalColor,
|
||||
seed: 1,
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
[preset, height],
|
||||
)
|
||||
|
||||
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
|
||||
if (!activeLevelId) return
|
||||
const s = useTreesStore.getState()
|
||||
const flower = FlowerNode.parse({
|
||||
preset: s.flowerPreset,
|
||||
height: s.flowerHeight,
|
||||
petalColor: FLOWER_PRESETS[s.flowerPreset].petalColor,
|
||||
seed: FLOWER_SEED_POOL[Math.floor(Math.random() * FLOWER_SEED_POOL.length)] ?? 1,
|
||||
position,
|
||||
rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0],
|
||||
})
|
||||
useScene.getState().createNode(flower as unknown as AnyNode, activeLevelId as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [flower.id as AnyNodeId] })
|
||||
triggerSFX('sfx:item-place')
|
||||
})
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return (
|
||||
<group ref={cursorRef} visible={cursorVisible}>
|
||||
<FlowerPreview node={previewNode} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// ez-tree loads its inlined textures at module scope (needs `document`), so
|
||||
// this module must only be imported from lazy client modules (renderers,
|
||||
// systems, tools, previews) — never from `index.ts`, a definition, or
|
||||
// `floorplan.ts`, or SSR/prerender crashes. Pure helpers shared with the
|
||||
// flower/grass builders live in `variant-utils.ts` for that reason.
|
||||
import { Tree } from '@dgreenheck/ez-tree'
|
||||
import type { BufferGeometry, Material, Mesh, Object3D } from 'three'
|
||||
import { ezPresetOf } from './presets'
|
||||
import type { TreeNode } from './schema'
|
||||
import { naturalHeight } from './variant-utils'
|
||||
import { toWindMaterial } from './wind-node'
|
||||
|
||||
/** The geometry-affecting fields of a tree. Two trees with the same spec share
|
||||
* one generated variant (and thus one InstancedMesh set). Per-instance fields
|
||||
* (position/rotation/height) are deliberately NOT here — they're cheap matrix
|
||||
* work, not geometry. */
|
||||
export type TreeSpec = Pick<
|
||||
TreeNode,
|
||||
| 'preset'
|
||||
| 'size'
|
||||
| 'treeType'
|
||||
| 'seed'
|
||||
| 'foliageDensity'
|
||||
| 'trunkThickness'
|
||||
| 'leafless'
|
||||
| 'leafColor'
|
||||
| 'branchColor'
|
||||
>
|
||||
|
||||
export function treeSpecOf(node: TreeNode): TreeSpec {
|
||||
// Default the non-override fields (nodes persisted before a field existed load
|
||||
// without it). The four overrides are left as-is — `undefined` means "inherit
|
||||
// the ez-tree preset" (its own seed/type/tints), resolved in `generateTree`.
|
||||
return {
|
||||
preset: node.preset ?? 'oak',
|
||||
size: node.size ?? 'medium',
|
||||
treeType: node.treeType,
|
||||
seed: node.seed,
|
||||
foliageDensity: node.foliageDensity ?? 1,
|
||||
trunkThickness: node.trunkThickness ?? 1,
|
||||
leafless: node.leafless ?? false,
|
||||
leafColor: node.leafColor,
|
||||
branchColor: node.branchColor,
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable variant id. Trees with the same key share one set of InstancedMeshes. */
|
||||
export function treeVariantKey(spec: TreeSpec): string {
|
||||
return [
|
||||
spec.preset,
|
||||
spec.size,
|
||||
spec.treeType,
|
||||
spec.seed,
|
||||
spec.foliageDensity,
|
||||
spec.trunkThickness,
|
||||
spec.leafless,
|
||||
spec.leafColor,
|
||||
spec.branchColor,
|
||||
].join(':')
|
||||
}
|
||||
|
||||
/** `#rrggbb` → 0xrrggbb, defaulting to white on anything missing/unparseable. */
|
||||
function hexToInt(hex: string | undefined): number {
|
||||
const n = Number.parseInt((hex ?? '').replace('#', ''), 16)
|
||||
return Number.isFinite(n) ? n : 0xffffff
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an ez-tree for a spec. ez-tree's `Tree` is a `THREE.Group`; textures
|
||||
* are inlined in the library (no asset hosting). `loadPreset` owns the full look
|
||||
* (seed, growth model, tints, branch/leaf structure); the curated params then
|
||||
* apply *on top* — but only where the node actually set them, so an unset field
|
||||
* keeps the preset's value (its canonical silhouette/colours). `trunkThickness`
|
||||
* and `foliageDensity` are multipliers (1 = preset default). Pure given its
|
||||
* inputs — same spec ⇒ same tree — which lets the renderer cache one generation
|
||||
* per variant.
|
||||
*/
|
||||
export function generateTree(spec: TreeSpec): Tree {
|
||||
const tree = new Tree()
|
||||
tree.loadPreset(ezPresetOf(spec.preset, spec.size))
|
||||
if (spec.seed != null) tree.options.seed = spec.seed
|
||||
if (spec.treeType != null) (tree.options as { type: string }).type = spec.treeType
|
||||
|
||||
const radius = tree.options.branch.radius as unknown as Record<string, number>
|
||||
for (const level of Object.keys(radius)) {
|
||||
const value = radius[level]
|
||||
if (value !== undefined) radius[level] = value * spec.trunkThickness
|
||||
}
|
||||
|
||||
const leaves = tree.options.leaves as { count: number; tint: number }
|
||||
leaves.count = spec.leafless ? 0 : Math.round(leaves.count * spec.foliageDensity)
|
||||
if (spec.leafColor != null) leaves.tint = hexToInt(spec.leafColor)
|
||||
if (spec.branchColor != null)
|
||||
(tree.options.bark as { tint: number }).tint = hexToInt(spec.branchColor)
|
||||
|
||||
tree.generate()
|
||||
return tree
|
||||
}
|
||||
|
||||
/** A renderable sub-mesh of a tree: geometry (baked into tree-local space) +
|
||||
* its material. The instanced renderer builds one InstancedMesh per sub-mesh
|
||||
* per variant. */
|
||||
export type TreeSubMesh = { geometry: BufferGeometry; material: Material | Material[] }
|
||||
|
||||
/** Geometry + height for one tree variant, generated once and shared across
|
||||
* every instance of that spec. */
|
||||
export type TreeVariantData = { subMeshes: TreeSubMesh[]; naturalHeight: number }
|
||||
|
||||
const variantCache = new Map<string, TreeVariantData>()
|
||||
|
||||
/**
|
||||
* Cached geometry for a spec. ez-tree's `generate()` is heavy, so it runs once
|
||||
* per variant; the resulting geometries/materials are retained here and shared
|
||||
* by every instance. The renderer must NOT dispose them (it sets `dispose={null}`
|
||||
* on the InstancedMesh).
|
||||
*/
|
||||
export function getVariantData(spec: TreeSpec): TreeVariantData {
|
||||
const key = treeVariantKey(spec)
|
||||
const cached = variantCache.get(key)
|
||||
if (cached) return cached
|
||||
const tree = generateTree(spec)
|
||||
const data: TreeVariantData = {
|
||||
subMeshes: extractSubMeshes(tree),
|
||||
naturalHeight: naturalHeight(tree),
|
||||
}
|
||||
variantCache.set(key, data)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Extract the leaf/bark sub-meshes, baking each mesh's local transform into a
|
||||
* cloned geometry so instance matrices only carry the node's own transform. */
|
||||
export function extractSubMeshes(tree: Object3D): TreeSubMesh[] {
|
||||
const out: TreeSubMesh[] = []
|
||||
tree.traverse((child) => {
|
||||
const mesh = child as Partial<Mesh>
|
||||
if (mesh.isMesh && mesh.geometry && mesh.material) {
|
||||
const geometry = mesh.geometry.clone()
|
||||
;(child as Mesh).updateMatrix()
|
||||
geometry.applyMatrix4((child as Mesh).matrix)
|
||||
// Swap ez-tree's plain materials for swaying node materials (WebGPU/TSL).
|
||||
const material = Array.isArray(mesh.material)
|
||||
? mesh.material.map(toWindMaterial)
|
||||
: toWindMaterial(mesh.material)
|
||||
out.push({ geometry, material })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildGrassFloorplan } from './floorplan'
|
||||
import { grassParametrics } from './grass-parametrics'
|
||||
import { GrassNode } from './grass-schema'
|
||||
|
||||
/**
|
||||
* The grass node definition — a third instanced kind alongside trees & flowers.
|
||||
* Same composition: a `def.system` batches every tuft into InstancedMeshes, a
|
||||
* featherweight `def.renderer` proxy keeps selection working, `parametrics`
|
||||
* gives the inspector, `tool`/`preview` drive placement.
|
||||
*/
|
||||
export const grassDefinition: NodeDefinition<typeof GrassNode> = {
|
||||
kind: 'trees:grass',
|
||||
bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D
|
||||
schemaVersion: 1,
|
||||
schema: GrassNode,
|
||||
category: 'furnish',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
preset: 'meadow',
|
||||
height: 0.4,
|
||||
seed: 1,
|
||||
bladeColor: '#5a8f3c',
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
rotatable: {
|
||||
axes: ['y'],
|
||||
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
|
||||
},
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
groupable: true,
|
||||
snappable: {},
|
||||
floorPlaced: {
|
||||
footprint: (node) => {
|
||||
const grass = node as unknown as GrassNode
|
||||
const radius = Math.max(0.1, grass.height * 0.3)
|
||||
return {
|
||||
dimensions: [radius * 2, grass.height, radius * 2] as [number, number, number],
|
||||
rotation: grass.rotation,
|
||||
}
|
||||
},
|
||||
collides: false,
|
||||
},
|
||||
},
|
||||
|
||||
parametrics: grassParametrics,
|
||||
floorplan: buildGrassFloorplan,
|
||||
|
||||
renderer: { kind: 'parametric', module: () => import('./grass-proxy-renderer') },
|
||||
system: { module: () => import('./grass-system'), priority: 3 },
|
||||
bakeReplaceRenderer: { module: () => import('./grass-static-renderer') },
|
||||
|
||||
preview: () => import('./grass-preview'),
|
||||
tool: () => import('./grass-tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Plant grass' },
|
||||
{ key: 'Esc', label: 'Stop' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Grass',
|
||||
description: 'A procedural grass tuft. Meadow, fescue, or reed.',
|
||||
icon: { kind: 'iconify', name: 'lucide:wheat' },
|
||||
paletteSection: 'furnish',
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A procedural grass tuft (example plugin node) — meadow, fescue, or reed, instanced like the trees.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type BufferGeometry, ConeGeometry, DoubleSide, Group, Mesh } from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { GRASS_PRESETS } from './grass-presets'
|
||||
import type { GrassNode, GrassPreset } from './grass-schema'
|
||||
import type { SubMesh, VariantData } from './instanced'
|
||||
import { mulberry32, naturalHeight } from './variant-utils'
|
||||
import { windStandardMaterial } from './wind-node'
|
||||
|
||||
export function grassVariantKey(preset: GrassPreset, seed: number, bladeColor: string): string {
|
||||
return `${preset}:${seed}:${bladeColor}`
|
||||
}
|
||||
|
||||
const variantCache = new Map<string, VariantData>()
|
||||
|
||||
/** Cached procedural grass geometry for a (preset, seed, bladeColor). One
|
||||
* generation per variant is shared across every instance — a whole lawn of the
|
||||
* same tuft is a single InstancedMesh. */
|
||||
export function getGrassVariant(node: GrassNode): VariantData {
|
||||
const key = grassVariantKey(node.preset, node.seed, node.bladeColor)
|
||||
const cached = variantCache.get(key)
|
||||
if (cached) return cached
|
||||
const group = buildGrass(node.preset, node.seed, node.bladeColor)
|
||||
const subMeshes: SubMesh[] = group.children
|
||||
.filter((c): c is Mesh => (c as Mesh).isMesh)
|
||||
.map((mesh) => ({ geometry: mesh.geometry, material: mesh.material }))
|
||||
const data: VariantData = { subMeshes, naturalHeight: naturalHeight(group) }
|
||||
variantCache.set(key, data)
|
||||
return data
|
||||
}
|
||||
|
||||
/** A tuft of flattened, leaning blades merged into one geometry (one draw per
|
||||
* instance). Deterministic in `seed` so the same variant renders identically. */
|
||||
function buildGrass(preset: GrassPreset, seed: number, bladeColor: string): Group {
|
||||
const spec = GRASS_PRESETS[preset] ?? GRASS_PRESETS.meadow
|
||||
const rng = mulberry32(seed >>> 0)
|
||||
const group = new Group()
|
||||
const mat = windStandardMaterial({ color: bladeColor, roughness: 0.9, side: DoubleSide })
|
||||
const h = spec.defaultHeight
|
||||
|
||||
const blades: BufferGeometry[] = []
|
||||
for (let i = 0; i < spec.blades; i++) {
|
||||
const bh = h * (0.6 + rng() * 0.6)
|
||||
const blade = new ConeGeometry(0.02, bh, 3)
|
||||
blade.scale(1, 1, 0.3) // flatten the cone into a blade
|
||||
blade.translate(0, bh / 2, 0)
|
||||
blade.rotateZ((rng() - 0.5) * 0.7) // lean
|
||||
const angle = rng() * Math.PI * 2
|
||||
blade.rotateY(angle)
|
||||
const r = rng() * 0.07
|
||||
blade.translate(Math.cos(angle) * r, 0, Math.sin(angle) * r)
|
||||
blades.push(blade)
|
||||
}
|
||||
group.add(new Mesh(mergeGeometries(blades, false) ?? blades[0], mat))
|
||||
return group
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
|
||||
/** Inspector for a placed grass tuft — rendered for free by the host's
|
||||
* `ParametricInspector` from this descriptor. */
|
||||
export const grassParametrics: ParametricDescriptor<GrassNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Grass',
|
||||
fields: [
|
||||
{ key: 'preset', kind: 'enum', options: ['meadow', 'fescue', 'reed'] },
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.05 },
|
||||
{ key: 'bladeColor', kind: 'color' },
|
||||
{ key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Position',
|
||||
fields: [{ key: 'position', kind: 'vec3' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { GRASS_ART } from './art'
|
||||
import type { GrassPreset } from './grass-schema'
|
||||
|
||||
/** Per-grass config: blade colour, blade count per tuft, default height (metres),
|
||||
* a swatch, and a replaceable card `thumbnail` (see `thumbnails.ts`). Pure data
|
||||
* shared by the geometry builder and the panel. */
|
||||
export type GrassPresetSpec = {
|
||||
id: GrassPreset
|
||||
label: string
|
||||
bladeColor: string
|
||||
blades: number
|
||||
defaultHeight: number
|
||||
swatch: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export const GRASS_PRESETS: Record<GrassPreset, GrassPresetSpec> = {
|
||||
meadow: {
|
||||
id: 'meadow',
|
||||
label: 'Meadow',
|
||||
bladeColor: '#5a8f3c',
|
||||
blades: 10,
|
||||
defaultHeight: 0.4,
|
||||
swatch: '#5a8f3c',
|
||||
thumbnail: GRASS_ART.meadow,
|
||||
},
|
||||
fescue: {
|
||||
id: 'fescue',
|
||||
label: 'Fescue',
|
||||
bladeColor: '#7fae55',
|
||||
blades: 8,
|
||||
defaultHeight: 0.7,
|
||||
swatch: '#7fae55',
|
||||
thumbnail: GRASS_ART.fescue,
|
||||
},
|
||||
reed: {
|
||||
id: 'reed',
|
||||
label: 'Reed',
|
||||
bladeColor: '#4a7d63',
|
||||
blades: 6,
|
||||
defaultHeight: 1.1,
|
||||
swatch: '#4a7d63',
|
||||
thumbnail: GRASS_ART.reed,
|
||||
},
|
||||
}
|
||||
|
||||
export const GRASS_PRESET_LIST: GrassPresetSpec[] = Object.values(GRASS_PRESETS)
|
||||
|
||||
/** Bounded seed pool so grass tufts share instancing variants (see trees). */
|
||||
export const GRASS_SEED_POOL = [1, 7, 13, 21, 34]
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { Material, MeshStandardMaterial } from 'three'
|
||||
import { getGrassVariant } from './grass-geometry'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
|
||||
const NO_RAYCAST = () => {}
|
||||
|
||||
/** Translucent placement ghost for a grass tuft — clones the variant materials
|
||||
* so the cursor preview is see-through without mutating the cached originals. */
|
||||
export default function GrassPreview({ node }: { node: GrassNode }) {
|
||||
const data = useMemo(() => getGrassVariant(node), [node])
|
||||
const scale = node.height / data.naturalHeight
|
||||
|
||||
const ghosts = useMemo(
|
||||
() =>
|
||||
data.subMeshes.map((sub) => {
|
||||
const base = (
|
||||
Array.isArray(sub.material) ? sub.material[0] : sub.material
|
||||
) as MeshStandardMaterial
|
||||
const clone = base.clone()
|
||||
clone.transparent = true
|
||||
clone.opacity = 0.55
|
||||
clone.depthWrite = false
|
||||
return clone
|
||||
}),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const m of ghosts as Material[]) m.dispose()
|
||||
},
|
||||
[ghosts],
|
||||
)
|
||||
|
||||
return (
|
||||
<group scale={scale}>
|
||||
{data.subMeshes.map((sub, i) => (
|
||||
<mesh
|
||||
dispose={null}
|
||||
geometry={sub.geometry}
|
||||
key={i}
|
||||
material={ghosts[i]}
|
||||
raycast={NO_RAYCAST}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { getGrassVariant } from './grass-geometry'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import { KindProxy } from './instanced'
|
||||
|
||||
const getVariant = (node: GrassNode) => getGrassVariant(node)
|
||||
const colliderRadius = (node: GrassNode) => Math.max(0.08, (node.height ?? 0.4) * 0.3)
|
||||
|
||||
/** Per-node selection proxy for the instanced grass tufts. */
|
||||
export default function GrassProxyRenderer({ node }: { node: GrassNode }) {
|
||||
return <KindProxy colliderRadius={colliderRadius} getVariant={getVariant} node={node} />
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseNode, nodeType, objectId } from '@pascal-app/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Grass tufts the plugin can place. The string persists in scene JSON. */
|
||||
export const GrassPreset = z.enum(['meadow', 'fescue', 'reed'])
|
||||
export type GrassPreset = z.infer<typeof GrassPreset>
|
||||
|
||||
/** A placed grass tuft — a third instanced kind alongside trees & flowers,
|
||||
* sharing the same instanced renderer + selection proxy via `instanced`. */
|
||||
export const GrassNode = BaseNode.extend({
|
||||
id: objectId('grass'),
|
||||
type: nodeType('trees:grass'),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
preset: GrassPreset.default('meadow'),
|
||||
height: z.number().positive().default(0.4),
|
||||
seed: z.number().int().default(1),
|
||||
/** Blade colour (hex). Baked from the preset at placement; recolour per-tuft
|
||||
* in the inspector. */
|
||||
bladeColor: z.string().default('#5a8f3c'),
|
||||
})
|
||||
|
||||
export type GrassNode = z.infer<typeof GrassNode>
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { getGrassVariant, grassVariantKey } from './grass-geometry'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import { InstancedNodes } from './instanced'
|
||||
|
||||
const variantKeyOf = (node: GrassNode) => grassVariantKey(node.preset, node.seed, node.bladeColor)
|
||||
const getVariant = (node: GrassNode) => getGrassVariant(node)
|
||||
|
||||
/** Collective baked-`/viewer` renderer for one level's grass (`bakeReplaceRenderer`). */
|
||||
export default function GrassReplaceInstances({ nodes }: { nodes: GrassNode[] }) {
|
||||
return (
|
||||
<InstancedNodes getVariant={getVariant} localSpace nodes={nodes} variantKeyOf={variantKeyOf} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { getGrassVariant, grassVariantKey } from './grass-geometry'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import { InstancedKindSystem } from './instanced'
|
||||
|
||||
const variantKeyOf = (node: GrassNode) => grassVariantKey(node.preset, node.seed, node.bladeColor)
|
||||
const getVariant = (node: GrassNode) => getGrassVariant(node)
|
||||
|
||||
/** Collective instanced renderer for every placed grass tuft (`def.system`). */
|
||||
export default function GrassSystem() {
|
||||
return (
|
||||
<InstancedKindSystem<GrassNode>
|
||||
getVariant={getVariant}
|
||||
kind="trees:grass"
|
||||
variantKeyOf={variantKeyOf}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { GRASS_PRESETS, GRASS_SEED_POOL } from './grass-presets'
|
||||
import GrassPreview from './grass-preview'
|
||||
import { GrassNode } from './grass-schema'
|
||||
import { usePlacement } from './placement'
|
||||
import { useTreesStore } from './store'
|
||||
|
||||
/** The grass placement tool — mirrors the trees/flowers tools, reading the grass
|
||||
* brush from the shared store. Blade colour is baked from the preset at
|
||||
* placement, then editable per-tuft in the inspector. */
|
||||
export default function GrassTool() {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const preset = useTreesStore((s) => s.grassPreset)
|
||||
const height = useTreesStore((s) => s.grassHeight)
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
GrassNode.parse({
|
||||
preset,
|
||||
height,
|
||||
bladeColor: GRASS_PRESETS[preset].bladeColor,
|
||||
seed: 1,
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
[preset, height],
|
||||
)
|
||||
|
||||
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
|
||||
if (!activeLevelId) return
|
||||
const s = useTreesStore.getState()
|
||||
const grass = GrassNode.parse({
|
||||
preset: s.grassPreset,
|
||||
height: s.grassHeight,
|
||||
bladeColor: GRASS_PRESETS[s.grassPreset].bladeColor,
|
||||
seed: GRASS_SEED_POOL[Math.floor(Math.random() * GRASS_SEED_POOL.length)] ?? 1,
|
||||
position,
|
||||
rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0],
|
||||
})
|
||||
useScene.getState().createNode(grass as unknown as AnyNode, activeLevelId as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [grass.id as AnyNodeId] })
|
||||
triggerSFX('sfx:item-place')
|
||||
})
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return (
|
||||
<group ref={cursorRef} visible={cursorVisible}>
|
||||
<GrassPreview node={previewNode} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three'
|
||||
import { toStaticMaterial } from './wind-node'
|
||||
|
||||
/**
|
||||
* Generic instanced-rendering core shared by every plant kind (trees, flowers,
|
||||
* …). A kind plugs in two pure functions — `variantKeyOf` (how to bucket nodes
|
||||
* that can share geometry) and `getVariant` (cached geometry for a node) — and
|
||||
* gets forest-scale instancing plus true-silhouette selection for free.
|
||||
*/
|
||||
|
||||
export type SubMesh = { geometry: BufferGeometry; material: Material | Material[] }
|
||||
export type VariantData = { subMeshes: SubMesh[]; naturalHeight: number }
|
||||
|
||||
/** The shape every placeable plant node shares. */
|
||||
export interface Placeable {
|
||||
id: string
|
||||
type: string
|
||||
parentId: string | null
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
height: number
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
const DUMMY = new Object3D()
|
||||
const INSTANCE_MATRIX = new Matrix4()
|
||||
const NO_RAYCAST = () => {}
|
||||
|
||||
// Wind is a TSL vertex bend baked into the variant materials (see `wind-node.ts`)
|
||||
// — animated on the GPU, so the instance matrices here stay static.
|
||||
|
||||
// ── Collective instanced renderer (a `def.system`) ───────────────────────────
|
||||
|
||||
export function InstancedKindSystem<N extends Placeable>({
|
||||
kind,
|
||||
variantKeyOf,
|
||||
getVariant,
|
||||
}: {
|
||||
kind: string
|
||||
variantKeyOf: (node: N) => string
|
||||
getVariant: (node: N) => VariantData
|
||||
}) {
|
||||
const scene = useScene((s) => s.nodes)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
// Hovered/selected plants render through their proxy instead (real geometry,
|
||||
// static materials) so the outline matches the visible mesh and a move drag
|
||||
// animates in realtime — skip them here to avoid a double draw. Keyed by the
|
||||
// *relevant* ids only, so hovering unrelated kinds doesn't churn matrices.
|
||||
const activeKey = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
if (hoveredId && (scene[hoveredId as AnyNodeId]?.type as string) === kind) ids.push(hoveredId)
|
||||
for (const id of selectedIds) {
|
||||
if ((scene[id as AnyNodeId]?.type as string) === kind) ids.push(id)
|
||||
}
|
||||
return ids.sort().join('|')
|
||||
}, [scene, kind, hoveredId, selectedIds])
|
||||
const nodes = useMemo(() => {
|
||||
const active = new Set(activeKey ? activeKey.split('|') : [])
|
||||
return Object.values(scene).filter(
|
||||
(n) => (n.type as string) === kind && !active.has(n.id as string),
|
||||
) as unknown as N[]
|
||||
}, [scene, kind, activeKey])
|
||||
return <InstancedNodes getVariant={getVariant} nodes={nodes} variantKeyOf={variantKeyOf} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance a given set of nodes, bucketed by geometry variant. Two callers:
|
||||
* - `InstancedKindSystem` (editor `def.system`) passes every node of a kind with
|
||||
* `localSpace={false}` — instances live at the scene root, so each matrix folds
|
||||
* in the parent level's world matrix (positions are stored level-local).
|
||||
* - the baked `/viewer` (`bakeReplaceRenderer`) passes one level's nodes with
|
||||
* `localSpace` — the meshes are portaled into that baked level (which supplies
|
||||
* the level transform), so instance matrices stay level-local, and the meshes
|
||||
* are `NO_RAYCAST` (scenery; a pick would resolve to the level anyway).
|
||||
*
|
||||
* Instancing carries the per-tree wind phase for free via `instanceIndex`; a
|
||||
* per-node render (one mesh each) would give every tree phase 0 → a whole
|
||||
* variant sways in unison.
|
||||
*/
|
||||
export function InstancedNodes<N extends Placeable>({
|
||||
nodes,
|
||||
variantKeyOf,
|
||||
getVariant,
|
||||
localSpace = false,
|
||||
}: {
|
||||
nodes: N[]
|
||||
variantKeyOf: (node: N) => string
|
||||
getVariant: (node: N) => VariantData
|
||||
localSpace?: boolean
|
||||
}) {
|
||||
const buckets = useMemo(() => {
|
||||
const map = new Map<string, { sample: N; nodes: N[] }>()
|
||||
for (const node of nodes) {
|
||||
const key = variantKeyOf(node)
|
||||
const bucket = map.get(key)
|
||||
if (bucket) bucket.nodes.push(node)
|
||||
else map.set(key, { sample: node, nodes: [node] })
|
||||
}
|
||||
return Array.from(map, ([key, value]) => ({ key, ...value }))
|
||||
}, [nodes, variantKeyOf])
|
||||
|
||||
return (
|
||||
<>
|
||||
{buckets.map((bucket) => (
|
||||
<Variant
|
||||
getVariant={getVariant}
|
||||
key={bucket.key}
|
||||
localSpace={localSpace}
|
||||
nodes={bucket.nodes}
|
||||
sample={bucket.sample}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Variant<N extends Placeable>({
|
||||
sample,
|
||||
nodes,
|
||||
getVariant,
|
||||
localSpace,
|
||||
}: {
|
||||
sample: N
|
||||
nodes: N[]
|
||||
getVariant: (node: N) => VariantData
|
||||
localSpace: boolean
|
||||
}) {
|
||||
const data = useMemo(() => getVariant(sample), [sample, getVariant])
|
||||
return (
|
||||
<>
|
||||
{data.subMeshes.map((subMesh, i) => (
|
||||
<InstancedSubMesh
|
||||
key={i}
|
||||
localSpace={localSpace}
|
||||
naturalHeight={data.naturalHeight}
|
||||
nodes={nodes}
|
||||
subMesh={subMesh}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InstancedSubMesh<N extends Placeable>({
|
||||
subMesh,
|
||||
nodes,
|
||||
naturalHeight,
|
||||
localSpace,
|
||||
}: {
|
||||
subMesh: SubMesh
|
||||
nodes: N[]
|
||||
naturalHeight: number
|
||||
localSpace: boolean
|
||||
}) {
|
||||
const ref = useRef<InstancedMesh>(null)
|
||||
// Round capacity up so the InstancedMesh isn't recreated on every placement —
|
||||
// only when crossing a 32-instance boundary. `dispose={null}` keeps the shared
|
||||
// (cached) geometry/material alive across any recreation.
|
||||
const capacity = Math.max(16, Math.ceil(nodes.length / 32) * 32)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mesh = ref.current
|
||||
if (!mesh) return
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
const node = nodes[i]
|
||||
if (!node) continue
|
||||
const scale = node.height / naturalHeight
|
||||
DUMMY.position.set(node.position[0], node.position[1], node.position[2])
|
||||
DUMMY.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
|
||||
DUMMY.scale.set(scale, scale, scale)
|
||||
DUMMY.updateMatrix()
|
||||
// `localSpace`: portaled into the parent level, which supplies the level
|
||||
// transform — matrices stay level-local. Otherwise instances live at the
|
||||
// scene root, so fold in the parent level's world matrix.
|
||||
const parent =
|
||||
!localSpace && node.parentId ? sceneRegistry.nodes.get(node.parentId) : undefined
|
||||
if (parent) {
|
||||
parent.updateWorldMatrix(true, false)
|
||||
INSTANCE_MATRIX.multiplyMatrices(parent.matrixWorld, DUMMY.matrix)
|
||||
mesh.setMatrixAt(i, INSTANCE_MATRIX)
|
||||
} else {
|
||||
mesh.setMatrixAt(i, DUMMY.matrix)
|
||||
}
|
||||
}
|
||||
mesh.count = nodes.length
|
||||
mesh.instanceMatrix.needsUpdate = true
|
||||
mesh.computeBoundingSphere()
|
||||
}, [nodes, naturalHeight, localSpace])
|
||||
|
||||
return (
|
||||
<instancedMesh
|
||||
args={[subMesh.geometry as BufferGeometry, subMesh.material as Material, capacity]}
|
||||
castShadow
|
||||
dispose={null}
|
||||
frustumCulled={false}
|
||||
raycast={localSpace ? NO_RAYCAST : undefined}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Per-node selection proxy (a `def.renderer`) ──────────────────────────────
|
||||
|
||||
const toStatic = (material: Material | Material[]) =>
|
||||
Array.isArray(material) ? material.map(toStaticMaterial) : toStaticMaterial(material)
|
||||
|
||||
/**
|
||||
* Per-node proxy that keeps the host's selection machinery working for an
|
||||
* instanced kind. The registered group carries the node transform (host
|
||||
* contract: move tools drive `sceneRegistry.nodes.get(id)` imperatively with
|
||||
* absolute level-local positions and mirror them via `useLiveTransforms` —
|
||||
* see `ParametricNodeRenderer`), so registering a nested child would apply
|
||||
* drag deltas in the node's rotated frame. The box collider is a positioned
|
||||
* sibling: the raycast target, kept out of the registered group so the
|
||||
* outline pass (which traces the registered object) shows the true
|
||||
* silhouette, not a box.
|
||||
*
|
||||
* While hovered/selected the collective system skips this node and the proxy
|
||||
* mounts the real geometry with **static twins** of the wind materials — the
|
||||
* outline mask renders with an override material and can't follow GPU sway,
|
||||
* so the plant holds still while outlined and the silhouette matches exactly.
|
||||
* During a GLB export the geometry mounts with the real materials instead, so
|
||||
* the exporter (which clones only the `scene-renderer` subtree, not the
|
||||
* collective InstancedMesh) captures each plant; the collider is dropped so it
|
||||
* doesn't bake as a phantom solid.
|
||||
*/
|
||||
export function KindProxy<N extends Placeable & { id: string }>({
|
||||
node,
|
||||
getVariant,
|
||||
colliderRadius,
|
||||
}: {
|
||||
node: N
|
||||
getVariant: (node: N) => VariantData
|
||||
colliderRadius: (node: N) => number
|
||||
}) {
|
||||
const registeredRef = useRef<Object3D>(null!)
|
||||
const handlers = useNodeEvents(node as never, node.type as never)
|
||||
useRegistry(node.id as AnyNodeId, node.type, registeredRef)
|
||||
|
||||
const isExporting = useViewer((s) => s.isExporting)
|
||||
const active = useViewer(
|
||||
(s) => s.hoveredId === node.id || s.selection.selectedIds.includes(node.id as never),
|
||||
)
|
||||
const showGeometry = active || isExporting
|
||||
|
||||
// Live drag transform — the move tool writes the same absolute position
|
||||
// imperatively to the registered group; applying it React-side too keeps the
|
||||
// two in agreement (and moves the collider along with the drag). The rotate /
|
||||
// resize gizmos publish through `useLiveNodeOverrides` instead — fold that in
|
||||
// too (mirrors ParametricNodeRenderer) so the plant turns live mid-drag
|
||||
// rather than snapping on commit.
|
||||
const live = useLiveTransforms((s) => s.get(node.id))
|
||||
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(node.id))
|
||||
const overridePosition = liveOverride?.position as [number, number, number] | undefined
|
||||
const overrideRotation = liveOverride?.rotation as [number, number, number] | undefined
|
||||
const position = live?.position ?? overridePosition ?? node.position ?? [0, 0, 0]
|
||||
const baseRotation = overrideRotation ?? node.rotation ?? [0, 0, 0]
|
||||
const rotation: [number, number, number] = live
|
||||
? [baseRotation[0], live.rotation, baseRotation[2]]
|
||||
: baseRotation
|
||||
|
||||
const height = Math.max(0.2, node.height ?? 1)
|
||||
const radius = colliderRadius(node)
|
||||
const variant = useMemo(
|
||||
() => (showGeometry ? getVariant(node) : null),
|
||||
[showGeometry, node, getVariant],
|
||||
)
|
||||
const geometryScale = variant ? height / variant.naturalHeight : 1
|
||||
|
||||
return (
|
||||
<group visible={node.visible !== false} {...handlers}>
|
||||
{!isExporting && (
|
||||
<mesh position={[position[0], position[1] + height / 2, position[2]]}>
|
||||
<boxGeometry args={[radius * 2, height, radius * 2]} />
|
||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
<group position={position} ref={registeredRef} rotation={rotation}>
|
||||
{variant && (
|
||||
<group scale={geometryScale}>
|
||||
{variant.subMeshes.map((subMesh, i) => (
|
||||
<mesh
|
||||
dispose={null}
|
||||
geometry={subMesh.geometry}
|
||||
key={i}
|
||||
material={isExporting ? subMesh.material : toStatic(subMesh.material)}
|
||||
raycast={NO_RAYCAST}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)}
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { type AnyNodeId, type ParametricDescriptor, useScene } from '@pascal-app/core'
|
||||
import { defaultHeightOf, TREE_SEED_POOL } from './presets'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
/**
|
||||
* The tree's right-hand inspector. This descriptor is the entire inspector —
|
||||
* the host's `ParametricInspector` renders every control (selects, sliders,
|
||||
* segmented switches, the native colour pickers, the vec3, and the Randomize
|
||||
* action) with zero tree-specific code in the editor. Demonstrates the "right
|
||||
* inspector comes free from `def.parametrics`" leg of the plugin surface.
|
||||
*
|
||||
* Colours (`leafColor`/`branchColor`) are edit-only — they're not on the
|
||||
* placement brush, so a planted tree starts neutral (texture colours) and is
|
||||
* tinted here per-tree.
|
||||
*/
|
||||
export const treeParametrics: ParametricDescriptor<TreeNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Tree',
|
||||
fields: [
|
||||
{
|
||||
key: 'preset',
|
||||
kind: 'enum',
|
||||
options: ['oak', 'pine', 'aspen', 'ash', 'bush', 'trellis'],
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
kind: 'enum',
|
||||
options: ['small', 'medium', 'large'],
|
||||
display: 'segmented',
|
||||
visibleIf: (n) => n.preset !== 'trellis',
|
||||
},
|
||||
{
|
||||
key: 'treeType',
|
||||
kind: 'enum',
|
||||
options: ['deciduous', 'evergreen'],
|
||||
display: 'segmented',
|
||||
},
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 1, max: 15, step: 0.5 },
|
||||
{ key: 'branchColor', kind: 'color' },
|
||||
{ key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Foliage',
|
||||
fields: [
|
||||
{ key: 'leafless', kind: 'boolean' },
|
||||
{
|
||||
key: 'foliageDensity',
|
||||
kind: 'number',
|
||||
min: 0,
|
||||
max: 1.5,
|
||||
step: 0.1,
|
||||
visibleIf: (n) => !n.leafless,
|
||||
},
|
||||
{ key: 'trunkThickness', kind: 'number', min: 0.3, max: 2.5, step: 0.1 },
|
||||
{ key: 'leafColor', kind: 'color', visibleIf: (n) => !n.leafless },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Position',
|
||||
fields: [{ key: 'position', kind: 'vec3' }],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
label: 'Randomize',
|
||||
// The action receives the live node; writing a new seed re-generates the
|
||||
// tree. Pick from the bounded pool so the result stays an instancing
|
||||
// variant shared with other trees, not a one-off mesh.
|
||||
onClick: (n) =>
|
||||
useScene.getState().updateNode(
|
||||
n.id as AnyNodeId,
|
||||
{
|
||||
seed: TREE_SEED_POOL[Math.floor(Math.random() * TREE_SEED_POOL.length)] ?? 1,
|
||||
} as Partial<TreeNode> as never,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Reset height',
|
||||
// Snap height back to the preset+size default (handy after changing size).
|
||||
onClick: (n) =>
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(
|
||||
n.id as AnyNodeId,
|
||||
{ height: defaultHeightOf(n.preset, n.size) } as Partial<TreeNode> as never,
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, sceneRegistry, snapPointToGrid } from '@pascal-app/core'
|
||||
import { isGridSnapActive, useEditor } from '@pascal-app/editor'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { type Group, Vector3 } from 'three'
|
||||
|
||||
const worldVec = new Vector3()
|
||||
|
||||
/** Snap a planar position to the grid when grid snapping is the active mode —
|
||||
* reading the same `isGridSnapActive()` toggle + `gridSnapStep` the built-in
|
||||
* item/shelf tools use, so plants honour the snap mode like every other item. */
|
||||
export function snapXZ(x: number, z: number): readonly [number, number] {
|
||||
if (!isGridSnapActive()) return [x, z]
|
||||
return snapPointToGrid([x, z], useEditor.getState().gridSnapStep)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a world-space grid hit into the active level's local frame, the way
|
||||
* the host stores node positions. Re-derived from the public `sceneRegistry`
|
||||
* because the built-in `floor-placement` helpers aren't part of the public
|
||||
* `@pascal-app/*` surface yet — a candidate for a future `@pascal-app/plugin-api`.
|
||||
*/
|
||||
export function toLevelLocal(
|
||||
levelId: string,
|
||||
world: [number, number, number],
|
||||
): [number, number, number] {
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
if (!levelObject) return [world[0], 0, world[2]]
|
||||
worldVec.set(world[0], world[1], world[2])
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
levelObject.worldToLocal(worldVec)
|
||||
return [worldVec.x, 0, worldVec.z]
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared placement wiring for any plant tool: ghosts a preview at the snapped
|
||||
* cursor on `grid:move`, and calls `onCommit` with the snapped level-local
|
||||
* position on `grid:click`. Returns the cursor group ref + visibility for the
|
||||
* tool to attach its preview to. `onCommit` is read through a ref so a tool can
|
||||
* close over live brush state without re-subscribing every render.
|
||||
*/
|
||||
export function usePlacement(
|
||||
activeLevelId: string | null,
|
||||
onCommit: (levelLocalPosition: [number, number, number]) => void,
|
||||
) {
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const [cursorVisible, setCursorVisible] = useState(false)
|
||||
const commitRef = useRef(onCommit)
|
||||
commitRef.current = onCommit
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
setCursorVisible(false)
|
||||
let lastWorld: [number, number, number] | null = null
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
setCursorVisible(true)
|
||||
const [lx, , lz] = event.localPosition
|
||||
const [sx, sz] = snapXZ(lx, lz)
|
||||
cursorRef.current?.position.set(sx, 0, sz)
|
||||
lastWorld = event.position
|
||||
}
|
||||
|
||||
const onClick = (event: GridEvent) => {
|
||||
const world = lastWorld ?? event.position
|
||||
const [lx, , lz] = toLevelLocal(activeLevelId, world)
|
||||
const [sx, sz] = snapXZ(lx, lz)
|
||||
commitRef.current([sx, 0, sz])
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', onClick)
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', onClick)
|
||||
}
|
||||
}, [activeLevelId])
|
||||
|
||||
return { cursorRef, cursorVisible }
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { SegmentedControl, SliderControl, ToggleControl, useEditor } from '@pascal-app/editor'
|
||||
import { FLOWER_PRESET_LIST } from './flower-presets'
|
||||
import type { FlowerPreset } from './flower-schema'
|
||||
import { GRASS_PRESET_LIST } from './grass-presets'
|
||||
import type { GrassPreset } from './grass-schema'
|
||||
import { TREE_PRESET_LIST } from './presets'
|
||||
import type { TreePreset } from './schema'
|
||||
import { type TreesPanelMode as Mode, useTreesStore } from './store'
|
||||
|
||||
const KIND: Record<Mode, string> = {
|
||||
trees: 'trees:tree',
|
||||
flowers: 'trees:flower',
|
||||
grass: 'trees:grass',
|
||||
}
|
||||
const NOUN: Record<Mode, string> = { trees: 'tree', flowers: 'flower', grass: 'grass' }
|
||||
|
||||
/**
|
||||
* The plugin's left-rail panel. A Trees / Flowers / Grass segmented control
|
||||
* switches the brush; picking a preset arms placement for that kind
|
||||
* (`setTool('trees:*')` + build mode). The count chip reads the scene reactively,
|
||||
* closing the triangle: panel → store → tool → scene → panel. It composes the
|
||||
* host's exported controls (`SegmentedControl`/`SliderControl`/`ToggleControl`)
|
||||
* so the brush matches the right-hand inspector pixel-for-pixel.
|
||||
*/
|
||||
export default function TreesPanel() {
|
||||
// Section lives in the plugin store (not local state) so "find in catalog"
|
||||
// can point the panel at the found node's section — see find-sync.ts.
|
||||
const mode = useTreesStore((s) => s.mode)
|
||||
const setMode = useTreesStore((s) => s.setMode)
|
||||
const activeTool = useEditor((s) => s.tool)
|
||||
const count = useScene(
|
||||
(s) => Object.values(s.nodes).filter((n) => (n.type as string) === KIND[mode]).length,
|
||||
)
|
||||
|
||||
const arming = activeTool === KIND[mode]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 text-sidebar-foreground">
|
||||
<header className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-base">Nature</h2>
|
||||
<span className="rounded-full bg-sidebar-accent px-2 py-0.5 text-sidebar-foreground/70 text-xs">
|
||||
{count} planted
|
||||
</span>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ label: 'Trees', value: 'trees' },
|
||||
{ label: 'Flowers', value: 'flowers' },
|
||||
{ label: 'Grass', value: 'grass' },
|
||||
]}
|
||||
value={mode}
|
||||
/>
|
||||
<p className="text-sidebar-foreground/50 text-xs">
|
||||
{arming
|
||||
? 'Click the ground to plant. Press Esc to stop.'
|
||||
: `Pick ${mode === 'grass' ? 'a grass' : `a ${NOUN[mode]}`}, then click the ground.`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{mode === 'trees' && <TreesSection arming={arming} />}
|
||||
{mode === 'flowers' && <FlowersSection arming={arming} />}
|
||||
{mode === 'grass' && <GrassSection arming={arming} />}
|
||||
|
||||
<footer className="-mx-4 -mb-4 sticky bottom-0 mt-1 border-sidebar-border/50 border-t bg-sidebar px-4 py-3 text-[11px] text-sidebar-foreground/50 leading-relaxed">
|
||||
Trees generated with{' '}
|
||||
<a
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-sidebar-foreground/70"
|
||||
href="https://github.com/dgreenheck/ez-tree"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
ez-tree
|
||||
</a>{' '}
|
||||
by{' '}
|
||||
<a
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-sidebar-foreground/70"
|
||||
href="https://x.com/dangreenheck"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Daniel Greenheck
|
||||
</a>{' '}
|
||||
(MIT).
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreesSection({ arming }: { arming: boolean }) {
|
||||
const selected = useTreesStore((s) => s.preset)
|
||||
const size = useTreesStore((s) => s.size)
|
||||
const height = useTreesStore((s) => s.height)
|
||||
const foliageDensity = useTreesStore((s) => s.foliageDensity)
|
||||
const trunkThickness = useTreesStore((s) => s.trunkThickness)
|
||||
const leafless = useTreesStore((s) => s.leafless)
|
||||
|
||||
const activate = (preset: TreePreset) => {
|
||||
useTreesStore.getState().setPreset(preset)
|
||||
useEditor.getState().setTool('trees:tree')
|
||||
useEditor.getState().setMode('build')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PresetGrid items={TREE_PRESET_LIST} onPick={activate} selected={arming ? selected : null} />
|
||||
{selected !== 'trellis' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SegmentedControl
|
||||
onChange={useTreesStore.getState().setSize}
|
||||
options={[
|
||||
{ label: 'S', value: 'small' },
|
||||
{ label: 'M', value: 'medium' },
|
||||
{ label: 'L', value: 'large' },
|
||||
]}
|
||||
value={size}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={15}
|
||||
min={1}
|
||||
onChange={useTreesStore.getState().setHeight}
|
||||
precision={1}
|
||||
restoreOnCommit={false}
|
||||
step={0.5}
|
||||
unit="m"
|
||||
value={height}
|
||||
/>
|
||||
{!leafless && (
|
||||
<SliderControl
|
||||
label="Foliage"
|
||||
max={1.5}
|
||||
min={0}
|
||||
onChange={useTreesStore.getState().setFoliageDensity}
|
||||
precision={1}
|
||||
restoreOnCommit={false}
|
||||
step={0.1}
|
||||
value={foliageDensity}
|
||||
/>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Trunk"
|
||||
max={2.5}
|
||||
min={0.3}
|
||||
onChange={useTreesStore.getState().setTrunkThickness}
|
||||
precision={1}
|
||||
restoreOnCommit={false}
|
||||
step={0.1}
|
||||
value={trunkThickness}
|
||||
/>
|
||||
<ToggleControl
|
||||
checked={leafless}
|
||||
label="Bare (leafless)"
|
||||
onChange={useTreesStore.getState().setLeafless}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowersSection({ arming }: { arming: boolean }) {
|
||||
const selected = useTreesStore((s) => s.flowerPreset)
|
||||
const height = useTreesStore((s) => s.flowerHeight)
|
||||
|
||||
const activate = (preset: FlowerPreset) => {
|
||||
useTreesStore.getState().setFlowerPreset(preset)
|
||||
useEditor.getState().setTool('trees:flower')
|
||||
useEditor.getState().setMode('build')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PresetGrid
|
||||
items={FLOWER_PRESET_LIST}
|
||||
onPick={activate}
|
||||
selected={arming ? selected : null}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={2}
|
||||
min={0.2}
|
||||
onChange={useTreesStore.getState().setFlowerHeight}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={height}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GrassSection({ arming }: { arming: boolean }) {
|
||||
const selected = useTreesStore((s) => s.grassPreset)
|
||||
const height = useTreesStore((s) => s.grassHeight)
|
||||
|
||||
const activate = (preset: GrassPreset) => {
|
||||
useTreesStore.getState().setGrassPreset(preset)
|
||||
useEditor.getState().setTool('trees:grass')
|
||||
useEditor.getState().setMode('build')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PresetGrid items={GRASS_PRESET_LIST} onPick={activate} selected={arming ? selected : null} />
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={2}
|
||||
min={0.1}
|
||||
onChange={useTreesStore.getState().setGrassHeight}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={height}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetGrid<T extends string>({
|
||||
items,
|
||||
selected,
|
||||
onPick,
|
||||
}: {
|
||||
items: ReadonlyArray<{ id: T; label: string; thumbnail: string }>
|
||||
selected: T | null
|
||||
onPick: (id: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{items.map((item) => {
|
||||
const isSelected = selected === item.id
|
||||
return (
|
||||
<button
|
||||
className={`group relative flex flex-col gap-2 rounded-xl border p-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-sidebar-ring bg-sidebar-accent shadow-sm'
|
||||
: 'border-sidebar-border hover:border-sidebar-ring/50 hover:bg-sidebar-accent/40'
|
||||
}`}
|
||||
key={item.id}
|
||||
onClick={() => onPick(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="aspect-square w-full rounded-lg bg-[#f3f4f6] object-cover ring-1 ring-black/10 transition-transform group-hover:scale-[1.02]"
|
||||
src={item.thumbnail}
|
||||
/>
|
||||
<span className="pl-0.5 font-medium text-xs">{item.label}</span>
|
||||
{isSelected && (
|
||||
<span className="absolute top-3 right-3 h-2 w-2 rounded-full bg-sidebar-ring ring-2 ring-sidebar-accent" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { TREE_ART } from './art'
|
||||
import type { TreePreset, TreeSize } from './schema'
|
||||
|
||||
/**
|
||||
* Per-species config: the ez-tree preset name for each size, a default placement
|
||||
* height per size (metres), a swatch colour, and a card `thumbnail` (a
|
||||
* replaceable placeholder image — see `thumbnails.ts`). Pure data — no three.js,
|
||||
* no React — shared by the panel grid and the instanced renderer so they stay in
|
||||
* lockstep. `ez[size]` is the exact ez-tree preset name passed to
|
||||
* `tree.loadPreset(...)`, exposing all of ez-tree's built-in presets through a
|
||||
* clean species × size model. `trellis` has a single preset (size ignored).
|
||||
*/
|
||||
export type TreePresetSpec = {
|
||||
id: TreePreset
|
||||
label: string
|
||||
/** ez-tree preset name keyed by size. */
|
||||
ez: Record<TreeSize, string>
|
||||
/** Default placement height keyed by size. */
|
||||
height: Record<TreeSize, number>
|
||||
/** Whether the size control applies (false for `trellis`). */
|
||||
sized: boolean
|
||||
swatch: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
function ezSizes(family: string): Record<TreeSize, string> {
|
||||
return { small: `${family} Small`, medium: `${family} Medium`, large: `${family} Large` }
|
||||
}
|
||||
|
||||
export const TREE_PRESETS: Record<TreePreset, TreePresetSpec> = {
|
||||
oak: {
|
||||
id: 'oak',
|
||||
label: 'Oak',
|
||||
ez: ezSizes('Oak'),
|
||||
height: { small: 5, medium: 7, large: 11 },
|
||||
sized: true,
|
||||
swatch: '#4f7942',
|
||||
thumbnail: TREE_ART.oak,
|
||||
},
|
||||
pine: {
|
||||
id: 'pine',
|
||||
label: 'Pine',
|
||||
ez: ezSizes('Pine'),
|
||||
height: { small: 6, medium: 9, large: 14 },
|
||||
sized: true,
|
||||
swatch: '#2f5d3a',
|
||||
thumbnail: TREE_ART.pine,
|
||||
},
|
||||
aspen: {
|
||||
id: 'aspen',
|
||||
label: 'Aspen',
|
||||
ez: ezSizes('Aspen'),
|
||||
height: { small: 5, medium: 8, large: 12 },
|
||||
sized: true,
|
||||
swatch: '#8fae5d',
|
||||
thumbnail: TREE_ART.aspen,
|
||||
},
|
||||
ash: {
|
||||
id: 'ash',
|
||||
label: 'Ash',
|
||||
ez: ezSizes('Ash'),
|
||||
height: { small: 5, medium: 8, large: 12 },
|
||||
sized: true,
|
||||
swatch: '#6f9457',
|
||||
thumbnail: TREE_ART.ash,
|
||||
},
|
||||
bush: {
|
||||
id: 'bush',
|
||||
label: 'Bush',
|
||||
ez: { small: 'Bush 1', medium: 'Bush 2', large: 'Bush 3' },
|
||||
height: { small: 1.2, medium: 1.5, large: 1.8 },
|
||||
sized: true,
|
||||
swatch: '#5c8a4a',
|
||||
thumbnail: TREE_ART.bush,
|
||||
},
|
||||
trellis: {
|
||||
id: 'trellis',
|
||||
label: 'Trellis',
|
||||
ez: { small: 'Trellis', medium: 'Trellis', large: 'Trellis' },
|
||||
height: { small: 3, medium: 3, large: 3 },
|
||||
sized: false,
|
||||
swatch: '#8b6b45',
|
||||
thumbnail: TREE_ART.trellis,
|
||||
},
|
||||
}
|
||||
|
||||
export const TREE_PRESET_LIST: TreePresetSpec[] = Object.values(TREE_PRESETS)
|
||||
|
||||
/** The ez-tree preset name for a species + size (size ignored for `trellis`). */
|
||||
export function ezPresetOf(preset: TreePreset, size: TreeSize): string {
|
||||
return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).ez[size]
|
||||
}
|
||||
|
||||
/** Default placement height for a species + size. */
|
||||
export function defaultHeightOf(preset: TreePreset, size: TreeSize): number {
|
||||
return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).height[size]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded seed pool. The placement tool and the Randomize action pick from
|
||||
* this set so trees share geometry variants — that sharing is what makes
|
||||
* instancing pay off. A power user can still type an arbitrary seed in the
|
||||
* inspector; that tree just renders as its own single-instance variant.
|
||||
*/
|
||||
export const TREE_SEED_POOL = [1, 7, 13, 21, 34, 55, 89, 144]
|
||||
|
||||
/** Pick a seed from the pool, varied by an index so it stays deterministic
|
||||
* (no Math.random in schema-importable code paths). */
|
||||
export function seedFromPool(index: number): number {
|
||||
return TREE_SEED_POOL[Math.abs(index) % TREE_SEED_POOL.length] ?? 1
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { Material } from 'three'
|
||||
import { generateTree, treeSpecOf } from './geometry'
|
||||
import type { TreeNode } from './schema'
|
||||
import { naturalHeight } from './variant-utils'
|
||||
|
||||
/**
|
||||
* Translucent placement ghost — a single ez-tree (not instanced) scaled to the
|
||||
* node's height, following the cursor. Clones each material for the see-through
|
||||
* look and disables raycast so the ghost never intercepts the cursor ray (which
|
||||
* would freeze `grid:move`).
|
||||
*/
|
||||
export default function TreePreview({ node }: { node: TreeNode }) {
|
||||
const built = useMemo(() => {
|
||||
const tree = generateTree(treeSpecOf(node))
|
||||
tree.scale.setScalar(node.height / naturalHeight(tree))
|
||||
return tree
|
||||
}, [node])
|
||||
|
||||
useEffect(() => {
|
||||
const cloned: Material[] = []
|
||||
built.traverse((obj) => {
|
||||
;(obj as unknown as { raycast: () => void }).raycast = () => {}
|
||||
const mesh = obj as { material?: Material | Material[] }
|
||||
if (!mesh.material) return
|
||||
const ghost = (mat: Material): Material => {
|
||||
const c = mat.clone()
|
||||
c.transparent = true
|
||||
c.opacity = 0.5
|
||||
c.depthWrite = false
|
||||
cloned.push(c)
|
||||
return c
|
||||
}
|
||||
mesh.material = Array.isArray(mesh.material) ? mesh.material.map(ghost) : ghost(mesh.material)
|
||||
})
|
||||
return () => {
|
||||
for (const c of cloned) c.dispose()
|
||||
}
|
||||
}, [built])
|
||||
|
||||
return <primitive object={built} />
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { getVariantData, treeSpecOf } from './geometry'
|
||||
import { KindProxy } from './instanced'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node))
|
||||
const colliderRadius = (node: TreeNode) => Math.max(0.4, (node.height ?? 5) * 0.18)
|
||||
|
||||
/**
|
||||
* Per-node selection proxy for the instanced trees — a thin binding of the
|
||||
* generic {@link KindProxy} to this kind's geometry + collider size.
|
||||
*/
|
||||
export default function TreeProxyRenderer({ node }: { node: TreeNode }) {
|
||||
return <KindProxy colliderRadius={colliderRadius} getVariant={getVariant} node={node} />
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BaseNode, nodeType, objectId } from '@pascal-app/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Tree species the plugin can place, each backed by an ez-tree preset family.
|
||||
* The string persists in scene JSON. */
|
||||
export const TreePreset = z.enum(['oak', 'pine', 'aspen', 'ash', 'bush', 'trellis'])
|
||||
export type TreePreset = z.infer<typeof TreePreset>
|
||||
|
||||
/** Preset size variant. Maps to ez-tree's Small/Medium/Large presets (and
|
||||
* Bush 1/2/3); ignored for `trellis`, which has a single preset. */
|
||||
export const TreeSize = z.enum(['small', 'medium', 'large'])
|
||||
export type TreeSize = z.infer<typeof TreeSize>
|
||||
|
||||
/** ez-tree's two growth models — deciduous (spreading) vs evergreen (conical). */
|
||||
export const TreeType = z.enum(['deciduous', 'evergreen'])
|
||||
export type TreeType = z.infer<typeof TreeType>
|
||||
|
||||
/**
|
||||
* Schema for a placed tree. Composed from the public `BaseNode` exactly the way
|
||||
* built-in node kinds are — `objectId`/`nodeType` come from `@pascal-app/core`,
|
||||
* so a plugin needs no private host internals to mint a persistable node.
|
||||
*
|
||||
* `type` is the namespaced kind `trees:tree`. Every geometry-relevant field
|
||||
* (preset/size/treeType/seed/foliage/trunk/leafless/leafColor/branchColor) is
|
||||
* folded into the instancing variant key; `height`/`position`/`rotation` are
|
||||
* cheap per-instance transforms.
|
||||
*/
|
||||
export const TreeNode = BaseNode.extend({
|
||||
id: objectId('tree'),
|
||||
type: nodeType('trees:tree'),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
preset: TreePreset.default('oak'),
|
||||
size: TreeSize.default('medium'),
|
||||
// Overrides — all optional so an unset field inherits the ez-tree preset (its
|
||||
// own seed/type/tints), exactly like flower `petalColor`. Placing a tree stores
|
||||
// none of these, so a fresh tree is the pure preset; the inspector sets them.
|
||||
/** Growth-model override. Unset ⇒ inherit the preset (oak → deciduous, pine → evergreen). */
|
||||
treeType: TreeType.optional(),
|
||||
height: z.number().positive().default(7),
|
||||
/** Geometry-seed override. Unset ⇒ the preset's own seed (its canonical silhouette);
|
||||
* set (e.g. via Randomize) to vary the tree. */
|
||||
seed: z.number().int().optional(),
|
||||
// Curated geometry params (folded into the instancing variant key):
|
||||
/** Leaf-count multiplier vs the preset (1 = preset default). */
|
||||
foliageDensity: z.number().min(0).max(1.5).default(1),
|
||||
/** Branch-radius multiplier (1 = preset default). */
|
||||
trunkThickness: z.number().min(0.3).max(2.5).default(1),
|
||||
/** Strip all leaves — a bare winter silhouette. */
|
||||
leafless: z.boolean().default(false),
|
||||
/** Leaf tint override (hex). Unset ⇒ the preset's leaf tint. */
|
||||
leafColor: z.string().optional(),
|
||||
/** Bark/branch tint override (hex). Unset ⇒ the preset's bark tint. */
|
||||
branchColor: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TreeNode = z.infer<typeof TreeNode>
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { getVariantData, treeSpecOf, treeVariantKey } from './geometry'
|
||||
import { InstancedNodes } from './instanced'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
const variantKeyOf = (node: TreeNode) => treeVariantKey(treeSpecOf(node))
|
||||
const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node))
|
||||
|
||||
/**
|
||||
* Collective renderer for the baked `/viewer` (`bakeReplaceRenderer`): one baked
|
||||
* level's trees, instanced in level-local space (the viewer portals this into
|
||||
* that level's `Object3D`). Same instancing as the editor `system`, so wind
|
||||
* phase varies per tree via `instanceIndex` and a forest is a few draw calls.
|
||||
*/
|
||||
export default function TreeReplaceInstances({ nodes }: { nodes: TreeNode[] }) {
|
||||
return (
|
||||
<InstancedNodes getVariant={getVariant} localSpace nodes={nodes} variantKeyOf={variantKeyOf} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { create } from 'zustand'
|
||||
import { FLOWER_PRESETS } from './flower-presets'
|
||||
import type { FlowerPreset } from './flower-schema'
|
||||
import { GRASS_PRESETS } from './grass-presets'
|
||||
import type { GrassPreset } from './grass-schema'
|
||||
import { defaultHeightOf, TREE_PRESETS } from './presets'
|
||||
import type { TreePreset, TreeSize } from './schema'
|
||||
|
||||
/**
|
||||
* The plugin's own module-level state — the example of "plugins self-manage
|
||||
* runtime state with module-level stores" from the plugin-authoring contract.
|
||||
* It holds the placement "brush": what the next planted tree/flower looks like.
|
||||
* The presets panel writes it; the placement tool reads it. No host lifecycle
|
||||
* slot. Colours are intentionally absent — they're edit-only (inspector).
|
||||
*/
|
||||
/** Which section of the Nature panel is showing. */
|
||||
export type TreesPanelMode = 'trees' | 'flowers' | 'grass'
|
||||
|
||||
type TreesStore = {
|
||||
/** Active panel section — in the store (not panel-local state) so the host's
|
||||
* "find in catalog" can land on the right section (see `find-sync.ts`). */
|
||||
mode: TreesPanelMode
|
||||
setMode: (mode: TreesPanelMode) => void
|
||||
preset: TreePreset
|
||||
size: TreeSize
|
||||
/** Height (m) of the next tree — a per-instance scale, never affects placed trees. */
|
||||
height: number
|
||||
/** Leaf-count multiplier vs the preset (folded into the instancing variant). */
|
||||
foliageDensity: number
|
||||
/** Branch-radius multiplier (folded into the instancing variant). */
|
||||
trunkThickness: number
|
||||
/** Plant bare (leafless) trees. */
|
||||
leafless: boolean
|
||||
setPreset: (preset: TreePreset) => void
|
||||
setSize: (size: TreeSize) => void
|
||||
setHeight: (height: number) => void
|
||||
setFoliageDensity: (value: number) => void
|
||||
setTrunkThickness: (value: number) => void
|
||||
setLeafless: (value: boolean) => void
|
||||
// Flower brush (sibling kind).
|
||||
flowerPreset: FlowerPreset
|
||||
flowerHeight: number
|
||||
setFlowerPreset: (preset: FlowerPreset) => void
|
||||
setFlowerHeight: (height: number) => void
|
||||
// Grass brush (sibling kind).
|
||||
grassPreset: GrassPreset
|
||||
grassHeight: number
|
||||
setGrassPreset: (preset: GrassPreset) => void
|
||||
setGrassHeight: (height: number) => void
|
||||
}
|
||||
|
||||
export const useTreesStore = create<TreesStore>((set, get) => ({
|
||||
mode: 'trees',
|
||||
setMode: (mode) => set({ mode }),
|
||||
preset: 'oak',
|
||||
size: 'medium',
|
||||
height: TREE_PRESETS.oak.height.medium,
|
||||
foliageDensity: 1,
|
||||
trunkThickness: 1,
|
||||
leafless: false,
|
||||
// Switching preset/size re-seeds the height to that combo's natural default;
|
||||
// the foliage/trunk brush settings carry over. Growth model comes from the
|
||||
// preset (oak → deciduous, pine → evergreen); override per-tree in the inspector.
|
||||
setPreset: (preset) => set({ preset, height: defaultHeightOf(preset, get().size) }),
|
||||
setSize: (size) => set({ size, height: defaultHeightOf(get().preset, size) }),
|
||||
setHeight: (height) => set({ height }),
|
||||
setFoliageDensity: (foliageDensity) => set({ foliageDensity }),
|
||||
setTrunkThickness: (trunkThickness) => set({ trunkThickness }),
|
||||
setLeafless: (leafless) => set({ leafless }),
|
||||
flowerPreset: 'daisy',
|
||||
flowerHeight: FLOWER_PRESETS.daisy.defaultHeight,
|
||||
setFlowerPreset: (flowerPreset) =>
|
||||
set({ flowerPreset, flowerHeight: FLOWER_PRESETS[flowerPreset].defaultHeight }),
|
||||
setFlowerHeight: (flowerHeight) => set({ flowerHeight }),
|
||||
grassPreset: 'meadow',
|
||||
grassHeight: GRASS_PRESETS.meadow.defaultHeight,
|
||||
setGrassPreset: (grassPreset) =>
|
||||
set({ grassPreset, grassHeight: GRASS_PRESETS[grassPreset].defaultHeight }),
|
||||
setGrassHeight: (grassHeight) => set({ grassHeight }),
|
||||
}))
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { getVariantData, treeSpecOf, treeVariantKey } from './geometry'
|
||||
import { InstancedKindSystem } from './instanced'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
// Module-scope so identities stay stable (the system memoises on them).
|
||||
const variantKeyOf = (node: TreeNode) => treeVariantKey(treeSpecOf(node))
|
||||
const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node))
|
||||
|
||||
/**
|
||||
* Collective instanced renderer for every placed tree — contributed via
|
||||
* `def.system`. Buckets trees by their geometry variant and draws each variant
|
||||
* as one InstancedMesh per ez-tree sub-mesh, so a forest is a handful of draw
|
||||
* calls. Selection/outline come from the per-node proxy renderer.
|
||||
*/
|
||||
export default function TreesSystem() {
|
||||
return (
|
||||
<InstancedKindSystem<TreeNode>
|
||||
getVariant={getVariant}
|
||||
kind="trees:tree"
|
||||
variantKeyOf={variantKeyOf}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { usePlacement } from './placement'
|
||||
import TreePreview from './preview'
|
||||
import { TreeNode } from './schema'
|
||||
import { useTreesStore } from './store'
|
||||
|
||||
/**
|
||||
* The trees placement tool. Mounted by the host's registry-first `ToolManager`
|
||||
* whenever `tool === 'trees:tree'` — no host edit per kind. Reads the panel
|
||||
* brush from the plugin store, ghosts a preview at the snapped cursor, and
|
||||
* commits a tree on click. Snapping + level conversion live in `usePlacement`.
|
||||
*/
|
||||
export default function TreeTool() {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const brush = useTreesStore()
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
TreeNode.parse({
|
||||
preset: brush.preset,
|
||||
size: brush.size,
|
||||
height: brush.height,
|
||||
foliageDensity: brush.foliageDensity,
|
||||
trunkThickness: brush.trunkThickness,
|
||||
leafless: brush.leafless,
|
||||
// seed/treeType left unset → the ghost shows the pure preset, as placed.
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
[
|
||||
brush.preset,
|
||||
brush.size,
|
||||
brush.height,
|
||||
brush.foliageDensity,
|
||||
brush.trunkThickness,
|
||||
brush.leafless,
|
||||
],
|
||||
)
|
||||
|
||||
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
|
||||
if (!activeLevelId) return
|
||||
const s = useTreesStore.getState()
|
||||
const tree = TreeNode.parse({
|
||||
preset: s.preset,
|
||||
size: s.size,
|
||||
height: s.height,
|
||||
foliageDensity: s.foliageDensity,
|
||||
trunkThickness: s.trunkThickness,
|
||||
leafless: s.leafless,
|
||||
// seed/treeType unset → the pure ez-tree preset (its canonical seed + type).
|
||||
// All same-preset trees then share one instancing variant; a random Y
|
||||
// rotation keeps a planted row from looking cloned. Use Randomize (inspector)
|
||||
// to vary a tree's seed.
|
||||
position,
|
||||
rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0],
|
||||
})
|
||||
useScene.getState().createNode(tree as unknown as AnyNode, activeLevelId as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [tree.id as AnyNodeId] })
|
||||
triggerSFX('sfx:item-place')
|
||||
})
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return (
|
||||
<group ref={cursorRef} visible={cursorVisible}>
|
||||
<TreePreview node={previewNode} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Box3, type Object3D } from 'three'
|
||||
|
||||
// Pure helpers shared by the tree/flower/grass variant builders. They live
|
||||
// apart from `geometry.ts` because that module imports ez-tree, which loads
|
||||
// its inlined textures at module scope (needs `document`) and therefore must
|
||||
// never sit on an eagerly-imported path (index → definitions → floorplan) —
|
||||
// SSR/prerender would crash. Only lazy client modules may import `geometry.ts`.
|
||||
|
||||
/** Natural (unscaled) height of a generated plant, so the renderer can scale
|
||||
* each instance to the node's `height`. */
|
||||
export function naturalHeight(obj: Object3D): number {
|
||||
const box = new Box3().setFromObject(obj)
|
||||
return Math.max(0.001, box.max.y - box.min.y)
|
||||
}
|
||||
|
||||
/** Deterministic 32-bit RNG (mulberry32) — same seed ⇒ same geometry. Shared by
|
||||
* the procedural flower/grass builders so a variant is stable across instances. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed || 1
|
||||
return () => {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Color, Material, Side, Texture } from 'three'
|
||||
import { cos, Fn, float, instanceIndex, positionLocal, sin, time, uv } from 'three/tsl'
|
||||
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
|
||||
/**
|
||||
* Always-on wind for the plant kinds, done in TSL so it runs on the editor's
|
||||
* WebGPU renderer (which ignores WebGL's `onBeforeCompile`). Two motions:
|
||||
*
|
||||
* - **Leaf flutter** (`LEAF_FLUTTER`) — ez-tree's own approach: the sway scales
|
||||
* with the leaf card's `uv.y`, so each leaf swings from its attachment while
|
||||
* the trunk and branches stay put. A whole-tree height-based bend, by contrast,
|
||||
* is a rigid rotation about the base and reads as the tree spinning in place.
|
||||
* Multi-frequency for a natural gust; phased per instance + per leaf.
|
||||
* - **Stem bend** (`STEM_BEND`) — for the small procedural kinds (flowers,
|
||||
* grass), a gentle whole-plant lean proportional to height reads fine.
|
||||
*
|
||||
* ez-tree isn't touched: its generated materials are re-created as node materials
|
||||
* carrying the texture/tint (`toWindMaterial`) — only the `leaves` material gets
|
||||
* the flutter node; bark stays static. `time` is advanced by the renderer.
|
||||
*/
|
||||
|
||||
// ── Leaf flutter (tree leaves) ───────────────────────────────────────────────
|
||||
const LEAF_FREQUENCY = 1.2
|
||||
const LEAF_STRENGTH = 0.3
|
||||
|
||||
const leafFlutter = Fn(() => {
|
||||
const p = positionLocal.toVar()
|
||||
const offset = float(instanceIndex).mul(0.7).add(p.x.add(p.z).mul(0.3))
|
||||
const t = time.mul(LEAF_FREQUENCY)
|
||||
const wave = sin(t.add(offset))
|
||||
.mul(0.5)
|
||||
.add(sin(t.mul(2).add(offset.mul(1.3))).mul(0.3))
|
||||
.add(sin(t.mul(5).add(offset.mul(1.5))).mul(0.2))
|
||||
const sway = uv().y.mul(LEAF_STRENGTH).mul(wave)
|
||||
p.x.addAssign(sway)
|
||||
p.z.addAssign(sway)
|
||||
return p
|
||||
})
|
||||
const LEAF_FLUTTER = leafFlutter()
|
||||
|
||||
// ── Stem bend (flowers, grass) ───────────────────────────────────────────────
|
||||
const STEM_FREQUENCY = 1.3
|
||||
const STEM_STRENGTH = 0.05
|
||||
|
||||
const stemBend = Fn(() => {
|
||||
const p = positionLocal.toVar()
|
||||
const h = p.y.max(0)
|
||||
const phase = float(instanceIndex).mul(0.618)
|
||||
const t = time.mul(STEM_FREQUENCY).add(phase)
|
||||
p.x.addAssign(h.mul(STEM_STRENGTH).mul(sin(t)))
|
||||
p.z.addAssign(h.mul(STEM_STRENGTH).mul(cos(t.mul(1.1))))
|
||||
return p
|
||||
})
|
||||
const STEM_BEND = stemBend()
|
||||
|
||||
/** The classic-material fields we carry over — enough to reproduce ez-tree's
|
||||
* bark/leaf look (textured, tinted, alpha-cut billboards). */
|
||||
type ClassicMaterial = Material & {
|
||||
map?: Texture | null
|
||||
alphaMap?: Texture | null
|
||||
color?: Color
|
||||
side?: Side
|
||||
alphaTest?: number
|
||||
opacity?: number
|
||||
transparent?: boolean
|
||||
depthWrite?: boolean
|
||||
}
|
||||
|
||||
const cache = new WeakMap<Material, MeshStandardNodeMaterial>()
|
||||
|
||||
/** Re-create a generated (ez-tree) material as a `MeshStandardNodeMaterial`,
|
||||
* transferring its texture/tint explicitly (node materials don't pick these up
|
||||
* via `Material.copy()`). Only the `leaves` material flutters; bark stays static.
|
||||
* Cached per source so shared variant materials convert once. */
|
||||
export function toWindMaterial(material: Material): MeshStandardNodeMaterial {
|
||||
const cached = cache.get(material)
|
||||
if (cached) return cached
|
||||
const src = material as ClassicMaterial
|
||||
const node = new MeshStandardNodeMaterial({
|
||||
map: src.map ?? null,
|
||||
alphaMap: src.alphaMap ?? null,
|
||||
color: src.color,
|
||||
side: src.side,
|
||||
alphaTest: src.alphaTest ?? 0,
|
||||
transparent: src.transparent ?? false,
|
||||
opacity: src.opacity ?? 1,
|
||||
depthWrite: src.depthWrite ?? true,
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
})
|
||||
if (material.name === 'leaves') node.positionNode = LEAF_FLUTTER
|
||||
cache.set(material, node)
|
||||
return node
|
||||
}
|
||||
|
||||
/** Build a swaying node material for the procedural kinds (flowers/grass). */
|
||||
export function windStandardMaterial(
|
||||
params: ConstructorParameters<typeof MeshStandardNodeMaterial>[0],
|
||||
): MeshStandardNodeMaterial {
|
||||
const material = new MeshStandardNodeMaterial(params)
|
||||
material.positionNode = STEM_BEND
|
||||
return material
|
||||
}
|
||||
|
||||
const staticCache = new WeakMap<Material, Material>()
|
||||
|
||||
/** Windless twin of a wind material — same look, no `positionNode`. The outline
|
||||
* mask pass renders outlined meshes with a shared override material, so an
|
||||
* outline can never follow the GPU sway; the selection proxy renders this twin
|
||||
* instead, so the outlined silhouette and the visible mesh match exactly (the
|
||||
* plant simply holds still while hovered/selected). Built by explicit property
|
||||
* transfer, not `.clone()` — node-material clone drops `map`/`color` (same
|
||||
* pitfall as `toWindMaterial`). Cached per source. */
|
||||
export function toStaticMaterial(material: Material): Material {
|
||||
const cached = staticCache.get(material)
|
||||
if (cached) return cached
|
||||
const src = material as MeshStandardNodeMaterial
|
||||
const twin = new MeshStandardNodeMaterial({
|
||||
map: src.map ?? null,
|
||||
alphaMap: src.alphaMap ?? null,
|
||||
color: src.color,
|
||||
side: src.side,
|
||||
alphaTest: src.alphaTest ?? 0,
|
||||
transparent: src.transparent ?? false,
|
||||
opacity: src.opacity ?? 1,
|
||||
depthWrite: src.depthWrite ?? true,
|
||||
roughness: src.roughness,
|
||||
metalness: src.metalness,
|
||||
})
|
||||
staticCache.set(material, twin)
|
||||
return twin
|
||||
}
|
||||