添加打包web
This commit is contained in:
parent
da63dd4877
commit
23472b9639
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user