three-base 2.0.145 → 2.0.168

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();
@@ -24,7 +24,8 @@
24
24
  * 同一时刻只激活名册 0 号槽(中心)那一个纹理(全局共享单激活方案)。
25
25
  */
26
26
  import { BufferGeometry, BackSide, Float32BufferAttribute, Matrix4, Mesh, Shape, ShapeGeometry, SRGBColorSpace, Vector3, } from "three";
27
- import { createPlaceholderTexture, MKT_PANO_MAX_SLOTS, isPanoTexMode, mktPanoTiledFlag, mktPanoTexUrls, isRtStitchKey, rtStitchKeyParts, RT_STITCH_KEY_PREFIX, RT_STITCH_SLOT_COUNT } from "./mktShader";
27
+ import { createPlaceholderTexture, isPanoTexMode, mktPanoTiledFlag, mktPanoTexUrls, isRtStitchKey, rtStitchKeyParts, RT_STITCH_KEY_PREFIX, RT_STITCH_SLOT_COUNT } from "./mktShader";
28
+ import { MKT_PANO_MAX_SLOTS } from "./mktMappingShader";
28
29
  import { createMktFloorMappingShaderMaterial } from "./mktMappingShader";
29
30
  // ⭐ 皇上旨 2026-10-06:拼合 = shader 逐格画进 WebGLRenderTarget,**一份实现**(canvas 2D 那一份连同
30
31
  // `?panoStitch=canvas` 开关整条删除)
@@ -41,7 +41,8 @@ import {
41
41
  Vector3,
42
42
  } from "three";
43
43
  import type { WebGLRenderer } from "three";
44
- import { createPlaceholderTexture, MKT_PANO_MAX_SLOTS, isPanoTexMode, mktPanoTiledFlag, mktPanoTexUrls, isRtStitchKey, rtStitchKeyParts, RT_STITCH_KEY_PREFIX, RT_STITCH_SLOT_COUNT, type PanoTexMode } from "./mktShader";
44
+ import { createPlaceholderTexture, isPanoTexMode, mktPanoTiledFlag, mktPanoTexUrls, isRtStitchKey, rtStitchKeyParts, RT_STITCH_KEY_PREFIX, RT_STITCH_SLOT_COUNT, type PanoTexMode } from "./mktShader";
45
+ import { MKT_PANO_MAX_SLOTS } from "./mktMappingShader";
45
46
  import { createMktFloorMappingShaderMaterial } from "./mktMappingShader";
46
47
  // ⭐ 皇上旨 2026-10-06:拼合 = shader 逐格画进 WebGLRenderTarget,**一份实现**(canvas 2D 那一份连同
47
48
  // `?panoStitch=canvas` 开关整条删除)
@@ -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
  }
@@ -1,4 +1,13 @@
1
1
  import { ShaderMaterial } from "three";
2
+ /**
3
+ * ⭐ 融合材质的全景点数**上限**(皇上旨 10-06「一次能够展示 8 个全景纹理」):每点占 **2** 张拼合槽纹理
4
+ * (32 片 8×4 → 左右 2 槽,每槽 4×4 格)⇒ **8 × 2 = 16 个 sampler**,再多样本编译不过。
5
+ * (sampler 总数 16 是硬件硬顶,本机 `MAX_TEXTURE_IMAGE_UNITS` 实测 16。)
6
+ *
7
+ * ⭐⭐ 2026-10-09 皇上旨「MktMesh 只有映射一种,blend/selected 归编辑器」⇒ 该常量从 `mktShader.ts`
8
+ * **搬到这里**(映射材质的所在地)。它同时是库与编辑器那份 blend 的**共同上限**,两边必须同值。
9
+ */
10
+ export declare const MKT_PANO_MAX_SLOTS = 8;
2
11
  /**
3
12
  * 创建「全景序号直取」着色器材质(皇上旨 2026-09-29「三模式三 shader」之二):
4
13
  * 编辑器「映射设置模式」的墙材质 + **基类 MktMesh(发布/预览)的固定墙材质**
@@ -6,7 +6,16 @@
6
6
  * 与 blend / selected 共用同一份 floorVertexShader 与同一套采样口径(改采样两处同改)。
7
7
  */
8
8
  import { BackSide, Matrix4, ShaderMaterial, Vector3 } from "three";
