Schema v2 adds style/rows/columns/withBack/withSides/withBottom/bracketStyle and a `children: ItemNode[]` field for item hosting. Schema-level defaults preserve the v1 wall-shelf visual so existing scenes load unchanged; the placement tool spreads `shelfDefinition.defaults()` for fresh shelves (cubby 3x2 at 1m × 0.5m × 1.8m, thickness 0.05m, back/sides/bottom on). Four style geometries (wall-shelf / bookshelf / open-rack / cubby) share the dimensional schema. `shelfRowSurfaceYs` exposes one host surface per row, plus the bottom-board top when `withBottom` is on for cubby / bookshelf. Material is a single paintable surface (same shape walls / slabs / stairs use); `DEFAULT_SHELF_MATERIAL` aligned with `DEFAULT_WALL_MATERIAL` so unpainted shelves read as the canonical off-white. Preview clones each cached material before mutating `transparent / opacity` on the ghost — without the clone the mutation leaked into the cached `getShelfMaterial` instance every committed shelf was using, rendering them all see-through after the first placement preview rendered. Store hardening: `migrateNodes` patches missing `children: []` on v1 shelves, and `updateNodesAction` reparenting tolerates a missing children array on the new parent. `MaterialTarget` enum adds `'shelf'` so paint mode picks up the kind. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
8.5 KiB
TypeScript
204 lines
8.5 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
||
import type { Mesh } from 'three'
|
||
import { buildShelfGeometry, shelfRowSurfaceYs } from '../geometry'
|
||
import { ShelfNode } from '../schema'
|
||
|
||
describe('buildShelfGeometry — wall-shelf', () => {
|
||
test('returns a Group with one board + two brackets (default v1 shape)', () => {
|
||
const node = ShelfNode.parse({})
|
||
const group = buildShelfGeometry(node)
|
||
const names = group.children.map((c) => c.name)
|
||
expect(names).toContain('shelf-board-0')
|
||
expect(names).toContain('shelf-bracket-left')
|
||
expect(names).toContain('shelf-bracket-right')
|
||
expect(group.children.length).toBe(3)
|
||
})
|
||
|
||
test('hidden bracket style omits both brackets', () => {
|
||
const node = ShelfNode.parse({ bracketStyle: 'hidden' })
|
||
const group = buildShelfGeometry(node)
|
||
expect(group.children.length).toBe(1)
|
||
expect(group.children[0]!.name).toBe('shelf-board-0')
|
||
})
|
||
|
||
test('top board y-center matches height + thickness/2 (v1 semantic preserved)', () => {
|
||
const node = ShelfNode.parse({ height: 1.0, thickness: 0.05 })
|
||
const group = buildShelfGeometry(node)
|
||
const top = group.children.find((c) => c.name === 'shelf-board-0') as Mesh | undefined
|
||
expect(top).toBeDefined()
|
||
expect(top!.position.y).toBeCloseTo(1.0 + 0.025)
|
||
})
|
||
|
||
test('rows > 1 produces multiple boards evenly spaced from height/rows to height', () => {
|
||
const node = ShelfNode.parse({ rows: 3, height: 1.8, thickness: 0.04 })
|
||
const group = buildShelfGeometry(node)
|
||
const boards = group.children.filter((c) => c.name.startsWith('shelf-board-')) as Mesh[]
|
||
expect(boards.length).toBe(3)
|
||
const ys = boards.map((b) => b.position.y).sort((a, b) => a - b)
|
||
expect(ys[0]).toBeCloseTo(0.6 + 0.02)
|
||
expect(ys[1]).toBeCloseTo(1.2 + 0.02)
|
||
expect(ys[2]).toBeCloseTo(1.8 + 0.02)
|
||
})
|
||
|
||
test('industrial bracket style produces wider bracket boxes', () => {
|
||
const minimal = buildShelfGeometry(ShelfNode.parse({ bracketStyle: 'minimal', depth: 0.4 }))
|
||
const industrial = buildShelfGeometry(
|
||
ShelfNode.parse({ bracketStyle: 'industrial', depth: 0.4 }),
|
||
)
|
||
const minimalBracket = minimal.children.find((c) => c.name === 'shelf-bracket-left') as Mesh
|
||
const industrialBracket = industrial.children.find(
|
||
(c) => c.name === 'shelf-bracket-left',
|
||
) as Mesh
|
||
const minimalParams = (minimalBracket.geometry as any).parameters
|
||
const industrialParams = (industrialBracket.geometry as any).parameters
|
||
expect(industrialParams.width).toBeGreaterThan(minimalParams.width)
|
||
})
|
||
})
|
||
|
||
describe('buildShelfGeometry — bookshelf', () => {
|
||
test('emits side panels + multiple boards', () => {
|
||
const node = ShelfNode.parse({ style: 'bookshelf', rows: 4, height: 1.8 })
|
||
const group = buildShelfGeometry(node)
|
||
const names = group.children.map((c) => c.name)
|
||
expect(names).toContain('shelf-side-left')
|
||
expect(names).toContain('shelf-side-right')
|
||
expect(names.filter((n) => n.startsWith('shelf-board-')).length).toBe(4)
|
||
})
|
||
|
||
test('withBack adds a back panel', () => {
|
||
const without = buildShelfGeometry(ShelfNode.parse({ style: 'bookshelf', withBack: false }))
|
||
const withBack = buildShelfGeometry(ShelfNode.parse({ style: 'bookshelf', withBack: true }))
|
||
expect(without.children.find((c) => c.name === 'shelf-back')).toBeUndefined()
|
||
expect(withBack.children.find((c) => c.name === 'shelf-back')).toBeDefined()
|
||
})
|
||
|
||
test('columns > 1 adds vertical dividers', () => {
|
||
const node = ShelfNode.parse({ style: 'bookshelf', columns: 3 })
|
||
const group = buildShelfGeometry(node)
|
||
const dividers = group.children.filter((c) => c.name.startsWith('shelf-divider-col-'))
|
||
expect(dividers.length).toBe(2)
|
||
})
|
||
|
||
test('withSides=false replaces side panels with corner posts', () => {
|
||
const node = ShelfNode.parse({ style: 'bookshelf', withSides: false })
|
||
const group = buildShelfGeometry(node)
|
||
const names = group.children.map((c) => c.name)
|
||
expect(names).not.toContain('shelf-side-left')
|
||
expect(names.filter((n) => n.startsWith('shelf-post-')).length).toBe(4)
|
||
})
|
||
})
|
||
|
||
describe('buildShelfGeometry — open-rack', () => {
|
||
test('always emits four corner posts', () => {
|
||
const node = ShelfNode.parse({ style: 'open-rack' })
|
||
const group = buildShelfGeometry(node)
|
||
const posts = group.children.filter((c) => c.name.startsWith('shelf-post-'))
|
||
expect(posts.length).toBe(4)
|
||
})
|
||
|
||
test('withBack adds horizontal cross-braces top + bottom', () => {
|
||
const node = ShelfNode.parse({ style: 'open-rack', withBack: true })
|
||
const group = buildShelfGeometry(node)
|
||
const braces = group.children.filter((c) => c.name.startsWith('shelf-brace-h-'))
|
||
expect(braces.length).toBe(2)
|
||
})
|
||
})
|
||
|
||
describe('buildShelfGeometry — cubby', () => {
|
||
test('grid of cubbies emits sides + back + boards + dividers', () => {
|
||
const node = ShelfNode.parse({ style: 'cubby', rows: 3, columns: 3 })
|
||
const group = buildShelfGeometry(node)
|
||
const names = group.children.map((c) => c.name)
|
||
expect(names).toContain('shelf-side-left')
|
||
expect(names).toContain('shelf-side-right')
|
||
expect(names).toContain('shelf-back')
|
||
// Boards: rows = 3 → 3 horizontal boards.
|
||
expect(names.filter((n) => n.startsWith('shelf-board-')).length).toBe(3)
|
||
// Dividers: (columns − 1) per row → 2 × 3 = 6.
|
||
expect(names.filter((n) => /^shelf-divider-\d+-\d+$/.test(n)).length).toBe(6)
|
||
})
|
||
|
||
test('withBottom adds a floor board at y = thickness/2', () => {
|
||
const node = ShelfNode.parse({ style: 'cubby', withBottom: true, thickness: 0.05 })
|
||
const group = buildShelfGeometry(node)
|
||
const bottom = group.children.find((c) => c.name === 'shelf-board-bottom') as Mesh | undefined
|
||
expect(bottom).toBeDefined()
|
||
expect(bottom!.position.y).toBeCloseTo(0.025)
|
||
})
|
||
|
||
test('withBottom=false omits the floor board', () => {
|
||
const node = ShelfNode.parse({ style: 'cubby', withBottom: false })
|
||
const group = buildShelfGeometry(node)
|
||
expect(group.children.find((c) => c.name === 'shelf-board-bottom')).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('shelfRowSurfaceYs — withBottom', () => {
|
||
test('prepends y = thickness when cubby has withBottom on', () => {
|
||
const node = ShelfNode.parse({
|
||
style: 'cubby',
|
||
withBottom: true,
|
||
rows: 3,
|
||
height: 1.8,
|
||
thickness: 0.05,
|
||
})
|
||
const ys = shelfRowSurfaceYs(node)
|
||
expect(ys.length).toBe(4)
|
||
expect(ys[0]).toBeCloseTo(0.05) // top of bottom board
|
||
})
|
||
|
||
test('ignores withBottom for wall-shelf', () => {
|
||
const node = ShelfNode.parse({ style: 'wall-shelf', withBottom: true })
|
||
const ys = shelfRowSurfaceYs(node)
|
||
expect(ys.length).toBe(1)
|
||
})
|
||
})
|
||
|
||
describe('shelfRowSurfaceYs', () => {
|
||
test('returns one Y per row, all at board top', () => {
|
||
const node = ShelfNode.parse({ rows: 3, height: 1.8, thickness: 0.04 })
|
||
const ys = shelfRowSurfaceYs(node)
|
||
expect(ys.length).toBe(3)
|
||
// Y values are sorted ascending and represent top-of-board.
|
||
expect(ys[0]).toBeCloseTo(0.6 + 0.04)
|
||
expect(ys[1]).toBeCloseTo(1.2 + 0.04)
|
||
expect(ys[2]).toBeCloseTo(1.8 + 0.04)
|
||
})
|
||
|
||
test('rows=1 returns single Y at v1 top-of-board (height + thickness)', () => {
|
||
const node = ShelfNode.parse({ height: 0.9, thickness: 0.04 })
|
||
const ys = shelfRowSurfaceYs(node)
|
||
expect(ys.length).toBe(1)
|
||
expect(ys[0]).toBeCloseTo(0.94)
|
||
})
|
||
})
|
||
|
||
describe('material application', () => {
|
||
test('default shelf material is the canonical white shared with walls / stairs', () => {
|
||
const board = buildShelfGeometry(ShelfNode.parse({})).children.find(
|
||
(c) => c.name === 'shelf-board-0',
|
||
) as Mesh
|
||
const material = board.material as { color: { getHexString(): string } }
|
||
// DEFAULT_SHELF_MATERIAL is '#ffffff' — same as DEFAULT_WALL_MATERIAL /
|
||
// DEFAULT_STAIR_MATERIAL so an unpainted shelf reads as the same
|
||
// "default white" surface the rest of the structural kinds use.
|
||
expect(material.color.getHexString().toLowerCase()).toBe('ffffff')
|
||
})
|
||
|
||
test('user-set material is applied (not the default)', () => {
|
||
const defaultBoard = (
|
||
buildShelfGeometry(ShelfNode.parse({})).children.find(
|
||
(c) => c.name === 'shelf-board-0',
|
||
) as Mesh
|
||
).material as { color: { getHexString(): string } }
|
||
const customBoard = (
|
||
buildShelfGeometry(
|
||
ShelfNode.parse({
|
||
material: { properties: { color: '#112233' } },
|
||
}),
|
||
).children.find((c) => c.name === 'shelf-board-0') as Mesh
|
||
).material as { color: { getHexString(): string } }
|
||
expect(customBoard.color.getHexString()).not.toBe(defaultBoard.color.getHexString())
|
||
})
|
||
})
|