minecraft-renderer 0.1.88 → 0.1.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-renderer",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -253,19 +253,21 @@ function getBlockFromStateId(version: string, stateId: number) {
253
253
  return Block.fromStateId(stateId, 1)
254
254
  }
255
255
 
256
- function getAllShapesForState(version: string, stateId: number, blockProvider: WorldBlockProvider): Uint16Array[] {
257
- const cacheKey = `${version}:${stateId}`
258
- const cached = shapeCache.get(cacheKey)
259
- if (cached) return cached
260
-
261
- const shapes = DIR_KEYS.map(() => emptyShape())
262
- const blockObj = getBlockFromStateId(version, stateId)
263
- if (!blockObj || !blockRendersSolid(blockObj)) {
264
- shapeCache.set(cacheKey, shapes)
265
- return shapes
256
+ function isFullCubeRenderModel(models: BlockModelPartsResolved | undefined): boolean {
257
+ try {
258
+ if (!models?.length || models.length !== 1) return false
259
+ return models[0]!.every(v =>
260
+ (v.elements ?? []).every(e => {
261
+ return e.from[0] === 0 && e.from[1] === 0 && e.from[2] === 0 && e.to[0] === 16 && e.to[1] === 16 && e.to[2] === 16
262
+ })
263
+ )
264
+ } catch {
265
+ return false
266
266
  }
267
+ }
267
268
 
268
- const models = blockProvider.getAllResolvedModels0_1({ name: blockObj.name, properties: blockObj.getProperties() }, false) as BlockModelPartsResolved
269
+ function buildShapesFromRenderModel(models: BlockModelPartsResolved | undefined): Uint16Array[] {
270
+ const shapes = DIR_KEYS.map(() => emptyShape())
269
271
 
270
272
  for (const modelVars of models ?? []) {
271
273
  const model = modelVars[0]
@@ -289,10 +291,52 @@ function getAllShapesForState(version: string, stateId: number, blockProvider: W
289
291
  }
290
292
  }
291
293
 
292
- shapeCache.set(cacheKey, shapes)
293
294
  return shapes
294
295
  }
295
296
 
297
+ function buildShapesFromCollisionBoxes(boxes: number[][]): Uint16Array[] {
298
+ const shapes = DIR_KEYS.map(() => emptyShape())
299
+
300
+ for (const box of boxes) {
301
+ const x0 = snapBlockUnit(box[0]! * 16)
302
+ const y0 = snapBlockUnit(box[1]! * 16)
303
+ const z0 = snapBlockUnit(box[2]! * 16)
304
+ const x1 = snapBlockUnit(box[3]! * 16)
305
+ const y1 = snapBlockUnit(box[4]! * 16)
306
+ const z1 = snapBlockUnit(box[5]! * 16)
307
+
308
+ if (y1 === 16) orRectIntoShape(x0, z0, x1, z1, shapes[dirIndex([0, 1, 0])]!)
309
+ if (y0 === 0) orRectIntoShape(x0, z0, x1, z1, shapes[dirIndex([0, -1, 0])]!)
310
+ if (x1 === 16) orRectIntoShape(z0, y0, z1, y1, shapes[dirIndex([1, 0, 0])]!)
311
+ if (x0 === 0) orRectIntoShape(z0, y0, z1, y1, shapes[dirIndex([-1, 0, 0])]!)
312
+ if (z1 === 16) orRectIntoShape(x0, y0, x1, y1, shapes[dirIndex([0, 0, 1])]!)
313
+ if (z0 === 0) orRectIntoShape(x0, y0, x1, y1, shapes[dirIndex([0, 0, -1])]!)
314
+ }
315
+
316
+ return shapes
317
+ }
318
+
319
+ function getAllShapesForState(version: string, stateId: number, blockProvider: WorldBlockProvider): Uint16Array[] {
320
+ const cacheKey = `${version}:${stateId}`
321
+ const cached = shapeCache.get(cacheKey)
322
+ if (cached) return cached
323
+
324
+ const shapes = DIR_KEYS.map(() => emptyShape())
325
+ const blockObj = getBlockFromStateId(version, stateId)
326
+ if (!blockObj || !blockRendersSolid(blockObj)) {
327
+ shapeCache.set(cacheKey, shapes)
328
+ return shapes
329
+ }
330
+
331
+ const models = blockProvider.getAllResolvedModels0_1({ name: blockObj.name, properties: blockObj.getProperties() }, false) as BlockModelPartsResolved
332
+ const boxes = blockObj.shapes as number[][] | undefined
333
+
334
+ const resolvedShapes = isFullCubeRenderModel(models) ? buildShapesFromRenderModel(models) : boxes?.length ? buildShapesFromCollisionBoxes(boxes) : shapes
335
+
336
+ shapeCache.set(cacheKey, resolvedShapes)
337
+ return resolvedShapes
338
+ }
339
+
296
340
  export function getOcclusionShape(version: string, stateId: number, worldDir: CardinalDir, blockProvider: WorldBlockProvider): Uint16Array {
297
341
  return getAllShapesForState(version, stateId, blockProvider)[dirIndex(worldDir)]!
298
342
  }
@@ -73,6 +73,42 @@ test('getOcclusionShape: full cube covers entire plane', () => {
73
73
  }
74
74
  })
75
75
 
76
+ test('getOcclusionShape: cactus down occlusion is inset, not full footprint', () => {
77
+ const mcData = MinecraftData(VERSION)
78
+ const cactusId = mcData.blocksByName.cactus!.defaultState
79
+ const downShape = getOcclusionShape(VERSION, cactusId, [0, -1, 0], blockProvider())
80
+ expect(downShape[0]! & 1).toBe(0)
81
+ expect(downShape[0]! & (1 << 15)).toBe(0)
82
+ let covered = 0
83
+ for (let row = 0; row < 16; row++) {
84
+ for (let col = 0; col < 16; col++) {
85
+ if (downShape[row]! & (1 << col)) covered++
86
+ }
87
+ }
88
+ expect(covered).toBe(14 * 14)
89
+ expect(getOcclusionShape(VERSION, cactusId, [0, 1, 0], blockProvider())[0]).toBe(0)
90
+ })
91
+
92
+ test('faceIsCulled: grass top not culled by cactus above', () => {
93
+ const mcData = MinecraftData(VERSION)
94
+ const grassId = mcData.blocksByName.grass_block!.defaultState
95
+ const cactusId = mcData.blocksByName.cactus!.defaultState
96
+ const provider = blockProvider()
97
+ const blockObj = PrismarineBlockLoader(VERSION).fromStateId(grassId, 1)
98
+ const models = provider.getAllResolvedModels0_1({ name: blockObj.name, properties: blockObj.getProperties() }, false)
99
+ const element = models[0]![0]!.elements![0]!
100
+ expect(faceIsCulled(VERSION, element, 'up', cactusId, { stateId: grassId, name: 'grass_block' }, provider, [0, 1, 0], null)).toBe(false)
101
+ })
102
+
103
+ test('getOcclusionShape: soul sand keeps full render-model occlusion despite shorter collision', () => {
104
+ const mcData = MinecraftData(VERSION)
105
+ const soulSandId = mcData.blocksByName.soul_sand!.defaultState
106
+ const downShape = getOcclusionShape(VERSION, soulSandId, [0, -1, 0], blockProvider())
107
+ for (let row = 0; row < 16; row++) {
108
+ expect(downShape[row]).toBe(0xffff)
109
+ }
110
+ })
111
+
76
112
  test('getOcclusionShape: farmland side is shorter than full height', () => {
77
113
  const mcData = MinecraftData(VERSION)
78
114
  const farmlandId = mcData.blocksByName.farmland!.defaultState
@@ -1,7 +1,7 @@
1
1
  //@ts-nocheck
2
2
  import { BasePlaygroundScene } from '../baseScene'
3
3
 
4
- /** Manual check for issue #49: farmland field + cut copper stairs (1.18.2). */
4
+ /** Manual check for issue #49/#73: partial-block culling + cactus on grass (1.18.2). */
5
5
  export default class extends BasePlaygroundScene {
6
6
  override version = '1.18.2'
7
7
  enableCameraOrbitControl = true
@@ -27,5 +27,18 @@ export default class extends BasePlaygroundScene {
27
27
  this.addWorldBlock(20 + x, stairY, 4, 'cut_copper_stairs', stairProps)
28
28
  this.addWorldBlock(20 + x, stairY + 1, 4, 'cut_copper_stairs', stairProps)
29
29
  }
30
+
31
+ const cactusY = 64
32
+ for (let x = 0; x < 4; x++) {
33
+ this.addWorldBlock(30 + x, cactusY - 1, 8, 'grass_block')
34
+ this.addWorldBlock(30 + x, cactusY, 8, 'cactus')
35
+ }
36
+ this.addWorldBlock(35, cactusY - 1, 8, 'grass_block')
37
+ this.addWorldBlock(35, cactusY, 8, 'cactus')
38
+ this.addWorldBlock(35, cactusY + 1, 8, 'cactus')
39
+
40
+ this.addWorldBlock(30, cactusY - 1, 10, 'soul_sand')
41
+ this.addWorldBlock(31, cactusY - 1, 10, 'stone_slab', { type: 'bottom' })
42
+ this.addWorldBlock(32, cactusY - 1, 10, 'stone_slab', { type: 'bottom' })
30
43
  }
31
44
  }
@@ -1,11 +1,5 @@
1
1
  //@ts-nocheck
2
- import {
3
- assertDrawSpansWithinLiveRanges,
4
- carveSpansAroundPendingRanges,
5
- FULL_DRAW_VISIBLE_FRACTION,
6
- MAX_OPAQUE_SPANS,
7
- type DirtyRange
8
- } from './globalLegacyBuffer'
2
+ import { carveSpansAroundPendingRanges, FULL_DRAW_VISIBLE_FRACTION, MAX_OPAQUE_SPANS, type DirtyRange } from './globalLegacyBuffer'
9
3
 
10
4
  export { FULL_DRAW_VISIBLE_FRACTION, MAX_OPAQUE_SPANS as MAX_CUBE_SPANS }
11
5
 
@@ -54,10 +48,8 @@ export function buildVisibleCubeSpans(
54
48
  } else {
55
49
  spans = visibleSlots.map(s => ({ start: s.start, count: s.count }))
56
50
  spans.sort((a, b) => a.start - b.start)
57
- const liveDrawRanges = spans.map(s => ({ ...s }))
58
51
  mergeCubeSpans(spans)
59
52
  spans = carveSpansAroundPendingRanges(spans, pendingRanges)
60
- assertDrawSpansWithinLiveRanges(spans, liveDrawRanges, 'globalBlockBuffer')
61
53
  return spans
62
54
  }
63
55
  return carveSpansAroundPendingRanges(spans, pendingRanges)
@@ -711,10 +711,8 @@ export class GlobalBlockBuffer {
711
711
  this.finalizePendingReplace(key)
712
712
  }
713
713
 
714
- console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', '(need', minFaces, ')')
715
714
  let newCap = this.capacityFaces
716
715
  while (newCap < minFaces) newCap += GROWTH_INCREMENT_FACES
717
- console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', newCap)
718
716
 
719
717
  const nw0 = new Uint32Array(newCap)
720
718
  const nw1 = new Uint32Array(newCap)
@@ -32,33 +32,6 @@ export const FULL_DRAW_VISIBLE_FRACTION = 0.75
32
32
  /** Initial multi_draw scratch size; arrays auto-grow — not a draw-call cap. */
33
33
  export const MAX_OPAQUE_SPANS = 64
34
34
 
35
- /** Dev assert: every quad in draw spans must lie in a live section's drawable range. */
36
- export function assertDrawSpansWithinLiveRanges(
37
- spans: ReadonlyArray<{ start: number; count: number }>,
38
- liveRanges: ReadonlyArray<{ start: number; count: number }>,
39
- bufferName: string
40
- ): void {
41
- for (const span of spans) {
42
- for (let q = span.start; q < span.start + span.count; q++) {
43
- let inLive = false
44
- for (const live of liveRanges) {
45
- if (q >= live.start && q < live.start + live.count) {
46
- inLive = true
47
- break
48
- }
49
- }
50
- if (!inLive) {
51
- console.error('[GlobalLegacyBuffer] draw span covers non-live quad', {
52
- buffer: bufferName,
53
- quad: q,
54
- span,
55
- liveRanges
56
- })
57
- }
58
- }
59
- }
60
- }
61
-
62
35
  export type DirtyRange = { start: number; end: number }
63
36
 
64
37
  /** Split draw spans to exclude quad/face ranges still in pendingRanges (not yet on GPU). */
@@ -481,7 +454,6 @@ export class GlobalLegacyBuffer {
481
454
 
482
455
  if (mode === 'opaque') {
483
456
  let finalQuads: Array<{ start: number; count: number }>
484
- const liveDrawRanges = spans.map(s => ({ ...s }))
485
457
  const usedFullDraw = this.canUseFullDrawShortcut() && visibleQuadCount >= this.highWatermark * FULL_DRAW_VISIBLE_FRACTION
486
458
  if (usedFullDraw) {
487
459
  finalQuads = [{ start: 0, count: this.highWatermark }]
@@ -491,9 +463,6 @@ export class GlobalLegacyBuffer {
491
463
  finalQuads = spans
492
464
  }
493
465
  finalQuads = carveSpansAroundPendingRanges(finalQuads, this.pendingRanges)
494
- if (!usedFullDraw) {
495
- assertDrawSpansWithinLiveRanges(finalQuads, liveDrawRanges, this.mesh.name)
496
- }
497
466
  for (const span of finalQuads) {
498
467
  pushIndexSpan(span.start, span.count)
499
468
  }
@@ -2,6 +2,18 @@
2
2
  import { describe, expect, test } from 'vitest'
3
3
  import { buildVisibleCubeSpans } from '../cubeDrawSpans'
4
4
 
5
+ type DrawSpan = { start: number; count: number }
6
+
7
+ /** Every index in output spans must lie in the union of input section ranges. */
8
+ function expectEveryIndexInUnion(spans: DrawSpan[], sectionRanges: DrawSpan[]): void {
9
+ for (const span of spans) {
10
+ for (let i = span.start; i < span.start + span.count; i++) {
11
+ const inUnion = sectionRanges.some(r => i >= r.start && i < r.start + r.count)
12
+ expect(inUnion, `index ${i} not in union of section ranges`).toBe(true)
13
+ }
14
+ }
15
+ }
16
+
5
17
  describe('buildVisibleCubeSpans', () => {
6
18
  test('contiguous slots merge into one span', () => {
7
19
  const spans = buildVisibleCubeSpans(
@@ -100,4 +112,23 @@ describe('buildVisibleCubeSpans', () => {
100
112
  expect(buildVisibleCubeSpans([], 10)).toEqual([])
101
113
  expect(buildVisibleCubeSpans([{ start: 0, count: 1 }], 0)).toEqual([])
102
114
  })
115
+
116
+ test('merged adjacent sections stay within union of input ranges', () => {
117
+ const sectionRanges = [
118
+ { start: 100, count: 10 },
119
+ { start: 110, count: 10 }
120
+ ]
121
+ const spans = buildVisibleCubeSpans(sectionRanges, 120, false)
122
+ expect(spans).toEqual([{ start: 100, count: 20 }])
123
+ expectEveryIndexInUnion(spans, sectionRanges)
124
+ })
125
+
126
+ test('carved merged spans stay within union of input ranges', () => {
127
+ const sectionRanges = [
128
+ { start: 0, count: 10 },
129
+ { start: 10, count: 10 }
130
+ ]
131
+ const spans = buildVisibleCubeSpans(sectionRanges, 20, false, undefined, [{ start: 5, end: 14 }])
132
+ expectEveryIndexInUnion(spans, sectionRanges)
133
+ })
103
134
  })
@@ -701,6 +701,21 @@ test('GlobalLegacyBuffer: carveSpansAroundPendingRanges splits merged span', ()
701
701
  ])
702
702
  })
703
703
 
704
+ test('GlobalLegacyBuffer: carved merged adjacent sections stay within union of input ranges', () => {
705
+ const sectionRanges = [
706
+ { start: 100, count: 10 },
707
+ { start: 110, count: 10 }
708
+ ]
709
+ const merged = [{ start: 100, count: 20 }]
710
+ const carved = carveSpansAroundPendingRanges(merged, [{ start: 105, end: 114 }])
711
+ for (const span of carved) {
712
+ for (let q = span.start; q < span.start + span.count; q++) {
713
+ const inUnion = sectionRanges.some(r => q >= r.start && q < r.start + r.count)
714
+ expect(inUnion, `quad ${q} not in union of section ranges`).toBe(true)
715
+ }
716
+ }
717
+ })
718
+
704
719
  test('GlobalLegacyBuffer: uploadEpoch increments when dirty range drains', () => {
705
720
  const scene = new THREE.Scene()
706
721
  const mat = createGlobalLegacyBlockMaterial()
@@ -642,7 +642,7 @@ export function renderWasmOutputToGeometry(
642
642
  const doAO = (model as { ao?: boolean }).ao ?? cachedModel.boundingBox !== 'empty'
643
643
 
644
644
  let forceCullMask = 0
645
- if (world) {
645
+ if (world && cachedModel.isCube) {
646
646
  const shaderCubeFaceNameToIndex: Record<string, number> = {
647
647
  up: 0,
648
648
  down: 1,
@@ -787,7 +787,11 @@ export function renderWasmOutputToGeometry(
787
787
  const maxy = element.to[1]
788
788
  const maxz = element.to[2]
789
789
 
790
- if (faceIdx !== undefined && (block.visible_faces & (1 << faceIdx)) === 0) {
790
+ // `visible_faces` is a cell-boundary occlusion mask, so it may only cull faces
791
+ // that vanilla considers cullable at all — i.e. faces that declare `cullface`.
792
+ // Inset faces without it (a stair riser) are always drawn. Removing this guard
793
+ // is what broke issue #81 in 7ce1ebb.
794
+ if (matchingEFace.cullface && faceIdx !== undefined && (block.visible_faces & (1 << faceIdx)) === 0) {
791
795
  continue
792
796
  }
793
797
 
@@ -14,6 +14,7 @@ import { setBlockStatesData, getSectionGeometry } from '../../mesher-shared/mode
14
14
  import { resetFaceOcclusionCache } from '../../mesher-shared/faceOcclusion'
15
15
  import { convertChunkToWasm } from '../bridge/convertChunk'
16
16
  import { renderWasmOutputToGeometry } from '../bridge/render-from-wasm'
17
+ import type { ExportedSection } from '../../mesher-shared/exportedGeometryTypes'
17
18
 
18
19
  const VERSION = '1.18.2'
19
20
  const SECTION_Y = 0
@@ -130,6 +131,81 @@ function assertMesherParity(world: World, expectedQuads: number) {
130
131
  expect(wasmShader).toBe(expectedQuads)
131
132
  }
132
133
 
134
+ function renderWasmSection(world: World, shaderCubes = false): ExportedSection {
135
+ const column = world.getColumn(0, 0)!
136
+ const conversion = convertChunkToWasm(column, VERSION, 0, 0, SECTION_Y, SECTION_Y + SECTION_HEIGHT, SECTION_Y, SECTION_HEIGHT)
137
+ const wasmResult = wasmModule.generate_geometry(
138
+ 0,
139
+ SECTION_Y,
140
+ 0,
141
+ SECTION_HEIGHT,
142
+ SECTION_Y,
143
+ SECTION_Y + SECTION_HEIGHT,
144
+ SECTION_Y,
145
+ conversion.blockStates,
146
+ conversion.blockLight,
147
+ conversion.skyLight,
148
+ conversion.biomesArray,
149
+ conversion.invisibleBlocks,
150
+ conversion.transparentBlocks,
151
+ conversion.noAoBlocks,
152
+ conversion.cullIdenticalBlocks,
153
+ conversion.occludingBlocks,
154
+ true,
155
+ false,
156
+ 15
157
+ )
158
+ return renderWasmOutputToGeometry(wasmResult, VERSION, '0,0,0', { x: 8, y: 8, z: 8 }, world, {
159
+ sectionHeight: SECTION_HEIGHT,
160
+ shaderCubes
161
+ })
162
+ }
163
+
164
+ /** Inset stair riser in cell (bx, by, bz): normal along expectedNx, all four verts on the cell mid-x-plane and within the cell AABB. */
165
+ function hasInsetHorizontalRiser(section: ExportedSection, bx: number, by: number, bz: number, expectedNx: 1 | -1): boolean {
166
+ const pos = section.geometry.positions
167
+ const norm = section.geometry.normals
168
+ const idx = section.geometry.indices
169
+ const eps = 1e-3
170
+ const minX = (bx & 15) - 8
171
+ const maxX = minX + 1
172
+ const minY = (by & 15) - 8
173
+ const maxY = minY + 1
174
+ const minZ = (bz & 15) - 8
175
+ const maxZ = minZ + 1
176
+ const riserX = minX + 0.5
177
+
178
+ for (let i = 0; i < idx.length; i += 6) {
179
+ const vertIndices = [...new Set([idx[i], idx[i + 1], idx[i + 2], idx[i + 3], idx[i + 4], idx[i + 5]])]
180
+ if (vertIndices.length !== 4) continue
181
+
182
+ const nx = norm[vertIndices[0]! * 3]!
183
+ const ny = norm[vertIndices[0]! * 3 + 1]!
184
+ const nz = norm[vertIndices[0]! * 3 + 2]!
185
+ if (Math.abs(ny) > eps || Math.abs(nz) > eps) continue
186
+ if (Math.abs(nx - expectedNx) > eps) continue
187
+
188
+ const onRiserPlane = vertIndices.every(vi => Math.abs(pos[vi * 3]! - riserX) < eps)
189
+ if (!onRiserPlane) continue
190
+
191
+ const inCell = vertIndices.every(vi => {
192
+ const px = pos[vi * 3]!
193
+ const py = pos[vi * 3 + 1]!
194
+ const pz = pos[vi * 3 + 2]!
195
+ return px >= minX - eps && px <= maxX + eps && py >= minY - eps && py <= maxY + eps && pz >= minZ - eps && pz <= maxZ + eps
196
+ })
197
+ if (inCell) return true
198
+ }
199
+ return false
200
+ }
201
+
202
+ function stairWithStoneNeighbor(facing: 'east' | 'west' | 'south' | 'north', stone: { x: number; y: number; z: number }): BlockSpec[] {
203
+ return [
204
+ { x: 0, y: 0, z: 0, name: 'cut_copper_stairs', props: { facing, half: 'bottom', shape: 'straight', waterlogged: false } },
205
+ { x: stone.x, y: stone.y, z: stone.z, name: 'stone' }
206
+ ]
207
+ }
208
+
133
209
  function farmlandFieldBlocks(): BlockSpec[] {
134
210
  const blocks: BlockSpec[] = []
135
211
  for (let z = 0; z < 16; z++) {
@@ -279,6 +355,32 @@ test('culling regression: glass/leaves cluster — see-through blocks not over-c
279
355
  assertMesherParity(world, 20)
280
356
  })
281
357
 
358
+ test('culling regression: cactus on grass — ground top face not culled (issue #73)', () => {
359
+ const world = buildWorld([
360
+ { x: 0, y: 0, z: 0, name: 'grass_block' },
361
+ { x: 0, y: 1, z: 0, name: 'cactus' }
362
+ ])
363
+ // Pre-fix: grass top culled by cactus cosmetic cap → 14 quads. Post-fix: grass top drawn → 15.
364
+ assertMesherParity(world, 15)
365
+ })
366
+
367
+ test('culling regression: cactus stacked on cactus — no over-cull holes', () => {
368
+ const world = buildWorld([
369
+ { x: 0, y: 0, z: 0, name: 'cactus' },
370
+ { x: 0, y: 1, z: 0, name: 'cactus' }
371
+ ])
372
+ assertMesherParity(world, 12)
373
+ })
374
+
375
+ test('culling regression: cactus beside solid block — side faces still drawn', () => {
376
+ const world = buildWorld([
377
+ { x: 0, y: 0, z: 0, name: 'dirt' },
378
+ { x: 1, y: 0, z: 0, name: 'cactus' }
379
+ ])
380
+ expect(countQuadsFromLegacy(world)).toBeGreaterThan(9)
381
+ expect(countQuadsFromWasm(world, false)).toBeGreaterThan(9)
382
+ })
383
+
282
384
  test('shader cubes: dirt UP face culled under farmland', () => {
283
385
  const world = buildWorld([
284
386
  { x: 0, y: 0, z: 0, name: 'dirt' },
@@ -305,3 +407,59 @@ test('shader cubes: dirt side not culled beside bottom slab', () => {
305
407
  expect(wasmShader).toBe(legacy)
306
408
  expect(wasmShader).toBeGreaterThan(5)
307
409
  })
410
+
411
+ test('culling regression: east-facing stair + stone to east — full silhouette, no inset riser drop (issue #81)', () => {
412
+ const world = buildWorld(stairWithStoneNeighbor('east', { x: 1, y: 0, z: 0 }))
413
+ assertMesherParity(world, 14)
414
+ })
415
+
416
+ test('culling regression: west-facing stair + stone to east — inset riser kept (issue #81)', () => {
417
+ const world = buildWorld(stairWithStoneNeighbor('west', { x: 1, y: 0, z: 0 }))
418
+ assertMesherParity(world, 16)
419
+ expect(hasInsetHorizontalRiser(renderWasmSection(world), 0, 0, 0, 1)).toBe(true)
420
+ })
421
+
422
+ test('culling regression: south-facing stair + stone to south — inset riser kept (issue #81)', () => {
423
+ const world = buildWorld(stairWithStoneNeighbor('south', { x: 0, y: 0, z: 1 }))
424
+ // Tall half faces south toward stone (east/west analog); stone's north face is culled.
425
+ assertMesherParity(world, 14)
426
+ })
427
+
428
+ test('culling regression: north-facing stair + stone to south — inset riser kept (issue #81)', () => {
429
+ const world = buildWorld([
430
+ { x: 0, y: 0, z: 0, name: 'cut_copper_stairs', props: { facing: 'north', half: 'bottom', shape: 'straight', waterlogged: false } },
431
+ { x: 0, y: 0, z: 1, name: 'stone' }
432
+ ])
433
+ assertMesherParity(world, 16)
434
+ })
435
+
436
+ test('culling regression: all horizontal facings beside occluding cube — parity table (issue #81)', () => {
437
+ const cases: Array<{ blocks: BlockSpec[]; quads: number }> = [
438
+ { blocks: stairWithStoneNeighbor('east', { x: 1, y: 0, z: 0 }), quads: 14 },
439
+ { blocks: stairWithStoneNeighbor('west', { x: 1, y: 0, z: 0 }), quads: 16 },
440
+ { blocks: stairWithStoneNeighbor('south', { x: 0, y: 0, z: 1 }), quads: 14 },
441
+ {
442
+ blocks: [
443
+ { x: 0, y: 0, z: 0, name: 'cut_copper_stairs', props: { facing: 'north', half: 'bottom', shape: 'straight', waterlogged: false } },
444
+ { x: 0, y: 0, z: 1, name: 'stone' }
445
+ ],
446
+ quads: 16
447
+ }
448
+ ]
449
+ for (const { blocks, quads } of cases) {
450
+ assertMesherParity(buildWorld(blocks), quads)
451
+ }
452
+ })
453
+
454
+ test('culling regression: sofa scene — acacia stairs risers beside stripped log (issue #81)', () => {
455
+ const world = buildWorld([
456
+ { x: 1, y: 0, z: 0, name: 'stripped_acacia_log' },
457
+ { x: 1, y: 1, z: 0, name: 'acacia_pressure_plate' },
458
+ { x: 0, y: 0, z: 0, name: 'acacia_stairs', props: { facing: 'west', half: 'bottom', shape: 'straight', waterlogged: false } },
459
+ { x: 2, y: 0, z: 0, name: 'acacia_stairs', props: { facing: 'east', half: 'bottom', shape: 'straight', waterlogged: false } }
460
+ ])
461
+ assertMesherParity(world, 31)
462
+ const section = renderWasmSection(world)
463
+ expect(hasInsetHorizontalRiser(section, 0, 0, 0, 1)).toBe(true)
464
+ expect(hasInsetHorizontalRiser(section, 2, 0, 0, -1)).toBe(true)
465
+ })