9
- import { createPlaceholderTexture, floorVertexShader, MKT_PANO_MAX_SLOTS, RT_STITCH_SLOT_COUNT } from "./mktShader";
9
+ import { createPlaceholderTexture, floorVertexShader, RT_STITCH_SLOT_COUNT } from "./mktShader";
10
+ /**
11
+ * ⭐ 融合材质的全景点数**上限**(皇上旨 10-06「一次能够展示 8 个全景纹理」):每点占 **2** 张拼合槽纹理
12
+ * (32 片 8×4 → 左右 2 槽,每槽 4×4 格)⇒ **8 × 2 = 16 个 sampler**,再多样本编译不过。
13
+ * (sampler 总数 16 是硬件硬顶,本机 `MAX_TEXTURE_IMAGE_UNITS` 实测 16。)
14
+ *
15
+ * ⭐⭐ 2026-10-09 皇上旨「MktMesh 只有映射一种,blend/selected 归编辑器」⇒ 该常量从 `mktShader.ts`
16
+ * **搬到这里**(映射材质的所在地)。它同时是库与编辑器那份 blend 的**共同上限**,两边必须同值。
17
+ */
18
+ export const MKT_PANO_MAX_SLOTS = 8;
10
19
  /**
11
20
  * 序号直取片元(皇上旨 2026-09-29「三模式三 shader」之二):
12
21
  * 按三角面 aPanoIndex 在 panoIndices 槽里找对应全景纹理 —— **不看距离、不看夹角**(blend 那套打分/归约整段没有)。
@@ -6,7 +6,17 @@
6
6
  * 与 blend / selected 共用同一份 floorVertexShader 与同一套采样口径(改采样两处同改)。
7
7
  */
8
8
  import { BackSide, Matrix4, ShaderMaterial, Vector3 } from "three";
9
- import { createPlaceholderTexture, floorVertexShader, MKT_PANO_MAX_SLOTS, RT_STITCH_SLOT_COUNT } from "./mktShader";
9
+ import { createPlaceholderTexture, floorVertexShader, RT_STITCH_SLOT_COUNT } from "./mktShader";
10
+
11
+ /**
12
+ * ⭐ 融合材质的全景点数**上限**(皇上旨 10-06「一次能够展示 8 个全景纹理」):每点占 **2** 张拼合槽纹理
13
+ * (32 片 8×4 → 左右 2 槽,每槽 4×4 格)⇒ **8 × 2 = 16 个 sampler**,再多样本编译不过。
14
+ * (sampler 总数 16 是硬件硬顶,本机 `MAX_TEXTURE_IMAGE_UNITS` 实测 16。)
15
+ *
16
+ * ⭐⭐ 2026-10-09 皇上旨「MktMesh 只有映射一种,blend/selected 归编辑器」⇒ 该常量从 `mktShader.ts`
17
+ * **搬到这里**(映射材质的所在地)。它同时是库与编辑器那份 blend 的**共同上限**,两边必须同值。
18
+ */
19
+ export const MKT_PANO_MAX_SLOTS = 8;
10
20
 
11
21
  /**
12
22
  * 序号直取片元(皇上旨 2026-09-29「三模式三 shader」之二):
@@ -1,6 +1,55 @@
1
1
  import { DataTexture, Matrix4, ShaderMaterial } from "three";
2
+ /** ⭐ 2026-10-09 随blend 材质搬去编辑器,故改为导出(那边拼片元 GLSL 时要用)。 */
3
+ export declare const TILE_DEBUG_GLSL: string;
2
4
  /** 诊断配色是否开启(应用层据此把全景点标记染成同色板,两边对得上) */
3
5
  export declare const MKT_PANO_TINT_ON: boolean;
