minecraft-renderer 0.1.92 → 0.1.94

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.
Files changed (43) hide show
  1. package/dist/minecraft-renderer.js +68 -68
  2. package/dist/minecraft-renderer.js.meta.json +1 -1
  3. package/dist/threeWorker.js +522 -522
  4. package/package.json +1 -1
  5. package/src/graphicsBackend/appViewer.ts +4 -3
  6. package/src/graphicsBackend/config.ts +1 -1
  7. package/src/graphicsBackend/types.ts +4 -1
  8. package/src/lib/worldrendererCommon.ts +3 -3
  9. package/src/three/entities.ts +329 -43
  10. package/src/three/entity/EntityMesh.ts +8 -3
  11. package/src/three/entity/animations.js +37 -25
  12. package/src/three/entity/animations.test.ts +249 -0
  13. package/src/three/entity/boatModelRotation.test.ts +29 -0
  14. package/src/three/entity/boatModelRotation.ts +14 -0
  15. package/src/three/entity/boatPaddleAnimation.test.ts +108 -0
  16. package/src/three/entity/boatPaddleAnimation.ts +104 -0
  17. package/src/three/entity/boatPassengerRendering.ts +20 -0
  18. package/src/three/entity/boatPassengerRotation.test.ts +166 -0
  19. package/src/three/entity/boatPassengerRotation.ts +71 -0
  20. package/src/three/entity/boatRenderSetup.test.ts +236 -0
  21. package/src/three/entity/boatRenderSetup.ts +214 -0
  22. package/src/three/entity/entityHeadPose.test.ts +15 -0
  23. package/src/three/entity/entityHeadPose.ts +22 -0
  24. package/src/three/entity/horseEntityScale.test.ts +11 -0
  25. package/src/three/entity/horseEntityScale.ts +9 -0
  26. package/src/three/entity/horseHeadAnimation.test.ts +159 -0
  27. package/src/three/entity/horseHeadAnimation.ts +151 -0
  28. package/src/three/entity/horsePassengerRendering.test.ts +77 -0
  29. package/src/three/entity/interpolationPolicy.test.ts +259 -0
  30. package/src/three/entity/interpolationPolicy.ts +187 -0
  31. package/src/three/entity/localHorseCameraYawLock.test.ts +72 -0
  32. package/src/three/entity/localVehicleRendering.integration.test.ts +247 -0
  33. package/src/three/entity/minecartLocalPassengerRendering.test.ts +208 -0
  34. package/src/three/entity/networkHeadPitchRendering.test.ts +102 -0
  35. package/src/three/entity/networkHeadPitchRendering.ts +44 -0
  36. package/src/three/entity/remoteBoatPassengerRotation.integration.test.ts +227 -0
  37. package/src/three/entity/remoteBoatPassengerRotation.ts +128 -0
  38. package/src/three/entity/vehiclePassengerProduction.test.ts +214 -0
  39. package/src/three/entity/vehiclePassengerRendering.ts +56 -0
  40. package/src/three/entity/vehiclePassengerUpdate.ts +82 -0
  41. package/src/three/graphicsBackendBase.ts +5 -5
  42. package/src/three/graphicsBackendOffThread.ts +2 -2
  43. package/src/three/worldRendererThree.ts +54 -27
