minecraft-renderer 0.1.80 → 0.1.81

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.
@@ -296,36 +296,6 @@ test('ChunkMeshManager: invalid blend geometry falls back to pooled mesh', () =>
296
296
  manager.dispose()
297
297
  })
298
298
 
299
- test('ChunkMeshManager: raycastGlobalLegacySections rejects off-ray sections within center distance', () => {
300
- const manager = createManager()
301
- const onRayKey = '0,0,0'
302
- const offRayKey = '0,2,0'
303
-
304
- manager.updateSection(onRayKey, makeBlendOnlyGeometry())
305
-
306
- const offRayGeo = makeBlendOnlyGeometry()
307
- offRayGeo.sz = 40
308
- manager.updateSection(offRayKey, offRayGeo)
309
-
310
- expect(manager.globalLegacyBlendBuffer?.hasSection(onRayKey)).toBe(true)
311
- expect(manager.globalLegacyBlendBuffer?.hasSection(offRayKey)).toBe(true)
312
- expect(manager.sectionObjects[offRayKey]?.worldZ).toBe(40)
313
-
314
- const origin = new THREE.Vector3(4, 8, 8)
315
- const direction = new THREE.Vector3(1, 0, 0).normalize()
316
- const raycaster = new THREE.Raycaster(origin, direction)
317
- raycaster.far = 4
318
-
319
- const hit = manager.raycastGlobalLegacySections(raycaster, origin, 80)
320
- expect(hit).toBeDefined()
321
- expect(hit!).toBeGreaterThan(2)
322
- expect(hit!).toBeLessThan(4)
323
-
324
- manager.cleanupSection(onRayKey)
325
- manager.cleanupSection(offRayKey)
326
- manager.dispose()
327
- })
328
-
329
299
  test('ChunkMeshManager: mixed opaque and blend route to separate global buffers', () => {
330
300
  const manager = createManager()
331
301
  const key = '0,0,0'
@@ -0,0 +1,120 @@
1
+ //@ts-nocheck
2
+ import { raycastAabb, raycastAabbFromInside } from './sectionRaycastAabb'
3
+
4
+ export type VoxelRaycastHit = {
5
+ distance: number
6
+ blockX: number
7
+ blockY: number
8
+ blockZ: number
9
+ }
10
+
11
+ const intBound = (s: number, ds: number): number => {
12
+ if (ds === 0) return Infinity
13
+ if (ds < 0) return intBound(-s, -ds)
14
+ const frac = s - Math.floor(s)
15
+ return (1 - frac) / ds
16
+ }
17
+
18
+ const hitSolidBlock = (
19
+ ox: number,
20
+ oy: number,
21
+ oz: number,
22
+ dx: number,
23
+ dy: number,
24
+ dz: number,
25
+ bx: number,
26
+ by: number,
27
+ bz: number,
28
+ maxDist: number,
29
+ radius: number,
30
+ minCameraDistance: number
31
+ ): number | undefined => {
32
+ const minX = bx - radius
33
+ const minY = by - radius
34
+ const minZ = bz - radius
35
+ const maxX = bx + 1 + radius
36
+ const maxY = by + 1 + radius
37
+ const maxZ = bz + 1 + radius
38
+
39
+ let t = raycastAabb(ox, oy, oz, dx, dy, dz, minX, minY, minZ, maxX, maxY, maxZ, maxDist)
40
+ if (t === undefined) {
41
+ t = raycastAabbFromInside(ox, oy, oz, dx, dy, dz, minX, minY, minZ, maxX, maxY, maxZ, maxDist)
42
+ }
43
+ if (t === undefined) return undefined
44
+ return Math.max(minCameraDistance, t)
45
+ }
46
+
47
+ /**
48
+ * Amanatides–Woo grid traversal against solid block volumes (not rendered mesh faces).
49
+ * `radius` is baked into per-block AABB expansion (swept sphere).
50
+ */
51
+ export function raycastVoxelSolid(
52
+ ox: number,
53
+ oy: number,
54
+ oz: number,
55
+ dx: number,
56
+ dy: number,
57
+ dz: number,
58
+ maxDist: number,
59
+ radius: number,
60
+ minCameraDistance: number,
61
+ isSolid: (x: number, y: number, z: number) => boolean
62
+ ): VoxelRaycastHit | undefined {
63
+ if (maxDist <= 0) return undefined
64
+
65
+ let x = Math.floor(ox)
66
+ let y = Math.floor(oy)
67
+ let z = Math.floor(oz)
68
+
69
+ const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0
70
+ const stepY = dy > 0 ? 1 : dy < 0 ? -1 : 0
71
+ const stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0
72
+
73
+ const tDeltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity
74
+ const tDeltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity
75
+ const tDeltaZ = stepZ !== 0 ? Math.abs(1 / dz) : Infinity
76
+
77
+ let tMaxX = stepX !== 0 ? intBound(ox, dx) : Infinity
78
+ let tMaxY = stepY !== 0 ? intBound(oy, dy) : Infinity
79
+ let tMaxZ = stepZ !== 0 ? intBound(oz, dz) : Infinity
80
+
81
+ const tryHit = (bx: number, by: number, bz: number): VoxelRaycastHit | undefined => {
82
+ if (!isSolid(bx, by, bz)) return undefined
83
+ const distance = hitSolidBlock(ox, oy, oz, dx, dy, dz, bx, by, bz, maxDist, radius, minCameraDistance)
84
+ if (distance === undefined || distance > maxDist) return undefined
85
+ return { distance, blockX: bx, blockY: by, blockZ: bz }
86
+ }
87
+
88
+ let hit = tryHit(x, y, z)
89
+ if (hit) return hit
90
+
91
+ let t = 0
92
+ while (t <= maxDist) {
93
+ if (tMaxX < tMaxY) {
94
+ if (tMaxX < tMaxZ) {
95
+ x += stepX
96
+ t = tMaxX
97
+ tMaxX += tDeltaX
98
+ } else {
99
+ z += stepZ
100
+ t = tMaxZ
101
+ tMaxZ += tDeltaZ
102
+ }
103
+ } else if (tMaxY < tMaxZ) {
104
+ y += stepY
105
+ t = tMaxY
106
+ tMaxY += tDeltaY
107
+ } else {
108
+ z += stepZ
109
+ t = tMaxZ
110
+ tMaxZ += tDeltaZ
111
+ }
112
+
113
+ if (t > maxDist) break
114
+
115
+ hit = tryHit(x, y, z)
116
+ if (hit) return hit
117
+ }
118
+
119
+ return undefined
120
+ }
@@ -33,6 +33,7 @@ import { FireworksManager } from './fireworks'
33
33
  import { SceneOrigin } from './sceneOrigin'
34
34
  import { downloadWorldGeometry } from './worldGeometryExport'
35
35
  import { ChunkMeshManager } from './chunkMeshManager'
36
+ import { raycastVoxelSolid } from './thirdPersonVoxelRaycast'
36
37
  import type { RendererModuleManifest, RegisteredModule, RendererModuleController } from './rendererModuleSystem'
37
38
  import { BUILTIN_MODULES } from './modules/index'
38
39
  import { formatPerformanceFactorsDebug, PerformanceMonitor } from '../performanceMonitor'
@@ -149,7 +150,6 @@ export class WorldRendererThree extends WorldRendererCommon {
149
150
  private readonly _tpScenePos = new THREE.Vector3()
150
151
  private readonly _tpAxisX = new THREE.Vector3(1, 0, 0)
151
152
  private readonly _tpAxisY = new THREE.Vector3(0, 1, 0)
152
- private readonly _tpRaycaster = new THREE.Raycaster()
153
153
  private readonly _tpChunkWorldPos = new THREE.Vector3()
154
154
 
155
155
  get tilesRendered() {
@@ -788,6 +788,10 @@ export class WorldRendererThree extends WorldRendererCommon {
788
788
  text += `LEG-B: ${formatCompact(s.used)}/${formatCompact(s.capacity)}q `
789
789
  }
790
790
  text += `MEM: ${this.chunkMeshManager.getEstimatedMemoryUsage().total} `
791
+ const camCollisionBytes = this.cameraCollisionBlockCache.getAllocatedBytes()
792
+ if (camCollisionBytes > 0) {
793
+ text += `CAM: ${formatCompact(camCollisionBytes)}b `
794
+ }
791
795
  const pf = formatPerformanceFactorsDebug(this.reactiveState.world.instabilityFactors)
792
796
  if (pf) text += `PF: ${pf} `
793
797
  // entities can be seen in F3
@@ -1006,50 +1010,36 @@ export class WorldRendererThree extends WorldRendererCommon {
1006
1010
  this.debugRaycast(pos, direction, distance)
1007
1011
  }
1008
1012
 
1009
- const raycaster = this._tpRaycaster
1010
- raycaster.set(pos, direction)
1011
- raycaster.far = distance
1012
-
1013
- const maxCenterDistance = 80
1014
- const maxCenterDistSq = maxCenterDistance * maxCenterDistance
1015
- const ox = pos.x
1016
- const oy = pos.y
1017
- const oz = pos.z
1018
-
1019
- // Legacy section meshes: world-space raycast (static mesh.matrix translation).
1020
- const legacyMeshes: THREE.Object3D[] = []
1021
- for (const obj of Object.values(this.sectionObjects)) {
1022
- if (obj.name !== 'chunk' || !obj.visible) continue
1023
- if (obj.worldX === undefined) continue
1024
- const dcx = obj.worldX - ox
1025
- const dcy = obj.worldY! - oy
1026
- const dcz = obj.worldZ! - oz
1027
- if (dcx * dcx + dcy * dcy + dcz * dcz > maxCenterDistSq) continue
1028
- const mesh = obj.children.find(child => child.name === 'mesh')
1029
- if (mesh) legacyMeshes.push(mesh)
1030
- }
1031
-
1032
- const intersects = raycaster.intersectObjects(legacyMeshes, false)
1013
+ /** Swept-sphere radius for third-person camera collision (baked into voxel AABB expansion). */
1014
+ const CAMERA_COLLISION_RADIUS = 0.25
1015
+ /** Hard floor only guards zero/negative; never clamp up past a hit distance. */
1016
+ const MIN_CAMERA_DISTANCE = 0.05
1017
+ /** Eye→camera minimum so the lens stays outside the player head when a wall squeeze would pull closer. */
1018
+ const MIN_THIRD_PERSON_BODY_CLEARANCE = 0.4
1019
+
1020
+ const blockCache = this.cameraCollisionBlockCache
1021
+ const voxelHit = raycastVoxelSolid(
1022
+ pos.x,
1023
+ pos.y,
1024
+ pos.z,
1025
+ direction.x,
1026
+ direction.y,
1027
+ direction.z,
1028
+ distance,
1029
+ CAMERA_COLLISION_RADIUS,
1030
+ MIN_CAMERA_DISTANCE,
1031
+ (bx, by, bz) => blockCache.isSolidBlock(bx, by, bz)
1032
+ )
1033
1033
 
1034
1034
  let finalDistance = distance
1035
- if (intersects.length > 0) {
1036
- finalDistance = Math.max(0.5, intersects[0].distance - 0.2)
1035
+ if (voxelHit !== undefined) {
1036
+ finalDistance = Math.min(finalDistance, voxelHit.distance)
1037
1037
  }
1038
-
1039
- // Shader cubes in global buffer: tight per-section AABBs (no mesh raycast).
1040
- const boxHit = this.chunkMeshManager.raycastShaderSectionAABBs(pos, direction, finalDistance, maxCenterDistance)
1041
- if (boxHit !== undefined) {
1042
- finalDistance = Math.max(0.5, boxHit - 0.2)
1038
+ if (finalDistance < MIN_THIRD_PERSON_BODY_CLEARANCE) {
1039
+ finalDistance = MIN_THIRD_PERSON_BODY_CLEARANCE
1043
1040
  }
1044
1041
 
1045
- const legacyGlobalHit = this.chunkMeshManager.raycastGlobalLegacySections(raycaster, pos, maxCenterDistance)
1046
- if (legacyGlobalHit !== undefined) {
1047
- finalDistance = Math.max(0.5, legacyGlobalHit - 0.2)
1048
- }
1049
-
1050
- const finalPos = new Vec3(pos.x + direction.x * finalDistance, pos.y + direction.y * finalDistance, pos.z + direction.z * finalDistance)
1051
-
1052
- return finalPos
1042
+ return new Vec3(pos.x + direction.x * finalDistance, pos.y + direction.y * finalDistance, pos.z + direction.z * finalDistance)
1053
1043
  }
1054
1044
 
1055
1045
  private debugRaycastHelper?: THREE.ArrowHelper
@@ -0,0 +1,82 @@
1
+ //@ts-nocheck
2
+ import { test, expect } from 'vitest'
3
+ import Chunks from 'prismarine-chunk'
4
+ import MinecraftData from 'minecraft-data'
5
+ import { Vec3 } from 'vec3'
6
+ import { CAMERA_COLLISION_BYTES_PER_SECTION, CameraCollisionBlockCache } from '../../three/cameraCollisionBlockCache'
7
+
8
+ const VERSION = '1.16.5'
9
+
10
+ function makeChunkWithStoneFloor(y: number) {
11
+ const mcData = MinecraftData(VERSION)
12
+ const Chunk = Chunks(VERSION) as any
13
+ const chunk = new Chunk(undefined as any)
14
+ const stoneId = mcData.blocksByName.stone.defaultState
15
+ for (let x = 0; x < 16; x++) {
16
+ for (let z = 0; z < 16; z++) {
17
+ chunk.setBlockStateId(new Vec3(x, y, z), stoneId)
18
+ }
19
+ }
20
+ return chunk
21
+ }
22
+
23
+ test('CameraCollisionBlockCache: ingestColumn sets solids and skips air sections', () => {
24
+ const cache = new CameraCollisionBlockCache(VERSION)
25
+ cache.setWorldBounds(0, 256)
26
+ const chunk = makeChunkWithStoneFloor(64)
27
+ cache.ingestColumn(0, 0, chunk.toJson())
28
+
29
+ expect(cache.isSolidBlock(0, 64, 0)).toBe(true)
30
+ expect(cache.isSolidBlock(15, 64, 15)).toBe(true)
31
+ expect(cache.isSolidBlock(0, 65, 0)).toBe(false)
32
+ expect(cache.isSolidBlock(0, 0, 0)).toBe(false)
33
+
34
+ // One 16³ section at y=64 only (not the whole column height).
35
+ expect(cache.getAllocatedSectionCount()).toBe(1)
36
+ expect(cache.getAllocatedBytes()).toBe(CAMERA_COLLISION_BYTES_PER_SECTION)
37
+ })
38
+
39
+ test('CameraCollisionBlockCache: setBlockStateId updates and frees empty sections', () => {
40
+ const cache = new CameraCollisionBlockCache(VERSION)
41
+ cache.setWorldBounds(0, 256)
42
+ const mcData = MinecraftData(VERSION)
43
+ const stoneId = mcData.blocksByName.stone.defaultState
44
+
45
+ cache.setBlockStateId(5, 70, 5, stoneId)
46
+ expect(cache.isSolidBlock(5, 70, 5)).toBe(true)
47
+ expect(cache.getAllocatedSectionCount()).toBe(1)
48
+
49
+ cache.setBlockStateId(5, 70, 5, 0)
50
+ expect(cache.isSolidBlock(5, 70, 5)).toBe(false)
51
+ expect(cache.getAllocatedSectionCount()).toBe(0)
52
+ })
53
+
54
+ test('CameraCollisionBlockCache: removeColumn frees all section bitsets in column', () => {
55
+ const cache = new CameraCollisionBlockCache(VERSION)
56
+ cache.setWorldBounds(0, 256)
57
+ const mcData = MinecraftData(VERSION)
58
+ const stoneId = mcData.blocksByName.stone.defaultState
59
+
60
+ cache.setBlockStateId(0, 64, 0, stoneId)
61
+ cache.setBlockStateId(0, 80, 0, stoneId)
62
+ expect(cache.getAllocatedSectionCount()).toBe(2)
63
+
64
+ cache.removeColumn(0, 0)
65
+ expect(cache.getAllocatedSectionCount()).toBe(0)
66
+ expect(cache.isSolidBlock(0, 64, 0)).toBe(false)
67
+ })
68
+
69
+ test('CameraCollisionBlockCache: clear and setWorldBounds drop all sections', () => {
70
+ const cache = new CameraCollisionBlockCache(VERSION)
71
+ cache.setWorldBounds(0, 256)
72
+ const chunk = makeChunkWithStoneFloor(10)
73
+ cache.ingestColumn(16, 32, chunk.toJson())
74
+ expect(cache.getAllocatedSectionCount()).toBeGreaterThan(0)
75
+
76
+ cache.clear()
77
+ expect(cache.getAllocatedSectionCount()).toBe(0)
78
+
79
+ cache.ingestColumn(16, 32, chunk.toJson())
80
+ cache.setWorldBounds(-64, 320)
81
+ expect(cache.getAllocatedSectionCount()).toBe(0)
82
+ })
@@ -1,27 +1,9 @@
1
1
  //@ts-nocheck
2
2
  import { test, expect } from 'vitest'
3
- import {
4
- computeShaderSectionRaycastAabb,
5
- raycastAabb,
6
- raycastShaderBlocksAabb,
7
- raycastSectionAabb,
8
- sectionAabbIntersectsRay
9
- } from '../../three/sectionRaycastAabb'
10
- import { LEGACY_SECTION_HALF_EXTENT } from '../../three/legacySectionCull'
3
+ import { computeShaderSectionRaycastAabb, raycastAabb } from '../../three/sectionRaycastAabb'
11
4
  import { SHADER_CUBES_WORDS_PER_FACE } from '../bridge/shaderCubeBridge'
12
5
  import { WORD0 } from '../../three/shaders/cubeBlockShader'
13
6
 
14
- test('raycastSectionAabb: hit along +X (full 16³)', () => {
15
- const t = raycastSectionAabb(0, 0, 0, 1, 0, 0, 16, 0, 0, 100)
16
- expect(t).toBeDefined()
17
- expect(t!).toBeGreaterThanOrEqual(8)
18
- expect(t!).toBeLessThanOrEqual(24)
19
- })
20
-
21
- test('raycastSectionAabb: miss behind ray', () => {
22
- expect(raycastSectionAabb(0, 0, 0, 1, 0, 0, -32, 0, 0, 100)).toBeUndefined()
23
- })
24
-
25
7
  test('raycastAabb: respects maxDist', () => {
26
8
  expect(raycastAabb(0, 0, 0, 1, 0, 0, 100, 0, 0, 108, 0, 8, 10)).toBeUndefined()
27
9
  })
@@ -49,93 +31,6 @@ test('raycastAabb: origin inside box is ignored', () => {
49
31
  expect(t).toBeUndefined()
50
32
  })
51
33
 
52
- test('raycastShaderBlocksAabb: hits wall block when origin is inside section aggregate AABB', () => {
53
- const words = new Uint32Array(SHADER_CUBES_WORDS_PER_FACE * 3)
54
- words[0] = 8 | (4 << WORD0.LY_SHIFT) | (8 << WORD0.LZ_SHIFT)
55
- words[4] = 8 | (5 << WORD0.LY_SHIFT) | (8 << WORD0.LZ_SHIFT)
56
- words[8] = 8 | (6 << WORD0.LY_SHIFT) | (8 << WORD0.LZ_SHIFT)
57
- const visitGen = new Uint16Array(4096)
58
- const visitStamp = 1
59
- const t = raycastShaderBlocksAabb(words, 0, 3, SHADER_CUBES_WORDS_PER_FACE, 8, 8, 8, 4.5, 5.5, 8.5, 1, 0, 0, 10, visitGen, visitStamp)!
60
- expect(t).toBeGreaterThan(0)
61
- expect(t).toBeLessThan(4)
62
- })
63
-
64
- test('raycastShaderBlocksAabb: ray into empty space does not hit', () => {
65
- const words = new Uint32Array(SHADER_CUBES_WORDS_PER_FACE)
66
- words[0] = 8 | (4 << WORD0.LY_SHIFT) | (8 << WORD0.LZ_SHIFT)
67
- const visitGen = new Uint16Array(4096)
68
- const visitStamp = 1
69
- const t = raycastShaderBlocksAabb(words, 0, 1, SHADER_CUBES_WORDS_PER_FACE, 8, 8, 8, 4.5, 5.5, 8.5, -1, 0, 0, 10, visitGen, visitStamp)
70
- expect(t).toBeUndefined()
71
- })
72
-
73
- test('raycastShaderBlocksAabb: eye inside solid block uses exit distance', () => {
74
- const lx = 5
75
- const ly = 3
76
- const lz = 7
77
- const sectionCenterX = 8
78
- const sectionCenterY = 8
79
- const sectionCenterZ = 8
80
- const words = new Uint32Array(SHADER_CUBES_WORDS_PER_FACE)
81
- words[0] = lx | (ly << WORD0.LY_SHIFT) | (lz << WORD0.LZ_SHIFT)
82
- const visitGen = new Uint16Array(4096)
83
- const visitStamp = 1
84
- const ox = sectionCenterX - 8 + lx + 0.5
85
- const oy = sectionCenterY - 8 + ly + 0.5
86
- const oz = sectionCenterZ - 8 + lz + 0.5
87
- const t = raycastShaderBlocksAabb(
88
- words,
89
- 0,
90
- 1,
91
- SHADER_CUBES_WORDS_PER_FACE,
92
- sectionCenterX,
93
- sectionCenterY,
94
- sectionCenterZ,
95
- ox,
96
- oy,
97
- oz,
98
- 0,
99
- 0,
100
- 1,
101
- 10,
102
- visitGen,
103
- visitStamp
104
- )!
105
- expect(t).toBeGreaterThan(0)
106
- expect(t).toBeLessThanOrEqual(10)
107
- expect(t).toBeCloseTo(0.5, 5)
108
- })
109
-
110
- test('raycastShaderBlocksAabb: SoA stride-1 layout (GlobalBlockBuffer style)', () => {
111
- const w0 = new Uint32Array(2)
112
- w0[0] = 8 | (4 << WORD0.LY_SHIFT) | (8 << WORD0.LZ_SHIFT)
113
- w0[1] = 8 | (5 << WORD0.LY_SHIFT) | (8 << WORD0.LZ_SHIFT)
114
- const visitGen = new Uint16Array(4096)
115
- const visitStamp = 1
116
- const t = raycastShaderBlocksAabb(w0, 0, 2, 1, 8, 8, 8, 4.5, 5.5, 8.5, 1, 0, 0, 10, visitGen, visitStamp)!
117
- expect(t).toBeGreaterThan(0)
118
- expect(t).toBeLessThan(4)
119
- })
120
-
121
- const SECTION_HALF = LEGACY_SECTION_HALF_EXTENT + 0.01
122
-
123
- test('sectionAabbIntersectsRay: ray toward section center within far', () => {
124
- expect(sectionAabbIntersectsRay(8, 8, 8, 4, 8, 8, 1, 0, 0, 4, SECTION_HALF)).toBe(true)
125
- })
126
-
127
- test('sectionAabbIntersectsRay: far shorter than gap to section', () => {
128
- expect(sectionAabbIntersectsRay(8, 8, 8, -20, 8, 8, 1, 0, 0, 3, SECTION_HALF)).toBe(false)
129
- })
130
-
131
- test('sectionAabbIntersectsRay: parallel offset ray misses box', () => {
132
- expect(sectionAabbIntersectsRay(8, 8, 8, -20, 8, -20, 1, 0, 0, 100, SECTION_HALF)).toBe(false)
133
- })
134
-
135
- test('sectionAabbIntersectsRay: origin inside box', () => {
136
- expect(sectionAabbIntersectsRay(8, 8, 8, 8, 8, 8, 0, 1, 0, 4, SECTION_HALF)).toBe(true)
137
- })
138
-
139
34
  test('raycastAabb: narrow floor slab blocks downward ray', () => {
140
35
  const words = new Uint32Array(SHADER_CUBES_WORDS_PER_FACE * 2)
141
36
  words[0] = 4 | (0 << WORD0.LY_SHIFT) | (4 << WORD0.LZ_SHIFT)
@@ -0,0 +1,63 @@
1
+ //@ts-nocheck
2
+ import { test, expect } from 'vitest'
3
+ import { raycastVoxelSolid } from '../../three/thirdPersonVoxelRaycast'
4
+
5
+ const solid = (blocks: Set<string>) => (x: number, y: number, z: number) => blocks.has(`${x},${y},${z}`)
6
+
7
+ test('raycastVoxelSolid: straight-back ray hits wall', () => {
8
+ const blocks = solid(new Set(['5,5,5', '5,5,6', '5,5,7', '5,5,8']))
9
+ const hit = raycastVoxelSolid(0.5, 5.5, 5.5, 1, 0, 0, 10, 0.25, 0.05, blocks)
10
+ expect(hit).toBeDefined()
11
+ expect(hit!.blockX).toBe(5)
12
+ expect(hit!.distance).toBeGreaterThan(0)
13
+ expect(hit!.distance).toBeLessThan(5)
14
+ })
15
+
16
+ test('raycastVoxelSolid: diagonal ray through corner seam still hits volume', () => {
17
+ // Pit walls: solid L-shape; eye above pit looks back-down through corner gap in mesh shell.
18
+ const blocks = solid(
19
+ new Set([
20
+ // floor y=5
21
+ '4,5,4',
22
+ '5,5,4',
23
+ '6,5,4',
24
+ '4,5,5',
25
+ '5,5,5',
26
+ '6,5,5',
27
+ // walls
28
+ '4,6,4',
29
+ '4,7,4',
30
+ '5,6,4',
31
+ '6,6,5',
32
+ '6,7,5',
33
+ // surrounding ground (would be hollow in mesh)
34
+ '3,5,3',
35
+ '3,6,3',
36
+ '3,7,3',
37
+ '7,5,7',
38
+ '7,6,7',
39
+ '7,7,7'
40
+ ])
41
+ )
42
+ const dx = -0.6
43
+ const dy = -0.5
44
+ const dz = -0.6
45
+ const len = Math.hypot(dx, dy, dz)
46
+ const hit = raycastVoxelSolid(5.5, 7.5, 5.5, dx / len, dy / len, dz / len, 4, 0.25, 0.05, blocks)
47
+ expect(hit).toBeDefined()
48
+ expect(hit!.distance).toBeLessThan(4)
49
+ })
50
+
51
+ test('raycastVoxelSolid: passes through air and decorative pass-through is caller responsibility', () => {
52
+ const blocks = solid(new Set(['0,0,3']))
53
+ const hit = raycastVoxelSolid(0.5, 0.5, 0.5, 0, 0, 1, 10, 0.25, 0.05, blocks)
54
+ expect(hit?.blockZ).toBe(3)
55
+ })
56
+
57
+ test('raycastVoxelSolid: downward ray hits floor slab', () => {
58
+ const blocks = solid(new Set(['0,4,0']))
59
+ const hit = raycastVoxelSolid(0.5, 6.5, 0.5, 0, -1, 0, 10, 0.25, 0.05, blocks)
60
+ expect(hit).toBeDefined()
61
+ expect(hit!.blockY).toBe(4)
62
+ expect(hit!.distance).toBeCloseTo(1.25, 1)
63
+ })