6
+ /**
7
+ * 🔬 诊断色的**叠加强度**(0~1):`mix(原画面色, 染色, 本值)`。1 = 整块换成色相(09-25 初版,墙上只剩明暗,
8
+ * 照片本身看不清);皇上旨 2026-09-26 晚「叠加的颜色比例少一点」⇒ 取 **0.5**:色相仍是主色、一眼分得出归属,
9
+ * 但底下那张全景图的明暗与颜色能同时读出来。两份材质(融合 / 单全景)**共用这一个值**。
10
+ */
11
+ /** ⭐ 2026-10-09 同上:随 blend 材质搬去编辑器,故改为导出。 */
12
+ export declare const PANO_TINT_MIX = 0.5;
13
+ /**
14
+ * 🔬 **距离明暗诊断图**(皇上旨 2026-09-26 晚「你计一下每个点距离最近的一个全景点的距离, 然后距离越小颜色就是白色,
15
+ * 越远颜色就是黑色」)。
16
+ *
17
+ * 用法:分两条路,**各自独立**,谁也不靠谁:
18
+ * · **编辑器(MktMeshEdit)= 建材质时显式传 `panoDist: mktPanoDistEditorOn()`** ⇒ 默认关、地址里写 `panoDist=1` 才开
19
+ * (皇上旨 2026-09-27 晚「把颜色换成纹理,全景的纹理」—— 灰阶诊断让位给真投影,诊断图按需开)。
20
+ * · **库侧(发布页 / 预览弹窗)= 不传 ⇒ 读 URL 的 `PANO_DIST_ON`,默认关** ⇒ 正常渲染路径零影响。
21
+ * 开了之后片元算完最终颜色,按**这面这点到「它实际用的那张全景」的投影中心的水平距离**涂明暗:
22
+ * **贴到球脚下 = 白,远到房间最远角 = 黑**。
23
+ * 与归属配色的分工:`panoTint` 回答「这块归哪个球」(分家线),`panoDist` 回答「这点离自己的球到底有多远」——
24
+ * 后者能把「整墙一个色」那面墙上的**连续渐变**照出来,距离判据是不是真在起作用一眼可辨(恒白/恒黑才是坏的)。
25
+ * ⚠️ 距离口径与取图那条**同一个**:水平(xz)距离、候选 = 存活槽、门优先兜底不限门 ⇒ 白色区域就是那一张覆盖到的地方。
26
+ * ⚠️ 黑端(多远算全黑)= 应用层每次喂的 `uPanoDistMax`,取**墙体上最远处到自己最近景点的那段水平距离**
27
+ * (`_panoDistRange()`,皇上那间约 12m)⇒「黑」就是这间房可能出现的最大距离,跨面可比(不是每面各自归一化)。
28
+ * ⛔ 别拿地板尺寸当尺子:默认地板盘 `FLOOR_SIZE_DEFAULT = 80`(远超房间),对角线 113m 会把整面墙的灰阶压平
29
+ * (09-26 实测横扫三个点亮度 222/223/224,等于没有渐变)。
30
+ * ⚠️ 明暗曲线 = **线性**(`1 − d/D`,皇上旨 2026-09-27「要,改成线性渐变」)。09-26 那版 `pow(…, 2.2)` 的动机是
31
+ * 「着色器里是线性色,不补感知伽马会中间偏亮」—— 代价实测更糟:6m 处灰阶只剩 0.22 ⇒ 渐变几乎全压在景点脚下,
32
+ * 他数不出圆。线性后同一点 0.50 中灰,整间房的远近一眼读得出。
33
+ * ⚠️ 强度**不沿用** `PANO_TINT_MIX` 而是自己的 `PANO_DIST_MIX = 1`(整块换成灰阶):皇上要的是「近=白、远=黑」这张**距离图**本身,
34
+ * 09-26 实测按 0.5 混 ⇒ 一面墙横扫过去只有 135→151(19 级)的差别,肉眼仍是「一片灰」;归属配色那条 0.5 的旨意针对的是
35
+ * 「色叠加把照片盖住了」,与本图无关(要看照片本来颜色就把 `?panoDist` 关掉)。
36
+ * ⚠️ 两道诊断**同时开**时本图后上色 ⇒ 明暗图盖住色相(要同时看归属与距离请分开截两张)。
37
+ * ⚠️ 开关口径历经三版:① 初版「默认关、URL `?panoDist=1` 才开」⇒ 皇上第二天地址栏里只剩 `panoTint=1`,
38
+ * 看到的又是实拍照片,误判「代码被改回去了」(09-26 实测线上包里 panoDist 一段俱在,纯为开关没跟上)。
39
+ * ② 皇上同日晚旨「继续改成 距离越近越白、越远越黑的模式」⇒ 改成**编辑器这一页默认开**:写法是在 renderer
40
+ * 模块顶层调 `mktPanoDistEditorDefaultOn()` 翻全局开关。09-26 实测线上打印当场暴露它是**半张图**:
41
+ * 「墙 / 地板 / 家具三份材质的片元着色器不是同一份」—— 立方体那份是 `static material = create…()`,
42
+ * 在**模块求值期**就建好了,永远早于任何应用层模块体里的语句 ⇒ 墙/地板黑了白了,立方体仍是实拍。
43
+ * ③ 现行(本次定论):**开关不再靠语句先后**,而是建材质时把这一份材质要不要这张图**显式说清楚**
44
+ * (融合工厂的 `panoDist` 选项)。⇒ 编辑器三处建材质点同传一个纯函数,预览/发布页不传 ⇒ 天然关。
45
+ * ④ 09-27 晚皇上旨「把颜色换成纹理,全景的纹理」⇒ 编辑器这一页翻回**默认关**(`mktPanoDistEditorOn()`
46
+ * = `panoDebugOn("panoDist")`),灰阶诊断按需 `?panoDist=1` 再看。口径机制不变,只翻默认值。
47
+ * ⛔ 不许再回到「模块顶层翻全局变量」那一路:那份 GLSL 是建材质时一次性拼出来的,谁先谁后取决于 import 顺序,
48
+ * 改一次 import 就悄悄漏一面(且 `统一性检查` 会当场报不是同一份源码)。
49
+ */
50
+ /** ⭐ 2026-10-09 皇上旨「MktMesh 只有映射一种,blend/selected 归编辑器」⇒ 本常量改为导出:
51
+ * 编辑器那份 `mktMeshFloorBlendShader.ts` 要用它当默认值。库内的映射材质仍照常用。 */
52
+ export declare const PANO_DIST_ON: boolean;
4
53
  /**
5
54
  * 🔬 **编辑器这一页**该不该有近白远黑图:**默认关**(皇上旨 2026-09-27 晚「把颜色换成纹理,全景的纹理」——
6
55
  * 灰阶诊断盖住了真投影,默认让位给全景纹理;要看诊断图 URL 显式写 `panoDist=1`,`#` 前后都认)。
@@ -9,6 +58,16 @@ export declare const MKT_PANO_TINT_ON: boolean;
9
58
  * ⚠️ URL 解析只留库侧这一处(与 `panoDebugOn` 同族口径),⛔ 应用层不许再各解一遍。
10
59
  */