@@ -0,0 +1,166 @@
1
+ //@ts-nocheck
2
+ import { expect, test } from 'vitest'
3
+ import {
4
+ BOAT_PASSENGER_MAX_HEAD_YAW_RAD,
5
+ clampBoatPassengerRelativeHeadYaw,
6
+ getClampedBoatPassengerYaw,
7
+ normalizeYaw,
8
+ normalizeYawDelta,
9
+ resolveBoatPassengerThirdPersonRotation,
10
+ shouldApplyBoatPassengerRotation,
11
+ shouldApplyBoatPassengerThirdPersonRotation
12
+ } from './boatPassengerRotation'
13
+
14
+ const BOAT_YAW = -0.5645049299419149
15
+
16
+ test('clamp relative head yaw at ±105° boundaries', () => {
17
+ expect(clampBoatPassengerRelativeHeadYaw(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
18
+ expect(clampBoatPassengerRelativeHeadYaw(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD)).toBeCloseTo(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
19
+ })
20
+
21
+ test('clamp relative head yaw beyond both boundaries', () => {
22
+ const over = BOAT_PASSENGER_MAX_HEAD_YAW_RAD + 0.5
23
+ const under = -BOAT_PASSENGER_MAX_HEAD_YAW_RAD - 0.5
24
+ expect(clampBoatPassengerRelativeHeadYaw(over)).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
25
+ expect(clampBoatPassengerRelativeHeadYaw(under)).toBeCloseTo(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
26
+ })
27
+
28
+ test('normalizeYawDelta handles π/-π seam', () => {
29
+ expect(normalizeYawDelta(Math.PI, -Math.PI)).toBeCloseTo(0)
30
+ expect(normalizeYawDelta(-Math.PI, Math.PI)).toBeCloseTo(0)
31
+ expect(normalizeYawDelta(0.1, 2 * Math.PI + 0.1)).toBeCloseTo(0)
32
+ })
33
+
34
+ test('normalizeYaw wraps 2π to 0', () => {
35
+ expect(normalizeYaw(2 * Math.PI)).toBeCloseTo(0)
36
+ expect(normalizeYaw(-2 * Math.PI)).toBeCloseTo(0)
37
+ })
38
+
39
+ test('getClampedBoatPassengerYaw clamps absolute look beyond 105° from boat', () => {
40
+ const requested = BOAT_YAW + 2.5
41
+ const clamped = getClampedBoatPassengerYaw(requested, BOAT_YAW)
42
+ expect(normalizeYawDelta(BOAT_YAW, clamped)).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
43
+ })
44
+
45
+ test('renderer and camera share the same effective yaw', () => {
46
+ const cameraYaw = BOAT_YAW + 1.9
47
+ const clampedLook = getClampedBoatPassengerYaw(cameraYaw, BOAT_YAW)
48
+ const resolved = resolveBoatPassengerThirdPersonRotation({
49
+ cameraYaw,
50
+ cameraPitch: 0.2,
51
+ vehicleYaw: BOAT_YAW
52
+ })
53
+ expect(resolved.effectiveCameraYaw).toBeCloseTo(clampedLook)
54
+ expect(normalizeYaw(BOAT_YAW + resolved.headYaw)).toBeCloseTo(clampedLook)
55
+ })
56
+
57
+ test('positive camera delta yields positive local head yaw', () => {
58
+ const cameraYaw = BOAT_YAW + 0.5
59
+ const resolved = resolveBoatPassengerThirdPersonRotation({
60
+ cameraYaw,
61
+ cameraPitch: 0,
62
+ vehicleYaw: BOAT_YAW
63
+ })
64
+ expect(resolved.headYaw).toBeGreaterThan(0)
65
+ expect(resolved.headYaw).toBeCloseTo(normalizeYawDelta(BOAT_YAW, cameraYaw))
66
+ })
67
+
68
+ test('head yaw sign follows normalize(cameraYaw - vehicleYaw) without inversion', () => {
69
+ const positive = resolveBoatPassengerThirdPersonRotation({
70
+ cameraYaw: BOAT_YAW + 0.3,
71
+ cameraPitch: 0,
72
+ vehicleYaw: BOAT_YAW
73
+ })
74
+ const negative = resolveBoatPassengerThirdPersonRotation({
75
+ cameraYaw: BOAT_YAW - 0.3,
76
+ cameraPitch: 0,
77
+ vehicleYaw: BOAT_YAW
78
+ })
79
+ expect(positive.headYaw).toBeCloseTo(0.3)
80
+ expect(negative.headYaw).toBeCloseTo(-0.3)
81
+ })
82
+
83
+ test('shouldApplyBoatPassengerRotation excludes minecart and horse without third-person requirement', () => {
84
+ const base = {
85
+ isAnchoredPassenger: true,
86
+ vehicleYaw: BOAT_YAW
87
+ }
88
+ expect(shouldApplyBoatPassengerRotation({ ...base, vehicleName: 'boat' })).toBe(true)
89
+ expect(shouldApplyBoatPassengerRotation({ ...base, vehicleName: 'minecart' })).toBe(false)
90
+ expect(shouldApplyBoatPassengerRotation({ ...base, vehicleName: 'horse' })).toBe(false)
91
+ })
92
+
93
+ test('shouldApplyBoatPassengerThirdPersonRotation excludes minecart and horse', () => {
94
+ const base = {
95
+ isThirdPerson: true,
96
+ isAnchoredPassenger: true,
97
+ vehicleYaw: BOAT_YAW
98
+ }
99
+ expect(shouldApplyBoatPassengerThirdPersonRotation({ ...base, vehicleName: 'boat' })).toBe(true)
100
+ expect(shouldApplyBoatPassengerThirdPersonRotation({ ...base, vehicleName: 'minecart' })).toBe(false)
101
+ expect(shouldApplyBoatPassengerThirdPersonRotation({ ...base, vehicleName: 'horse' })).toBe(false)
102
+ })
103
+
104
+ test('first mount frame without anchor uses camera fallback gate', () => {
105
+ expect(
106
+ shouldApplyBoatPassengerThirdPersonRotation({
107
+ isThirdPerson: true,
108
+ isAnchoredPassenger: false,
109
+ vehicleName: 'boat',
110
+ vehicleYaw: BOAT_YAW
111
+ })
112
+ ).toBe(false)
113
+ })
114
+
115
+ test('first dismount frame without anchor uses camera fallback gate', () => {
116
+ expect(
117
+ shouldApplyBoatPassengerThirdPersonRotation({
118
+ isThirdPerson: true,
119
+ isAnchoredPassenger: false,
120
+ vehicleName: undefined,
121
+ vehicleYaw: undefined
122
+ })
123
+ ).toBe(false)
124
+ })
125
+
126
+ test('non-finite or missing vehicle yaw disables boat branch', () => {
127
+ expect(
128
+ shouldApplyBoatPassengerThirdPersonRotation({
129
+ isThirdPerson: true,
130
+ isAnchoredPassenger: true,
131
+ vehicleName: 'boat',
132
+ vehicleYaw: Number.NaN
133
+ })
134
+ ).toBe(false)
135
+ expect(getClampedBoatPassengerYaw(1.2, Number.NaN)).toBe(1.2)
136
+ expect(getClampedBoatPassengerYaw(Number.NaN, BOAT_YAW)).toBeNaN()
137
+ })
138
+
139
+ test('acceptance identity: headYaw ≈ clamp(normalize(cameraYaw - boatYaw))', () => {
140
+ const cameraYaw = BOAT_YAW + 1.1
141
+ const resolved = resolveBoatPassengerThirdPersonRotation({
142
+ cameraYaw,
143
+ cameraPitch: -0.4,
144
+ vehicleYaw: BOAT_YAW
145
+ })
146
+ const expectedHead = clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, cameraYaw))
147
+ expect(resolved.bodyYaw).toBeCloseTo(BOAT_YAW)
148
+ expect(resolved.headYaw).toBeCloseTo(expectedHead)
149
+ expect(resolved.effectiveCameraYaw).toBeCloseTo(normalizeYaw(BOAT_YAW + expectedHead))
150
+ expect(resolved.headPitch).toBeCloseTo(0.4)
151
+ })
152
+
153
+ test('helmet head armor copies skin head yaw after boat passenger rotation', () => {
154
+ const resolved = resolveBoatPassengerThirdPersonRotation({
155
+ cameraYaw: BOAT_YAW + 0.45,
156
+ cameraPitch: -0.2,
157
+ vehicleYaw: BOAT_YAW
158
+ })
159
+ const skinHead = { x: resolved.headPitch, y: resolved.headYaw, z: 0 }
160
+ const helmet = { x: 0, y: 0, z: 0 }
161
+ helmet.x = -skinHead.x
162
+ helmet.y = skinHead.y
163
+ helmet.z = skinHead.z
164
+ expect(helmet.y).toBeCloseTo(resolved.headYaw)
165
+ expect(helmet.x).toBeCloseTo(-resolved.headPitch)
166
+ })
@@ -0,0 +1,71 @@
1
+ //@ts-nocheck
2
+ import { isBoatEntityName } from './boatModelRotation'
3
+
4
+ /** Vanilla 1.17.1 `Boat.clampRotation` limit (degrees → radians). */
5
+ export const BOAT_PASSENGER_MAX_HEAD_YAW_RAD = (105 * Math.PI) / 180
6
+
7
+ export function normalizeYawDelta(from: number, to: number): number {
8
+ let delta = to - from
9
+ while (delta > Math.PI) delta -= Math.PI * 2
10
+ while (delta < -Math.PI) delta += Math.PI * 2
11
+ return delta
12
+ }
13
+
14
+ export function normalizeYaw(yaw: number): number {
15
+ return normalizeYawDelta(0, yaw)
16
+ }
17
+
18
+ export function clampBoatPassengerRelativeHeadYaw(relativeYaw: number): number {
19
+ return Math.max(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD, Math.min(BOAT_PASSENGER_MAX_HEAD_YAW_RAD, relativeYaw))
20
+ }
21
+
22
+ /** Clamp absolute look yaw for a boat passenger (vanilla `Boat.clampRotation`). */
23
+ export function getClampedBoatPassengerYaw(requestedYaw: number, boatYaw: number): number {
24
+ if (!Number.isFinite(requestedYaw) || !Number.isFinite(boatYaw)) return requestedYaw
25
+ const relative = normalizeYawDelta(boatYaw, requestedYaw)
26
+ const clampedRelative = clampBoatPassengerRelativeHeadYaw(relative)
27
+ return normalizeYaw(boatYaw + clampedRelative)
28
+ }
29
+
30
+ export type BoatPassengerThirdPersonRotation = {
31
+ bodyYaw: number
32
+ headYaw: number
33
+ headPitch: number
34
+ effectiveCameraYaw: number
35
+ }
36
+
37
+ export function resolveBoatPassengerThirdPersonRotation(params: {
38
+ cameraYaw: number
39
+ cameraPitch: number
40
+ vehicleYaw: number
41
+ }): BoatPassengerThirdPersonRotation {
42
+ const { cameraYaw, cameraPitch, vehicleYaw } = params
43
+ const relativeHeadYaw = normalizeYawDelta(vehicleYaw, cameraYaw)
44
+ const headYaw = clampBoatPassengerRelativeHeadYaw(relativeHeadYaw)
45
+ const effectiveCameraYaw = normalizeYaw(vehicleYaw + headYaw)
46
+ return {
47
+ bodyYaw: vehicleYaw,
48
+ headYaw,
49
+ headPitch: -cameraPitch,
50
+ effectiveCameraYaw
51
+ }
52
+ }
53
+
54
+ export function shouldApplyBoatPassengerRotation(params: {
55
+ isAnchoredPassenger: boolean
56
+ vehicleName: string | undefined
57
+ vehicleYaw: number | undefined
58
+ }): boolean {
59
+ return params.isAnchoredPassenger && isBoatEntityName(params.vehicleName) && typeof params.vehicleYaw === 'number' && Number.isFinite(params.vehicleYaw)
60
+ }
61
+
62
+ export function shouldApplyBoatPassengerThirdPersonRotation(params: {
63
+ isThirdPerson: boolean
64
+ isAnchoredPassenger: boolean
65
+ vehicleName: string | undefined
66
+ vehicleYaw: number | undefined
67
+ }): boolean {
68
+ return params.isThirdPerson && shouldApplyBoatPassengerRotation(params)
69
+ }
70
+
71
+ export { isBoatEntityName }
@@ -0,0 +1,236 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import { expect, test } from 'vitest'
4
+ import {
5
+ BOAT_HULL_RENDER_ORDER,
6
+ BOAT_OBJ_OFFSET_Y,
7
+ BOAT_PADDLE_GEOMETRY_DELTA,
8
+ BOAT_PADDLE_LEFT_PIVOT,
9
+ BOAT_PADDLE_PIVOT_LEFT_NAME,
10
+ BOAT_PADDLE_PIVOT_RIGHT_NAME,
11
+ BOAT_PADDLE_RIGHT_PIVOT,
12
+ BOAT_PADDLE_SOURCE_LEFT,
13
+ BOAT_WATER_PATCH_CENTER_Y,
14
+ BOAT_WATER_PATCH_CENTER_Z,
15
+ BOAT_WATER_PATCH_DEPTH,
16
+ BOAT_WATER_PATCH_HEIGHT,
17
+ BOAT_WATER_PATCH_NAME,
18
+ BOAT_WATER_PATCH_RENDER_ORDER,
19
+ BOAT_WATER_PATCH_WIDTH,
20
+ BOAT_WATER_PATCH_WORLD_BOUNDS,
21
+ applyBoatHullRenderSettings,
22
+ boatObjLocalToEntitySpace,
23
+ createBoatWaterPatchMesh,
24
+ getBoatWaterPatchEntitySpaceBounds,
25
+ setupBoatMesh,
26
+ setupBoatPaddlePivots
27
+ } from './boatRenderSetup'
28
+
29
+ test('water patch uses vanilla dimensions in blocks', () => {
30
+ const patch = createBoatWaterPatchMesh()
31
+ const size = new THREE.Vector3()
32
+ patch.geometry.computeBoundingBox()
33
+ patch.geometry.boundingBox!.getSize(size)
34
+ expect(size.x).toBeCloseTo(BOAT_WATER_PATCH_WIDTH, 5)
35
+ expect(size.y).toBeCloseTo(BOAT_WATER_PATCH_HEIGHT, 5)
36
+ expect(size.z).toBeCloseTo(BOAT_WATER_PATCH_DEPTH, 5)
37
+ })
38
+
39
+ test('water patch local center matches vanilla OBJ placement', () => {
40
+ const patch = createBoatWaterPatchMesh()
41
+ expect(patch.position.x).toBe(0)
42
+ expect(patch.position.y).toBeCloseTo(BOAT_WATER_PATCH_CENTER_Y, 5)
43
+ expect(patch.position.z).toBeCloseTo(BOAT_WATER_PATCH_CENTER_Z, 5)
44
+ })
45
+
46
+ test('water patch entity-space bounds match vanilla world-relative bounds', () => {
47
+ const bounds = getBoatWaterPatchEntitySpaceBounds(BOAT_OBJ_OFFSET_Y)
48
+ expect(bounds.minX).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.minX, 5)
49
+ expect(bounds.maxX).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.maxX, 5)
50
+ expect(bounds.minY).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.minY, 5)
51
+ expect(bounds.maxY).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.maxY, 5)
52
+ expect(bounds.minZ).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.minZ, 5)
53
+ expect(bounds.maxZ).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.maxZ, 5)
54
+ })
55
+
56
+ test('water patch material is depth-only', () => {
57
+ const patch = createBoatWaterPatchMesh()
58
+ const material = patch.material as THREE.MeshBasicMaterial
59
+ expect(material.colorWrite).toBe(false)
60
+ expect(material.depthWrite).toBe(true)
61
+ expect(material.depthTest).toBe(true)
62
+ })
63
+
64
+ test('boat hull renders in opaque list before depth-only patch', () => {
65
+ const root = new THREE.Object3D()
66
+ const hull = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.1 }))
67
+ hull.name = 'bottom'
68
+ root.add(hull)
69
+ const { waterPatch } = setupBoatMesh(root)
70
+
71
+ expect(hull.renderOrder).toBe(BOAT_HULL_RENDER_ORDER)
72
+ expect(waterPatch.renderOrder).toBe(BOAT_WATER_PATCH_RENDER_ORDER)
73
+ expect(waterPatch.renderOrder).toBeGreaterThan(hull.renderOrder)
74
+ expect(waterPatch.name).toBe(BOAT_WATER_PATCH_NAME)
75
+ })
76
+
77
+ test('boat hull material is alpha-tested opaque with depth write', () => {
78
+ const root = new THREE.Object3D()
79
+ const hull = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.05 }))
80
+ root.add(hull)
81
+ applyBoatHullRenderSettings(root)
82
+ const material = hull.material as THREE.MeshBasicMaterial
83
+ expect(material.transparent).toBe(false)
84
+ expect(material.alphaTest).toBeGreaterThanOrEqual(0.1)
85
+ expect(material.depthWrite).toBe(true)
86
+ expect(material.depthTest).toBe(true)
87
+ })
88
+
89
+ test('boat OBJ hull entity-space Y bounds after BOAT_OBJ_OFFSET_Y', () => {
90
+ const fs = require('node:fs')
91
+ const path = require('node:path')
92
+ const objPath = path.join(__dirname, 'models/boat.obj')
93
+ const txt = fs.readFileSync(objPath, 'utf8') as string
94
+ const groups: Record<string, { min: number; max: number }> = {}
95
+ let current = ''
96
+ for (const line of txt.split('\n')) {
97
+ if (line.startsWith('o ')) current = line.slice(2).trim()
98
+ if (!line.startsWith('v ')) continue
99
+ const y = Number(line.split(/\s+/)[2])
100
+ groups[current] ??= { min: Infinity, max: -Infinity }
101
+ groups[current].min = Math.min(groups[current].min, y + BOAT_OBJ_OFFSET_Y)
102
+ groups[current].max = Math.max(groups[current].max, y + BOAT_OBJ_OFFSET_Y)
103
+ }
104
+
105
+ expect(groups.bottom.min).toBeCloseTo(0, 5)
106
+ expect(groups.bottom.max).toBeCloseTo(0.1875, 5)
107
+ for (const name of ['front', 'back', 'left', 'right'] as const) {
108
+ expect(groups[name].min).toBeCloseTo(0.1875, 5)
109
+ expect(groups[name].max).toBeCloseTo(0.5625, 5)
110
+ }
111
+
112
+ const patch = getBoatWaterPatchEntitySpaceBounds(BOAT_OBJ_OFFSET_Y)
113
+ expect(patch.minY).toBeCloseTo(0.375, 5)
114
+ expect(patch.maxY).toBeCloseTo(0.5625, 5)
115
+ })
116
+
117
+ function makeBoatRootWithPaddles(): THREE.Object3D {
118
+ const root = new THREE.Object3D()
119
+ const leftShaft = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.5))
120
+ leftShaft.name = 'paddle_left'
121
+ leftShaft.position.set(0.2, 1.5, 1.0)
122
+ const leftBlade = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.2))
123
+ leftBlade.name = 'paddle_left'
124
+ leftBlade.position.set(0.15, 1.4, 1.2)
125
+ const rightShaft = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.5))
126
+ rightShaft.name = 'paddle_right'
127
+ rightShaft.position.set(0.2, 1.5, -1.0)
128
+ const rightBlade = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.2))
129
+ rightBlade.name = 'paddle_right'
130
+ rightBlade.position.set(0.15, 1.4, -1.2)
131
+ root.add(leftShaft, leftBlade, rightShaft, rightBlade)
132
+ return root
133
+ }
134
+
135
+ test('setupBoatPaddlePivots groups both paddle pieces by side', () => {
136
+ const root = makeBoatRootWithPaddles()
137
+ const { leftPivot, rightPivot } = setupBoatPaddlePivots(root)
138
+
139
+ expect(leftPivot?.children.length).toBe(2)
140
+ expect(rightPivot?.children.length).toBe(2)
141
+ expect(leftPivot?.name).toBe(BOAT_PADDLE_PIVOT_LEFT_NAME)
142
+ expect(rightPivot?.name).toBe(BOAT_PADDLE_PIVOT_RIGHT_NAME)
143
+ })
144
+
145
+ function getMeshRootLocalPosition(root: THREE.Object3D, mesh: THREE.Object3D): THREE.Vector3 {
146
+ const rootLocal = new THREE.Vector3()
147
+ let current: THREE.Object3D | null = mesh
148
+ while (current && current !== root) {
149
+ rootLocal.add(current.position)
150
+ current = current.parent
151
+ }
152
+ return rootLocal
153
+ }
154
+
155
+ test('setupBoatPaddlePivots uses corrected vanilla attachment positions', () => {
156
+ const root = makeBoatRootWithPaddles()
157
+ const { leftPivot, rightPivot } = setupBoatPaddlePivots(root)
158
+
159
+ expect(leftPivot?.position.toArray()).toEqual(BOAT_PADDLE_LEFT_PIVOT.toArray())
160
+ expect(rightPivot?.position.toArray()).toEqual(BOAT_PADDLE_RIGHT_PIVOT.toArray())
161
+ })
162
+
163
+ test('setupBoatPaddlePivots shifts root-local paddle geometry by geometry delta', () => {
164
+ const root = makeBoatRootWithPaddles()
165
+ const before = root.children.map(child => child.position.clone())
166
+ setupBoatPaddlePivots(root)
167
+
168
+ const afterPositions: THREE.Vector3[] = []
169
+ root.traverse(child => {
170
+ if (child instanceof THREE.Mesh && (child.name === 'paddle_left' || child.name === 'paddle_right')) {
171
+ afterPositions.push(getMeshRootLocalPosition(root, child))
172
+ }
173
+ })
174
+
175
+ expect(afterPositions.length).toBe(4)
176
+ for (let i = 0; i < before.length; i++) {
177
+ expect(afterPositions[i].x).toBeCloseTo(before[i].x + BOAT_PADDLE_GEOMETRY_DELTA.x, 5)
178
+ expect(afterPositions[i].y).toBeCloseTo(before[i].y + BOAT_PADDLE_GEOMETRY_DELTA.y, 5)
179
+ expect(afterPositions[i].z).toBeCloseTo(before[i].z + BOAT_PADDLE_GEOMETRY_DELTA.z, 5)
180
+ }
181
+ })
182
+
183
+ test('corrected paddle pivots map to vanilla entity Z with mesh yaw −π/2', () => {
184
+ const left = boatObjLocalToEntitySpace(BOAT_PADDLE_LEFT_PIVOT.x, BOAT_PADDLE_LEFT_PIVOT.y, BOAT_PADDLE_LEFT_PIVOT.z)
185
+ const right = boatObjLocalToEntitySpace(BOAT_PADDLE_RIGHT_PIVOT.x, BOAT_PADDLE_RIGHT_PIVOT.y, BOAT_PADDLE_RIGHT_PIVOT.z)
186
+
187
+ expect(left.z).toBeCloseTo(-3 / 16, 5)
188
+ expect(right.z).toBeCloseTo(-3 / 16, 5)
189
+ expect(left.y).toBeCloseTo(0.6875, 5)
190
+ expect(right.y).toBeCloseTo(0.6875, 5)
191
+ expect(left.x).toBeCloseTo(-9 / 16, 5)
192
+ expect(right.x).toBeCloseTo(9 / 16, 5)
193
+ })
194
+
195
+ test('setupBoatPaddlePivots offsets child geometry from source pivot for rotation', () => {
196
+ const root = makeBoatRootWithPaddles()
197
+ const { leftPivot } = setupBoatPaddlePivots(root)
198
+ expect(leftPivot).toBeDefined()
199
+
200
+ const shaft = leftPivot!.children[0]
201
+ const rootLocalBefore = new THREE.Vector3(0.2, 1.5, 1.0)
202
+ expect(shaft.position.x).toBeCloseTo(rootLocalBefore.x - BOAT_PADDLE_SOURCE_LEFT.x, 5)
203
+ expect(shaft.position.y).toBeCloseTo(rootLocalBefore.y - BOAT_PADDLE_SOURCE_LEFT.y, 5)
204
+ expect(shaft.position.z).toBeCloseTo(rootLocalBefore.z - BOAT_PADDLE_SOURCE_LEFT.z, 5)
205
+ })
206
+
207
+ test('rotating a pivot moves both paddle pieces rigidly', () => {
208
+ const root = makeBoatRootWithPaddles()
209
+ const { leftPivot } = setupBoatPaddlePivots(root)
210
+ expect(leftPivot).toBeDefined()
211
+
212
+ const childOffsetsBefore = leftPivot!.children.map(child => child.position.clone())
213
+ leftPivot!.rotation.y = Math.PI / 4
214
+ leftPivot!.updateMatrixWorld(true)
215
+
216
+ leftPivot!.children.forEach((child, index) => {
217
+ expect(child.position.x).toBeCloseTo(childOffsetsBefore[index].x, 5)
218
+ expect(child.position.y).toBeCloseTo(childOffsetsBefore[index].y, 5)
219
+ expect(child.position.z).toBeCloseTo(childOffsetsBefore[index].z, 5)
220
+ })
221
+ expect(leftPivot!.position.toArray()).toEqual(BOAT_PADDLE_LEFT_PIVOT.toArray())
222
+ })
223
+
224
+ test('setupBoatMesh is idempotent for paddle pivots and water patch', () => {
225
+ const root = makeBoatRootWithPaddles()
226
+ setupBoatMesh(root)
227
+ const leftPivot = root.userData.boatPaddleLeftPivot as THREE.Object3D
228
+ const waterPatchCountBefore = root.children.filter(child => child.name === BOAT_WATER_PATCH_NAME).length
229
+
230
+ setupBoatMesh(root)
231
+
232
+ expect(root.userData.boatPaddleLeftPivot).toBe(leftPivot)
233
+ expect(root.children.filter(child => child.name === BOAT_WATER_PATCH_NAME).length).toBe(waterPatchCountBefore)
234
+ expect(root.children.filter(child => child.name === BOAT_PADDLE_PIVOT_LEFT_NAME).length).toBe(1)
235
+ expect(root.children.filter(child => child.name === BOAT_PADDLE_PIVOT_RIGHT_NAME).length).toBe(1)
236
+ })
@@ -0,0 +1,214 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import { BOAT_MESH_YAW_OFFSET } from './boatModelRotation'
4
+
5
+ /** Vanilla BoatModel.createWaterPatch() size in blocks (after X rotation). */
6
+ export const BOAT_WATER_PATCH_WIDTH = 28 / 16
7
+ export const BOAT_WATER_PATCH_HEIGHT = 3 / 16
8
+ export const BOAT_WATER_PATCH_DEPTH = 16 / 16
9
+
10
+ /** OBJ boat root offset applied in EntityMesh (single source — import from here, do not duplicate). */
11
+ export const BOAT_OBJ_OFFSET_Y = -1.125
12
+
13
+ /**
14
+ * Vanilla 1.17.1 water patch center in OBJ-local space.
15
+ * Tuned with BOAT_OBJ_OFFSET_Y so entity-space bounds stay Y [0.375, 0.5625].
16
+ */
17
+ export const BOAT_WATER_PATCH_CENTER_Y = 1.59375
18
+ export const BOAT_WATER_PATCH_CENTER_Z = 0
19
+
20
+ export const BOAT_WATER_PATCH_WORLD_BOUNDS = {
21
+ minX: -0.875,
22
+ maxX: 0.875,
23
+ minY: 0.375,
24
+ maxY: 0.5625,
25
+ minZ: -0.5,
26
+ maxZ: 0.5
27
+ } as const
28
+
29
+ export const BOAT_HULL_RENDER_ORDER = 0
30
+ export const BOAT_WATER_PATCH_RENDER_ORDER = 1
31
+
32
+ export const BOAT_WATER_PATCH_NAME = 'boat_water_patch'
33
+
34
+ export const BOAT_PADDLE_PIVOT_LEFT_NAME = 'boat_paddle_pivot_left'
35
+ export const BOAT_PADDLE_PIVOT_RIGHT_NAME = 'boat_paddle_pivot_right'
36
+
37
+ /** Original handoff pivot (rotation center only — geometry stays aft if used alone). */
38
+ export const BOAT_PADDLE_SOURCE_LEFT = new THREE.Vector3(3 / 16, 28 / 16, 9 / 16)
39
+ export const BOAT_PADDLE_SOURCE_RIGHT = new THREE.Vector3(3 / 16, 28 / 16, -9 / 16)
40
+
41
+ /** Root-local shift applied to paddle mesh together with the pivot move. */
42
+ export const BOAT_PADDLE_GEOMETRY_DELTA = new THREE.Vector3(-6 / 16, 1 / 16, 0)
43
+
44
+ /** Vanilla 1.17.1 oarlock positions in OBJ-local blocks (after geometry delta). */
45
+ export const BOAT_PADDLE_LEFT_PIVOT = new THREE.Vector3(-3 / 16, 29 / 16, 9 / 16)
46
+ export const BOAT_PADDLE_RIGHT_PIVOT = new THREE.Vector3(-3 / 16, 29 / 16, -9 / 16)
47
+
48
+ /** Entity-space mapping for OBJ-local points (mesh `rotation.y = −π/2`, `BOAT_OBJ_OFFSET_Y`). */
49
+ export function boatObjLocalToEntitySpace(objX: number, objY: number, objZ: number): THREE.Vector3 {
50
+ return new THREE.Vector3(-objZ, objY + BOAT_OBJ_OFFSET_Y, objX)
51
+ }
52
+
53
+ const _rootLocalPos = new THREE.Vector3()
54
+
55
+ function getRootLocalPosition(object: THREE.Object3D, root: THREE.Object3D): THREE.Vector3 {
56
+ _rootLocalPos.set(0, 0, 0)
57
+ let current: THREE.Object3D | null = object
58
+ while (current && current !== root) {
59
+ _rootLocalPos.add(current.position)
60
+ current = current.parent
61
+ }
62
+ return _rootLocalPos
63
+ }
64
+
65
+ function createBoatPaddlePivot(
66
+ root: THREE.Object3D,
67
+ pivotName: string,
68
+ sourcePivot: THREE.Vector3,
69
+ targetPivot: THREE.Vector3,
70
+ paddleMeshes: THREE.Object3D[]
71
+ ): THREE.Object3D | undefined {
72
+ if (paddleMeshes.length === 0) return undefined
73
+
74
+ const pivot = new THREE.Object3D()
75
+ pivot.name = pivotName
76
+ pivot.position.copy(targetPivot)
77
+ root.add(pivot)
78
+
79
+ for (const mesh of paddleMeshes) {
80
+ const parent = mesh.parent
81
+ if (!parent) continue
82
+ const rootLocal = getRootLocalPosition(mesh, root)
83
+ parent.remove(mesh)
84
+ // Offset from source oarlock; pivot sits at target so root-local geometry shifts by delta.
85
+ mesh.position.copy(rootLocal).sub(sourcePivot)
86
+ pivot.add(mesh)
87
+ }
88
+
89
+ return pivot
90
+ }
91
+
92
+ function collectBoatPaddleMeshes(root: THREE.Object3D, sideName: 'paddle_left' | 'paddle_right'): THREE.Object3D[] {
93
+ const meshes: THREE.Object3D[] = []
94
+ root.traverse(child => {
95
+ if (child === root) return
96
+ if (child.name === BOAT_WATER_PATCH_NAME) return
97
+ if (child.name === BOAT_PADDLE_PIVOT_LEFT_NAME || child.name === BOAT_PADDLE_PIVOT_RIGHT_NAME) return
98
+ if (child.name === sideName) meshes.push(child)
99
+ })
100
+ return meshes
101
+ }
102
+
103
+ export function setupBoatPaddlePivots(root: THREE.Object3D): {
104
+ leftPivot?: THREE.Object3D
105
+ rightPivot?: THREE.Object3D
106
+ } {
107
+ if (root.userData.boatPaddleLeftPivot || root.userData.boatPaddleRightPivot) {
108
+ return {
109
+ leftPivot: root.userData.boatPaddleLeftPivot as THREE.Object3D | undefined,
110
+ rightPivot: root.userData.boatPaddleRightPivot as THREE.Object3D | undefined
111
+ }
112
+ }
113
+
114
+ const leftPivot = createBoatPaddlePivot(
115
+ root,
116
+ BOAT_PADDLE_PIVOT_LEFT_NAME,
117
+ BOAT_PADDLE_SOURCE_LEFT,
118
+ BOAT_PADDLE_LEFT_PIVOT,
119
+ collectBoatPaddleMeshes(root, 'paddle_left')
120
+ )
121
+ const rightPivot = createBoatPaddlePivot(
122
+ root,
123
+ BOAT_PADDLE_PIVOT_RIGHT_NAME,
124
+ BOAT_PADDLE_SOURCE_RIGHT,
125
+ BOAT_PADDLE_RIGHT_PIVOT,
126
+ collectBoatPaddleMeshes(root, 'paddle_right')
127
+ )
128
+
129
+ if (leftPivot) root.userData.boatPaddleLeftPivot = leftPivot
130
+ if (rightPivot) root.userData.boatPaddleRightPivot = rightPivot
131
+
132
+ return { leftPivot, rightPivot }
133
+ }
134
+
135
+ export function getBoatMeshYawOffset(): number {
136
+ return BOAT_MESH_YAW_OFFSET
137
+ }
138
+
139
+ export function createBoatWaterPatchGeometry(): THREE.BoxGeometry {
140
+ return new THREE.BoxGeometry(BOAT_WATER_PATCH_WIDTH, BOAT_WATER_PATCH_HEIGHT, BOAT_WATER_PATCH_DEPTH)
141
+ }
142
+
143
+ export function createBoatWaterPatchMaterial(): THREE.MeshBasicMaterial {
144
+ return new THREE.MeshBasicMaterial({
145
+ colorWrite: false,
146
+ depthWrite: true,
147
+ depthTest: true
148
+ })
149
+ }
150
+
151
+ export function createBoatWaterPatchMesh(): THREE.Mesh {
152
+ const mesh = new THREE.Mesh(createBoatWaterPatchGeometry(), createBoatWaterPatchMaterial())
153
+ mesh.name = BOAT_WATER_PATCH_NAME
154
+ mesh.position.set(0, BOAT_WATER_PATCH_CENTER_Y, BOAT_WATER_PATCH_CENTER_Z)
155
+ mesh.renderOrder = BOAT_WATER_PATCH_RENDER_ORDER
156
+ return mesh
157
+ }
158
+
159
+ export function applyBoatHullRenderSettings(root: THREE.Object3D): void {
160
+ root.traverse(child => {
161
+ if (!(child instanceof THREE.Mesh)) return
162
+ if (child.name === BOAT_WATER_PATCH_NAME) return
163
+ const materials = Array.isArray(child.material) ? child.material : [child.material]
164
+ for (const material of materials) {
165
+ if (!(material instanceof THREE.Material)) continue
166
+ material.transparent = false
167
+ material.depthWrite = true
168
+ material.depthTest = true
169
+ if ('alphaTest' in material) {
170
+ material.alphaTest = Math.max(material.alphaTest ?? 0, 0.1)
171
+ }
172
+ material.needsUpdate = true
173
+ }
174
+ child.renderOrder = BOAT_HULL_RENDER_ORDER
175
+ })
176
+ }
177
+
178
+ export function getBoatWaterPatchEntitySpaceBounds(objOffsetY = BOAT_OBJ_OFFSET_Y) {
179
+ const centerY = objOffsetY + BOAT_WATER_PATCH_CENTER_Y
180
+ const halfHeight = BOAT_WATER_PATCH_HEIGHT / 2
181
+ const halfWidth = BOAT_WATER_PATCH_WIDTH / 2
182
+ const halfDepth = BOAT_WATER_PATCH_DEPTH / 2
183
+ return {
184
+ minX: -halfWidth,
185
+ maxX: halfWidth,
186
+ minY: centerY - halfHeight,
187
+ maxY: centerY + halfHeight,
188
+ minZ: BOAT_WATER_PATCH_CENTER_Z - halfDepth,
189
+ maxZ: BOAT_WATER_PATCH_CENTER_Z + halfDepth
190
+ }
191
+ }
192
+
193
+ export function setupBoatMesh(root: THREE.Object3D): { waterPatch: THREE.Mesh } {
194
+ applyBoatHullRenderSettings(root)
195
+ setupBoatPaddlePivots(root)
196
+ let waterPatch = root.userData.boatWaterPatch as THREE.Mesh | undefined
197
+ if (!waterPatch) {
198
+ waterPatch = createBoatWaterPatchMesh()
199
+ waterPatch.visible = false
200
+ root.add(waterPatch)
201
+ root.userData.boatWaterPatch = waterPatch
202
+ }
203
+ return { waterPatch }
204
+ }
205
+
206
+ export function disposeBoatWaterPatch(root: THREE.Object3D): void {
207
+ const patch = root.userData.boatWaterPatch as THREE.Mesh | undefined
208
+ if (!patch) return
209
+ patch.geometry.dispose()
210
+ if (patch.material instanceof THREE.Material) {
211
+ patch.material.dispose()
212
+ }
213
+ delete root.userData.boatWaterPatch
214
+ }