Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/elevation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, AnyNode>
}

/**
* 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<string, AnyNode>) {
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)
})
})
50 changes: 50 additions & 0 deletions src/elevation.ts
Original file line number Diff line number Diff line change
@@ -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<string, AnyNode> = 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<string, AnyNode> = useScene.getState().nodes,
): number {
return getFloorStackedPosition({
node: { ...(draft as AnyNode), parentId: null },
nodes,
position,
rotation: (draft as { rotation?: unknown }).rotation,
levelId,
})[1]
}
38 changes: 21 additions & 17 deletions src/flower-tool.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 (
<group ref={cursorRef} visible={cursorVisible}>
<group layers={EDITOR_LAYER} ref={cursorRef} visible={cursorVisible}>
<FlowerPreview node={previewNode} />
</group>
)
Expand Down
38 changes: 21 additions & 17 deletions src/grass-tool.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 (
<group ref={cursorRef} visible={cursorVisible}>
<group layers={EDITOR_LAYER} ref={cursorRef} visible={cursorVisible}>
<GrassPreview node={previewNode} />
</group>
)
Expand Down
Loading
Loading