11
60
  export declare function mktPanoDistEditorOn(): boolean;
61
+ /**
62
+ * 🔬 距离明暗图的「求最近景点」那一段 —— **真循环**版(皇上旨 2026-09-27 晚「那就更不对了,我一开始的需求就是
63
+ * 把 4 个全景点的坐标传给 shader,按最近取用」+ 看过代码后点头要这版)。
64
+ * 语义与他原话逐字对齐:每个像素遍历 `uPanoPoints[0..N-1]`,只留最小水平距离。
65
+ * ⚠️ 09-27 晚起取图与明暗图**同把尺**(皇上「取用距离最近的那个全景点的瓦片纹理」定版):取图那条的夹角门
66
+ * 已整链删除 ⇒ 两边都是「纯水平距离最近」,圆与图永远一致。⛔ 本函数只动颜色,不动取图。
67
+ * ⚠️ 动态下标只碰普通 uniform 数组(`vec4[]`),不碰 sampler ⇒ ANGLE 那条「sampler 必须常量下标」的限制管不到这里。
68
+ */
69
+ /** ⭐ 2026-10-09 同上:随 blend 材质一起搬去编辑器,故改为导出(那边拼片元 GLSL 时要用)。 */
70
+ export declare function panoDistLoopGlsl(colorVar: string, maxPano: number): string;
12
71
  /** 末尾 4 色(红/绿/蓝/黄):景点数 > 20 时循环取用 */
13
72
  export declare const MKT_PANO_TINT_HEX: readonly number[];
14
73
  /** 「第 i 个**景点**」的诊断色(按景点序号,不是槽位):0~19 各一色,>=20 循环末尾 4 色 */
