three-base 2.0.188 → 2.0.192

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.
@@ -1,1368 +1,1437 @@
1
- /*
2
- * @Author: xiaosihan
3
- * @Date: 2026-08-25
4
- * @Description: MktMesh —— 墨卡托模型(普通 Mesh + 墨卡托投影全景 + 由点位挤出生成的墙体)。
5
- *
6
- * 定位:继承 THREE.Mesh(普通网格,不再用 InstancedMesh)。
7
- * 墙面几何由一组「地面折线点位」(wallPoints,XZ 平面)通过挤出生成:
8
- * - 墙体侧面:复用 three-base 的 ExtrudeMesh(shapePath = [[0,0],[0,墙高]] 沿点位挤出一道幕墙);
9
- * - 墙体上/下两个多边形封盖:用 three.js 自带的 ShapeGeometry 由点位三角化生成。
10
- * 墨卡托投影全景纹理(panorama)实时映射到墙面上:每帧按槽位名册(中心 ± 邻居)决定 shader 那 8 格装哪 8 张。
11
- *
12
- * 核心特性:
13
- * 1. 墙面 = 普通 Mesh 几何(世界坐标),MktMesh 自身保持单位变换;
14
- * 2. 纹理列表 textures:每个纹理含 url + 世界矩阵(位置=墨卡托投影中心,旋转=朝向);
15
- * 3. 默认每帧(onBeforeRender)按「离**名册锚点**最近的水平距离(XZ)」算出**槽位名册**(中心 ± 邻居,见
16
- * `panoSlotRoster.ts`;皇上旨 2026-10-03 起预览/发布与编辑器共用同一份算法 ⇒ 两边装同一批全景)。
17
- * ⭐ 皇上旨 2026-10-07 第二十一道旨:锚点 = 外部控制器的中心点(`setRosterCenter` 注入),没注入才回落相机位置;
18
- * 中心变化或名册里有空格被补上人时加载新瓦片 → 设置 shader 的 8 个全景槽 panorama + textureMatrix;
19
- * 4. exportData / importData 支持导入导出(墙点位 + 纹理数组 + ⭐ 10-26 旨起的平面图 / 模型两层)。
20
- * 5. ⚠️ 铁律:激活纹理(墙面上正在绘制的全景)的旋转/位置变化,必须立即同步到
21
- * shader 的 textureMatrix uniform(见 setTexture),否则平面纹理不跟着全景球转。
22
- *
23
- * 说明:所有实例共享同一份 material(单一 panorama uniform),
24
- * 同一时刻只激活名册 0 号槽(中心)那一个纹理(全局共享单激活方案)。
25
- */
26
- import {
27
- BufferAttribute,
28
- BufferGeometry,
29
- Camera,
30
- BackSide,
31
- Float32BufferAttribute,
32
- Material,
33
- Matrix4,
34
- Side,
35
- Mesh,
36
- Shape,
37
- ShapeGeometry,
38
- ShaderMaterial,
39
- SRGBColorSpace,
40
- Texture,
41
- Vector3,
42
- Vector4,
43
- } from "three";
44
- import type { WebGLRenderer } from "three";
45
- import { createPlaceholderTexture, isPanoTexMode, mktPanoTiledFlag, mktPanoTexUrls, isRtStitchKey, rtStitchKeyParts, RT_STITCH_KEY_PREFIX, RT_STITCH_SLOT_COUNT, type PanoTexMode } from "./mktShader";
46
- import { MKT_PANO_MAX_SLOTS } from "./mktMappingShader";
47
- import { createMktFloorMappingShaderMaterial } from "./mktMappingShader";
48
- // ⭐ 皇上旨 2026-10-06:拼合 = shader 逐格画进 WebGLRenderTarget,**一份实现**(canvas 2D 那一份连同
49
- // `?panoStitch=canvas` 开关整条删除)
50
- import { PanoTileStitcher, type PanoStitcher } from "./panoTileStitcher";
51
- import { panoSlotRosterStep, PANO_SLOT_SWAP_MARGIN_M } from "./panoSlotRoster";
52
- // ⭐ 甲(皇上旨 2026-10-05「你试试甲」):整图/瓦片的下载与解码一律走这里(解码在浏览器工作线程,
53
- // 不再让上传那一拍的 `texImage2D(<img>)` 替我们在主线程同步解 270MB)。编辑态同一份实现。
54
- import { loadPanoTexture, releasePanoTexture } from "./panoBitmapTexture";
55
- // ⭐ 皇上旨 2026-10-03「整套搬进库(彻底同源)」:「一个点槽的左右 2 张拼合槽纹理怎么换绑」只有这一份策略实现,
56
- // 编辑态 `mktMeshEditRenderer._bindSlotTiles` 与库侧都只「委托一行」+ 注入各自取图口(⛔ 不许有第二份)。
57
- import { panoSlotBind, panoSlotTileKeys } from "./panoSlotBind";
58
- import threeUtils from "../threeUtils";
59
- import ExtrudeMesh from "../ExtrudeMesh";
60
- import type { MktMeshData, MktTextureInfo, WallPoint, WallSide } from "./types";
61
-
62
- // ⭐ 皇上旨 2026-10-07「上下封口的三角面能不能不要那么长 / 三角面再细分一下」:封盖三角化之后
63
- // 再按**最长边二分**切细(算法本体在 `./wallCapTessellate`,零依赖纯函数 ⇒ 护栏能单独真跑)。
64
- import { subdivideCapTriangles, WALL_CAP_MAX_EDGE_M } from "./wallCapTessellate";
65
- // ⭐ 皇上旨 2026-10-08「json 里面的图片的地址,把域名去掉,只保留相对的根路径」:
66
- // 导出时剥域名(`exportData`)、导入时补回来(`importData`)——**各只有这一处**,⛔ 不许在别处另剥/另补。
67
- import { stripAssetHost, absAssetUrl } from "./assetUrl";
68
- // ⭐ 皇上旨 2026-10-26「按这个方案,把图片平面和模型一起搬进库里」:这两层(含每帧喂瓦片那条编排)
69
- // 原先各住在本站两个渲染器里一份 ⇒ 下游直接用包里的 `MktMesh` 时,存档里的 `images`/`models` 两键没人读。
70
- // 入库之后它们挂在 `MktMesh` **自己的子树**下,与墙同一棵变换 ⇒ 预览那三根 transform 滑杆搬得动整份展品。
71
- import { ImagePlane, type ImagePlaneData } from "./imagePlane";
72
- import { ModelSlot, type ModelSlotData } from "./modelSlot";
73
- import { ImagePlaneTileLoader } from "./imageTileLoader";
74
-
75
- export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
76
- /** 纹理列表(每个纹理的 url + 世界矩阵) */
77
- textures: MktTextureInfo[] = [];
78
- /** 当前激活(正在绘制)的纹理索引,-1 表示尚未激活。⭐ 10-03 起该值 = 槽位名册的**中心**(`_slotPanos[0]`),不是「每帧纯最近那张」 */
79
- activeIndex: number = -1;
80
-
81
- /** 墙体地面折线点位(XZ 平面,世界坐标) */
82
- wallPoints: WallPoint[] = [];
83
- /** 墙体高度(沿 +Y 挤出) */
84
- wallHeight: number = 3;
85
- /** 墙体折线是否闭合(首尾相连成环) */
86
- wallClosed: boolean = true;
87
- /** 墙体正反面显示(字段保留兼容存档;09-29 晚旨后无论何值材质一律 BackSide,见 _wallSideToThree) */
88
- wallSide: WallSide = "double";
89
- /** 是否跳过墙体上/下多边形封盖(true = 只留侧面幕墙)。⚠️ 2026-09-26 核实:本库与编辑态**都取默认 false**
90
- * (旧注释写「编辑态 MktDevMesh 设为 true」是错的 —— MktDevMesh 明确保留封盖,与发布态一致,
91
- * 见其类头注释)。封盖与侧面**同一份几何、同一份材质** ⇒ 正反面(material.side)天然同步,
92
- * 没有「封盖不跟墙体正反面」的第二套口径(这正是皇上 09-26 第①条要查的点)。 */
93
- skipWallCaps: boolean = false;
94
- /**
95
- * ⭐ 皇上旨 2026-10-07「上下封口的三角面能不能不要那么长 / 三角面再细分一下,不要那么大」:
96
- * 上/下封盖在耳切三角化之后,再按**最长边二分**切到每条边都 ≤ 本值(米)。粒度越细 ⇒
97
- * 封盖上「一个三角面 = 一张全景」的区块越小,序号映射 / 取图在天花板和地面上分得越准
98
- * (原先一个耳切大三角能横跨整个房间 ⇒ 整片天花板只能是同一张全景)。
99
- * ⛔ 0 / 负数 = 不细分(= 10-07 之前的形状)。
100
- * ⚠️ 改这个值会**改变面数** ⇒ 存档里的逐面序号按「块」继承(见 `importData` 那句 warn)。
101
- */
102
- wallCapMaxEdgeM: number = WALL_CAP_MAX_EDGE_M;
103
-
104
- /**
105
- * 全景「序号直接取」映射:每个三角面(faceIndex)→ 被指派的「全景全局序号」(-1=未设)。
106
- * 序号直接取由着色器读取 geometry 的 aPanoIndex;与 panoIndices 槽数组配合取对应全景纹理。
107
- * 长度 = 几何三角面数,随 rebuildWallGeometry 按位置重算(保留旧映射,见下方注释)。
108
- * 该数据随工程存档持久化(MktMeshData.panoFaceMapping),编辑器/预览/发布三端同源。
109
- */
110
- panoFaceMapping: number[] = [];
111
- /**
112
- * 全景点坐标数组(每全景 3 个数 = 中心点 xyz,世界坐标)。
113
- * ⭐ 2026-09-30 旨:全景点数据是**独立的纯几何数据**,与纹理图片是否加载/解码无关;
114
- * 智能序号只认这份,不认 textures。由 setTextures/addTexture/removeTexture/setTexture(矩阵变更) 派生(_syncPanoCenters)。
115
- * 本数组是「每全景 xyz」的**源**;序号映射最终烘焙成几何属性 `aPanoCenter`(每三角面9数,与 position 对齐),见 _syncPanoCenterAttr。
116
- * ⚠️⚠️ 2026-10-01 血案修复:本字段改为**读取时实时派生**(getter),不再依赖「谁记得调 _syncPanoCenters」。
117
- * 起因:`MktDevMesh.setTextures` 覆写了基类(编辑态不激活单全景),直接 `this.textures = textures` 而**不调
118
- * super** ⇒ 派生点整条被绕过,`panoCenters` 恒为空 ⇒ 映射模式下点「智能序号」在 centerCount===0 处静默 return,
119
- * 墙上全空且无任何提示。派生点式缓存在有子类覆写时就必然漏,**改为实时派生才是根治**(读一次 O(全景数),
120
- * 调用方只有智能序号与 _syncPanoCenterAttr,都不是每帧路径)。
121
- */
122
- get panoCenters(): number[] {
123
- this._syncPanoCenters();
124
- return this._panoCentersCache;
125
- }
126
- /** 派生结果的落点(勿直接读写,统一走 panoCenters getter / _syncPanoCenters) */
127
- private _panoCentersCache: number[] = [];
128
- /** 旧几何的「面中心 XZ→{y, 序号}」索引,用于改墙/改高后按位置保留全景序号映射(皇上旨 2026-09-28 决策②)。 */
129
- private _panoXZMap: Map<string, { y: number; idx: number }[]> = new Map();
130
-
131
- /**
132
- * ⭐ 槽位名册(皇上旨 2026-10-03「库侧也一并改成中心 ± 邻居」):每帧由 `updateTexture` 走
133
- * `panoSlotRosterStep` 算出,**0 号槽 = 中心**(⭐ 10-07 第二十一道旨:离**控制器中心点** XZ 最近那张,
134
- * 带 `PANO_SLOT_SWAP_MARGIN_M` 死区;锚点见 `_rosterCenter`),
135
- * 1..7 = 中心在全景列表里的序号一层层往外扩的邻居(⭐ 10-07 旨「尽可能填满 8 个全景位置」⇒ 越界的序号不占格、
136
- * 继续往有货那侧扩 ⇒ 填几格 = min(项目全景总数, 8),只有全景总数不够时才有空格)。
137
- * ⇒ 与编辑态 `mktMeshEditRenderer._slotPanos` **同一份算法、同一个常数**(预览看到的那几张 = 编辑器里的那几张,均为 8 个全景槽)。
138
- * 存**对象引用**不存下标(删点会让下标整体前移,引用不会认错人)。
139
- * 消费方 = `feedFaceMapping`(墙 8 格);⭐ 10-03 旨② 后预览立方体与墙**共用同一份材质**,
140
- * ⭐ 10-12 旨后立方体**逐颗自带序号**(`InstancedChild.panoIndex` → 逐实例 `aPanoIndex`),
141
- * 不再是从前那句「几何没这条 attribute ⇒ `vPanoIndex ≡ 0` ⇒ 全体看 0 号槽那张」;
142
- * 原来那条「把中心那几张抄给立方体单全景材质」的 `currentTiles` 旁路已整条删除。
143
- */
144
- private _slotPanos: (MktTextureInfo | undefined)[] = [];
145
- /**
146
- * ⭐ 名册那把尺子的**锚点**(皇上旨 2026-10-07 第二十一道旨:「判断使用哪些全景球的判断条件,
147
- * 从距离相机的距离改成判断距离控制器的中心点的距离」)。这里存外部控制器的中心点
148
- * (轨道控制器 = `controls.target`,也就是画面正中央那个注视点),由消费方经 `setRosterCenter()` 注入。
149
- * · 只读 XZ ⇒ 与名册历代那把「水平距离」尺同一把(09-28 旨),高度差不进尺子。
150
- * · `null` ⇒ 回落 `camera.position`(老口径):发布页那一路没有控制器引用,不注入就一切照旧。
151
- * · 存的是**引用**(OrbitControls 原地改它那个 Vector3)⇒ 消费方设一次即可,不必每帧重设。
152
- * · 注入的是**世界坐标**;`updateTexture` 里先 `worldToLocal` 换算到本网格局部空间再比距离
153
- * (全景矩阵与着色器取图的 `vProjPos` 都在局部空间,见那里的 2026-10-11 丙旨说明)。
154
- * ⛔ 反过来去给全景矩阵乘 `matrixWorld` 是错的:那会让 JS 侧的尺子和 shader 里的取图各走一路。
155
- */
156
- private _rosterCenter: Vector3 | null = null;
157
- /** 锚点换算到局部空间的落点(每帧复用同一只临时量,避免分配) */
158
- private _rosterAnchorLocal: Vector3 = new Vector3();
159
- /**
160
- * ⭐ 皇上旨 2026-10-03「整套搬进库(彻底同源)」:库侧不再自养「按全景序号取整槽、齐活才写」那一路,
161
- * 改成与编辑态**同一份换绑策略**(`./panoSlotBind`)+ 同一口径的三张表:
162
- * 地址→纹理缓存、已上传 GPU 集合、待上传排队集合。治的两个病:
163
- * ① 换景点闪一帧灰(库侧原先没有「未就绪整槽沿用上一批」这层兜底);
164
- * ② 图与投影矩阵来自**不同批**(原先矩阵无条件 copy,纹理却可能还在路上)。
165
- * 地址 → 已解码纹理;值为 `null` = 该地址正在加载(占位 ⇒ 同一地址不会被问两次就下两遍)。
166
- * ⚠️ 皇上旨 2026-10-02:运行中**不淘汰**任何一张,只在 `dispose()` 整批释放。
167
- */
168
- private _texCache: Map<string, Texture | null> = new Map();
169
- /** 已上传 GPU 的纹理:没进这张表的**不许**绑进 shader(首绑现场 `texImage2D` 就是那一下卡顿,皇上旨 09-29「两个大头别在同一帧」) */
170
- private _texUploaded: WeakSet<Texture> = new WeakSet();
171
- /** 已排进全局队列、等着「只干上传那一句」的纹理(`addWork` 按闭包判重判不出来 ⇒ 必须自备集合) */
172
- private _uploadQueued: Set<Texture> = new Set();
173
- /** 加载地址真的拼了 ossSize 缩放参数的条目 ⇒ 换档只重载这些(真瓦片一律原图,不进此集) */
174
- private _ossSizeDepKeys: Set<string> = new Set();
175
- /** 渲染器:由 `onBeforeRender` 每帧捕获,预上传走它的 `initTexture`;没捕获到就退回 three 的自绑路径(不报错) */
176
- private _glRenderer: WebGLRenderer | null = null;
177
- /** ⭐ 32 瓦片(横 8 × 竖 4 切片)离屏拼合器:左右 2 槽纹理由它逐格拼出(每帧一张新纹理进 GPU + 已进 GPU 的逐格画)。
178
- * 编辑器渲染器经 `panoStitcher` 复用同一实例(同一份槽纹理缓存,⛔ 不另建第二份显存)。
179
- * 皇上旨 2026-10-06「不用 canvas 2D 的 drawImage 拼合, 用一个 shader 一个一个渲染到 RT 上面去」
180
- * +「canvas 是什么意思, 按照我的想法, 不应该有 canvas 才对啊」⇒ **只此一份实现**:旧的 canvas 2D
181
- * 那一份连同 `?panoStitch=canvas` 对照开关整条删除。⚠️ RT 是 GL 上下文私有资源 ⇒ 见拼合器 `_switchRenderer`。 */
182
- private _stitcher: PanoStitcher = new PanoTileStitcher();
183
- /** 拼合器只读访问(编辑器渲染器共用;⛔ 不另建第二实例) */
184
- get panoStitcher(): PanoStitcher {
185
- return this._stitcher;
186
- }
187
- /** 融合材质未绑纹理时的占位(避免空纹理采样报错;深灰兜底由着色器 uFaceMissingColor 负责) */
188
- protected _placeholder!: Texture;
189
- // 纹理下载/解码走 `loadPanoTexture`(甲,2026-10-05),结果按**地址**进 `_texCache`,运行中不淘汰、只随 `dispose()` 释放。
190
- // ⛔ 别换回 `TextureLoader`:它只等 `img.onload`(不解码),解码会被拽到上传那一拍的 `texImage2D(<img>)` 上同步做完。
191
- /** 纹理 OSS 缩放尺寸(1024 / 2048 / 空=原图),由外部 setOssSize 注入;默认 1024 保持旧行为。
192
- * 编辑器渲染器与预览渲染器各自把 store.ossSize 推下来,使全景球/预览按该尺寸加载。 */
193
- private _ossSize: "1024" | "2048" | "" = "1024";
194
- /** 全景纹理形态:tiled=左右 2 槽各装一张**拼合槽纹理**(该点 32 片 横 8 × 竖 4 → 2 张 4096×4096,10-06 晚旨)
195
- * / whole=2 槽同为一张完整等距柱状原图。
196
- * 由外部 setPanoTexMode 注入(与 _ossSize 同一套注入路径),默认 tiled 保持改造前行为。 */
197
- private _panoTexMode: PanoTexMode = "tiled";
198
- /** 当前全景纹理形态(只读)。消费方(预览立方体等自持材质的路径)据此算 `mktPanoTiledFlag`,
199
- * 必须与基类喂纹理处用同一个值,否则标记与地址不同源 ⇒ 整图被按 1/2 横向铺满球面(10-06 晚起纵向不分块,四槽时代是 1/4)。 */
200
- get panoTexMode(): PanoTexMode {
201
- return this._panoTexMode;
202
- }
203
-
204
- // 复用临时对象(避免每帧 new,降低 GC 压力)
205
- private _tmpV3: Vector3 = new Vector3();
206
-
207
- /**
208
- * 图片平面层(⭐ 10-26 旨入库):对象挂在**本 MktMesh 的子树**里,⛔ 不是场景根
209
- * ⇒ 移动/旋转/缩放这个展品时,墙 + 全景 + 平面图 + 模型一起走。
210
- */
211
- imagePlanes: ImagePlane[] = [];
212
- /** 模型槽层(同一棵子树,口径同上) */
213
- models: ModelSlot[] = [];
214
- /**
215
- * 那 4 张瓦片的下载/解码缓存。⭐ 与全景 `_texCache` 同一条旨(10-02「纹理都缓存在对象里面,执行 dispose 时才销毁」)
216
- * ⇒ 切工程**不**清它,回来那一圈直接命中缓存、不会再空一格;只随本对象 `dispose()` 释放。
217
- */
218
- private _imgTileLoader = new ImagePlaneTileLoader();
219
- /**
220
- * 逐帧绑定用的两张账(2026-10-26 定的方案:地址一次性全部丢给下载,绑材质改成「一帧一颗平面」)。
221
- * 全局 rAF 每拍只跑一项 `addWork`,而 `addWork` 按闭包引用判重 ⇒ 每次现造的新闭包它判不出来,
222
- * ⛔ 去重闸门必须像全景上传那张 `_uploadQueued` 一样自备。
223
- */
224
- private _imgBindQueued = new Set<ImagePlane>();
225
- /** 平面 → 「它 4 条地址全部落定(就绪或失败)时的那串地址」⇒ 地址不换就不再重排;上传换图会改这串 ⇒ 自动重排 */
226
- private _imgBindDone = new Map<ImagePlane, string>();
227
- /**
228
- * 模型槽「GLB 已取回、等这一帧把它挂进父元素」的在册表 —— 与上面图片绑定同一手法(用户 10-26 需求:
229
- * 一次性全部拿去加载,用 `threeUtils.addWork` 逐帧加到场景里)。整层十几个槽同时到货时,
230
- * 一帧只有一颗进 `_contentRoot`(进图那一拍要重算包围盒 + 排布 + 首次把几何送上 GPU)。
231
- */
232
- private _modelMountQueued = new Set<ModelSlot>();
233
-
234
- constructor(opts: { wallHeight?: number; wallClosed?: boolean } = {}) {
235
- // 发布/预览态墙:**固定**用「全景序号直取」着色器(皇上旨 2026-09-29:mktmesh 就只有按照 attribute
236
- // 配置来显示全景这一种固定的 shader——每帧由 feedFaceMapping 按 panoFaceMapping 把被引用的全景
237
- // 喂进 8 个全景槽,按三角面 aPanoIndex 取对应纹理;原 blend+uFaceMappingMode 运行时分支已拆出独立 shader)。
238
- const material = createMktFloorMappingShaderMaterial({ maxPano: MKT_PANO_MAX_SLOTS });
239
- const placeholder = createPlaceholderTexture();
240
- super(new BufferGeometry(), material);
241
- this._placeholder = placeholder;
242
- // ⭐ 2026-09-26 彻查「正反面」:材质 face 朝向**只有一个写入口** = setWallSide()(内部走 _wallSideToThree)。
243
- // 这里过去写死 `material.side = DoubleSide`,与那条映射并列出第二份「谁决定正面」的写法;
244
- // 改成构造末尾用自身字段回灌:值仍是默认 "double" ⇒ **当时**画面逐字不变,但侧面上不再有两处口径。
245
- // ⚠️ 那句「画面逐字不变」只在那一天成立:09-29 晚旨之后 `_wallSideToThree` 三档全落 BackSide ⇒
246
- // 这一行回灌落地的**就是**反面(不是双面)。皇上旨 2026-10-07「mktmesh 的 shader 和 mktdevmesh 的映射 shader 保持一致」
247
- // 之后预览也删了自己那句手写 DoubleSide 覆盖 ⇒ 「朝向只有一个写入口」这条对库侧/编辑态/预览三处同时成立。
248
- // (必须在 super() 之后调用 —— this.wallSide 是类字段,早于 super() 读不到。)
249
- this.setWallSide(this.wallSide);
250
- // 保险:禁止视锥剔除,避免 boundingSphere 边界问题导致 GPU 跳过绘制
251
- this.frustumCulled = false;
252
- if (opts.wallHeight !== undefined) this.wallHeight = opts.wallHeight;
253
- if (opts.wallClosed !== undefined) this.wallClosed = opts.wallClosed;
254
- // 每帧渲染前:① 同步「投影空间逆矩阵」(让墙的投影与 MktMesh 整体变换解耦);
255
- // ② 按**名册锚点**(⭐ 10-07 第二十一道旨:控制器中心点,未注入时才是相机位置)的 XZ 距离算**槽位名册**(中心 ± 邻居),中心换人时更新 `activeIndex`;
256
- // ③ 发布/预览态墙按全景序号直接取喂食,8 格 = 名册那 8 张(皇上旨 2026-09-28 决策③ + 10-03「库侧同口径」)
257
- this.onBeforeRender = (renderer, _scene, camera) => {
258
- this._glRenderer = renderer;
259
- this.syncProjInvMatrix();
260
- this.updateTexture(camera as Camera);
261
- this.feedFaceMapping();
262
- };
263
- }
264
-
265
- // ───────────────────────── 墙体几何(由点位挤出) ─────────────────────────
266
-
267
- /**
268
- * 设置墙体地面折线点位,并重建墙体几何。
269
- * @param points 地面折线点位(XZ 平面,世界坐标),至少 2 个点才能生成墙体
270
- * @param opts.closed 是否闭合(首尾相连成环),默认沿用当前 wallClosed
271
- * @param opts.height 墙高(沿 +Y 挤出),默认沿用当前 wallHeight
272
- */
273
- setWallPoints(points: WallPoint[], opts: { closed?: boolean; height?: number } = {}): void {
274
- // ⚠️ 必须保留每个点的 y(离地高度),否则「墙点 Y 高度可调」会丢值
275
- this.wallPoints = points.map((p) => ({ x: p.x, y: p.y ?? 0, z: p.z }));
276
- if (opts.closed !== undefined) this.wallClosed = opts.closed;
277
- if (opts.height !== undefined) this.wallHeight = opts.height;
278
- this.rebuildWallGeometry();
279
- }
280
-
281
- /** 读取墙点位(副本,含 y,调用方可自行修改再回设) */
282
- getWallPoints(): WallPoint[] {
283
- return this.wallPoints.map((p) => ({ x: p.x, y: p.y, z: p.z }));
284
- }
285
-
286
- /**
287
- * 墙正面/反面枚举 → three.js Side 常量。
288
- * ⭐ 2026-09-29 晚旨「shader 设反面显示、不要双面」:三档**全部**落到 BackSide(对应历史上
289
- * 的 wallSide="back" 档 —— 零厚度幕墙只画**朝房间内的那一面**,从房间外看穿过去、看不到内部全景)。
290
- * API 形参与 wallSide 字段保留(存档/调用方兼容)。⚠️ 地板/立方体**不由这里控制**:编辑态地板恒走「仅显示选中」工厂(DoubleSide);
291
- * 立方体在 mapping 档与墙**共用同一份材质实例**(皇上旨 2026-10-02),⇒ 朝向跟着墙 = BackSide,
292
- * 另两档(blend / selected)另建一份 mapping 并显式拉回 DoubleSide(见渲染器 `_applyDisplayMode`;预览自 10-07 旨起与编辑态同口径)。
293
- * ⚠️ 若实测房间内反而看不到墙(几何法线朝向与预期相反),把这里的 BackSide 换成 FrontSide 即可(一行)。
294
- */
295
- private _wallSideToThree(_side: WallSide): Side {
296
- return BackSide;
297
- }
298
-
299
- /** 设置墙体正反面显示(**材质 side 的唯一写入口**;参数保留兼容,当前无论传什么都渲染**反面**,见 _wallSideToThree) */
300
- setWallSide(side: WallSide): void {
301
- this.wallSide = side;
302
- (this.material as Material).side = this._wallSideToThree(side);
303
- }
304
-
305
- /** 由 wallPoints / wallHeight / wallClosed 重建墙体几何:侧面(ExtrudeMesh) + 上下封盖(ShapeGeometry) */
306
- private rebuildWallGeometry(): void {
307
- const pts = this.wallPoints;
308
- const height = this.wallHeight;
309
- const positions: number[] = [];
310
-
311
- // ── 全景序号映射:改墙/改高后按位置保留(皇上旨 2026-09-28 决策②)──
312
- // 捕获旧几何面中心 + 旧映射(this.geometry 此刻仍是旧几何,尚未 dispose)。
313
- // ⭐ 10-07 封盖细分之后:新面中心**多半不再落在旧面中心那几个 XZ 上**(细分是在三角形内部加了一堆点)
314
- // ⇒ 只认「XZ 键精确相等」的话,封盖那一整片序号会被丢光 ⇒ 这里多留一份**旧三角形的 XZ 顶点**,
315
- // 新面中心改用「它落在哪个旧三角形里」来认祖(点在三角形内),认不到才退回精确键那一路。
316
- const oldPosAttr = this.geometry.getAttribute("position") as BufferAttribute | undefined;
317
- const oldFaceCount = oldPosAttr ? Math.floor(oldPosAttr.count / 3) : 0;
318
- const oldCentroids: { x: number; y: number; z: number }[] = new Array(oldFaceCount);
319
- const oldTris: { ax: number; az: number; bx: number; bz: number; cx: number; cz: number; y: number; idx: number }[] = [];
320
- const oldMapping = this.panoFaceMapping;
321
- for (let f = 0; f < oldFaceCount; f++) {
322
- const a = 3 * f, b = 3 * f + 1, c = 3 * f + 2;
323
- const ax = oldPosAttr!.getX(a), ay = oldPosAttr!.getY(a), az = oldPosAttr!.getZ(a);
324
- const bx = oldPosAttr!.getX(b), by = oldPosAttr!.getY(b), bz = oldPosAttr!.getZ(b);
325
- const cx = oldPosAttr!.getX(c), cy = oldPosAttr!.getY(c), cz = oldPosAttr!.getZ(c);
326
- oldCentroids[f] = { x: (ax + bx + cx) / 3, y: (ay + by + cy) / 3, z: (az + bz + cz) / 3 };
327
- oldTris.push({ ax, az, bx, bz, cx, cz, y: (ay + by + cy) / 3, idx: oldMapping[f] ?? -1 });
328
- }
329
-
330
- if (pts.length >= 2) {
331
- // —— 墙体侧面:复用 ExtrudeMesh,shapePath=[[0,0],[0,height]] 沿地面折线挤出一道幕墙 ——
332
- // 路径点的 vector3[1] = p.y,即墙基离地高度;ExtrudeMesh 把整个截面抬到该高度再向上挤出 height,
333
- // 故底面落在 p.y、顶面落在 p.y + height(支点位高低错落 / 阶梯墙基)。
334
- const paths = pts.map((p, i) => ({
335
- vector3: [p.x, p.y, p.z] as [number, number, number],
336
- radius: 0,
337
- segments: 0,
338
- scale: { x: 1, y: 1 },
339
- name: `w${i}`,
340
- }));
341
- const tmp = new ExtrudeMesh(
342
- { paths, shapePath: [[0, 0], [0, height]], closed: this.wallClosed },
343
- undefined as unknown as Material
344
- );
345
- const sidePos = tmp.geometry.getAttribute("position");
346
- for (let i = 0; i < sidePos.count; i++) {
347
- positions.push(sidePos.getX(i), sidePos.getY(i), sidePos.getZ(i));
348
- }
349
- tmp.geometry.dispose();
350
-
351
- // —— 墙体上/下两个多边形封盖:用 three.js 自带的 ShapeGeometry 由点位三角化 ——
352
- // ⚠️ 2026-09-26 更正:此前这句注释写的是「编辑态只要幕墙、不要封顶」——**与事实相反**,勿再照它理解。
353
- // `skipWallCaps` 在库侧与编辑态 `MktDevMesh` 都保持默认 false —— 封盖与侧面**推进同一份 geometry、
354
- // 共用同一份 material**(见下方 positions.push 与 `this.geometry = geo`),故「墙体正反面」参数一改,
355
- // 封盖与侧面同帧跟着翻,不存在第二处需要同步的 side。本开关只留给「只要幕墙不要封顶」的外部调用方,
356
- // 本项目内**无人置 true**。
357
- if (!this.skipWallCaps) {
358
- // Shape 用 2D 坐标(x, z);rotateX(90°) 把 (x,y,0)→(x,0,y),故 shape 的 (px,pz) 落回世界 (px,0,pz),与侧面点位一致。
359
- // 每个封盖顶点按 (x,z) 反查对应点位的 y:下封盖 y=p.y,上封盖 y=p.y+height,使封盖随墙基高低错落。
360
- const shape = new Shape();
361
- pts.forEach((p, i) => {
362
- if (i === 0) shape.moveTo(p.x, p.z);
363
- else shape.lineTo(p.x, p.z);
364
- });
365
- if (this.wallClosed) shape.closePath();
366
- const cap = new ShapeGeometry(shape);
367
- cap.rotateX(Math.PI / 2);
368
- const capNI = cap.toNonIndexed();
369
- const capPos = capNI.getAttribute("position");
370
- // 坐标→Y 映射:**三角化后的顶点都是多边形点的副本**(没有插值点)⇒ 可按 (x,z) 精确落回点位高度。
371
- // ⚠️ 细分切出来的**内部点**不在多边形点的位置上 ⇒ 这张表查不到(会打成 0 ⇒ 封盖塌下去),
372
- // 它们的 y 由下面的细分函数按两端平均插值(落在原平面上)。
373
- const yByXZ = new Map<string, number>();
374
- for (const p of pts) yByXZ.set(`${p.x},${p.z}`, p.y);
375
- const yOf = (x: number, z: number): number => {
376
- const v = yByXZ.get(`${x},${z}`);
377
- return v === undefined ? 0 : v;
378
- };
379
- // ⭐ 10-07 旨「封盖三角面不要那么长 / 再细分一下」:先把封盖三角化结果摊成扁平三角形
380
- // (每个顶点带上它那处墙基的 y),再按**最长边二分**切到每条边都不长于 `wallCapMaxEdgeM`
381
- // (0 / 负数 = 不细分)。细分只在封盖平面内加顶点 ⇒ **形状一点不变,只是面变小**。
382
- const rawCap: number[] = [];
383
- for (let i = 0; i < capPos.count; i++) {
384
- const x = capPos.getX(i);
385
- const z = capPos.getZ(i);
386
- rawCap.push(x, yOf(x, z), z);
387
- }
388
- const capTris = subdivideCapTriangles(rawCap, this.wallCapMaxEdgeM);
389
- // 下封盖(level=0)→ y=p.y;上封盖(level=height)→ y=p.y+height
390
- for (let lv = 0; lv <= 1; lv++) {
391
- const base = lv === 0 ? 0 : height;
392
- if (lv === 0) {
393
- // 下封盖:顶点顺序与 shape 一致,法线朝下(指向地面外侧)
394
- for (let i = 0; i < capTris.length; i += 9) {
395
- positions.push(capTris[i], capTris[i + 1] + base, capTris[i + 2]);
396
- positions.push(capTris[i + 3], capTris[i + 4] + base, capTris[i + 5]);
397
- positions.push(capTris[i + 6], capTris[i + 7] + base, capTris[i + 8]);
398
- }
399
- } else {
400
- // 上封盖:每个三角形的**顶点顺序倒过来** ⇒ 绕序翻转 ⇒ 法线由朝下翻为朝上(朝天/朝外,
401
- // 与「盒子封盖朝外」一致;单面(front/back)模式下从外部俯视可见、内部仰视不可见)。
402
- for (let i = 0; i < capTris.length; i += 9) {
403
- positions.push(capTris[i + 6], capTris[i + 7] + base, capTris[i + 8]);
404
- positions.push(capTris[i + 3], capTris[i + 4] + base, capTris[i + 5]);
405
- positions.push(capTris[i], capTris[i + 1] + base, capTris[i + 2]);
406
- }
407
- }
408
- }
409
- capNI.dispose();
410
- cap.dispose();
411
- }
412
- }
413
-
414
- const geo = new BufferGeometry();
415
- geo.setAttribute("position", new Float32BufferAttribute(positions, 3));
416
- // ⭐ 必须算法线(皇上旨 2026-09-24「模型面上映射要先判全景点方向与面法线夹角 <90°」):
417
- // 本几何是非索引三角形汤(侧面 + 上下封盖),computeVertexNormals 因此给出**逐面**法线,
418
- // 正好就是「面的法线」。缺它着色器读到 (0,0,0) ⇒ 点积恒 0 ⇒ 夹角门永远筛不到候选(会走兜底,不崩但白改)。
419
- geo.computeVertexNormals();
420
-
421
- // ── 按位置重算全景序号映射(保留旧指派,决策②)──
422
- const faceCount = Math.floor(positions.length / 9);
423
- const newMapping: number[] = new Array(faceCount).fill(-1);
424
- const rk = (v: number): number => Math.round(v * 1000) / 1000; // 1mm 量化做 XZ 键
425
- // 旧 XZ 索引:同一 XZ 可能对应上下两个封盖(y 不同),故一个键挂一个 {y,idx} 列表
426
- const oldMap = new Map<string, { y: number; idx: number }[]>();
427
- for (let f = 0; f < oldFaceCount; f++) {
428
- const c = oldCentroids[f];
429
- const key = `${rk(c.x)},${rk(c.z)}`;
430
- const arr = oldMap.get(key) ?? [];
431
- arr.push({ y: c.y, idx: oldMapping[f] ?? -1 });
432
- oldMap.set(key, arr);
433
- }
434
- // ⭐ 10-07 封盖细分后**新加的一层**:新面中心「落在哪个旧三角形里」就继承那一片的序号。
435
- // 只在上面那道精确 XZ 键**没命中**时才走(命中 = 老口径,一个字没改)。
436
- // ⚠️ 非加不可的理由:封盖被切成几百上千个小面后,新面中心几乎不可能正好等于某个旧面中心
437
- // ⇒ 没有这一层,改墙高 / 改墙点之后封盖的序号会整片变 -1(得重跑一次「智能序号」才看得见图)。
438
- const insideBudget = oldTris.length * faceCount <= 4000000; // 兜底闸:面数大到离谱就跳过这层,别把改墙那一下卡死
439
- const side2 = (ax: number, az: number, bx: number, bz: number, px: number, pz: number): number =>
440
- (bx - ax) * (pz - az) - (bz - az) * (px - ax);
441
- /** 点在 XZ 平面上的三角形内(叉积同号)判据:命中多个(上下封盖同 XZ)时取 y 最近那一片 */
442
- const pickInside = (px: number, pz: number, cy: number): number => {
443
- let best = -1;
444
- let bestD = Infinity;
445
- for (let i = 0; i < oldTris.length; i++) {
446
- const t = oldTris[i];
447
- const d1 = side2(t.ax, t.az, t.bx, t.bz, px, pz);
448
- const d2 = side2(t.bx, t.bz, t.cx, t.cz, px, pz);
449
- const d3 = side2(t.cx, t.cz, t.ax, t.az, px, pz);
450
- // 三个叉积里有正有负 ⇒ 点在三角形外(全 0 = 退化成一条线的面,也算命中,交给 y 差挑)
451
- if ((d1 > 0 || d2 > 0 || d3 > 0) && (d1 < 0 || d2 < 0 || d3 < 0)) continue;
452
- const d = Math.abs(t.y - cy);
453
- if (d < bestD) { bestD = d; best = t.idx; }
454
- }
455
- return best;
456
- };
457
- for (let f = 0; f < faceCount; f++) {
458
- const a = 9 * f;
459
- const cx = (positions[a] + positions[a + 3] + positions[a + 6]) / 3;
460
- const cz = (positions[a + 2] + positions[a + 5] + positions[a + 8]) / 3;
461
- const cy = (positions[a + 1] + positions[a + 4] + positions[a + 7]) / 3;
462
- const cand = oldMap.get(`${rk(cx)},${rk(cz)}`);
463
- if (cand && cand.length) {
464
- // 同 XZ 多个候选(上/下封盖)时取 y 最近者,避免把底盖映射错配到顶盖
465
- let best = cand[0];
466
- let bestD = Math.abs(best.y - cy);
467
- for (let i = 1; i < cand.length; i++) {
468
- const d = Math.abs(cand[i].y - cy);
469
- if (d < bestD) { bestD = d; best = cand[i]; }
470
- }
471
- newMapping[f] = best.idx;
472
- } else if (insideBudget && oldTris.length) {
473
- const v = pickInside(cx, cz, cy);
474
- if (v >= 0) newMapping[f] = v; // -1 = 那一片本来就没指派,保持未指派
475
- }
476
- }
477
- this.panoFaceMapping = newMapping;
478
- // 重建 XZ 索引(供下次改墙保留)
479
- const xzMap = new Map<string, { y: number; idx: number }[]>();
480
- for (let f = 0; f < faceCount; f++) {
481
- const a = 9 * f;
482
- const cx = (positions[a] + positions[a + 3] + positions[a + 6]) / 3;
483
- const cz = (positions[a + 2] + positions[a + 5] + positions[a + 8]) / 3;
484
- const cy = (positions[a + 1] + positions[a + 4] + positions[a + 7]) / 3;
485
- const key = `${rk(cx)},${rk(cz)}`;
486
- const arr = xzMap.get(key) ?? [];
487
- arr.push({ y: cy, idx: newMapping[f] });
488
- xzMap.set(key, arr);
489
- }
490
- this._panoXZMap = xzMap;
491
- // aPanoIndex 几何属性(每三角面 3 顶点同值 = 该面归属的全景全局序号;默认 -1 = 未设)
492
- const panoIdxAttr = new Float32Array(faceCount * 3);
493
- for (let f = 0; f < faceCount; f++) {
494
- const v = newMapping[f] ?? -1;
495
- panoIdxAttr[3 * f] = v;
496
- panoIdxAttr[3 * f + 1] = v;
497
- panoIdxAttr[3 * f + 2] = v;
498
- }
499
- geo.setAttribute("aPanoIndex", new Float32BufferAttribute(panoIdxAttr, 1));
500
-
501
- this.geometry.dispose();
502
- this.geometry = geo;
503
- this._syncPanoCenterAttr(); // ⭐ 2026-09-30:同步 aPanoCenter(每三角面9数,与 position 对齐)
504
- }
505
-
506
- /**
507
- * 设置某个三角面(faceIndex)归属的全景全局序号;-1=清除(序号模式下显示深灰兜底色)。
508
- * 更新映射数组 + 几何 aPanoIndex 属性(仅该面 3 顶点),并同步 XZ 索引供后续改墙保留。
509
- */
510
- setFacePanoIndex(faceIndex: number, globalIndex: number): void {
511
- if (faceIndex < 0 || faceIndex >= this.panoFaceMapping.length) return;
512
- this.panoFaceMapping[faceIndex] = globalIndex;
513
- const attr = this.geometry.getAttribute("aPanoIndex") as Float32BufferAttribute | undefined;
514
- if (attr && attr.count === this.panoFaceMapping.length * 3) {
515
- attr.setXYZ(faceIndex * 3, globalIndex, globalIndex, globalIndex);
516
- attr.needsUpdate = true;
517
- // ⭐ 2026-09-30:同步该面 aPanoCenter(每三角面9数,与 position 对齐)
518
- const cAttr = this.geometry.getAttribute("aPanoCenter") as Float32BufferAttribute | undefined;
519
- if (cAttr && cAttr.count === this.panoFaceMapping.length * 3) {
520
- const nPano = this.panoCenters.length / 3;
521
- let cx = 0, cy = 0, cz = 0;
522
- if (globalIndex >= 0 && globalIndex < nPano) {
523
- cx = this.panoCenters[globalIndex * 3];
524
- cy = this.panoCenters[globalIndex * 3 + 1];
525
- cz = this.panoCenters[globalIndex * 3 + 2];
526
- }
527
- for (let v = 0; v < 3; v++) cAttr.setXYZ(faceIndex * 3 + v, cx, cy, cz);
528
- cAttr.needsUpdate = true;
529
- }
530
- } else {
531
- // 属性缺失/长度不符(极少):整面重建(按位置保留会把它当成 -1,但此处是显式指定,可接受)
532
- this.rebuildWallGeometry();
533
- }
534
- this._rebuildXZMap();
535
- }
536
-
537
- /** 重建 XZ 索引(映射变化后调用,保证下次改墙按位置保留) */
538
- private _rebuildXZMap(): void {
539
- const pos = this.geometry.getAttribute("position") as BufferAttribute | undefined;
540
- const faceCount = pos ? Math.floor(pos.count / 3) : 0;
541
- const rk = (v: number): number => Math.round(v * 1000) / 1000;
542
- const m = new Map<string, { y: number; idx: number }[]>();
543
- for (let f = 0; f < faceCount; f++) {
544
- const a = 9 * f;
545
- const cx = (pos!.getX(a) + pos!.getX(a + 3) + pos!.getX(a + 6)) / 3;
546
- const cz = (pos!.getZ(a) + pos!.getZ(a + 3) + pos!.getZ(a + 8)) / 3;
547
- const cy = (pos!.getY(a) + pos!.getY(a + 3) + pos!.getY(a + 7)) / 3;
548
- const key = `${rk(cx)},${rk(cz)}`;
549
- const arr = m.get(key) ?? [];
550
- arr.push({ y: cy, idx: this.panoFaceMapping[f] ?? -1 });
551
- m.set(key, arr);
552
- }
553
- this._panoXZMap = m;
554
- }
555
-
556
- /** 三角面总数(= 几何顶点数 / 3,因非索引三角形汤) */
557
- getFaceCount(): number {
558
- const pos = this.geometry.getAttribute("position") as BufferAttribute | undefined;
559
- return pos ? Math.floor(pos.count / 3) : 0;
560
- }
561
-
562
- /**
563
- * 返回被三角面引用的全景全局序号集合(去重),按引用面数降序、最多 maxSlots 个。
564
- * ⚠️ 2026-10-03 起**不在喂料路上**(墙那 8 格改由槽位名册决定,见 `feedFaceMapping`);保留为公共查询 API
565
- * (外部若要按「哪几张全景被指派得最多」统计仍可用),⛔ 不许再拿它决定 shader 装哪几张。
566
- */
567
- getReferencedPanoIndices(maxSlots: number): number[] {
568
- const cnt = new Map<number, number>();
569
- for (const idx of this.panoFaceMapping) {
570
- if (idx >= 0) cnt.set(idx, (cnt.get(idx) ?? 0) + 1);
571
- }
572
- const sorted = [...cnt.entries()].sort((a, b) => b[1] - a[1]).map((e) => e[0]);
573
- return sorted.slice(0, maxSlots);
574
- }
575
-
576
- /** 仅重建 aPanoIndex 几何属性(映射数据变化、几何未变时调用,避免整面重建) */
577
- rebuildPanoIndexAttribute(): void {
578
- const faceCount = this.panoFaceMapping.length;
579
- const attr = this.geometry.getAttribute("aPanoIndex") as Float32BufferAttribute | undefined;
580
- if (attr && attr.count === faceCount * 3) {
581
- for (let f = 0; f < faceCount; f++) {
582
- const v = this.panoFaceMapping[f] ?? -1;
583
- attr.setXYZ(f * 3, v, v, v);
584
- }
585
- attr.needsUpdate = true;
586
- } else {
587
- this.rebuildWallGeometry();
588
- }
589
- this._rebuildXZMap();
590
- this._syncPanoCenterAttr(); // ⭐ 2026-09-30:同步 aPanoCenter(每三角面9数,与 position 对齐)
591
- }
592
-
593
- // ───────────────────────── 纹理列表管理 ─────────────────────────
594
-
595
- /** 从 textures 各矩阵平移分量(elements[12,13,14],即墨卡托投影中心)派生 panoCenters(每全景 xyz)。
596
- * 全景点位置与纹理图片加载解耦,纯几何;智能序号只依赖这份数据。 */
597
- protected _syncPanoCenters(): void {
598
- const out: number[] = new Array(this.textures.length * 3);
599
- for (let i = 0; i < this.textures.length; i++) {
600
- const e = this.textures[i].matrix.elements;
601
- out[i * 3] = e[12];
602
- out[i * 3 + 1] = e[13];
603
- out[i * 3 + 2] = e[14];
604
- }
605
- // ⚠️ 写缓存字段而非 this.panoCenters —— 后者是 getter,会递归回本方法
606
- this._panoCentersCache = out;
607
- }
608
-
609
- /**
610
- * 写 aPanoCenter 几何属性:每三角面 9 数(该面归属全景的中心 xyz,复制到 3 顶点),
611
- * itemSize=3、count = position.count,与 position **完全对齐**
612
- * (皇上旨 2026-09-30 第③点:「每个三角面 9 个数一组,全景也按 9 数一组、和顶点数组长度对齐」)。
613
- * 源 = panoCenters(每全景 xyz);面未映射(gi<0 或越界)填 0。
614
- * rebuildWallGeometry / rebuildPanoIndexAttribute / setFacePanoIndex 都会调它保持同步。
615
- */
616
- private _syncPanoCenterAttr(): void {
617
- const faceCount = this.panoFaceMapping.length;
618
- const posAttr = this.geometry.getAttribute("position") as BufferAttribute | undefined;
619
- if (!posAttr || posAttr.count !== faceCount * 3) return; // 几何与映射不一致先跳过
620
- const centers = this.panoCenters;
621
- const nPano = centers.length / 3;
622
- let attr = this.geometry.getAttribute("aPanoCenter") as Float32BufferAttribute | undefined;
623
- if (!attr || attr.count !== faceCount * 3) {
624
- attr = new Float32BufferAttribute(new Float32Array(faceCount * 9), 3);
625
- this.geometry.setAttribute("aPanoCenter", attr);
626
- }
627
- const arr = attr.array as Float32Array;
628
- for (let f = 0; f < faceCount; f++) {
629
- const gi = this.panoFaceMapping[f] ?? -1;
630
- let cx = 0, cy = 0, cz = 0;
631
- if (gi >= 0 && gi < nPano) {
632
- cx = centers[gi * 3]; cy = centers[gi * 3 + 1]; cz = centers[gi * 3 + 2];
633
- }
634
- const a = 9 * f; // 每三角面 9 数,与 position 同布局
635
- for (let v = 0; v < 3; v++) {
636
- const o = a + v * 3;
637
- arr[o] = cx; arr[o + 1] = cy; arr[o + 2] = cz;
638
- }
639
- }
640
- attr.needsUpdate = true;
641
- }
642
-
643
- /** 设置纹理列表并激活第一个(或最近的)纹理 */
644
- setTextures(textures: MktTextureInfo[]): void {
645
- // ⭐ 皇上旨 2026-10-02「纹理都缓存在对象里面,执行 dispose 时才销毁」⇒ 换工程**不**清 `_texCache`
646
- // (旧项目的瓦片留到本对象 dispose 再释放;清它等于下次切回来重走一遍下载 + 解码 = 卡顿源头)。
647
- // 要清的只有**名册**:里面躺的是上一批 `MktTextureInfo` 对象引用,留着会让下一帧拿已删景点的 URL 去喂槽。
648
- this._slotPanos = [];
649
- this.textures = textures;
650
- this.activeIndex = -1;
651
- this._syncPanoCenters(); // ⭐ 2026-09-30:派生全景点坐标(与纹理解耦)
652
- if (this.textures.length > 0) {
653
- this.activateTexture(0);
654
- }
655
- }
656
-
657
- /** 追加一个纹理 */
658
- addTexture(info: MktTextureInfo): void {
659
- this.textures.push(info);
660
- this._syncPanoCenters();
661
- }
662
-
663
- /** 删除一个纹理;若删除的是当前激活纹理则回退,索引随之前移 */
664
- removeTexture(index: number): void {
665
- if (index < 0 || index >= this.textures.length) {
666
- return;
667
- }
668
- this.textures.splice(index, 1);
669
- this._syncPanoCenters();
670
- // ⭐ 10-03 旨⑤:缓存键是**地址**不是「全景序号」⇒ 删点导致序号整体前移**不需要**清缓存
671
- // (旧写法 `_invalidateTileCache()` 是因为按序号缓存、不清就把旧地址挂在新序号上);
672
- // 名册存对象引用 ⇒ 被删的那个对象下一帧自然从名册里掉出去(`panoSlotRosterStep` 只认 dists 里还有的人)。
673
- if (this.activeIndex === index) {
674
- this.activeIndex = -1;
675
- } else if (this.activeIndex > index) {
676
- this.activeIndex--;
677
- }
678
- }
679
-
680
- /**
681
- * 更新单个纹理的 url / 名称 / 矩阵;若改的是「当前激活纹理」则同步生效。
682
- *
683
- * ⚠️ 需求铁律(防止回归):墙面绘制的是「当前激活纹理」,其朝向由 shader 的 textureMatrix uniform 决定。
684
- * 所以只要激活纹理的位置/旋转变了,必须**立即**把新矩阵 copy 进 textureMatrix uniform——
685
- * 否则用户旋转/移动全景球时,平面纹理不会跟着一起转。
686
- * 另:改旋转不要触发纹理网络 reload,只有 url 真正变化才重载。
687
- */
688
- setTexture(index: number, opts: { url?: string; name?: string; matrix?: Matrix4; tiles?: string[] }): void {
689
- if (index < 0 || index >= this.textures.length) {
690
- return;
691
- }
692
- const cur = this.textures[index];
693
- if (opts.url !== undefined) {
694
- cur.url = opts.url;
695
- }
696
- // 名称只是标识,不参与渲染,改它**不**触发纹理 reload(见下方 opts.url 分支)
697
- if (opts.name !== undefined) {
698
- cur.name = opts.name;
699
- }
700
- if (opts.tiles !== undefined) {
701
- cur.tiles = opts.tiles;
702
- }
703
- if (opts.matrix !== undefined) {
704
- cur.matrix.copy(opts.matrix);
705
- this._syncPanoCenters(); // ⭐ 全景位置/旋转一变,全景点坐标同步派生
706
- }
707
- if (index === this.activeIndex) {
708
- // 关键:激活纹理的矩阵一变,立刻同步到 shader uniform(墙面纹理才会跟着转)。
709
- // ⚠️ 基类材质现改为融合形态(textureMatrices 数组),无单数 textureMatrix;
710
- // 单全景形态用 textureMatrix、融合形态用 textureMatrices[0],两者都接住。
711
- const tm = (this.material.uniforms.textureMatrix?.value ??
712
- this.material.uniforms.textureMatrices?.value?.[0]) as Matrix4 | undefined;
713
- if (tm) tm.copy(cur.matrix);
714
- // ⭐ 10-03 旨⑤:改地址/瓦片**不需要**在这里手工失效重载 —— 缓存按地址建键,新 URL 天然是新条目,
715
- // 下一帧 `feedFaceMapping` 见「账本里的地址 ≠ 目标地址」就走整槽换人(判不齐则沿用上一批),
716
- // 跨形态(32 片拼合 ↔ 整片)那一次由 binder 的整档判齐支负责。旧写法(清序号缓存 + 重新 activate)已整条删除。
717
- }
718
- }
719
-
720
- /**
721
- * 把 inverse(matrixWorld) 写进当前材质的 uProjInvMatrix(每帧一次)—— 「投影与自身变换解耦」的唯一写入点。
722
- *
723
- * ⚠️ 需求铁律(皇上旨 2026-09-19):「墙的矩阵和全景的矩阵是相对的,无论 MktMesh 模型整体
724
- * 怎么移动/旋转/缩放,墙上的纹理都保持不变」。
725
- * 达成条件是「取样方向的两个操作数都对 MktMesh 的整体变换免疫」:
726
- * · 全景点矩阵(textureMatrix)是**世界矩阵**、属独立于 MktMesh 的外部数据 → 本就不随它动;
727
- * · 墙顶点经 modelMatrix 升到世界坐标 → **会**随它动 → 一个动一个不动,方向即变,纹理在墙上滑动。
728
- * 故此处把 inverse(matrixWorld) 交给着色器,把墙顶点也拉回「与整体变换无关的几何局部坐标」,
729
- * 两个操作数双双免疫,方向恒定,纹理稳稳贴在墙上。
730
- * ⚠️ M ≠ I 时 vProjPos(局部)与 textureMatrix(世界)并不在同一空间 —— 这是**刻意**的:
731
- * 皇上要的是「纹理贴在墙上不动」,条件是「两个操作数都不随 M 变」,而非「同处一个空间」。
732
- * 千万别"好心"再去把 textureMatrix 也乘一遍 inverse(matrixWorld),那会让纹理重新动起来。
733
- *
734
- * 每帧无条件重算(一次 4x4 求逆,代价可忽略):用户拖预览变换面板时 matrixWorld 每帧都在变,
735
- * 靠「变更时通知」容易漏,无条件同步最稳。材质被替换(如编辑态 blend↔single 重建)时按当前材质重新取
736
- * uniform,故 **MktDevMesh 也必须每帧调用本方法**(它同样会重建材质,见该类 onBeforeRender 注释)。
737
- * 材质没有该 uniform(旧材质/第三方材质)时静默跳过 —— 退化为「世界坐标」的旧行为。
738
- */
739
- syncProjInvMatrix(): void {
740
- const mat = this.material as ShaderMaterial;
741
- const u = mat && mat.uniforms ? mat.uniforms.uProjInvMatrix : undefined;
742
- if (!u) return;
743
- (u.value as Matrix4).copy(this.matrixWorld).invert();
744
- }
745
-
746
- // ───────────────────────── 每帧最近纹理计算 ─────────────────────────
747
-
748
- /** 每帧算「离**名册锚点**最近」的水平距离(XZ)⇒ 走**槽位名册**(中心 ± 邻居)⇒ 名册中心变化时切换激活纹理。
749
- * ⭐ 皇上旨 2026-10-07 第二十一道旨:锚点 = 外部控制器的中心点(`setRosterCenter` 注入的轨道注视点);
750
- * 没注入(如发布页那一路)才回落相机位置。 */
751
- updateTexture(camera: Camera): void {
752
- const n = this.textures.length;
753
- if (n === 0) {
754
- this._slotPanos = [];
755
- this.activeIndex = -1;
756
- return;
757
- }
758
- // ⭐ 皇上旨 2026-10-11 丙:尺子的两端必须落在**同一个空间**。注入的锚点是世界坐标,而下面那把尺子量的
759
- // 全景位置记在本网格的**局部(制图)空间** —— 依据是着色器自己:`vProjPos = uProjInvMatrix·modelMatrix·pos`
760
- // = M⁻¹·M·pos = 局部(`uProjInvMatrix` 正是 `syncProjInvMatrix` 里那句 `matrixWorld` 求逆),片元再拿
761
- // `dir = vProjPos - tm[3].xyz` 取图 ⇒ 全景矩阵只可能和 `vProjPos` 同空间,即局部。
762
- // 所以换算放在**锚点这一端**(`worldToLocal`),⛔ 不去给全景矩阵乘 `matrixWorld`:那是把 JS 的尺子
763
- // 和 shader 的取图拆成两路。网格无变换时这一步是恒等 ⇒ 老项目逐字不变;有旋转时(消费方给 mkt 加了
764
- // `rotation.y`)原来会把「往世界的某一头走」量成「往局部的另一头走」,人走到位了名册还不动。
765
- const ref = this._rosterAnchorLocal.copy(this._rosterCenter ?? camera.position);
766
- this.worldToLocal(ref);
767
- const dists: { t: MktTextureInfo; d: number }[] = [];
768
- for (let i = 0; i < n; i++) {
769
- const e = this.textures[i].matrix.elements;
770
- this._tmpV3.set(e[12], e[13], e[14]);
771
- // ⚠️ 全景点矩阵记在本网格的局部空间(与 `wallPoints` 同一空间,见上面丙旨那段着色器推导),
772
- // 所以拿来直接和**已换算到局部**的 `ref` 比距离;⛔ 这里绝不乘 `matrixWorld`(双重变换 = 选错全景)。
773
- // ⭐ 09-28 09:33 旨:挑图与编辑器取图同一把「**水平距离**」尺(XZ 平面,不含高度差)。
774
- // const d = this._tmpV3.distanceTo(camera.position); // 旧原理:三维直线距离——全景点同高(y 恒 1.6)时与本式排序等价,注释保留备查
775
- // ⭐ 10-07 第二十一道旨:尺子的另一端从相机位置换成 `ref`(控制器中心点;未注入时它就是相机位置)。
776
- dists.push({ t: this.textures[i], d: Math.hypot(this._tmpV3.x - ref.x, this._tmpV3.z - ref.z) });
777
- }
778
- // ⭐ 皇上旨 2026-10-03「1.可以」:预览/发布态的 8 格**也**按「中心 ± 邻居」名册挑(算法与死区常数
779
- // 都在 ./panoSlotRoster,编辑态那份读的是同一处 ⇒ 预览与编辑器逐字同一批全景);
780
- // ⛔ 不再按「引用面数取前 4」(getReferencedPanoIndices 那套静态名单只留作公共 API,不进喂料路)。
781
- this._slotPanos = panoSlotRosterStep(this._slotPanos, dists, MKT_PANO_MAX_SLOTS, PANO_SLOT_SWAP_MARGIN_M);
782
- // ⭐ 10-03 旨②:中心那张不再要「抄给立方体」(立方体已与墙共用同一份材质),但**仍**留 `activeIndex`
783
- // 作为「名册中心有没有换人」的闩锁(下面的比较 + 外部只读),换人才重新激活。
784
- const center = this._slotPanos[0];
785
- const target = center ? this.textures.indexOf(center) : -1;
786
- if (target >= 0 && target !== this.activeIndex) {
787
- this.activateTexture(target);
788
- }
789
- // 每帧**只**驱动一次拼合器(皇上旨 09-29「一帧里别同时处理很多纹理」+ 10-06「每帧只载入一张新纹理到 GPU」
790
- // ⇒ 拼合器内部分两段:每帧最多 1 次新纹理上传,画进 RT 只认已在显存里的那几格):
791
- // 下面的 `feedFaceMapping` 与各槽 `ensureTileTexture` 一律只 peek,不在这里重复 pump。
792
- // ⭐ 10-06:RT 要在 renderer 上才画得进去 —— `_glRenderer` 由 `onBeforeRender` 在**这一句之前**捕获
793
- // (构造后头一帧还没捕获到就传 null:那一帧什么都不画,下一帧自然补上)。
794
- this._stitcher.pump(this._glRenderer);
795
- }
796
-
797
- /** 给纹理地址追加 OSS 图片缩放参数(1024 / 2048 / 空=不缩放)。
798
- * 尺寸由外部 setOssSize 控制(默认 1024,与编辑器渲染器 resolveOssUrl 语义一致);
799
- * 预览/发布库经 setOssSize 注入,使全景球按所选尺寸加载。
800
- * ⚠️ `original=true`(这一槽喂的是**真瓦片地址**,即 rt 虚拟键那一路)时**一律走原图**、不拼尺寸参数
801
- * (皇上旨 2026-09-23「切换到4片瓦片的时候, 就不需要设置oss 尺寸参数了, 默认使用原图」)。
802
- * 判据与喂地址、写 `uPanoTiled` 同源(`mktPanoTiledFlag`),别另算一套。 */
803
- private resolveOssUrl(url: string, original = false): string {
804
- // ⭐ 32 瓦片拼合的虚拟键不是真 URL(纹理由 PanoTileStitcher 提供),原样放行、绝不拼 OSS 参数
805
- if (!url || url.startsWith(RT_STITCH_KEY_PREFIX) || url.includes("x-oss-process")) {
806
- return url;
807
- }
808
- if (original) return url;
809
- const size = this._ossSize;
810
- if (!size) return url;
811
- const sep = url.includes("?") ? "&" : "?";
812
- return `${url}${sep}x-oss-process=image/resize,m_lfit,w_${size}`;
813
- }
814
-
815
- /** ⭐ 皇上旨 2026-10-07 第二十一道旨:把「用哪些全景球」那把尺子的锚点从相机位置换成**控制器中心点**。
816
- * 消费方(预览弹窗)把轨道控制器的 `target` 交进来即可;传 `null` = 回落相机位置(老口径,发布页照旧)。
817
- * 存引用不存副本 ⇒ 轨道控制器原地改那个 Vector3,设一次就一直跟着走。 */
818
- setRosterCenter(center: Vector3 | null): void {
819
- this._rosterCenter = center;
820
- }
821
-
822
- /** 设置纹理 OSS 缩放尺寸(1024 / 2048 / 空=原图);变化即按新尺寸重载当前激活纹理。
823
- * 编辑器 MktDevMesh 关闭了 activateTexture(activeIndex=-1),此处为 no-op;预览/发布库用基类激活,会真正按新尺寸重载。 */
824
- setOssSize(size: "1024" | "2048" | ""): void {
825
- const next = size === "1024" || size === "2048" || size === "" ? size : "1024";
826
- if (next === this._ossSize) return;
827
- this._ossSize = next;
828
- this._reloadActiveTexture();
829
- }
830
-
831
- /** ossSize 换档后重载「加载地址拼了尺寸参数」的那批条目(与编辑态 `_reloadFloorTextures` 同口径:
832
- * dispose + 删条目,下一帧 `_peekTileAt` 自然按新档重拉)。
833
- * ⚠️ 真瓦片(现行 32 片/全景)一律走原图 ⇒ 不在重载范围;整图档才受尺寸档影响(皇上旨 09-23)。 */
834
- private _reloadActiveTexture(): void {
835
- if (this._ossSizeDepKeys.size === 0) return;
836
- for (const key of this._ossSizeDepKeys) {
837
- releasePanoTexture(this._texCache.get(key)); // 换档丢掉的这张要连位图一起关(见 `releasePanoTexture`)
838
- this._texCache.delete(key);
839
- }
840
- this._ossSizeDepKeys.clear();
841
- }
842
-
843
- // ───────────── 全景瓦片缓存(按地址 + 每 tick 一张上传;与编辑态同一份策略) ─────────────
844
-
845
- /** 换绑一个点槽的左右 2 张拼合槽纹理:策略全在 `./panoSlotBind`(皇上旨 10-03「整套搬进库」⇒ 编辑器与库**同一份**),
846
- * 这里只把本类的取图口与占位纹理注入进去。返回 true = 图与点位配好了(调用方才写投影矩阵)。 */
847
- private _bindSlotTiles(
848
- mat: ShaderMaterial, panos: Texture[], base: number, tiles: string[], tiled: boolean, slot: number
849
- ): boolean {
850
- return panoSlotBind(mat, panos, base, tiles, tiled, slot, {
851
- peek: (key, original) => this._peekTileAt(key, original),
852
- // 跨形态(瓦片 ↔ 整片)那一次要「整槽同进同退」:库里没有过渡档那一层,逐张 peek 攒成整槽即可。
853
- // ⛔ 长度由 `RT_STITCH_SLOT_COUNT` 派生(10-06 晚旨后是 2 张,⛔ 别写死 4 个下标)
854
- peekSlot: (t, original) =>
855
- Array.from({ length: RT_STITCH_SLOT_COUNT }, (_, k) => this._peekTileAt(t[k], original)),
856
- placeholder: this._placeholder,
857
- });
858
- }
859
-
860
- /** 本材质「panoramas[i] 这一格认的是哪张瓦片地址」——逐张换绑的账本(⚠️ 账本实体在 `./panoSlotBind`,
861
- * 与编辑态共一本,按材质实例分键 ⇒ 两处材质不同实例、天然不串味)。 */
862
- private _tileKeys(mat: ShaderMaterial, len: number): string[] {
863
- return panoSlotTileKeys(mat, len);
864
- }
865
-
866
- /** 按缓存键要一张**已经设进 shader** 的纹理:没有就当场发起下载(异步代码一律不进全局队列 —— 皇上旨 2026-09-25),
867
- * 解码完还没上传的排一个「只干上传那一句」的队列项;两关都过了才返回给它去绑。
868
- * ⭐ `rt:` 虚拟键(32 瓦片拼合槽)走拼合器 peek;⛔ 不在这里 pump(每帧驱动点唯一,见 `updateTexture`,
869
- * 否则一帧画好几格 = 把「每帧只载入一张新纹理」那道闸从后门拆了)。 */
870
- private _peekTileAt(key: string, original: boolean): Texture | null {
871
- // ⭐ 2026-10-25:空地址 = 「这个点还没设图片」,不是一条能加载的 URL ⇒ 直接判没有。
872
- // 放过去会让 ensureTileTexture 拿空串请求当前文档地址、失败后每帧重试。
873
- if (!key) return null;
874
- if (isRtStitchKey(key)) {
875
- const p = rtStitchKeyParts(key);
876
- if (!p) return null;
877
- // ⭐ 2026-10-09 皇上旨「切换全景时上一个全景的内容要渲染成深灰」:透传 renderer,
878
- // 新全景还没画进格时 peek 会**先把 RT 建出来(已涂缺失灰)并交出** ⇒ 这一帧拿到深灰底,
879
- // 而非 null(null 会被 panoSlotBind 判成「判不齐」⇒ 沿用上一批 ⇒ 墙上闪上一个全景)。
880
- const tex = this._stitcher.peek(p.batch, p.slot, this._glRenderer);
881
- if (!tex) return null;
882
- // ⭐ 皇上旨 09-29「两个大头别在同一帧」:普通瓦片不许在 render 首绑现场 texSubImage2D ⇒ 走
883
- // 「每 tick 一张」预上传队列;没排到的帧返回 null,自动推迟绑定 ⇒ render 里从此没有首绑上传。
884
- // ⭐ 皇上旨 10-06 换成 RT 那一份后这一闸**自动跳过**:RT 的附件纹理本来就在显存里,
885
- // 压根没有「上传」那一下(⛔ 也别拿 `initTexture` 去喂它 —— 那是 three 自己管的渲染目标)。
886
- if (!this._stitcher.resident(tex) && !this._texUploaded.has(tex)) {
887
- this._commitTileToShader(tex);
888
- return null;
889
- }
890
- return tex;
891
- }
892
- const tex = this._texCache.get(key);
893
- if (!tex) {
894
- // 未开始或加载中:直接发起(缓存里以 null 占位 ⇒ 同一地址不会被问两次就下两遍)。
895
- // 发起只是把 XHR 丢出去(微秒级),主线程成本可忽略;真正贵的解码/上传各自有闸。
896
- this.ensureTileTexture(key, original);
897
- return null;
898
- }
899
- if (!this._texUploaded.has(tex)) {
900
- this._commitTileToShader(tex); // 已解码但还没上 GPU:排它自己的那一个 tick,这一帧不绑
901
- return null;
902
- }
903
- return tex;
904
- }
905
-
906
- /** 真正发起一张瓦片纹理的加载(`key` = 原始地址;rt 虚拟键由 `_peekTileAt` 上半段拦走,不会到这里)。
907
- * ⚠️ 皇上旨 2026-09-28「加载瓦片的时候, 瓦片纹理不需要添加 oss 的参数」⇒ 真瓦片(`original=true`,现行 32 片/全景)走原图,
908
- * 整图档才按 ossSize 档缩(`resolveOssUrl`)。 */
909
- private ensureTileTexture(key: string, original = false): Texture | null {
910
- // ⭐ 2026-10-25:空地址直接判「没有」,不起载(与编辑态 ensureFloorTexture 同一道闸)。
911
- if (!key) return null;
912
- const cached = this._texCache.get(key);
913
- if (cached) return cached;
914
- if (cached === null) return null; // 加载中
915
- this._texCache.set(key, null); // 标记加载中
916
- const loadUrl = this.resolveOssUrl(key, original);
917
- if (loadUrl !== key) this._ossSizeDepKeys.add(key);
918
- loadPanoTexture(loadUrl).then(
919
- (tex) => {
920
- tex.colorSpace = SRGBColorSpace;
921
- // 该键可能已被 `_reloadActiveTexture`(换档)删掉并重新发起 ⇒ 按键写回即可:地址就是本条目的身份,
922
- // 不存在「过期回调写脏另一个地址」的问题(与编辑态 `_floorTexCache` 同一口径)。
923
- this._texCache.set(key, tex);
924
- // 解码已在浏览器工作线程做完(甲)⇒ 这里只排它**自己那一个**队列项(只含「上传 GPU」那一句):
925
- // 一批里几张同时解完 ⇒ 各自排各自的 tick 逐张落地,上传那一拍不再顺带同步解码。
926
- this._commitTileToShader(tex);
927
- },
928
- () => {
929
- // 单张失败:摘掉「加载中」占位 ⇒ 下一帧同键判定仍是未就绪,自动重试(旧库路径本来就会重试,口径不变)。
930
- if (this._texCache.get(key) === null) this._texCache.delete(key);
931
- }
932
- );
933
- return null;
934
- }
935
-
936
- /** ⭐ 把一张「已经加载解码好」的纹理排进全局队列,排到的那个 tick 只做一件事:把它上传到 GPU。
937
- * 皇上旨 2026-09-25 原文:「队列里面的代码就是 把加载好的纹理 设置给 shader 的 这一句代码」——
938
- * 所以下载、淘汰、绑定判断统统不许出现在回调里。
939
- * ⚠️ 去重闸门必须是自己的 `_uploadQueued`:`addWork` 用 `workList.includes(work)` 判重,而每次都是新闭包 ⇒ 判不出来。
940
- * ⭐ 皇上旨 2026-10-05:「你在调用 addWork 的时候,再调一次 addWork 传入一个空函数,这样真正需要执行的方法就会隔一帧执行」
941
- * ⇒ 真活前面先垫一个空函数占一格(全局队列每 tick 只跑一项 ⇒ 多一格 = 晚一帧),与编辑态 `_commitTileToShader` 同一口径。
942
- * ⚠️ 空函数**必须内联每次现造**:`addWork` 按引用判重,共用一个常量引用时第二张起空格进不了队列 ⇒ 改动静默失效。 */
943
- private _commitTileToShader(tex: Texture): void {
944
- if (this._texUploaded.has(tex) || this._uploadQueued.has(tex)) return;
945
- this._uploadQueued.add(tex);
946
- threeUtils.addWork(() => {}); // 占一格(什么都不干),把下面那一项推到下一个 tick
947
- threeUtils.addWork(() => this._uploadTileTexture(tex));
948
- }
949
-
950
- /** 队列回调的全部内容:把这一张纹理上传到 GPU(`texImage2D` + mipmap 才是那一下贵的),每 tick 一张。 */
951
- private _uploadTileTexture(tex: Texture): void {
952
- // 排上队之后已被清掉(那张 dispose 过了)⇒ 本项作废:再上传会把显存凭空建回来且无人回收。
953
- if (!this._uploadQueued.delete(tex)) return;
954
- try {
955
- // `_glRenderer` 还没被 `onBeforeRender` 捕获到(构造后尚未渲染过一帧)⇒ 跳过预上传,
956
- // 交给 three 在绑定那一帧自己上传 —— 那是改造前的老路径,不会报错,只是那一张没享受「错峰」。
957
- if (this._glRenderer) this._glRenderer.initTexture(tex);
958
- } catch {
959
- // 皇上旨 2026-10-12「日志都去掉」⇒ 这里不再出声;降级路径本身不变(下面照记「已处理」,
960
- // 最坏退回 three 在绑定那一帧自己上传的老路径)。
961
- }
962
- // ⚠️ 成败都记「已处理」。不记 ⇒ 这张永远进不了 `_texUploaded` ⇒ 那一格永远换不上新图,
963
- // 比「卡一下」严重得多;记了最坏也只是退回 three 在绑定那一帧自己上传的老路径。
964
- this._texUploaded.add(tex);
965
- }
966
-
967
- /**
968
- * 序号直接取模式喂食(皇上旨 2026-09-28 决策③:MktMesh 基类只有这一种模式)。
969
- * ⭐ 皇上旨 2026-10-03「1.可以」:shader 各槽装的是**槽位名册**那 `MKT_PANO_MAX_SLOTS` 张(`_slotPanos`,
970
- * 中心 ± 邻居,与编辑态 `mktMeshEditRenderer.feedFaceMapping` 同一份算法、同一个死区常数)
971
- * ⇒ 预览/发布与编辑器同一屏装同一批全景。⛔ 不再按「被引用面数取前 4」(那是 09-28 的静态名单,
972
- * 与相机无关 ⇒ 走近的景点进不了槽,正是他这次报「预览里 shader 不对」的根因)。
973
- * 片元按 aPanoIndex 在 panoIndices 里找对应 slot 取纹理;指派的那张不在当前名册里 ⇒ 该面回落 `uFaceMissingColor` 深灰,
974
- * 把**名册锚点**挪近那张景点后自动出图(⭐ 10-07 第二十一道旨:预览轨道模式 = 控制器中心点,第一人称/未注入 = 相机位置;与编辑态同一份已知代价)。
975
- */
976
- feedFaceMapping(): void {
977
- this._stitcher.pump(this._glRenderer); // 每帧驱动拼合器(≤1 片/帧;下方逐槽只 peek 不驱动)
978
- const mat = this.material as ShaderMaterial;
979
- const u = mat.uniforms;
980
- if (!u.panoIndices) return; // 非融合材质(防御)
981
- u.uPanoramaVisible.value = 1;
982
- // 复用统一喂料:填 panoramas[]/textureMatrices[]/weights[] + uPanoramaVisible
983
- // (材质本身就是序号直取 shader,恒按 panoIndices 选槽取图——原 uFaceMappingMode=1 翻转已随拆分删除;
984
- // ⚠️ 本函数**从来没写过** uColorSpace:那句旧注释是骗人的。色彩空间 2026-10-07 已整条退路——
985
- // 纹理恒标 SRGBColorSpace 由硬件解码一次,shader 不再手动 pow,故连 uniform 都不存在了。)
986
- const maxPano = (u.panoIndices.value as Float32Array).length;
987
- const panos = u.panoramas.value as Texture[];
988
- const mats = u.textureMatrices.value as Matrix4[];
989
- const ws = u.weights.value as Float32Array;
990
- const idxU = u.panoIndices.value as Float32Array;
991
- const keys = this._tileKeys(mat, panos.length);
992
- for (let s = 0; s < maxPano; s++) {
993
- const base = s * RT_STITCH_SLOT_COUNT; // ⛔ 不是 s*4:10-06 晚旨后一个全景只占左右 2 槽
994
- const info = this._slotPanos[s]; // 名册没这一格(项目全景总数不够 / 首帧未算)⇒ 走下面的清空支
995
- if (!info) {
996
- for (let k = 0; k < RT_STITCH_SLOT_COUNT; k++) panos[base + k] = this._placeholder;
997
- mats[s].identity();
998
- ws[s] = 0;
999
- idxU[s] = -1;
1000
- continue;
1001
- }
1002
- idxU[s] = this.textures.indexOf(info);
1003
- // ⭐ 2026-10-25 皇上旨「把默认图去掉,图片没有加载的时候就显示纯灰色」:
1004
- // 这个点**没设图片**(url 空且没有切片)⇒ 按空槽喂料,权重 0 ⇒ 片元回落 uFaceMissingColor 中性灰。
1005
- // ⛔ 别把空地址放下去:空 key 会走到 `_peekTileAt` → `ensureTileTexture("")` 发一次空请求
1006
- // (解析成当前文档地址),失败后下一帧再试 = 每帧一个废请求。序号照留(归属还在,只是没图)。
1007
- const hasSource = !!info.url || !!(info.tiles && info.tiles.length > 0);
1008
- if (!hasSource) {
1009
- for (let k = 0; k < RT_STITCH_SLOT_COUNT; k++) panos[base + k] = this._placeholder;
1010
- mats[s].identity();
1011
- ws[s] = 0;
1012
- continue;
1013
- }
1014
- // 各槽喂什么地址 = 「用户选的形态 + 该点有没有切过片」共同决定(whole 或无 tiles ⇒ 各槽同为一张整图)
1015
- const tiles = mktPanoTexUrls(info.url, info.tiles, this._panoTexMode);
1016
- // ⚠️ 「是不是真瓦片」只算一次:喂地址(走不走 OSS 尺寸档)与写 uPanoTiled 共用同一个值
1017
- const tiled = mktPanoTiledFlag(info.tiles, this._panoTexMode) === 1;
1018
- // ⭐ 10-06 晚旨「只支持 32 张(8×4)」:任意 1~32 条瓦片整组交拼合器(不足 32 张的格子涂灰);
1019
- // 这 2 槽是虚拟键,这里只负责「每帧把真 URL 喂给拼合器」(虚拟键本身不含完整地址)。
1020
- if (tiled && info.tiles && info.tiles.length > 0) this._stitcher.ensure(info.tiles);
1021
- // ⭐ 皇上旨 2026-10-03「整套搬进库」:换绑成败由**共用的那份策略**判定(未就绪 ⇒ 整槽沿用上一批)。
1022
- // 没换成就**矩阵也别动** ⇒ 旧图永远配旧矩阵(原来矩阵无条件 copy = 新矩阵配旧图,正是②的错配)。
1023
- const bound = this._bindSlotTiles(mat, panos, base, tiles, tiled, s);
1024
- if (bound) mats[s].copy(info.matrix);
1025
- // 左上格当整槽的代表。⚠️ 判据必须**同时**看账本:`keys[base]` 为空串 ⇒ 这座槽**从没成功绑过**,
1026
- // 此刻 `panos[base]` 里躺的是占位/工厂默认 1×1,而矩阵还是单位阵 —— 给它权重等于拿「世界原点」
1027
- // 当投影中心投一片素色。已绑过的槽(账本非空)换人没判齐时权重照给 ⇒ 沿用上一批,不整体退出。
1028
- const hasReal = !!keys[base] && !!panos[base] && panos[base] !== this._placeholder;
1029
- // 库侧单点不混合 ⇒ 权重恒 1(编辑态那份是距离反比 camW,这里没有「多张混出一个面」的事)
1030
- ws[s] = hasReal ? 1 : 0;
1031
- }
1032
- }
1033
-
1034
- /** 设置全景纹理形态(tiled=8×4 三十二片走拼合器 / whole=一整片原图);非法值回落 tiled。
1035
- * ⭐ 10-03 旨⑤:这里**不再**手工重载纹理 —— 形态一变目标地址就变了(瓦片 URL ↔ 整图 URL),
1036
- * 下一帧 `feedFaceMapping` 里共用的那份 binder 见「槽内现有形态 ≠ 目标形态」自动走整档判齐、一次换绑并同帧翻标记。
1037
- * 编辑器 MktDevMesh 走的是自己那路(同一个 binder、同一份策略),本方法对它只是记下形态。 */
1038
- setPanoTexMode(mode: PanoTexMode): void {
1039
- const next = isPanoTexMode(mode) ? mode : "tiled";
1040
- if (next === this._panoTexMode) return;
1041
- this._panoTexMode = next;
1042
- }
1043
-
1044
- /**
1045
- * 记下「当前激活(名册 0 号槽 = 中心)是第几个全景」。
1046
- * ⭐ 皇上旨 2026-10-03 旨②:预览立方体已与墙**共用同一份材质**,原来那句「把中心那几张抄给立方体单全景材质」
1047
- * 的 `currentTiles` 旁路整条删除 ⇒ 本方法不再加载瓦片、不再写 `uPanoTiled`(形态标记归 binder 逐槽写,
1048
- * 地址/标记/分辨率三者同源的判据在 `feedFaceMapping`)。留着它是因为 `updateTexture` 用它做「中心换人」的闩锁,
1049
- * 且它是发布态的公共 API(外部只读 `activeIndex` 靠这里维护)。
1050
- */
1051
- activateTexture(index: number): void {
1052
- if (index < 0 || index >= this.textures.length) {
1053
- return;
1054
- }
1055
- this.activeIndex = index;
1056
- }
1057
-
1058
- // ───────────────────────── 导入 / 导出 ─────────────────────────
1059
-
1060
- /**
1061
- * ⭐ 项目名(皇上旨10-08 19:53「导出时图片地址改成 `{项目名称}/xxx.jpg`」)。
1062
- * 落盘时用它顶替 OSS 上传目录前缀(`three-base_upload/panorama/`),导入时据此还原。
1063
- * 消费方(编辑器)用 `setProjectName()` 注入;没注入时退化为「只剥域名」的纯相对形态。
1064
- */
1065
- projectName: string = "";
1066
-
1067
- /** 注入项目名(导出 json 前调一次即可;导入时无须调——还原靠落盘地址自身的首段) */
1068
- setProjectName(name: string): void {
1069
- this.projectName = typeof name === "string" ? name : "";
1070
- }
1071
-
1072
- // ───────────── 平面图 / 模型两层(⭐ 皇上旨 2026-10-26「把图片平面和模型一起搬进库里」) ─────────────
1073
-
1074
- /** 单颗图片平面入层(编辑态双击落点用):进数组 + 挂进本对象子树 + 起排它自己的逐帧绑定 */
1075
- addImagePlane(ip: ImagePlane): ImagePlane {
1076
- this.imagePlanes.push(ip);
1077
- this.add(ip);
1078
- this._queueImagePlaneBind(ip);
1079
- return ip;
1080
- }
1081
-
1082
- /** 摘掉一颗并释放它自己的几何/材质;那 4 张瓦片纹理归 `_imgTileLoader` 管 ⇒ ⛔ 不在这里 dispose */
1083
- removeImagePlane(ip: ImagePlane): void {
1084
- const i = this.imagePlanes.indexOf(ip);
1085
- if (i >= 0) this.imagePlanes.splice(i, 1);
1086
- this._imgBindQueued.delete(ip);
1087
- this._imgBindDone.delete(ip);
1088
- this.remove(ip);
1089
- ip.dispose();
1090
- }
1091
-
1092
- /** 单颗模型槽入层(编辑态双击落点、以及「复制模型」用) */
1093
- addModel(ms: ModelSlot): ModelSlot {
1094
- this.models.push(ms);
1095
- this.add(ms);
1096
- return ms;
1097
- }
1098
-
1099
- /** 摘掉一槽并释放(子树里的几何/材质/贴图由 `ModelSlot.dispose` 自己收尾;排在后头的挂载帧靠「不在册」早退) */
1100
- removeModel(ms: ModelSlot): void {
1101
- const i = this.models.indexOf(ms);
1102
- if (i >= 0) this.models.splice(i, 1);
1103
- this._modelMountQueued.delete(ms);
1104
- this.remove(ms);
1105
- ms.dispose();
1106
- }
1107
-
1108
- /**
1109
- * 整层重建图片平面(`importData` 与预览那一路)。
1110
- * ⚠️ 进来的地址必须是**已补回域名**的绝对形态:补域名只有 `importData` 那一处(与 `textures` 同一把 `assetUrl` 尺),
1111
- * ⛔ 本方法里不许再补一遍,也⛔ 不许在调用方各自补。
1112
- */
1113
- setImagePlanes(datas: ImagePlaneData[]): void {
1114
- this._imgBindQueued.clear();
1115
- this._imgBindDone.clear();
1116
- for (const ip of this.imagePlanes) {
1117
- this.remove(ip);
1118
- ip.dispose();
1119
- }
1120
- this.imagePlanes = [];
1121
- for (const d of datas) {
1122
- const ip = new ImagePlane(d.name);
1123
- ip.applyData(d);
1124
- this.imagePlanes.push(ip);
1125
- this.add(ip);
1126
- }
1127
- // 整层重建完立刻起排:地址一次性全交给下载、绑材质逐帧一记 ⇒ 宿主不驱动这一层也长得出来
1128
- this.feedImagePlaneTiles();
1129
- }
1130
-
1131
- /**
1132
- * 整层重建模型槽(`importData` 与预览那一路),地址口径的要求同 `setImagePlanes`。
1133
- * ⚠️ GLB 逐只**异步取、不 await**(十几只串一条队会把载入卡死),每只落地那一刻各回调一次 `onEachLoad`:
1134
- * 预览每帧真画 ⇒ 不传;编辑器是按需渲染 ⇒ 传一个「标脏」的钩子,否则模型挂上来屏幕不动。
1135
- * 2026-10-26 决定:取回与「挂进父元素」分两拍 —— 地址一次性全交给下载(网络 + 解析不占渲染帧),
1136
- * 真正进场景图那一步交给 `threeUtils.addWork` ⇒ 全局 rAF 每拍只跑一项 = **一帧只挂一颗槽**
1137
- * (挂载那一拍要重算包围盒 + 排布 + 首次把整棵子树的几何送上 GPU,十几颗挤在同一帧就是他要拆掉的那一卡)。
1138
- */
1139
- setModels(datas: ModelSlotData[], onEachLoad?: () => void): void {
1140
- this._modelMountQueued.clear();
1141
- for (const ms of this.models) {
1142
- this.remove(ms);
1143
- ms.dispose();
1144
- }
1145
- this.models = [];
1146
- for (const d of datas) {
1147
- const ms = new ModelSlot(d.name);
1148
- ms.applyData(d);
1149
- this.models.push(ms);
1150
- this.add(ms); // 槽本体(占位小立方体)当场进图 ⇒ GLB 在路上时这一槽不凭空消失
1151
- if (d.url) void ms.loadModel(d.url).then(() => this._queueModelMount(ms, onEachLoad));
1152
- }
1153
- }
1154
-
1155
- /** 把「备好待挂」的那一槽交给全局队列;⛔ 去重闸门自备(`addWork` 按闭包引用判重,新闭包它判不出来) */
1156
- private _queueModelMount(ms: ModelSlot, onEachLoad?: () => void): void {
1157
- if (this._modelMountQueued.has(ms)) return;
1158
- this._modelMountQueued.add(ms);
1159
- threeUtils.addWork(() => {});
1160
- threeUtils.addWork(() => this._mountModelStep(ms, onEachLoad));
1161
- }
1162
-
1163
- /** 全局队列里的那一帧:只挂这一槽,挂好才回调(`onEachLoad` = 按需渲染宿主的标脏钩子) */
1164
- private _mountModelStep(ms: ModelSlot, onEachLoad?: () => void): void {
1165
- this._modelMountQueued.delete(ms);
1166
- // 槽已退场(换项目 / 删除):待挂那棵由 `ModelSlot.dispose` → `_detachModel` 当场释放,这里不挂
1167
- if (!this.models.includes(ms)) return;
1168
- if (ms.mountStaged() && onEachLoad) onEachLoad();
1169
- }
1170
-
1171
- /**
1172
- * 把各颗平面那 4 张瓦片的纹理绑进材质(10-22 晚定的口径:不走拼合器 RT)。
1173
- * 2026-10-26 决定改成**逐帧**:一次把整层地址全丢给下载器(下载/解码在网络与工作线程侧,不占这一帧),
1174
- * 真正写 uniform 的那一步交给 `threeUtils.addWork` —— 全局 rAF 每拍只跑一项 ⇒ 一帧只绑一颗平面,
1175
- * 一整层平面的纹理上传被摊到多帧,不再挤在同一记调用里(旧写法一记 `feed` 就把所有平面全绑完)。
1176
- * ⛔ 不进 `onBeforeRender` 自驱;持有渲染循环的那一方(编辑态 / 预览 / 下游)每帧调一次只是「补排漏网的平面」,
1177
- * 而整层重建(`setImagePlanes`)末尾自己起排 ⇒ 只调 `importData(整串)` 的宿主不必自己驱动也会长出来。
1178
- * 还没下载完/缺格的那一格绑 null ⇒ 片元那一格整格 discard,不落像素(用户 10-26 连收两条:先「不要默认图」,
1179
- * 再「不是显示灰色, 而是直接 discard 丢弃」)。
1180
- */
1181
- feedImagePlaneTiles(): void {
1182
- for (const ip of this.imagePlanes) this._queueImagePlaneBind(ip);
1183
- }
1184
-
1185
- /** 一颗平面排一帧的绑定工作:先把它那 4 条地址**一次性全部**交给下载器,再把「写材质」排进全局队列。 */
1186
- private _queueImagePlaneBind(ip: ImagePlane): void {
1187
- const tiles = ip.tiles;
1188
- const sig = (tiles ?? []).join("|");
1189
- // 两道闸:已排在队里 = 不重复排;这串地址已经落定过 = 不再动它(换图会改这串 ⇒ 自然会重排)
1190
- if (this._imgBindQueued.has(ip) || this._imgBindDone.get(ip) === sig) return;
1191
- const zero = new Vector4(0, 0, 1, 1);
1192
- // ⛔ 旧存档 16 条地址(> 满批 4)直接不收 ⇒ 整张不落像素(四格全 discard),按 10-20 口径重传即可;空 tiles 同样
1193
- if (!tiles || tiles.length !== 4) {
1194
- ip.setTileTextures([null, null, null, null], [zero, zero.clone(), zero.clone(), zero.clone()]);
1195
- this._imgBindDone.set(ip, sig);
1196
- return;
1197
- }
1198
- for (const url of tiles) this._imgTileLoader.preload(url); // ① 一次性全部拿去加载
1199
- this._imgBindQueued.add(ip);
1200
- threeUtils.addWork(() => {}); // 占一格(什么都不干),把下面那一项推到下一个 tick —— 与全景上传同一手法
1201
- threeUtils.addWork(() => this._bindImagePlaneStep(ip, sig));
1202
- }
1203
-
1204
- /** 队列里的一格:只绑**这一颗**平面里已就绪的那几格;还有在途的就重新排队,⛔ 不在这里等、也⛔ 不顺手把别的平面一起做完。 */
1205
- private _bindImagePlaneStep(ip: ImagePlane, sig: string): void {
1206
- this._imgBindQueued.delete(ip);
1207
- if (!this.imagePlanes.includes(ip)) return; // 已摘掉(删平面 / 切项目整层重建)⇒ 这一格什么都不做
1208
- const tiles = ip.tiles;
1209
- if ((tiles ?? []).join("|") !== sig) return; // 地址中途换了 ⇒ 下一记 feed 按新拼串重排
1210
- const zero = new Vector4(0, 0, 1, 1);
1211
- const texs: (Texture | null)[] = [null, null, null, null];
1212
- const insets: Vector4[] = [zero.clone(), zero.clone(), zero.clone(), zero.clone()];
1213
- let pending = false;
1214
- for (let i = 0; i < 4; i++) {
1215
- const url = tiles[i];
1216
- if (!url) continue;
1217
- const e = this._imgTileLoader.get(url);
1218
- if (e && e.tex) {
1219
- texs[i] = e.tex;
1220
- insets[i] = this._imgTileLoader.inset(e, i);
1221
- } else if (this._imgTileLoader.isPending(url)) {
1222
- pending = true; // 还在下载/解码 ⇒ 这一格先绑已到的,剩下的下一拍再看
1223
- }
1224
- }
1225
- ip.setTileTextures(texs, insets);
1226
- if (pending) {
1227
- this._queueImagePlaneBind(ip);
1228
- return;
1229
- }
1230
- this._imgBindDone.set(ip, sig); // 全部落定(含下载失败那格:它留 null,那一格就永久 discard)
1231
- }
1232
-
1233
- /** 导出数据:墙点位 + 墙高 + 是否闭合 + 纹理数组(url + 矩阵)
1234
- * @param projName 项目名(可选,优先于 `setProjectName` 注入的那个)——图片地址落成 `{项目名}/xxx.jpg` */
1235
- exportData(projName?: string): MktMeshData {
1236
- const pn = typeof projName === "string" && projName ? projName : this.projectName;
1237
- return {
1238
- wallPoints: this.wallPoints.map((p) => ({ x: p.x, y: p.y, z: p.z })),
1239
- wallHeight: this.wallHeight,
1240
- wallClosed: this.wallClosed,
1241
- wallSide: this.wallSide,
1242
- // 全景序号映射:每个三角面归属的全景全局序号(与 wallPoints 同几何拓扑绑定,随存档走)
1243
- panoFaceMapping: this.panoFaceMapping.slice(),
1244
- textures: this.textures.map((t) => ({
1245
- // ⭐ 10-08 旨:存档里只落**相对路径**(域名在读 JavaScript 之外也用不上,还锁死环境);
1246
- // 同日 18:46 旨「前面不要有 /」⇒ ⛔ 无前导 /;19:53 旨「改成 {项目名称}/xxx.jpg」
1247
- // ⇒ OSS 上传目录前缀(`three-base_upload/panorama/`)整段顶掉,落成 `{项目名}/x_0.jpg`。
1248
- // `stripAssetHost` 幂等 ⇒ 内存里已经是相对的不会出问题,旧存档的绝对地址也不会反复剥。
1249
- url: stripAssetHost(t.url, pn),
1250
- name: t.name ?? "",
1251
- // 2026-09-20:导出 4 瓦片(顺序 [左上, 右上, 左下, 右下]);旧数据无 tiles 时导空数组
1252
- tiles: (t.tiles && t.tiles.length ? t.tiles.map((u) => stripAssetHost(u, pn)) : []) as string[],
1253
- matrix: t.matrix.toArray(),
1254
- })),
1255
- // ⭐ 10-26 旨入库:这两层的地址与 `textures` 共用上面那一条剥域名口径(⛔ 不是另起一把尺),
1256
- // 其余字段(位置/旋转/宽高/瓦片数/落点面/原始包围盒)由各自的 `toData()` 逐字存。
1257
- images: this.imagePlanes.map((ip) => {
1258
- const d = ip.toData();
1259
- return {
1260
- ...d,
1261
- url: d.url ? stripAssetHost(d.url, pn) : "",
1262
- tiles: d.tiles.length ? d.tiles.map((u) => stripAssetHost(u, pn)) : [],
1263
- };
1264
- }),
1265
- models: this.models.map((ms) => {
1266
- const d = ms.toData();
1267
- return { ...d, url: d.url ? stripAssetHost(d.url, pn) : "" };
1268
- }),
1269
- };
1270
- }
1271
-
1272
- /** 导入数据:重建墙体几何与纹理列表,并激活第一个纹理
1273
- * @param projName 项目名(可选)——用于把落盘的 `{项目名}/x_0.jpg` 还原回真身OSS 地址
1274
- * @param onModelLoad 每只 GLB 落地时回调一次(可选)——按需渲染的宿主(编辑器)拿它标脏;每帧真画的预览不传 */
1275
- importData(data: MktMeshData, projName?: string, onModelLoad?: () => void): void {
1276
- // ⭐ 10-08 旨的「补回域名」那颗项目名,10-26 旨之后全景/平面图/模型**三层共用同一个**(原来在 textures 的
1277
- // map 里就地取,下面那两层也要用 ⇒ 提到方法开头,⛔ 不是第二处口径)。
1278
- const pn = typeof projName === "string" && projName ? projName : this.projectName;
1279
- this.setWallPoints(data.wallPoints ?? [], {
1280
- closed: data.wallClosed,
1281
- height: data.wallHeight,
1282
- });
1283
- // ⭐ 还原全景序号映射**放在 setTextures 之后**:rebuildPanoIndexAttribute 会顺手烘焙 aPanoCenter
1284
- // (源 = panoCenters,由 textures 实时派生),排在 setTextures 前面 = 拿旧/空纹理列表算圆心 ⇒ 属性里全是 0。
1285
- this.setWallSide(data.wallSide ?? "double");
1286
- this.setTextures(
1287
- (data.textures ?? []).map((t) => {
1288
- // ⭐ 10-08 旨的成对另一半:存档落的是相对路径 ⇒ 进内存前在这里**唯一一处**补回域名,
1289
- // 下游(拼合器 / 预览 / UI `<img>`)拿到的仍是绝对地址 ⇒ 它们一行都不用改。
1290
- // · `absAssetUrl` 幂等 + **同时认旧存档的根相对形态** ⇒ 上午发布前存的那批
1291
- // (`/three-base_upload/…`)不必重存也能正常加载,不会被拼成 `https://host/https://host/…`
1292
- // · 19:53 旨后还要把 `{项目名}/x_0.jpg` **还原**成真身 OSS 地址(顶掉的上传目录要补回来),
1293
- // 否则 json 里的地址指不回原图(图片裂)。`pn` 取入参、回落 `setProjectName` 注入的那个。
1294
- const urlAbs = absAssetUrl(t.url as string, pn);
1295
- return {
1296
- url: urlAbs,
1297
- name: t.name ?? "",
1298
- // 2026-09-20:导入瓦片;缺省回退 [url](旧存档整图占 1 格)——10-06 晚旨「只支持 32 张(横 8 × 竖 4)」后
1299
- // 该点走拼合器:整图缩在 1/32 格 + 其余 31 格涂灰,数据不全一眼可见(重传 32 片切片即修复)
1300
- tiles: t.tiles && t.tiles.length ? t.tiles.map((u) => absAssetUrl(u, pn)) : [urlAbs],
1301
- matrix: new Matrix4().fromArray(t.matrix),
1302
- };
1303
- })
1304
- );
1305
- // setWallPoints 已按位置重算过映射(旧为空 → 全 -1),此处用存档里的逐面指派覆盖。
1306
- // 几何拓扑由同一份 wallPoints 重建,面索引与导出时一致 ⇒ 逐面写入即可对齐。
1307
- // ⭐ 皇上旨 2026-10-03「补」:长度不符**不再整份丢弃**——按较小长度逐面灌回(存档多出的面丢掉,
1308
- // 墙变长多出来的那些面保持 setWallPoints 按位置继承到的值)。
1309
- // 2026-10-12 旨「日志都去掉」⇒ 这一条不再 warn,差值只按较小者静默还原。
1310
- const imported = (data.panoFaceMapping && data.panoFaceMapping.length ? data.panoFaceMapping : []) as number[];
1311
- const faceCount = this.panoFaceMapping.length;
1312
- const restore = Math.min(imported.length, faceCount);
1313
- if (restore > 0) {
1314
- for (let f = 0; f < restore; f++) this.panoFaceMapping[f] = imported[f];
1315
- this.rebuildPanoIndexAttribute();
1316
- }
1317
- // ⭐ 10-26 旨「把图片平面和模型一起搬进库里」的另一半:这两层也从**同一份 JSON** 里读,
1318
- // 地址在这一处补回域名(与上面 textures 同一条尺)⇒ 下游只调 `importData(整串)` 就全画出来。
1319
- // 旧存档没有这两个键 ⇒ 空数组 = 整层清空,与「墙 + 全景」那套逐字同口径。
1320
- this.setImagePlanes(
1321
- (data.images ?? []).map((d) => ({
1322
- ...d,
1323
- url: d.url ? absAssetUrl(d.url, pn) : "",
1324
- tiles: (d.tiles ?? []).map((u) => absAssetUrl(u, pn)),
1325
- }))
1326
- );
1327
- this.setModels(
1328
- (data.models ?? []).map((d) => ({ ...d, url: d.url ? absAssetUrl(d.url, pn) : "" })),
1329
- onModelLoad
1330
- );
1331
- }
1332
-
1333
- // ───────────────────────── 释放 ─────────────────────────
1334
-
1335
- /** 释放 GPU 资源:瓦片缓存(**只有这里**清,皇上旨 2026-10-02)、拼合器、平面图/模型两层、几何、材质 */
1336
- dispose(): this {
1337
- // ⚠️ rt 虚拟键从不进 `_texCache`(拼合槽纹理由 `_stitcher` 自己持有并释放),这里清的都是本类下载的 Texture。
1338
- // ⭐ 甲(2026-10-05):`releasePanoTexture` 除 `dispose()` 外还要 `ImageBitmap.close()` —— 位图的原生内存
1339
- // 不像 `<img>` 能等 GC,一张 270MB 挂着不关,切几轮全景就把进程吃爆。
1340
- for (const tex of this._texCache.values()) releasePanoTexture(tex);
1341
- this._texCache.clear();
1342
- this._texUploaded = new WeakSet(); // WeakSet 无 clear ⇒ 整只换掉
1343
- this._uploadQueued.clear(); // 排队项即便回调仍触发,也会因 `!this._uploadQueued.delete(tex)` 早退
1344
- this._ossSizeDepKeys.clear();
1345
- // ⭐ 10-26 旨入库的两层:子对象逐个 dispose,平面那 4 张瓦片的缓存**只在这里**整批清
1346
- // (与上面 `_texCache` 同一条旨 ⇒ 切工程不重建这一份、也就不会再空出一格)。
1347
- for (const ip of this.imagePlanes) {
1348
- this.remove(ip);
1349
- ip.dispose();
1350
- }
1351
- this.imagePlanes = [];
1352
- for (const ms of this.models) {
1353
- this.remove(ms);
1354
- ms.dispose();
1355
- }
1356
- this.models = [];
1357
- this._imgTileLoader.clear();
1358
- this._imgBindQueued.clear();
1359
- this._imgBindDone.clear();
1360
- this._modelMountQueued.clear();
1361
- this._stitcher.dispose();
1362
- this._glRenderer = null;
1363
- if (this._placeholder) this._placeholder.dispose();
1364
- this.geometry.dispose();
1365
- this.material.dispose();
1366
- return this;
1367
- }
1368
- }
1
+ /*
2
+ * @Author: xiaosihan
3
+ * @Date: 2026-08-25
4
+ * @Description: MktMesh —— 墨卡托模型(普通 Mesh + 墨卡托投影全景 + 由点位挤出生成的墙体)。
5
+ *
6
+ * 定位:继承 THREE.Mesh(普通网格,不再用 InstancedMesh)。
7
+ * 墙面几何由一组「地面折线点位」(wallPoints,XZ 平面)通过挤出生成:
8
+ * - 墙体侧面:复用 three-base 的 ExtrudeMesh(shapePath = [[0,0],[0,墙高]] 沿点位挤出一道幕墙);
9
+ * - 墙体上/下两个多边形封盖:用 three.js 自带的 ShapeGeometry 由点位三角化生成。
10
+ * 墨卡托投影全景纹理(panorama)实时映射到墙面上:每帧按槽位名册(中心 ± 邻居)决定 shader 那 8 格装哪 8 张。
11
+ *
12
+ * 核心特性:
13
+ * 1. 墙面 = 普通 Mesh 几何(世界坐标),MktMesh 自身保持单位变换;
14
+ * 2. 纹理列表 textures:每个纹理含 url + 世界矩阵(位置=墨卡托投影中心,旋转=朝向);
15
+ * 3. 默认每帧(onBeforeRender)按「离**名册锚点**最近的水平距离(XZ)」算出**槽位名册**(中心 ± 邻居,见
16
+ * `panoSlotRoster.ts`;皇上旨 2026-10-03 起预览/发布与编辑器共用同一份算法 ⇒ 两边装同一批全景)。
17
+ * ⭐ 皇上旨 2026-10-07 第二十一道旨:锚点 = 外部控制器的中心点(`setRosterCenter` 注入),没注入才回落相机位置;
18
+ * 中心变化或名册里有空格被补上人时加载新瓦片 → 设置 shader 的 8 个全景槽 panorama + textureMatrix;
19
+ * 4. exportData / importData 支持导入导出(墙点位 + 纹理数组 + ⭐ 10-26 旨起的平面图 / 模型两层)。
20
+ * 5. ⚠️ 铁律:激活纹理(墙面上正在绘制的全景)的旋转/位置变化,必须立即同步到
21
+ * shader 的 textureMatrix uniform(见 setTexture),否则平面纹理不跟着全景球转。
22
+ *
23
+ * 说明:所有实例共享同一份 material(单一 panorama uniform),
24
+ * 同一时刻只激活名册 0 号槽(中心)那一个纹理(全局共享单激活方案)。
25
+ */
26
+ import {
27
+ BufferAttribute,
28
+ BufferGeometry,
29
+ Camera,
30
+ BackSide,
31
+ Float32BufferAttribute,
32
+ Material,
33
+ Matrix4,
34
+ Side,
35
+ Mesh,
36
+ Shape,
37
+ ShapeGeometry,
38
+ ShaderMaterial,
39
+ SRGBColorSpace,
40
+ Texture,
41
+ Vector3,
42
+ Vector4,
43
+ } from "three";
44
+ import type { WebGLRenderer } from "three";
45
+ import { createPlaceholderTexture, isPanoTexMode, mktPanoTiledFlag, mktPanoTexUrls, isRtStitchKey, rtStitchKeyParts, RT_STITCH_KEY_PREFIX, RT_STITCH_SLOT_COUNT, type PanoTexMode } from "./mktShader";
46
+ import { MKT_PANO_MAX_SLOTS } from "./mktMappingShader";
47
+ import { createMktFloorMappingShaderMaterial } from "./mktMappingShader";
48
+ // ⭐ 皇上旨 2026-10-06:拼合 = shader 逐格画进 WebGLRenderTarget,**一份实现**(canvas 2D 那一份连同
49
+ // `?panoStitch=canvas` 开关整条删除)
50
+ import { PanoTileStitcher, type PanoStitcher } from "./panoTileStitcher";
51
+ import { panoSlotRosterStep, PANO_SLOT_SWAP_MARGIN_M } from "./panoSlotRoster";
52
+ // ⭐ 甲(皇上旨 2026-10-05「你试试甲」):整图/瓦片的下载与解码一律走这里(解码在浏览器工作线程,
53
+ // 不再让上传那一拍的 `texImage2D(<img>)` 替我们在主线程同步解 270MB)。编辑态同一份实现。
54
+ import { loadPanoTexture, releasePanoTexture } from "./panoBitmapTexture";
55
+ // ⭐ 皇上旨 2026-10-03「整套搬进库(彻底同源)」:「一个点槽的左右 2 张拼合槽纹理怎么换绑」只有这一份策略实现,
56
+ // 编辑态 `mktMeshEditRenderer._bindSlotTiles` 与库侧都只「委托一行」+ 注入各自取图口(⛔ 不许有第二份)。
57
+ import { panoSlotBind, panoSlotTileKeys } from "./panoSlotBind";
58
+ import threeUtils from "../threeUtils";
59
+ import ExtrudeMesh from "../ExtrudeMesh";
60
+ import type { MktMeshData, MktTextureInfo, WallPoint, WallSide } from "./types";
61
+
62
+ // ⭐ 皇上旨 2026-10-07「上下封口的三角面能不能不要那么长 / 三角面再细分一下」:封盖三角化之后
63
+ // 再按**最长边二分**切细(算法本体在 `./wallCapTessellate`,零依赖纯函数 ⇒ 护栏能单独真跑)。
64
+ import { subdivideCapTriangles, WALL_CAP_MAX_EDGE_M } from "./wallCapTessellate";
65
+ // ⭐ 皇上旨 2026-10-08「json 里面的图片的地址,把域名去掉,只保留相对的根路径」:
66
+ // 导出时剥域名(`exportData`)、导入时补回来(`importData`)——**各只有这一处**,⛔ 不许在别处另剥/另补。
67
+ import { stripAssetHost, absAssetUrl } from "./assetUrl";
68
+ // ⭐ 皇上旨 2026-10-26「按这个方案,把图片平面和模型一起搬进库里」:这两层(含每帧喂瓦片那条编排)
69
+ // 原先各住在本站两个渲染器里一份 ⇒ 下游直接用包里的 `MktMesh` 时,存档里的 `images`/`models` 两键没人读。
70
+ // 入库之后它们挂在 `MktMesh` **自己的子树**下,与墙同一棵变换 ⇒ 预览那三根 transform 滑杆搬得动整份展品。
71
+ import { ImagePlane, type ImagePlaneData } from "./imagePlane";
72
+ import { ModelSlot, type ModelSlotData } from "./modelSlot";
73
+ import { ImagePlaneTileLoader } from "./imageTileLoader";
74
+
75
+ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
76
+ /** 纹理列表(每个纹理的 url + 世界矩阵) */
77
+ textures: MktTextureInfo[] = [];
78
+ /** 当前激活(正在绘制)的纹理索引,-1 表示尚未激活。⭐ 10-03 起该值 = 槽位名册的**中心**(`_slotPanos[0]`),不是「每帧纯最近那张」 */
79
+ activeIndex: number = -1;
80
+
81
+ /** 墙体地面折线点位(XZ 平面,世界坐标) */
82
+ wallPoints: WallPoint[] = [];
83
+ /** 墙体高度(沿 +Y 挤出) */
84
+ wallHeight: number = 3;
85
+ /** 墙体折线是否闭合(首尾相连成环) */
86
+ wallClosed: boolean = true;
87
+ /** 墙体正反面显示(字段保留兼容存档;09-29 晚旨后无论何值材质一律 BackSide,见 _wallSideToThree) */
88
+ wallSide: WallSide = "double";
89
+ /** 是否跳过墙体上/下多边形封盖(true = 只留侧面幕墙)。⚠️ 2026-09-26 核实:本库与编辑态**都取默认 false**
90
+ * (旧注释写「编辑态 MktDevMesh 设为 true」是错的 —— MktDevMesh 明确保留封盖,与发布态一致,
91
+ * 见其类头注释)。封盖与侧面**同一份几何、同一份材质** ⇒ 正反面(material.side)天然同步,
92
+ * 没有「封盖不跟墙体正反面」的第二套口径(这正是皇上 09-26 第①条要查的点)。 */
93
+ skipWallCaps: boolean = false;
94
+ /**
95
+ * ⭐ 皇上旨 2026-10-07「上下封口的三角面能不能不要那么长 / 三角面再细分一下,不要那么大」:
96
+ * 上/下封盖在耳切三角化之后,再按**最长边二分**切到每条边都 ≤ 本值(米)。粒度越细 ⇒
97
+ * 封盖上「一个三角面 = 一张全景」的区块越小,序号映射 / 取图在天花板和地面上分得越准
98
+ * (原先一个耳切大三角能横跨整个房间 ⇒ 整片天花板只能是同一张全景)。
99
+ * ⛔ 0 / 负数 = 不细分(= 10-07 之前的形状)。
100
+ * ⚠️ 改这个值会**改变面数** ⇒ 存档里的逐面序号按「块」继承(见 `importData` 那句 warn)。
101
+ */
102
+ wallCapMaxEdgeM: number = WALL_CAP_MAX_EDGE_M;
103
+
104
+ /**
105
+ * 全景「序号直接取」映射:每个三角面(faceIndex)→ 被指派的「全景全局序号」(-1=未设)。
106
+ * 序号直接取由着色器读取 geometry 的 aPanoIndex;与 panoIndices 槽数组配合取对应全景纹理。
107
+ * 长度 = 几何三角面数,随 rebuildWallGeometry 按位置重算(保留旧映射,见下方注释)。
108
+ * 该数据随工程存档持久化(MktMeshData.panoFaceMapping),编辑器/预览/发布三端同源。
109
+ */
110
+ panoFaceMapping: number[] = [];
111
+ /**
112
+ * 全景点坐标数组(每全景 3 个数 = 中心点 xyz,世界坐标)。
113
+ * ⭐ 2026-09-30 旨:全景点数据是**独立的纯几何数据**,与纹理图片是否加载/解码无关;
114
+ * 智能序号只认这份,不认 textures。由 setTextures/addTexture/removeTexture/setTexture(矩阵变更) 派生(_syncPanoCenters)。
115
+ * 本数组是「每全景 xyz」的**源**;序号映射最终烘焙成几何属性 `aPanoCenter`(每三角面9数,与 position 对齐),见 _syncPanoCenterAttr。
116
+ * ⚠️⚠️ 2026-10-01 血案修复:本字段改为**读取时实时派生**(getter),不再依赖「谁记得调 _syncPanoCenters」。
117
+ * 起因:`MktDevMesh.setTextures` 覆写了基类(编辑态不激活单全景),直接 `this.textures = textures` 而**不调
118
+ * super** ⇒ 派生点整条被绕过,`panoCenters` 恒为空 ⇒ 映射模式下点「智能序号」在 centerCount===0 处静默 return,
119
+ * 墙上全空且无任何提示。派生点式缓存在有子类覆写时就必然漏,**改为实时派生才是根治**(读一次 O(全景数),
120
+ * 调用方只有智能序号与 _syncPanoCenterAttr,都不是每帧路径)。
121
+ */
122
+ get panoCenters(): number[] {
123
+ this._syncPanoCenters();
124
+ return this._panoCentersCache;
125
+ }
126
+ /** 派生结果的落点(勿直接读写,统一走 panoCenters getter / _syncPanoCenters) */
127
+ private _panoCentersCache: number[] = [];
128
+ /** 旧几何的「面中心 XZ→{y, 序号}」索引,用于改墙/改高后按位置保留全景序号映射(皇上旨 2026-09-28 决策②)。 */
129
+ private _panoXZMap: Map<string, { y: number; idx: number }[]> = new Map();
130
+
131
+ /**
132
+ * ⭐ 槽位名册(皇上旨 2026-10-03「库侧也一并改成中心 ± 邻居」):每帧由 `updateTexture` 走
133
+ * `panoSlotRosterStep` 算出,**0 号槽 = 中心**(⭐ 10-07 第二十一道旨:离**控制器中心点** XZ 最近那张,
134
+ * 带 `PANO_SLOT_SWAP_MARGIN_M` 死区;锚点见 `_rosterCenter`),
135
+ * 1..7 = 中心在全景列表里的序号一层层往外扩的邻居(⭐ 10-07 旨「尽可能填满 8 个全景位置」⇒ 越界的序号不占格、
136
+ * 继续往有货那侧扩 ⇒ 填几格 = min(项目全景总数, 8),只有全景总数不够时才有空格)。
137
+ * ⇒ 与编辑态 `mktMeshEditRenderer._slotPanos` **同一份算法、同一个常数**(预览看到的那几张 = 编辑器里的那几张,均为 8 个全景槽)。
138
+ * 存**对象引用**不存下标(删点会让下标整体前移,引用不会认错人)。
139
+ * 消费方 = `feedFaceMapping`(墙 8 格);⭐ 10-03 旨② 后预览立方体与墙**共用同一份材质**,
140
+ * ⭐ 10-12 旨后立方体**逐颗自带序号**(`InstancedChild.panoIndex` → 逐实例 `aPanoIndex`),
141
+ * 不再是从前那句「几何没这条 attribute ⇒ `vPanoIndex ≡ 0` ⇒ 全体看 0 号槽那张」;
142
+ * 原来那条「把中心那几张抄给立方体单全景材质」的 `currentTiles` 旁路已整条删除。
143
+ */
144
+ private _slotPanos: (MktTextureInfo | undefined)[] = [];
145
+ /**
146
+ * ⭐ 名册那把尺子的**锚点**(皇上旨 2026-10-07 第二十一道旨:「判断使用哪些全景球的判断条件,
147
+ * 从距离相机的距离改成判断距离控制器的中心点的距离」)。这里存外部控制器的中心点
148
+ * (轨道控制器 = `controls.target`,也就是画面正中央那个注视点),由消费方经 `setRosterCenter()` 注入。
149
+ * · 只读 XZ ⇒ 与名册历代那把「水平距离」尺同一把(09-28 旨),高度差不进尺子。
150
+ * · `null` ⇒ 回落 `camera.position`(老口径):发布页那一路没有控制器引用,不注入就一切照旧。
151
+ * · 存的是**引用**(OrbitControls 原地改它那个 Vector3)⇒ 消费方设一次即可,不必每帧重设。
152
+ * · 注入的是**世界坐标**;`updateTexture` 里先 `worldToLocal` 换算到本网格局部空间再比距离
153
+ * (全景矩阵与着色器取图的 `vProjPos` 都在局部空间,见那里的 2026-10-11 丙旨说明)。
154
+ * ⛔ 反过来去给全景矩阵乘 `matrixWorld` 是错的:那会让 JS 侧的尺子和 shader 里的取图各走一路。
155
+ */
156
+ private _rosterCenter: Vector3 | null = null;
157
+ /** 锚点换算到局部空间的落点(每帧复用同一只临时量,避免分配) */
158
+ private _rosterAnchorLocal: Vector3 = new Vector3();
159
+ /**
160
+ * ⭐ 皇上旨 2026-10-03「整套搬进库(彻底同源)」:库侧不再自养「按全景序号取整槽、齐活才写」那一路,
161
+ * 改成与编辑态**同一份换绑策略**(`./panoSlotBind`)+ 同一口径的三张表:
162
+ * 地址→纹理缓存、已上传 GPU 集合、待上传排队集合。治的两个病:
163
+ * ① 换景点闪一帧灰(库侧原先没有「未就绪整槽沿用上一批」这层兜底);
164
+ * ② 图与投影矩阵来自**不同批**(原先矩阵无条件 copy,纹理却可能还在路上)。
165
+ * 地址 → 已解码纹理;值为 `null` = 该地址正在加载(占位 ⇒ 同一地址不会被问两次就下两遍)。
166
+ * ⚠️ 皇上旨 2026-10-02:运行中**不淘汰**任何一张,只在 `dispose()` 整批释放。
167
+ */
168
+ private _texCache: Map<string, Texture | null> = new Map();
169
+ /** 已上传 GPU 的纹理:没进这张表的**不许**绑进 shader(首绑现场 `texImage2D` 就是那一下卡顿,皇上旨 09-29「两个大头别在同一帧」) */
170
+ private _texUploaded: WeakSet<Texture> = new WeakSet();
171
+ /** 已排进全局队列、等着「只干上传那一句」的纹理(`addWork` 按闭包判重判不出来 ⇒ 必须自备集合) */
172
+ private _uploadQueued: Set<Texture> = new Set();
173
+ /** 加载地址真的拼了 ossSize 缩放参数的条目 ⇒ 换档只重载这些(真瓦片一律原图,不进此集) */
174
+ private _ossSizeDepKeys: Set<string> = new Set();
175
+ /** 渲染器:由 `onBeforeRender` 每帧捕获,预上传走它的 `initTexture`;没捕获到就退回 three 的自绑路径(不报错) */
176
+ private _glRenderer: WebGLRenderer | null = null;
177
+ /** ⭐ 32 瓦片(横 8 × 竖 4 切片,每槽 4×4=16 格、槽纹理 6144×6144)离屏拼合器:左右 2 槽纹理由它逐格拼出(每帧一张新纹理进 GPU + 已进 GPU 的逐格画)。
178
+ * 编辑器渲染器经 `panoStitcher` 复用同一实例(同一份槽纹理缓存,⛔ 不另建第二份显存)。
179
+ * 皇上旨 2026-10-06「不用 canvas 2D 的 drawImage 拼合, 用一个 shader 一个一个渲染到 RT 上面去」
180
+ * +「canvas 是什么意思, 按照我的想法, 不应该有 canvas 才对啊」⇒ **只此一份实现**:旧的 canvas 2D
181
+ * 那一份连同 `?panoStitch=canvas` 对照开关整条删除。⚠️ RT 是 GL 上下文私有资源 ⇒ 见拼合器 `_switchRenderer`。 */
182
+ private _stitcher: PanoStitcher = new PanoTileStitcher();
183
+ /** 拼合器只读访问(编辑器渲染器共用;⛔ 不另建第二实例) */
184
+ get panoStitcher(): PanoStitcher {
185
+ return this._stitcher;
186
+ }
187
+ /** 融合材质未绑纹理时的占位(避免空纹理采样报错;深灰兜底由着色器 uFaceMissingColor 负责) */
188
+ protected _placeholder!: Texture;
189
+ // 纹理下载/解码走 `loadPanoTexture`(甲,2026-10-05),结果按**地址**进 `_texCache`,运行中不淘汰、只随 `dispose()` 释放。
190
+ // ⛔ 别换回 `TextureLoader`:它只等 `img.onload`(不解码),解码会被拽到上传那一拍的 `texImage2D(<img>)` 上同步做完。
191
+ /** 纹理 OSS 缩放尺寸(1024 / 2048 / 空=原图),由外部 setOssSize 注入;默认 1024 保持旧行为。
192
+ * 编辑器渲染器与预览渲染器各自把 store.ossSize 推下来,使全景球/预览按该尺寸加载。 */
193
+ private _ossSize: "1024" | "2048" | "" = "1024";
194
+ /** 全景纹理形态:tiled=左右 2 槽各装一张**拼合槽纹理**(该点 32 片 横 8 × 竖 4 → 2 张 6144×6144;形态是 10-06 晚立的,槽尺寸 10-26 起是 6144²,2026-10-27 的切档一字未动)
195
+ * / whole=2 槽同为一张完整等距柱状原图。
196
+ * 由外部 setPanoTexMode 注入(与 _ossSize 同一套注入路径),默认 tiled 保持改造前行为。 */
197
+ private _panoTexMode: PanoTexMode = "tiled";
198
+ /** 当前全景纹理形态(只读)。消费方(预览立方体等自持材质的路径)据此算 `mktPanoTiledFlag`,
199
+ * 必须与基类喂纹理处用同一个值,否则标记与地址不同源 ⇒ 整图被按 1/2 横向铺满球面(10-06 晚起纵向不分块,四槽时代是 1/4)。 */
200
+ get panoTexMode(): PanoTexMode {
201
+ return this._panoTexMode;
202
+ }
203
+
204
+ // 复用临时对象(避免每帧 new,降低 GC 压力)
205
+ private _tmpV3: Vector3 = new Vector3();
206
+
207
+ /**
208
+ * 图片平面层(⭐ 10-26 旨入库):对象挂在**本 MktMesh 的子树**里,⛔ 不是场景根
209
+ * ⇒ 移动/旋转/缩放这个展品时,墙 + 全景 + 平面图 + 模型一起走。
210
+ */
211
+ imagePlanes: ImagePlane[] = [];
212
+ /** 模型槽层(同一棵子树,口径同上) */
213
+ models: ModelSlot[] = [];
214
+ /**
215
+ * 那 4 张瓦片的下载/解码缓存。⭐ 与全景 `_texCache` 同一条旨(10-02「纹理都缓存在对象里面,执行 dispose 时才销毁」)
216
+ * ⇒ 切工程**不**清它,回来那一圈直接命中缓存、不会再空一格;只随本对象 `dispose()` 释放。
217
+ */
218
+ private _imgTileLoader = new ImagePlaneTileLoader();
219
+ /**
220
+ * 逐帧绑定用的两张账(2026-10-26 定的方案:地址一次性全部丢给下载,绑材质改成「一帧一颗平面」)。
221
+ * 全局 rAF 每拍只跑一项 `addWork`,而 `addWork` 按闭包引用判重 ⇒ 每次现造的新闭包它判不出来,
222
+ * ⛔ 去重闸门必须像全景上传那张 `_uploadQueued` 一样自备。
223
+ */
224
+ private _imgBindQueued = new Set<ImagePlane>();
225
+ /** 平面 → 「它 4 条地址全部落定(就绪或失败)时的那串地址」⇒ 地址不换就不再重排;上传换图会改这串 ⇒ 自动重排 */
226
+ private _imgBindDone = new Map<ImagePlane, string>();
227
+ /**
228
+ * 模型槽「GLB 已取回、等这一帧把它挂进父元素」的在册表 —— 与上面图片绑定同一手法(用户 10-26 需求:
229
+ * 一次性全部拿去加载,用 `threeUtils.addWork` 逐帧加到场景里)。整层十几个槽同时到货时,
230
+ * 一帧只有一颗进 `_contentRoot`(进图那一拍要重算包围盒 + 排布 + 首次把几何送上 GPU)。
231
+ */
232
+ private _modelMountQueued = new Set<ModelSlot>();
233
+ /**
234
+ * 「压着不亮、等内容落定后一帧放一颗」的在册表(2026-10-28 需求:进展厅时平面图与 GLB 全在同几帧里
235
+ * 首次送 GPU —— 顶点缓冲上传 + program 编译都在「亮出来的那一帧」,几十颗挤一起就是那一下卡)。
236
+ *
237
+ * ⚠️ 为什么闸口在**本对象的 `onBeforeRender`**、不在这颗平面/模型自己的:three 遍历场景图的第一句就是
238
+ * `if ( object.visible === false ) return;`(`node_modules/three/build/three.module.js:29837`),
239
+ * 它和整棵子树当帧直接跳过 ⇒ 隐形对象**一次都不会**收到 `onBeforeRender`,挂在自己身上 = 永远不亮。
240
+ * 墙(本对象)每帧都进渲染列表,所以闸口设在这座上,在册表里逐帧放行。
241
+ * ⚠️ 单颗新增那两路(编辑态双击落点 / 复制 / 上传)⛔ 不进这张表 —— 刚加的东西必须当场看得见。
242
+ * ⚠️ `visible` 本是业务的旋钮,这里只是**借一帧**:整层重建那两路的对象是当场新建的(默认可见),所以进表前
243
+ * 不需要再看它一眼;但**放行那一拍之前**要接管某颗的显隐,必须调 `releaseReveal(o)` 摘牌,
244
+ * 否则它会在轮到自身那一帧被写回可见(`boolean` 只有一个位,放行那里分不出这笔是谁写的)。
245
+ */
246
+ private _revealPending: Array<ImagePlane | ModelSlot> = [];
247
+
248
+ constructor(opts: { wallHeight?: number; wallClosed?: boolean } = {}) {
249
+ // 发布/预览态墙:**固定**用「全景序号直取」着色器(皇上旨 2026-09-29:mktmesh 就只有按照 attribute
250
+ // 配置来显示全景这一种固定的 shader——每帧由 feedFaceMapping 按 panoFaceMapping 把被引用的全景
251
+ // 喂进 8 个全景槽,按三角面 aPanoIndex 取对应纹理;原 blend+uFaceMappingMode 运行时分支已拆出独立 shader)。
252
+ const material = createMktFloorMappingShaderMaterial({ maxPano: MKT_PANO_MAX_SLOTS });
253
+ const placeholder = createPlaceholderTexture();
254
+ super(new BufferGeometry(), material);
255
+ this._placeholder = placeholder;
256
+ // ⭐ 2026-09-26 彻查「正反面」:材质 face 朝向**只有一个写入口** = setWallSide()(内部走 _wallSideToThree)。
257
+ // 这里过去写死 `material.side = DoubleSide`,与那条映射并列出第二份「谁决定正面」的写法;
258
+ // 改成构造末尾用自身字段回灌:值仍是默认 "double" ⇒ **当时**画面逐字不变,但侧面上不再有两处口径。
259
+ // ⚠️ 那句「画面逐字不变」只在那一天成立:09-29 晚旨之后 `_wallSideToThree` 三档全落 BackSide ⇒
260
+ // 这一行回灌落地的**就是**反面(不是双面)。皇上旨 2026-10-07「mktmesh 的 shader 和 mktdevmesh 的映射 shader 保持一致」
261
+ // 之后预览也删了自己那句手写 DoubleSide 覆盖 ⇒ 「朝向只有一个写入口」这条对库侧/编辑态/预览三处同时成立。
262
+ // (必须在 super() 之后调用 —— this.wallSide 是类字段,早于 super() 读不到。)
263
+ this.setWallSide(this.wallSide);
264
+ // 保险:禁止视锥剔除,避免 boundingSphere 边界问题导致 GPU 跳过绘制
265
+ this.frustumCulled = false;
266
+ if (opts.wallHeight !== undefined) this.wallHeight = opts.wallHeight;
267
+ if (opts.wallClosed !== undefined) this.wallClosed = opts.wallClosed;
268
+ // 每帧渲染前:① 同步「投影空间逆矩阵」(让墙的投影与 MktMesh 整体变换解耦);
269
+ // ② 按**名册锚点**(⭐ 10-07 第二十一道旨:控制器中心点,未注入时才是相机位置)的 XZ 距离算**槽位名册**(中心 ± 邻居),中心换人时更新 `activeIndex`;
270
+ // ③ 发布/预览态墙按全景序号直接取喂食,8 格 = 名册那 8 张(皇上旨 2026-09-28 决策③ + 10-03「库侧同口径」)
271
+ this.onBeforeRender = (renderer, _scene, camera) => {
272
+ this._glRenderer = renderer;
273
+ this.syncProjInvMatrix();
274
+ this.updateTexture(camera as Camera);
275
+ this.feedFaceMapping();
276
+ this.tickProgressiveReveal();
277
+ };
278
+ }
279
+
280
+ // ───────────────────────── 墙体几何(由点位挤出) ─────────────────────────
281
+
282
+ /**
283
+ * 设置墙体地面折线点位,并重建墙体几何。
284
+ * @param points 地面折线点位(XZ 平面,世界坐标),至少 2 个点才能生成墙体
285
+ * @param opts.closed 是否闭合(首尾相连成环),默认沿用当前 wallClosed
286
+ * @param opts.height 墙高(沿 +Y 挤出),默认沿用当前 wallHeight
287
+ */
288
+ setWallPoints(points: WallPoint[], opts: { closed?: boolean; height?: number } = {}): void {
289
+ // ⚠️ 必须保留每个点的 y(离地高度),否则「墙点 Y 高度可调」会丢值
290
+ this.wallPoints = points.map((p) => ({ x: p.x, y: p.y ?? 0, z: p.z }));
291
+ if (opts.closed !== undefined) this.wallClosed = opts.closed;
292
+ if (opts.height !== undefined) this.wallHeight = opts.height;
293
+ this.rebuildWallGeometry();
294
+ }
295
+
296
+ /** 读取墙点位(副本,含 y,调用方可自行修改再回设) */
297
+ getWallPoints(): WallPoint[] {
298
+ return this.wallPoints.map((p) => ({ x: p.x, y: p.y, z: p.z }));
299
+ }
300
+
301
+ /**
302
+ * 墙正面/反面枚举 → three.js Side 常量。
303
+ * ⭐ 2026-09-29 晚旨「shader 设反面显示、不要双面」:三档**全部**落到 BackSide(对应历史上
304
+ * 的 wallSide="back" 档 —— 零厚度幕墙只画**朝房间内的那一面**,从房间外看穿过去、看不到内部全景)。
305
+ * API 形参与 wallSide 字段保留(存档/调用方兼容)。⚠️ 地板/立方体**不由这里控制**:编辑态地板恒走「仅显示选中」工厂(DoubleSide);
306
+ * 立方体在 mapping 档与墙**共用同一份材质实例**(皇上旨 2026-10-02),⇒ 朝向跟着墙 = BackSide,
307
+ * 另两档(blend / selected)另建一份 mapping 并显式拉回 DoubleSide(见渲染器 `_applyDisplayMode`;预览自 10-07 旨起与编辑态同口径)。
308
+ * ⚠️ 若实测房间内反而看不到墙(几何法线朝向与预期相反),把这里的 BackSide 换成 FrontSide 即可(一行)。
309
+ */
310
+ private _wallSideToThree(_side: WallSide): Side {
311
+ return BackSide;
312
+ }
313
+
314
+ /** 设置墙体正反面显示(**材质 side 的唯一写入口**;参数保留兼容,当前无论传什么都渲染**反面**,见 _wallSideToThree) */
315
+ setWallSide(side: WallSide): void {
316
+ this.wallSide = side;
317
+ (this.material as Material).side = this._wallSideToThree(side);
318
+ }
319
+
320
+ /** 由 wallPoints / wallHeight / wallClosed 重建墙体几何:侧面(ExtrudeMesh) + 上下封盖(ShapeGeometry) */
321
+ private rebuildWallGeometry(): void {
322
+ const pts = this.wallPoints;
323
+ const height = this.wallHeight;
324
+ const positions: number[] = [];
325
+
326
+ // ── 全景序号映射:改墙/改高后按位置保留(皇上旨 2026-09-28 决策②)──
327
+ // 捕获旧几何面中心 + 旧映射(this.geometry 此刻仍是旧几何,尚未 dispose)。
328
+ // ⭐ 10-07 封盖细分之后:新面中心**多半不再落在旧面中心那几个 XZ 上**(细分是在三角形内部加了一堆点)
329
+ // ⇒ 只认「XZ 键精确相等」的话,封盖那一整片序号会被丢光 ⇒ 这里多留一份**旧三角形的 XZ 顶点**,
330
+ // 新面中心改用「它落在哪个旧三角形里」来认祖(点在三角形内),认不到才退回精确键那一路。
331
+ const oldPosAttr = this.geometry.getAttribute("position") as BufferAttribute | undefined;
332
+ const oldFaceCount = oldPosAttr ? Math.floor(oldPosAttr.count / 3) : 0;
333
+ const oldCentroids: { x: number; y: number; z: number }[] = new Array(oldFaceCount);
334
+ const oldTris: { ax: number; az: number; bx: number; bz: number; cx: number; cz: number; y: number; idx: number }[] = [];
335
+ const oldMapping = this.panoFaceMapping;
336
+ for (let f = 0; f < oldFaceCount; f++) {
337
+ const a = 3 * f, b = 3 * f + 1, c = 3 * f + 2;
338
+ const ax = oldPosAttr!.getX(a), ay = oldPosAttr!.getY(a), az = oldPosAttr!.getZ(a);
339
+ const bx = oldPosAttr!.getX(b), by = oldPosAttr!.getY(b), bz = oldPosAttr!.getZ(b);
340
+ const cx = oldPosAttr!.getX(c), cy = oldPosAttr!.getY(c), cz = oldPosAttr!.getZ(c);
341
+ oldCentroids[f] = { x: (ax + bx + cx) / 3, y: (ay + by + cy) / 3, z: (az + bz + cz) / 3 };
342
+ oldTris.push({ ax, az, bx, bz, cx, cz, y: (ay + by + cy) / 3, idx: oldMapping[f] ?? -1 });
343
+ }
344
+
345
+ if (pts.length >= 2) {
346
+ // —— 墙体侧面:复用 ExtrudeMesh,shapePath=[[0,0],[0,height]] 沿地面折线挤出一道幕墙 ——
347
+ // 路径点的 vector3[1] = p.y,即墙基离地高度;ExtrudeMesh 把整个截面抬到该高度再向上挤出 height,
348
+ // 故底面落在 p.y、顶面落在 p.y + height(支点位高低错落 / 阶梯墙基)。
349
+ const paths = pts.map((p, i) => ({
350
+ vector3: [p.x, p.y, p.z] as [number, number, number],
351
+ radius: 0,
352
+ segments: 0,
353
+ scale: { x: 1, y: 1 },
354
+ name: `w${i}`,
355
+ }));
356
+ const tmp = new ExtrudeMesh(
357
+ { paths, shapePath: [[0, 0], [0, height]], closed: this.wallClosed },
358
+ undefined as unknown as Material
359
+ );
360
+ const sidePos = tmp.geometry.getAttribute("position");
361
+ for (let i = 0; i < sidePos.count; i++) {
362
+ positions.push(sidePos.getX(i), sidePos.getY(i), sidePos.getZ(i));
363
+ }
364
+ tmp.geometry.dispose();
365
+
366
+ // —— 墙体上/下两个多边形封盖:用 three.js 自带的 ShapeGeometry 由点位三角化 ——
367
+ // ⚠️ 2026-09-26 更正:此前这句注释写的是「编辑态只要幕墙、不要封顶」——**与事实相反**,勿再照它理解。
368
+ // `skipWallCaps` 在库侧与编辑态 `MktDevMesh` 都保持默认 false —— 封盖与侧面**推进同一份 geometry、
369
+ // 共用同一份 material**(见下方 positions.push 与 `this.geometry = geo`),故「墙体正反面」参数一改,
370
+ // 封盖与侧面同帧跟着翻,不存在第二处需要同步的 side。本开关只留给「只要幕墙不要封顶」的外部调用方,
371
+ // 本项目内**无人置 true**。
372
+ if (!this.skipWallCaps) {
373
+ // Shape 用 2D 坐标(x, z);rotateX(90°) 把 (x,y,0)→(x,0,y),故 shape 的 (px,pz) 落回世界 (px,0,pz),与侧面点位一致。
374
+ // 每个封盖顶点按 (x,z) 反查对应点位的 y:下封盖 y=p.y,上封盖 y=p.y+height,使封盖随墙基高低错落。
375
+ const shape = new Shape();
376
+ pts.forEach((p, i) => {
377
+ if (i === 0) shape.moveTo(p.x, p.z);
378
+ else shape.lineTo(p.x, p.z);
379
+ });
380
+ if (this.wallClosed) shape.closePath();
381
+ const cap = new ShapeGeometry(shape);
382
+ cap.rotateX(Math.PI / 2);
383
+ const capNI = cap.toNonIndexed();
384
+ const capPos = capNI.getAttribute("position");
385
+ // 坐标→Y 映射:**三角化后的顶点都是多边形点的副本**(没有插值点)⇒ 可按 (x,z) 精确落回点位高度。
386
+ // ⚠️ 细分切出来的**内部点**不在多边形点的位置上 ⇒ 这张表查不到(会打成 0 ⇒ 封盖塌下去),
387
+ // 它们的 y 由下面的细分函数按两端平均插值(落在原平面上)。
388
+ const yByXZ = new Map<string, number>();
389
+ for (const p of pts) yByXZ.set(`${p.x},${p.z}`, p.y);
390
+ const yOf = (x: number, z: number): number => {
391
+ const v = yByXZ.get(`${x},${z}`);
392
+ return v === undefined ? 0 : v;
393
+ };
394
+ // ⭐ 10-07 旨「封盖三角面不要那么长 / 再细分一下」:先把封盖三角化结果摊成扁平三角形
395
+ // (每个顶点带上它那处墙基的 y),再按**最长边二分**切到每条边都不长于 `wallCapMaxEdgeM`
396
+ // (0 / 负数 = 不细分)。细分只在封盖平面内加顶点 ⇒ **形状一点不变,只是面变小**。
397
+ const rawCap: number[] = [];
398
+ for (let i = 0; i < capPos.count; i++) {
399
+ const x = capPos.getX(i);
400
+ const z = capPos.getZ(i);
401
+ rawCap.push(x, yOf(x, z), z);
402
+ }
403
+ const capTris = subdivideCapTriangles(rawCap, this.wallCapMaxEdgeM);
404
+ // 下封盖(level=0)→ y=p.y;上封盖(level=height)→ y=p.y+height
405
+ for (let lv = 0; lv <= 1; lv++) {
406
+ const base = lv === 0 ? 0 : height;
407
+ if (lv === 0) {
408
+ // 下封盖:顶点顺序与 shape 一致,法线朝下(指向地面外侧)
409
+ for (let i = 0; i < capTris.length; i += 9) {
410
+ positions.push(capTris[i], capTris[i + 1] + base, capTris[i + 2]);
411
+ positions.push(capTris[i + 3], capTris[i + 4] + base, capTris[i + 5]);
412
+ positions.push(capTris[i + 6], capTris[i + 7] + base, capTris[i + 8]);
413
+ }
414
+ } else {
415
+ // 上封盖:每个三角形的**顶点顺序倒过来** ⇒ 绕序翻转 ⇒ 法线由朝下翻为朝上(朝天/朝外,
416
+ // 与「盒子封盖朝外」一致;单面(front/back)模式下从外部俯视可见、内部仰视不可见)。
417
+ for (let i = 0; i < capTris.length; i += 9) {
418
+ positions.push(capTris[i + 6], capTris[i + 7] + base, capTris[i + 8]);
419
+ positions.push(capTris[i + 3], capTris[i + 4] + base, capTris[i + 5]);
420
+ positions.push(capTris[i], capTris[i + 1] + base, capTris[i + 2]);
421
+ }
422
+ }
423
+ }
424
+ capNI.dispose();
425
+ cap.dispose();
426
+ }
427
+ }
428
+
429
+ const geo = new BufferGeometry();
430
+ geo.setAttribute("position", new Float32BufferAttribute(positions, 3));
431
+ // ⭐ 必须算法线(皇上旨 2026-09-24「模型面上映射要先判全景点方向与面法线夹角 <90°」):
432
+ // 本几何是非索引三角形汤(侧面 + 上下封盖),computeVertexNormals 因此给出**逐面**法线,
433
+ // 正好就是「面的法线」。缺它着色器读到 (0,0,0) ⇒ 点积恒 0 ⇒ 夹角门永远筛不到候选(会走兜底,不崩但白改)。
434
+ geo.computeVertexNormals();
435
+
436
+ // ── 按位置重算全景序号映射(保留旧指派,决策②)──
437
+ const faceCount = Math.floor(positions.length / 9);
438
+ const newMapping: number[] = new Array(faceCount).fill(-1);
439
+ const rk = (v: number): number => Math.round(v * 1000) / 1000; // 1mm 量化做 XZ 键
440
+ // 旧 XZ 索引:同一 XZ 可能对应上下两个封盖(y 不同),故一个键挂一个 {y,idx} 列表
441
+ const oldMap = new Map<string, { y: number; idx: number }[]>();
442
+ for (let f = 0; f < oldFaceCount; f++) {
443
+ const c = oldCentroids[f];
444
+ const key = `${rk(c.x)},${rk(c.z)}`;
445
+ const arr = oldMap.get(key) ?? [];
446
+ arr.push({ y: c.y, idx: oldMapping[f] ?? -1 });
447
+ oldMap.set(key, arr);
448
+ }
449
+ // ⭐ 10-07 封盖细分后**新加的一层**:新面中心「落在哪个旧三角形里」就继承那一片的序号。
450
+ // 只在上面那道精确 XZ 键**没命中**时才走(命中 = 老口径,一个字没改)。
451
+ // ⚠️ 非加不可的理由:封盖被切成几百上千个小面后,新面中心几乎不可能正好等于某个旧面中心
452
+ // ⇒ 没有这一层,改墙高 / 改墙点之后封盖的序号会整片变 -1(得重跑一次「智能序号」才看得见图)。
453
+ const insideBudget = oldTris.length * faceCount <= 4000000; // 兜底闸:面数大到离谱就跳过这层,别把改墙那一下卡死
454
+ const side2 = (ax: number, az: number, bx: number, bz: number, px: number, pz: number): number =>
455
+ (bx - ax) * (pz - az) - (bz - az) * (px - ax);
456
+ /** 点在 XZ 平面上的三角形内(叉积同号)判据:命中多个(上下封盖同 XZ)时取 y 最近那一片 */
457
+ const pickInside = (px: number, pz: number, cy: number): number => {
458
+ let best = -1;
459
+ let bestD = Infinity;
460
+ for (let i = 0; i < oldTris.length; i++) {
461
+ const t = oldTris[i];
462
+ const d1 = side2(t.ax, t.az, t.bx, t.bz, px, pz);
463
+ const d2 = side2(t.bx, t.bz, t.cx, t.cz, px, pz);
464
+ const d3 = side2(t.cx, t.cz, t.ax, t.az, px, pz);
465
+ // 三个叉积里有正有负 ⇒ 点在三角形外(全 0 = 退化成一条线的面,也算命中,交给 y 差挑)
466
+ if ((d1 > 0 || d2 > 0 || d3 > 0) && (d1 < 0 || d2 < 0 || d3 < 0)) continue;
467
+ const d = Math.abs(t.y - cy);
468
+ if (d < bestD) { bestD = d; best = t.idx; }
469
+ }
470
+ return best;
471
+ };
472
+ for (let f = 0; f < faceCount; f++) {
473
+ const a = 9 * f;
474
+ const cx = (positions[a] + positions[a + 3] + positions[a + 6]) / 3;
475
+ const cz = (positions[a + 2] + positions[a + 5] + positions[a + 8]) / 3;
476
+ const cy = (positions[a + 1] + positions[a + 4] + positions[a + 7]) / 3;
477
+ const cand = oldMap.get(`${rk(cx)},${rk(cz)}`);
478
+ if (cand && cand.length) {
479
+ // 同 XZ 多个候选(上/下封盖)时取 y 最近者,避免把底盖映射错配到顶盖
480
+ let best = cand[0];
481
+ let bestD = Math.abs(best.y - cy);
482
+ for (let i = 1; i < cand.length; i++) {
483
+ const d = Math.abs(cand[i].y - cy);
484
+ if (d < bestD) { bestD = d; best = cand[i]; }
485
+ }
486
+ newMapping[f] = best.idx;
487
+ } else if (insideBudget && oldTris.length) {
488
+ const v = pickInside(cx, cz, cy);
489
+ if (v >= 0) newMapping[f] = v; // -1 = 那一片本来就没指派,保持未指派
490
+ }
491
+ }
492
+ this.panoFaceMapping = newMapping;
493
+ // 重建 XZ 索引(供下次改墙保留)
494
+ const xzMap = new Map<string, { y: number; idx: number }[]>();
495
+ for (let f = 0; f < faceCount; f++) {
496
+ const a = 9 * f;
497
+ const cx = (positions[a] + positions[a + 3] + positions[a + 6]) / 3;
498
+ const cz = (positions[a + 2] + positions[a + 5] + positions[a + 8]) / 3;
499
+ const cy = (positions[a + 1] + positions[a + 4] + positions[a + 7]) / 3;
500
+ const key = `${rk(cx)},${rk(cz)}`;
501
+ const arr = xzMap.get(key) ?? [];
502
+ arr.push({ y: cy, idx: newMapping[f] });
503
+ xzMap.set(key, arr);
504
+ }
505
+ this._panoXZMap = xzMap;
506
+ // aPanoIndex 几何属性(每三角面 3 顶点同值 = 该面归属的全景全局序号;默认 -1 = 未设)
507
+ const panoIdxAttr = new Float32Array(faceCount * 3);
508
+ for (let f = 0; f < faceCount; f++) {
509
+ const v = newMapping[f] ?? -1;
510
+ panoIdxAttr[3 * f] = v;
511
+ panoIdxAttr[3 * f + 1] = v;
512
+ panoIdxAttr[3 * f + 2] = v;
513
+ }
514
+ geo.setAttribute("aPanoIndex", new Float32BufferAttribute(panoIdxAttr, 1));
515
+
516
+ this.geometry.dispose();
517
+ this.geometry = geo;
518
+ this._syncPanoCenterAttr(); // ⭐ 2026-09-30:同步 aPanoCenter(每三角面9数,与 position 对齐)
519
+ }
520
+
521
+ /**
522
+ * 设置某个三角面(faceIndex)归属的全景全局序号;-1=清除(序号模式下显示深灰兜底色)。
523
+ * 更新映射数组 + 几何 aPanoIndex 属性(仅该面 3 顶点),并同步 XZ 索引供后续改墙保留。
524
+ */
525
+ setFacePanoIndex(faceIndex: number, globalIndex: number): void {
526
+ if (faceIndex < 0 || faceIndex >= this.panoFaceMapping.length) return;
527
+ this.panoFaceMapping[faceIndex] = globalIndex;
528
+ const attr = this.geometry.getAttribute("aPanoIndex") as Float32BufferAttribute | undefined;
529
+ if (attr && attr.count === this.panoFaceMapping.length * 3) {
530
+ attr.setXYZ(faceIndex * 3, globalIndex, globalIndex, globalIndex);
531
+ attr.needsUpdate = true;
532
+ // ⭐ 2026-09-30:同步该面 aPanoCenter(每三角面9数,与 position 对齐)
533
+ const cAttr = this.geometry.getAttribute("aPanoCenter") as Float32BufferAttribute | undefined;
534
+ if (cAttr && cAttr.count === this.panoFaceMapping.length * 3) {
535
+ const nPano = this.panoCenters.length / 3;
536
+ let cx = 0, cy = 0, cz = 0;
537
+ if (globalIndex >= 0 && globalIndex < nPano) {
538
+ cx = this.panoCenters[globalIndex * 3];
539
+ cy = this.panoCenters[globalIndex * 3 + 1];
540
+ cz = this.panoCenters[globalIndex * 3 + 2];
541
+ }
542
+ for (let v = 0; v < 3; v++) cAttr.setXYZ(faceIndex * 3 + v, cx, cy, cz);
543
+ cAttr.needsUpdate = true;
544
+ }
545
+ } else {
546
+ // 属性缺失/长度不符(极少):整面重建(按位置保留会把它当成 -1,但此处是显式指定,可接受)
547
+ this.rebuildWallGeometry();
548
+ }
549
+ this._rebuildXZMap();
550
+ }
551
+
552
+ /** 重建 XZ 索引(映射变化后调用,保证下次改墙按位置保留) */
553
+ private _rebuildXZMap(): void {
554
+ const pos = this.geometry.getAttribute("position") as BufferAttribute | undefined;
555
+ const faceCount = pos ? Math.floor(pos.count / 3) : 0;
556
+ const rk = (v: number): number => Math.round(v * 1000) / 1000;
557
+ const m = new Map<string, { y: number; idx: number }[]>();
558
+ for (let f = 0; f < faceCount; f++) {
559
+ const a = 9 * f;
560
+ const cx = (pos!.getX(a) + pos!.getX(a + 3) + pos!.getX(a + 6)) / 3;
561
+ const cz = (pos!.getZ(a) + pos!.getZ(a + 3) + pos!.getZ(a + 8)) / 3;
562
+ const cy = (pos!.getY(a) + pos!.getY(a + 3) + pos!.getY(a + 7)) / 3;
563
+ const key = `${rk(cx)},${rk(cz)}`;
564
+ const arr = m.get(key) ?? [];
565
+ arr.push({ y: cy, idx: this.panoFaceMapping[f] ?? -1 });
566
+ m.set(key, arr);
567
+ }
568
+ this._panoXZMap = m;
569
+ }
570
+
571
+ /** 三角面总数(= 几何顶点数 / 3,因非索引三角形汤) */
572
+ getFaceCount(): number {
573
+ const pos = this.geometry.getAttribute("position") as BufferAttribute | undefined;
574
+ return pos ? Math.floor(pos.count / 3) : 0;
575
+ }
576
+
577
+ /**
578
+ * 返回被三角面引用的全景全局序号集合(去重),按引用面数降序、最多 maxSlots 个。
579
+ * ⚠️ 2026-10-03 起**不在喂料路上**(墙那 8 格改由槽位名册决定,见 `feedFaceMapping`);保留为公共查询 API
580
+ * (外部若要按「哪几张全景被指派得最多」统计仍可用),⛔ 不许再拿它决定 shader 装哪几张。
581
+ */
582
+ getReferencedPanoIndices(maxSlots: number): number[] {
583
+ const cnt = new Map<number, number>();
584
+ for (const idx of this.panoFaceMapping) {
585
+ if (idx >= 0) cnt.set(idx, (cnt.get(idx) ?? 0) + 1);
586
+ }
587
+ const sorted = [...cnt.entries()].sort((a, b) => b[1] - a[1]).map((e) => e[0]);
588
+ return sorted.slice(0, maxSlots);
589
+ }
590
+
591
+ /** 仅重建 aPanoIndex 几何属性(映射数据变化、几何未变时调用,避免整面重建) */
592
+ rebuildPanoIndexAttribute(): void {
593
+ const faceCount = this.panoFaceMapping.length;
594
+ const attr = this.geometry.getAttribute("aPanoIndex") as Float32BufferAttribute | undefined;
595
+ if (attr && attr.count === faceCount * 3) {
596
+ for (let f = 0; f < faceCount; f++) {
597
+ const v = this.panoFaceMapping[f] ?? -1;
598
+ attr.setXYZ(f * 3, v, v, v);
599
+ }
600
+ attr.needsUpdate = true;
601
+ } else {
602
+ this.rebuildWallGeometry();
603
+ }
604
+ this._rebuildXZMap();
605
+ this._syncPanoCenterAttr(); // ⭐ 2026-09-30:同步 aPanoCenter(每三角面9数,与 position 对齐)
606
+ }
607
+
608
+ // ───────────────────────── 纹理列表管理 ─────────────────────────
609
+
610
+ /** 从 textures 各矩阵平移分量(elements[12,13,14],即墨卡托投影中心)派生 panoCenters(每全景 xyz)。
611
+ * 全景点位置与纹理图片加载解耦,纯几何;智能序号只依赖这份数据。 */
612
+ protected _syncPanoCenters(): void {
613
+ const out: number[] = new Array(this.textures.length * 3);
614
+ for (let i = 0; i < this.textures.length; i++) {
615
+ const e = this.textures[i].matrix.elements;
616
+ out[i * 3] = e[12];
617
+ out[i * 3 + 1] = e[13];
618
+ out[i * 3 + 2] = e[14];
619
+ }
620
+ // ⚠️ 写缓存字段而非 this.panoCenters —— 后者是 getter,会递归回本方法
621
+ this._panoCentersCache = out;
622
+ }
623
+
624
+ /**
625
+ * 写 aPanoCenter 几何属性:每三角面 9 数(该面归属全景的中心 xyz,复制到 3 顶点),
626
+ * itemSize=3、count = position.count,与 position **完全对齐**
627
+ * (皇上旨 2026-09-30 第③点:「每个三角面 9 个数一组,全景也按 9 数一组、和顶点数组长度对齐」)。
628
+ * 源 = panoCenters(每全景 xyz);面未映射(gi<0 或越界)填 0。
629
+ * rebuildWallGeometry / rebuildPanoIndexAttribute / setFacePanoIndex 都会调它保持同步。
630
+ */
631
+ private _syncPanoCenterAttr(): void {
632
+ const faceCount = this.panoFaceMapping.length;
633
+ const posAttr = this.geometry.getAttribute("position") as BufferAttribute | undefined;
634
+ if (!posAttr || posAttr.count !== faceCount * 3) return; // 几何与映射不一致先跳过
635
+ const centers = this.panoCenters;
636
+ const nPano = centers.length / 3;
637
+ let attr = this.geometry.getAttribute("aPanoCenter") as Float32BufferAttribute | undefined;
638
+ if (!attr || attr.count !== faceCount * 3) {
639
+ attr = new Float32BufferAttribute(new Float32Array(faceCount * 9), 3);
640
+ this.geometry.setAttribute("aPanoCenter", attr);
641
+ }
642
+ const arr = attr.array as Float32Array;
643
+ for (let f = 0; f < faceCount; f++) {
644
+ const gi = this.panoFaceMapping[f] ?? -1;
645
+ let cx = 0, cy = 0, cz = 0;
646
+ if (gi >= 0 && gi < nPano) {
647
+ cx = centers[gi * 3]; cy = centers[gi * 3 + 1]; cz = centers[gi * 3 + 2];
648
+ }
649
+ const a = 9 * f; // 每三角面 9 数,与 position 同布局
650
+ for (let v = 0; v < 3; v++) {
651
+ const o = a + v * 3;
652
+ arr[o] = cx; arr[o + 1] = cy; arr[o + 2] = cz;
653
+ }
654
+ }
655
+ attr.needsUpdate = true;
656
+ }
657
+
658
+ /** 设置纹理列表并激活第一个(或最近的)纹理 */
659
+ setTextures(textures: MktTextureInfo[]): void {
660
+ // ⭐ 皇上旨 2026-10-02「纹理都缓存在对象里面,执行 dispose 时才销毁」⇒ 换工程**不**清 `_texCache`
661
+ // (旧项目的瓦片留到本对象 dispose 再释放;清它等于下次切回来重走一遍下载 + 解码 = 卡顿源头)。
662
+ // 要清的只有**名册**:里面躺的是上一批 `MktTextureInfo` 对象引用,留着会让下一帧拿已删景点的 URL 去喂槽。
663
+ this._slotPanos = [];
664
+ this.textures = textures;
665
+ this.activeIndex = -1;
666
+ this._syncPanoCenters(); // ⭐ 2026-09-30:派生全景点坐标(与纹理解耦)
667
+ if (this.textures.length > 0) {
668
+ this.activateTexture(0);
669
+ }
670
+ }
671
+
672
+ /** 追加一个纹理 */
673
+ addTexture(info: MktTextureInfo): void {
674
+ this.textures.push(info);
675
+ this._syncPanoCenters();
676
+ }
677
+
678
+ /** 删除一个纹理;若删除的是当前激活纹理则回退,索引随之前移 */
679
+ removeTexture(index: number): void {
680
+ if (index < 0 || index >= this.textures.length) {
681
+ return;
682
+ }
683
+ this.textures.splice(index, 1);
684
+ this._syncPanoCenters();
685
+ // ⭐ 10-03 旨⑤:缓存键是**地址**不是「全景序号」⇒ 删点导致序号整体前移**不需要**清缓存
686
+ // (旧写法 `_invalidateTileCache()` 是因为按序号缓存、不清就把旧地址挂在新序号上);
687
+ // 名册存对象引用 ⇒ 被删的那个对象下一帧自然从名册里掉出去(`panoSlotRosterStep` 只认 dists 里还有的人)。
688
+ if (this.activeIndex === index) {
689
+ this.activeIndex = -1;
690
+ } else if (this.activeIndex > index) {
691
+ this.activeIndex--;
692
+ }
693
+ }
694
+
695
+ /**
696
+ * 更新单个纹理的 url / 名称 / 矩阵;若改的是「当前激活纹理」则同步生效。
697
+ *
698
+ * ⚠️ 需求铁律(防止回归):墙面绘制的是「当前激活纹理」,其朝向由 shader 的 textureMatrix uniform 决定。
699
+ * 所以只要激活纹理的位置/旋转变了,必须**立即**把新矩阵 copy 进 textureMatrix uniform——
700
+ * 否则用户旋转/移动全景球时,平面纹理不会跟着一起转。
701
+ * 另:改旋转不要触发纹理网络 reload,只有 url 真正变化才重载。
702
+ */
703
+ setTexture(index: number, opts: { url?: string; name?: string; matrix?: Matrix4; tiles?: string[] }): void {
704
+ if (index < 0 || index >= this.textures.length) {
705
+ return;
706
+ }
707
+ const cur = this.textures[index];
708
+ if (opts.url !== undefined) {
709
+ cur.url = opts.url;
710
+ }
711
+ // 名称只是标识,不参与渲染,改它**不**触发纹理 reload(见下方 opts.url 分支)
712
+ if (opts.name !== undefined) {
713
+ cur.name = opts.name;
714
+ }
715
+ if (opts.tiles !== undefined) {
716
+ cur.tiles = opts.tiles;
717
+ }
718
+ if (opts.matrix !== undefined) {
719
+ cur.matrix.copy(opts.matrix);
720
+ this._syncPanoCenters(); // ⭐ 全景位置/旋转一变,全景点坐标同步派生
721
+ }
722
+ if (index === this.activeIndex) {
723
+ // 关键:激活纹理的矩阵一变,立刻同步到 shader uniform(墙面纹理才会跟着转)。
724
+ // ⚠️ 基类材质现改为融合形态(textureMatrices 数组),无单数 textureMatrix;
725
+ // 单全景形态用 textureMatrix、融合形态用 textureMatrices[0],两者都接住。
726
+ const tm = (this.material.uniforms.textureMatrix?.value ??
727
+ this.material.uniforms.textureMatrices?.value?.[0]) as Matrix4 | undefined;
728
+ if (tm) tm.copy(cur.matrix);
729
+ // ⭐ 10-03 旨⑤:改地址/瓦片**不需要**在这里手工失效重载 —— 缓存按地址建键,新 URL 天然是新条目,
730
+ // 下一帧 `feedFaceMapping` 见「账本里的地址 ≠ 目标地址」就走整槽换人(判不齐则沿用上一批),
731
+ // 跨形态(32 片拼合 ↔ 整片)那一次由 binder 的整档判齐支负责。旧写法(清序号缓存 + 重新 activate)已整条删除。
732
+ }
733
+ }
734
+
735
+ /**
736
+ * 把 inverse(matrixWorld) 写进当前材质的 uProjInvMatrix(每帧一次)—— 「投影与自身变换解耦」的唯一写入点。
737
+ *
738
+ * ⚠️ 需求铁律(皇上旨 2026-09-19):「墙的矩阵和全景的矩阵是相对的,无论 MktMesh 模型整体
739
+ * 怎么移动/旋转/缩放,墙上的纹理都保持不变」。
740
+ * 达成条件是「取样方向的两个操作数都对 MktMesh 的整体变换免疫」:
741
+ * · 全景点矩阵(textureMatrix)是**世界矩阵**、属独立于 MktMesh 的外部数据 → 本就不随它动;
742
+ * · 墙顶点经 modelMatrix 升到世界坐标 → **会**随它动 → 一个动一个不动,方向即变,纹理在墙上滑动。
743
+ * 故此处把 inverse(matrixWorld) 交给着色器,把墙顶点也拉回「与整体变换无关的几何局部坐标」,
744
+ * 两个操作数双双免疫,方向恒定,纹理稳稳贴在墙上。
745
+ * ⚠️ M ≠ I 时 vProjPos(局部)与 textureMatrix(世界)并不在同一空间 —— 这是**刻意**的:
746
+ * 皇上要的是「纹理贴在墙上不动」,条件是「两个操作数都不随 M 变」,而非「同处一个空间」。
747
+ * 千万别"好心"再去把 textureMatrix 也乘一遍 inverse(matrixWorld),那会让纹理重新动起来。
748
+ *
749
+ * 每帧无条件重算(一次 4x4 求逆,代价可忽略):用户拖预览变换面板时 matrixWorld 每帧都在变,
750
+ * 靠「变更时通知」容易漏,无条件同步最稳。材质被替换(如编辑态 blend↔single 重建)时按当前材质重新取
751
+ * uniform,故 **MktDevMesh 也必须每帧调用本方法**(它同样会重建材质,见该类 onBeforeRender 注释)。
752
+ * 材质没有该 uniform(旧材质/第三方材质)时静默跳过 —— 退化为「世界坐标」的旧行为。
753
+ */
754
+ syncProjInvMatrix(): void {
755
+ const mat = this.material as ShaderMaterial;
756
+ const u = mat && mat.uniforms ? mat.uniforms.uProjInvMatrix : undefined;
757
+ if (!u) return;
758
+ (u.value as Matrix4).copy(this.matrixWorld).invert();
759
+ }
760
+
761
+ // ───────────────────────── 每帧最近纹理计算 ─────────────────────────
762
+
763
+ /** 每帧算「离**名册锚点**最近」的水平距离(XZ)⇒ 走**槽位名册**(中心 ± 邻居)⇒ 名册中心变化时切换激活纹理。
764
+ * ⭐ 皇上旨 2026-10-07 第二十一道旨:锚点 = 外部控制器的中心点(`setRosterCenter` 注入的轨道注视点);
765
+ * 没注入(如发布页那一路)才回落相机位置。 */
766
+ updateTexture(camera: Camera): void {
767
+ const n = this.textures.length;
768
+ if (n === 0) {
769
+ this._slotPanos = [];
770
+ this.activeIndex = -1;
771
+ return;
772
+ }
773
+ // ⭐ 皇上旨 2026-10-11 丙:尺子的两端必须落在**同一个空间**。注入的锚点是世界坐标,而下面那把尺子量的
774
+ // 全景位置记在本网格的**局部(制图)空间** —— 依据是着色器自己:`vProjPos = uProjInvMatrix·modelMatrix·pos`
775
+ // = M⁻¹·M·pos = 局部(`uProjInvMatrix` 正是 `syncProjInvMatrix` 里那句 `matrixWorld` 求逆),片元再拿
776
+ // `dir = vProjPos - tm[3].xyz` 取图 ⇒ 全景矩阵只可能和 `vProjPos` 同空间,即局部。
777
+ // 所以换算放在**锚点这一端**(`worldToLocal`),⛔ 不去给全景矩阵乘 `matrixWorld`:那是把 JS 的尺子
778
+ // 和 shader 的取图拆成两路。网格无变换时这一步是恒等 ⇒ 老项目逐字不变;有旋转时(消费方给 mkt 加了
779
+ // `rotation.y`)原来会把「往世界的某一头走」量成「往局部的另一头走」,人走到位了名册还不动。
780
+ const ref = this._rosterAnchorLocal.copy(this._rosterCenter ?? camera.position);
781
+ this.worldToLocal(ref);
782
+ const dists: { t: MktTextureInfo; d: number }[] = [];
783
+ for (let i = 0; i < n; i++) {
784
+ const e = this.textures[i].matrix.elements;
785
+ this._tmpV3.set(e[12], e[13], e[14]);
786
+ // ⚠️ 全景点矩阵记在本网格的局部空间(与 `wallPoints` 同一空间,见上面丙旨那段着色器推导),
787
+ // 所以拿来直接和**已换算到局部**的 `ref` 比距离;⛔ 这里绝不乘 `matrixWorld`(双重变换 = 选错全景)。
788
+ // ⭐ 09-28 09:33 旨:挑图与编辑器取图同一把「**水平距离**」尺(XZ 平面,不含高度差)。
789
+ // const d = this._tmpV3.distanceTo(camera.position); // 旧原理:三维直线距离——全景点同高(y 恒 1.6)时与本式排序等价,注释保留备查
790
+ // ⭐ 10-07 第二十一道旨:尺子的另一端从相机位置换成 `ref`(控制器中心点;未注入时它就是相机位置)。
791
+ dists.push({ t: this.textures[i], d: Math.hypot(this._tmpV3.x - ref.x, this._tmpV3.z - ref.z) });
792
+ }
793
+ // ⭐ 皇上旨 2026-10-03「1.可以」:预览/发布态的 8 格**也**按「中心 ± 邻居」名册挑(算法与死区常数
794
+ // 都在 ./panoSlotRoster,编辑态那份读的是同一处 ⇒ 预览与编辑器逐字同一批全景);
795
+ // ⛔ 不再按「引用面数取前 4」(getReferencedPanoIndices 那套静态名单只留作公共 API,不进喂料路)。
796
+ this._slotPanos = panoSlotRosterStep(this._slotPanos, dists, MKT_PANO_MAX_SLOTS, PANO_SLOT_SWAP_MARGIN_M);
797
+ // ⭐ 10-03 旨②:中心那张不再要「抄给立方体」(立方体已与墙共用同一份材质),但**仍**留 `activeIndex`
798
+ // 作为「名册中心有没有换人」的闩锁(下面的比较 + 外部只读),换人才重新激活。
799
+ const center = this._slotPanos[0];
800
+ const target = center ? this.textures.indexOf(center) : -1;
801
+ if (target >= 0 && target !== this.activeIndex) {
802
+ this.activateTexture(target);
803
+ }
804
+ // 每帧**只**驱动一次拼合器(皇上旨 09-29「一帧里别同时处理很多纹理」+ 10-06「每帧只载入一张新纹理到 GPU」
805
+ // ⇒ 拼合器内部分两段:每帧最多 1 次新纹理上传,画进 RT 只认已在显存里的那几格):
806
+ // 下面的 `feedFaceMapping` 与各槽 `ensureTileTexture` 一律只 peek,不在这里重复 pump。
807
+ // ⭐ 10-06:RT 要在 renderer 上才画得进去 —— `_glRenderer` 由 `onBeforeRender` 在**这一句之前**捕获
808
+ // (构造后头一帧还没捕获到就传 null:那一帧什么都不画,下一帧自然补上)。
809
+ this._stitcher.pump(this._glRenderer);
810
+ }
811
+
812
+ /** 给纹理地址追加 OSS 图片缩放参数(1024 / 2048 / 空=不缩放)。
813
+ * 尺寸由外部 setOssSize 控制(默认 1024,与编辑器渲染器 resolveOssUrl 语义一致);
814
+ * 预览/发布库经 setOssSize 注入,使全景球按所选尺寸加载。
815
+ * ⚠️ `original=true`(这一槽喂的是**真瓦片地址**,即 rt 虚拟键那一路)时**一律走原图**、不拼尺寸参数
816
+ * (皇上旨 2026-09-23「切换到4片瓦片的时候, 就不需要设置oss 尺寸参数了, 默认使用原图」)。
817
+ * 判据与喂地址、写 `uPanoTiled` 同源(`mktPanoTiledFlag`),别另算一套。 */
818
+ private resolveOssUrl(url: string, original = false): string {
819
+ // ⭐ 32 瓦片拼合的虚拟键不是真 URL(纹理由 PanoTileStitcher 提供),原样放行、绝不拼 OSS 参数
820
+ if (!url || url.startsWith(RT_STITCH_KEY_PREFIX) || url.includes("x-oss-process")) {
821
+ return url;
822
+ }
823
+ if (original) return url;
824
+ const size = this._ossSize;
825
+ if (!size) return url;
826
+ const sep = url.includes("?") ? "&" : "?";
827
+ return `${url}${sep}x-oss-process=image/resize,m_lfit,w_${size}`;
828
+ }
829
+
830
+ /** ⭐ 皇上旨 2026-10-07 第二十一道旨:把「用哪些全景球」那把尺子的锚点从相机位置换成**控制器中心点**。
831
+ * 消费方(预览弹窗)把轨道控制器的 `target` 交进来即可;传 `null` = 回落相机位置(老口径,发布页照旧)。
832
+ * 存引用不存副本 ⇒ 轨道控制器原地改那个 Vector3,设一次就一直跟着走。 */
833
+ setRosterCenter(center: Vector3 | null): void {
834
+ this._rosterCenter = center;
835
+ }
836
+
837
+ /** 设置纹理 OSS 缩放尺寸(1024 / 2048 / 空=原图);变化即按新尺寸重载当前激活纹理。
838
+ * 编辑器 MktDevMesh 关闭了 activateTexture(activeIndex=-1),此处为 no-op;预览/发布库用基类激活,会真正按新尺寸重载。 */
839
+ setOssSize(size: "1024" | "2048" | ""): void {
840
+ const next = size === "1024" || size === "2048" || size === "" ? size : "1024";
841
+ if (next === this._ossSize) return;
842
+ this._ossSize = next;
843
+ this._reloadActiveTexture();
844
+ }
845
+
846
+ /** ossSize 换档后重载「加载地址拼了尺寸参数」的那批条目(与编辑态 `_reloadFloorTextures` 同口径:
847
+ * dispose + 删条目,下一帧 `_peekTileAt` 自然按新档重拉)。
848
+ * ⚠️ 真瓦片(现行 32 片/全景)一律走原图 ⇒ 不在重载范围;整图档才受尺寸档影响(皇上旨 09-23)。 */
849
+ private _reloadActiveTexture(): void {
850
+ if (this._ossSizeDepKeys.size === 0) return;
851
+ for (const key of this._ossSizeDepKeys) {
852
+ releasePanoTexture(this._texCache.get(key)); // 换档丢掉的这张要连位图一起关(见 `releasePanoTexture`)
853
+ this._texCache.delete(key);
854
+ }
855
+ this._ossSizeDepKeys.clear();
856
+ }
857
+
858
+ // ───────────── 全景瓦片缓存(按地址 + 每 tick 一张上传;与编辑态同一份策略) ─────────────
859
+
860
+ /** 换绑一个点槽的左右 2 张拼合槽纹理:策略全在 `./panoSlotBind`(皇上旨 10-03「整套搬进库」⇒ 编辑器与库**同一份**),
861
+ * 这里只把本类的取图口与占位纹理注入进去。返回 true = 图与点位配好了(调用方才写投影矩阵)。 */
862
+ private _bindSlotTiles(
863
+ mat: ShaderMaterial, panos: Texture[], base: number, tiles: string[], tiled: boolean, slot: number
864
+ ): boolean {
865
+ return panoSlotBind(mat, panos, base, tiles, tiled, slot, {
866
+ peek: (key, original) => this._peekTileAt(key, original),
867
+ // 跨形态(瓦片 ↔ 整片)那一次要「整槽同进同退」:库里没有过渡档那一层,逐张 peek 攒成整槽即可。
868
+ // ⛔ 长度由 `RT_STITCH_SLOT_COUNT` 派生(10-06 晚旨后是 2 张,⛔ 别写死 4 个下标)
869
+ peekSlot: (t, original) =>
870
+ Array.from({ length: RT_STITCH_SLOT_COUNT }, (_, k) => this._peekTileAt(t[k], original)),
871
+ placeholder: this._placeholder,
872
+ });
873
+ }
874
+
875
+ /** 本材质「panoramas[i] 这一格认的是哪张瓦片地址」——逐张换绑的账本(⚠️ 账本实体在 `./panoSlotBind`,
876
+ * 与编辑态共一本,按材质实例分键 ⇒ 两处材质不同实例、天然不串味)。 */
877
+ private _tileKeys(mat: ShaderMaterial, len: number): string[] {
878
+ return panoSlotTileKeys(mat, len);
879
+ }
880
+
881
+ /** 按缓存键要一张**已经设进 shader** 的纹理:没有就当场发起下载(异步代码一律不进全局队列 —— 皇上旨 2026-09-25),
882
+ * 解码完还没上传的排一个「只干上传那一句」的队列项;两关都过了才返回给它去绑。
883
+ * ⭐ `rt:` 虚拟键(32 瓦片拼合槽)走拼合器 peek;⛔ 不在这里 pump(每帧驱动点唯一,见 `updateTexture`,
884
+ * 否则一帧画好几格 = 把「每帧只载入一张新纹理」那道闸从后门拆了)。 */
885
+ private _peekTileAt(key: string, original: boolean): Texture | null {
886
+ // ⭐ 2026-10-25:空地址 = 「这个点还没设图片」,不是一条能加载的 URL ⇒ 直接判没有。
887
+ // 放过去会让 ensureTileTexture 拿空串请求当前文档地址、失败后每帧重试。
888
+ if (!key) return null;
889
+ if (isRtStitchKey(key)) {
890
+ const p = rtStitchKeyParts(key);
891
+ if (!p) return null;
892
+ // ⭐ 2026-10-09 皇上旨「切换全景时上一个全景的内容要渲染成深灰」:透传 renderer,
893
+ // 新全景还没画进格时 peek 会**先把 RT 建出来(已涂缺失灰)并交出** ⇒ 这一帧拿到深灰底,
894
+ // 而非 null(null 会被 panoSlotBind 判成「判不齐」⇒ 沿用上一批 ⇒ 墙上闪上一个全景)。
895
+ const tex = this._stitcher.peek(p.batch, p.slot, this._glRenderer);
896
+ if (!tex) return null;
897
+ // ⭐ 皇上旨 09-29「两个大头别在同一帧」:普通瓦片不许在 render 首绑现场 texSubImage2D ⇒ 走
898
+ // 「每 tick 一张」预上传队列;没排到的帧返回 null,自动推迟绑定 ⇒ render 里从此没有首绑上传。
899
+ // ⭐ 皇上旨 10-06 换成 RT 那一份后这一闸**自动跳过**:RT 的附件纹理本来就在显存里,
900
+ // 压根没有「上传」那一下(⛔ 也别拿 `initTexture` 去喂它 —— 那是 three 自己管的渲染目标)。
901
+ if (!this._stitcher.resident(tex) && !this._texUploaded.has(tex)) {
902
+ this._commitTileToShader(tex);
903
+ return null;
904
+ }
905
+ return tex;
906
+ }
907
+ const tex = this._texCache.get(key);
908
+ if (!tex) {
909
+ // 未开始或加载中:直接发起(缓存里以 null 占位 ⇒ 同一地址不会被问两次就下两遍)。
910
+ // 发起只是把 XHR 丢出去(微秒级),主线程成本可忽略;真正贵的解码/上传各自有闸。
911
+ this.ensureTileTexture(key, original);
912
+ return null;
913
+ }
914
+ if (!this._texUploaded.has(tex)) {
915
+ this._commitTileToShader(tex); // 已解码但还没上 GPU:排它自己的那一个 tick,这一帧不绑
916
+ return null;
917
+ }
918
+ return tex;
919
+ }
920
+
921
+ /** 真正发起一张瓦片纹理的加载(`key` = 原始地址;rt 虚拟键由 `_peekTileAt` 上半段拦走,不会到这里)。
922
+ * ⚠️ 皇上旨 2026-09-28「加载瓦片的时候, 瓦片纹理不需要添加 oss 的参数」⇒ 真瓦片(`original=true`,现行 32 片/全景)走原图,
923
+ * 整图档才按 ossSize 档缩(`resolveOssUrl`)。 */
924
+ private ensureTileTexture(key: string, original = false): Texture | null {
925
+ // ⭐ 2026-10-25:空地址直接判「没有」,不起载(与编辑态 ensureFloorTexture 同一道闸)。
926
+ if (!key) return null;
927
+ const cached = this._texCache.get(key);
928
+ if (cached) return cached;
929
+ if (cached === null) return null; // 加载中
930
+ this._texCache.set(key, null); // 标记加载中
931
+ const loadUrl = this.resolveOssUrl(key, original);
932
+ if (loadUrl !== key) this._ossSizeDepKeys.add(key);
933
+ loadPanoTexture(loadUrl).then(
934
+ (tex) => {
935
+ tex.colorSpace = SRGBColorSpace;
936
+ // 该键可能已被 `_reloadActiveTexture`(换档)删掉并重新发起 ⇒ 按键写回即可:地址就是本条目的身份,
937
+ // 不存在「过期回调写脏另一个地址」的问题(与编辑态 `_floorTexCache` 同一口径)。
938
+ this._texCache.set(key, tex);
939
+ // 解码已在浏览器工作线程做完(甲)⇒ 这里只排它**自己那一个**队列项(只含「上传 GPU」那一句):
940
+ // 一批里几张同时解完 ⇒ 各自排各自的 tick 逐张落地,上传那一拍不再顺带同步解码。
941
+ this._commitTileToShader(tex);
942
+ },
943
+ () => {
944
+ // 单张失败:摘掉「加载中」占位 ⇒ 下一帧同键判定仍是未就绪,自动重试(旧库路径本来就会重试,口径不变)。
945
+ if (this._texCache.get(key) === null) this._texCache.delete(key);
946
+ }
947
+ );
948
+ return null;
949
+ }
950
+
951
+ /** ⭐ 把一张「已经加载解码好」的纹理排进全局队列,排到的那个 tick 只做一件事:把它上传到 GPU。
952
+ * 皇上旨 2026-09-25 原文:「队列里面的代码就是 把加载好的纹理 设置给 shader 的 这一句代码」——
953
+ * 所以下载、淘汰、绑定判断统统不许出现在回调里。
954
+ * ⚠️ 去重闸门必须是自己的 `_uploadQueued`:`addWork` 用 `workList.includes(work)` 判重,而每次都是新闭包 ⇒ 判不出来。
955
+ * ⭐ 皇上旨 2026-10-05:「你在调用 addWork 的时候,再调一次 addWork 传入一个空函数,这样真正需要执行的方法就会隔一帧执行」
956
+ * ⇒ 真活前面先垫一个空函数占一格(全局队列每 tick 只跑一项 ⇒ 多一格 = 晚一帧),与编辑态 `_commitTileToShader` 同一口径。
957
+ * ⚠️ 空函数**必须内联每次现造**:`addWork` 按引用判重,共用一个常量引用时第二张起空格进不了队列 ⇒ 改动静默失效。 */
958
+ private _commitTileToShader(tex: Texture): void {
959
+ if (this._texUploaded.has(tex) || this._uploadQueued.has(tex)) return;
960
+ this._uploadQueued.add(tex);
961
+ threeUtils.addWork(() => {}); // 占一格(什么都不干),把下面那一项推到下一个 tick
962
+ threeUtils.addWork(() => this._uploadTileTexture(tex));
963
+ }
964
+
965
+ /** 队列回调的全部内容:把这一张纹理上传到 GPU(`texImage2D` + mipmap 才是那一下贵的),每 tick 一张。 */
966
+ private _uploadTileTexture(tex: Texture): void {
967
+ // 排上队之后已被清掉(那张 dispose 过了)⇒ 本项作废:再上传会把显存凭空建回来且无人回收。
968
+ if (!this._uploadQueued.delete(tex)) return;
969
+ try {
970
+ // `_glRenderer` 还没被 `onBeforeRender` 捕获到(构造后尚未渲染过一帧)⇒ 跳过预上传,
971
+ // 交给 three 在绑定那一帧自己上传 —— 那是改造前的老路径,不会报错,只是那一张没享受「错峰」。
972
+ if (this._glRenderer) this._glRenderer.initTexture(tex);
973
+ } catch {
974
+ // 皇上旨 2026-10-12「日志都去掉」⇒ 这里不再出声;降级路径本身不变(下面照记「已处理」,
975
+ // 最坏退回 three 在绑定那一帧自己上传的老路径)。
976
+ }
977
+ // ⚠️ 成败都记「已处理」。不记 ⇒ 这张永远进不了 `_texUploaded` ⇒ 那一格永远换不上新图,
978
+ // 比「卡一下」严重得多;记了最坏也只是退回 three 在绑定那一帧自己上传的老路径。
979
+ this._texUploaded.add(tex);
980
+ }
981
+
982
+ /**
983
+ * 序号直接取模式喂食(皇上旨 2026-09-28 决策③:MktMesh 基类只有这一种模式)。
984
+ * ⭐ 皇上旨 2026-10-03「1.可以」:shader 各槽装的是**槽位名册**那 `MKT_PANO_MAX_SLOTS` 张(`_slotPanos`,
985
+ * 中心 ± 邻居,与编辑态 `mktMeshEditRenderer.feedFaceMapping` 同一份算法、同一个死区常数)
986
+ * ⇒ 预览/发布与编辑器同一屏装同一批全景。⛔ 不再按「被引用面数取前 4」(那是 09-28 的静态名单,
987
+ * 与相机无关 ⇒ 走近的景点进不了槽,正是他这次报「预览里 shader 不对」的根因)。
988
+ * 片元按 aPanoIndex 在 panoIndices 里找对应 slot 取纹理;指派的那张不在当前名册里 ⇒ 该面回落 `uFaceMissingColor` 深灰,
989
+ * 把**名册锚点**挪近那张景点后自动出图(⭐ 10-07 第二十一道旨:预览轨道模式 = 控制器中心点,第一人称/未注入 = 相机位置;与编辑态同一份已知代价)。
990
+ */
991
+ feedFaceMapping(): void {
992
+ this._stitcher.pump(this._glRenderer); // 每帧驱动拼合器(≤1 片/帧;下方逐槽只 peek 不驱动)
993
+ const mat = this.material as ShaderMaterial;
994
+ const u = mat.uniforms;
995
+ if (!u.panoIndices) return; // 非融合材质(防御)
996
+ u.uPanoramaVisible.value = 1;
997
+ // 复用统一喂料:填 panoramas[]/textureMatrices[]/weights[] + uPanoramaVisible
998
+ // (材质本身就是序号直取 shader,恒按 panoIndices 选槽取图——原 uFaceMappingMode=1 翻转已随拆分删除;
999
+ // ⚠️ 本函数**从来没写过** uColorSpace:那句旧注释是骗人的。色彩空间 2026-10-07 已整条退路——
1000
+ // 纹理恒标 SRGBColorSpace 由硬件解码一次,shader 不再手动 pow,故连 uniform 都不存在了。)
1001
+ const maxPano = (u.panoIndices.value as Float32Array).length;
1002
+ const panos = u.panoramas.value as Texture[];
1003
+ const mats = u.textureMatrices.value as Matrix4[];
1004
+ const ws = u.weights.value as Float32Array;
1005
+ const idxU = u.panoIndices.value as Float32Array;
1006
+ const keys = this._tileKeys(mat, panos.length);
1007
+ for (let s = 0; s < maxPano; s++) {
1008
+ const base = s * RT_STITCH_SLOT_COUNT; // ⛔ 不是 s*4:10-06 晚旨后一个全景只占左右 2 槽
1009
+ const info = this._slotPanos[s]; // 名册没这一格(项目全景总数不够 / 首帧未算)⇒ 走下面的清空支
1010
+ if (!info) {
1011
+ for (let k = 0; k < RT_STITCH_SLOT_COUNT; k++) panos[base + k] = this._placeholder;
1012
+ mats[s].identity();
1013
+ ws[s] = 0;
1014
+ idxU[s] = -1;
1015
+ continue;
1016
+ }
1017
+ idxU[s] = this.textures.indexOf(info);
1018
+ // ⭐ 2026-10-25 皇上旨「把默认图去掉,图片没有加载的时候就显示纯灰色」:
1019
+ // 这个点**没设图片**(url 空且没有切片)⇒ 按空槽喂料,权重 0 ⇒ 片元回落 uFaceMissingColor 中性灰。
1020
+ // ⛔ 别把空地址放下去:空 key 会走到 `_peekTileAt` → `ensureTileTexture("")` 发一次空请求
1021
+ // (解析成当前文档地址),失败后下一帧再试 = 每帧一个废请求。序号照留(归属还在,只是没图)。
1022
+ const hasSource = !!info.url || !!(info.tiles && info.tiles.length > 0);
1023
+ if (!hasSource) {
1024
+ for (let k = 0; k < RT_STITCH_SLOT_COUNT; k++) panos[base + k] = this._placeholder;
1025
+ mats[s].identity();
1026
+ ws[s] = 0;
1027
+ continue;
1028
+ }
1029
+ // 各槽喂什么地址 = 「用户选的形态 + 该点有没有切过片」共同决定(whole 或无 tiles ⇒ 各槽同为一张整图)
1030
+ const tiles = mktPanoTexUrls(info.url, info.tiles, this._panoTexMode);
1031
+ // ⚠️ 「是不是真瓦片」只算一次:喂地址(走不走 OSS 尺寸档)与写 uPanoTiled 共用同一个值
1032
+ const tiled = mktPanoTiledFlag(info.tiles, this._panoTexMode) === 1;
1033
+ // ⭐ 10-06 晚旨「只支持 32 张(8×4)」:任意 1~32 条瓦片整组交拼合器(不足 32 张的格子涂灰);
1034
+ // 这 2 槽是虚拟键,这里只负责「每帧把真 URL 喂给拼合器」(虚拟键本身不含完整地址)。
1035
+ if (tiled && info.tiles && info.tiles.length > 0) this._stitcher.ensure(info.tiles);
1036
+ // ⭐ 皇上旨 2026-10-03「整套搬进库」:换绑成败由**共用的那份策略**判定(未就绪 ⇒ 整槽沿用上一批)。
1037
+ // 没换成就**矩阵也别动** ⇒ 旧图永远配旧矩阵(原来矩阵无条件 copy = 新矩阵配旧图,正是②的错配)。
1038
+ const bound = this._bindSlotTiles(mat, panos, base, tiles, tiled, s);
1039
+ if (bound) mats[s].copy(info.matrix);
1040
+ // 左上格当整槽的代表。⚠️ 判据必须**同时**看账本:`keys[base]` 为空串 ⇒ 这座槽**从没成功绑过**,
1041
+ // 此刻 `panos[base]` 里躺的是占位/工厂默认 1×1,而矩阵还是单位阵 —— 给它权重等于拿「世界原点」
1042
+ // 当投影中心投一片素色。已绑过的槽(账本非空)换人没判齐时权重照给 ⇒ 沿用上一批,不整体退出。
1043
+ const hasReal = !!keys[base] && !!panos[base] && panos[base] !== this._placeholder;
1044
+ // 库侧单点不混合 ⇒ 权重恒 1(编辑态那份是距离反比 camW,这里没有「多张混出一个面」的事)
1045
+ ws[s] = hasReal ? 1 : 0;
1046
+ }
1047
+ }
1048
+
1049
+ /** 设置全景纹理形态(tiled=8×4 三十二片走拼合器 / whole=一整片原图);非法值回落 tiled。
1050
+ * ⭐ 10-03 旨⑤:这里**不再**手工重载纹理 —— 形态一变目标地址就变了(瓦片 URL ↔ 整图 URL),
1051
+ * 下一帧 `feedFaceMapping` 里共用的那份 binder 见「槽内现有形态 ≠ 目标形态」自动走整档判齐、一次换绑并同帧翻标记。
1052
+ * 编辑器 MktDevMesh 走的是自己那路(同一个 binder、同一份策略),本方法对它只是记下形态。 */
1053
+ setPanoTexMode(mode: PanoTexMode): void {
1054
+ const next = isPanoTexMode(mode) ? mode : "tiled";
1055
+ if (next === this._panoTexMode) return;
1056
+ this._panoTexMode = next;
1057
+ }
1058
+
1059
+ /**
1060
+ * 记下「当前激活(名册 0 号槽 = 中心)是第几个全景」。
1061
+ * ⭐ 皇上旨 2026-10-03 旨②:预览立方体已与墙**共用同一份材质**,原来那句「把中心那几张抄给立方体单全景材质」
1062
+ * 的 `currentTiles` 旁路整条删除 ⇒ 本方法不再加载瓦片、不再写 `uPanoTiled`(形态标记归 binder 逐槽写,
1063
+ * 地址/标记/分辨率三者同源的判据在 `feedFaceMapping`)。留着它是因为 `updateTexture` 用它做「中心换人」的闩锁,
1064
+ * 且它是发布态的公共 API(外部只读 `activeIndex` 靠这里维护)。
1065
+ */
1066
+ activateTexture(index: number): void {
1067
+ if (index < 0 || index >= this.textures.length) {
1068
+ return;
1069
+ }
1070
+ this.activeIndex = index;
1071
+ }
1072
+
1073
+ // ───────────────────────── 导入 / 导出 ─────────────────────────
1074
+
1075
+ /**
1076
+ * ⭐ 项目名(皇上旨10-08 19:53「导出时图片地址改成 `{项目名称}/xxx.jpg`」)。
1077
+ * 落盘时用它顶替 OSS 上传目录前缀(`three-base_upload/panorama/`),导入时据此还原。
1078
+ * 消费方(编辑器)用 `setProjectName()` 注入;没注入时退化为「只剥域名」的纯相对形态。
1079
+ */
1080
+ projectName: string = "";
1081
+
1082
+ /** 注入项目名(导出 json 前调一次即可;导入时无须调——还原靠落盘地址自身的首段) */
1083
+ setProjectName(name: string): void {
1084
+ this.projectName = typeof name === "string" ? name : "";
1085
+ }
1086
+
1087
+ // ───────────── 平面图 / 模型两层(⭐ 皇上旨 2026-10-26「把图片平面和模型一起搬进库里」) ─────────────
1088
+
1089
+ /** 单颗图片平面入层(编辑态双击落点用):进数组 + 挂进本对象子树 + 起排它自己的逐帧绑定 */
1090
+ addImagePlane(ip: ImagePlane): ImagePlane {
1091
+ this.imagePlanes.push(ip);
1092
+ this.add(ip);
1093
+ this._queueImagePlaneBind(ip);
1094
+ return ip;
1095
+ }
1096
+
1097
+ /** 摘掉一颗并释放它自己的几何/材质;那 4 张瓦片纹理归 `_imgTileLoader` 管 ⇒ ⛔ 不在这里 dispose */
1098
+ removeImagePlane(ip: ImagePlane): void {
1099
+ const i = this.imagePlanes.indexOf(ip);
1100
+ if (i >= 0) this.imagePlanes.splice(i, 1);
1101
+ this._imgBindQueued.delete(ip);
1102
+ this._imgBindDone.delete(ip);
1103
+ this._dropReveal(ip);
1104
+ this.remove(ip);
1105
+ ip.dispose();
1106
+ }
1107
+
1108
+ /** 单颗模型槽入层(编辑态双击落点、以及「复制模型」用):⛔ 不压点亮 —— 刚加的必须当场看得见 */
1109
+ addModel(ms: ModelSlot): ModelSlot {
1110
+ this.models.push(ms);
1111
+ this.add(ms);
1112
+ return ms;
1113
+ }
1114
+
1115
+ /** 摘掉一槽并释放(子树里的几何/材质/贴图由 `ModelSlot.dispose` 自己收尾;排在后头的挂载帧靠「不在册」早退) */
1116
+ removeModel(ms: ModelSlot): void {
1117
+ const i = this.models.indexOf(ms);
1118
+ if (i >= 0) this.models.splice(i, 1);
1119
+ this._modelMountQueued.delete(ms);
1120
+ this._dropReveal(ms);
1121
+ this.remove(ms);
1122
+ ms.dispose();
1123
+ }
1124
+
1125
+ /** 出「逐帧点亮」那张在册表(删除 / 整层重建旧的一批都走这里,⛔ 不许留已经 dispose 的对象) */
1126
+ private _dropReveal(o: ImagePlane | ModelSlot): void {
1127
+ const i = this._revealPending.indexOf(o);
1128
+ if (i >= 0) this._revealPending.splice(i, 1);
1129
+ }
1130
+
1131
+ /**
1132
+ * 把某颗平面/模型的显隐控制权交回调用方(2026-10-28:`visible` 上有业务在用,点亮那一拍⛔ 不能覆盖
1133
+ * 放行之前写下的那一笔)。摘牌后它**仍是压着的**(整层重建那一路进来就是 `false`),要它可见就自己写 `true`。
1134
+ */
1135
+ releaseReveal(o: ImagePlane | ModelSlot): void {
1136
+ this._dropReveal(o);
1137
+ }
1138
+
1139
+ /**
1140
+ * 整层重建图片平面(`importData` 与预览那一路)。
1141
+ * ⚠️ 进来的地址必须是**已补回域名**的绝对形态:补域名只有 `importData` 那一处(与 `textures` 同一把 `assetUrl` 尺),
1142
+ * ⛔ 本方法里不许再补一遍,也⛔ 不许在调用方各自补。
1143
+ */
1144
+ setImagePlanes(datas: ImagePlaneData[]): void {
1145
+ this._imgBindQueued.clear();
1146
+ this._imgBindDone.clear();
1147
+ for (const ip of this.imagePlanes) {
1148
+ this._dropReveal(ip);
1149
+ this.remove(ip);
1150
+ ip.dispose();
1151
+ }
1152
+ this.imagePlanes = [];
1153
+ for (const d of datas) {
1154
+ const ip = new ImagePlane(d.name);
1155
+ ip.applyData(d);
1156
+ this.imagePlanes.push(ip);
1157
+ this.add(ip);
1158
+ // 压着等点亮:4 格地址齐全才有东西可画(片数那把尺 = 下面 `_queueImagePlaneBind` 的 `tiles.length !== 4`,
1159
+ // ⛔ 不是这里另立一份);不齐的那一档本来就整片 discard、永远落不定 ⇒ 不压(省得白占一帧在册表)。
1160
+ if (d.tiles && d.tiles.length === 4) {
1161
+ ip.visible = false;
1162
+ this._revealPending.push(ip);
1163
+ }
1164
+ }
1165
+ // 整层重建完立刻起排:地址一次性全交给下载、绑材质逐帧一记 ⇒ 宿主不驱动这一层也长得出来
1166
+ this.feedImagePlaneTiles();
1167
+ }
1168
+
1169
+ /**
1170
+ * 整层重建模型槽(`importData` 与预览那一路),地址口径的要求同 `setImagePlanes`。
1171
+ * ⚠️ GLB 逐只**异步取、不 await**(十几只串一条队会把载入卡死),每只落地那一刻各回调一次 `onEachLoad`:
1172
+ * 预览每帧真画 ⇒ 不传;编辑器是按需渲染 ⇒ 传一个「标脏」的钩子,否则模型挂上来屏幕不动。
1173
+ * 2026-10-26 决定:取回与「挂进父元素」分两拍 —— 地址一次性全交给下载(网络 + 解析不占渲染帧),
1174
+ * 真正进场景图那一步交给 `threeUtils.addWork` ⇒ 全局 rAF 每拍只跑一项 = **一帧只挂一颗槽**
1175
+ * (挂载那一拍要重算包围盒 + 排布 + 首次把整棵子树的几何送上 GPU,十几颗挤在同一帧就是他要拆掉的那一卡)。
1176
+ * 2026-10-28 再加第三拍「点亮」:挂载那一拍走的是 setTimeout 链,主线程被几十次 GLB 解析压住时渲染帧会滞后,
1177
+ * 于是好几颗在同一记 render 里首次上 GPU ⇒ 整层重建出来的槽先压着不亮,由 `tickProgressiveReveal`
1178
+ * **一帧只放一颗**(那一下顶点缓冲上传 + program 编译才真正摊到 N 帧上)。单颗那两路(落点新增 / 复制 / 上传)不受这一拍管。
1179
+ */
1180
+ setModels(datas: ModelSlotData[], onEachLoad?: () => void): void {
1181
+ this._modelMountQueued.clear();
1182
+ for (const ms of this.models) {
1183
+ this._dropReveal(ms);
1184
+ this.remove(ms);
1185
+ ms.dispose();
1186
+ }
1187
+ this.models = [];
1188
+ for (const d of datas) {
1189
+ const ms = new ModelSlot(d.name);
1190
+ ms.applyData(d);
1191
+ this.models.push(ms);
1192
+ // 槽本体(占位小立方体)当场进图:在册表、射线拾取、`exportData` 都立刻认它
1193
+ this.add(ms);
1194
+ if (d.url) {
1195
+ // 压着等点亮:GLB 落进 `_contentRoot` 那一拍之后,还要再等一次「一帧一颗」的放行才上 GPU
1196
+ //(⚠️ 与旧的「GLB 在路上时这一槽露出占位方块」不同:整层重建这一路一颗都不露,直到轮到自己亮)
1197
+ ms.visible = false;
1198
+ this._revealPending.push(ms);
1199
+ void ms.loadModel(d.url).then(() => this._queueModelMount(ms, onEachLoad));
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ /** 把「备好待挂」的那一槽交给全局队列;⛔ 去重闸门自备(`addWork` 按闭包引用判重,新闭包它判不出来) */
1205
+ private _queueModelMount(ms: ModelSlot, onEachLoad?: () => void): void {
1206
+ if (this._modelMountQueued.has(ms)) return;
1207
+ this._modelMountQueued.add(ms);
1208
+ threeUtils.addWork(() => {});
1209
+ threeUtils.addWork(() => this._mountModelStep(ms, onEachLoad));
1210
+ }
1211
+
1212
+ /** 全局队列里的那一帧:只挂这一槽,挂好才回调(`onEachLoad` = 按需渲染宿主的标脏钩子) */
1213
+ private _mountModelStep(ms: ModelSlot, onEachLoad?: () => void): void {
1214
+ this._modelMountQueued.delete(ms);
1215
+ // 槽已退场(换项目 / 删除):待挂那棵由 `ModelSlot.dispose` → `_detachModel` 当场释放,这里不挂
1216
+ if (!this.models.includes(ms)) return;
1217
+ if (ms.mountStaged() && onEachLoad) onEachLoad();
1218
+ }
1219
+
1220
+ /**
1221
+ * 把各颗平面那 4 张瓦片的纹理绑进材质(10-22 晚定的口径:不走拼合器 RT)。
1222
+ * 2026-10-26 决定改成**逐帧**:一次把整层地址全丢给下载器(下载/解码在网络与工作线程侧,不占这一帧),
1223
+ * 真正写 uniform 的那一步交给 `threeUtils.addWork` —— 全局 rAF 每拍只跑一项 ⇒ 一帧只绑一颗平面,
1224
+ * 一整层平面的纹理上传被摊到多帧,不再挤在同一记调用里(旧写法一记 `feed` 就把所有平面全绑完)。
1225
+ * ⛔ 不进 `onBeforeRender` 自驱;持有渲染循环的那一方(编辑态 / 预览 / 下游)每帧调一次只是「补排漏网的平面」,
1226
+ * 而整层重建(`setImagePlanes`)末尾自己起排 ⇒ 只调 `importData(整串)` 的宿主不必自己驱动也会长出来。
1227
+ * 还没下载完/缺格的那一格绑 null ⇒ 片元那一格整格 discard,不落像素(用户 10-26 连收两条:先「不要默认图」,
1228
+ * 再「不是显示灰色, 而是直接 discard 丢弃」)。
1229
+ */
1230
+ feedImagePlaneTiles(): void {
1231
+ for (const ip of this.imagePlanes) this._queueImagePlaneBind(ip);
1232
+ }
1233
+
1234
+ /** 一颗平面排一帧的绑定工作:先把它那 4 条地址**一次性全部**交给下载器,再把「写材质」排进全局队列。 */
1235
+ private _queueImagePlaneBind(ip: ImagePlane): void {
1236
+ const tiles = ip.tiles;
1237
+ const sig = (tiles ?? []).join("|");
1238
+ // 两道闸:已排在队里 = 不重复排;这串地址已经落定过 = 不再动它(换图会改这串 ⇒ 自然会重排)
1239
+ if (this._imgBindQueued.has(ip) || this._imgBindDone.get(ip) === sig) return;
1240
+ const zero = new Vector4(0, 0, 1, 1);
1241
+ // ⛔ 旧存档 16 条地址(> 满批 4)直接不收 ⇒ 整张不落像素(四格全 discard),按 10-20 口径重传即可;空 tiles 同样
1242
+ if (!tiles || tiles.length !== 4) {
1243
+ ip.setTileTextures([null, null, null, null], [zero, zero.clone(), zero.clone(), zero.clone()]);
1244
+ this._imgBindDone.set(ip, sig);
1245
+ return;
1246
+ }
1247
+ for (const url of tiles) this._imgTileLoader.preload(url); // ① 一次性全部拿去加载
1248
+ this._imgBindQueued.add(ip);
1249
+ threeUtils.addWork(() => {}); // 占一格(什么都不干),把下面那一项推到下一个 tick —— 与全景上传同一手法
1250
+ threeUtils.addWork(() => this._bindImagePlaneStep(ip, sig));
1251
+ }
1252
+
1253
+ /** 队列里的一格:只绑**这一颗**平面里已就绪的那几格;还有在途的就重新排队,⛔ 不在这里等、也⛔ 不顺手把别的平面一起做完。 */
1254
+ private _bindImagePlaneStep(ip: ImagePlane, sig: string): void {
1255
+ this._imgBindQueued.delete(ip);
1256
+ if (!this.imagePlanes.includes(ip)) return; // 已摘掉(删平面 / 切项目整层重建)⇒ 这一格什么都不做
1257
+ const tiles = ip.tiles;
1258
+ if ((tiles ?? []).join("|") !== sig) return; // 地址中途换了 ⇒ 下一记 feed 按新拼串重排
1259
+ const zero = new Vector4(0, 0, 1, 1);
1260
+ const texs: (Texture | null)[] = [null, null, null, null];
1261
+ const insets: Vector4[] = [zero.clone(), zero.clone(), zero.clone(), zero.clone()];
1262
+ let pending = false;
1263
+ for (let i = 0; i < 4; i++) {
1264
+ const url = tiles[i];
1265
+ if (!url) continue;
1266
+ const e = this._imgTileLoader.get(url);
1267
+ if (e && e.tex) {
1268
+ texs[i] = e.tex;
1269
+ insets[i] = this._imgTileLoader.inset(e, i);
1270
+ } else if (this._imgTileLoader.isPending(url)) {
1271
+ pending = true; // 还在下载/解码 ⇒ 这一格先绑已到的,剩下的下一拍再看
1272
+ }
1273
+ }
1274
+ ip.setTileTextures(texs, insets);
1275
+ if (pending) {
1276
+ this._queueImagePlaneBind(ip);
1277
+ return;
1278
+ }
1279
+ this._imgBindDone.set(ip, sig); // 全部落定(含下载失败那格:它留 null,那一格就永久 discard)
1280
+ }
1281
+
1282
+ /**
1283
+ * 逐帧点亮:每帧从在册表里放行**一颗**(内容已落定的第一颗),亮完就把这一颗出册。
1284
+ * ⚠️ 一记只放一颗 ⇒ 顶点缓冲上传与首次 program 编译被摊到 N 帧,而不是同几帧里全上(那一下卡)。
1285
+ * ⚠️ 由 `onBeforeRender` 每帧调(编辑态那份在 `MktDevMesh`,覆写时⛔ 不许漏掉这一句);
1286
+ * 表空了它就是一句 `length` 判断,⛔ 不需要再像旧写法那样把钩子置 null(钩子上还挂着全景投影那三件事)。
1287
+ * ⚠️ 这里写的 `visible` 只针对**自己压进表的那几颗** ⇒ 表外的对象(单颗新增、已 dispose、被 `releaseReveal`
1288
+ * 摘牌的)一律不碰;要在放行前接管某颗,调 `releaseReveal(o)`。
1289
+ */
1290
+ tickProgressiveReveal(): void {
1291
+ const q = this._revealPending;
1292
+ for (let i = 0; i < q.length; i++) {
1293
+ const o = q[i];
1294
+ if (!o.loaded) continue; // 内容还在路上 ⇒ 这一颗继续压着,先看后面已经落定的
1295
+ q.splice(i, 1);
1296
+ o.visible = true;
1297
+ return;
1298
+ }
1299
+ }
1300
+
1301
+ /** 导出数据:墙点位 + 墙高 + 是否闭合 + 纹理数组(url + 矩阵)
1302
+ * @param projName 项目名(可选,优先于 `setProjectName` 注入的那个)——图片地址落成 `{项目名}/xxx.jpg` */
1303
+ exportData(projName?: string): MktMeshData {
1304
+ const pn = typeof projName === "string" && projName ? projName : this.projectName;
1305
+ return {
1306
+ wallPoints: this.wallPoints.map((p) => ({ x: p.x, y: p.y, z: p.z })),
1307
+ wallHeight: this.wallHeight,
1308
+ wallClosed: this.wallClosed,
1309
+ wallSide: this.wallSide,
1310
+ // 全景序号映射:每个三角面归属的全景全局序号(与 wallPoints 同几何拓扑绑定,随存档走)
1311
+ panoFaceMapping: this.panoFaceMapping.slice(),
1312
+ textures: this.textures.map((t) => ({
1313
+ // ⭐ 10-08 旨:存档里只落**相对路径**(域名在读 JavaScript 之外也用不上,还锁死环境);
1314
+ // 同日 18:46 旨「前面不要有 /」⇒ ⛔ 无前导 /;19:53 旨「改成 {项目名称}/xxx.jpg」
1315
+ // ⇒ OSS 上传目录前缀(`three-base_upload/panorama/`)整段顶掉,落成 `{项目名}/x_0.jpg`。
1316
+ // `stripAssetHost` 幂等 ⇒ 内存里已经是相对的不会出问题,旧存档的绝对地址也不会反复剥。
1317
+ url: stripAssetHost(t.url, pn),
1318
+ name: t.name ?? "",
1319
+ // 2026-09-20:导出 4 瓦片(顺序 [左上, 右上, 左下, 右下]);旧数据无 tiles 时导空数组
1320
+ tiles: (t.tiles && t.tiles.length ? t.tiles.map((u) => stripAssetHost(u, pn)) : []) as string[],
1321
+ matrix: t.matrix.toArray(),
1322
+ })),
1323
+ // ⭐ 10-26 旨入库:这两层的地址与 `textures` 共用上面那一条剥域名口径(⛔ 不是另起一把尺),
1324
+ // 其余字段(位置/旋转/宽高/瓦片数/落点面/原始包围盒)由各自的 `toData()` 逐字存。
1325
+ images: this.imagePlanes.map((ip) => {
1326
+ const d = ip.toData();
1327
+ return {
1328
+ ...d,
1329
+ url: d.url ? stripAssetHost(d.url, pn) : "",
1330
+ tiles: d.tiles.length ? d.tiles.map((u) => stripAssetHost(u, pn)) : [],
1331
+ };
1332
+ }),
1333
+ models: this.models.map((ms) => {
1334
+ const d = ms.toData();
1335
+ return { ...d, url: d.url ? stripAssetHost(d.url, pn) : "" };
1336
+ }),
1337
+ };
1338
+ }
1339
+
1340
+ /** 导入数据:重建墙体几何与纹理列表,并激活第一个纹理
1341
+ * @param projName 项目名(可选)——用于把落盘的 `{项目名}/x_0.jpg` 还原回真身OSS 地址
1342
+ * @param onModelLoad 每只 GLB 落地时回调一次(可选)——按需渲染的宿主(编辑器)拿它标脏;每帧真画的预览不传 */
1343
+ importData(data: MktMeshData, projName?: string, onModelLoad?: () => void): void {
1344
+ // ⭐ 10-08 旨的「补回域名」那颗项目名,10-26 旨之后全景/平面图/模型**三层共用同一个**(原来在 textures 的
1345
+ // map 里就地取,下面那两层也要用 ⇒ 提到方法开头,⛔ 不是第二处口径)。
1346
+ const pn = typeof projName === "string" && projName ? projName : this.projectName;
1347
+ this.setWallPoints(data.wallPoints ?? [], {
1348
+ closed: data.wallClosed,
1349
+ height: data.wallHeight,
1350
+ });
1351
+ // ⭐ 还原全景序号映射**放在 setTextures 之后**:rebuildPanoIndexAttribute 会顺手烘焙 aPanoCenter
1352
+ // (源 = panoCenters,由 textures 实时派生),排在 setTextures 前面 = 拿旧/空纹理列表算圆心 ⇒ 属性里全是 0。
1353
+ this.setWallSide(data.wallSide ?? "double");
1354
+ this.setTextures(
1355
+ (data.textures ?? []).map((t) => {
1356
+ // ⭐ 10-08 旨的成对另一半:存档落的是相对路径 ⇒ 进内存前在这里**唯一一处**补回域名,
1357
+ // 下游(拼合器 / 预览 / UI `<img>`)拿到的仍是绝对地址 ⇒ 它们一行都不用改。
1358
+ // · `absAssetUrl` 幂等 + **同时认旧存档的根相对形态** ⇒ 上午发布前存的那批
1359
+ // (`/three-base_upload/…`)不必重存也能正常加载,不会被拼成 `https://host/https://host/…`
1360
+ // · 19:53 旨后还要把 `{项目名}/x_0.jpg` **还原**成真身 OSS 地址(顶掉的上传目录要补回来),
1361
+ // 否则 json 里的地址指不回原图(图片裂)。`pn` 取入参、回落 `setProjectName` 注入的那个。
1362
+ const urlAbs = absAssetUrl(t.url as string, pn);
1363
+ return {
1364
+ url: urlAbs,
1365
+ name: t.name ?? "",
1366
+ // 2026-09-20:导入瓦片;缺省回退 [url](旧存档整图占 1 格)——10-06 晚旨「只支持 32 张(横 8 × 竖 4)」后
1367
+ // 该点走拼合器:整图缩在 1/32 格 + 其余 31 格涂灰,数据不全一眼可见(重传 32 片切片即修复)
1368
+ tiles: t.tiles && t.tiles.length ? t.tiles.map((u) => absAssetUrl(u, pn)) : [urlAbs],
1369
+ matrix: new Matrix4().fromArray(t.matrix),
1370
+ };
1371
+ })
1372
+ );
1373
+ // setWallPoints 已按位置重算过映射(旧为空 → 全 -1),此处用存档里的逐面指派覆盖。
1374
+ // 几何拓扑由同一份 wallPoints 重建,面索引与导出时一致 ⇒ 逐面写入即可对齐。
1375
+ // ⭐ 皇上旨 2026-10-03「补」:长度不符**不再整份丢弃**——按较小长度逐面灌回(存档多出的面丢掉,
1376
+ // 墙变长多出来的那些面保持 setWallPoints 按位置继承到的值)。
1377
+ // 2026-10-12 旨「日志都去掉」⇒ 这一条不再 warn,差值只按较小者静默还原。
1378
+ const imported = (data.panoFaceMapping && data.panoFaceMapping.length ? data.panoFaceMapping : []) as number[];
1379
+ const faceCount = this.panoFaceMapping.length;
1380
+ const restore = Math.min(imported.length, faceCount);
1381
+ if (restore > 0) {
1382
+ for (let f = 0; f < restore; f++) this.panoFaceMapping[f] = imported[f];
1383
+ this.rebuildPanoIndexAttribute();
1384
+ }
1385
+ // ⭐ 10-26 旨「把图片平面和模型一起搬进库里」的另一半:这两层也从**同一份 JSON** 里读,
1386
+ // 地址在这一处补回域名(与上面 textures 同一条尺)⇒ 下游只调 `importData(整串)` 就全画出来。
1387
+ // 旧存档没有这两个键 ⇒ 空数组 = 整层清空,与「墙 + 全景」那套逐字同口径。
1388
+ this.setImagePlanes(
1389
+ (data.images ?? []).map((d) => ({
1390
+ ...d,
1391
+ url: d.url ? absAssetUrl(d.url, pn) : "",
1392
+ tiles: (d.tiles ?? []).map((u) => absAssetUrl(u, pn)),
1393
+ }))
1394
+ );
1395
+ this.setModels(
1396
+ (data.models ?? []).map((d) => ({ ...d, url: d.url ? absAssetUrl(d.url, pn) : "" })),
1397
+ onModelLoad
1398
+ );
1399
+ }
1400
+
1401
+ // ───────────────────────── 释放 ─────────────────────────
1402
+
1403
+ /** 释放 GPU 资源:瓦片缓存(**只有这里**清,皇上旨 2026-10-02)、拼合器、平面图/模型两层、几何、材质 */
1404
+ dispose(): this {
1405
+ // ⚠️ rt 虚拟键从不进 `_texCache`(拼合槽纹理由 `_stitcher` 自己持有并释放),这里清的都是本类下载的 Texture。
1406
+ // ⭐ 甲(2026-10-05):`releasePanoTexture` 除 `dispose()` 外还要 `ImageBitmap.close()` —— 位图的原生内存
1407
+ // 不像 `<img>` 能等 GC,一张 270MB 挂着不关,切几轮全景就把进程吃爆。
1408
+ for (const tex of this._texCache.values()) releasePanoTexture(tex);
1409
+ this._texCache.clear();
1410
+ this._texUploaded = new WeakSet(); // WeakSet 无 clear ⇒ 整只换掉
1411
+ this._uploadQueued.clear(); // 排队项即便回调仍触发,也会因 `!this._uploadQueued.delete(tex)` 早退
1412
+ this._ossSizeDepKeys.clear();
1413
+ // ⭐ 10-26 旨入库的两层:子对象逐个 dispose,平面那 4 张瓦片的缓存**只在这里**整批清
1414
+ // (与上面 `_texCache` 同一条旨 ⇒ 切工程不重建这一份、也就不会再空出一格)。
1415
+ for (const ip of this.imagePlanes) {
1416
+ this.remove(ip);
1417
+ ip.dispose();
1418
+ }
1419
+ this.imagePlanes = [];
1420
+ for (const ms of this.models) {
1421
+ this.remove(ms);
1422
+ ms.dispose();
1423
+ }
1424
+ this.models = [];
1425
+ this._imgTileLoader.clear();
1426
+ this._imgBindQueued.clear();
1427
+ this._imgBindDone.clear();
1428
+ this._modelMountQueued.clear();
1429
+ this._revealPending = [];
1430
+ this._stitcher.dispose();
1431
+ this._glRenderer = null;
1432
+ if (this._placeholder) this._placeholder.dispose();
1433
+ this.geometry.dispose();
1434
+ this.material.dispose();
1435
+ return this;
1436
+ }
1437
+ }