From 23472b9639d5800788668a7a03e4e9648cafc3cb Mon Sep 17 00:00:00 2001 From: Rowland <975945824@qq.com> Date: Mon, 16 Mar 2026 09:47:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=89=93=E5=8C=85web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- imgui.ini | 4 +- project/webgl_packager.py | 503 +++++++++++++++++++++++ templates/webgl/viewer.js | 840 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 1345 insertions(+), 2 deletions(-) diff --git a/imgui.ini b/imgui.ini index 8d1592d0..854da81c 100644 --- a/imgui.ini +++ b/imgui.ini @@ -84,12 +84,12 @@ Size=400,300 Collapsed=0 [Window][选择路径] -Pos=625,258 +Pos=390,125 Size=600,500 Collapsed=0 [Window][打开项目] -Pos=675,308 +Pos=440,175 Size=500,400 Collapsed=0 diff --git a/project/webgl_packager.py b/project/webgl_packager.py index 5aee4a19..4a9dd19b 100644 --- a/project/webgl_packager.py +++ b/project/webgl_packager.py @@ -50,6 +50,7 @@ class WebGLPackager: self._copied_source_to_uri: Dict[str, str] = {} self._name_counter: Dict[str, int] = {} self._node_id_by_pointer: Dict[int, str] = {} + self._baseline_subnode_cache: Dict[str, Dict[str, Any]] = {} self.report: Dict[str, Any] = { "status": "failed", @@ -141,6 +142,7 @@ class WebGLPackager: shutil.copy2(str(entry), str(vendor_dst / entry.name)) self._try_resolve_vendor_files(vendor_dst) + self._strip_vendor_source_mapping_urls(vendor_dst) # Placeholder marker warning placeholder_file = vendor_dst / "three.module.min.js" @@ -202,6 +204,36 @@ class WebGLPackager: if found_source: shutil.copy2(str(found_source), str(dst_path)) + def _strip_vendor_source_mapping_urls(self, vendor_dst: Path) -> None: + """Remove sourceMappingURL hints to avoid noisy 404s in offline preview.""" + line_patterns = ( + re.compile(r"^\s*//[#@]\s*sourceMappingURL=.*$", re.IGNORECASE), + re.compile(r"^\s*/\*[#@]\s*sourceMappingURL=.*\*/\s*$", re.IGNORECASE), + ) + + for js_file in vendor_dst.glob("*.js"): + try: + content = js_file.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + + lines = content.splitlines() + filtered = [ + line for line in lines + if not any(pattern.match(line) for pattern in line_patterns) + ] + if len(filtered) == len(lines): + continue + + text = "\n".join(filtered) + if content.endswith("\n"): + text += "\n" + try: + js_file.write_text(text, encoding="utf-8") + self.report["warnings"].append(f"已移除 sourceMappingURL: vendor/{js_file.name}") + except Exception: + continue + def _build_scene_manifest(self, project_name: str) -> Dict[str, Any]: render = getattr(self.world, "render", None) if not render: @@ -285,6 +317,23 @@ class WebGLPackager: continue if node.getName() in {"render", "camera", "cam"}: continue + # Only export true model roots. Scene traversal may put many child nodes + # into scene_manager.models, but they should be replayed via subnode overrides. + is_model_root = False + try: + is_model_root = node.hasTag("is_model_root") + except Exception: + is_model_root = False + has_model_path_tag = False + for tag_name in ("model_path", "saved_model_path", "original_path", "file"): + try: + if node.hasTag(tag_name): + has_model_path_tag = True + break + except Exception: + continue + if not is_model_root and not has_model_path_tag: + continue models.append(node) return models @@ -446,6 +495,9 @@ class WebGLPackager: material_override = self._extract_material_override(node) textures = self._collect_and_copy_texture_overrides(node, model_source) + subnode_overrides = self._collect_ssbo_subnode_transform_overrides(node) + if not subnode_overrides: + subnode_overrides = self._collect_subnode_transform_overrides(node, model_source) entry: Dict[str, Any] = { "id": node_id, @@ -459,8 +511,459 @@ class WebGLPackager: } if textures: entry["texture_overrides"] = textures + if subnode_overrides: + entry["subnode_overrides"] = subnode_overrides return entry + def _collect_ssbo_subnode_transform_overrides(self, model_node) -> Dict[str, Any]: + """ + Collect changed subnode transforms from SSBO controller runtime state. + This path is required because SSBO edits may not exist in model_node's + ordinary child hierarchy. + """ + ssbo_editor = getattr(self.world, "ssbo_editor", None) + controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None + controller_model = getattr(controller, "model", None) if controller else None + + if not controller or self._is_np_empty(controller_model) or self._is_np_empty(model_node): + return {} + + if not self._nodepath_equivalent(controller_model, model_node): + model_source_key = self._nodepath_source_key(model_node) + controller_source_key = self._nodepath_source_key(controller_model) + same_source = bool(model_source_key and controller_source_key and model_source_key == controller_source_key) + same_name = False + try: + same_name = (controller_model.getName() == model_node.getName()) + except Exception: + same_name = False + if not same_source and not same_name: + return {} + + id_to_object_np = getattr(controller, "id_to_object_np", {}) + id_to_name = getattr(controller, "id_to_name", {}) + global_transforms = getattr(controller, "global_transforms", []) + tree_root_key = str(getattr(controller, "tree_root_key", "0") or "0") + + if not isinstance(id_to_object_np, dict) or not isinstance(id_to_name, dict): + return {} + + by_index_map: Dict[Tuple[int, ...], List[float]] = {} + by_index_base_map: Dict[Tuple[int, ...], List[float]] = {} + by_name_map: Dict[Tuple[str, ...], List[float]] = {} + by_name_base_map: Dict[Tuple[str, ...], List[float]] = {} + + for gid, obj_np in id_to_object_np.items(): + if self._is_np_empty(obj_np): + continue + + key = id_to_name.get(gid) + if key is None: + continue + key = str(key) + if not key: + continue + + index_path = self._parse_ssbo_tree_key_to_index_path(key, tree_root_key) + if not index_path: + continue + + try: + cur_mat = obj_np.getMat(controller_model) + except Exception: + try: + cur_mat = obj_np.get_mat(controller_model) + except Exception: + continue + + base_mat = None + try: + if int(gid) < len(global_transforms): + base_mat = global_transforms[int(gid)] + except Exception: + base_mat = None + + if base_mat is not None and self._mat4_objects_close(cur_mat, base_mat): + continue + + mat_values = self._mat4_to_row_major_list(cur_mat) + if not mat_values or self._is_identity_matrix_list(mat_values): + continue + + base_values = None + if base_mat is not None: + try: + base_values = self._mat4_to_row_major_list(base_mat) + except Exception: + base_values = None + if base_values and len(base_values) != 16: + base_values = None + + existing = by_index_map.get(index_path) + if existing and not self._matrix_list_close(existing, mat_values): + # Multiple geoms mapped to same tree node with different mats: + # keep first one and warn once. + self.report["warnings"].append( + f"SSBO 子节点矩阵冲突,保留首个值: {model_node.getName()} path={list(index_path)}" + ) + continue + if not existing: + by_index_map[index_path] = mat_values + if base_values: + by_index_base_map[index_path] = base_values + + name_path = self._ssbo_index_path_to_name_path(controller, index_path, tree_root_key) + if name_path: + raw_name_path = tuple(str(v).strip() for v in name_path if str(v).strip()) + normalized_name_path = self._normalize_name_path(name_path) + export_paths = [] + if raw_name_path: + export_paths.append(raw_name_path) + if normalized_name_path and normalized_name_path != raw_name_path: + export_paths.append(normalized_name_path) + + for p in export_paths: + by_name_map[p] = mat_values + if base_values and (p not in by_name_base_map): + by_name_base_map[p] = base_values + + if not by_index_map: + return {} + + by_index = [] + for path, mat in sorted(by_index_map.items(), key=lambda x: (len(x[0]), x[0])): + item = { + "path": [int(v) for v in path], + "matrix_local_row_major": mat, + } + base_values = by_index_base_map.get(path) + if base_values: + item["base_matrix_model_root_row_major"] = base_values + by_index.append(item) + + by_name = [] + for path, mat in sorted(by_name_map.items(), key=lambda x: (len(x[0]), x[0])): + item = { + "path": [str(v) for v in path], + "matrix_local_row_major": mat, + } + base_values = by_name_base_map.get(path) + if base_values: + item["base_matrix_model_root_row_major"] = base_values + by_name.append(item) + + payload = { + "source": "ssbo", + # SSBO runtime stores matrices in model-root space + # (obj_np.getMat(controller.model)). + "matrix_space": "model_root", + "by_index": by_index, + } + if by_name: + payload["by_name"] = by_name + self.report["warnings"].append( + f"SSBO子节点变换已导出: {model_node.getName()} ({len(by_index)} items)" + ) + return payload + + @staticmethod + def _is_np_empty(np) -> bool: + if not np: + return True + try: + return np.isEmpty() + except Exception: + try: + return np.is_empty() + except Exception: + return True + + @staticmethod + def _nodepath_equivalent(a, b) -> bool: + try: + if a == b: + return True + except Exception: + pass + try: + return a.node() == b.node() + except Exception: + return False + + @staticmethod + def _nodepath_source_key(np) -> str: + if not np: + return "" + for tag_name in ("saved_model_path", "model_path", "original_path", "file"): + try: + if np.hasTag(tag_name): + value = (np.getTag(tag_name) or "").strip() + if value: + return value.replace("\\", "/").lower() + except Exception: + continue + return "" + + @staticmethod + def _parse_ssbo_tree_key_to_index_path(key: str, tree_root_key: str) -> Tuple[int, ...]: + text = str(key).strip() + if not text: + return tuple() + parts = [p for p in text.split("/") if p != ""] + if not parts: + return tuple() + # keys are like "0/1/2"; drop tree root segment. + if parts and parts[0] == str(tree_root_key): + parts = parts[1:] + out: List[int] = [] + for p in parts: + try: + out.append(int(p)) + except Exception: + return tuple() + return tuple(out) + + def _ssbo_index_path_to_name_path(self, controller, index_path: Tuple[int, ...], tree_root_key: str) -> Tuple[str, ...]: + tree_nodes = getattr(controller, "tree_nodes", {}) or {} + if str(tree_root_key) not in tree_nodes: + return tuple() + + parent_key = str(tree_root_key) + out: List[str] = [] + for child_slot in index_path: + parent = tree_nodes.get(parent_key) + if not isinstance(parent, dict): + return tuple() + children = parent.get("children", []) + if not isinstance(children, list): + return tuple() + if child_slot < 0 or child_slot >= len(children): + return tuple() + + child_key = children[child_slot] + child = tree_nodes.get(child_key, {}) + child_name = str(child.get("name", "")) + + # Build occurrence index among same-name siblings. + occur = 0 + for i in range(child_slot): + prev_key = children[i] + prev_name = str(tree_nodes.get(prev_key, {}).get("name", "")) + if prev_name == child_name: + occur += 1 + out.append(f"{child_name}#{occur}") + parent_key = child_key + + return tuple(out) + + @staticmethod + def _normalize_name_path(path: Tuple[str, ...]) -> Tuple[str, ...]: + """Drop empty segments only; keep duplicate segments to preserve depth.""" + normalized: List[str] = [] + for seg in path: + s = str(seg).strip() + if not s: + continue + normalized.append(s) + return tuple(normalized) + + def _collect_subnode_transform_overrides(self, root_node, model_source: str) -> Dict[str, Any]: + """ + Collect changed local transforms for descendants under a model root. + Only export deltas against a baseline loaded from model_source to avoid + replaying all intrinsic node transforms (which can cause double transforms). + """ + try: + if not root_node or root_node.isEmpty(): + return {} + except Exception: + return {} + + current_snapshot = self._snapshot_subnode_local_matrices(root_node) + if not current_snapshot["by_index"]: + return {} + + baseline_snapshot = self._load_baseline_subnode_snapshot(model_source) + if not baseline_snapshot["by_index"] and not baseline_snapshot["by_name"]: + # No reliable baseline -> skip overrides to avoid corrupt scale/rotation. + self.report["warnings"].append( + f"跳过子节点变换导出(缺少基线): {root_node.getName() if hasattr(root_node, 'getName') else 'unknown'}" + ) + return {} + + changed_by_index: List[Dict[str, Any]] = [] + changed_by_name: List[Dict[str, Any]] = [] + + for index_path, payload in current_snapshot["by_index"].items(): + current_mat = payload["matrix_local_row_major"] + name_path = payload["name_path"] + + baseline_payload = baseline_snapshot["by_index"].get(index_path) + if baseline_payload is None: + baseline_payload = baseline_snapshot["by_name"].get(name_path) + + baseline_mat = baseline_payload["matrix_local_row_major"] if baseline_payload else None + if baseline_mat is not None and self._matrix_list_close(current_mat, baseline_mat): + continue + # Safety: identity overrides often indicate hierarchy mismatch and can + # destroy source glTF built-in transforms when replayed in web runtime. + if self._is_identity_matrix_list(current_mat): + continue + + changed_by_index.append( + { + "path": [int(v) for v in index_path], + "matrix_local_row_major": current_mat, + } + ) + changed_by_name.append( + { + "path": [str(v) for v in name_path], + "matrix_local_row_major": current_mat, + } + ) + + if not changed_by_index and not changed_by_name: + return {} + + return { + "matrix_space": "local_parent", + "by_index": changed_by_index, + "by_name": changed_by_name, + } + + def _snapshot_subnode_local_matrices(self, root_node) -> Dict[str, Dict[Tuple[Any, ...], Dict[str, Any]]]: + snapshot: Dict[str, Dict[Tuple[Any, ...], Dict[str, Any]]] = { + "by_index": {}, + "by_name": {}, + } + + try: + if not root_node or root_node.isEmpty(): + return snapshot + except Exception: + return snapshot + + def walk(node, index_path: Tuple[int, ...], name_path: Tuple[str, ...]) -> None: + if index_path: + parent = None + try: + parent = node.getParent() + except Exception: + parent = None + + if parent and not parent.isEmpty(): + local_mat = node.getMat(parent) + else: + local_mat = node.getMat() + + matrix_row_major = self._mat4_to_row_major_list(local_mat) + payload = { + "index_path": index_path, + "name_path": name_path, + "matrix_local_row_major": matrix_row_major, + } + snapshot["by_index"][index_path] = payload + snapshot["by_name"][name_path] = payload + + name_count: Dict[str, int] = {} + for child_index, child in enumerate(node.getChildren()): + child_name = "" + try: + child_name = child.getName() or "" + except Exception: + child_name = "" + occur = name_count.get(child_name, 0) + name_count[child_name] = occur + 1 + walk( + child, + index_path + (child_index,), + name_path + (f"{child_name}#{occur}",), + ) + + try: + walk(root_node, tuple(), tuple()) + except Exception as exc: + self.report["warnings"].append( + f"采集子节点矩阵失败: {root_node.getName() if hasattr(root_node, 'getName') else 'unknown'} ({exc})" + ) + + return snapshot + + def _load_baseline_subnode_snapshot(self, model_source: str) -> Dict[str, Dict[Tuple[Any, ...], Dict[str, Any]]]: + key = os.path.normpath(model_source or "") + if not key: + return {"by_index": {}, "by_name": {}} + if key in self._baseline_subnode_cache: + return self._baseline_subnode_cache[key] + + snapshot: Dict[str, Dict[Tuple[Any, ...], Dict[str, Any]]] = {"by_index": {}, "by_name": {}} + loader = getattr(self.world, "loader", None) + if loader is None: + self._baseline_subnode_cache[key] = snapshot + return snapshot + + baseline_root = None + try: + baseline_root = loader.loadModel(key) + if baseline_root and not baseline_root.isEmpty(): + snapshot = self._snapshot_subnode_local_matrices(baseline_root) + except Exception as exc: + self.report["warnings"].append(f"加载基线模型失败: {key} ({exc})") + snapshot = {"by_index": {}, "by_name": {}} + finally: + try: + if baseline_root and not baseline_root.isEmpty(): + baseline_root.removeNode() + except Exception: + pass + + self._baseline_subnode_cache[key] = snapshot + return snapshot + + @staticmethod + def _matrix_list_close(a: List[float], b: List[float], eps: float = 1e-5) -> bool: + if (not isinstance(a, list)) or (not isinstance(b, list)) or len(a) != 16 or len(b) != 16: + return False + for i in range(16): + try: + if abs(float(a[i]) - float(b[i])) > eps: + return False + except Exception: + return False + return True + + @staticmethod + def _mat4_objects_close(a, b, eps: float = 1e-5) -> bool: + if a is None or b is None: + return False + try: + for r in range(4): + for c in range(4): + if abs(float(a.getCell(r, c)) - float(b.getCell(r, c))) > eps: + return False + return True + except Exception: + return False + + @staticmethod + def _is_identity_matrix_list(values: List[float], eps: float = 1e-6) -> bool: + if not isinstance(values, list) or len(values) != 16: + return False + identity = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + ] + for i in range(16): + try: + if abs(float(values[i]) - identity[i]) > eps: + return False + except Exception: + return False + return True + def _build_light_node_entry(self, node, kind: str) -> Optional[Dict[str, Any]]: node_id = self._node_id_by_pointer.get(id(node)) if not node_id: diff --git a/templates/webgl/viewer.js b/templates/webgl/viewer.js index fc186443..661415df 100644 --- a/templates/webgl/viewer.js +++ b/templates/webgl/viewer.js @@ -128,6 +128,838 @@ function applyTextureOverrides(THREE, root, textureOverrides, textureLoader) { }); } +function pathKey(parts) { + if (!Array.isArray(parts)) return ""; + return parts.map((v) => String(v)).join("/"); +} + +function normalizeNamePath(parts) { + if (!Array.isArray(parts)) return []; + const out = []; + for (const raw of parts) { + const s = String(raw ?? "").trim(); + if (!s) continue; + if (out.length > 0 && out[out.length - 1] === s) continue; + out.push(s); + } + return out; +} + +function stripOccurrenceSuffix(segment) { + return String(segment ?? "").replace(/#\d+$/, ""); +} + +function normalizeNamePathLoose(parts) { + return normalizeNamePath(parts).map((s) => stripOccurrenceSuffix(s)); +} + +function stripBlenderDuplicateSuffix(segment) { + return String(segment ?? "").replace(/\.\d{3}$/, ""); +} + +function normalizeNamePathVeryLoose(parts) { + return normalizeNamePathLoose(parts).map((s) => stripBlenderDuplicateSuffix(s)); +} + +function canonicalizeNameSegment(segment) { + const noOcc = stripOccurrenceSuffix(segment).toLowerCase(); + return noOcc.replace(/[^a-z0-9]+/g, ""); +} + +function normalizeNamePathCanonical(parts) { + return normalizeNamePath(parts) + .map((s) => canonicalizeNameSegment(s)) + .filter((s) => !!s); +} + +function matrixSignatureRowMajor(values, digits = 5) { + if (!Array.isArray(values) || values.length !== 16) return ""; + const out = []; + for (let i = 0; i < 16; i += 1) { + const num = Number(values[i]); + if (!Number.isFinite(num)) return ""; + out.push(num.toFixed(digits)); + } + return out.join(","); +} + +function isSuffixPath(shorter, longer) { + if (!Array.isArray(shorter) || !Array.isArray(longer)) return false; + if (shorter.length > longer.length) return false; + const offset = longer.length - shorter.length; + for (let i = 0; i < shorter.length; i += 1) { + if (shorter[i] !== longer[offset + i]) return false; + } + return true; +} + +function suffixMatchScore(hintParts, candidateParts) { + if (!Array.isArray(hintParts) || !Array.isArray(candidateParts)) return 0; + let i = hintParts.length - 1; + let j = candidateParts.length - 1; + let score = 0; + while (i >= 0 && j >= 0) { + if (String(hintParts[i]) !== String(candidateParts[j])) break; + score += 1; + i -= 1; + j -= 1; + } + return score; +} + +function isIdentityRowMajorMatrix(m, eps = 1e-6) { + if (!Array.isArray(m) || m.length !== 16) return false; + const identity = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]; + for (let i = 0; i < 16; i += 1) { + if (Math.abs(Number(m[i]) - identity[i]) > eps) return false; + } + return true; +} + +function applySubnodeOverrides(THREE, root, overrides, basis, basisInv, matrixConvention) { + if (!root || !overrides || typeof overrides !== "object") return 0; + const preferName = String(overrides.source || "").toLowerCase() === "ssbo"; + const matrixSpace = String(overrides.matrix_space || (preferName ? "model_root" : "local_parent")).toLowerCase(); + const modelRootSpace = matrixSpace === "model_root"; + + const byIndex = new Map(); + const byName = new Map(); + const byNameNormalized = new Map(); + const byNameLoose = new Map(); + const byNameVeryLoose = new Map(); + const byNameCanonical = new Map(); + const overrideTerminalLooseCount = new Map(); + const overrideTerminalLooseFirst = new Map(); + const overrideTerminalLooseUnique = new Map(); + const overrideTerminalCanonicalCount = new Map(); + const overrideTerminalCanonicalFirst = new Map(); + const overrideTerminalCanonicalUnique = new Map(); + const tempParentInv = new THREE.Matrix4(); + const rootWorldInv = new THREE.Matrix4(); + const tempModelRoot = new THREE.Matrix4(); + const matrixMatchEps = 0.25; + const matrixTieEps = 1e-5; + const maxDepthDiffForMatrixFallback = 3; + const depthHintsByMatrixSig = new Map(); + const terminalHintsByMatrixSig = new Map(); // very loose terminal hints + const strictTerminalHintsByMatrixSig = new Map(); // keep .001 + const canonicalTerminalHintsByMatrixSig = new Map(); // punctuation-insensitive + keep digits + const strictPathHintsByMatrixSig = new Map(); // strip only #occurrence + const veryLoosePathHintsByMatrixSig = new Map(); // strip #occurrence + .001 + const canonicalPathHintsByMatrixSig = new Map(); // punctuation-insensitive + keep digits + + const makeOverrideEntry = (item, path) => { + if (!item || !Array.isArray(path) || !Array.isArray(item.matrix_local_row_major)) return null; + if (item.matrix_local_row_major.length !== 16) return null; + const matrixSig = matrixSignatureRowMajor(item.matrix_local_row_major); + const baseMatrix = Array.isArray(item.base_matrix_model_root_row_major) && item.base_matrix_model_root_row_major.length === 16 + ? item.base_matrix_model_root_row_major + : null; + const depth = Array.isArray(path) ? path.length : 0; + return { + path, + matrix: item.matrix_local_row_major, + baseMatrix, + matrixSig, + expectedDepth: depth, + depthHints: depth > 0 ? [depth] : [], + terminalHints: [], + terminalHintsStrict: [], + terminalHintsCanonical: [], + pathHintsStrict: [], + pathHintsVeryLoose: [], + pathHintsCanonical: [], + }; + }; + + for (const item of Array.isArray(overrides.by_index) ? overrides.by_index : []) { + const entry = makeOverrideEntry(item, Array.isArray(item?.path) ? item.path : null); + if (!entry) continue; + byIndex.set(pathKey(entry.path), entry); + } + + for (const item of Array.isArray(overrides.by_name) ? overrides.by_name : []) { + const entry = makeOverrideEntry(item, Array.isArray(item?.path) ? item.path : null); + if (!entry) continue; + + const rawKey = pathKey(entry.path); + byName.set(rawKey, entry); + const normParts = normalizeNamePath(item.path); + const normKey = pathKey(normParts); + if (normKey && !byNameNormalized.has(normKey)) { + byNameNormalized.set(normKey, entry); + } + const looseParts = normalizeNamePathLoose(item.path); + const looseKey = pathKey(looseParts); + if (looseKey && !byNameLoose.has(looseKey)) { + byNameLoose.set(looseKey, entry); + } + const veryLooseParts = normalizeNamePathVeryLoose(item.path); + const veryLooseKey = pathKey(veryLooseParts); + if (veryLooseKey && !byNameVeryLoose.has(veryLooseKey)) { + byNameVeryLoose.set(veryLooseKey, entry); + } + const canonicalParts = normalizeNamePathCanonical(item.path); + const canonicalKey = pathKey(canonicalParts); + if (canonicalKey && !byNameCanonical.has(canonicalKey)) { + byNameCanonical.set(canonicalKey, entry); + } + const terminalLoose = looseParts.length > 0 ? looseParts[looseParts.length - 1] : ""; + const terminalStrict = looseParts.length > 0 ? looseParts[looseParts.length - 1] : ""; + const terminalCanonical = canonicalParts.length > 0 ? canonicalParts[canonicalParts.length - 1] : ""; + if (terminalLoose) { + overrideTerminalLooseCount.set( + terminalLoose, + (overrideTerminalLooseCount.get(terminalLoose) ?? 0) + 1, + ); + if (!overrideTerminalLooseFirst.has(terminalLoose)) { + overrideTerminalLooseFirst.set(terminalLoose, entry); + } + } + if (terminalCanonical) { + overrideTerminalCanonicalCount.set( + terminalCanonical, + (overrideTerminalCanonicalCount.get(terminalCanonical) ?? 0) + 1, + ); + if (!overrideTerminalCanonicalFirst.has(terminalCanonical)) { + overrideTerminalCanonicalFirst.set(terminalCanonical, entry); + } + } + + const depth = Array.isArray(item.path) ? item.path.length : 0; + if (entry.matrixSig && depth > 0) { + const depthList = depthHintsByMatrixSig.get(entry.matrixSig) || []; + depthList.push(depth); + depthHintsByMatrixSig.set(entry.matrixSig, depthList); + } + const terminalVeryLoose = veryLooseParts.length > 0 ? veryLooseParts[veryLooseParts.length - 1] : ""; + if (entry.matrixSig && terminalVeryLoose) { + const termSet = terminalHintsByMatrixSig.get(entry.matrixSig) || new Set(); + termSet.add(terminalVeryLoose); + terminalHintsByMatrixSig.set(entry.matrixSig, termSet); + } + if (entry.matrixSig && terminalStrict) { + const termSetStrict = strictTerminalHintsByMatrixSig.get(entry.matrixSig) || new Set(); + termSetStrict.add(terminalStrict); + strictTerminalHintsByMatrixSig.set(entry.matrixSig, termSetStrict); + } + if (entry.matrixSig && terminalCanonical) { + const termSetCanonical = canonicalTerminalHintsByMatrixSig.get(entry.matrixSig) || new Set(); + termSetCanonical.add(terminalCanonical); + canonicalTerminalHintsByMatrixSig.set(entry.matrixSig, termSetCanonical); + } + if (entry.matrixSig && looseParts.length > 0) { + const strictPaths = strictPathHintsByMatrixSig.get(entry.matrixSig) || []; + strictPaths.push(looseParts); + strictPathHintsByMatrixSig.set(entry.matrixSig, strictPaths); + } + if (entry.matrixSig && veryLooseParts.length > 0) { + const veryLoosePaths = veryLoosePathHintsByMatrixSig.get(entry.matrixSig) || []; + veryLoosePaths.push(veryLooseParts); + veryLoosePathHintsByMatrixSig.set(entry.matrixSig, veryLoosePaths); + } + if (entry.matrixSig && canonicalParts.length > 0) { + const canonicalPaths = canonicalPathHintsByMatrixSig.get(entry.matrixSig) || []; + canonicalPaths.push(canonicalParts); + canonicalPathHintsByMatrixSig.set(entry.matrixSig, canonicalPaths); + } + } + + for (const entry of byIndex.values()) { + if (!entry) continue; + if (entry.matrixSig && depthHintsByMatrixSig.has(entry.matrixSig)) { + const extra = depthHintsByMatrixSig.get(entry.matrixSig) || []; + entry.depthHints = Array.from(new Set([...(entry.depthHints || []), ...extra])); + if (entry.depthHints.length > 0) { + entry.expectedDepth = Math.min(...entry.depthHints); + } + } + if (entry.matrixSig && terminalHintsByMatrixSig.has(entry.matrixSig)) { + entry.terminalHints = Array.from(terminalHintsByMatrixSig.get(entry.matrixSig) || []); + } + if (entry.matrixSig && strictTerminalHintsByMatrixSig.has(entry.matrixSig)) { + entry.terminalHintsStrict = Array.from(strictTerminalHintsByMatrixSig.get(entry.matrixSig) || []); + } + if (entry.matrixSig && canonicalTerminalHintsByMatrixSig.has(entry.matrixSig)) { + entry.terminalHintsCanonical = Array.from(canonicalTerminalHintsByMatrixSig.get(entry.matrixSig) || []); + } + if (entry.matrixSig && strictPathHintsByMatrixSig.has(entry.matrixSig)) { + entry.pathHintsStrict = strictPathHintsByMatrixSig.get(entry.matrixSig) || []; + } + if (entry.matrixSig && veryLoosePathHintsByMatrixSig.has(entry.matrixSig)) { + entry.pathHintsVeryLoose = veryLoosePathHintsByMatrixSig.get(entry.matrixSig) || []; + } + if (entry.matrixSig && canonicalPathHintsByMatrixSig.has(entry.matrixSig)) { + entry.pathHintsCanonical = canonicalPathHintsByMatrixSig.get(entry.matrixSig) || []; + } + } + + for (const [name, count] of overrideTerminalLooseCount.entries()) { + if (count === 1 && overrideTerminalLooseFirst.has(name)) { + overrideTerminalLooseUnique.set(name, overrideTerminalLooseFirst.get(name)); + } + } + for (const [name, count] of overrideTerminalCanonicalCount.entries()) { + if (count === 1 && overrideTerminalCanonicalFirst.has(name)) { + overrideTerminalCanonicalUnique.set(name, overrideTerminalCanonicalFirst.get(name)); + } + } + + const byNameEntries = Array.from(byName.keys()).map((k) => { + const rawParts = k ? k.split("/") : []; + const looseParts = normalizeNamePathLoose(rawParts); + const veryLooseParts = normalizeNamePathVeryLoose(rawParts); + const canonicalParts = normalizeNamePathCanonical(rawParts); + return { + rawParts, + normParts: normalizeNamePath(rawParts), + looseParts, + veryLooseParts, + canonicalParts, + entry: byName.get(k), + }; + }); + + if (byIndex.size === 0 && byName.size === 0) return 0; + + const nodeEntries = []; + const nodeTerminalLooseCount = new Map(); + const nodeTerminalCanonicalCount = new Map(); + const collectNodes = (node, indexPath, namePath) => { + if (indexPath.length > 0) { + const normalizedNameParts = normalizeNamePath(namePath); + const looseNameParts = normalizeNamePathLoose(namePath); + const veryLooseNameParts = normalizeNamePathVeryLoose(namePath); + const canonicalNameParts = normalizeNamePathCanonical(namePath); + const terminalLoose = looseNameParts.length > 0 ? looseNameParts[looseNameParts.length - 1] : ""; + const terminalCanonical = canonicalNameParts.length > 0 ? canonicalNameParts[canonicalNameParts.length - 1] : ""; + if (terminalLoose) { + nodeTerminalLooseCount.set( + terminalLoose, + (nodeTerminalLooseCount.get(terminalLoose) ?? 0) + 1, + ); + } + if (terminalCanonical) { + nodeTerminalCanonicalCount.set( + terminalCanonical, + (nodeTerminalCanonicalCount.get(terminalCanonical) ?? 0) + 1, + ); + } + nodeEntries.push({ + node, + indexPath, + namePath, + normalizedNameParts, + looseNameParts, + veryLooseNameParts, + canonicalNameParts, + terminalLoose, + terminalCanonical, + modelRootMatrix: null, + }); + } + + const nameCount = new Map(); + for (let i = 0; i < node.children.length; i += 1) { + const child = node.children[i]; + const childName = String(child?.name ?? ""); + const occur = nameCount.get(childName) ?? 0; + nameCount.set(childName, occur + 1); + collectNodes( + child, + indexPath.concat(i), + namePath.concat(`${childName}#${occur}`), + ); + } + }; + collectNodes(root, [], []); + + // Ensure parent.matrixWorld is valid before resolving model-root-space overrides. + root.updateMatrixWorld(true); + rootWorldInv.copy(root.matrixWorld).invert(); + for (const nodeEntry of nodeEntries) { + nodeEntry.modelRootMatrix = tempModelRoot.copy(rootWorldInv).multiply(nodeEntry.node.matrixWorld).clone(); + } + + let applied = 0; + let matrixFallbackApplied = 0; + const appliedDebug = []; + const usedOverrideEntries = new Set(); + const usedOverrideSigs = new Set(); + const usedNodes = new Set(); + + const applyOverrideToNode = (node, overrideEntry, nodeEntry = null) => { + if (!overrideEntry || !Array.isArray(overrideEntry.matrix) || overrideEntry.matrix.length !== 16) return ""; + if (isIdentityRowMajorMatrix(overrideEntry.matrix)) return ""; + + const convertedMat = convertNodeMatrix( + THREE, + overrideEntry.matrix, + basis, + basisInv, + matrixConvention, + ); + + // Some Panda paths include an extra Geom wrapper level (same-name duplicate), + // but GLTFLoader may collapse it. In this case absolute target matrix can map to + // a slightly different runtime node. Use model-space delta replay to avoid jumps. + const overrideDepth = Array.isArray(overrideEntry.path) ? overrideEntry.path.length : 0; + const runtimeDepth = Array.isArray(nodeEntry?.namePath) ? nodeEntry.namePath.length : 0; + const collapsedWrapperDepth = overrideDepth > runtimeDepth; + let targetNode = node; + let targetNodeModel = nodeEntry?.modelRootMatrix + ? nodeEntry.modelRootMatrix.clone() + : tempModelRoot.copy(rootWorldInv).multiply(node.matrixWorld).clone(); + + if (collapsedWrapperDepth && Array.isArray(overrideEntry.path) && overrideEntry.path.length > 0) { + const expectedTail = canonicalizeNameSegment(overrideEntry.path[overrideEntry.path.length - 1]); + const sameNameChildren = (Array.isArray(node.children) ? node.children : []).filter( + (c) => canonicalizeNameSegment(c?.name ?? "") === expectedTail, + ); + if (sameNameChildren.length === 1) { + targetNode = sameNameChildren[0]; + targetNode.updateMatrixWorld(true); + targetNodeModel = tempModelRoot.copy(rootWorldInv).multiply(targetNode.matrixWorld).clone(); + } + } + if ( + modelRootSpace + && collapsedWrapperDepth + && Array.isArray(overrideEntry.baseMatrix) + && overrideEntry.baseMatrix.length === 16 + ) { + const baseConverted = convertNodeMatrix( + THREE, + overrideEntry.baseMatrix, + basis, + basisInv, + matrixConvention, + ); + + const deltaModel = convertedMat.clone().multiply(baseConverted.clone().invert()); + if ( + targetNode + && targetNode.isSkinnedMesh + && targetNode.skeleton + && Array.isArray(targetNode.skeleton.bones) + && targetNode.skeleton.bones.length > 0 + ) { + const bones = targetNode.skeleton.bones; + const boneSet = new Set(bones); + const rootBones = bones.filter((b) => !b.parent || !boneSet.has(b.parent)); + if (rootBones.length > 0) { + const tx = targetNodeModel.elements[12]; + const ty = targetNodeModel.elements[13]; + const tz = targetNodeModel.elements[14]; + let bestRoot = rootBones[0]; + let bestDist = Number.POSITIVE_INFINITY; + for (const b of rootBones) { + const bModel = tempModelRoot.copy(rootWorldInv).multiply(b.matrixWorld); + const dx = Number(bModel.elements[12]) - Number(tx); + const dy = Number(bModel.elements[13]) - Number(ty); + const dz = Number(bModel.elements[14]) - Number(tz); + const dist = (dx * dx) + (dy * dy) + (dz * dz); + if (dist < bestDist) { + bestDist = dist; + bestRoot = b; + } + } + + const bestRootModel = tempModelRoot.copy(rootWorldInv).multiply(bestRoot.matrixWorld).clone(); + const rootTargetModel = deltaModel.clone().multiply(bestRootModel); + let rootLocal = rootTargetModel; + if (bestRoot.parent) { + tempParentInv.copy(bestRoot.parent.matrixWorld).invert(); + rootLocal = tempParentInv.multiply(rootTargetModel); + } + bestRoot.matrixAutoUpdate = false; + bestRoot.matrix.copy(rootLocal); + bestRoot.matrix.decompose(bestRoot.position, bestRoot.quaternion, bestRoot.scale); + bestRoot.updateMatrixWorld(true); + return "skeleton_delta"; + } + } + + const targetModel = deltaModel.multiply(targetNodeModel); + let localDeltaMat = targetModel; + if (targetNode.parent) { + tempParentInv.copy(targetNode.parent.matrixWorld).invert(); + localDeltaMat = tempParentInv.multiply(targetModel); + } + targetNode.matrixAutoUpdate = false; + targetNode.matrix.copy(localDeltaMat); + targetNode.matrix.decompose(targetNode.position, targetNode.quaternion, targetNode.scale); + targetNode.updateMatrixWorld(false); + if (nodeEntry && targetNode === node) { + nodeEntry.modelRootMatrix = targetModel.clone(); + } + return targetNode === node ? "matrix_delta" : "matrix_delta_child"; + } + + let localMat = convertedMat; + + // SSBO edits are exported in model-root space (same as Panda's obj_np.getMat(model_root)). + if (modelRootSpace && targetNode.parent) { + tempParentInv.copy(targetNode.parent.matrixWorld).invert(); + localMat = tempParentInv.multiply(convertedMat); + } + + targetNode.matrixAutoUpdate = false; + targetNode.matrix.copy(localMat); + targetNode.matrix.decompose(targetNode.position, targetNode.quaternion, targetNode.scale); + targetNode.updateMatrixWorld(false); + return targetNode === node ? "matrix" : "matrix_child"; + }; + + const matrixMaxAbsDiff = (a, b) => { + if (!a || !b) return Number.POSITIVE_INFINITY; + const ae = a.elements; + const be = b.elements; + let diff = 0; + for (let i = 0; i < 16; i += 1) { + const d = Math.abs(Number(ae[i]) - Number(be[i])); + if (d > diff) diff = d; + } + return diff; + }; + + for (const entry of nodeEntries) { + const { + node, + indexPath, + namePath, + normalizedNameParts, + looseNameParts, + veryLooseNameParts, + canonicalNameParts, + terminalLoose, + terminalCanonical, + } = entry; + const indexKey = pathKey(indexPath); + const nameKey = pathKey(namePath); + const normalizedNameKey = pathKey(normalizedNameParts); + const looseNameKey = pathKey(looseNameParts); + const veryLooseNameKey = pathKey(veryLooseNameParts); + const canonicalNameKey = pathKey(canonicalNameParts); + + let matchedOverrideEntry = preferName + ? ( + byName.get(nameKey) + || byNameNormalized.get(normalizedNameKey) + || byNameLoose.get(looseNameKey) + || byNameVeryLoose.get(veryLooseNameKey) + || byNameCanonical.get(canonicalNameKey) + || byIndex.get(indexKey) + ) + : ( + byIndex.get(indexKey) + || byName.get(nameKey) + || byNameNormalized.get(normalizedNameKey) + || byNameLoose.get(looseNameKey) + || byNameVeryLoose.get(veryLooseNameKey) + || byNameCanonical.get(canonicalNameKey) + ); + + // Fallback A: tolerate wrappers when occurrence indices still match. + if (!matchedOverrideEntry && normalizedNameParts.length > 0) { + for (const info of byNameEntries) { + if (!info || !info.entry) continue; + if (isSuffixPath(info.normParts, normalizedNameParts) || isSuffixPath(normalizedNameParts, info.normParts)) { + matchedOverrideEntry = info.entry; + break; + } + } + } + + // Fallback B: ignore occurrence suffix like "#0" when matching. + if (!matchedOverrideEntry && looseNameParts.length > 0) { + for (const info of byNameEntries) { + if (!info || !info.entry) continue; + if (isSuffixPath(info.looseParts, looseNameParts) || isSuffixPath(looseNameParts, info.looseParts)) { + matchedOverrideEntry = info.entry; + break; + } + } + } + + // Fallback C1: ignore both '#occurrence' and Blender '.001/.002' suffixes. + if (!matchedOverrideEntry && veryLooseNameParts.length > 0) { + for (const info of byNameEntries) { + if (!info || !info.entry) continue; + if (isSuffixPath(info.veryLooseParts, veryLooseNameParts) || isSuffixPath(veryLooseNameParts, info.veryLooseParts)) { + matchedOverrideEntry = info.entry; + break; + } + } + } + + // Fallback C2: punctuation-insensitive path matching while keeping numeric tails. + if (!matchedOverrideEntry && canonicalNameParts.length > 0) { + for (const info of byNameEntries) { + if (!info || !info.entry) continue; + if (isSuffixPath(info.canonicalParts, canonicalNameParts) || isSuffixPath(canonicalNameParts, info.canonicalParts)) { + matchedOverrideEntry = info.entry; + break; + } + } + } + + // Fallback C: unique terminal-name match (only when both sides are unique). + if (!matchedOverrideEntry && terminalLoose) { + const nodeTerminalCount = nodeTerminalLooseCount.get(terminalLoose) ?? 0; + if (nodeTerminalCount === 1 && overrideTerminalLooseUnique.has(terminalLoose)) { + matchedOverrideEntry = overrideTerminalLooseUnique.get(terminalLoose); + } + } + + // Fallback C3: punctuation-insensitive unique terminal match. + if (!matchedOverrideEntry && terminalCanonical) { + const nodeTerminalCount = nodeTerminalCanonicalCount.get(terminalCanonical) ?? 0; + if (nodeTerminalCount === 1 && overrideTerminalCanonicalUnique.has(terminalCanonical)) { + matchedOverrideEntry = overrideTerminalCanonicalUnique.get(terminalCanonical); + } + } + + const applyMode = applyOverrideToNode(node, matchedOverrideEntry, entry); + if (applyMode) { + usedOverrideEntries.add(matchedOverrideEntry); + if (matchedOverrideEntry.matrixSig) usedOverrideSigs.add(matchedOverrideEntry.matrixSig); + const indexEntry = byIndex.get(indexKey); + if (indexEntry) { + usedOverrideEntries.add(indexEntry); + if (indexEntry.matrixSig) usedOverrideSigs.add(indexEntry.matrixSig); + } + usedNodes.add(node); + applied += 1; + if (appliedDebug.length < 8) { + appliedDebug.push({ + runtimePath: pathKey(namePath), + runtimeIndex: pathKey(indexPath), + overridePath: Array.isArray(matchedOverrideEntry.path) ? pathKey(matchedOverrideEntry.path) : "", + via: applyMode, + }); + } + } + } + + // Fallback D: matrix-based matching for SSBO when names/index do not align. + const unmatchedIndexEntries = Array.from(byIndex.values()).filter( + (v) => ( + !usedOverrideEntries.has(v) + && !(v.matrixSig && usedOverrideSigs.has(v.matrixSig)) + && Array.isArray(v.baseMatrix) + && v.baseMatrix.length === 16 + ), + ); + for (const overrideEntry of unmatchedIndexEntries) { + const baseConverted = convertNodeMatrix( + THREE, + overrideEntry.baseMatrix, + basis, + basisInv, + matrixConvention, + ); + + let best = null; + let bestErr = Number.POSITIVE_INFINITY; + let bestDepthErr = Number.POSITIVE_INFINITY; + let tieCount = 0; + let tieCandidates = []; + const depthHints = Array.isArray(overrideEntry.depthHints) && overrideEntry.depthHints.length > 0 + ? overrideEntry.depthHints + : [Number(overrideEntry.expectedDepth ?? 0)]; + for (const nodeEntry of nodeEntries) { + if (!nodeEntry || !nodeEntry.node || usedNodes.has(nodeEntry.node)) continue; + const nodeDepth = Number(nodeEntry.indexPath?.length ?? 0); + let depthErr = Number.POSITIVE_INFINITY; + for (const hint of depthHints) { + const v = Math.abs(nodeDepth - Number(hint)); + if (v < depthErr) depthErr = v; + } + if (depthErr > maxDepthDiffForMatrixFallback) continue; + + const err = matrixMaxAbsDiff(nodeEntry.modelRootMatrix, baseConverted); + const better = + depthErr < bestDepthErr || + (depthErr === bestDepthErr && err < bestErr - matrixTieEps); + const tie = + depthErr === bestDepthErr && + Math.abs(err - bestErr) <= matrixTieEps; + + if (better) { + bestErr = err; + bestDepthErr = depthErr; + best = nodeEntry; + tieCount = 1; + tieCandidates = [nodeEntry]; + } else if (tie) { + tieCount += 1; + tieCandidates.push(nodeEntry); + } + } + + if (best && tieCount > 1) { + const strictPathHints = Array.isArray(overrideEntry.pathHintsStrict) ? overrideEntry.pathHintsStrict : []; + const veryLoosePathHints = Array.isArray(overrideEntry.pathHintsVeryLoose) ? overrideEntry.pathHintsVeryLoose : []; + const canonicalPathHints = Array.isArray(overrideEntry.pathHintsCanonical) ? overrideEntry.pathHintsCanonical : []; + const termHintsStrict = Array.isArray(overrideEntry.terminalHintsStrict) ? overrideEntry.terminalHintsStrict : []; + const termHints = Array.isArray(overrideEntry.terminalHints) ? overrideEntry.terminalHints : []; + const termHintsCanonical = Array.isArray(overrideEntry.terminalHintsCanonical) ? overrideEntry.terminalHintsCanonical : []; + const ranked = tieCandidates.map((nodeEntry) => { + const strictTail = Array.isArray(nodeEntry.looseNameParts) && nodeEntry.looseNameParts.length > 0 + ? nodeEntry.looseNameParts[nodeEntry.looseNameParts.length - 1] + : ""; + const veryLooseTail = Array.isArray(nodeEntry.veryLooseNameParts) && nodeEntry.veryLooseNameParts.length > 0 + ? nodeEntry.veryLooseNameParts[nodeEntry.veryLooseNameParts.length - 1] + : ""; + const canonicalTail = Array.isArray(nodeEntry.canonicalNameParts) && nodeEntry.canonicalNameParts.length > 0 + ? nodeEntry.canonicalNameParts[nodeEntry.canonicalNameParts.length - 1] + : ""; + let strictPathScore = 0; + for (const hint of strictPathHints) { + strictPathScore = Math.max(strictPathScore, suffixMatchScore(hint, nodeEntry.looseNameParts || [])); + } + let veryLoosePathScore = 0; + for (const hint of veryLoosePathHints) { + veryLoosePathScore = Math.max(veryLoosePathScore, suffixMatchScore(hint, nodeEntry.veryLooseNameParts || [])); + } + let canonicalPathScore = 0; + for (const hint of canonicalPathHints) { + canonicalPathScore = Math.max(canonicalPathScore, suffixMatchScore(hint, nodeEntry.canonicalNameParts || [])); + } + const strictTailHit = (strictTail && termHintsStrict.includes(strictTail)) ? 1 : 0; + const veryLooseTailHit = (veryLooseTail && termHints.includes(veryLooseTail)) ? 1 : 0; + const canonicalTailHit = (canonicalTail && termHintsCanonical.includes(canonicalTail)) ? 1 : 0; + return { + nodeEntry, + strictPathScore, + canonicalPathScore, + veryLoosePathScore, + strictTailHit, + canonicalTailHit, + veryLooseTailHit, + }; + }); + + ranked.sort((a, b) => { + if (b.strictPathScore !== a.strictPathScore) return b.strictPathScore - a.strictPathScore; + if (b.strictTailHit !== a.strictTailHit) return b.strictTailHit - a.strictTailHit; + if (b.canonicalPathScore !== a.canonicalPathScore) return b.canonicalPathScore - a.canonicalPathScore; + if (b.canonicalTailHit !== a.canonicalTailHit) return b.canonicalTailHit - a.canonicalTailHit; + if (b.veryLoosePathScore !== a.veryLoosePathScore) return b.veryLoosePathScore - a.veryLoosePathScore; + if (b.veryLooseTailHit !== a.veryLooseTailHit) return b.veryLooseTailHit - a.veryLooseTailHit; + return 0; + }); + + if (ranked.length > 0) { + const top = ranked[0]; + const second = ranked.length > 1 ? ranked[1] : null; + const topTuple = [ + top.strictPathScore, + top.strictTailHit, + top.canonicalPathScore, + top.canonicalTailHit, + top.veryLoosePathScore, + top.veryLooseTailHit, + ].join("|"); + const secondTuple = second + ? [ + second.strictPathScore, + second.strictTailHit, + second.canonicalPathScore, + second.canonicalTailHit, + second.veryLoosePathScore, + second.veryLooseTailHit, + ].join("|") + : ""; + const topHasSignal = ( + top.strictPathScore > 0 + || top.strictTailHit > 0 + || top.canonicalPathScore > 0 + || top.canonicalTailHit > 0 + || top.veryLoosePathScore > 0 + || top.veryLooseTailHit > 0 + ); + const uniqueTop = !second || topTuple !== secondTuple; + if (topHasSignal && uniqueTop) { + best = top.nodeEntry; + tieCount = 1; + } + } + } + + if (best && tieCount > 1) { + console.warn("[WebGLPack] Matrix fallback ambiguous, skip override:", { + path: overrideEntry.path, + depthHints, + bestDepthErr, + bestErr, + tieCount, + }); + continue; + } + + const fallbackMode = (best && bestErr <= matrixMatchEps) + ? applyOverrideToNode(best.node, overrideEntry, best) + : ""; + if (fallbackMode) { + usedOverrideEntries.add(overrideEntry); + if (overrideEntry.matrixSig) usedOverrideSigs.add(overrideEntry.matrixSig); + usedNodes.add(best.node); + applied += 1; + matrixFallbackApplied += 1; + if (appliedDebug.length < 8) { + appliedDebug.push({ + runtimePath: pathKey(best.namePath || []), + runtimeIndex: pathKey(best.indexPath || []), + overridePath: Array.isArray(overrideEntry.path) ? pathKey(overrideEntry.path) : "", + via: `matrix_fallback:${fallbackMode}`, + }); + } + } + } + + const unusedIndex = Array.from(byIndex.entries()) + .filter(([, entry]) => !usedOverrideEntries.has(entry) && !(entry.matrixSig && usedOverrideSigs.has(entry.matrixSig))) + .map(([key]) => key); + const unusedName = Array.from(byName.entries()) + .filter(([, entry]) => !usedOverrideEntries.has(entry) && !(entry.matrixSig && usedOverrideSigs.has(entry.matrixSig))) + .map(([key]) => key); + + if (unusedIndex.length > 0 || unusedName.length > 0) { + console.warn("[WebGLPack] Subnode overrides partially unmatched:", { + rootName: root.name || "(unnamed)", + applied, + matrixFallbackApplied, + totalIndexOverrides: byIndex.size, + totalNameOverrides: byName.size, + unmatchedIndex: unusedIndex.slice(0, 20), + unmatchedName: unusedName.slice(0, 20), + }); + console.info( + "[WebGLPack] Sample runtime node paths:", + nodeEntries.slice(0, 20).map((v) => pathKey(v.namePath)), + ); + } else { + console.info("[WebGLPack] Subnode overrides matched:", { + rootName: root.name || "(unnamed)", + applied, + matrixFallbackApplied, + totalIndexOverrides: byIndex.size, + totalNameOverrides: byName.size, + matrixSpace, + appliedSample: appliedDebug, + }); + } + + return applied; +} + function directionToThree(THREE, direction, basis) { const d = new THREE.Vector3( Number(direction?.[0] ?? 0), @@ -304,6 +1136,14 @@ async function bootstrap() { (gltf) => { const root = gltf.scene || (Array.isArray(gltf.scenes) ? gltf.scenes[0] : null); if (root) { + applySubnodeOverrides( + THREE, + root, + node.subnode_overrides || null, + basis, + basisInv, + matrixConvention, + ); applyMaterialOverride(THREE, root, node.material_override || null); applyTextureOverrides(THREE, root, node.texture_overrides || [], textureLoader); obj.add(root);