three-base 2.0.145 → 2.0.166

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/FPScontrol.d.ts CHANGED
@@ -133,6 +133,26 @@ export default class FPScontrol extends EventDispatcher<FPScontrolEventTypeMap>
133
133
  readonly GRAVITY: number;
134
134
  STEPS_PER_FRAME: number;
135
135
  speed: number;
136
+ /**
137
+ * ⭐ 鼠标转视角的**除数**(越小越灵敏、越大越慢)—— 皇上 2026-10-09 旨「把写死的 /500 提成可调参数」。
138
+ * ⚠️ 它是**除数不是速度**:`rotation -= movement / lookSpeed`,所以
139
+ * 500 = 现状(皇上定的基线,一字未动);拧小= 手更敏感,拧大 = 更「重」。
140
+ * ⚠️ 历史包袱:原为三处写死的字面量 500(第一人称 yaw/pitch + 第三人称 Y)。
141
+ * 本字段作为**唯一口径**,默认 500 ⇒ 数值与旧版逐位一致,不是行为变更。
142
+ */
143
+ lookSpeed: number;
144
+ /**
145
+ * ⭐ 水平转向反转(皇上 2026-10-09 旨「水平+俯仰全反」的可配置入口)。
146
+ * false = 常规(鼠标右移视角右转);true = 反转(鼠标右移视角左转)。
147
+ * 与 `invertLookY` **各自独立**,可以只反一个、也可以两个都反。
148
+ */
149
+ invertLookX: boolean;
150
+ /**
151
+ * ⭐ 俯仰(pitch)反转:false = 常规(鼠标上移视角上抬);true = 反转(鼠标上移视角下压)。
152
+ * ⚠️ 翻转**只影响观察方向**,不碰 `camera.rotation.order = "YXZ"` 与 pitch 的物理含义
153
+ * ⇒ 碰撞射线仍旧只跟随 yaw(见 `_localRayOrigins` 那段),翻转后**贴墙/顶天花板判定不受影响**。
154
+ */
155
+ invertLookY: boolean;
136
156
  camera: Camera;
137
157
  inertia: boolean;
138
158
  /** 是否允许跳跃(空格触发)。false 时按空格不跳。默认 true */
