@woosh/meep-engine 2.163.1 → 2.163.2

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.163.1",
9
+ "version": "2.163.2",
10
10
  "main": "build/meep.module.js",
11
11
  "module": "build/meep.module.js",
12
12
  "exports": {
@@ -1 +1 @@
1
- {"version":3,"file":"build_geometry_catmullrom.d.ts","sourceRoot":"","sources":["../../../../../../../../src/engine/graphics/ecs/path/tube/build/build_geometry_catmullrom.js"],"names":[],"mappings":"AAoFA;;;;;;;;;;GAUG;AACH,mFAPW,MAAM,EAAE,gBACR,MAAM,EAAE,GAAC,YAAY,mBACrB,MAAM,EAAE,iBACR,MAAM,eACN,MAAM,GACL,MAAM,cAAc,CAoI/B"}
1
+ {"version":3,"file":"build_geometry_catmullrom.d.ts","sourceRoot":"","sources":["../../../../../../../../src/engine/graphics/ecs/path/tube/build/build_geometry_catmullrom.js"],"names":[],"mappings":"AAsJA;;;;;;;;;;GAUG;AACH,mFAPW,MAAM,EAAE,gBACR,MAAM,EAAE,GAAC,YAAY,mBACrB,MAAM,EAAE,iBACR,MAAM,eACN,MAAM,GACL,MAAM,cAAc,CAkJ/B"}
@@ -1,226 +1,306 @@
1
- import { v3_dot } from "../../../../../../core/geom/vec3/v3_dot.js";
2
- import { v3_length } from "../../../../../../core/geom/vec3/v3_length.js";
3
- import { clamp } from "../../../../../../core/math/clamp.js";
4
- import { max2 } from "../../../../../../core/math/max2.js";
5
- import {
6
- computeNonuniformCaltmullRomSplineDerivative
7
- } from "../../../../../../core/math/spline/computeNonuniformCaltmullRomSplineDerivative.js";
8
- import { PathNormalType } from "../PathNormalType.js";
9
- import { computeFrenetFrames } from "./computeFrenetFrames.js";
10
- import { makeTubeGeometry } from "./makeTubeGeometry.js";
11
-
12
-
13
- const scratch_array_0 = [];
14
- const scratch_array_1 = [];
15
- const scratch_array_2 = [];
16
- const scratch_array_3 = [];
17
-
18
- /**
19
- *
20
- * @param {number[]} positions
21
- * @param {number[]} derivatives
22
- * @param {number} result_offset
23
- * @param {Path} path
24
- * @param {number} offset
25
- */
26
- function sample_path(positions, derivatives, result_offset, path, offset) {
27
-
28
-
29
- if (!path.find_index_and_normalized_distance(scratch_array_0, offset)) {
30
- return 0;
31
- }
32
-
33
-
34
- /**
35
- *
36
- * @type {number}
37
- */
38
- const i1 = scratch_array_0[0];
39
-
40
- /**
41
- *
42
- * @type {number}
43
- */
44
- const t = scratch_array_0[1];
45
-
46
-
47
- const input_length = path.getPointCount();
48
-
49
- const max_index = input_length - 1;
50
-
51
- const i0 = clamp(i1 - 1, 0, max_index);
52
- const i2 = clamp(i1 + 1, 0, max_index);
53
- const i3 = clamp(i1 + 2, 0, max_index);
54
-
55
- path.readPositionToArray(i0, scratch_array_0, 0);
56
- path.readPositionToArray(i1, scratch_array_1, 0);
57
- path.readPositionToArray(i2, scratch_array_2, 0);
58
- path.readPositionToArray(i3, scratch_array_3, 0);
59
-
60
- computeNonuniformCaltmullRomSplineDerivative(
61
- positions, result_offset,
62
- derivatives, result_offset,
63
- scratch_array_0, scratch_array_1, scratch_array_2, scratch_array_3,
64
- 3, t, 0.5
65
- );
66
-
67
- // normalize derivative
68
- const dx = derivatives[result_offset];
69
- const dy = derivatives[result_offset + 1];
70
- const dz = derivatives[result_offset + 2];
71
-
72
- const mag = v3_length(dx, dy, dz);
73
-
74
- if (mag !== 0) {
75
- const inv_mag = 1 / mag;
76
-
77
- derivatives[result_offset] = dx * inv_mag;
78
- derivatives[result_offset + 1] = dy * inv_mag;
79
- derivatives[result_offset + 2] = dz * inv_mag;
80
- }
81
-
82
- return i1;
83
- }
84
-
85
- /**
86
- *
87
- * @param {Path} path
88
- * @param {TubePathStyle} style
89
- * @param {number[]} shape
90
- * @param {number[]|Float32Array} shape_normal
91
- * @param {number[]} shape_transform
92
- * @param {number} segment_start
93
- * @param {number} segment_end
94
- * @return {THREE.BufferGeometry}
95
- */
96
- export function build_geometry_catmullrom(
97
- path, style,
98
- shape, shape_normal, shape_transform,
99
- segment_start, segment_end
100
- ) {
101
-
102
- const point_count = path.getPointCount();
103
-
104
- // resample curve
105
- const total_points = Math.ceil(point_count * style.resolution);
106
-
107
- const reference_step_size = 1 / max2(0.00001, style.resolution);
108
- const reference_min_step_size = reference_step_size * 0.25;
109
-
110
- let step_size = reference_step_size;
111
-
112
- const path_length = path.length;
113
-
114
- const sample_positions_f32 = [];
115
- const sample_derivatives_f32 = [];
116
-
117
- let added_points = 0;
118
-
119
- // initial segment
120
- let last_knot_index = sample_path(
121
- sample_positions_f32,
122
- sample_derivatives_f32,
123
- added_points * 3,
124
- path,
125
- segment_start * path_length
126
- );
127
-
128
- added_points++;
129
-
130
- let current_offset = segment_start * path_length;
131
-
132
- let step_resized_direction = 0;
133
-
134
- const absolute_end_offset = segment_end * path_length;
135
-
136
- for (; current_offset < absolute_end_offset; current_offset += step_size) {
137
-
138
-
139
- const point_address = added_points * 3;
140
-
141
- const knot_index = sample_path(
142
- sample_positions_f32,
143
- sample_derivatives_f32,
144
- point_address,
145
- path,
146
- current_offset
147
- );
148
-
149
- // check difference with previous derivative
150
- const previous_point_index = added_points - 1;
151
- const previous_point_address = previous_point_index * 3;
152
-
153
- // derivatives are basically normals, and dot product is has Cosine value of angle between two vectors
154
- const dot = v3_dot(
155
- sample_derivatives_f32[previous_point_address], sample_derivatives_f32[previous_point_address + 1], sample_derivatives_f32[previous_point_address + 2],
156
- sample_derivatives_f32[point_address], sample_derivatives_f32[point_address + 1], sample_derivatives_f32[point_address + 2]
157
- );
158
-
159
- // angular difference results in larger visual error for longer runs
160
- const error_size = (1 - dot) * step_size;
161
-
162
- if (
163
- (step_size > reference_min_step_size)
164
- && (
165
- (
166
- // check if we jumped over a knot, so that we can sample down to get closer to it
167
- knot_index > last_knot_index
168
- && step_size > reference_min_step_size
169
- )
170
- || (
171
- error_size * 10 > reference_min_step_size
172
- && step_resized_direction <= 0
173
- )
174
- || (
175
- // check if we're getting too close to the end of the segment, this lets us create a nice end
176
- current_offset + step_size > absolute_end_offset
177
- )
178
- )
179
- ) {
180
-
181
- // step looks to be too large
182
- current_offset -= step_size;
183
- step_size *= 0.5;
184
- step_resized_direction = -1;
185
- continue; // retry
186
-
187
- } else if (
188
- error_size < 0.02
189
- && step_resized_direction >= 0
190
- ) {
191
-
192
- // step looks to be too small
193
- current_offset -= step_size;
194
- step_size *= 2;
195
- step_resized_direction = 1;
196
- continue; // retry
197
-
198
- }
199
-
200
- // reset adaptive step direction
201
- step_resized_direction = 0;
202
- // remember the last knot, so we know when we get to the end of the segment
203
- last_knot_index = knot_index;
204
-
205
- added_points++
206
- }
207
-
208
- if ((absolute_end_offset - current_offset + step_size) > 0.0001) {
209
- // end
210
-
211
- sample_path(sample_positions_f32, sample_derivatives_f32, added_points * 3, path, absolute_end_offset);
212
-
213
- added_points++;
214
- }
215
-
216
- // console.log(`Total Points ${added_points}`) // DEBUG info
217
-
218
- const normal_hint = style.path_normal_type === PathNormalType.FixedStart ? style.path_normal : undefined;
219
-
220
- const frames = computeFrenetFrames(sample_positions_f32, false, normal_hint);
221
-
222
- return makeTubeGeometry(
223
- sample_positions_f32, frames.normals, frames.binormals, frames.tangents,
224
- shape, shape_normal, shape.length / 2, shape_transform, false, style.cap_type
225
- );
226
- }
1
+ import { v3_dot } from "../../../../../../core/geom/vec3/v3_dot.js";
2
+ import { v3_length } from "../../../../../../core/geom/vec3/v3_length.js";
3
+ import { clamp } from "../../../../../../core/math/clamp.js";
4
+ import { max2 } from "../../../../../../core/math/max2.js";
5
+ import {
6
+ computeNonuniformCaltmullRomSplineDerivative
7
+ } from "../../../../../../core/math/spline/computeNonuniformCaltmullRomSplineDerivative.js";
8
+ import { PathNormalType } from "../PathNormalType.js";
9
+ import { computeFrenetFrames } from "./computeFrenetFrames.js";
10
+ import { makeTubeGeometry } from "./makeTubeGeometry.js";
11
+
12
+
13
+ const scratch_array_0 = [];
14
+ const scratch_array_1 = [];
15
+ const scratch_array_2 = [];
16
+ const scratch_array_3 = [];
17
+
18
+ /**
19
+ *
20
+ * @param {number[]} positions
21
+ * @param {number[]} derivatives
22
+ * @param {number} result_offset
23
+ * @param {Path} path
24
+ * @param {number} offset
25
+ */
26
+ function sample_path(positions, derivatives, result_offset, path, offset) {
27
+
28
+
29
+ if (!path.find_index_and_normalized_distance(scratch_array_0, offset)) {
30
+ return 0;
31
+ }
32
+
33
+
34
+ /**
35
+ *
36
+ * @type {number}
37
+ */
38
+ const i1 = scratch_array_0[0];
39
+
40
+ /**
41
+ *
42
+ * @type {number}
43
+ */
44
+ const t = scratch_array_0[1];
45
+
46
+
47
+ const input_length = path.getPointCount();
48
+
49
+ const max_index = input_length - 1;
50
+
51
+ const i0 = clamp(i1 - 1, 0, max_index);
52
+ const i2 = clamp(i1 + 1, 0, max_index);
53
+ const i3 = clamp(i1 + 2, 0, max_index);
54
+
55
+ path.readPositionToArray(i0, scratch_array_0, 0);
56
+ path.readPositionToArray(i1, scratch_array_1, 0);
57
+ path.readPositionToArray(i2, scratch_array_2, 0);
58
+ path.readPositionToArray(i3, scratch_array_3, 0);
59
+
60
+ computeNonuniformCaltmullRomSplineDerivative(
61
+ positions, result_offset,
62
+ derivatives, result_offset,
63
+ scratch_array_0, scratch_array_1, scratch_array_2, scratch_array_3,
64
+ 3, t, 0.5
65
+ );
66
+
67
+ // normalize derivative
68
+ const dx = derivatives[result_offset];
69
+ const dy = derivatives[result_offset + 1];
70
+ const dz = derivatives[result_offset + 2];
71
+
72
+ const mag = v3_length(dx, dy, dz);
73
+
74
+ if (mag !== 0) {
75
+ const inv_mag = 1 / mag;
76
+
77
+ derivatives[result_offset] = dx * inv_mag;
78
+ derivatives[result_offset + 1] = dy * inv_mag;
79
+ derivatives[result_offset + 2] = dz * inv_mag;
80
+ }
81
+
82
+ return i1;
83
+ }
84
+
85
+ /**
86
+ * Stabilise one open-end frame. computeFrenetFrames derives the tangent at a
87
+ * path end from the end chord (P1-P0 / P[n]-P[n-1]); the adaptive resampler sets
88
+ * that neighbour's spacing, so on a curve the chord direction jumps frame to
89
+ * frame and swings the end cap (a small wobble becomes a >100-degree flip as an
90
+ * animated dash slides across a path knot). The analytic spline derivative is
91
+ * smooth in the path parameter and resampling-independent, so adopt it for the
92
+ * end tangent and re-orthogonalise the end normal/binormal around it.
93
+ *
94
+ * @param {{normals:Vector3[],binormals:Vector3[],tangents:Vector3[]}} frames
95
+ * @param {number[]|Float32Array} derivatives normalised per-sample spline derivatives
96
+ * @param {number} i sample index whose frame is stabilised
97
+ * @param {number} derivative_index sample to read the spline derivative from
98
+ * (use an interior neighbour: the analytic derivative is bogus at the
99
+ * path's exact parametric ends, param 0/1, where the spline clamps its
100
+ * control points)
101
+ */
102
+ function stabilize_end_frame(frames, derivatives, i, derivative_index) {
103
+ const d3 = derivative_index * 3;
104
+
105
+ let tx = derivatives[d3];
106
+ let ty = derivatives[d3 + 1];
107
+ let tz = derivatives[d3 + 2];
108
+
109
+ const tl = Math.sqrt(tx * tx + ty * ty + tz * tz);
110
+ if (!(tl > 1e-8)) {
111
+ // derivative unavailable/degenerate: keep the Frenet frame
112
+ return;
113
+ }
114
+ tx /= tl; ty /= tl; tz /= tl;
115
+
116
+ const N = frames.normals[i];
117
+
118
+ // The spline derivative already points 'forward' (towards increasing
119
+ // samples), the same orientation computeFrenetFrames uses, so adopt it as-is.
120
+ // (Aligning it to the OLD end tangent would re-import that tangent's jitter -
121
+ // when the jittery chord swings past 90 degrees the sign would flip and the
122
+ // cap axis would reverse.)
123
+ frames.tangents[i].set(tx, ty, tz);
124
+
125
+ // re-orthogonalise the existing normal against the new tangent (Gram-Schmidt)
126
+ const dot = N.x * tx + N.y * ty + N.z * tz;
127
+ let nx = N.x - dot * tx;
128
+ let ny = N.y - dot * ty;
129
+ let nz = N.z - dot * tz;
130
+ let nl = Math.sqrt(nx * nx + ny * ny + nz * nz);
131
+
132
+ if (!(nl > 1e-6)) {
133
+ // old normal was ~parallel to the new tangent: pick any perpendicular axis
134
+ const ax = Math.abs(tx), ay = Math.abs(ty), az = Math.abs(tz);
135
+ if (ax <= ay && ax <= az) { nx = 0; ny = -tz; nz = ty; }
136
+ else if (ay <= az) { nx = -tz; ny = 0; nz = tx; }
137
+ else { nx = -ty; ny = tx; nz = 0; }
138
+ nl = Math.sqrt(nx * nx + ny * ny + nz * nz);
139
+ }
140
+ nx /= nl; ny /= nl; nz /= nl;
141
+ N.set(nx, ny, nz);
142
+
143
+ // binormal = tangent x normal (matches computeFrenetFrames' convention)
144
+ frames.binormals[i].set(
145
+ ty * nz - tz * ny,
146
+ tz * nx - tx * nz,
147
+ tx * ny - ty * nx
148
+ );
149
+ }
150
+
151
+ /**
152
+ *
153
+ * @param {Path} path
154
+ * @param {TubePathStyle} style
155
+ * @param {number[]} shape
156
+ * @param {number[]|Float32Array} shape_normal
157
+ * @param {number[]} shape_transform
158
+ * @param {number} segment_start
159
+ * @param {number} segment_end
160
+ * @return {THREE.BufferGeometry}
161
+ */
162
+ export function build_geometry_catmullrom(
163
+ path, style,
164
+ shape, shape_normal, shape_transform,
165
+ segment_start, segment_end
166
+ ) {
167
+
168
+ const point_count = path.getPointCount();
169
+
170
+ // resample curve
171
+ const total_points = Math.ceil(point_count * style.resolution);
172
+
173
+ const reference_step_size = 1 / max2(0.00001, style.resolution);
174
+ const reference_min_step_size = reference_step_size * 0.25;
175
+
176
+ let step_size = reference_step_size;
177
+
178
+ const path_length = path.length;
179
+
180
+ const sample_positions_f32 = [];
181
+ const sample_derivatives_f32 = [];
182
+
183
+ let added_points = 0;
184
+
185
+ // initial segment
186
+ let last_knot_index = sample_path(
187
+ sample_positions_f32,
188
+ sample_derivatives_f32,
189
+ added_points * 3,
190
+ path,
191
+ segment_start * path_length
192
+ );
193
+
194
+ added_points++;
195
+
196
+ let current_offset = segment_start * path_length;
197
+
198
+ let step_resized_direction = 0;
199
+
200
+ const absolute_end_offset = segment_end * path_length;
201
+
202
+ for (; current_offset < absolute_end_offset; current_offset += step_size) {
203
+
204
+
205
+ const point_address = added_points * 3;
206
+
207
+ const knot_index = sample_path(
208
+ sample_positions_f32,
209
+ sample_derivatives_f32,
210
+ point_address,
211
+ path,
212
+ current_offset
213
+ );
214
+
215
+ // check difference with previous derivative
216
+ const previous_point_index = added_points - 1;
217
+ const previous_point_address = previous_point_index * 3;
218
+
219
+ // derivatives are basically normals, and dot product is has Cosine value of angle between two vectors
220
+ const dot = v3_dot(
221
+ sample_derivatives_f32[previous_point_address], sample_derivatives_f32[previous_point_address + 1], sample_derivatives_f32[previous_point_address + 2],
222
+ sample_derivatives_f32[point_address], sample_derivatives_f32[point_address + 1], sample_derivatives_f32[point_address + 2]
223
+ );
224
+
225
+ // angular difference results in larger visual error for longer runs
226
+ const error_size = (1 - dot) * step_size;
227
+
228
+ if (
229
+ (step_size > reference_min_step_size)
230
+ && (
231
+ (
232
+ // check if we jumped over a knot, so that we can sample down to get closer to it
233
+ knot_index > last_knot_index
234
+ && step_size > reference_min_step_size
235
+ )
236
+ || (
237
+ error_size * 10 > reference_min_step_size
238
+ && step_resized_direction <= 0
239
+ )
240
+ || (
241
+ // check if we're getting too close to the end of the segment, this lets us create a nice end
242
+ current_offset + step_size > absolute_end_offset
243
+ )
244
+ )
245
+ ) {
246
+
247
+ // step looks to be too large
248
+ current_offset -= step_size;
249
+ step_size *= 0.5;
250
+ step_resized_direction = -1;
251
+ continue; // retry
252
+
253
+ } else if (
254
+ error_size < 0.02
255
+ && step_resized_direction >= 0
256
+ ) {
257
+
258
+ // step looks to be too small
259
+ current_offset -= step_size;
260
+ step_size *= 2;
261
+ step_resized_direction = 1;
262
+ continue; // retry
263
+
264
+ }
265
+
266
+ // reset adaptive step direction
267
+ step_resized_direction = 0;
268
+ // remember the last knot, so we know when we get to the end of the segment
269
+ last_knot_index = knot_index;
270
+
271
+ added_points++
272
+ }
273
+
274
+ if ((absolute_end_offset - current_offset + step_size) > 0.0001) {
275
+ // end
276
+
277
+ sample_path(sample_positions_f32, sample_derivatives_f32, added_points * 3, path, absolute_end_offset);
278
+
279
+ added_points++;
280
+ }
281
+
282
+ // console.log(`Total Points ${added_points}`) // DEBUG info
283
+
284
+ const normal_hint = style.path_normal_type === PathNormalType.FixedStart ? style.path_normal : undefined;
285
+
286
+ const frames = computeFrenetFrames(sample_positions_f32, false, normal_hint);
287
+
288
+ // Stabilise the START frame so its round cap doesn't twitch as the adaptive
289
+ // resampling shifts. Only the start needs it: computeFrenetFrames' start
290
+ // tangent is the first chord (P1-P0), and the resampler's first-segment
291
+ // length/direction jitters frame to frame; the analytic derivative at the
292
+ // start sample is smooth in the path parameter. The END frame's tangent is
293
+ // already stable (the sampler approaches the end with a small, consistent
294
+ // final segment) and the analytic derivative is unreliable at the path's
295
+ // exact parametric end (param 1, where the spline clamps control points), so
296
+ // the end is deliberately left on its chord-based frame.
297
+ const last_sample_index = (sample_positions_f32.length / 3) - 1;
298
+ if (last_sample_index >= 2) {
299
+ stabilize_end_frame(frames, sample_derivatives_f32, 0, 0);
300
+ }
301
+
302
+ return makeTubeGeometry(
303
+ sample_positions_f32, frames.normals, frames.binormals, frames.tangents,
304
+ shape, shape_normal, shape.length / 2, shape_transform, false, style.cap_type
305
+ );
306
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"make_cap.d.ts","sourceRoot":"","sources":["../../../../../../../../src/engine/graphics/ecs/path/tube/build/make_cap.js"],"names":[],"mappings":"AAwQA;;;;;;;;;;;;;;GAcG;AACH,4DAbW,MAAM,gBACN,YAAY,GAAC,MAAM,EAAE,cACrB,OAAO,EAAE,gBACT,OAAO,EAAE,eAET,OAAO,EAAE,SACT,MAAM,EAAE,gBACR,MAAM,EAAE,GAAC,YAAY,gBACrB,MAAM,mBACN,MAAM,EAAE,GAAC,YAAY,aACrB,MAAM,QACN,OAAO,QA6BjB;AAED;;;;;;GAMG;AACH,wDALW,MAAM,OACN;IAAC,aAAa,EAAC,MAAM,CAAC;IAAC,YAAY,EAAC,MAAM,CAAA;CAAC,mBAC3C,MAAM,QACN,OAAO,QAiBjB;wBA1UuB,OAAO;wBAMP,eAAe"}
1
+ {"version":3,"file":"make_cap.d.ts","sourceRoot":"","sources":["../../../../../../../../src/engine/graphics/ecs/path/tube/build/make_cap.js"],"names":[],"mappings":"AAiRA;;;;;;;;;;;;;;GAcG;AACH,4DAbW,MAAM,gBACN,YAAY,GAAC,MAAM,EAAE,cACrB,OAAO,EAAE,gBACT,OAAO,EAAE,eAET,OAAO,EAAE,SACT,MAAM,EAAE,gBACR,MAAM,EAAE,GAAC,YAAY,gBACrB,MAAM,mBACN,MAAM,EAAE,GAAC,YAAY,aACrB,MAAM,QACN,OAAO,QA6BjB;AAED;;;;;;GAMG;AACH,wDALW,MAAM,OACN;IAAC,aAAa,EAAC,MAAM,CAAC;IAAC,YAAY,EAAC,MAAM,CAAA;CAAC,mBAC3C,MAAM,QACN,OAAO,QAiBjB;wBAnVuB,OAAO;wBAMP,eAAe"}
@@ -79,31 +79,40 @@ function make_cap_round(
79
79
  const B = in_binormals[index];
80
80
  const T = in_tangents[index];
81
81
 
82
- // Outward cap axis. Derive it from the PATH itself (the segment between the
83
- // cap and its neighbour sample) rather than from N x B: the Frenet frame's
84
- // tangent can flip sign at curvature features, and an N x B-derived axis
85
- // would then make the cap bulge inward for a frame ("cap twists off-axis").
86
- // The path direction is always well-defined and outward. Fall back to the
87
- // frame only if the neighbour sample is coincident (degenerate segment).
82
+ // Outward cap axis. The dome bulges along the tube tangent at the cap; the
83
+ // axis must point outward (away from the body) and be stable frame to frame.
84
+ // T is the per-sample curve tangent, oriented towards increasing samples
85
+ // ("forward"). Outward is therefore backward at the start cap and forward at
86
+ // the end cap - i.e. -direction * T - a sign that depends only on which end
87
+ // this is, never on the neighbour sample. The old code derived the axis from
88
+ // the neighbour chord (cap point minus the adjacent sample); that points
89
+ // outward but its DIRECTION jitters, because the adaptive resampler sets the
90
+ // neighbour's spacing and on a curve a longer/shorter first segment points a
91
+ // different way - so the chord, and the dome with it, swung as animated
92
+ // dashes slid across path knots (the "twitch"). The neighbour chord is now
93
+ // only a fallback for when T is unavailable.
88
94
  const point_count = in_positions.length / 3;
89
95
  const neighbour = direction > 0
90
96
  ? Math.min(index + 1, point_count - 1)
91
97
  : Math.max(index - 1, 0);
92
98
  const n3 = neighbour * 3;
93
99
 
94
- const tangent = new Vector3(
95
- Px - in_positions[n3],
96
- Py - in_positions[n3 + 1],
97
- Pz - in_positions[n3 + 2]
98
- );
100
+ const tangent = new Vector3();
99
101
 
100
- if (tangent.lengthSq() > 1e-12) {
101
- tangent.normalize();
102
+ if (T !== undefined && T.lengthSq() > 1e-12) {
103
+ tangent.copy(T).normalize().multiplyScalar(-direction);
102
104
  } else {
103
- // degenerate: neighbour coincides with the cap point, use the frame
104
- tangent.crossVectors(N, B);
105
- tangent.normalize();
106
- tangent.multiplyScalar(-direction);
105
+ const out_x = Px - in_positions[n3];
106
+ const out_y = Py - in_positions[n3 + 1];
107
+ const out_z = Pz - in_positions[n3 + 2];
108
+
109
+ if ((out_x * out_x + out_y * out_y + out_z * out_z) > 1e-12) {
110
+ // no usable tangent: the neighbour chord already points outward
111
+ tangent.set(out_x, out_y, out_z).normalize();
112
+ } else {
113
+ // fully degenerate: derive the axis from the frame
114
+ tangent.crossVectors(N, B).normalize().multiplyScalar(-direction);
115
+ }
107
116
  }
108
117
 
109
118
  // Use the path frame's N/B directly for BOTH ends. The profile orientation