添加打包web

This commit is contained in:
Rowland 2026-03-16 09:47:34 +08:00
parent da63dd4877
commit 23472b9639
3 changed files with 1345 additions and 2 deletions

View File

@ -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

View File

@ -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:

View File

@ -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);