Wassim SAMAD 924567293a shelf: v2 — cubby default, withBottom, item hosting, paintable surface
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>
2026-05-19 15:11:13 -04:00

204 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
})
})