@woosh/meep-engine 2.144.0 → 2.145.0

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
@@ -6,7 +6,7 @@
6
6
  "description": "Pure JavaScript game engine. Fully featured and production ready.",
7
7
  "type": "module",
8
8
  "author": "Alexander Goldring",
9
- "version": "2.144.0",
9
+ "version": "2.145.0",
10
10
  "main": "build/meep.module.js",
11
11
  "module": "build/meep.module.js",
12
12
  "exports": {
@@ -1 +1 @@
1
- {"version":3,"file":"BVH.d.ts","sourceRoot":"","sources":["../../../../../src/core/bvh2/bvh3/BVH.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"BVH.d.ts","sourceRoot":"","sources":["../../../../../src/core/bvh2/bvh3/BVH.js"],"names":[],"mappings":"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"}
@@ -800,7 +800,14 @@ export class BVH {
800
800
  const uint32 = this.__data_uint32;
801
801
 
802
802
  while (index !== NULL_NODE) {
803
- index = this.balance(index);
803
+ // SAH-reducing rotation (Box2D v3 style) — keeps query cost near the
804
+ // SAH optimum. {@link balance_height} (pure height-AVL) is kept as
805
+ // the alternative. The tree shape this produces differs from the
806
+ // height-balanced one, which (because the contact solver's order
807
+ // follows broadphase traversal order) shifts Gauss-Seidel
808
+ // convergence on near-aligned stacks — see the BVH balance note in
809
+ // engine/physics/PLAN.md.
810
+ index = this.balance_rotate(index);
804
811
 
805
812
  const node_address = index * ELEMENT_WORD_COUNT;
806
813
 
@@ -875,13 +882,18 @@ export class BVH {
875
882
  }
876
883
 
877
884
  /**
878
- * Perform a left or right rotation if node A is imbalanced.
879
- * Returns the new root index.
885
+ * **Height-balancing** rotation (Box2D v2 `b2DynamicTree::Balance`): if the
886
+ * two subtrees of node A differ in height by more than one, rotate the
887
+ * taller grandchild up. Keeps the tree height ≤ O(log N) but is chosen
888
+ * purely on *height* — it does not consider surface area, so the result is
889
+ * height-balanced yet not SAH-balanced (query cost can stay 1.3–2× above an
890
+ * SAH-optimal tree). See {@link balance_rotate} for the SAH-driven variant.
891
+ * Returns the (possibly new) subtree-root index.
880
892
  * @param {number} iA
881
893
  * @returns {number}
882
894
  * @private
883
895
  */
884
- balance(iA) {
896
+ balance_height(iA) {
885
897
  assert.notEqual(iA, NULL_NODE, 'input is a null node');
886
898
 
887
899
  //b2TreeNode* A = m_nodes + iA;
@@ -1079,6 +1091,148 @@ export class BVH {
1079
1091
  return iA;
1080
1092
  }
1081
1093
 
1094
+ /**
1095
+ * **SAH-reducing** rotation (Kensler 2008 / Box2D v3 `b2RotateNodes`). For
1096
+ * node A with children B, C, consider the four ways to swap a child of A
1097
+ * with a grandchild (a child of the other child); each rotation re-forms
1098
+ * exactly one internal node and changes the tree's total surface-area
1099
+ * (SAH) cost by `area(old child) − area(new child)`. Apply the swap with
1100
+ * the largest positive reduction, or none if no swap helps.
1101
+ *
1102
+ * Unlike {@link balance_height} the subtree root A never moves (so the
1103
+ * return value is always `iA`), and the criterion is *surface area*, not
1104
+ * height — keeping query cost near the SAH optimum. Height is not an
1105
+ * explicit invariant here, but SAH-good trees stay well-shaped for
1106
+ * spatially-distributed data (this is what modern Box2D ships).
1107
+ *
1108
+ * Deterministic: a pure function of the current tree state.
1109
+ *
1110
+ * @param {number} iA
1111
+ * @returns {number} always `iA` (the subtree root is unchanged)
1112
+ * @private
1113
+ */
1114
+ balance_rotate(iA) {
1115
+ assert.notEqual(iA, NULL_NODE, 'input is a null node');
1116
+
1117
+ const uint32 = this.__data_uint32;
1118
+ const a_addr = iA * ELEMENT_WORD_COUNT;
1119
+
1120
+ if (this.node_is_leaf(iA) || uint32[a_addr + COLUMN_HEIGHT] < 2) {
1121
+ return iA;
1122
+ }
1123
+
1124
+ const iB = uint32[a_addr + COLUMN_CHILD_1];
1125
+ const iC = uint32[a_addr + COLUMN_CHILD_2];
1126
+
1127
+ const b_leaf = this.node_is_leaf(iB);
1128
+ const c_leaf = this.node_is_leaf(iC);
1129
+
1130
+ // Largest beneficial SAH reduction found so far. Strictly positive, so a
1131
+ // zero-improvement (or worse) configuration leaves the tree untouched —
1132
+ // no churn, and deterministic.
1133
+ let best_improvement = 0;
1134
+ let best_rotation = 0; // 1..4, 0 = keep as-is
1135
+
1136
+ let iD = NULL_NODE, iE = NULL_NODE, iF = NULL_NODE, iG = NULL_NODE;
1137
+
1138
+ if (!b_leaf) {
1139
+ // B is internal with children D, E. Moving C down into B (and a
1140
+ // grandchild D/E up to A) re-forms B; the only changed area is B's.
1141
+ const area_b = this.node_get_surface_area(iB);
1142
+ iD = uint32[iB * ELEMENT_WORD_COUNT + COLUMN_CHILD_1];
1143
+ iE = uint32[iB * ELEMENT_WORD_COUNT + COLUMN_CHILD_2];
1144
+
1145
+ // Rotation 1 — swap C and D → B = (C, E), A = (B, D).
1146
+ const imp1 = area_b - this.node_get_combined_surface_area(iC, iE);
1147
+ if (imp1 > best_improvement) { best_improvement = imp1; best_rotation = 1; }
1148
+
1149
+ // Rotation 2 — swap C and E → B = (D, C), A = (B, E).
1150
+ const imp2 = area_b - this.node_get_combined_surface_area(iD, iC);
1151
+ if (imp2 > best_improvement) { best_improvement = imp2; best_rotation = 2; }
1152
+ }
1153
+
1154
+ if (!c_leaf) {
1155
+ // C is internal with children F, G. Symmetric: re-forms C.
1156
+ const area_c = this.node_get_surface_area(iC);
1157
+ iF = uint32[iC * ELEMENT_WORD_COUNT + COLUMN_CHILD_1];
1158
+ iG = uint32[iC * ELEMENT_WORD_COUNT + COLUMN_CHILD_2];
1159
+
1160
+ // Rotation 3 — swap B and F → C = (B, G), A = (F, C).
1161
+ const imp3 = area_c - this.node_get_combined_surface_area(iB, iG);
1162
+ if (imp3 > best_improvement) { best_improvement = imp3; best_rotation = 3; }
1163
+
1164
+ // Rotation 4 — swap B and G → C = (F, B), A = (G, C).
1165
+ const imp4 = area_c - this.node_get_combined_surface_area(iF, iB);
1166
+ if (imp4 > best_improvement) { best_improvement = imp4; best_rotation = 4; }
1167
+ }
1168
+
1169
+ if (best_rotation === 0) {
1170
+ return iA; // no beneficial rotation
1171
+ }
1172
+
1173
+ // Apply the chosen rotation: re-wire one child↔grandchild pair, then
1174
+ // refresh the re-formed internal node's AABB + height. A's AABB / height
1175
+ // are recomputed by the caller (bubble_up_update) after we return, but
1176
+ // we also set A's height here so the subtree is self-consistent.
1177
+ switch (best_rotation) {
1178
+ case 1: { // swap C and D: B = (C, E), A = (B, D)
1179
+ uint32[a_addr + COLUMN_CHILD_2] = iD;
1180
+ uint32[iB * ELEMENT_WORD_COUNT + COLUMN_CHILD_1] = iC;
1181
+ uint32[iD * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iA;
1182
+ uint32[iC * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iB;
1183
+ this.node_set_combined_aabb(iB, iC, iE);
1184
+ uint32[iB * ELEMENT_WORD_COUNT + COLUMN_HEIGHT] = 1 + max2(
1185
+ uint32[iC * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
1186
+ uint32[iE * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
1187
+ );
1188
+ break;
1189
+ }
1190
+ case 2: { // swap C and E: B = (D, C), A = (B, E)
1191
+ uint32[a_addr + COLUMN_CHILD_2] = iE;
1192
+ uint32[iB * ELEMENT_WORD_COUNT + COLUMN_CHILD_2] = iC;
1193
+ uint32[iE * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iA;
1194
+ uint32[iC * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iB;
1195
+ this.node_set_combined_aabb(iB, iD, iC);
1196
+ uint32[iB * ELEMENT_WORD_COUNT + COLUMN_HEIGHT] = 1 + max2(
1197
+ uint32[iD * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
1198
+ uint32[iC * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
1199
+ );
1200
+ break;
1201
+ }
1202
+ case 3: { // swap B and F: C = (B, G), A = (F, C)
1203
+ uint32[a_addr + COLUMN_CHILD_1] = iF;
1204
+ uint32[iC * ELEMENT_WORD_COUNT + COLUMN_CHILD_1] = iB;
1205
+ uint32[iF * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iA;
1206
+ uint32[iB * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iC;
1207
+ this.node_set_combined_aabb(iC, iB, iG);
1208
+ uint32[iC * ELEMENT_WORD_COUNT + COLUMN_HEIGHT] = 1 + max2(
1209
+ uint32[iB * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
1210
+ uint32[iG * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
1211
+ );
1212
+ break;
1213
+ }
1214
+ case 4: { // swap B and G: C = (F, B), A = (G, C)
1215
+ uint32[a_addr + COLUMN_CHILD_1] = iG;
1216
+ uint32[iC * ELEMENT_WORD_COUNT + COLUMN_CHILD_2] = iB;
1217
+ uint32[iG * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iA;
1218
+ uint32[iB * ELEMENT_WORD_COUNT + COLUMN_PARENT] = iC;
1219
+ this.node_set_combined_aabb(iC, iF, iB);
1220
+ uint32[iC * ELEMENT_WORD_COUNT + COLUMN_HEIGHT] = 1 + max2(
1221
+ uint32[iF * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
1222
+ uint32[iB * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
1223
+ );
1224
+ break;
1225
+ }
1226
+ }
1227
+
1228
+ uint32[a_addr + COLUMN_HEIGHT] = 1 + max2(
1229
+ uint32[uint32[a_addr + COLUMN_CHILD_1] * ELEMENT_WORD_COUNT + COLUMN_HEIGHT],
1230
+ uint32[uint32[a_addr + COLUMN_CHILD_2] * ELEMENT_WORD_COUNT + COLUMN_HEIGHT]
1231
+ );
1232
+
1233
+ return iA;
1234
+ }
1235
+
1082
1236
  /**
1083
1237
  * Utility method for assigning both children at once
1084
1238
  * Children must be valid nodes (non-null)
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A solid right circular cylinder aligned with the Y axis, centred at the local
3
+ * origin. A disk of {@link radius} extruded along Y for {@link height}, so it
4
+ * spans `y ∈ [-height/2, +height/2]` with **flat** circular caps (the capsule's
5
+ * flat-cap sibling — same radius/height parametrisation, hemispherical caps
6
+ * replaced by disks).
7
+ *
8
+ * Convex, so the physics narrowphase handles it through the GJK + EPA fallback
9
+ * via {@link support} — there is no closed-form cylinder-vs-X contact path yet
10
+ * (a future refinement; the curved side is the usual smooth-support EPA case).
11
+ *
12
+ * @author Alex Goldring
13
+ * @copyright Company Named Limited (c) 2026
14
+ */
15
+ export class CylinderShape3D extends AbstractShape3D {
16
+ /**
17
+ * @param {number} radius
18
+ * @param {number} height
19
+ * @returns {CylinderShape3D}
20
+ */
21
+ static from(radius: number, height: number): CylinderShape3D;
22
+ /**
23
+ * Radius of the circular cross-section.
24
+ * @type {number}
25
+ */
26
+ radius: number;
27
+ /**
28
+ * Full height along the Y axis (total extent — the cylinder spans
29
+ * `[-height/2, +height/2]`).
30
+ * @type {number}
31
+ */
32
+ height: number;
33
+ compute_bounding_box(result: any): void;
34
+ support(result: any, result_offset: any, direction_x: any, direction_y: any, direction_z: any): void;
35
+ signed_distance_at_point(point: any): number;
36
+ signed_distance_gradient_at_point(result: any, point: any): number;
37
+ contains_point(point: any): boolean;
38
+ nearest_point_on_surface(result: any, reference: any): void;
39
+ sample_random_point_in_volume(result: any, result_offset: any, random: any): void;
40
+ /**
41
+ * @param {CylinderShape3D} other
42
+ * @returns {boolean}
43
+ */
44
+ equals(other: CylinderShape3D): boolean;
45
+ /**
46
+ * Fast type-check marker, mirroring `isSphereShape3D` / `isBoxShape3D` /
47
+ * `isCapsuleShape3D`. Reserved for a future closed-form cylinder narrowphase
48
+ * dispatch; until then the cylinder routes through GJK + EPA like any other
49
+ * convex shape (no marker check needed for that path).
50
+ * @readonly
51
+ * @type {boolean}
52
+ */
53
+ readonly isCylinderShape3D: boolean;
54
+ }
55
+ import { AbstractShape3D } from "./AbstractShape3D.js";
56
+ //# sourceMappingURL=CylinderShape3D.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CylinderShape3D.d.ts","sourceRoot":"","sources":["../../../../../../src/core/geom/3d/shape/CylinderShape3D.js"],"names":[],"mappings":"AAUA;;;;;;;;;;;;;GAaG;AACH;IAkBI;;;;OAIG;IACH,oBAJW,MAAM,UACN,MAAM,GACJ,eAAe,CAa3B;IA9BG;;;OAGG;IACH,QAFU,MAAM,CAEC;IAEjB;;;;OAIG;IACH,QAFU,MAAM,CAED;IAgCnB,wCAUC;IAED,qGAqBC;IAED,6CAoBC;IAED,mEAEC;IAED,oCASC;IAED,4DA0CC;IAED,kFAOC;IAED;;;OAGG;IACH,cAHW,eAAe,GACb,OAAO,CAMnB;IAUL;;;;;;;OAOG;IACH,4BAFU,OAAO,CAE0B;CAV1C;gCA/M+B,sBAAsB"}
@@ -0,0 +1,223 @@
1
+ import { assert } from "../../../assert.js";
2
+ import { clamp } from "../../../math/clamp.js";
3
+ import { sign_not_zero } from "../../../math/sign_not_zero.js";
4
+ import { computeHashFloat } from "../../../primitives/numbers/computeHashFloat.js";
5
+ import { randomPointInCircle } from "../../random/randomPointInCircle.js";
6
+ import { AbstractShape3D } from "./AbstractShape3D.js";
7
+ import { compute_signed_distance_gradient_by_sampling } from "./util/compute_signed_distance_gradient_by_sampling.js";
8
+
9
+ const scratch_v3 = [0, 0, 0];
10
+
11
+ /**
12
+ * A solid right circular cylinder aligned with the Y axis, centred at the local
13
+ * origin. A disk of {@link radius} extruded along Y for {@link height}, so it
14
+ * spans `y ∈ [-height/2, +height/2]` with **flat** circular caps (the capsule's
15
+ * flat-cap sibling — same radius/height parametrisation, hemispherical caps
16
+ * replaced by disks).
17
+ *
18
+ * Convex, so the physics narrowphase handles it through the GJK + EPA fallback
19
+ * via {@link support} — there is no closed-form cylinder-vs-X contact path yet
20
+ * (a future refinement; the curved side is the usual smooth-support EPA case).
21
+ *
22
+ * @author Alex Goldring
23
+ * @copyright Company Named Limited (c) 2026
24
+ */
25
+ export class CylinderShape3D extends AbstractShape3D {
26
+ constructor() {
27
+ super();
28
+
29
+ /**
30
+ * Radius of the circular cross-section.
31
+ * @type {number}
32
+ */
33
+ this.radius = 0.5;
34
+
35
+ /**
36
+ * Full height along the Y axis (total extent — the cylinder spans
37
+ * `[-height/2, +height/2]`).
38
+ * @type {number}
39
+ */
40
+ this.height = 1;
41
+ }
42
+
43
+ /**
44
+ * @param {number} radius
45
+ * @param {number} height
46
+ * @returns {CylinderShape3D}
47
+ */
48
+ static from(radius, height) {
49
+ assert.isNumber(radius, 'radius');
50
+ assert.isNumber(height, 'height');
51
+ assert.greaterThanOrEqual(radius, 0, 'radius');
52
+ assert.greaterThanOrEqual(height, 0, 'height');
53
+
54
+ const r = new CylinderShape3D();
55
+ r.radius = radius;
56
+ r.height = height;
57
+
58
+ return r;
59
+ }
60
+
61
+ get volume() {
62
+ const r = this.radius;
63
+ return Math.PI * r * r * this.height;
64
+ }
65
+
66
+ get surface_area() {
67
+ const r = this.radius;
68
+ // two circular caps + the lateral (side) surface
69
+ return 2 * Math.PI * r * r + 2 * Math.PI * r * this.height;
70
+ }
71
+
72
+ compute_bounding_box(result) {
73
+ const r = this.radius;
74
+ const half_h = this.height / 2;
75
+
76
+ result[0] = -r;
77
+ result[1] = -half_h;
78
+ result[2] = -r;
79
+ result[3] = r;
80
+ result[4] = half_h;
81
+ result[5] = r;
82
+ }
83
+
84
+ support(result, result_offset, direction_x, direction_y, direction_z) {
85
+ const r = this.radius;
86
+ const half_h = this.height / 2;
87
+
88
+ // Radial: farthest point on the cross-section disk along (dx, dz). For a
89
+ // purely axial query (rl ≈ 0) any point on the cap face is extremal —
90
+ // the cap centre (0,0) is on the (flat) surface, so it's a valid support.
91
+ const rl = Math.sqrt(direction_x * direction_x + direction_z * direction_z);
92
+ let sx = 0, sz = 0;
93
+ if (rl > 0) {
94
+ const inv = r / rl;
95
+ sx = direction_x * inv;
96
+ sz = direction_z * inv;
97
+ }
98
+
99
+ // Axial: the cap on the +direction.y side.
100
+ const sy = sign_not_zero(direction_y) * half_h;
101
+
102
+ result[result_offset] = sx;
103
+ result[result_offset + 1] = sy;
104
+ result[result_offset + 2] = sz;
105
+ }
106
+
107
+ signed_distance_at_point(point) {
108
+ const px = point[0];
109
+ const py = point[1];
110
+ const pz = point[2];
111
+
112
+ const half_h = this.height / 2;
113
+
114
+ // Inigo Quilez capped-cylinder SDF, axis = Y. `d_radial` / `d_axial` are
115
+ // the signed distances outside the infinite cylinder and the slab.
116
+ const d_radial = Math.sqrt(px * px + pz * pz) - this.radius;
117
+ const d_axial = Math.abs(py) - half_h;
118
+
119
+ const ext_r = d_radial > 0 ? d_radial : 0;
120
+ const ext_a = d_axial > 0 ? d_axial : 0;
121
+ const outside = Math.sqrt(ext_r * ext_r + ext_a * ext_a);
122
+
123
+ const m = d_radial > d_axial ? d_radial : d_axial;
124
+ const inside = m < 0 ? m : 0;
125
+
126
+ return outside + inside;
127
+ }
128
+
129
+ signed_distance_gradient_at_point(result, point) {
130
+ return compute_signed_distance_gradient_by_sampling(result, this, point);
131
+ }
132
+
133
+ contains_point(point) {
134
+ const px = point[0];
135
+ const py = point[1];
136
+ const pz = point[2];
137
+
138
+ const half_h = this.height / 2;
139
+ const r = this.radius;
140
+
141
+ return (px * px + pz * pz) < r * r && (py < half_h) && (py > -half_h);
142
+ }
143
+
144
+ nearest_point_on_surface(result, reference) {
145
+ const rx = reference[0];
146
+ const ry = reference[1];
147
+ const rz = reference[2];
148
+
149
+ const r = this.radius;
150
+ const half_h = this.height / 2;
151
+
152
+ const rho = Math.sqrt(rx * rx + rz * rz);
153
+ let dirx, dirz;
154
+ if (rho > 0) {
155
+ dirx = rx / rho;
156
+ dirz = rz / rho;
157
+ } else {
158
+ // On the axis — radial direction is undefined; pick +X.
159
+ dirx = 1;
160
+ dirz = 0;
161
+ }
162
+
163
+ // Three candidate projections; the nearest to the reference is the
164
+ // closest surface point (correct for points inside and outside).
165
+ // 1. side — radial clamped to r, y clamped to the band
166
+ // 2. top cap — y = +half_h, radial clamped to r
167
+ // 3. bot cap — y = -half_h, radial clamped to r
168
+ const cy = clamp(ry, -half_h, half_h);
169
+ const s1x = r * dirx, s1y = cy, s1z = r * dirz;
170
+
171
+ const rc = rho < r ? rho : r;
172
+ const s2x = rc * dirx, s2y = half_h, s2z = rc * dirz;
173
+ const s3x = rc * dirx, s3y = -half_h, s3z = rc * dirz;
174
+
175
+ const d1 = (s1x - rx) * (s1x - rx) + (s1y - ry) * (s1y - ry) + (s1z - rz) * (s1z - rz);
176
+ const d2 = (s2x - rx) * (s2x - rx) + (s2y - ry) * (s2y - ry) + (s2z - rz) * (s2z - rz);
177
+ const d3 = (s3x - rx) * (s3x - rx) + (s3y - ry) * (s3y - ry) + (s3z - rz) * (s3z - rz);
178
+
179
+ if (d1 <= d2 && d1 <= d3) {
180
+ result[0] = s1x; result[1] = s1y; result[2] = s1z;
181
+ } else if (d2 <= d3) {
182
+ result[0] = s2x; result[1] = s2y; result[2] = s2z;
183
+ } else {
184
+ result[0] = s3x; result[1] = s3y; result[2] = s3z;
185
+ }
186
+ }
187
+
188
+ sample_random_point_in_volume(result, result_offset, random) {
189
+ // Uniform in the disk (sqrt-weighted radius) × uniform in y.
190
+ randomPointInCircle(random, scratch_v3, 0);
191
+
192
+ result[result_offset] = scratch_v3[0] * this.radius;
193
+ result[result_offset + 1] = (random() - 0.5) * this.height;
194
+ result[result_offset + 2] = scratch_v3[1] * this.radius;
195
+ }
196
+
197
+ /**
198
+ * @param {CylinderShape3D} other
199
+ * @returns {boolean}
200
+ */
201
+ equals(other) {
202
+ return super.equals(other)
203
+ && this.radius === other.radius
204
+ && this.height === other.height;
205
+ }
206
+
207
+ hash() {
208
+ const a = computeHashFloat(this.radius);
209
+ const b = computeHashFloat(this.height);
210
+
211
+ return ((a << 5) - a + b) | 0;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Fast type-check marker, mirroring `isSphereShape3D` / `isBoxShape3D` /
217
+ * `isCapsuleShape3D`. Reserved for a future closed-form cylinder narrowphase
218
+ * dispatch; until then the cylinder routes through GJK + EPA like any other
219
+ * convex shape (no marker check needed for that path).
220
+ * @readonly
221
+ * @type {boolean}
222
+ */
223
+ CylinderShape3D.prototype.isCylinderShape3D = true;
@@ -1 +1 @@
1
- {"version":3,"file":"shape_to_type.d.ts","sourceRoot":"","sources":["../../../../../../../src/core/geom/3d/shape/json/shape_to_type.js"],"names":[],"mappings":"AAMA;;;;GAIG;AACH,uDAFa,MAAM,CAkBlB"}
1
+ {"version":3,"file":"shape_to_type.d.ts","sourceRoot":"","sources":["../../../../../../../src/core/geom/3d/shape/json/shape_to_type.js"],"names":[],"mappings":"AAOA;;;;GAIG;AACH,uDAFa,MAAM,CAoBlB"}
@@ -1,4 +1,5 @@
1
1
  import { CapsuleShape3D } from "../CapsuleShape3D.js";
2
+ import { CylinderShape3D } from "../CylinderShape3D.js";
2
3
  import { SphereShape3D } from "../SphereShape3D.js";
3
4
  import { TransformedShape3D } from "../TransformedShape3D.js";
4
5
  import { UnionShape3D } from "../UnionShape3D.js";
@@ -22,6 +23,8 @@ export function shape_to_type(shape) {
22
23
  return 'transform';
23
24
  } else if (shape instanceof CapsuleShape3D) {
24
25
  return 'capsule';
26
+ } else if (shape instanceof CylinderShape3D) {
27
+ return 'cylinder';
25
28
  } else {
26
29
  throw new Error('Unsupported shape');
27
30
  }
@@ -30,6 +30,20 @@ export namespace type_adapters {
30
30
  height: number;
31
31
  };
32
32
  }
33
+ namespace cylinder {
34
+ function read({ radius, height }: {
35
+ radius: any;
36
+ height: any;
37
+ }): CylinderShape3D;
38
+ /**
39
+ *
40
+ * @param {CylinderShape3D} object
41
+ */
42
+ function write(object: CylinderShape3D): {
43
+ radius: number;
44
+ height: number;
45
+ };
46
+ }
33
47
  namespace transform {
34
48
  function read({ transform, subject }: {
35
49
  transform: any;
@@ -60,6 +74,7 @@ export namespace type_adapters {
60
74
  import { UnitCubeShape3D } from "../UnitCubeShape3D.js";
61
75
  import { SphereShape3D } from "../SphereShape3D.js";
62
76
  import { CapsuleShape3D } from "../CapsuleShape3D.js";
77
+ import { CylinderShape3D } from "../CylinderShape3D.js";
63
78
  import { TransformedShape3D } from "../TransformedShape3D.js";
64
79
  import { UnionShape3D } from "../UnionShape3D.js";
65
80
  //# sourceMappingURL=type_adapters.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"type_adapters.d.ts","sourceRoot":"","sources":["../../../../../../../src/core/geom/3d/shape/json/type_adapters.js"],"names":[],"mappings":";;QAWQ,iCAEC;QACD,qBAEC;;;QAGD;;0BAQC;QACD;;WAEG;QACH;;;;UAIC;;;QAGD;;;2BAEC;QACD;;;WAGG;QACH;;;UAKC;;;QAGD;;;+BAIC;QACD;;;WAGG;QACH;;;UAKC;;;QAGD;;yBAIC;QACD;;;WAGG;QACH;;UAIC;;;gCA/EuB,uBAAuB;8BAHzB,qBAAqB;+BADpB,sBAAsB;mCAElB,0BAA0B;6BAChC,oBAAoB"}
1
+ {"version":3,"file":"type_adapters.d.ts","sourceRoot":"","sources":["../../../../../../../src/core/geom/3d/shape/json/type_adapters.js"],"names":[],"mappings":";;QAYQ,iCAEC;QACD,qBAEC;;;QAGD;;0BAQC;QACD;;WAEG;QACH;;;;UAIC;;;QAGD;;;2BAEC;QACD;;;WAGG;QACH;;;UAKC;;;QAGD;;;4BAEC;QACD;;;WAGG;QACH;;;UAKC;;;QAGD;;;+BAIC;QACD;;;WAGG;QACH;;;UAKC;;;QAGD;;yBAIC;QACD;;;WAGG;QACH;;UAIC;;;gCA9FuB,uBAAuB;8BAHzB,qBAAqB;+BAFpB,sBAAsB;gCACrB,uBAAuB;mCAEpB,0BAA0B;6BAChC,oBAAoB"}
@@ -1,4 +1,5 @@
1
1
  import { CapsuleShape3D } from "../CapsuleShape3D.js";
2
+ import { CylinderShape3D } from "../CylinderShape3D.js";
2
3
  import { SphereShape3D } from "../SphereShape3D.js";
3
4
  import { TransformedShape3D } from "../TransformedShape3D.js";
4
5
  import { UnionShape3D } from "../UnionShape3D.js";
@@ -50,6 +51,21 @@ export const type_adapters = {
50
51
  };
51
52
  }
52
53
  },
54
+ 'cylinder': {
55
+ read({ radius, height }) {
56
+ return CylinderShape3D.from(radius, height);
57
+ },
58
+ /**
59
+ *
60
+ * @param {CylinderShape3D} object
61
+ */
62
+ write(object) {
63
+ return {
64
+ radius: object.radius,
65
+ height: object.height
66
+ };
67
+ }
68
+ },
53
69
  'transform': {
54
70
  read({ transform, subject }) {
55
71
  const subject_shape = shape_from_json(subject);
@@ -195,34 +195,84 @@ lives here.
195
195
 
196
196
  ### Phase 3 — Stairs ✅ **landed**
197
197
  Real stair climbing, gated by `stepHeight` (default 0.3 m, just under
198
- the capsule radius).
199
-
200
- The implementation diverged from the planned explicit "up-forward-down"
201
- step sweep that was tried and removed. The capsule's *round bottom*
202
- already rolls up a riser shorter than its radius (the slide moves the
203
- player up positionally), and an explicit step-up fought it (incremental
204
- wall-climbing, edge-wedging). What was actually needed was **honest
205
- ground categorization on a step edge**, which two probes split cleanly:
206
-
207
- - **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
208
- a drop stepHeight snaps onto the lower surface (stays grounded);
209
- a larger drop goes airborne.
210
- - **Step-UP** = categorize takes **walkability from a centre raycast**
211
- (sees the real planar surface; ignores a step's convex top edge and
212
- a wall's side face — so a steep *slope* is correctly not-grounded)
213
- and **rest height from a footprint shapecast** (raises onto a step
214
- the leading edge overhangs, capped at `stepHeight`). A single probe
215
- can't tell a climbable step edge from a steep slope both are
216
- steep-normal contacts which is why the two are split.
217
-
218
- This is also what fixed the "launch off every low step" jank: with the
219
- player correctly grounded through the roll, the per-tick vertical
220
- velocity is zeroed and never accumulates.
221
-
222
- **Guard tests** (`collision/Stairs.spec.js`, all green): climb a 5-step
223
- staircase staying grounded; clear a single curb without stalling; a
224
- 0.5 m riser (> stepHeight) blocks; descend a staircase grounded the
225
- whole way (no launch off each lip).
198
+ the capsule radius). Three cooperating pieces — it took iterating to
199
+ find that all three are needed:
200
+
201
+ 1. **Explicit step-up** (`_tryStepUp`, Source/Jolt up-forward-down).
202
+ When a grounded move is blocked, decide step-vs-wall with a **thin
203
+ horizontal ray at the step-height plane** (`sy + stepHeight + skin`),
204
+ then only if clear lift by stepHeight, advance the residual
205
+ forward, drop onto the step, commit when it gained ground within
206
+ stepHeight, and **restore the horizontal velocity**. The velocity
207
+ restore is the crux for REALISTIC stairs: the capsule's round bottom
208
+ also rolls up low risers, but only with sustained forward momentum,
209
+ and a controller that reads back the slide-clipped velocity loses
210
+ that momentum at the riser and stalls. The step-up climbs without
211
+ depending on momentum and hands the velocity back so the player keeps
212
+ moving up the flight.
213
+
214
+ The step-vs-wall ray is what stops the round bottom climbing a
215
+ too-tall wall and the reason it's a THIN ray, not the obvious
216
+ "lift the capsule and sweep it forward" clearance cast: lifted so its
217
+ tip sits at stepHeight, the capsule's rounded bottom narrows through
218
+ the band `(stepHeight, stepHeight+radius)`, so a wall whose top lands
219
+ in that band is never reached by the swept shape — it reads "clear"
220
+ and the player climbs it, *faster speed reaching further into the
221
+ round-off* (the gray-box bug: jitter at a walk, clean climb at a
222
+ sprint). A thin ray has no round-off: it reads the obstacle's true
223
+ height, so a step (top below the plane) is passed over and a wall
224
+ (top above) is struck on its front face, at any approach speed.
225
+
226
+ The ray is cast along the **blocked direction** (the horizontal
227
+ velocity the slide removed = the obstacle's inward normal), from the
228
+ **pre-slide** centre, reaching the swept distance plus a forward
229
+ extent. Both matter for OBLIQUE approaches: at 45° the capsule meets a
230
+ wall with its normal-direction extent, so a ray along *travel* stops
231
+ short of the face and reads a wall as clear (climbing it); and
232
+ rounding a convex corner the slide carries the *post*-slide centre
233
+ just past the corner, so a ray from there shoots past the obstacle —
234
+ the pre-slide centre was still in front of what blocked it. The reach
235
+ tracking the sweep isn't the banned speed coupling: the climb-or-block
236
+ decision is the step-height plane alone; the reach only governs how
237
+ far ahead to look for what the slide already hit.
238
+
239
+ A single ray still can't catch a convex *point*: grazing a corner a
240
+ few degrees off its bisector, the blocked normal runs nearly parallel
241
+ to a face, so the ray slips past the corner just outside the footprint
242
+ and reads clear. The backstop is a post-hoc OVERLAP query — after
243
+ up-forward-down, if the destination overlaps geometry the climb landed
244
+ the round body ON a wall corner, not on a step (a real step top is
245
+ rested on `skin` above, never overlapping), so it's rejected. Probe for
246
+ the common case, overlap-test to catch what a ray threads past.
247
+
248
+ The same round-bottom perch is why the slide keeps every contact's true
249
+ normal (no "flatten steep contacts to vertical" — that would also rob
250
+ a too-steep *slope* of its downhill slide) and why the footprint
251
+ mount in (2) is gated on height above the **centre surface**, not the
252
+ feet (gating on the feet lets a capsule that rode up a hair mount the
253
+ next sliver and ratchet up a wall).
254
+ 2. **Two-probe ground categorize** for honest grounded-ness on a step
255
+ edge — *walkability* from a centre raycast (ignores a step's convex
256
+ top edge and a wall's side face, so a steep *slope* is correctly
257
+ not-grounded) and *rest height* from a footprint shapecast (mounts
258
+ a step the leading edge overhangs). A single probe can't tell a
259
+ climbable step edge from a steep slope; the split can.
260
+ 3. **Step-DOWN** = the ground-stick reach is `stepHeight`: walking off
261
+ a drop ≤ stepHeight snaps onto the lower surface (stays grounded);
262
+ a larger drop goes airborne.
263
+
264
+ Footgun caught in the prototype: my first stair test used *deep
265
+ overlapping* steps, where the round-bottom roll alone climbs and the
266
+ explicit step-up looked unnecessary. Realistic *thin* treads (shallower
267
+ than the capsule footprint) need the step-up — see the thin-tread tests.
268
+
269
+ **Guard tests** (`collision/Stairs.spec.js` + `KinematicMoverIntegration.spec.js`,
270
+ all green): climb a 5-step staircase; climb a thin-tread staircase
271
+ (treads < footprint) both at the mover level and through the full
272
+ controller; clear a single curb; a 0.5 m riser (> stepHeight) blocks;
273
+ a 0.5 m riser is a **clean wall — no edge ride-up, and clearing is
274
+ speed-independent** (walk and sprint both stop dead, the gray-box guard);
275
+ descend a staircase grounded the whole way.
226
276
 
227
277
  ### Phase 4 — Motor seam + delete old code ✅ **landed**
228
278
  The mover is now the **only** collision path when a `PhysicsSystem` is
@@ -182,6 +182,19 @@ export class FirstPersonPlayerControllerSystem extends System<any, any, any, any
182
182
  * @param {number} dt
183
183
  */
184
184
  private _integrateVerticalAndResolveGround;
185
+ /**
186
+ * Resolve the current `runtime.velocity*` against the world — the
187
+ * physics-backed {@link KinematicMover} (recover + sweep-and-slide +
188
+ * ground-categorize) when a PhysicsSystem is present, else the no-physics
189
+ * flat-ground fallback. Gravity is NOT applied here: the standard path
190
+ * applies it in {@link _applyGravity} just before, and abilities with a
191
+ * non-standard vertical model (WallRun's reduced gravity) apply their own
192
+ * and then call this directly — so motion routes through one motor for
193
+ * everyone (sweep-and-slide, anti-tunnel, land/leave events) rather than
194
+ * a bespoke `position._add` + hand-rolled ground-catch.
195
+ * @private
196
+ */
197
+ private _resolveMotion;
185
198
  /**
186
199
  * Motor: integrate gravity into `velocityY` with the fall / variable-
187
200
  * cut multipliers. The mover never invents motion, so gravity lives
@@ -1 +1 @@
1
- {"version":3,"file":"FirstPersonPlayerControllerSystem.d.ts","sourceRoot":"","sources":["../../../../../src/engine/control/first-person/FirstPersonPlayerControllerSystem.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"FirstPersonPlayerControllerSystem.d.ts","sourceRoot":"","sources":["../../../../../src/engine/control/first-person/FirstPersonPlayerControllerSystem.js"],"names":[],"mappings":"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"}
@@ -1128,14 +1128,28 @@ export class FirstPersonPlayerControllerSystem extends System {
1128
1128
  */
1129
1129
  _integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt) {
1130
1130
  this._applyGravity(controller, runtime, dt);
1131
+ this._resolveMotion(controller, runtime, bodyTransform, dt);
1132
+ this._detectJumpApex(controller, runtime, bodyTransform);
1133
+ }
1131
1134
 
1135
+ /**
1136
+ * Resolve the current `runtime.velocity*` against the world — the
1137
+ * physics-backed {@link KinematicMover} (recover + sweep-and-slide +
1138
+ * ground-categorize) when a PhysicsSystem is present, else the no-physics
1139
+ * flat-ground fallback. Gravity is NOT applied here: the standard path
1140
+ * applies it in {@link _applyGravity} just before, and abilities with a
1141
+ * non-standard vertical model (WallRun's reduced gravity) apply their own
1142
+ * and then call this directly — so motion routes through one motor for
1143
+ * everyone (sweep-and-slide, anti-tunnel, land/leave events) rather than
1144
+ * a bespoke `position._add` + hand-rolled ground-catch.
1145
+ * @private
1146
+ */
1147
+ _resolveMotion(controller, runtime, bodyTransform, dt) {
1132
1148
  if (this.physicsSystem !== null) {
1133
1149
  this._moveViaMover(controller, runtime, bodyTransform, dt);
1134
1150
  } else {
1135
1151
  this._moveFlatGround(controller, runtime, bodyTransform, dt);
1136
1152
  }
1137
-
1138
- this._detectJumpApex(controller, runtime, bodyTransform);
1139
1153
  }
1140
1154
 
1141
1155
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"WallRun.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/control/first-person/abilities/WallRun.js"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IAMQ,qDAAqD;IACrD,iBAAiB;IACjB,yDAAyD;IACzD,cAAgB;IAGpB,kEAiBC;IAED,gDAqBC;IAED;;;;OAIG;IACH,wBAEC;IAED,uFAiFC;CAKJ;wBAlLuB,cAAc"}
1
+ {"version":3,"file":"WallRun.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/control/first-person/abilities/WallRun.js"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IAMQ,qDAAqD;IACrD,iBAAiB;IACjB,yDAAyD;IACzD,cAAgB;IAGpB,kEAiBC;IAED,gDAqBC;IAED;;;;OAIG;IACH,wBAEC;IAED,uFAgEC;CAKJ;wBAjKuB,cAAc"}
@@ -131,43 +131,26 @@ export class WallRun extends Ability {
131
131
  const rollSign = this._side === "L" ? -1 : 1;
132
132
  runtime.leanTargetRad = rollSign * cfg.cameraRollDeg * DEG_TO_RAD;
133
133
 
134
- // -- Reduced gravity.
134
+ // -- Reduced gravity. This is wall-run's OWN vertical model (a
135
+ // fraction of base gravity, no fall multiplier), applied here rather
136
+ // than via the system's standard gravity step.
135
137
  runtime.velocityY -= runtime.gravity * cfg.gravityFactor * dt;
136
138
 
137
- // -- Integrate position (manual don't call the system's vertical
138
- // phase because that would re-apply gravity with the fall multiplier).
139
- bodyTransform.position._add(
140
- runtime.velocityX * dt,
141
- runtime.velocityY * dt,
142
- runtime.velocityZ * dt,
143
- );
144
-
145
- // -- Ground catch. Reduced gravity is still gravity; over the
146
- // wall-run's lifetime the player drifts down. Without a ground
147
- // check here, the player can integrate PAST the floor — at
148
- // which point the wall sensor eventually drops out, base
149
- // resumes, and base's downward groundResolver raycast (from
150
- // below the floor) doesn't find anything above. Player free-
151
- // falls forever.
152
- //
153
- // Consult the same effective ground the base integrator uses:
154
- // max(useBuiltInFlatGround baseline, groundResolver). If we've
155
- // sunk to or past it, snap, zero vy, and release — base will
156
- // mark grounded on the next tick.
157
- let groundY = system.useBuiltInFlatGround ? system.groundY : null;
158
- if (system.groundResolver !== null) {
159
- const resolved = system.groundResolver(
160
- bodyTransform.position.x,
161
- bodyTransform.position.y,
162
- bodyTransform.position.z,
163
- );
164
- if (resolved !== null && (groundY === null || resolved > groundY)) {
165
- groundY = resolved;
166
- }
167
- }
168
- if (groundY !== null && bodyTransform.position.y <= groundY) {
169
- bodyTransform.position.setY(groundY);
170
- runtime.velocityY = 0;
139
+ // -- Resolve the move through the shared motor. With the reduced-
140
+ // gravity velocity set above, route it through the same sweep-and-
141
+ // slide + ground-categorize the base locomotion uses (the mover when
142
+ // physics is present, else the flat-ground fallback). _resolveMotion
143
+ // does NOT re-apply gravity. This replaces a raw `position._add` plus
144
+ // a hand-rolled groundResolver catch: the wall-runner now (a) can't
145
+ // drift INTO geometry (the sweep clips it) nor sail OFF a ledge the
146
+ // mover would have caught, and (b) gets the motor's anti-tunnel
147
+ // ground catch for free the same reason base no longer free-falls
148
+ // past a floor. The motor sets `state.grounded` and fires land/leave.
149
+ system._resolveMotion(controller, runtime, bodyTransform, dt);
150
+
151
+ // -- Exit: the motor caught a floor at the wall's foot (we ran out
152
+ // onto the ground). Release; base resumes next tick.
153
+ if (controller.state.grounded) {
171
154
  return false;
172
155
  }
173
156
 
@@ -45,11 +45,12 @@ export class KinematicMover {
45
45
  * `MIN_WALK_NORMAL` / Source `normal.z ≥ 0.7`.
46
46
  * @param {number} [options.stepHeight=0.3] maximum step the player
47
47
  * traverses, both up and down:
48
- * - UP: ground-categorize mounts the player onto a surface up to
49
- * `stepHeight` above the feet (a step the capsule's leading edge
50
- * reaches); a taller riser isn't mounted and the slide blocks it.
51
- * The capsule's round bottom does the actual climbing motion;
52
- * `stepHeight` is the gate on how high categorize will stick.
48
+ * - UP: a riser no taller than this is climbed — by the explicit
49
+ * step-up ({@link _tryStepUp}, up-forward-down, which works
50
+ * regardless of forward momentum) and by `_categorizeGround`
51
+ * mounting the player onto a surface within `stepHeight` of the
52
+ * feet. A taller riser blocks (the slide stops the player; the
53
+ * step-up's forward clearance cast detects the wall and aborts).
53
54
  * - DOWN: the ground-stick reach — walking off a drop no larger
54
55
  * than this snaps the player onto the lower surface (stays
55
56
  * grounded); a larger drop goes airborne (a real ledge).
@@ -76,6 +77,7 @@ export class KinematicMover {
76
77
  _penDir: Float64Array;
77
78
  _planes: Float64Array;
78
79
  _cand: Float64Array;
80
+ _support: Float64Array;
79
81
  /**
80
82
  * Reused result. `grounded` / `groundNormal` are Phase 2 outputs;
81
83
  * in Phase 1 they're left at their defaults (the mover doesn't
@@ -110,6 +112,34 @@ export class KinematicMover {
110
112
  grounded: boolean;
111
113
  groundNormal: Vector3;
112
114
  };
115
+ /**
116
+ * Stair step-up. Called after a slide that hit something while the
117
+ * player was trying to move horizontally on the ground. Re-runs the
118
+ * horizontal move from the pre-slide position lifted by `stepHeight`,
119
+ * guarded by a forward CLEARANCE cast: if the path is still blocked at
120
+ * the lifted height the obstacle is taller than a step (a wall) and we
121
+ * abandon the attempt; if clear it's a step, so advance over it and
122
+ * drop. Commits only when it advances farther horizontally than the
123
+ * plain slide and the rise is within `stepHeight`. On success the
124
+ * horizontal velocity is restored (the riser wasn't a wall) so the
125
+ * controller doesn't read back a stalled speed; the vertical is left
126
+ * for `_categorizeGround` to settle on the step.
127
+ *
128
+ * @private
129
+ */
130
+ private _tryStepUp;
131
+ /**
132
+ * Distance the shape can sweep along a unit axis before contact (less
133
+ * `skin`), or the full `maxDist` if clear. For the step-up lift/drop.
134
+ * @private
135
+ */
136
+ private _castDistance;
137
+ /**
138
+ * True when a centre raycast at (x,y,z) finds a walkable surface
139
+ * within stick range — gates step-up on "actually standing".
140
+ * @private
141
+ */
142
+ private _groundedAt;
113
143
  /**
114
144
  * Push the capsule out of any geometry it currently overlaps. Each
115
145
  * pass queries overlaps, finds the single deepest penetration via
@@ -1 +1 @@
1
- {"version":3,"file":"KinematicMover.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/control/first-person/collision/KinematicMover.js"],"names":[],"mappings":"AAmBA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IACI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,2BA7BW,OAAO,uCAAuC,EAAE,aAAa,OAC7D,OAAO,wCAAwC,EAAE,sBAAsB;QAKtD,IAAI,GAArB,MAAM;QAEW,kBAAkB,GAAnC,MAAM;QAEW,oBAAoB,GAArC,MAAM;QAEW,aAAa,GAA9B,MAAM;QAIW,UAAU,GAA3B,MAAM;OAqChB;IAvBG,6EAAkC;IAClC,6EAAc;IACd,aAA6D;IAC7D,2BAAmG;IACnG,6BAAyG;IACzG,sBAAsF;IACtF,mBAA6E;IAG7E,WAAsB;IACtB,0BAAqC;IACrC,yBAAsC;IACtC,sBAAkC;IAClC,sBAAoD;IACpD,oBAAgC;IAEhC;;;;;OAKG;IACH,SAFU;QAAC,GAAG,EAAC,OAAO,CAAC;QAAC,QAAQ,EAAC,OAAO,CAAC;QAAC,YAAY,EAAC,OAAO,CAAA;KAAC,CAEmB;IAGtF;;;;;;;;;;;;OAYG;IACH,eATW,OAAO,YACP;QAAC,CAAC,EAAC,MAAM,CAAC;QAAA,CAAC,EAAC,MAAM,CAAC;QAAA,CAAC,EAAC,MAAM,CAAC;QAAA,CAAC,EAAC,MAAM,CAAA;KAAC,SACrC,OAAO,mDAAmD,EAAE,eAAe,YAC3E,OAAO,MACP,MAAM,mBACE,MAAM,YAAW,QAAQ,KAAG,OAAO,GAEzC;QAAC,GAAG,EAAC,OAAO,CAAC;QAAC,QAAQ,EAAC,OAAO,CAAC;QAAC,YAAY,EAAC,OAAO,CAAA;KAAC,CA+BjE;IAED;;;;;;;;;OASG;IACH,iBAwCC;IAED;;;;;;;;OAQG;IACH,eA0HC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,0BAgDC;IAED;;;;;OAKG;IACH,cAKC;CACJ;qBAtaoB,sCAAsC;oCAIvB,iDAAiD;oBALjE,kCAAkC;yBAG7B,kCAAkC"}
1
+ {"version":3,"file":"KinematicMover.d.ts","sourceRoot":"","sources":["../../../../../../src/engine/control/first-person/collision/KinematicMover.js"],"names":[],"mappings":"AAmBA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH;IACI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,2BA9BW,OAAO,uCAAuC,EAAE,aAAa,OAC7D,OAAO,wCAAwC,EAAE,sBAAsB;QAKtD,IAAI,GAArB,MAAM;QAEW,kBAAkB,GAAnC,MAAM;QAEW,oBAAoB,GAArC,MAAM;QAEW,aAAa,GAA9B,MAAM;QAIW,UAAU,GAA3B,MAAM;OAuChB;IAxBG,6EAAkC;IAClC,6EAAc;IACd,aAA6D;IAC7D,2BAAmG;IACnG,6BAAyG;IACzG,sBAAsF;IACtF,mBAA6E;IAG7E,WAAsB;IACtB,0BAAqC;IACrC,yBAAsC;IACtC,sBAAkC;IAClC,sBAAoD;IACpD,oBAAgC;IAChC,uBAAmC;IAEnC;;;;;OAKG;IACH,SAFU;QAAC,GAAG,EAAC,OAAO,CAAC;QAAC,QAAQ,EAAC,OAAO,CAAC;QAAC,YAAY,EAAC,OAAO,CAAA;KAAC,CAEmB;IAGtF;;;;;;;;;;;;OAYG;IACH,eATW,OAAO,YACP;QAAC,CAAC,EAAC,MAAM,CAAC;QAAA,CAAC,EAAC,MAAM,CAAC;QAAA,CAAC,EAAC,MAAM,CAAC;QAAA,CAAC,EAAC,MAAM,CAAA;KAAC,SACrC,OAAO,mDAAmD,EAAE,eAAe,YAC3E,OAAO,MACP,MAAM,mBACE,MAAM,YAAW,QAAQ,KAAG,OAAO,GAEzC;QAAC,GAAG,EAAC,OAAO,CAAC;QAAC,QAAQ,EAAC,OAAO,CAAC;QAAC,YAAY,EAAC,OAAO,CAAA;KAAC,CA4CjE;IAED;;;;;;;;;;;;;;OAcG;IACH,mBA0FC;IAED;;;;OAIG;IACH,sBAQC;IAED;;;;OAIG;IACH,oBASC;IAED;;;;;;;;;OASG;IACH,iBAwCC;IAED;;;;;;;;OAQG;IACH,eAmIC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,0BAsDC;IAED;;;;;OAKG;IACH,cAKC;CACJ;qBA9kBoB,sCAAsC;oCAIvB,iDAAiD;oBALjE,kCAAkC;yBAG7B,kCAAkC"}
@@ -64,11 +64,12 @@ export class KinematicMover {
64
64
  * `MIN_WALK_NORMAL` / Source `normal.z ≥ 0.7`.
65
65
  * @param {number} [options.stepHeight=0.3] maximum step the player
66
66
  * traverses, both up and down:
67
- * - UP: ground-categorize mounts the player onto a surface up to
68
- * `stepHeight` above the feet (a step the capsule's leading edge
69
- * reaches); a taller riser isn't mounted and the slide blocks it.
70
- * The capsule's round bottom does the actual climbing motion;
71
- * `stepHeight` is the gate on how high categorize will stick.
67
+ * - UP: a riser no taller than this is climbed — by the explicit
68
+ * step-up ({@link _tryStepUp}, up-forward-down, which works
69
+ * regardless of forward momentum) and by `_categorizeGround`
70
+ * mounting the player onto a surface within `stepHeight` of the
71
+ * feet. A taller riser blocks (the slide stops the player; the
72
+ * step-up's forward clearance cast detects the wall and aborts).
72
73
  * - DOWN: the ground-stick reach — walking off a drop no larger
73
74
  * than this snaps the player onto the lower surface (stays
74
75
  * grounded); a larger drop goes airborne (a real ledge).
@@ -91,6 +92,7 @@ export class KinematicMover {
91
92
  this._penDir = new Float64Array(3); // compute_penetration out
92
93
  this._planes = new Float64Array(MAX_CLIP_PLANES * 3);
93
94
  this._cand = new Float64Array(3); // clipped-velocity candidate
95
+ this._support = new Float64Array(3); // shape support point (forward extent)
94
96
 
95
97
  /**
96
98
  * Reused result. `grounded` / `groundNormal` are Phase 2 outputs;
@@ -139,12 +141,163 @@ export class KinematicMover {
139
141
  // 2. Sweep-and-slide the desired motion.
140
142
  this._slide(position, rotation, shape, velocity, dt, filter, result);
141
143
 
144
+ // 2b. Stairs — if a grounded move was blocked, try to step up and
145
+ // over a low riser. The capsule's round bottom also rolls up
146
+ // low steps via the slide, but only with enough forward
147
+ // momentum; a controller that reads back the slide-clipped
148
+ // velocity loses that momentum at the riser and gets stuck.
149
+ // The explicit step-up climbs without depending on momentum
150
+ // AND restores the horizontal velocity, so the player keeps
151
+ // moving up the flight.
152
+ if (this.stepHeight > 0 && !ascending && result.hit) {
153
+ this._tryStepUp(position, rotation, shape, velocity,
154
+ sx, sy, sz, svx, svy, svz, dt, filter);
155
+ }
156
+
142
157
  // 3. Ground categorize + stick + slope velocity clip.
143
158
  this._categorizeGround(position, rotation, shape, velocity, filter, result, ascending);
144
159
 
145
160
  return result;
146
161
  }
147
162
 
163
+ /**
164
+ * Stair step-up. Called after a slide that hit something while the
165
+ * player was trying to move horizontally on the ground. Re-runs the
166
+ * horizontal move from the pre-slide position lifted by `stepHeight`,
167
+ * guarded by a forward CLEARANCE cast: if the path is still blocked at
168
+ * the lifted height the obstacle is taller than a step (a wall) and we
169
+ * abandon the attempt; if clear it's a step, so advance over it and
170
+ * drop. Commits only when it advances farther horizontally than the
171
+ * plain slide and the rise is within `stepHeight`. On success the
172
+ * horizontal velocity is restored (the riser wasn't a wall) so the
173
+ * controller doesn't read back a stalled speed; the vertical is left
174
+ * for `_categorizeGround` to settle on the step.
175
+ *
176
+ * @private
177
+ */
178
+ _tryStepUp(position, rotation, shape, velocity, sx, sy, sz, svx, svy, svz, dt, filter) {
179
+ const hdx = svx * dt, hdz = svz * dt;
180
+ const forwardLen = Math.sqrt(hdx * hdx + hdz * hdz);
181
+ if (forwardLen < MIN_MOVE) return; // no horizontal intent
182
+ // Only step from a grounded start — never mid-air (else a player
183
+ // could climb a wall by jumping into it).
184
+ if (!this._groundedAt(sx, sy, sz, filter)) return;
185
+
186
+ const slideProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
187
+ const px = position.x, py = position.y, pz = position.z;
188
+ const pvx = velocity.x, pvy = velocity.y, pvz = velocity.z;
189
+ const fnx = hdx / forwardLen, fnz = hdz / forwardLen; // travel direction
190
+
191
+ // Direction the slide was BLOCKED in — the inward normal of the
192
+ // obstacle the player walked into = the horizontal velocity the
193
+ // slide removed (pre-slide minus clipped). For a head-on approach
194
+ // this equals the travel direction; for an OBLIQUE one it's the
195
+ // wall's normal, which is what we must probe along: the capsule
196
+ // meets the wall with its normal-direction extent, so a probe cast
197
+ // along TRAVEL would stop short of the face (it only has to reach
198
+ // radius/​cos θ along travel, but radius along the normal) and read a
199
+ // wall as clear, climbing it. Deriving it from the velocity change
200
+ // needs no slide-internal normal plumbed out.
201
+ let bdx = svx - velocity.x, bdz = svz - velocity.z;
202
+ const bdLen = Math.sqrt(bdx * bdx + bdz * bdz);
203
+ if (bdLen < MIN_MOVE) return; // nothing opposed the horizontal move — no wall/step ahead
204
+ bdx /= bdLen; bdz /= bdLen;
205
+
206
+ // ── Step-vs-wall decision: a THIN horizontal ray at the step-height
207
+ // plane ──────────────────────────────────────────────────────
208
+ // The obvious "lift the capsule by stepHeight and sweep it forward"
209
+ // clearance test is fooled by the capsule's ROUNDED bottom: lifted
210
+ // so its tip sits at stepHeight, the hemisphere narrows through the
211
+ // band (stepHeight, stepHeight+radius), so a wall whose top lands in
212
+ // that band is never reached by the swept capsule — it reads "clear"
213
+ // and the player climbs a too-tall wall (and does so speed-
214
+ // dependently, since a faster sweep reaches further into the round-
215
+ // off). A thin ray has no such round-off. Cast it INTO the obstacle
216
+ // (along the blocked normal) at exactly the highest climbable height:
217
+ // anything taller than a step has solid material crossing that plane
218
+ // and is struck on its front face; a genuine step (top below the
219
+ // plane) is passed clean over. This reads the obstacle's true height,
220
+ // independent of how the round bottom would perch on its edge — so
221
+ // "clear it or don't" holds at any speed and any approach angle.
222
+ //
223
+ // Origin at the PRE-slide centre, not post-slide: rounding a convex
224
+ // corner, the slide can carry the centre just past the corner (out
225
+ // of the obstacle's footprint) so a probe from there shoots past it
226
+ // and reads clear, climbing the corner. The pre-slide centre was
227
+ // still in front of what blocked it. Reach spans the swept move
228
+ // (`forwardLen`) plus a forward-extent to the leading edge plus a
229
+ // few skins — enough to cross the front face wherever along the
230
+ // sweep contact happened. The reach tracking the sweep is not the
231
+ // banned speed coupling: the climb-or-block DECISION is the
232
+ // step-height plane alone; the reach only governs how far ahead we
233
+ // look for the thing the slide already hit.
234
+ shape.support(this._support, 0, bdx, 0, bdz);
235
+ const lead = this._support[0] * bdx + this._support[2] * bdz; // extent toward the wall (capsule radius)
236
+ const ray = this._ray;
237
+ ray.setOrigin(sx, sy + this.stepHeight + this.skin, sz);
238
+ ray.setDirection(bdx, 0, bdz);
239
+ ray.tMax = forwardLen + lead + 4 * this.skin;
240
+ if (this.physicsSystem.raycast(ray, this._hit, filter)) return; // taller than a step — a wall; keep the plain slide
241
+
242
+ // Confirmed climbable (nothing crosses the step-height plane ahead).
243
+ // Up–forward–down places the capsule onto the step top.
244
+ position.set(sx, sy, sz);
245
+ const up = this._castDistance(position, rotation, shape, 0, 1, 0, this.stepHeight, filter);
246
+ position._add(0, up, 0);
247
+ position._add(fnx * forwardLen, 0, fnz * forwardLen);
248
+ const down = this._castDistance(position, rotation, shape, 0, -1, 0, up + this.skin, filter);
249
+ position._add(0, -down, 0);
250
+
251
+ // Reject a climb that ends INSIDE geometry. The thin step-height
252
+ // probe is a single ray, so at a convex CORNER it can thread past
253
+ // the corner point (the contact normal there is near-parallel to a
254
+ // face, so the ray runs alongside the box just outside its
255
+ // footprint) and read clear — then up-forward-down perches the round
256
+ // body on that corner, overlapping it. A genuine step top is rested
257
+ // on `skin` ABOVE, never overlapping; so an overlap at the
258
+ // destination is the tell that we climbed onto a wall, not a step.
259
+ const stepProg = (position.x - sx) * (position.x - sx) + (position.z - sz) * (position.z - sz);
260
+ const heightGain = position.y - sy;
261
+ const overlaps = this.physicsSystem.overlap(shape, position, rotation, this._overlapBuf, 0, filter) > 0;
262
+ if (stepProg > slideProg + 1e-8 && heightGain <= this.stepHeight + this.skin && !overlaps) {
263
+ velocity.set(svx, svy, svz); // restore horizontal; vertical settled by categorize
264
+ } else {
265
+ position.set(px, py, pz);
266
+ velocity.set(pvx, pvy, pvz);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Distance the shape can sweep along a unit axis before contact (less
272
+ * `skin`), or the full `maxDist` if clear. For the step-up lift/drop.
273
+ * @private
274
+ */
275
+ _castDistance(position, rotation, shape, dx, dy, dz, maxDist, filter) {
276
+ const ray = this._ray;
277
+ ray.setOrigin(position.x, position.y, position.z);
278
+ ray.setDirection(dx, dy, dz);
279
+ ray.tMax = maxDist;
280
+ if (!this.physicsSystem.shapeCast(ray, shape, rotation, this._hit, filter)) return maxDist;
281
+ const t = this._hit.t - this.skin;
282
+ return t > 0 ? t : 0;
283
+ }
284
+
285
+ /**
286
+ * True when a centre raycast at (x,y,z) finds a walkable surface
287
+ * within stick range — gates step-up on "actually standing".
288
+ * @private
289
+ */
290
+ _groundedAt(x, y, z, filter) {
291
+ const ray = this._ray;
292
+ const lift = this.stepHeight;
293
+ ray.setOrigin(x, y + lift, z);
294
+ ray.setDirection(0, -1, 0);
295
+ ray.tMax = lift + this.stepHeight + this.skin;
296
+ if (!this.physicsSystem.raycast(ray, this._hit, filter)) return false;
297
+ if (this._hit.normal.y < this.minWalkNormal) return false;
298
+ return (y + lift - this._hit.t) <= y + this.skin;
299
+ }
300
+
148
301
  /**
149
302
  * Push the capsule out of any geometry it currently overlaps. Each
150
303
  * pass queries overlaps, finds the single deepest penetration via
@@ -272,6 +425,15 @@ export class KinematicMover {
272
425
  vx = vy = vz = 0;
273
426
  break;
274
427
  }
428
+ // Store the true contact plane for velocity clipping. The
429
+ // capsule's round bottom does NOT roll up a too-tall obstacle:
430
+ // standing on the floor it meets a wall with its full-radius
431
+ // cylinder side (a horizontal normal — a clean stop), and
432
+ // CLIMBING a step ≤ stepHeight is the explicit step-up's job,
433
+ // gated by a thin step-height probe that reads true obstacle
434
+ // height regardless of approach speed. So the slide keeps every
435
+ // surface's real normal — flattening steep contacts to vertical
436
+ // here would also rob a too-steep SLOPE of its downhill slide.
275
437
  const po = numPlanes * 3;
276
438
  planes[po] = hit.normal.x;
277
439
  planes[po + 1] = hit.normal.y;
@@ -375,7 +537,8 @@ export class KinematicMover {
375
537
  const nx = hit.normal.x, ny = hit.normal.y, nz = hit.normal.z;
376
538
  result.groundNormal.set(nx, ny, nz);
377
539
  if (ny < this.minWalkNormal) return; // steep slope under the feet — slide, not grounded
378
- let surfaceY = position.y + lift - hit.t;
540
+ const centreSurfaceY = position.y + lift - hit.t; // the planar ground under the feet
541
+ let surfaceY = centreSurfaceY;
379
542
 
380
543
  // (B) Footprint shapecast — mount a step the leading edge overhangs.
381
544
  ray.setOrigin(position.x, position.y + lift, position.z);
@@ -383,10 +546,15 @@ export class KinematicMover {
383
546
  ray.tMax = reach;
384
547
  if (this.physicsSystem.shapeCast(ray, shape, rotation, hit, filter) && hit.t > this.skin) {
385
548
  const stepY = position.y + lift - hit.t;
386
- // Raise onto it only if it's higher than the centre surface
387
- // and within a climbable step (a taller one is a wall the
388
- // slide blocks leave the player on the centre surface).
389
- if (stepY > surfaceY && stepY - position.y <= this.stepHeight + this.skin) {
549
+ // Mount it only if it's higher than the centre surface and no
550
+ // more than `stepHeight` ABOVE THAT CENTRE SURFACE not above
551
+ // the feet. Gating on the feet would let the player climb a
552
+ // wall incrementally: ride up its edge a hair, then mount the
553
+ // now-within-reach next sliver, and so on. Gating on the
554
+ // ground under the centre means a wall top is always >
555
+ // stepHeight above the floor the centre sees → never mounted,
556
+ // and a capsule that rode up gets snapped back to the floor.
557
+ if (stepY > surfaceY && stepY - centreSurfaceY <= this.stepHeight + this.skin) {
390
558
  surfaceY = stepY;
391
559
  }
392
560
  }
@@ -604,13 +604,44 @@ scaffolding is in place.
604
604
  bodies where speculative margin isn't enough. The bench's falling
605
605
  tower (1km drop onto a 1cm floor) is the concrete reproducer —
606
606
  180 / 1000 bodies tunnel.
607
- - [ ] **Broadphase BVH balance / raycast traversal cost**: the raycast bench
608
- (`queries/raycast.bench.spec.js`) shows ~linear-in-N per-ray cost
609
- (~50 µs/ray at 500 bodies), i.e. a ray walks most of the tree — the static
610
- BVH built by sequential `link` inserts is poorly balanced. Orthogonal to
611
- raycast narrowphase (which adds only per-crossed-leaf refine, <1% here);
612
- affects every BVH query. Needs a balanced/refitting build (SAH or
613
- incremental rotation) on the static tree.
607
+ - [x] **Broadphase BVH balance SAH rotation.** The dynamic AABB tree
608
+ (`core/bvh2/bvh3/BVH.js`, a Box2D port) used SAH-cost insertion but a
609
+ *height-only* AVL rotation (`balance_height`): height-balanced yet not
610
+ SAH-balanced, so queries walked more nodes than needed. Replaced the
611
+ rotation in `bubble_up_update` with `balance_rotate` the Box2D-v3 /
612
+ Kensler SAH-reducing rotation (for node A with children B, C, evaluate the
613
+ four child↔grandchild swaps and apply the one that most reduces the
614
+ surface-area cost). Deterministic; identical pair set.
615
+ - Measured (same-session A/B, heavy benches): raycast **−9%**
616
+ (28.2→25.6 µs/ray), falling-tower median **−10%**, settling-grid
617
+ median **−12%**, and the **990/1000-churn stress −27%**
618
+ (63.95→46.68 ms mean over 10k ticks) — biggest where the tree churns
619
+ hardest. Determinism (8-trial bit-identical) holds.
620
+ - **Insertion cost (measured):** `balance_rotate` does 4 surface-area
621
+ evaluations per bubble-up level vs `balance_height`'s single height
622
+ compare, so *pure bulk insertion* is **~1.4–1.5× slower** — the 100k
623
+ synthetic insert bench (`BVH.spec.js`, drift-controlled interleaved
624
+ A/B) drops from **~37k → ~25k inserts/sec** (~27→~40 µs/insert). This
625
+ is the balancer's worst case (insert-only, zero queries/refits to
626
+ amortise against). It does not show up end-to-end: static trees are
627
+ built once then queried forever, dynamic bodies insert once then
628
+ refit/query every frame, and even the 990/1000-swap stress test — the
629
+ maximal insert-churn workload — is net **−27%**. Accepted.
630
+ - **Tradeoff (documented):** the contact solver's Gauss-Seidel order
631
+ follows broadphase traversal order (see `generate_pairs`), so the
632
+ different tree shape shifts convergence on near-aligned stacks — the
633
+ synthetic 128-cube wall now sleeps at ~10 s (was ~6.9 s). It still
634
+ settles, doesn't creep / topple (all bug-guard assertions hold); only
635
+ the sleep *time* moved (that test's budget was bumped 9→11 s with a
636
+ note). Random-shape scenes (falling tower) were faster *and* settled
637
+ fine.
638
+ - **Follow-up:** decouple the solve order from tree shape — sort the
639
+ broadphase pair list by `(idA, idB)` before narrowphase so contact
640
+ order is body-id-deterministic regardless of tree shape. Then no tree
641
+ change can affect convergence (and the stack settles identically under
642
+ either balancer). Has a per-step sort cost + wide test re-baseline, so
643
+ it's its own task. `balance_height` is retained for comparison /
644
+ fallback.
614
645
  - [ ] **Per-island parallel solve**: today's island data layout would
615
646
  allow worker-based solving once `SharedArrayBuffer` is available.
616
647
  Out-of-scope unless / until SAB is universally usable.
@@ -637,8 +668,19 @@ scaffolding is in place.
637
668
  - [ ] **Convex hull shape** with eigen-based principal-axes inertia
638
669
  derivation. Hooks `matrix_eigenvalues_in_place` from the existing
639
670
  linalg layer.
640
- - [ ] **Cylinder / cone shapes** (closed-form pairs against the existing
641
- family + GJK+EPA fallback for general convex).
671
+ - [~] **Cylinder / cone shapes.**
672
+ - [x] **`CylinderShape3D`** Y-aligned solid cylinder (radius + full
673
+ height, flat caps; the capsule's flat-cap sibling). Exact `support`,
674
+ capped-cylinder SDF, bounds, `contains` / `nearest_point` /
675
+ volume-sampling, equals/hash, `'cylinder'` JSON tag, `isCylinderShape3D`
676
+ marker. Convex → routes through the narrowphase **GJK + EPA** fallback
677
+ (no marker dispatch needed); spec asserts overlap-detected +
678
+ MTV-separates vs sphere/box. Closed-form cylinder-vs-X contact pairs
679
+ are a future refinement (the curved side is the usual smooth-support
680
+ EPA case — same status as pre-closed-form sphere/capsule).
681
+ - [ ] Closed-form cylinder contact pairs (cylinder × box / sphere / capsule
682
+ / plane) for multi-point cap manifolds + stable resting.
683
+ - [ ] **Cone shape** (+ closed-form / GJK fallback).
642
684
 
643
685
  ### API polish
644
686
  - [x] **`overlap(shape, position, rotation, output, output_offset,