package/FPScontrol.js CHANGED
@@ -185,6 +185,26 @@ export default class FPScontrol extends EventDispatcher {
185
185
  GRAVITY = 10; // 重力(皇上定案:固定 10,不做编辑项 ⇒ readonly,写它编译就报错)
186
186
  STEPS_PER_FRAME = 2; // 每帧碰撞计算的次数
187
187
  speed = 1; // 速度
188
+ /**
189
+ * ⭐ 鼠标转视角的**除数**(越小越灵敏、越大越慢)—— 皇上 2026-10-09 旨「把写死的 /500 提成可调参数」。
190
+ * ⚠️ 它是**除数不是速度**:`rotation -= movement / lookSpeed`,所以
191
+ * 500 = 现状(皇上定的基线,一字未动);拧小= 手更敏感,拧大 = 更「重」。
192
+ * ⚠️ 历史包袱:原为三处写死的字面量 500(第一人称 yaw/pitch + 第三人称 Y)。
193
+ * 本字段作为**唯一口径**,默认 500 ⇒ 数值与旧版逐位一致,不是行为变更。
194
+ */
195
+ lookSpeed = 500;
196
+ /**
197
+ * ⭐ 水平转向反转(皇上 2026-10-09 旨「水平+俯仰全反」的可配置入口)。
198
+ * false = 常规(鼠标右移视角右转);true = 反转(鼠标右移视角左转)。
199
+ * 与 `invertLookY` **各自独立**,可以只反一个、也可以两个都反。
200
+ */
201
+ invertLookX = false;
202
+ /**
203
+ * ⭐ 俯仰(pitch)反转:false = 常规(鼠标上移视角上抬);true = 反转(鼠标上移视角下压)。
204
+ * ⚠️ 翻转**只影响观察方向**,不碰 `camera.rotation.order = "YXZ"` 与 pitch 的物理含义
205
+ * ⇒ 碰撞射线仍旧只跟随 yaw(见 `_localRayOrigins` 那段),翻转后**贴墙/顶天花板判定不受影响**。
206
+ */
207
+ invertLookY = false;
188
208
  camera;
189
209
  // 移动时是否保持惯性 由外部来配置
190
210
  inertia = true;
@@ -588,6 +608,46 @@ export default class FPScontrol extends EventDispatcher {
588
608
  digits: 2,
589
609
  desc: "第一人称移动速度倍率(面板与右侧表单同一个字段)",
590
610
  },
611
+ {
612
+ group: "移动与视角",
613
+ label: "视角灵敏度",
614
+ key: "lookSpeed",
615
+ min: 50,
616
+ max: 2000,
617
+ step: 10,
618
+ digits: 0,
619
+ desc: "鼠标转视角的**除数**(越小越灵敏、越大越慢)。500 = 旧版写死值,拧到它与旧版逐位一致。例:250 灵敏一倍、1000 慢一倍",
620
+ },
621
+ {
622
+ // ⚠️ 布尔字段按 0/1 数值旋钮挂:FpsParamSpec 的 min/max/step/digits 是数值型,
623
+ // 故用 get/set 把 boolean 换算成 0/1(面板上拧 1 = 开、0 = 关),字段本身仍是 boolean。
624
+ group: "移动与视角",
625
+ label: "水平反转",
626
+ key: "invertLookX",
627
+ min: 0,
628
+ max: 1,
629
+ step: 1,
630
+ digits: 0,
631
+ desc: "0 = 常规(鼠标右移视角右转);1 = 反转(鼠标右移视角左转)。与「俯仰反转」各自独立,可只反一个",
632
+ get: (fps) => (fps.invertLookX ? 1 : 0),
633
+ set: (fps, v) => {
634
+ fps.invertLookX = v >= 0.5;
635
+ },
636
+ },
637
+ {
638
+ group: "移动与视角",
639
+ label: "俯仰反转",
640
+ key: "invertLookY",
641
+ min: 0,
642
+ max: 1,
643
+ step: 1,
644
+ digits: 0,
645
+ desc: "0 = 常规(鼠标上移视角上抬);1 = 反转(鼠标上移视角下压)。⚠️ 只改观察方向,碰撞射线仍只跟随 yaw ⇒ 贴墙/顶天花板判定不受影响",
646
+ get: (fps) => (fps.invertLookY ? 1 : 0),
647
+ set: (fps, v) => {
648
+ fps.invertLookY = v >= 0.5;
649
+ },
650
+ },
591
651
  {
592
652
  group: "移动与视角",
593
653
  label: "跳跃初速度",
@@ -1235,17 +1295,24 @@ export default class FPScontrol extends EventDispatcher {
1235
1295
  };
1236
1296
  mousemove = (event) => {
1237
1297
  if (this.enable && this.isMousedown) {
1298
+ // ⭐ 2026-10-09 皇上旨「把上下左右的交互反过来」:除数改读可写字段 `lookSpeed`(默认 500 = 旧值,一字未动),
1299
+ // 并加两个**各自独立**的反转开关。写成 × (invert ? -1 : 1) 而不是改符号的写法,
1300
+ // 是为了避免出现 `-=-` 这种双重取负的歧义。
1301
+ // ⚠️ 俯仰取反**不动** `_thirdPersonAngleX` 的 clamp 之外任何东西 —— 限位对称,翻转后依然 ±89°。
1302
+ const s = this.lookSpeed;
1303
+ const sx = this.invertLookX ? -1 : 1;
1304
+ const sy = this.invertLookY ? -1 : 1;
1238
1305
  if (this.thirdPerson) {
1239
1306
  // 第三人称:旋转轨道角
1240
- this._thirdPersonAngleY -= event.movementX / 500;
1307
+ this._thirdPersonAngleY -= event.movementX / s * sx;
1241
1308
  // 俯仰与第一人称同向:鼠标下移 → 视角下压 → 相机升高俯视(故用 +=)
1242
- this._thirdPersonAngleX += event.movementY / 500;
1309
+ this._thirdPersonAngleX += event.movementY / s * sy;
1243
1310
  this._thirdPersonAngleX = clamp(this._thirdPersonAngleX, degToRad(-89), degToRad(89));
1244
1311
  }
1245
1312
  else {
1246
1313
  // 第一人称:直接旋转相机
1247
- this.camera.rotation.y -= event.movementX / 500;
1248
- this.camera.rotation.x -= event.movementY / 500;
1314
+ this.camera.rotation.y -= event.movementX / s * sx;
1315
+ this.camera.rotation.x -= event.movementY / s * sy;
1249
1316
  }
1250
1317
  }
1251
1318
  };
package/FPScontrol.ts CHANGED
@@ -236,6 +236,26 @@ export default class FPScontrol extends EventDispatcher<FPScontrolEventTypeMap>
236
236
  readonly GRAVITY: number = 10; // 重力(皇上定案:固定 10,不做编辑项 ⇒ readonly,写它编译就报错)
237
237
  STEPS_PER_FRAME: number = 2; // 每帧碰撞计算的次数
238
238
  speed: number = 1; // 速度
239
+ /**
240
+ * ⭐ 鼠标转视角的**除数**(越小越灵敏、越大越慢)—— 皇上 2026-10-09 旨「把写死的 /500 提成可调参数」。
241
+ * ⚠️ 它是**除数不是速度**:`rotation -= movement / lookSpeed`,所以
242
+ * 500 = 现状(皇上定的基线,一字未动);拧小= 手更敏感,拧大 = 更「重」。
243
+ * ⚠️ 历史包袱:原为三处写死的字面量 500(第一人称 yaw/pitch + 第三人称 Y)。
244
+ * 本字段作为**唯一口径**,默认 500 ⇒ 数值与旧版逐位一致,不是行为变更。
245
+ */
246
+ lookSpeed: number = 500;
247
+ /**
248
+ * ⭐ 水平转向反转(皇上 2026-10-09 旨「水平+俯仰全反」的可配置入口)。
249
+ * false = 常规(鼠标右移视角右转);true = 反转(鼠标右移视角左转)。
250
+ * 与 `invertLookY` **各自独立**,可以只反一个、也可以两个都反。
251
+ */
252
+ invertLookX: boolean = false;
253
+ /**
254
+ * ⭐ 俯仰(pitch)反转:false = 常规(鼠标上移视角上抬);true = 反转(鼠标上移视角下压)。
255
+ * ⚠️ 翻转**只影响观察方向**,不碰 `camera.rotation.order = "YXZ"` 与 pitch 的物理含义
256
+ * ⇒ 碰撞射线仍旧只跟随 yaw(见 `_localRayOrigins` 那段),翻转后**贴墙/顶天花板判定不受影响**。
257
+ */
258
+ invertLookY: boolean = false;
239
259
  camera: Camera;
240
260
  // 移动时是否保持惯性 由外部来配置
241
261
  inertia: boolean = true;
@@ -642,6 +662,46 @@ export default class FPScontrol extends EventDispatcher<FPScontrolEventTypeMap>
642
662
  digits: 2,
643
663
  desc: "第一人称移动速度倍率(面板与右侧表单同一个字段)",
644
664
  },
665
+ {
666
+ group: "移动与视角",
667
+ label: "视角灵敏度",
668
+ key: "lookSpeed",
669
+ min: 50,
670
+ max: 2000,
671
+ step: 10,
672
+ digits: 0,
673
+ desc: "鼠标转视角的**除数**(越小越灵敏、越大越慢)。500 = 旧版写死值,拧到它与旧版逐位一致。例:250 灵敏一倍、1000 慢一倍",
674
+ },
675
+ {
676
+ // ⚠️ 布尔字段按 0/1 数值旋钮挂:FpsParamSpec 的 min/max/step/digits 是数值型,
677
+ // 故用 get/set 把 boolean 换算成 0/1(面板上拧 1 = 开、0 = 关),字段本身仍是 boolean。
678
+ group: "移动与视角",
679
+ label: "水平反转",
680
+ key: "invertLookX",
681
+ min: 0,
682
+ max: 1,
683
+ step: 1,
684
+ digits: 0,
685
+ desc: "0 = 常规(鼠标右移视角右转);1 = 反转(鼠标右移视角左转)。与「俯仰反转」各自独立,可只反一个",
686
+ get: (fps) => (fps.invertLookX ? 1 : 0),
687
+ set: (fps, v) => {
688
+ fps.invertLookX = v >= 0.5;
689
+ },
690
+ },
691
+ {
692
+ group: "移动与视角",
693
+ label: "俯仰反转",
694
+ key: "invertLookY",
695
+ min: 0,
696
+ max: 1,
697
+ step: 1,
698
+ digits: 0,
699
+ desc: "0 = 常规(鼠标上移视角上抬);1 = 反转(鼠标上移视角下压)。⚠️ 只改观察方向,碰撞射线仍只跟随 yaw ⇒ 贴墙/顶天花板判定不受影响",
700
+ get: (fps) => (fps.invertLookY ? 1 : 0),
701
+ set: (fps, v) => {
702
+ fps.invertLookY = v >= 0.5;
703
+ },
704
+ },
645
705
  {
646
706
  group: "移动与视角",
647
707
  label: "跳跃初速度",
@@ -1284,17 +1344,24 @@ export default class FPScontrol extends EventDispatcher<FPScontrolEventTypeMap>
1284
1344
  };
1285
1345
  mousemove = (event: MouseEvent): void => {
1286
1346
  if (this.enable && this.isMousedown) {
1347
+ // ⭐ 2026-10-09 皇上旨「把上下左右的交互反过来」:除数改读可写字段 `lookSpeed`(默认 500 = 旧值,一字未动),
1348
+ // 并加两个**各自独立**的反转开关。写成 × (invert ? -1 : 1) 而不是改符号的写法,
1349
+ // 是为了避免出现 `-=-` 这种双重取负的歧义。
1350
+ // ⚠️ 俯仰取反**不动** `_thirdPersonAngleX` 的 clamp 之外任何东西 —— 限位对称,翻转后依然 ±89°。
1351
+ const s = this.lookSpeed;
1352
+ const sx = this.invertLookX ? -1 : 1;
1353
+ const sy = this.invertLookY ? -1 : 1;
1287
1354
  if (this.thirdPerson) {
1288
1355
  // 第三人称:旋转轨道角
1289
- this._thirdPersonAngleY -= event.movementX / 500;
1356
+ this._thirdPersonAngleY -= event.movementX / s * sx;
1290
1357
  // 俯仰与第一人称同向:鼠标下移 → 视角下压 → 相机升高俯视(故用 +=)
1291
- this._thirdPersonAngleX += event.movementY / 500;
1358
+ this._thirdPersonAngleX += event.movementY / s * sy;
1292
1359
  this._thirdPersonAngleX = clamp(this._thirdPersonAngleX, degToRad(-89), degToRad(89));
1293
1360
  }
1294
1361
  else {
1295
1362
  // 第一人称:直接旋转相机
1296
- this.camera.rotation.y -= event.movementX / 500;
1297
- this.camera.rotation.x -= event.movementY / 500;
1363
+ this.camera.rotation.y -= event.movementX / s * sx;
1364
+ this.camera.rotation.x -= event.movementY / s * sy;
1298
1365
  }
1299
1366
  }
1300
1367
  };
package/MeshBase.js CHANGED
@@ -127,7 +127,8 @@ export default class MeshBase extends Group {
127
127
  this.reset(mesh.clone(true));
128
128
  }
129
129
  }