@@ -228,36 +287,19 @@ export declare function setMktPanoTiled(material: ShaderMaterial | null | undefi
228
287
  * panorama / textureMatrix 由 MktMesh 在运行时按「最近纹理」动态设置。
229
288
  */
230
289
  export declare function createMktShaderMaterial(): ShaderMaterial;
231
- export interface MktFloorBlendOptions {
232
- /** 最多参与融合的全景点数(⭐ 10-06 晚旨后每点占左右 **2** 张拼合槽纹理,纹理总数 = maxPano×2,须 ≤ 16),默认 `MKT_PANO_MAX_SLOTS`(=8) ⇒ 8×2 = 16 = 采样器硬顶 */
233
- maxPano?: number;
234
- /**
235
- * 🔬 这一份材质要不要「距离明暗图」(近白远黑):**不传 = 读 URL 开关 `?panoDist=1`**(发布页 / 预览就该走这条)。
236
- * 编辑器三处建材质点显式传 `mktPanoDistEditorOn()`(默认开)⇒ 墙/地板/立方体**同一份源码**、同一张图。
237
- * ⚠️ 只能在这里给,⛔ 没有「建完再翻」这条路:这段 GLSL 在 new ShaderMaterial 时一次性拼死。
238
- */
239
- panoDist?: boolean;
240
- }
241
- /** 融合材质的全景点数**上限**:每点占 **2** 张拼合槽纹理(32 片 8×4 → 左右 2 槽,每槽 4×4 格)⇒ **8 × 2 = 16 个 sampler**,再多样本编译不过。
242
- * ⭐ 2026-10-06 晚皇上旨「一次能够展示 8 个全景纹理」:靠的就是「每点从 4 槽降到 2 槽」——
243
- * sampler 总数 16 是硬件硬顶(皇上本机 `MAX_TEXTURE_IMAGE_UNITS` 实测 16,一直没变),
244
- * 原来 16 ÷ 4 槽 = 4 个景点,现在 16 ÷ 2 槽 = **8 个景点**,画质与单全景显存**分毫未动**。 */
245
- export declare const MKT_PANO_MAX_SLOTS = 8;
246
- /**
247
- * 创建「多全景融合」着色器材质:每帧由外部喂入最近 maxPano 个全景点(⭐ 10-06 晚旨后每点 2 张拼合槽纹理)。
248
- * 同一份着色器同时服务普通 Mesh(墙/地板)与 InstancedMesh(家具立方体):
249
- * 实例化与否由 three.js 按对象类型自动注入 USE_INSTANCING 宏决定,无需外部传 instanced 开关。
250
- * 数组 uniform 在创建时即按 maxPano 长度初始化(占位纹理 + 单位矩阵 + 权重 0),运行时由渲染器每帧改写对应槽位。
251
- * ⚠️ 取图口径只有一条(皇上旨 2026-09-25 晚「都要一起改」→ 09-27 晚「取用距离最近的那个全景点的瓦片纹理」定版):
252
- * 地板 / 墙 / 家具**全部**按「水平距离最近的一张」取图(09-24~09-25 的夹角门已整链删除),
253
- * 不再有「地板按覆盖度融合」这条路,故也**没有** `pickNearest` 选项(旧的 0/1 分流 uniform 已删)。
254
- */
255
- export declare function createMktFloorBlendShaderMaterial(options?: MktFloorBlendOptions): ShaderMaterial;
256
290
  /**
257
- * 创建「仅显示选中全景」着色器材质(皇上旨 2026-09-29「三模式三 shader」之三):仅编辑器使用。
258
- * uniform 形状与 blend 工厂同档 ⇒ 喂料复用 feedBlendProjection 槽位指派路零改动;
259
- * 本 shader 只读 0 号点槽(GLSL 写死),uFloorCoreRadius/uFloorFadeRange 喂料会写但不读(静默忽略)。
291
+ * 多全景融合着色器:把「最近最多 maxPano 个全景」以数组传入,每个片元对这些张各自做等距柱状投射,
292
+ * 再取**唯一一条**口径(皇上旨 2026-09-25 晚「都要一起改」→ 2026-09-27 晚「取用距离最近的那个全景点的瓦片纹理」定版):
293
+ * - **候选里只取水平距离最近的那一张,满权重**(地板 / 墙 / 家具立方体**同一份、同一条**,
294
+ * 旧的「地板按覆盖度加权融合、交界渐隐」那一路已按旨删除 ⇒ 面上不再有渐隐带,也不再有中央露灰)。
295
+ * - ⚠️ 09-24~09-25 的「夹角门(点在面正面侧才合格)+ 全不合格兜底」已按 09-27 晚旨**整链删除**:
296
+ * 它是「明暗圆对、纹理不对」的第二来源(墙背后更近的点被门否掉,纹理落到正面侧的远槽);
297
+ * 纯距离口径下墙背后的全景也会投过来,皇上知情选定。
298
+ * - 候选池按「槽里有没有全景」筛,**不按覆盖半径(c>0)筛**,否则两个景点距离一远就变成
299
+ * 「各占一片互不相干的 territory、交界处整条露灰底」,距离判据压根轮不到(实测 bothCandidates=0)。
300
+ * - ⚠️ 代价(已向皇上报过并获批):`uFloorCoreRadius` / `uFloorFadeRange` 两根覆盖半径旋钮**从此对任何面都不参与取图**。
301
+ * 控件与 uniform 暂未删(未奉旨),删与不删等皇上下结论。
302
+ * - panoramas[i] : 第 i 张全景纹理
303
+ * - textureMatrices[i]: 第 i 张全景的世界矩阵(位置=投影中心,旋转=朝向)
304
+ * - weights[i] : 第 i 张是否活着(camW,0 = 该槽未绑全景 ⇒ 不进候选池)
260
305
  */
261
- export declare function createMktFloorSelectedShaderMaterial(options?: {
262
- maxPano?: number;
263
- }): ShaderMaterial;