diff --git a/src/elevation.test.ts b/src/elevation.test.ts new file mode 100644 index 0000000..714edc1 --- /dev/null +++ b/src/elevation.test.ts @@ -0,0 +1,141 @@ +import { beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + BuildingNode, + initSpatialGridSync, + LevelNode, + loadPlugin, + SlabNode, + useScene, +} from '@pascal-app/core' +import { draftElevation, plantElevation } from './elevation' +import { treesPlugin } from './index' + +/** + * The elevation contract an instanced kind has to satisfy by hand. + * + * A plant's stored Y is always 0 — the surface it stands on is resolved at render + * time. For a per-node kind the host does that for free, but a collective renderer + * draws into one `InstancedMesh`, and the host's `FloorElevationSystem` only writes + * to a node's *registered* object, which here is the invisible selection proxy. So + * `instanced.tsx` resolves it, through the functions under test. + * + * Every assertion below would read 0 under the old `node.position[1]`, which is + * exactly why the bug survived: in a flat test scene the broken and correct code + * agree. + */ + +const DECK_ELEVATION = 1.2 +const KINDS = ['trees:tree', 'trees:flower', 'trees:grass'] + +/** `level_0` at grade, optionally holding a 4×4 deck slab over the origin. */ +function scene(deckElevation: number | null) { + const children: string[] = [] + const nodes: AnyNode[] = [] + + if (deckElevation !== null) { + const deck = SlabNode.parse({ + parentId: 'level_0', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + elevation: deckElevation, + thickness: 0.05, + }) as AnyNode + children.push(deck.id as string) + nodes.push(deck) + } + + nodes.push( + BuildingNode.parse({ id: 'building_a', parentId: null, children: ['level_0'] }) as AnyNode, + LevelNode.parse({ + id: 'level_0', + level: 0, + height: 2.5, + parentId: 'building_a', + children, + }) as AnyNode, + ) + + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +/** + * Publish a scene and let the spatial grid index it. The slab election reads that + * index, not the record — without the settle every lookup reports "no slab" and + * the deck cases pass as 0 for the wrong reason. + */ +async function publish(nodes: Record) { + useScene.setState({ nodes } as never) + await Bun.sleep(30) +} + +/** A committed plant of `kind`, positioned flat the way its tool commits it. */ +function plant(kind: string, x: number, z: number) { + return { + id: `${kind}_1`, + type: kind, + object: 'node' as const, + parentId: 'level_0', + visible: true, + metadata: {}, + position: [x, 0, z] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + height: 1.5, + } +} + +beforeAll(async () => { + // The resolver reads each kind's `floorPlaced` footprint off the registry. + await loadPlugin(treesPlugin as never) + initSpatialGridSync() +}) + +describe.each(KINDS)('%s', (kind) => { + test('rests on the storey plane with nothing under it', async () => { + const nodes = scene(null) + await publish(nodes) + + expect(plantElevation(plant(kind, 2, 2), nodes)).toBe(0) + }) + + test('rides a slab it stands on rather than its stored zero', async () => { + const nodes = scene(DECK_ELEVATION) + await publish(nodes) + + const node = plant(kind, 2, 2) + expect(node.position[1]).toBe(0) + expect(plantElevation(node, nodes)).toBeCloseTo(DECK_ELEVATION) + }) + + test('a plant beyond the slab stays on the storey plane', async () => { + const nodes = scene(DECK_ELEVATION) + await publish(nodes) + + // Outside the deck's 0..4 footprint: the lift is per-position, not global. + expect(plantElevation(plant(kind, 20, 20), nodes)).toBe(0) + }) +}) + +describe('placement ghost', () => { + test('previews the surface the commit will land on', async () => { + const nodes = scene(DECK_ELEVATION) + await publish(nodes) + + // An uncommitted draft has no parent, so the level is named explicitly. + const draft = { ...plant('trees:tree', 0, 0), parentId: null } + expect(draftElevation(draft, 'level_0', [2, 0, 2], nodes)).toBeCloseTo(DECK_ELEVATION) + expect(draftElevation(draft, 'level_0', [20, 0, 20], nodes)).toBe(0) + }) + + test('an unresolvable level keeps the ghost flat rather than throwing', async () => { + const nodes = scene(DECK_ELEVATION) + await publish(nodes) + + const draft = { ...plant('trees:tree', 0, 0), parentId: null } + expect(draftElevation(draft, 'level_missing', [2, 0, 2], nodes)).toBe(0) + }) +}) diff --git a/src/elevation.ts b/src/elevation.ts new file mode 100644 index 0000000..04a1fa5 --- /dev/null +++ b/src/elevation.ts @@ -0,0 +1,50 @@ +import { type AnyNode, getFloorStackedPosition, useScene } from '@pascal-app/core' + +/** + * Where a plant actually stands: its stored base plus whatever the host elects + * under it — a stacked slab (deck, plinth) or the sculpted ground. + * + * Stored positions are flat by contract (`[x, 0, z]`); the lift is presentation + * and is never committed. For an ordinary per-node kind the host's + * `FloorElevationSystem` applies it for free, but it writes to each node's + * *registered* object — and for a collective renderer that object is the + * invisible selection proxy, not the instance. So an instanced kind has to + * resolve this itself, at every point it writes a transform. Reading + * `node.position[1]` raw is what left every plant at `y = 0`: floating under a + * deck and buried in a hillside. + * + * Kept in its own module, free of any Three.js or React import, so the seam is + * testable without a canvas — see `elevation.test.ts`. + */ +export function plantElevation( + node: { id: string; type: string; position: [number, number, number] }, + nodes: Record = useScene.getState().nodes, +): number { + return getFloorStackedPosition({ + node: node as unknown as AnyNode, + nodes, + position: node.position, + })[1] +} + +/** + * The ghost's Y for a draft the placement tool has not committed yet. + * + * Same question as {@link plantElevation}, but the draft is unparented, so the + * level it will land on has to be named explicitly — the resolver reads + * `parentId` first and only falls back to `levelId`. + */ +export function draftElevation( + draft: unknown, + levelId: string, + position: [number, number, number], + nodes: Record = useScene.getState().nodes, +): number { + return getFloorStackedPosition({ + node: { ...(draft as AnyNode), parentId: null }, + nodes, + position, + rotation: (draft as { rotation?: unknown }).rotation, + levelId, + })[1] +} diff --git a/src/flower-tool.tsx b/src/flower-tool.tsx index 3538d2f..988edf4 100644 --- a/src/flower-tool.tsx +++ b/src/flower-tool.tsx @@ -1,7 +1,7 @@ 'use client' import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { EDITOR_LAYER, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { FLOWER_PRESETS, FLOWER_SEED_POOL } from './flower-presets' @@ -31,26 +31,30 @@ export default function FlowerTool() { [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') - }) + 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') + }, + previewNode, + ) if (!activeLevelId) return null return ( - + ) diff --git a/src/grass-tool.tsx b/src/grass-tool.tsx index f04f866..f240b1b 100644 --- a/src/grass-tool.tsx +++ b/src/grass-tool.tsx @@ -1,7 +1,7 @@ 'use client' import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { EDITOR_LAYER, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { GRASS_PRESETS, GRASS_SEED_POOL } from './grass-presets' @@ -31,26 +31,30 @@ export default function GrassTool() { [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') - }) + 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') + }, + previewNode, + ) if (!activeLevelId) return null return ( - + ) diff --git a/src/instanced.tsx b/src/instanced.tsx index 0854baf..cac3b93 100644 --- a/src/instanced.tsx +++ b/src/instanced.tsx @@ -12,6 +12,7 @@ import { useNodeEvents, useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three' +import { plantElevation } from './elevation' import { toStaticMaterial } from './wind-node' /** @@ -196,16 +197,16 @@ function InstancedSubMesh({ // write — the per-frame staleness check below compares against it so a level // move (explode, elevation edit) refreshes instances without a node change. const parentWorlds = useRef(new Map()) - const writeMatrices = useCallback(() => { const mesh = ref.current if (!mesh) return parentWorlds.current.clear() + const sceneNodes = useScene.getState().nodes 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.position.set(node.position[0], plantElevation(node, sceneNodes), node.position[2]) DUMMY.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2]) DUMMY.scale.set(scale, scale, scale) DUMMY.updateMatrix() @@ -234,24 +235,46 @@ function InstancedSubMesh({ writeMatrices() }, [writeMatrices]) - // A parent level can move without any node of this kind changing (level - // explode, elevation edits), which would leave the baked-in world transform - // stale. Compare each referenced level's matrixWorld against the snapshot — - // a handful of levels × 16 floats per frame — and rewrite only on change. + // Two things can invalidate the written matrices with no change to any node of + // this kind, so both are checked per frame: + // + // - the parent level's world transform (explode, level-height edits), which is + // baked into every instance matrix, and + // - the floor a plant stands on: a deck slab raised, or the ground sculpted — + // and a terrain stroke publishes to a transient store, never touching the + // scene graph, so there is no node change to observe. + // + // The floor case rides the host's dirty marks (it marks every `floorPlaced` node + // at grade on each dab) rather than re-resolving every instance: a forest would + // be thousands of spatial queries per frame for a signal that is idle almost + // always. Reading the marks *here* rather than in an effect is deliberate — this + // callback has no explicit priority, so it runs before the priority-2 pass in + // `InstancedKindSystem` that consumes them, whereas a React effect might not + // flush until after the marks were already cleared. useFrame(() => { - if (localSpace || !ref.current) return - for (const [id, cached] of parentWorlds.current) { - const parent = sceneRegistry.nodes.get(id) - if (!parent) continue - parent.updateWorldMatrix(true, false) - const elements = parent.matrixWorld.elements - for (let i = 0; i < 16; i += 1) { - if (elements[i] !== cached[i]) { - writeMatrices() - return + if (!ref.current) return + if (!localSpace) { + for (const [id, cached] of parentWorlds.current) { + const parent = sceneRegistry.nodes.get(id) + if (!parent) continue + parent.updateWorldMatrix(true, false) + const elements = parent.matrixWorld.elements + for (let i = 0; i < 16; i += 1) { + if (elements[i] !== cached[i]) { + writeMatrices() + return + } } } } + const { dirtyNodes } = useScene.getState() + if (dirtyNodes.size === 0) return + for (const node of nodes) { + if (node && dirtyNodes.has(node.id as AnyNodeId)) { + writeMatrices() + return + } + } }) return ( @@ -336,10 +359,18 @@ export function KindProxy({ ) const geometryScale = variant ? height / variant.naturalHeight : 1 + // The registered group's Y is the host's: `FloorElevationSystem` overwrites it + // every frame with the resolved floor lift. The collider is a *sibling* of that + // group (so the outline pass traces the real silhouette, not a box), which puts + // it outside the host's reach — resolve the same lift for it here, or the hit + // volume stays at the storey plane while the plant it stands for rides a deck + // or a hillside. + const colliderY = plantElevation({ ...node, position }) + height / 2 + return ( {!isExporting && ( - + diff --git a/src/placement.tsx b/src/placement.tsx index db5c69d..4904c6a 100644 --- a/src/placement.tsx +++ b/src/placement.tsx @@ -4,6 +4,7 @@ import { emitter, type GridEvent, sceneRegistry, snapPointToGrid } from '@pascal import { useEditor } from '@pascal-app/editor' import { useEffect, useRef, useState } from 'react' import { type Group, Vector3 } from 'three' +import { draftElevation } from './elevation' const worldVec = new Vector3() @@ -45,26 +46,48 @@ export function toLevelLocal( * 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. + * + * `previewNode` is the draft the tool is about to place. It exists only so the + * ghost can be raised onto whatever it will actually land on — the floor + * resolver reads the kind's `floorPlaced` footprint off the node, so a plain + * position is not enough to ask the question. Tools that omit it get a ghost + * pinned to the storey plane. */ export function usePlacement( activeLevelId: string | null, onCommit: (levelLocalPosition: [number, number, number]) => void, + previewNode?: unknown, ) { const cursorRef = useRef(null) const [cursorVisible, setCursorVisible] = useState(false) const commitRef = useRef(onCommit) commitRef.current = onCommit + const previewRef = useRef(previewNode) + previewRef.current = previewNode useEffect(() => { if (!activeLevelId) return setCursorVisible(false) let lastWorld: [number, number, number] | null = null + /** + * Where the ghost stands: the snapped point raised onto the surface the + * commit will elect — a stacked slab, or the sculpted ground. The commit + * itself stays flat (`[x, 0, z]`); the lift is presentation, applied to the + * stored base by the host's floor-elevation pass once the node exists. A + * ghost that skipped this floated at the storey plane over a deck and sank + * into every hillside, so the plant jumped on click. + */ + const ghostY = (x: number, z: number): number => { + const node = previewRef.current + return node ? draftElevation(node, activeLevelId, [x, 0, z]) : 0 + } + const onMove = (event: GridEvent) => { setCursorVisible(true) const [lx, , lz] = event.localPosition const [sx, sz] = snapXZ(lx, lz) - cursorRef.current?.position.set(sx, 0, sz) + cursorRef.current?.position.set(sx, ghostY(sx, sz), sz) lastWorld = event.position } diff --git a/src/tool.tsx b/src/tool.tsx index 305155f..4c8e698 100644 --- a/src/tool.tsx +++ b/src/tool.tsx @@ -40,27 +40,31 @@ export default function TreeTool() { [preset, size, height, foliageDensity, trunkThickness, 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') - }) + 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') + }, + previewNode, + ) if (!activeLevelId) return null