130
- await new Promise((resolve) => requestAnimationFrame(resolve));
130
+ // ⭐ 2026-10-09 皇上旨:走 threeUtils 公共 rAF 链,不再另起独立 rAF 回调
131
+ await threeUtils.waitNextFrame();
131
132
  this.loaded = true;
132
133
  // 加载完成事件
133
134
  dispatchCustomEvent(this, { type: "loaded" });
@@ -147,7 +148,10 @@ export default class MeshBase extends Group {
147
148
  if (this.resoles.length === 0) {
148
149
  (async () => {
149
150
  while (!this.loaded) {
150
- await new Promise((resolve) => requestAnimationFrame(resolve));
151
+ //⭐ 2026-10-09 皇上旨「整条 rAF 链只走 threeUtils 一条」:
152
+ // 原生 rAF 是**独立于主循环的另一个回调**,同一帧并行跑;
153
+ // 模型多时(十几个 GLB 同时 awaitLoaded)每帧多十几个回调 ⇒ 卡顿。
154
+ await threeUtils.waitNextFrame();
151
155
  }
152
156
  while (this.resoles.length > 0) {
153
157
  const resole = this.resoles.pop();
package/MeshBase.ts CHANGED
@@ -131,7 +131,8 @@ export default class MeshBase extends Group {
131
131
  this.reset(mesh.clone(true));
132
132
  }
133
133
  }
134
- await new Promise((resolve) => requestAnimationFrame(resolve));
134
+ // ⭐ 2026-10-09 皇上旨:走 threeUtils 公共 rAF 链,不再另起独立 rAF 回调
135
+ await threeUtils.waitNextFrame();
135
136
  this.loaded = true;
136
137
  // 加载完成事件
137
138
  dispatchCustomEvent(this, { type: "loaded" });
@@ -151,7 +152,10 @@ export default class MeshBase extends Group {
151
152
  if (this.resoles.length === 0) {
152
153
  (async () => {
153
154
  while (!this.loaded) {
154
- await new Promise((resolve) => requestAnimationFrame(resolve));
155
+ //⭐ 2026-10-09 皇上旨「整条 rAF 链只走 threeUtils 一条」:
156
+ // 原生 rAF 是**独立于主循环的另一个回调**,同一帧并行跑;
157
+ // 模型多时(十几个 GLB 同时 awaitLoaded)每帧多十几个回调 ⇒ 卡顿。
158
+ await threeUtils.waitNextFrame();
155
159
  }
156
160
  while (this.resoles.length > 0) {
157
161
  const resole = this.resoles.pop();
@@ -94,15 +94,16 @@ export function stripAssetHost(url, projName) {
94
94
  return url;
95
95
  // 去掉前导 `/`:得到真正的相对路径(`/a/b.jpg` → `a/b.jpg`;`/` → `""`)
96
96
  let path = rest.replace(/^\/+/, "");
97
- // 上传目录前缀整段丢掉,只留文件名(query / fragment 原样跟在后面)
97
+ // 上传目录前缀整段丢掉,换成**项目名**(皇上 19:53 旨);项目名缺失时退回「无项目名」的裸文件名。
98
+ // ⭐ 无论哪种形态,产出的地址都必须能被 `absAssetUrl` **原样还原**回真身 OSS 地址
99
+ // (这是「运行期地址不动、只在导出 json 里临时改」的前提 —— 顶替必须是可逆的)。
98
100
  if (path === UPLOAD_DIR_PREFIX || path.startsWith(`${UPLOAD_DIR_PREFIX}/`)) {
99
101
  const tail = path.slice(UPLOAD_DIR_PREFIX.length + 1); // 越过 `three-base_upload/` ⇒ `panorama/x_0.jpg`
100
102
  const cut = tail.search(/[/?#]/); // 截到第一个分隔符(`/` `?` `#`)
101
103
  // ⚠️ slice(cut) 会把那个分隔符一起带上(`panorama/x.jpg` → `/x.jpg`)⇒ 要 slice(cut+1)
102
104
  const file = cut < 0 ? tail : tail.slice(cut + 1); // 文件名(含后面的 ?/#)
103
105
  const dir = safeSeg(projName ?? "");
104
- path = dir ? `${dir}/${file}` : file;
105
- return path;
106
+ return dir ? `${dir}/${file}` : file;
106
107
  }
107
108
  // ⭐ 幂等对称:`{项目名}/x.jpg` 这种**已顶替过**的形态(还原回绝对后再剥)要认出来,
108
109
  // 否则项目名段会被原样留着、形态跟首轮落盘的不一致 ⇒ 导出→导入→导出闭环对不上。
@@ -142,13 +143,23 @@ export function absAssetUrl(url, projName) {
142
143
  if (isLocalUrl(url) || !isPathLike(url))
143
144
  return url;
144
145
  const path = url.replace(/^\/+/, "");
146
+ // ⛔ 空路径(`/` 或 `/` 去掉斜杠后)不是文件名,别当裸文件名补上传目录(实测会补出 `host/three-base_upload/panorama/`)
147
+ if (path === "")
148
+ return `${MKT_ASSET_HOST}/`;
145
149
  const segs = path.split("/");
146
- // 只在「恰好两段」且「首段不是已知站点目录」时,才按「项目名/文件名」还原
147
- if (segs.length === 2 && !KNOWN_TOP_DIRS.includes(segs[0])) {
150
+ // ⭐ 情形①:**裸文件名**(`x_26.jpg`,无目录)—— 这是 `stripAssetHost` 无项目名时的落盘形态,
151
+ // 它必然来自上传目录 ⇒ 直接还原回 `three-base_upload/panorama/`。
152
+ // ⛔ 漏了这一条就会补成 `https://host/x_26.jpg`(丢了上传目录)⇒ 线上取图 404(皇上 10-09 实测撞到)。
153
+ if (segs.length === 1)
154
+ return `${MKT_ASSET_HOST}/${UPLOAD_FULL_PREFIX}/${segs[0]}`;
155
+ // 情形②:`{项目名}/文件名`(两段、首段不是已知站点目录)⇒ 同样还原回上传目录。
156
+ // 判据与 `strip` 侧镜像,两边用同一把尺(`segs0IsProjectDir`)才能保证「顶替可逆」。
157
+ if (segs.length === 2 && segs0IsProjectDir(segs[0])) {
148
158
  const dir = safeSeg(projName ?? "");
149
159
  // 传了项目名就严格比它(最可靠);没传才放宽到「非已知目录即可认」
150
160
  if (!dir || segs[0] === dir)
151
161
  return `${MKT_ASSET_HOST}/${UPLOAD_FULL_PREFIX}/${segs[1]}`;
152
162
  }
163
+ // 情形③:其余形态(`three-base/model/a.glb`、已是 `three-base_upload/…` 的)⇒ 原样补域名,不还原。
153
164
  return `${MKT_ASSET_HOST}/${path}`;
154
165
  }
@@ -98,15 +98,16 @@ export function stripAssetHost(url: string, projName?: string): string {
98
98
  if (rest !== "" && !/^[/?#]/.test(rest)) return url;
99
99
  // 去掉前导 `/`:得到真正的相对路径(`/a/b.jpg` → `a/b.jpg`;`/` → `""`)
100
100
  let path = rest.replace(/^\/+/, "");
101
- // 上传目录前缀整段丢掉,只留文件名(query / fragment 原样跟在后面)
101
+ // 上传目录前缀整段丢掉,换成**项目名**(皇上 19:53 旨);项目名缺失时退回「无项目名」的裸文件名。
102
+ // ⭐ 无论哪种形态,产出的地址都必须能被 `absAssetUrl` **原样还原**回真身 OSS 地址
103
+ // (这是「运行期地址不动、只在导出 json 里临时改」的前提 —— 顶替必须是可逆的)。
102
104
  if (path === UPLOAD_DIR_PREFIX || path.startsWith(`${UPLOAD_DIR_PREFIX}/`)) {
103
105
  const tail = path.slice(UPLOAD_DIR_PREFIX.length + 1); // 越过 `three-base_upload/` ⇒ `panorama/x_0.jpg`
104
106
  const cut = tail.search(/[/?#]/); // 截到第一个分隔符(`/` `?` `#`)
105
107
  // ⚠️ slice(cut) 会把那个分隔符一起带上(`panorama/x.jpg` → `/x.jpg`)⇒ 要 slice(cut+1)
106
108
  const file = cut < 0 ? tail : tail.slice(cut + 1); // 文件名(含后面的 ?/#)
107
109
  const dir = safeSeg(projName ?? "");
108
- path = dir ? `${dir}/${file}` : file;
109
- return path;
110
+ return dir ? `${dir}/${file}` : file;
110
111
  }
111
112
  // ⭐ 幂等对称:`{项目名}/x.jpg` 这种**已顶替过**的形态(还原回绝对后再剥)要认出来,
112
113
  // 否则项目名段会被原样留着、形态跟首轮落盘的不一致 ⇒ 导出→导入→导出闭环对不上。
@@ -144,12 +145,20 @@ export function absAssetUrl(url: string, projName?: string): string {
144
145
  if (typeof url !== "string" || url === "") return url;
145
146
  if (isLocalUrl(url) || !isPathLike(url)) return url;
146
147
  const path = url.replace(/^\/+/, "");
148
+ // ⛔ 空路径(`/` 或 `/` 去掉斜杠后)不是文件名,别当裸文件名补上传目录(实测会补出 `host/three-base_upload/panorama/`)
149
+ if (path === "") return `${MKT_ASSET_HOST}/`;
147
150
  const segs = path.split("/");
148
- // 只在「恰好两段」且「首段不是已知站点目录」时,才按「项目名/文件名」还原
149
- if (segs.length === 2 && !KNOWN_TOP_DIRS.includes(segs[0])) {
151
+ // ⭐ 情形①:**裸文件名**(`x_26.jpg`,无目录)—— 这是 `stripAssetHost` 无项目名时的落盘形态,
152
+ // 它必然来自上传目录 ⇒ 直接还原回 `three-base_upload/panorama/`。
153
+ // ⛔ 漏了这一条就会补成 `https://host/x_26.jpg`(丢了上传目录)⇒ 线上取图 404(皇上 10-09 实测撞到)。
154
+ if (segs.length === 1) return `${MKT_ASSET_HOST}/${UPLOAD_FULL_PREFIX}/${segs[0]}`;
155
+ // 情形②:`{项目名}/文件名`(两段、首段不是已知站点目录)⇒ 同样还原回上传目录。
156
+ // 判据与 `strip` 侧镜像,两边用同一把尺(`segs0IsProjectDir`)才能保证「顶替可逆」。
157
+ if (segs.length === 2 && segs0IsProjectDir(segs[0])) {
150
158
  const dir = safeSeg(projName ?? "");
151
159
  // 传了项目名就严格比它(最可靠);没传才放宽到「非已知目录即可认」
152
160
  if (!dir || segs[0] === dir) return `${MKT_ASSET_HOST}/${UPLOAD_FULL_PREFIX}/${segs[1]}`;
153
161
  }
162
+ // 情形③:其余形态(`three-base/model/a.glb`、已是 `three-base_upload/…` 的)⇒ 原样补域名,不还原。
154
163
  return `${MKT_ASSET_HOST}/${path}`;
155
164
  }
package/PlaneWithHtml.js CHANGED
@@ -9,6 +9,7 @@ import { Texture, DoubleSide, Mesh, MeshBasicMaterial, PlaneGeometry, Vector2 }
9
9
  import { MathUtils } from "three";
10
10
  const { degToRad } = MathUtils;
11
11
  import threeLoader from "./threeLoader";
12
+ import threeUtils from "./threeUtils";
12
13
  // html 文本面板
13
14
  export default class PlaneWithHtml extends Mesh {
14
15
  constructor(html = "", backgroundColor = "#00000000") {
@@ -56,7 +57,8 @@ export default class PlaneWithHtml extends Mesh {
56
57
  this.material.map = texture;
57
58
  this.material.map.anisotropy = threeLoader.anisotropy;
58
59
  this.material.needsUpdate = true;
59
- await new Promise(resolve => requestAnimationFrame(resolve));
60
+ // ⭐ 2026-10-09 皇上旨:走 threeUtils 公共 rAF 链,不再另起独立 rAF 回调
61
+ await threeUtils.waitNextFrame();
60
62
  this.visible = true;
61
63
  this.div.remove();
62
64
  this.dispatchEvent({ type: "loaded" });
package/PlaneWithHtml.ts CHANGED
@@ -9,6 +9,7 @@ import { Texture, DoubleSide, Mesh, MeshBasicMaterial, PlaneGeometry, Vector2 }
9
9
  import { MathUtils } from "three";
10
10
  const { degToRad } = MathUtils;
11
11
  import threeLoader from "./threeLoader";
12
+ import threeUtils from "./threeUtils";
12
13
 
13
14
  type HexColorString = `#${string}`;
14
15
 
@@ -62,7 +63,8 @@ export default class PlaneWithHtml extends Mesh {
62
63
  this.material.map = texture;
63
64
  this.material.map.anisotropy = threeLoader.anisotropy;
64
65
  this.material.needsUpdate = true;
65
- await new Promise(resolve => requestAnimationFrame(resolve));
66
+ // ⭐ 2026-10-09 皇上旨:走 threeUtils 公共 rAF 链,不再另起独立 rAF 回调
67
+ await threeUtils.waitNextFrame();
66
68
  this.visible = true;
67
69
  this.div.remove();
68
70
  this.dispatchEvent({ type: "loaded" } as never);
package/SpriteWithHtml.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { snapdom } from "@zumer/snapdom";
8
8
  import { Texture, Sprite } from "three";
9
9
  import threeLoader from "./threeLoader";
10
+ import threeUtils from "./threeUtils";
10
11
  // html 文本面板
11
12
  export default class SpriteWithHtml extends Sprite {
12
13
  constructor(html = "", backgroundColor = "#00000000") {
@@ -36,7 +37,8 @@ export default class SpriteWithHtml extends Sprite {
36
37
  this.material.map.anisotropy = threeLoader.anisotropy;
37
38
  this.material.alphaTest = 0.1;
38
39
  this.material.needsUpdate = true;
39
- await new Promise(resolve => requestAnimationFrame(resolve));
40
+ // ⭐ 2026-10-09 皇上旨:走 threeUtils 公共 rAF 链,不再另起独立 rAF 回调
41
+ await threeUtils.waitNextFrame();
40
42
  this.visible = true;
41
43
  this.div.remove();
42
44
  this.dispatchEvent({ type: "loaded" });
package/SpriteWithHtml.ts CHANGED
@@ -8,6 +8,7 @@ import { snapdom } from "@zumer/snapdom";
8
8
  import { Texture, Sprite } from "three";
9
9
  import threeLoader from "./threeLoader";
10
10
  import type { SpriteWithHtmlEventMap } from "./EventMap";
11
+ import threeUtils from "./threeUtils";
11
12
 
12
13
  type HexColorString = `#${string}`;
13
14
 
@@ -42,7 +43,8 @@ export default class SpriteWithHtml extends Sprite {
42
43
  this.material.map.anisotropy = threeLoader.anisotropy;
43
44
  this.material.alphaTest = 0.1;
44
45
  this.material.needsUpdate = true;
45
- await new Promise(resolve => requestAnimationFrame(resolve));
46
+ // ⭐ 2026-10-09 皇上旨:走 threeUtils 公共 rAF 链,不再另起独立 rAF 回调
47
+ await threeUtils.waitNextFrame();
46
48
  this.visible = true;
47
49
  this.div.remove();
48
50
  this.dispatchEvent({ type: "loaded" } as never);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-base",
3
- "version": "2.0.145",
3
+ "version": "2.0.166",
4
4
  "description": "A Three.js extension library providing common 3D components, animations, materials, and utilities",
5
5
  "author": "xiaosihan",
6
6
  "publishConfig": {
package/threeUtils.d.ts CHANGED
@@ -11,6 +11,24 @@ declare class ThreeUtils extends EventDispatcher<ThreeUtilsEventTypeMap> {
11
11
  _center: Vector3;
12
12
  _clock: Clock;
13
13
  requestAnimationFrame(): void;
14
+ /**
15
+ * 等下一帧(**本库唯一的「等一帧」入口**,皇上 2026-10-09 旨)。
16
+ *
17
+ * ⛔ 为什么不再用原生 `await new Promise(r => requestAnimationFrame(r))`:
18
+ * 那是**独立于本类主循环的另一个 rAF 回调**,同一帧里与主循环并行跑 ⇒ 每帧多一次调度,
19
+ * 模型多时(如同时加载十几个 GLB,每个都 `awaitLoaded`)会积成每帧几十个回调 ⇒ 卡顿。
20
+ * 走本方法则**全部挂在本类这一条链上**(本类已把节流控到 30ms/帧),零额外调度。
21
+ *
22
+ * 典型用法(原三处`await new Promise(r => requestAnimationFrame(r))` 逐字替换即可):
23
+ * ```ts
24
+ * import threeUtils from "three-base/threeUtils";
25
+ * await threeUtils.waitNextFrame();
26
+ * ```
27
+ */
28
+ waitNextFrame(): Promise<void>;
29
+ /** 结算所有 `waitNextFrame` 的挂起项(每帧由 `requestAnimationFrame` 调一次) */
30
+ private _nextFrameWaiters;
31
+ private _flushNextFrameWaiters;
14
32
  private _keepZoomDoms;
15
33
  /**
16
34
  *计算需要保持比例的元素
package/threeUtils.js CHANGED
@@ -33,10 +33,44 @@ class ThreeUtils extends EventDispatcher {
33
33
  //执行一个计划任务
34
34
  let work = this.workList.splice(0, 1)[0];
35
35
  work && work();
36
+ // ⭐ 2026-10-09 皇上旨「整条 rAF 链只走 threeUtils 一条」:先结算本帧挂起的「等下一帧」,
37
+ // 再排下一拍 ⇒ 与 dispatchEvent 同拍派发,语义 =「下一帧的此刻」,与原生 rAF 同口径。
38
+ // ⚠️ 必须在 dispatchEvent **之后**结算:挂在事件上的 resolve 期望的就是下一帧,别在同一帧里被清掉。
39
+ this._flushNextFrameWaiters();
36
40
  setTimeout(() => {
37
41
  this.requestAnimationFrame();
38
42
  }, 30 - this._clock.getDelta());
39
43
  }
44
+ /**
45
+ * 等下一帧(**本库唯一的「等一帧」入口**,皇上 2026-10-09 旨)。
46
+ *
47
+ * ⛔ 为什么不再用原生 `await new Promise(r => requestAnimationFrame(r))`:
48
+ * 那是**独立于本类主循环的另一个 rAF 回调**,同一帧里与主循环并行跑 ⇒ 每帧多一次调度,
49
+ * 模型多时(如同时加载十几个 GLB,每个都 `awaitLoaded`)会积成每帧几十个回调 ⇒ 卡顿。
50
+ * 走本方法则**全部挂在本类这一条链上**(本类已把节流控到 30ms/帧),零额外调度。
51
+ *
52
+ * 典型用法(原三处`await new Promise(r => requestAnimationFrame(r))` 逐字替换即可):
53
+ * ```ts
54
+ * import threeUtils from "three-base/threeUtils";
55
+ * await threeUtils.waitNextFrame();
56
+ * ```
57
+ */
58
+ waitNextFrame() {
59
+ return new Promise((resolve) => {
60
+ this._nextFrameWaiters.push(resolve);
61
+ });
62
+ }
63
+ /** 结算所有 `waitNextFrame` 的挂起项(每帧由 `requestAnimationFrame` 调一次) */
64
+ _nextFrameWaiters = [];
65
+ _flushNextFrameWaiters() {
66
+ if (this._nextFrameWaiters.length === 0)
67
+ return;
68
+ // 先摘出来再逐个调:回调里可能又挂新的(新挂的要等下一帧,不在本拍结算)
69
+ const waiters = this._nextFrameWaiters;
70
+ this._nextFrameWaiters = [];
71
+ for (const resolve of waiters)
72
+ resolve();
73
+ }
40
74
  // 需要保持宽高比的元素列表
41
75
  _keepZoomDoms = [];
42
76
  /**
package/threeUtils.ts CHANGED
@@ -50,11 +50,46 @@ class ThreeUtils extends EventDispatcher<ThreeUtilsEventTypeMap> {
50
50
  //执行一个计划任务
51
51
  let work = this.workList.splice(0, 1)[0];
52
52
  work && work();
53
+ // ⭐ 2026-10-09 皇上旨「整条 rAF 链只走 threeUtils 一条」:先结算本帧挂起的「等下一帧」,
54
+ // 再排下一拍 ⇒ 与 dispatchEvent 同拍派发,语义 =「下一帧的此刻」,与原生 rAF 同口径。
55
+ // ⚠️ 必须在 dispatchEvent **之后**结算:挂在事件上的 resolve 期望的就是下一帧,别在同一帧里被清掉。
56
+ this._flushNextFrameWaiters();
53
57
  setTimeout(() => {
54
58
  this.requestAnimationFrame();
55
59
  }, 30 - this._clock.getDelta());
56
60
  }
57
61
 
62
+ /**
63
+ * 等下一帧(**本库唯一的「等一帧」入口**,皇上 2026-10-09 旨)。
64
+ *
65
+ * ⛔ 为什么不再用原生 `await new Promise(r => requestAnimationFrame(r))`:
66
+ * 那是**独立于本类主循环的另一个 rAF 回调**,同一帧里与主循环并行跑 ⇒ 每帧多一次调度,
67
+ * 模型多时(如同时加载十几个 GLB,每个都 `awaitLoaded`)会积成每帧几十个回调 ⇒ 卡顿。
68
+ * 走本方法则**全部挂在本类这一条链上**(本类已把节流控到 30ms/帧),零额外调度。
69
+ *
70
+ * 典型用法(原三处`await new Promise(r => requestAnimationFrame(r))` 逐字替换即可):
71
+ * ```ts
72
+ * import threeUtils from "three-base/threeUtils";
73
+ * await threeUtils.waitNextFrame();
74
+ * ```
75
+ */
76
+ waitNextFrame(): Promise<void> {
77
+ return new Promise<void>((resolve) => {
78
+ this._nextFrameWaiters.push(resolve);
79
+ });
80
+ }
81
+
82
+ /** 结算所有 `waitNextFrame` 的挂起项(每帧由 `requestAnimationFrame` 调一次) */
83
+ private _nextFrameWaiters: Array<() => void> = [];
84
+
85
+ private _flushNextFrameWaiters(): void {
86
+ if (this._nextFrameWaiters.length === 0) return;
87
+ // 先摘出来再逐个调:回调里可能又挂新的(新挂的要等下一帧,不在本拍结算)
88
+ const waiters = this._nextFrameWaiters;
89
+ this._nextFrameWaiters = [];
90
+ for (const resolve of waiters) resolve();
91
+ }
92
+
58
93
  // 需要保持宽高比的元素列表
59
94
  private _keepZoomDoms: KeepZoomDom[] = [];
60
95