From 0d33cf00977d16e6282931aba2cf771ec2c84c6b Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Mon, 5 Jan 2026 11:23:42 +0800 Subject: [PATCH] feat(node-editor, blueprint): add group box and math/logic nodes (#438) * feat(node-editor, blueprint): add group box and math/logic nodes node-editor: - Add visual group box for organizing nodes - Dynamic bounds calculation based on node pin counts - Groups auto-resize to wrap contained nodes - Dragging group header moves all nodes together blueprint: - Add comprehensive math nodes (modulo, power, sqrt, trig, etc.) - Add logic nodes (comparison, boolean, select) docs: - Update nodes.md with new math and logic nodes - Add group feature documentation to editor-guide.md * chore: remove unused debug and test scripts Remove FBX animation debug scripts that are no longer needed: - analyze-fbx, debug-*, test-*, verify-*, check-*, compare-*, trace-*, simple-fbx-test Remove unused kill-dev-server.js from editor-app --- .changeset/group-box-feature.md | 28 + .../docs/en/modules/blueprint/editor-guide.md | 24 + .../docs/en/modules/blueprint/nodes.md | 80 +- .../docs/modules/blueprint/editor-guide.md | 24 + .../content/docs/modules/blueprint/nodes.md | 78 +- .../src/components/editor/NodeEditor.tsx | 191 ++++- .../components/nodes/GroupNodeComponent.tsx | 124 +++ .../node-editor/src/components/nodes/index.ts | 6 + .../node-editor/src/domain/models/Graph.ts | 93 +- .../src/domain/models/NodeGroup.ts | 146 ++++ .../node-editor/src/domain/models/index.ts | 9 + packages/devtools/node-editor/src/index.ts | 10 +- .../node-editor/src/styles/GraphNode.css | 29 + .../node-editor/src/styles/GroupNode.css | 60 ++ .../devtools/node-editor/src/styles/index.css | 1 + .../editor-app/scripts/kill-dev-server.js | 47 - .../framework/blueprint/src/nodes/index.ts | 5 + .../src/nodes/logic/ComparisonNodes.ts | 435 ++++++++++ .../blueprint/src/nodes/logic/index.ts | 6 + .../src/nodes/math/MathOperations.ts | 441 ++++++++++ scripts/analyze-fbx.mjs | 239 ------ scripts/check-anim-coverage.mjs | 256 ------ scripts/check-bone-hierarchy.mjs | 259 ------ scripts/check-prerotation.mjs | 183 ---- scripts/compare-ibm.mjs | 318 ------- scripts/compare-world-matrix.mjs | 355 -------- scripts/debug-channels.mjs | 227 ----- scripts/debug-fbx-animation.mjs | 328 ------- scripts/debug-runtime-anim.mjs | 564 ------------ scripts/simple-fbx-test.mjs | 68 -- scripts/test-animation-t0.mjs | 143 ---- scripts/test-animation-times.mjs | 309 ------- scripts/test-fbx-animation.mjs | 741 ---------------- scripts/test-fbxloader-bindpose.mjs | 199 ----- scripts/test-full-pipeline.mjs | 806 ------------------ scripts/trace-fbxloader-output.mjs | 309 ------- scripts/verify-anim-t0.mjs | 377 -------- scripts/verify-animation-skeleton-mapping.mjs | 351 -------- scripts/verify-mesh-skinning.mjs | 388 --------- 39 files changed, 1770 insertions(+), 6487 deletions(-) create mode 100644 .changeset/group-box-feature.md create mode 100644 packages/devtools/node-editor/src/components/nodes/GroupNodeComponent.tsx create mode 100644 packages/devtools/node-editor/src/domain/models/NodeGroup.ts create mode 100644 packages/devtools/node-editor/src/styles/GroupNode.css delete mode 100644 packages/editor/editor-app/scripts/kill-dev-server.js create mode 100644 packages/framework/blueprint/src/nodes/logic/ComparisonNodes.ts create mode 100644 packages/framework/blueprint/src/nodes/logic/index.ts delete mode 100644 scripts/analyze-fbx.mjs delete mode 100644 scripts/check-anim-coverage.mjs delete mode 100644 scripts/check-bone-hierarchy.mjs delete mode 100644 scripts/check-prerotation.mjs delete mode 100644 scripts/compare-ibm.mjs delete mode 100644 scripts/compare-world-matrix.mjs delete mode 100644 scripts/debug-channels.mjs delete mode 100644 scripts/debug-fbx-animation.mjs delete mode 100644 scripts/debug-runtime-anim.mjs delete mode 100644 scripts/simple-fbx-test.mjs delete mode 100644 scripts/test-animation-t0.mjs delete mode 100644 scripts/test-animation-times.mjs delete mode 100644 scripts/test-fbx-animation.mjs delete mode 100644 scripts/test-fbxloader-bindpose.mjs delete mode 100644 scripts/test-full-pipeline.mjs delete mode 100644 scripts/trace-fbxloader-output.mjs delete mode 100644 scripts/verify-anim-t0.mjs delete mode 100644 scripts/verify-animation-skeleton-mapping.mjs delete mode 100644 scripts/verify-mesh-skinning.mjs diff --git a/.changeset/group-box-feature.md b/.changeset/group-box-feature.md new file mode 100644 index 00000000..82c2f22b --- /dev/null +++ b/.changeset/group-box-feature.md @@ -0,0 +1,28 @@ +--- +"@esengine/node-editor": minor +"@esengine/blueprint": minor +--- + +feat(node-editor): add visual group box for organizing nodes + +- Add NodeGroup model with dynamic bounds calculation based on node pin counts +- Add GroupNodeComponent for rendering group boxes behind nodes +- Groups automatically resize to wrap contained nodes +- Dragging group header moves all nodes inside together +- Support group serialization/deserialization +- Export `estimateNodeHeight` and `NodeBounds` for accurate size calculation + +feat(blueprint): add comprehensive math and logic nodes + +Math nodes: +- Modulo, Abs, Min, Max, Power, Sqrt +- Floor, Ceil, Round, Sign, Negate +- Sin, Cos, Tan, Asin, Acos, Atan, Atan2 +- DegToRad, RadToDeg, Lerp, InverseLerp +- Clamp, Wrap, RandomRange, RandomInt + +Logic nodes: +- Equal, NotEqual, GreaterThan, GreaterThanOrEqual +- LessThan, LessThanOrEqual, InRange +- AND, OR, NOT, XOR, NAND +- IsNull, Select (ternary) diff --git a/docs/src/content/docs/en/modules/blueprint/editor-guide.md b/docs/src/content/docs/en/modules/blueprint/editor-guide.md index 31b75a04..d98dd7e8 100644 --- a/docs/src/content/docs/en/modules/blueprint/editor-guide.md +++ b/docs/src/content/docs/en/modules/blueprint/editor-guide.md @@ -270,6 +270,30 @@ If you delete a variable but nodes still reference it: - Nodes display a **red border** and **warning icon** - You need to recreate the variable or delete these nodes +## Node Grouping + +You can organize multiple nodes into a visual group box to help manage complex blueprints. + +### Creating a Group + +1. Box-select or Ctrl+click to select multiple nodes (at least 2) +2. Right-click on the selected nodes +3. Choose **Create Group** +4. A group box will automatically wrap all selected nodes + +### Group Operations + +| Action | Method | +|--------|--------| +| Move group | Drag the group header, all nodes move together | +| Ungroup | Right-click on group box → **Ungroup** | + +### Features + +- **Dynamic sizing**: Group box automatically resizes to wrap all nodes +- **Independent movement**: You can move nodes within the group individually, and the box adjusts +- **Editor only**: Groups are purely visual organization, no runtime impact + ## Keyboard Shortcuts | Shortcut | Function | diff --git a/docs/src/content/docs/en/modules/blueprint/nodes.md b/docs/src/content/docs/en/modules/blueprint/nodes.md index 540c11dc..3a29cbc9 100644 --- a/docs/src/content/docs/en/modules/blueprint/nodes.md +++ b/docs/src/content/docs/en/modules/blueprint/nodes.md @@ -75,13 +75,87 @@ Control execution flow: ## Math Nodes +Basic Operations: + | Node | Description | |------|-------------| -| `Add` / `Subtract` / `Multiply` / `Divide` | Basic operations | +| `Add` / `Subtract` / `Multiply` / `Divide` | Basic arithmetic | +| `Modulo` | Modulo operation (%) | +| `Negate` | Negate value | | `Abs` | Absolute value | -| `Clamp` | Clamp to range | -| `Lerp` | Linear interpolation | +| `Sign` | Sign (+1, 0, -1) | | `Min` / `Max` | Minimum/Maximum | +| `Clamp` | Clamp to range | +| `Wrap` | Wrap value to range | + +Power & Roots: + +| Node | Description | +|------|-------------| +| `Power` | Power (A^B) | +| `Sqrt` | Square root | + +Rounding: + +| Node | Description | +|------|-------------| +| `Floor` | Round down | +| `Ceil` | Round up | +| `Round` | Round to nearest | + +Trigonometry: + +| Node | Description | +|------|-------------| +| `Sin` / `Cos` / `Tan` | Sine/Cosine/Tangent | +| `Asin` / `Acos` / `Atan` | Inverse trig functions | +| `Atan2` | Two-argument arctangent | +| `DegToRad` / `RadToDeg` | Degree/Radian conversion | + +Interpolation: + +| Node | Description | +|------|-------------| +| `Lerp` | Linear interpolation | +| `InverseLerp` | Inverse linear interpolation | + +Random: + +| Node | Description | +|------|-------------| +| `Random Range` | Random float in range | +| `Random Int` | Random integer in range | + +## Logic Nodes + +Comparison: + +| Node | Description | +|------|-------------| +| `Equal` | Equal (==) | +| `Not Equal` | Not equal (!=) | +| `Greater Than` | Greater than (>) | +| `Greater Or Equal` | Greater than or equal (>=) | +| `Less Than` | Less than (<) | +| `Less Or Equal` | Less than or equal (<=) | +| `In Range` | Check if value is in range | + +Logical Operations: + +| Node | Description | +|------|-------------| +| `AND` | Logical AND | +| `OR` | Logical OR | +| `NOT` | Logical NOT | +| `XOR` | Exclusive OR | +| `NAND` | NOT AND | + +Utility: + +| Node | Description | +|------|-------------| +| `Is Null` | Check if value is null | +| `Select` | Choose A or B based on condition (ternary) | ## Debug Nodes diff --git a/docs/src/content/docs/modules/blueprint/editor-guide.md b/docs/src/content/docs/modules/blueprint/editor-guide.md index c3a22069..f408ce53 100644 --- a/docs/src/content/docs/modules/blueprint/editor-guide.md +++ b/docs/src/content/docs/modules/blueprint/editor-guide.md @@ -270,6 +270,30 @@ your-project/ - 节点会显示 **红色边框** 和 **警告图标** - 需要重新创建变量或删除这些节点 +## 节点分组 + +可以将多个节点组织到一个可视化组框中,便于整理复杂蓝图。 + +### 创建组 + +1. 框选或 Ctrl+点击 选中多个节点(至少 2 个) +2. 右键点击选中的节点 +3. 选择 **创建分组** +4. 组框会自动包裹所有选中的节点 + +### 组操作 + +| 操作 | 方式 | +|------|------| +| 移动组 | 拖拽组框头部,所有节点一起移动 | +| 取消分组 | 右键点击组框 → **取消分组** | + +### 特性 + +- **动态大小**:组框会自动调整大小以包裹所有节点 +- **独立移动**:可以单独移动组内的节点,组框会自动调整 +- **仅编辑器**:组是纯视觉组织,不影响运行时逻辑 + ## 快捷键 | 快捷键 | 功能 | diff --git a/docs/src/content/docs/modules/blueprint/nodes.md b/docs/src/content/docs/modules/blueprint/nodes.md index ec98341c..18f7221c 100644 --- a/docs/src/content/docs/modules/blueprint/nodes.md +++ b/docs/src/content/docs/modules/blueprint/nodes.md @@ -75,13 +75,87 @@ description: "蓝图内置 ECS 操作节点" ## 数学节点 (Math) +基础运算: + | 节点 | 说明 | |------|------| | `Add` / `Subtract` / `Multiply` / `Divide` | 四则运算 | +| `Modulo` | 取模运算 (%) | +| `Negate` | 取负 | | `Abs` | 绝对值 | -| `Clamp` | 限制范围 | -| `Lerp` | 线性插值 | +| `Sign` | 符号 (+1, 0, -1) | | `Min` / `Max` | 最小/最大值 | +| `Clamp` | 限制在范围内 | +| `Wrap` | 循环限制在范围内 | + +幂与根: + +| 节点 | 说明 | +|------|------| +| `Power` | 幂运算 (A^B) | +| `Sqrt` | 平方根 | + +取整: + +| 节点 | 说明 | +|------|------| +| `Floor` | 向下取整 | +| `Ceil` | 向上取整 | +| `Round` | 四舍五入 | + +三角函数: + +| 节点 | 说明 | +|------|------| +| `Sin` / `Cos` / `Tan` | 正弦/余弦/正切 | +| `Asin` / `Acos` / `Atan` | 反三角函数 | +| `Atan2` | 两参数反正切 | +| `DegToRad` / `RadToDeg` | 角度与弧度转换 | + +插值: + +| 节点 | 说明 | +|------|------| +| `Lerp` | 线性插值 | +| `InverseLerp` | 反向线性插值 | + +随机数: + +| 节点 | 说明 | +|------|------| +| `Random Range` | 范围内随机浮点数 | +| `Random Int` | 范围内随机整数 | + +## 逻辑节点 (Logic) + +比较运算: + +| 节点 | 说明 | +|------|------| +| `Equal` | 等于 (==) | +| `Not Equal` | 不等于 (!=) | +| `Greater Than` | 大于 (>) | +| `Greater Or Equal` | 大于等于 (>=) | +| `Less Than` | 小于 (<) | +| `Less Or Equal` | 小于等于 (<=) | +| `In Range` | 检查值是否在范围内 | + +逻辑运算: + +| 节点 | 说明 | +|------|------| +| `AND` | 逻辑与 | +| `OR` | 逻辑或 | +| `NOT` | 逻辑非 | +| `XOR` | 异或 | +| `NAND` | 与非 | + +工具节点: + +| 节点 | 说明 | +|------|------| +| `Is Null` | 检查值是否为空 | +| `Select` | 根据条件选择 A 或 B (三元运算) | ## 调试节点 (Debug) diff --git a/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx b/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx index fd8e0f5e..a37aa7e2 100644 --- a/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx +++ b/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx @@ -4,8 +4,10 @@ import { GraphNode, NodeTemplate } from '../../domain/models/GraphNode'; import { Connection } from '../../domain/models/Connection'; import { Pin } from '../../domain/models/Pin'; import { Position } from '../../domain/value-objects/Position'; +import { NodeGroup, computeGroupBounds, estimateNodeHeight } from '../../domain/models/NodeGroup'; import { GraphCanvas } from '../canvas/GraphCanvas'; import { MemoizedGraphNodeComponent, NodeExecutionState } from '../nodes/GraphNodeComponent'; +import { MemoizedGroupNodeComponent } from '../nodes/GroupNodeComponent'; import { ConnectionLayer } from '../connections/ConnectionLine'; /** @@ -56,6 +58,12 @@ export interface NodeEditorProps { /** Connection context menu callback (连接右键菜单回调) */ onConnectionContextMenu?: (connection: Connection, e: React.MouseEvent) => void; + + /** Group context menu callback (组右键菜单回调) */ + onGroupContextMenu?: (group: NodeGroup, e: React.MouseEvent) => void; + + /** Group double click callback - typically used to expand group (组双击回调 - 通常用于展开组) */ + onGroupDoubleClick?: (group: NodeGroup) => void; } /** @@ -112,7 +120,9 @@ export const NodeEditor: React.FC = ({ onNodeDoubleClick: _onNodeDoubleClick, onCanvasContextMenu, onNodeContextMenu, - onConnectionContextMenu + onConnectionContextMenu, + onGroupContextMenu, + onGroupDoubleClick }) => { // Silence unused variable warnings (消除未使用变量警告) void _templates; @@ -152,6 +162,64 @@ export const NodeEditor: React.FC = ({ return graph.nodes.map(n => `${n.id}:${n.isCollapsed}`).join(','); }, [graph.nodes]); + // Groups are now simple visual boxes - no node hiding + // 组现在是简单的可视化框 - 不隐藏节点 + + // Track selected group IDs (local state, managed similarly to nodes) + // 跟踪选中的组ID(本地状态,类似节点管理方式) + const [selectedGroupIds, setSelectedGroupIds] = useState>(new Set()); + + // Group drag state - includes initial positions of nodes in the group + // 组拖拽状态 - 包含组内节点的初始位置 + const [groupDragState, setGroupDragState] = useState<{ + groupId: string; + startGroupPosition: Position; + startMouse: { x: number; y: number }; + nodeStartPositions: Map; + } | null>(null); + + // Key for tracking group changes + const groupsKey = useMemo(() => { + return graph.groups.map(g => `${g.id}:${g.position.x}:${g.position.y}`).join(','); + }, [graph.groups]); + + // Compute dynamic group bounds based on current node positions and sizes + // 根据当前节点位置和尺寸动态计算组边界 + const groupsWithDynamicBounds = useMemo(() => { + const defaultNodeWidth = 200; + + return graph.groups.map(group => { + // Get current bounds of all nodes in this group + const nodeBounds = group.nodeIds + .map(nodeId => graph.getNode(nodeId)) + .filter((node): node is GraphNode => node !== undefined) + .map(node => ({ + x: node.position.x, + y: node.position.y, + width: defaultNodeWidth, + height: estimateNodeHeight( + node.inputPins.length, + node.outputPins.length, + node.isCollapsed + ) + })); + + if (nodeBounds.length === 0) { + // No nodes found, use stored position/size as fallback + return group; + } + + // Calculate dynamic bounds based on actual node sizes + const { position, size } = computeGroupBounds(nodeBounds); + + return { + ...group, + position, + size + }; + }); + }, [graph.groups, graph.nodes]); + useEffect(() => { // Use requestAnimationFrame to wait for DOM to be fully rendered // 使用 requestAnimationFrame 等待 DOM 完全渲染 @@ -159,7 +227,7 @@ export const NodeEditor: React.FC = ({ forceUpdate(n => n + 1); }); return () => cancelAnimationFrame(rafId); - }, [graph.id, collapsedNodesKey]); + }, [graph.id, collapsedNodesKey, groupsKey]); /** * Converts screen coordinates to canvas coordinates @@ -181,6 +249,7 @@ export const NodeEditor: React.FC = ({ * * 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量 * 当节点收缩时,返回节点头部的位置 + * 当节点在折叠组中时,返回组节点的位置 */ const getPinPosition = useCallback((pinId: string): Position | undefined => { // First, find which node this pin belongs to @@ -319,6 +388,22 @@ export const NodeEditor: React.FC = ({ onGraphChange?.(newGraph); } + // Group dragging - moves all nodes inside (group bounds are dynamic) + // 组拖拽 - 移动组内所有节点(组边界是动态计算的) + if (groupDragState) { + const dx = mousePos.x - groupDragState.startMouse.x; + const dy = mousePos.y - groupDragState.startMouse.y; + + // Only move nodes - group bounds will auto-recalculate + let newGraph = graph; + for (const [nodeId, startPos] of groupDragState.nodeStartPositions) { + const newNodePos = new Position(startPos.x + dx, startPos.y + dy); + newGraph = newGraph.moveNode(nodeId, newNodePos); + } + + onGraphChange?.(newGraph); + } + // Connection dragging (连接拖拽) if (connectionDrag) { const isValid = hoveredPin ? connectionDrag.fromPin.canConnectTo(hoveredPin) : undefined; @@ -330,7 +415,7 @@ export const NodeEditor: React.FC = ({ isValid } : null); } - }, [graph, dragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]); + }, [graph, dragState, groupDragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]); /** * Handles mouse up to end dragging @@ -342,6 +427,11 @@ export const NodeEditor: React.FC = ({ setDragState(null); } + // End group dragging (结束组拖拽) + if (groupDragState) { + setGroupDragState(null); + } + // End connection dragging (结束连接拖拽) if (connectionDrag) { // Use hoveredPin directly instead of relying on async state update @@ -376,7 +466,7 @@ export const NodeEditor: React.FC = ({ setConnectionDrag(null); } - }, [graph, dragState, connectionDrag, hoveredPin, onGraphChange]); + }, [graph, dragState, groupDragState, connectionDrag, hoveredPin, onGraphChange]); /** * Handles pin mouse down @@ -487,6 +577,81 @@ export const NodeEditor: React.FC = ({ } }, [graph, onConnectionContextMenu]); + /** + * Handles group selection + * 处理组选择 + */ + const handleGroupSelect = useCallback((groupId: string, additive: boolean) => { + if (readOnly) return; + + const newSelection = new Set(selectedGroupIds); + + if (additive) { + if (newSelection.has(groupId)) { + newSelection.delete(groupId); + } else { + newSelection.add(groupId); + } + } else { + newSelection.clear(); + newSelection.add(groupId); + } + + setSelectedGroupIds(newSelection); + // Clear node and connection selection when selecting groups + onSelectionChange?.(new Set(), new Set()); + }, [selectedGroupIds, readOnly, onSelectionChange]); + + /** + * Handles group drag start + * 处理组拖拽开始 + * + * Captures initial positions of both the group and all nodes inside it + * 捕获组和组内所有节点的初始位置 + */ + const handleGroupDragStart = useCallback((groupId: string, startMouse: { x: number; y: number }) => { + if (readOnly) return; + + const group = graph.getGroup(groupId); + if (!group) return; + + // Convert screen coordinates to canvas coordinates (same as node dragging) + // 将屏幕坐标转换为画布坐标(与节点拖拽相同) + const canvasPos = screenToCanvas(startMouse.x, startMouse.y); + + // Capture initial positions of all nodes in the group + const nodeStartPositions = new Map(); + for (const nodeId of group.nodeIds) { + const node = graph.getNode(nodeId); + if (node) { + nodeStartPositions.set(nodeId, node.position); + } + } + + setGroupDragState({ + groupId, + startGroupPosition: group.position, + startMouse: { x: canvasPos.x, y: canvasPos.y }, + nodeStartPositions + }); + }, [graph, readOnly, screenToCanvas]); + + /** + * Handles group context menu + * 处理组右键菜单 + */ + const handleGroupContextMenu = useCallback((group: NodeGroup, e: React.MouseEvent) => { + onGroupContextMenu?.(group, e); + }, [onGroupContextMenu]); + + /** + * Handles group double click + * 处理组双击 + */ + const handleGroupDoubleClick = useCallback((group: NodeGroup) => { + onGroupDoubleClick?.(group); + }, [onGroupDoubleClick]); + /** * Handles canvas click to deselect * 处理画布点击取消选择 @@ -500,6 +665,7 @@ export const NodeEditor: React.FC = ({ } if (!readOnly) { onSelectionChange?.(new Set(), new Set()); + setSelectedGroupIds(new Set()); } }, [readOnly, onSelectionChange]); @@ -644,6 +810,21 @@ export const NodeEditor: React.FC = ({ onCanvasMouseMove={handleBoxSelectMove} onCanvasMouseUp={handleBoxSelectEnd} > + {/* Group boxes - rendered first so they appear behind nodes (组框 - 先渲染,这样显示在节点后面) */} + {/* Use dynamically calculated bounds so groups auto-resize to fit nodes */} + {groupsWithDynamicBounds.map(group => ( + + ))} + {/* Connection layer (连接层) */} = ({ onConnectionContextMenu={handleConnectionContextMenu} /> - {/* Nodes (节点) */} + {/* All Nodes (所有节点) */} {graph.nodes.map(node => ( void; + + /** Drag start handler */ + onDragStart?: (groupId: string, startPosition: { x: number; y: number }) => void; + + /** Context menu handler */ + onContextMenu?: (group: NodeGroup, e: React.MouseEvent) => void; + + /** Double click handler for editing name */ + onDoubleClick?: (group: NodeGroup) => void; +} + +/** + * GroupNodeComponent - Renders a visual group box around nodes + * GroupNodeComponent - 渲染节点周围的可视化组框 + * + * This is a simple background box that provides visual organization. + * 这是一个简单的背景框,提供视觉组织功能。 + */ +export const GroupNodeComponent: React.FC = ({ + group, + isSelected = false, + isDragging = false, + onSelect, + onDragStart, + onContextMenu, + onDoubleClick +}) => { + const groupRef = useRef(null); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + // Only handle clicks on the header or border, not on the content area + const target = e.target as HTMLElement; + if (!target.closest('.ne-group-box-header') && !target.classList.contains('ne-group-box')) { + return; + } + e.stopPropagation(); + + const additive = e.ctrlKey || e.metaKey; + onSelect?.(group.id, additive); + onDragStart?.(group.id, { x: e.clientX, y: e.clientY }); + }, [group.id, onSelect, onDragStart]); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu?.(group, e); + }, [group, onContextMenu]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + // Only handle double-click on the header + const target = e.target as HTMLElement; + if (!target.closest('.ne-group-box-header')) { + return; + } + e.stopPropagation(); + onDoubleClick?.(group); + }, [group, onDoubleClick]); + + const classNames = useMemo(() => { + const classes = ['ne-group-box']; + if (isSelected) classes.push('selected'); + if (isDragging) classes.push('dragging'); + return classes.join(' '); + }, [isSelected, isDragging]); + + const style: React.CSSProperties = useMemo(() => ({ + left: group.position.x, + top: group.position.y, + width: group.size.width, + height: group.size.height, + '--group-color': group.color || 'rgba(100, 149, 237, 0.15)' + } as React.CSSProperties), [group.position.x, group.position.y, group.size.width, group.size.height, group.color]); + + return ( +
+
+ {group.name} +
+
+ ); +}; + +/** + * Memoized version for performance + */ +export const MemoizedGroupNodeComponent = React.memo(GroupNodeComponent, (prev, next) => { + if (prev.group.id !== next.group.id) return false; + if (prev.isSelected !== next.isSelected) return false; + if (prev.isDragging !== next.isDragging) return false; + if (prev.group.position.x !== next.group.position.x || + prev.group.position.y !== next.group.position.y) return false; + if (prev.group.size.width !== next.group.size.width || + prev.group.size.height !== next.group.size.height) return false; + if (prev.group.name !== next.group.name) return false; + if (prev.group.color !== next.group.color) return false; + return true; +}); + +export default GroupNodeComponent; diff --git a/packages/devtools/node-editor/src/components/nodes/index.ts b/packages/devtools/node-editor/src/components/nodes/index.ts index 3621b69b..b56d28b5 100644 --- a/packages/devtools/node-editor/src/components/nodes/index.ts +++ b/packages/devtools/node-editor/src/components/nodes/index.ts @@ -4,3 +4,9 @@ export { type GraphNodeComponentProps, type NodeExecutionState } from './GraphNodeComponent'; + +export { + GroupNodeComponent, + MemoizedGroupNodeComponent, + type GroupNodeComponentProps +} from './GroupNodeComponent'; diff --git a/packages/devtools/node-editor/src/domain/models/Graph.ts b/packages/devtools/node-editor/src/domain/models/Graph.ts index d90802f2..457699f6 100644 --- a/packages/devtools/node-editor/src/domain/models/Graph.ts +++ b/packages/devtools/node-editor/src/domain/models/Graph.ts @@ -2,6 +2,7 @@ import { GraphNode } from './GraphNode'; import { Connection } from './Connection'; import { Pin } from './Pin'; import { Position } from '../value-objects/Position'; +import { NodeGroup, serializeNodeGroup } from './NodeGroup'; /** * Graph - Aggregate root for the node graph @@ -15,6 +16,7 @@ export class Graph { private readonly _name: string; private readonly _nodes: Map; private readonly _connections: Connection[]; + private readonly _groups: NodeGroup[]; private readonly _metadata: Record; constructor( @@ -22,12 +24,14 @@ export class Graph { name: string, nodes: GraphNode[] = [], connections: Connection[] = [], - metadata: Record = {} + metadata: Record = {}, + groups: NodeGroup[] = [] ) { this._id = id; this._name = name; this._nodes = new Map(nodes.map(n => [n.id, n])); this._connections = [...connections]; + this._groups = [...groups]; this._metadata = { ...metadata }; } @@ -59,6 +63,29 @@ export class Graph { return this._connections.length; } + /** + * Gets all groups (节点组) + */ + get groups(): NodeGroup[] { + return [...this._groups]; + } + + /** + * Gets a group by ID + * 通过ID获取组 + */ + getGroup(groupId: string): NodeGroup | undefined { + return this._groups.find(g => g.id === groupId); + } + + /** + * Gets the group containing a specific node + * 获取包含特定节点的组 + */ + getNodeGroup(nodeId: string): NodeGroup | undefined { + return this._groups.find(g => g.nodeIds.includes(nodeId)); + } + /** * Gets a node by ID * 通过ID获取节点 @@ -112,7 +139,7 @@ export class Graph { throw new Error(`Node with ID "${node.id}" already exists`); } const newNodes = [...this.nodes, node]; - return new Graph(this._id, this._name, newNodes, this._connections, this._metadata); + return new Graph(this._id, this._name, newNodes, this._connections, this._metadata, this._groups); } /** @@ -125,7 +152,14 @@ export class Graph { } const newNodes = this.nodes.filter(n => n.id !== nodeId); const newConnections = this._connections.filter(c => !c.involvesNode(nodeId)); - return new Graph(this._id, this._name, newNodes, newConnections, this._metadata); + // Also remove the node from any groups it belongs to + const newGroups = this._groups.map(g => { + if (g.nodeIds.includes(nodeId)) { + return { ...g, nodeIds: g.nodeIds.filter(id => id !== nodeId) }; + } + return g; + }).filter(g => g.nodeIds.length > 0); // Remove empty groups + return new Graph(this._id, this._name, newNodes, newConnections, this._metadata, newGroups); } /** @@ -138,7 +172,7 @@ export class Graph { const updatedNode = updater(node); const newNodes = this.nodes.map(n => n.id === nodeId ? updatedNode : n); - return new Graph(this._id, this._name, newNodes, this._connections, this._metadata); + return new Graph(this._id, this._name, newNodes, this._connections, this._metadata, this._groups); } /** @@ -184,7 +218,7 @@ export class Graph { } newConnections.push(connection); - return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata); + return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups); } /** @@ -196,7 +230,7 @@ export class Graph { if (newConnections.length === this._connections.length) { return this; } - return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata); + return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups); } /** @@ -208,7 +242,47 @@ export class Graph { if (newConnections.length === this._connections.length) { return this; } - return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata); + return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups); + } + + // ========== Group Operations (组操作) ========== + + /** + * Adds a new group (immutable) + * 添加新组(不可变) + */ + addGroup(group: NodeGroup): Graph { + if (this._groups.some(g => g.id === group.id)) { + throw new Error(`Group with ID "${group.id}" already exists`); + } + const newGroups = [...this._groups, group]; + return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups); + } + + /** + * Removes a group (immutable) + * 移除组(不可变) + */ + removeGroup(groupId: string): Graph { + const newGroups = this._groups.filter(g => g.id !== groupId); + if (newGroups.length === this._groups.length) { + return this; + } + return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups); + } + + /** + * Updates a group (immutable) + * 更新组(不可变) + */ + updateGroup(groupId: string, updater: (group: NodeGroup) => NodeGroup): Graph { + const groupIndex = this._groups.findIndex(g => g.id === groupId); + if (groupIndex === -1) return this; + + const updatedGroup = updater(this._groups[groupIndex]); + const newGroups = [...this._groups]; + newGroups[groupIndex] = updatedGroup; + return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups); } /** @@ -219,7 +293,7 @@ export class Graph { return new Graph(this._id, this._name, this.nodes, this._connections, { ...this._metadata, ...metadata - }); + }, this._groups); } /** @@ -227,7 +301,7 @@ export class Graph { * 创建具有更新名称的新图(不可变) */ rename(newName: string): Graph { - return new Graph(this._id, newName, this.nodes, this._connections, this._metadata); + return new Graph(this._id, newName, this.nodes, this._connections, this._metadata, this._groups); } /** @@ -257,6 +331,7 @@ export class Graph { name: this._name, nodes: this.nodes.map(n => n.toJSON()), connections: this._connections.map(c => c.toJSON()), + groups: this._groups.map(g => serializeNodeGroup(g)), metadata: this._metadata }; } diff --git a/packages/devtools/node-editor/src/domain/models/NodeGroup.ts b/packages/devtools/node-editor/src/domain/models/NodeGroup.ts new file mode 100644 index 00000000..d459787b --- /dev/null +++ b/packages/devtools/node-editor/src/domain/models/NodeGroup.ts @@ -0,0 +1,146 @@ +import { Position } from '../value-objects/Position'; + +/** + * NodeGroup - Represents a visual group box around nodes + * NodeGroup - 表示节点周围的可视化组框 + * + * Groups are purely visual organization - they don't affect runtime execution. + * 组是纯视觉组织 - 不影响运行时执行。 + */ +export interface NodeGroup { + /** Unique identifier for the group */ + id: string; + + /** Display name of the group */ + name: string; + + /** IDs of nodes contained in this group */ + nodeIds: string[]; + + /** Position of the group box (top-left corner) */ + position: Position; + + /** Size of the group box */ + size: { width: number; height: number }; + + /** Optional color for the group box */ + color?: string; +} + +/** + * Creates a new NodeGroup with the given properties + */ +export function createNodeGroup( + id: string, + name: string, + nodeIds: string[], + position: Position, + size: { width: number; height: number }, + color?: string +): NodeGroup { + return { + id, + name, + nodeIds: [...nodeIds], + position, + size, + color + }; +} + +/** + * Node bounds info for group calculation + * 用于组计算的节点边界信息 + */ +export interface NodeBounds { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Estimates node height based on pin count + * 根据引脚数量估算节点高度 + */ +export function estimateNodeHeight(inputPinCount: number, outputPinCount: number, isCollapsed: boolean = false): number { + if (isCollapsed) { + return 32; // Just header + } + const headerHeight = 32; + const pinHeight = 26; + const bottomPadding = 12; + const maxPins = Math.max(inputPinCount, outputPinCount); + return headerHeight + maxPins * pinHeight + bottomPadding; +} + +/** + * Computes the bounding box for a group based on its nodes + * Returns position (top-left) and size with padding + * + * @param nodeBounds - Array of node bounds (position + size) + * @param padding - Padding around the group box + */ +export function computeGroupBounds( + nodeBounds: NodeBounds[], + padding: number = 30 +): { position: Position; size: { width: number; height: number } } { + if (nodeBounds.length === 0) { + return { + position: new Position(0, 0), + size: { width: 250, height: 150 } + }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const node of nodeBounds) { + minX = Math.min(minX, node.x); + minY = Math.min(minY, node.y); + maxX = Math.max(maxX, node.x + node.width); + maxY = Math.max(maxY, node.y + node.height); + } + + // Add padding and header space for group title + const groupHeaderHeight = 28; + return { + position: new Position(minX - padding, minY - padding - groupHeaderHeight), + size: { + width: maxX - minX + padding * 2, + height: maxY - minY + padding * 2 + groupHeaderHeight + } + }; +} + +/** + * Serializes a NodeGroup for JSON storage + */ +export function serializeNodeGroup(group: NodeGroup): Record { + return { + id: group.id, + name: group.name, + nodeIds: [...group.nodeIds], + position: { x: group.position.x, y: group.position.y }, + size: { width: group.size.width, height: group.size.height }, + color: group.color + }; +} + +/** + * Deserializes a NodeGroup from JSON + */ +export function deserializeNodeGroup(data: Record): NodeGroup { + const pos = data.position as { x: number; y: number } | undefined; + const size = data.size as { width: number; height: number } | undefined; + return { + id: data.id as string, + name: data.name as string, + nodeIds: (data.nodeIds as string[]) || [], + position: new Position(pos?.x || 0, pos?.y || 0), + size: size || { width: 250, height: 150 }, + color: data.color as string | undefined + }; +} diff --git a/packages/devtools/node-editor/src/domain/models/index.ts b/packages/devtools/node-editor/src/domain/models/index.ts index 1e678aa5..cd40ceec 100644 --- a/packages/devtools/node-editor/src/domain/models/index.ts +++ b/packages/devtools/node-editor/src/domain/models/index.ts @@ -2,3 +2,12 @@ export { Pin, type PinDefinition } from './Pin'; export { GraphNode, type NodeTemplate, type NodeCategory } from './GraphNode'; export { Connection } from './Connection'; export { Graph } from './Graph'; +export { + type NodeGroup, + type NodeBounds, + createNodeGroup, + computeGroupBounds, + estimateNodeHeight, + serializeNodeGroup, + deserializeNodeGroup +} from './NodeGroup'; diff --git a/packages/devtools/node-editor/src/index.ts b/packages/devtools/node-editor/src/index.ts index cfb583bf..55639461 100644 --- a/packages/devtools/node-editor/src/index.ts +++ b/packages/devtools/node-editor/src/index.ts @@ -23,7 +23,15 @@ export { // Types type NodeTemplate, type NodeCategory, - type PinDefinition + type PinDefinition, + // NodeGroup + type NodeGroup, + type NodeBounds, + createNodeGroup, + computeGroupBounds, + estimateNodeHeight, + serializeNodeGroup, + deserializeNodeGroup } from './domain/models'; // Value objects (值对象) diff --git a/packages/devtools/node-editor/src/styles/GraphNode.css b/packages/devtools/node-editor/src/styles/GraphNode.css index ec16aaa1..013c4462 100644 --- a/packages/devtools/node-editor/src/styles/GraphNode.css +++ b/packages/devtools/node-editor/src/styles/GraphNode.css @@ -281,3 +281,32 @@ 0 0 20px rgba(255, 167, 38, 0.7); } } + +/* ==================== Group Node 组节点 ==================== */ +.ne-node.ne-group-node { + border: 2px dashed rgba(100, 149, 237, 0.6); + background: rgba(40, 60, 90, 0.85); +} + +.ne-node.ne-group-node:hover { + border-color: rgba(100, 149, 237, 0.8); +} + +.ne-node.ne-group-node.selected { + border-color: #6495ed; + box-shadow: 0 0 0 1px #6495ed, + 0 0 16px rgba(100, 149, 237, 0.5), + 0 4px 12px rgba(0, 0, 0, 0.5); +} + +.ne-group-header { + background: linear-gradient(90deg, #3a5f8a 0%, #2a4a6a 100%) !important; +} + +.ne-group-hint { + padding: 8px 12px; + font-size: 11px; + color: #8899aa; + font-style: italic; + text-align: center; +} diff --git a/packages/devtools/node-editor/src/styles/GroupNode.css b/packages/devtools/node-editor/src/styles/GroupNode.css new file mode 100644 index 00000000..32705cdd --- /dev/null +++ b/packages/devtools/node-editor/src/styles/GroupNode.css @@ -0,0 +1,60 @@ +/** + * Group Box Styles + * 组框样式 + */ + +/* ==================== Group Box Container 组框容器 ==================== */ +.ne-group-box { + position: absolute; + background: var(--group-color, rgba(100, 149, 237, 0.15)); + border: 2px dashed rgba(100, 149, 237, 0.5); + border-radius: 8px; + pointer-events: auto; + z-index: 0; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.ne-group-box:hover { + border-color: rgba(100, 149, 237, 0.7); +} + +.ne-group-box.selected { + border-color: var(--ne-node-border-selected, #e5a020); + box-shadow: 0 0 0 1px var(--ne-node-border-selected, #e5a020), + 0 0 12px rgba(229, 160, 32, 0.3); +} + +.ne-group-box.dragging { + opacity: 0.9; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +/* ==================== Group Box Header 组框头部 ==================== */ +.ne-group-box-header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 28px; + display: flex; + align-items: center; + padding: 0 10px; + background: rgba(100, 149, 237, 0.3); + border-radius: 6px 6px 0 0; + cursor: grab; + user-select: none; +} + +.ne-group-box-header:active { + cursor: grabbing; +} + +.ne-group-box-title { + color: #fff; + font-size: 12px; + font-weight: 500; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/devtools/node-editor/src/styles/index.css b/packages/devtools/node-editor/src/styles/index.css index bb1c29b2..183ab216 100644 --- a/packages/devtools/node-editor/src/styles/index.css +++ b/packages/devtools/node-editor/src/styles/index.css @@ -6,6 +6,7 @@ @import './variables.css'; @import './Canvas.css'; @import './GraphNode.css'; +@import './GroupNode.css'; @import './NodePin.css'; @import './Connection.css'; @import './ContextMenu.css'; diff --git a/packages/editor/editor-app/scripts/kill-dev-server.js b/packages/editor/editor-app/scripts/kill-dev-server.js deleted file mode 100644 index 99ca2755..00000000 --- a/packages/editor/editor-app/scripts/kill-dev-server.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * 清理开发服务器进程 - * 用于 Windows 平台自动清理残留的 Vite 进程 - */ - -import { execSync } from 'child_process'; - -const PORT = 5173; - -try { - console.log(`正在查找占用端口 ${PORT} 的进程...`); - - // Windows 命令 - const result = execSync(`netstat -ano | findstr :${PORT}`, { encoding: 'utf8' }); - - // 解析 PID - const lines = result.split('\n'); - const pids = new Set(); - - for (const line of lines) { - if (line.includes('LISTENING')) { - const parts = line.trim().split(/\s+/); - const pid = parts[parts.length - 1]; - if (pid && pid !== '0') { - pids.add(pid); - } - } - } - - if (pids.size === 0) { - console.log(`✓ 端口 ${PORT} 未被占用`); - } else { - console.log(`发现 ${pids.size} 个进程占用端口 ${PORT}`); - for (const pid of pids) { - try { - // Windows 需要使用 /F /PID 而不是 //F //PID - execSync(`taskkill /F /PID ${pid}`, { encoding: 'utf8', stdio: 'ignore' }); - console.log(`✓ 已终止进程 PID: ${pid}`); - } catch (e) { - console.log(`✗ 无法终止进程 PID: ${pid}`); - } - } - } -} catch (error) { - // 如果 netstat 没有找到结果,会抛出错误,这是正常的 - console.log(`✓ 端口 ${PORT} 未被占用`); -} diff --git a/packages/framework/blueprint/src/nodes/index.ts b/packages/framework/blueprint/src/nodes/index.ts index 81a888b1..1bdae4e3 100644 --- a/packages/framework/blueprint/src/nodes/index.ts +++ b/packages/framework/blueprint/src/nodes/index.ts @@ -7,6 +7,7 @@ * - ecs: ECS 操作(Entity, Component, Flow) * - variables: 变量读写 * - math: 数学运算 + * - logic: 比较和逻辑运算 * - time: 时间工具 * - debug: 调试工具 * @@ -15,6 +16,7 @@ * - ecs: ECS operations (Entity, Component, Flow) * - variables: Variable get/set * - math: Math operations + * - logic: Comparison and logical operations * - time: Time utilities * - debug: Debug utilities */ @@ -31,6 +33,9 @@ export * from './variables'; // Math operations | 数学运算 export * from './math'; +// Logic operations | 逻辑运算 +export * from './logic'; + // Time utilities | 时间工具 export * from './time'; diff --git a/packages/framework/blueprint/src/nodes/logic/ComparisonNodes.ts b/packages/framework/blueprint/src/nodes/logic/ComparisonNodes.ts new file mode 100644 index 00000000..e7038a36 --- /dev/null +++ b/packages/framework/blueprint/src/nodes/logic/ComparisonNodes.ts @@ -0,0 +1,435 @@ +/** + * @zh 比较运算节点 + * @en Comparison Operation Nodes + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; +import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; +import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; + +// ============================================================================ +// Equal Node (等于节点) +// ============================================================================ + +export const EqualTemplate: BlueprintNodeTemplate = { + type: 'Equal', + title: 'Equal', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if A equals B (如果 A 等于 B 则返回 true)', + keywords: ['equal', '==', 'same', 'compare', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'any', displayName: 'A' }, + { name: 'b', type: 'any', displayName: 'B' } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(EqualTemplate) +export class EqualExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = context.evaluateInput(node.id, 'a', null); + const b = context.evaluateInput(node.id, 'b', null); + return { outputs: { result: a === b } }; + } +} + +// ============================================================================ +// Not Equal Node (不等于节点) +// ============================================================================ + +export const NotEqualTemplate: BlueprintNodeTemplate = { + type: 'NotEqual', + title: 'Not Equal', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if A does not equal B (如果 A 不等于 B 则返回 true)', + keywords: ['not', 'equal', '!=', 'different', 'compare', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'any', displayName: 'A' }, + { name: 'b', type: 'any', displayName: 'B' } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(NotEqualTemplate) +export class NotEqualExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = context.evaluateInput(node.id, 'a', null); + const b = context.evaluateInput(node.id, 'b', null); + return { outputs: { result: a !== b } }; + } +} + +// ============================================================================ +// Greater Than Node (大于节点) +// ============================================================================ + +export const GreaterThanTemplate: BlueprintNodeTemplate = { + type: 'GreaterThan', + title: 'Greater Than', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if A is greater than B (如果 A 大于 B 则返回 true)', + keywords: ['greater', 'than', '>', 'compare', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(GreaterThanTemplate) +export class GreaterThanExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 0)); + return { outputs: { result: a > b } }; + } +} + +// ============================================================================ +// Greater Than Or Equal Node (大于等于节点) +// ============================================================================ + +export const GreaterThanOrEqualTemplate: BlueprintNodeTemplate = { + type: 'GreaterThanOrEqual', + title: 'Greater Or Equal', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if A is greater than or equal to B (如果 A 大于等于 B 则返回 true)', + keywords: ['greater', 'equal', '>=', 'compare', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(GreaterThanOrEqualTemplate) +export class GreaterThanOrEqualExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 0)); + return { outputs: { result: a >= b } }; + } +} + +// ============================================================================ +// Less Than Node (小于节点) +// ============================================================================ + +export const LessThanTemplate: BlueprintNodeTemplate = { + type: 'LessThan', + title: 'Less Than', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if A is less than B (如果 A 小于 B 则返回 true)', + keywords: ['less', 'than', '<', 'compare', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(LessThanTemplate) +export class LessThanExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 0)); + return { outputs: { result: a < b } }; + } +} + +// ============================================================================ +// Less Than Or Equal Node (小于等于节点) +// ============================================================================ + +export const LessThanOrEqualTemplate: BlueprintNodeTemplate = { + type: 'LessThanOrEqual', + title: 'Less Or Equal', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if A is less than or equal to B (如果 A 小于等于 B 则返回 true)', + keywords: ['less', 'equal', '<=', 'compare', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(LessThanOrEqualTemplate) +export class LessThanOrEqualExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 0)); + return { outputs: { result: a <= b } }; + } +} + +// ============================================================================ +// And Node (逻辑与节点) +// ============================================================================ + +export const AndTemplate: BlueprintNodeTemplate = { + type: 'And', + title: 'AND', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if both A and B are true (如果 A 和 B 都为 true 则返回 true)', + keywords: ['and', '&&', 'both', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'bool', displayName: 'A', defaultValue: false }, + { name: 'b', type: 'bool', displayName: 'B', defaultValue: false } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(AndTemplate) +export class AndExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Boolean(context.evaluateInput(node.id, 'a', false)); + const b = Boolean(context.evaluateInput(node.id, 'b', false)); + return { outputs: { result: a && b } }; + } +} + +// ============================================================================ +// Or Node (逻辑或节点) +// ============================================================================ + +export const OrTemplate: BlueprintNodeTemplate = { + type: 'Or', + title: 'OR', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if either A or B is true (如果 A 或 B 为 true 则返回 true)', + keywords: ['or', '||', 'either', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'bool', displayName: 'A', defaultValue: false }, + { name: 'b', type: 'bool', displayName: 'B', defaultValue: false } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(OrTemplate) +export class OrExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Boolean(context.evaluateInput(node.id, 'a', false)); + const b = Boolean(context.evaluateInput(node.id, 'b', false)); + return { outputs: { result: a || b } }; + } +} + +// ============================================================================ +// Not Node (逻辑非节点) +// ============================================================================ + +export const NotTemplate: BlueprintNodeTemplate = { + type: 'Not', + title: 'NOT', + category: 'logic', + color: '#9C27B0', + description: 'Returns the opposite boolean value (返回相反的布尔值)', + keywords: ['not', '!', 'negate', 'invert', 'logic'], + isPure: true, + inputs: [ + { name: 'value', type: 'bool', displayName: 'Value', defaultValue: false } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(NotTemplate) +export class NotExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Boolean(context.evaluateInput(node.id, 'value', false)); + return { outputs: { result: !value } }; + } +} + +// ============================================================================ +// XOR Node (异或节点) +// ============================================================================ + +export const XorTemplate: BlueprintNodeTemplate = { + type: 'Xor', + title: 'XOR', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if exactly one of A or B is true (如果 A 和 B 中恰好有一个为 true 则返回 true)', + keywords: ['xor', 'exclusive', 'or', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'bool', displayName: 'A', defaultValue: false }, + { name: 'b', type: 'bool', displayName: 'B', defaultValue: false } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(XorTemplate) +export class XorExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Boolean(context.evaluateInput(node.id, 'a', false)); + const b = Boolean(context.evaluateInput(node.id, 'b', false)); + return { outputs: { result: (a || b) && !(a && b) } }; + } +} + +// ============================================================================ +// NAND Node (与非节点) +// ============================================================================ + +export const NandTemplate: BlueprintNodeTemplate = { + type: 'Nand', + title: 'NAND', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if not both A and B are true (如果 A 和 B 不都为 true 则返回 true)', + keywords: ['nand', 'not', 'and', 'logic'], + isPure: true, + inputs: [ + { name: 'a', type: 'bool', displayName: 'A', defaultValue: false }, + { name: 'b', type: 'bool', displayName: 'B', defaultValue: false } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(NandTemplate) +export class NandExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Boolean(context.evaluateInput(node.id, 'a', false)); + const b = Boolean(context.evaluateInput(node.id, 'b', false)); + return { outputs: { result: !(a && b) } }; + } +} + +// ============================================================================ +// In Range Node (范围检查节点) +// ============================================================================ + +export const InRangeTemplate: BlueprintNodeTemplate = { + type: 'InRange', + title: 'In Range', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if value is between min and max (如果值在 min 和 max 之间则返回 true)', + keywords: ['range', 'between', 'check', 'logic'], + isPure: true, + inputs: [ + { name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }, + { name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 }, + { name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 }, + { name: 'inclusive', type: 'bool', displayName: 'Inclusive', defaultValue: true } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Result' } + ] +}; + +@RegisterNode(InRangeTemplate) +export class InRangeExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Number(context.evaluateInput(node.id, 'value', 0)); + const min = Number(context.evaluateInput(node.id, 'min', 0)); + const max = Number(context.evaluateInput(node.id, 'max', 1)); + const inclusive = Boolean(context.evaluateInput(node.id, 'inclusive', true)); + + const result = inclusive + ? value >= min && value <= max + : value > min && value < max; + + return { outputs: { result } }; + } +} + +// ============================================================================ +// Is Null Node (空值检查节点) +// ============================================================================ + +export const IsNullTemplate: BlueprintNodeTemplate = { + type: 'IsNull', + title: 'Is Null', + category: 'logic', + color: '#9C27B0', + description: 'Returns true if the value is null or undefined (如果值为 null 或 undefined 则返回 true)', + keywords: ['null', 'undefined', 'empty', 'check', 'logic'], + isPure: true, + inputs: [ + { name: 'value', type: 'any', displayName: 'Value' } + ], + outputs: [ + { name: 'result', type: 'bool', displayName: 'Is Null' } + ] +}; + +@RegisterNode(IsNullTemplate) +export class IsNullExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = context.evaluateInput(node.id, 'value', null); + return { outputs: { result: value == null } }; + } +} + +// ============================================================================ +// Select Node (选择节点) +// ============================================================================ + +export const SelectTemplate: BlueprintNodeTemplate = { + type: 'Select', + title: 'Select', + category: 'logic', + color: '#9C27B0', + description: 'Returns A if condition is true, otherwise returns B (如果条件为 true 返回 A,否则返回 B)', + keywords: ['select', 'choose', 'ternary', '?:', 'logic'], + isPure: true, + inputs: [ + { name: 'condition', type: 'bool', displayName: 'Condition', defaultValue: false }, + { name: 'a', type: 'any', displayName: 'A (True)' }, + { name: 'b', type: 'any', displayName: 'B (False)' } + ], + outputs: [ + { name: 'result', type: 'any', displayName: 'Result' } + ] +}; + +@RegisterNode(SelectTemplate) +export class SelectExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const condition = Boolean(context.evaluateInput(node.id, 'condition', false)); + const a = context.evaluateInput(node.id, 'a', null); + const b = context.evaluateInput(node.id, 'b', null); + return { outputs: { result: condition ? a : b } }; + } +} diff --git a/packages/framework/blueprint/src/nodes/logic/index.ts b/packages/framework/blueprint/src/nodes/logic/index.ts new file mode 100644 index 00000000..bbeb79ca --- /dev/null +++ b/packages/framework/blueprint/src/nodes/logic/index.ts @@ -0,0 +1,6 @@ +/** + * @zh 逻辑节点 - 比较和逻辑运算节点 + * @en Logic Nodes - Comparison and logical operation nodes + */ + +export * from './ComparisonNodes'; diff --git a/packages/framework/blueprint/src/nodes/math/MathOperations.ts b/packages/framework/blueprint/src/nodes/math/MathOperations.ts index 97bcc494..4a2ecc76 100644 --- a/packages/framework/blueprint/src/nodes/math/MathOperations.ts +++ b/packages/framework/blueprint/src/nodes/math/MathOperations.ts @@ -120,3 +120,444 @@ export class DivideExecutor implements INodeExecutor { return { outputs: { result: a / b } }; } } + +// ============================================================================ +// Modulo Node (取模节点) +// ============================================================================ + +export const ModuloTemplate: BlueprintNodeTemplate = { + type: 'Modulo', + title: 'Modulo', + category: 'math', + color: '#4CAF50', + description: 'Returns the remainder of A divided by B (返回 A 除以 B 的余数)', + keywords: ['modulo', 'mod', 'remainder', '%', 'math'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 1 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(ModuloTemplate) +export class ModuloExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 1)); + if (b === 0) return { outputs: { result: 0 } }; + return { outputs: { result: a % b } }; + } +} + +// ============================================================================ +// Absolute Value Node (绝对值节点) +// ============================================================================ + +export const AbsTemplate: BlueprintNodeTemplate = { + type: 'Abs', + title: 'Absolute', + category: 'math', + color: '#4CAF50', + description: 'Returns the absolute value (返回绝对值)', + keywords: ['abs', 'absolute', 'math'], + isPure: true, + inputs: [ + { name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(AbsTemplate) +export class AbsExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Number(context.evaluateInput(node.id, 'value', 0)); + return { outputs: { result: Math.abs(value) } }; + } +} + +// ============================================================================ +// Min Node (最小值节点) +// ============================================================================ + +export const MinTemplate: BlueprintNodeTemplate = { + type: 'Min', + title: 'Min', + category: 'math', + color: '#4CAF50', + description: 'Returns the smaller of two values (返回两个值中较小的一个)', + keywords: ['min', 'minimum', 'smaller', 'math'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(MinTemplate) +export class MinExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 0)); + return { outputs: { result: Math.min(a, b) } }; + } +} + +// ============================================================================ +// Max Node (最大值节点) +// ============================================================================ + +export const MaxTemplate: BlueprintNodeTemplate = { + type: 'Max', + title: 'Max', + category: 'math', + color: '#4CAF50', + description: 'Returns the larger of two values (返回两个值中较大的一个)', + keywords: ['max', 'maximum', 'larger', 'math'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(MaxTemplate) +export class MaxExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 0)); + return { outputs: { result: Math.max(a, b) } }; + } +} + +// ============================================================================ +// Clamp Node (限制范围节点) +// ============================================================================ + +export const ClampTemplate: BlueprintNodeTemplate = { + type: 'Clamp', + title: 'Clamp', + category: 'math', + color: '#4CAF50', + description: 'Clamps a value between min and max (将值限制在最小和最大之间)', + keywords: ['clamp', 'limit', 'range', 'bound', 'math'], + isPure: true, + inputs: [ + { name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }, + { name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 }, + { name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(ClampTemplate) +export class ClampExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Number(context.evaluateInput(node.id, 'value', 0)); + const min = Number(context.evaluateInput(node.id, 'min', 0)); + const max = Number(context.evaluateInput(node.id, 'max', 1)); + return { outputs: { result: Math.max(min, Math.min(max, value)) } }; + } +} + +// ============================================================================ +// Lerp Node (线性插值节点) +// ============================================================================ + +export const LerpTemplate: BlueprintNodeTemplate = { + type: 'Lerp', + title: 'Lerp', + category: 'math', + color: '#4CAF50', + description: 'Linear interpolation between A and B (A 和 B 之间的线性插值)', + keywords: ['lerp', 'interpolate', 'blend', 'mix', 'math'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 1 }, + { name: 't', type: 'float', displayName: 'Alpha', defaultValue: 0.5 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(LerpTemplate) +export class LerpExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 1)); + const t = Number(context.evaluateInput(node.id, 't', 0.5)); + return { outputs: { result: a + (b - a) * t } }; + } +} + +// ============================================================================ +// Random Range Node (随机范围节点) +// ============================================================================ + +export const RandomRangeTemplate: BlueprintNodeTemplate = { + type: 'RandomRange', + title: 'Random Range', + category: 'math', + color: '#4CAF50', + description: 'Returns a random number between min and max (返回 min 和 max 之间的随机数)', + keywords: ['random', 'range', 'rand', 'math'], + isPure: true, + inputs: [ + { name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 }, + { name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(RandomRangeTemplate) +export class RandomRangeExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const min = Number(context.evaluateInput(node.id, 'min', 0)); + const max = Number(context.evaluateInput(node.id, 'max', 1)); + return { outputs: { result: min + Math.random() * (max - min) } }; + } +} + +// ============================================================================ +// Random Integer Node (随机整数节点) +// ============================================================================ + +export const RandomIntTemplate: BlueprintNodeTemplate = { + type: 'RandomInt', + title: 'Random Integer', + category: 'math', + color: '#4CAF50', + description: 'Returns a random integer between min and max inclusive (返回 min 和 max 之间的随机整数,包含边界)', + keywords: ['random', 'int', 'integer', 'rand', 'math'], + isPure: true, + inputs: [ + { name: 'min', type: 'int', displayName: 'Min', defaultValue: 0 }, + { name: 'max', type: 'int', displayName: 'Max', defaultValue: 10 } + ], + outputs: [ + { name: 'result', type: 'int', displayName: 'Result' } + ] +}; + +@RegisterNode(RandomIntTemplate) +export class RandomIntExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const min = Math.floor(Number(context.evaluateInput(node.id, 'min', 0))); + const max = Math.floor(Number(context.evaluateInput(node.id, 'max', 10))); + return { outputs: { result: Math.floor(min + Math.random() * (max - min + 1)) } }; + } +} + +// ============================================================================ +// Power Node (幂运算节点) +// ============================================================================ + +export const PowerTemplate: BlueprintNodeTemplate = { + type: 'Power', + title: 'Power', + category: 'math', + color: '#4CAF50', + description: 'Returns base raised to the power of exponent (返回底数的指数次幂)', + keywords: ['power', 'pow', 'exponent', '^', 'math'], + isPure: true, + inputs: [ + { name: 'base', type: 'float', displayName: 'Base', defaultValue: 2 }, + { name: 'exponent', type: 'float', displayName: 'Exponent', defaultValue: 2 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(PowerTemplate) +export class PowerExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const base = Number(context.evaluateInput(node.id, 'base', 2)); + const exponent = Number(context.evaluateInput(node.id, 'exponent', 2)); + return { outputs: { result: Math.pow(base, exponent) } }; + } +} + +// ============================================================================ +// Square Root Node (平方根节点) +// ============================================================================ + +export const SqrtTemplate: BlueprintNodeTemplate = { + type: 'Sqrt', + title: 'Square Root', + category: 'math', + color: '#4CAF50', + description: 'Returns the square root (返回平方根)', + keywords: ['sqrt', 'square', 'root', 'math'], + isPure: true, + inputs: [ + { name: 'value', type: 'float', displayName: 'Value', defaultValue: 4 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(SqrtTemplate) +export class SqrtExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Number(context.evaluateInput(node.id, 'value', 4)); + return { outputs: { result: Math.sqrt(Math.abs(value)) } }; + } +} + +// ============================================================================ +// Floor Node (向下取整节点) +// ============================================================================ + +export const FloorTemplate: BlueprintNodeTemplate = { + type: 'Floor', + title: 'Floor', + category: 'math', + color: '#4CAF50', + description: 'Rounds down to the nearest integer (向下取整)', + keywords: ['floor', 'round', 'down', 'int', 'math'], + isPure: true, + inputs: [ + { name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'int', displayName: 'Result' } + ] +}; + +@RegisterNode(FloorTemplate) +export class FloorExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Number(context.evaluateInput(node.id, 'value', 0)); + return { outputs: { result: Math.floor(value) } }; + } +} + +// ============================================================================ +// Ceil Node (向上取整节点) +// ============================================================================ + +export const CeilTemplate: BlueprintNodeTemplate = { + type: 'Ceil', + title: 'Ceil', + category: 'math', + color: '#4CAF50', + description: 'Rounds up to the nearest integer (向上取整)', + keywords: ['ceil', 'ceiling', 'round', 'up', 'int', 'math'], + isPure: true, + inputs: [ + { name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'int', displayName: 'Result' } + ] +}; + +@RegisterNode(CeilTemplate) +export class CeilExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Number(context.evaluateInput(node.id, 'value', 0)); + return { outputs: { result: Math.ceil(value) } }; + } +} + +// ============================================================================ +// Round Node (四舍五入节点) +// ============================================================================ + +export const RoundTemplate: BlueprintNodeTemplate = { + type: 'Round', + title: 'Round', + category: 'math', + color: '#4CAF50', + description: 'Rounds to the nearest integer (四舍五入到最近的整数)', + keywords: ['round', 'int', 'math'], + isPure: true, + inputs: [ + { name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'int', displayName: 'Result' } + ] +}; + +@RegisterNode(RoundTemplate) +export class RoundExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Number(context.evaluateInput(node.id, 'value', 0)); + return { outputs: { result: Math.round(value) } }; + } +} + +// ============================================================================ +// Negate Node (取反节点) +// ============================================================================ + +export const NegateTemplate: BlueprintNodeTemplate = { + type: 'Negate', + title: 'Negate', + category: 'math', + color: '#4CAF50', + description: 'Returns the negative of a value (返回值的负数)', + keywords: ['negate', 'negative', 'minus', '-', 'math'], + isPure: true, + inputs: [ + { name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(NegateTemplate) +export class NegateExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Number(context.evaluateInput(node.id, 'value', 0)); + return { outputs: { result: -value } }; + } +} + +// ============================================================================ +// Sign Node (符号节点) +// ============================================================================ + +export const SignTemplate: BlueprintNodeTemplate = { + type: 'Sign', + title: 'Sign', + category: 'math', + color: '#4CAF50', + description: 'Returns -1, 0, or 1 based on the sign of the value (根据值的符号返回 -1、0 或 1)', + keywords: ['sign', 'positive', 'negative', 'math'], + isPure: true, + inputs: [ + { name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'int', displayName: 'Result' } + ] +}; + +@RegisterNode(SignTemplate) +export class SignExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const value = Number(context.evaluateInput(node.id, 'value', 0)); + return { outputs: { result: Math.sign(value) } }; + } +} diff --git a/scripts/analyze-fbx.mjs b/scripts/analyze-fbx.mjs deleted file mode 100644 index b642d2d8..00000000 --- a/scripts/analyze-fbx.mjs +++ /dev/null @@ -1,239 +0,0 @@ -/** - * FBX Animation Analysis Script - * 分析 FBX 文件的动画数据 - */ - -import { readFileSync } from 'fs'; - -const FBX_TIME_SECOND = 46186158000n; - -// Read FBX file -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; -console.log(`Analyzing: ${filePath}`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); - -// Check header -const magic = new TextDecoder().decode(buffer.slice(0, 21)); -console.log(`Header: "${magic}"`); - -const version = view.getUint32(23, true); -console.log(`FBX Version: ${version}`); - -// Simple FBX parser for animation data -let offset = 27; // After header - -function readNode(is64Bit) { - const startOffset = offset; - - let endOffset, numProperties, propertyListLen, nameLen; - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - // Read properties - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': // Int16 - properties.push(view.getInt16(offset, true)); - offset += 2; - break; - case 'C': // Bool - properties.push(buffer[offset] !== 0); - offset += 1; - break; - case 'I': // Int32 - properties.push(view.getInt32(offset, true)); - offset += 4; - break; - case 'F': // Float - properties.push(view.getFloat32(offset, true)); - offset += 4; - break; - case 'D': // Double - properties.push(view.getFloat64(offset, true)); - offset += 8; - break; - case 'L': // Int64 - properties.push(view.getBigInt64(offset, true)); - offset += 8; - break; - case 'S': // String - case 'R': // Raw binary - const strLen = view.getUint32(offset, true); - offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': // Float array - case 'd': // Double array - case 'l': // Long array - case 'i': // Int array - case 'b': // Bool array - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - // Uncompressed - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - // Compressed - skip for now - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - offset += compressedLen; - } - break; - default: - console.log(`Unknown type: ${typeCode} at offset ${offset - 1}`); - offset = propsEnd; - } - } - - // Read children - const children = []; - while (offset < endOffset) { - const child = readNode(is64Bit); - if (child) children.push(child); - else break; - } - - offset = endOffset; - - return { name, properties, children }; -} - -// Parse root nodes -const is64Bit = version >= 7500; -const rootNodes = []; - -while (offset < buffer.length - 100) { - const node = readNode(is64Bit); - if (node) { - rootNodes.push(node); - } else { - break; - } -} - -console.log(`Root nodes: ${rootNodes.map(n => n.name).join(', ')}`); - -// Find Objects node -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -if (!objectsNode) { - console.log('No Objects node found!'); - process.exit(1); -} - -// Find animation curves -const animCurves = objectsNode.children.filter(n => n.name === 'AnimationCurve'); -const animCurveNodes = objectsNode.children.filter(n => n.name === 'AnimationCurveNode'); - -console.log(`\nAnimation data:`); -console.log(` AnimationCurve nodes: ${animCurves.length}`); -console.log(` AnimationCurveNode nodes: ${animCurveNodes.length}`); - -// Analyze first few animation curves with actual data -console.log(`\nFirst 10 AnimationCurves with varying values:`); -let count = 0; -for (const curve of animCurves) { - if (count >= 10) break; - - // Find KeyTime and KeyValueFloat - let keyTimes = null; - let keyValues = null; - - for (const child of curve.children) { - if (child.name === 'KeyTime') { - keyTimes = child.properties[0]; - } else if (child.name === 'KeyValueFloat') { - keyValues = child.properties[0]; - } - } - - if (keyValues?.data) { - const values = keyValues.data; - const min = Math.min(...values); - const max = Math.max(...values); - - // Only show curves with varying values - if (Math.abs(max - min) > 0.001) { - const id = curve.properties[0]; - const name = curve.properties[1]?.split?.('\0')[0] || 'AnimationCurve'; - console.log(` Curve ${id}: ${values.length} keyframes, range: ${min.toFixed(4)} - ${max.toFixed(4)}`); - console.log(` First 5 values: ${values.slice(0, 5).map(v => v.toFixed(4)).join(', ')}`); - console.log(` Last 5 values: ${values.slice(-5).map(v => v.toFixed(4)).join(', ')}`); - count++; - } - } -} - -// Find Connections node -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); -if (connectionsNode) { - // Find connections with d|X, d|Y, d|Z properties - const curveConnections = connectionsNode.children.filter(c => { - const prop = c.properties[3]; - return prop === 'd|X' || prop === 'd|Y' || prop === 'd|Z'; - }); - console.log(`\nCurve connections (d|X/Y/Z): ${curveConnections.length}`); - - // Show first 10 - console.log(`First 10 curve connections:`); - for (let i = 0; i < Math.min(10, curveConnections.length); i++) { - const c = curveConnections[i]; - console.log(` ${c.properties[1]} -> ${c.properties[2]}, prop: ${c.properties[3]}`); - } -} - -// Find AnimationCurveNodes and their connections -console.log(`\nAnimationCurveNode analysis:`); -const curveNodesByAttr = { T: 0, R: 0, S: 0, other: 0 }; -for (const cn of animCurveNodes) { - const name = cn.properties[1]?.split?.('\0')[0] || ''; - if (name === 'T') curveNodesByAttr.T++; - else if (name === 'R') curveNodesByAttr.R++; - else if (name === 'S') curveNodesByAttr.S++; - else curveNodesByAttr.other++; -} -console.log(` Translation (T): ${curveNodesByAttr.T}`); -console.log(` Rotation (R): ${curveNodesByAttr.R}`); -console.log(` Scale (S): ${curveNodesByAttr.S}`); -console.log(` Other: ${curveNodesByAttr.other}`); - -console.log('\nDone!'); diff --git a/scripts/check-anim-coverage.mjs b/scripts/check-anim-coverage.mjs deleted file mode 100644 index 63fc5c5a..00000000 --- a/scripts/check-anim-coverage.mjs +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Check Animation Coverage - * 检查动画覆盖范围 - * - * Verify if animation provides data for all skeleton joints - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const FBX_TIME_SECOND = 46186158000n; -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Check Animation Coverage: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; - case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; - case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; - case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; - case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; - case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': case 'd': case 'l': case 'i': case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// Parse Models -const models = objectsNode.children - .filter(n => n.name === 'Model') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Model' - })); - -// Parse Clusters -const clusters = objectsNode.children - .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Cluster' - })); - -// Build cluster to bone mapping -const clusterToBone = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - const cluster = clusters.find(c => c.id === conn.toId); - if (cluster) clusterToBone.set(cluster.id, conn.fromId); - } -} - -// Build model ID to index -const modelToIndex = new Map(); -models.forEach((m, i) => modelToIndex.set(m.id, i)); - -// Build skeleton joints -const joints = []; -const boneModelIds = new Set(); -for (const cluster of clusters) { - const boneModelId = clusterToBone.get(cluster.id); - if (!boneModelId) continue; - - const nodeIndex = modelToIndex.get(boneModelId); - if (nodeIndex === undefined) continue; - - boneModelIds.add(boneModelId); - joints.push({ - name: models[nodeIndex].name, - nodeIndex, - boneModelId - }); -} - -console.log(`Skeleton joints: ${joints.length}`); -console.log(`Joint nodeIndices: ${[...new Set(joints.map(j => j.nodeIndex))].length} unique`); - -// Parse AnimationCurveNodes and find which models they target -const curveNodes = objectsNode.children - .filter(n => n.name === 'AnimationCurveNode') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || '' - })); - -// Build curveNode to model mapping (from OP connections) -const curveNodeToModel = new Map(); -for (const conn of connections) { - if (conn.type === 'OP' && conn.property?.includes('Lcl')) { - const cn = curveNodes.find(c => c.id === conn.fromId); - if (cn) { - curveNodeToModel.set(cn.id, { modelId: conn.toId, property: conn.property }); - } - } -} - -// Find which joints have animation -const jointsWithAnimation = new Set(); -const jointsWithTranslation = new Set(); -const jointsWithRotation = new Set(); -const jointsWithScale = new Set(); - -for (const [cnId, target] of curveNodeToModel) { - const nodeIndex = modelToIndex.get(target.modelId); - if (nodeIndex === undefined) continue; - - // Check if this node is a bone - const joint = joints.find(j => j.nodeIndex === nodeIndex); - if (joint) { - jointsWithAnimation.add(nodeIndex); - if (target.property.includes('Translation')) { - jointsWithTranslation.add(nodeIndex); - } else if (target.property.includes('Rotation')) { - jointsWithRotation.add(nodeIndex); - } else if (target.property.includes('Scaling')) { - jointsWithScale.add(nodeIndex); - } - } -} - -console.log(`\n=== ANIMATION COVERAGE ===`); -console.log(`Joints with ANY animation: ${jointsWithAnimation.size}/${joints.length}`); -console.log(`Joints with Translation: ${jointsWithTranslation.size}/${joints.length}`); -console.log(`Joints with Rotation: ${jointsWithRotation.size}/${joints.length}`); -console.log(`Joints with Scale: ${jointsWithScale.size}/${joints.length}`); - -const jointsWithoutAnimation = joints.filter(j => !jointsWithAnimation.has(j.nodeIndex)); -if (jointsWithoutAnimation.length > 0) { - console.log(`\n⚠️ Joints WITHOUT animation (${jointsWithoutAnimation.length}):`); - jointsWithoutAnimation.slice(0, 10).forEach(j => { - console.log(` nodeIndex=${j.nodeIndex}, name="${j.name}"`); - }); - - if (jointsWithoutAnimation.length > 10) { - console.log(` ... and ${jointsWithoutAnimation.length - 10} more`); - } - - console.log(`\nThese joints will fall back to node.transform, which may cause issues!`); -} else { - console.log(`\n✅ All joints have animation data!`); -} - -console.log('\nDone!'); diff --git a/scripts/check-bone-hierarchy.mjs b/scripts/check-bone-hierarchy.mjs deleted file mode 100644 index 750b4dfc..00000000 --- a/scripts/check-bone-hierarchy.mjs +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Check Bone Hierarchy - * 检查骨骼层级 - * - * Verify parent-child relationships for bones - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Check Bone Hierarchy: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; - case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; - case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; - case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; - case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; - case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': case 'd': case 'l': case 'i': case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// Parse Models -const models = objectsNode.children - .filter(n => n.name === 'Model') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Model', - type: n.properties[2]?.split?.('\0')[0] || '' - })); - -// Build parent relationships from connections -const modelParent = new Map(); -const modelChildren = new Map(); - -for (const conn of connections) { - if (conn.type === 'OO') { - const fromModel = models.find(m => m.id === conn.fromId); - const toModel = models.find(m => m.id === conn.toId); - if (fromModel && toModel) { - modelParent.set(conn.fromId, conn.toId); - if (!modelChildren.has(conn.toId)) { - modelChildren.set(conn.toId, []); - } - modelChildren.get(conn.toId).push(conn.fromId); - } - } -} - -// Find Bone001 and trace its parents -const bone001 = models.find(m => m.name === 'Bone001'); -if (bone001) { - console.log(`Bone001 (id=${bone001.id}):`); - console.log(` type: "${bone001.type}"`); - - // Trace parent chain - let currentId = bone001.id; - let depth = 0; - while (currentId && depth < 10) { - const parentId = modelParent.get(currentId); - if (parentId) { - const parent = models.find(m => m.id === parentId); - console.log(` Parent [${depth}]: "${parent?.name}" (id=${parentId}, type="${parent?.type}")`); - } else { - console.log(` Parent [${depth}]: ROOT (no parent)`); - break; - } - currentId = parentId; - depth++; - } -} - -// Show first level hierarchy -console.log(`\n=== ROOT LEVEL MODELS ===`); -const rootModels = models.filter(m => !modelParent.has(m.id)); -rootModels.forEach(m => { - console.log(`"${m.name}" (type="${m.type}")`); - const children = modelChildren.get(m.id) || []; - children.slice(0, 5).forEach(cid => { - const child = models.find(m => m.id === cid); - console.log(` └── "${child?.name}" (type="${child?.type}")`); - }); - if (children.length > 5) { - console.log(` ... and ${children.length - 5} more children`); - } -}); - -// Check if Bone001's parent has a transform that's not identity -const bone001Parent = modelParent.get(bone001?.id); -if (bone001Parent) { - const parent = models.find(m => m.id === bone001Parent); - console.log(`\n=== BONE001'S PARENT DETAILS ===`); - console.log(`Parent: "${parent?.name}" (type="${parent?.type}")`); - - // Find this parent in FBX and get its transform - for (const n of objectsNode.children) { - if (n.name === 'Model' && n.properties[0] === bone001Parent) { - let position = [0, 0, 0]; - let rotation = [0, 0, 0]; - let scale = [1, 1, 1]; - let preRotation = null; - - for (const child of n.children) { - if (child.name === 'Properties70') { - for (const prop of child.children) { - if (prop.properties[0] === 'Lcl Translation') { - position = [prop.properties[4], prop.properties[5], prop.properties[6]]; - } else if (prop.properties[0] === 'Lcl Rotation') { - rotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; - } else if (prop.properties[0] === 'Lcl Scaling') { - scale = [prop.properties[4], prop.properties[5], prop.properties[6]]; - } else if (prop.properties[0] === 'PreRotation') { - preRotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; - } - } - } - } - - console.log(` position: [${position.join(', ')}]`); - console.log(` rotation: [${rotation.join(', ')}]`); - console.log(` scale: [${scale.join(', ')}]`); - if (preRotation) { - console.log(` preRotation: [${preRotation.join(', ')}]`); - } - - // Check if parent has non-identity transform - const hasNonIdentityTransform = - position.some(v => Math.abs(v) > 0.001) || - rotation.some(v => Math.abs(v) > 0.001) || - scale.some(v => Math.abs(v - 1) > 0.001); - - if (hasNonIdentityTransform) { - console.log(`\n⚠️ Parent has non-identity transform!`); - console.log(`This transform MUST be included when calculating bone world matrices.`); - } else { - console.log(`\nParent has identity transform (no effect).`); - } - } - } -} - -console.log('\nDone!'); diff --git a/scripts/check-prerotation.mjs b/scripts/check-prerotation.mjs deleted file mode 100644 index 2f9c9638..00000000 --- a/scripts/check-prerotation.mjs +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Check PreRotation in FBX - * 检查 FBX 中的 PreRotation - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Checking PreRotation: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; - case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; - case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; - case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; - case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; - case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': case 'd': case 'l': case 'i': case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); - -// Parse Models and check for PreRotation -const modelsWithPreRot = []; -const modelsWithoutPreRot = []; - -for (const n of objectsNode.children) { - if (n.name !== 'Model') continue; - - const modelName = n.properties[1]?.split?.('\0')[0] || 'Model'; - let hasPreRotation = false; - let preRotation = null; - let lclRotation = null; - - for (const child of n.children) { - if (child.name === 'Properties70') { - for (const prop of child.children) { - if (prop.properties[0] === 'PreRotation') { - hasPreRotation = true; - preRotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; - } - if (prop.properties[0] === 'Lcl Rotation') { - lclRotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; - } - } - } - } - - if (hasPreRotation) { - modelsWithPreRot.push({ name: modelName, preRotation, lclRotation }); - } else { - modelsWithoutPreRot.push({ name: modelName, lclRotation }); - } -} - -console.log(`Models WITH PreRotation: ${modelsWithPreRot.length}`); -console.log(`Models WITHOUT PreRotation: ${modelsWithoutPreRot.length}`); - -if (modelsWithPreRot.length > 0) { - console.log(`\nFirst 5 models with PreRotation:`); - modelsWithPreRot.slice(0, 5).forEach(m => { - console.log(` "${m.name}":`); - console.log(` PreRotation: [${m.preRotation.map(v => v.toFixed(2)).join(', ')}]`); - console.log(` LclRotation: [${m.lclRotation?.map(v => v.toFixed(2)).join(', ') || 'none'}]`); - }); -} - -// Check if bones have PreRotation (bones typically have "Bone" in name) -const boneModels = modelsWithPreRot.filter(m => m.name.includes('Bone')); -console.log(`\nBone models with PreRotation: ${boneModels.length}`); - -if (boneModels.length > 0) { - console.log(`\n⚠️ This FBX has bones with PreRotation!`); - console.log(`PreRotation MUST be applied when building world matrices.`); -} - -console.log('\nDone!'); diff --git a/scripts/compare-ibm.mjs b/scripts/compare-ibm.mjs deleted file mode 100644 index 6627b990..00000000 --- a/scripts/compare-ibm.mjs +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Compare InverseBindMatrix calculation - * 比较逆绑定矩阵计算 - * - * This script compares the IBM calculated in test script vs FBXLoader's method - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Analyzing: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - const startOffset = offset; - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': - properties.push(view.getInt16(offset, true)); - offset += 2; - break; - case 'C': - properties.push(buffer[offset] !== 0); - offset += 1; - break; - case 'I': - properties.push(view.getInt32(offset, true)); - offset += 4; - break; - case 'F': - properties.push(view.getFloat32(offset, true)); - offset += 4; - break; - case 'D': - properties.push(view.getFloat64(offset, true)); - offset += 8; - break; - case 'L': - properties.push(view.getBigInt64(offset, true)); - offset += 8; - break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); - offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': - case 'd': - case 'l': - case 'i': - case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); - -// Find first Cluster deformer -const clusterNodes = objectsNode.children.filter(n => - n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster' -); - -console.log(`Found ${clusterNodes.length} clusters\n`); - -// Parse TransformLink from first cluster -const firstCluster = clusterNodes[0]; -const clusterName = firstCluster.properties[1]?.split?.('\0')[0] || 'Cluster'; - -console.log(`First cluster: "${clusterName}"`); - -// Find TransformLink child node -const transformLinkNode = firstCluster.children.find(c => c.name === 'TransformLink'); -if (!transformLinkNode) { - console.log('ERROR: No TransformLink found!'); - process.exit(1); -} - -const transformLinkData = transformLinkNode.properties[0]; -if (!transformLinkData?.data || transformLinkData.data.length !== 16) { - console.log('ERROR: TransformLink data is not 16 doubles!'); - console.log('Got:', transformLinkData); - process.exit(1); -} - -// FBX stores matrices in row-major order -// WebGL expects column-major order -const tlRaw = transformLinkData.data; -console.log('\n=== TransformLink Raw Data (FBX row-major) ==='); -console.log(`Row 0: ${tlRaw.slice(0, 4).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Row 1: ${tlRaw.slice(4, 8).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Row 2: ${tlRaw.slice(8, 12).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Row 3: ${tlRaw.slice(12, 16).map(v => v.toFixed(6)).join(', ')}`); - -// Convert to column-major for WebGL -const tlColMajor = new Float32Array([ - tlRaw[0], tlRaw[4], tlRaw[8], tlRaw[12], - tlRaw[1], tlRaw[5], tlRaw[9], tlRaw[13], - tlRaw[2], tlRaw[6], tlRaw[10], tlRaw[14], - tlRaw[3], tlRaw[7], tlRaw[11], tlRaw[15] -]); - -console.log('\n=== TransformLink (WebGL column-major) ==='); -console.log(`Col 0: ${Array.from(tlColMajor.slice(0, 4)).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Col 1: ${Array.from(tlColMajor.slice(4, 8)).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Col 2: ${Array.from(tlColMajor.slice(8, 12)).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Col 3: ${Array.from(tlColMajor.slice(12, 16)).map(v => v.toFixed(6)).join(', ')}`); - -// Invert the matrix (this is what FBXLoader does) -function invertMatrix4(m) { - const out = new Float32Array(16); - const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3]; - const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7]; - const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11]; - const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15]; - - const b00 = m00 * m11 - m01 * m10; - const b01 = m00 * m12 - m02 * m10; - const b02 = m00 * m13 - m03 * m10; - const b03 = m01 * m12 - m02 * m11; - const b04 = m01 * m13 - m03 * m11; - const b05 = m02 * m13 - m03 * m12; - const b06 = m20 * m31 - m21 * m30; - const b07 = m20 * m32 - m22 * m30; - const b08 = m20 * m33 - m23 * m30; - const b09 = m21 * m32 - m22 * m31; - const b10 = m21 * m33 - m23 * m31; - const b11 = m22 * m33 - m23 * m32; - - let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; - - if (Math.abs(det) < 1e-8) { - console.log('WARNING: Matrix is singular!'); - return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); - } - - det = 1.0 / det; - - out[0] = (m11 * b11 - m12 * b10 + m13 * b09) * det; - out[1] = (m02 * b10 - m01 * b11 - m03 * b09) * det; - out[2] = (m31 * b05 - m32 * b04 + m33 * b03) * det; - out[3] = (m22 * b04 - m21 * b05 - m23 * b03) * det; - out[4] = (m12 * b08 - m10 * b11 - m13 * b07) * det; - out[5] = (m00 * b11 - m02 * b08 + m03 * b07) * det; - out[6] = (m32 * b02 - m30 * b05 - m33 * b01) * det; - out[7] = (m20 * b05 - m22 * b02 + m23 * b01) * det; - out[8] = (m10 * b10 - m11 * b08 + m13 * b06) * det; - out[9] = (m01 * b08 - m00 * b10 - m03 * b06) * det; - out[10] = (m30 * b04 - m31 * b02 + m33 * b00) * det; - out[11] = (m21 * b02 - m20 * b04 - m23 * b00) * det; - out[12] = (m11 * b07 - m10 * b09 - m12 * b06) * det; - out[13] = (m00 * b09 - m01 * b07 + m02 * b06) * det; - out[14] = (m31 * b01 - m30 * b03 - m32 * b00) * det; - out[15] = (m20 * b03 - m21 * b01 + m22 * b00) * det; - - return out; -} - -// FBXLoader does: inverseBindMatrix = invertMatrix4(TransformLink) -// But does FBXLoader expect TransformLink in row-major or column-major? - -// Let's check what FBXLoader does with the raw TransformLink data -// Looking at FBXLoader.ts line 1045-1070, it reads TransformLink: -// cluster.transformLink = new Float32Array(transformLinkData.data); -// So it stores the raw FBX row-major data directly - -// Then at line 1707-1709: -// const inverseBindMatrix = cluster.transformLink -// ? this.invertMatrix4(cluster.transformLink) -// : this.createIdentityMatrix(); - -// The question is: does invertMatrix4 expect row-major or column-major input? -// Looking at the invertMatrix4 function, it uses standard column-major notation -// So if it receives row-major data, the result will be wrong! - -console.log('\n=== PROBLEM ANALYSIS ==='); -console.log('FBXLoader stores TransformLink as raw FBX data (row-major)'); -console.log('But invertMatrix4() expects column-major input (WebGL convention)'); -console.log('This mismatch could cause incorrect inverse bind matrices!\n'); - -// Test: invert the raw row-major data (what FBXLoader currently does) -const ibmWrong = invertMatrix4(new Float32Array(tlRaw)); -console.log('=== IBM from Row-Major Input (CURRENT - possibly wrong) ==='); -console.log(`Col 0: ${Array.from(ibmWrong.slice(0, 4)).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Col 1: ${Array.from(ibmWrong.slice(4, 8)).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Col 2: ${Array.from(ibmWrong.slice(8, 12)).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Col 3: ${Array.from(ibmWrong.slice(12, 16)).map(v => v.toFixed(6)).join(', ')}`); - -// Test: invert the transposed (column-major) data (correct approach) -const ibmCorrect = invertMatrix4(tlColMajor); -console.log('\n=== IBM from Column-Major Input (CORRECT) ==='); -console.log(`Col 0: ${Array.from(ibmCorrect.slice(0, 4)).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Col 1: ${Array.from(ibmCorrect.slice(4, 8)).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Col 2: ${Array.from(ibmCorrect.slice(8, 12)).map(v => v.toFixed(6)).join(', ')}`); -console.log(`Col 3: ${Array.from(ibmCorrect.slice(12, 16)).map(v => v.toFixed(6)).join(', ')}`); - -// Verify by checking M * M^-1 = I -function multiplyMatrices(a, b) { - const result = new Float32Array(16); - for (let row = 0; row < 4; row++) { - for (let col = 0; col < 4; col++) { - let sum = 0; - for (let k = 0; k < 4; k++) { - sum += a[row + k * 4] * b[k + col * 4]; - } - result[row + col * 4] = sum; - } - } - return result; -} - -console.log('\n=== VERIFICATION: TransformLink * IBM should = Identity ==='); -const verify1 = multiplyMatrices(tlColMajor, ibmCorrect); -console.log('Using column-major TransformLink * correct IBM:'); -console.log(`Diagonal: ${verify1[0].toFixed(4)}, ${verify1[5].toFixed(4)}, ${verify1[10].toFixed(4)}, ${verify1[15].toFixed(4)}`); - -const verify2 = multiplyMatrices(new Float32Array(tlRaw), ibmWrong); -console.log('Using row-major TransformLink * wrong IBM:'); -console.log(`Diagonal: ${verify2[0].toFixed(4)}, ${verify2[5].toFixed(4)}, ${verify2[10].toFixed(4)}, ${verify2[15].toFixed(4)}`); - -console.log('\nDone!'); diff --git a/scripts/compare-world-matrix.mjs b/scripts/compare-world-matrix.mjs deleted file mode 100644 index 419bce2e..00000000 --- a/scripts/compare-world-matrix.mjs +++ /dev/null @@ -1,355 +0,0 @@ -/** - * Compare TransformLink vs Calculated World Matrix - * 比较 TransformLink 和计算的世界矩阵 - * - * The issue: node.transform gives LOCAL transforms, but TransformLink is WORLD matrix. - * When we build worldMatrix from hierarchy, it might not equal TransformLink. - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Comparing World Matrix: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; - case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; - case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; - case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; - case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; - case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': case 'd': case 'l': case 'i': case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// Parse Models with Lcl transforms -const models = objectsNode.children - .filter(n => n.name === 'Model') - .map(n => { - const position = [0, 0, 0]; - const rotation = [0, 0, 0]; - const scale = [1, 1, 1]; - - for (const child of n.children) { - if (child.name === 'Properties70') { - for (const prop of child.children) { - if (prop.properties[0] === 'Lcl Translation') { - position[0] = prop.properties[4]; - position[1] = prop.properties[5]; - position[2] = prop.properties[6]; - } else if (prop.properties[0] === 'Lcl Rotation') { - rotation[0] = prop.properties[4]; - rotation[1] = prop.properties[5]; - rotation[2] = prop.properties[6]; - } else if (prop.properties[0] === 'Lcl Scaling') { - scale[0] = prop.properties[4]; - scale[1] = prop.properties[5]; - scale[2] = prop.properties[6]; - } - } - } - } - - return { - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Model', - position, rotation, scale - }; - }); - -// Parse Clusters with TransformLink -const clusters = objectsNode.children - .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') - .map(n => { - const cluster = { - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Cluster', - transformLink: null - }; - - for (const child of n.children) { - if (child.name === 'TransformLink') { - const data = child.properties[0]?.data; - if (data && data.length === 16) { - cluster.transformLink = new Float32Array(data); - } - } - } - - return cluster; - }); - -// Build mappings -const clusterToBone = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - const cluster = clusters.find(c => c.id === conn.toId); - if (cluster) clusterToBone.set(cluster.id, conn.fromId); - } -} - -const modelToIndex = new Map(); -const modelById = new Map(); -models.forEach((m, i) => { - modelToIndex.set(m.id, i); - modelById.set(m.id, m); -}); - -const modelParent = new Map(); -for (const conn of connections) { - if (conn.type === 'OO' && modelToIndex.has(conn.fromId) && modelToIndex.has(conn.toId)) { - modelParent.set(conn.fromId, conn.toId); - } -} - -// Euler to quaternion (XYZ intrinsic) -function eulerToQuaternion(x, y, z) { - const cx = Math.cos(x / 2), sx = Math.sin(x / 2); - const cy = Math.cos(y / 2), sy = Math.sin(y / 2); - const cz = Math.cos(z / 2), sz = Math.sin(z / 2); - return [ - sx * cy * cz - cx * sy * sz, - cx * sy * cz + sx * cy * sz, - cx * cy * sz - sx * sy * cz, - cx * cy * cz + sx * sy * sz - ]; -} - -function createTransformMatrix(position, rotation, scale) { - const [qx, qy, qz, qw] = rotation; - const [sx, sy, sz] = scale; - const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; - const yy = qy * qy, yz = qy * qz, yw = qy * qw; - const zz = qz * qz, zw = qz * qw; - - return new Float32Array([ - (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, - 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, - 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, - position[0], position[1], position[2], 1 - ]); -} - -function multiplyMatrices(a, b) { - const result = new Float32Array(16); - for (let row = 0; row < 4; row++) { - for (let col = 0; col < 4; col++) { - let sum = 0; - for (let k = 0; k < 4; k++) { - sum += a[row + k * 4] * b[k + col * 4]; - } - result[row + col * 4] = sum; - } - } - return result; -} - -function identity() { - return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); -} - -// Calculate world matrices from hierarchy -const worldMatrices = new Map(); - -function calculateWorldMatrix(modelId) { - if (worldMatrices.has(modelId)) return worldMatrices.get(modelId); - - const model = modelById.get(modelId); - if (!model) { - const mat = identity(); - worldMatrices.set(modelId, mat); - return mat; - } - - const rx = model.rotation[0] * Math.PI / 180; - const ry = model.rotation[1] * Math.PI / 180; - const rz = model.rotation[2] * Math.PI / 180; - const quat = eulerToQuaternion(rx, ry, rz); - const localMatrix = createTransformMatrix(model.position, quat, model.scale); - - const parentId = modelParent.get(modelId); - let worldMatrix; - if (parentId) { - const parentWorld = calculateWorldMatrix(parentId); - worldMatrix = multiplyMatrices(parentWorld, localMatrix); - } else { - worldMatrix = localMatrix; - } - - worldMatrices.set(modelId, worldMatrix); - return worldMatrix; -} - -console.log(`=== Comparing TransformLink vs Calculated World Matrix ===\n`); - -let matchCount = 0; -let mismatchCount = 0; - -for (const cluster of clusters) { - const boneModelId = clusterToBone.get(cluster.id); - if (!boneModelId || !cluster.transformLink) continue; - - const model = modelById.get(boneModelId); - const calculatedWorld = calculateWorldMatrix(boneModelId); - const transformLink = cluster.transformLink; - - // Compare - let maxDiff = 0; - for (let i = 0; i < 16; i++) { - const diff = Math.abs(calculatedWorld[i] - transformLink[i]); - if (diff > maxDiff) maxDiff = diff; - } - - if (maxDiff < 0.01) { - matchCount++; - } else { - mismatchCount++; - if (mismatchCount <= 3) { - console.log(`❌ MISMATCH: "${model?.name}" (maxDiff=${maxDiff.toFixed(4)})`); - console.log(` TransformLink:`); - console.log(` [${transformLink.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` [${transformLink.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` [${transformLink.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` [${transformLink.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` Calculated World:`); - console.log(` [${calculatedWorld.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` [${calculatedWorld.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` [${calculatedWorld.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` [${calculatedWorld.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); - console.log(''); - } - } -} - -console.log(`\n=== RESULT ===`); -console.log(`Match: ${matchCount}`); -console.log(`Mismatch: ${mismatchCount}`); - -if (mismatchCount > 0) { - console.log(`\n⚠️ TransformLink does NOT match calculated world matrix!`); - console.log(`This means Lcl Translation/Rotation/Scale don't build to the bind pose.`); - console.log(`\nPossible reasons:`); - console.log(`1. Missing PreRotation in the transform calculation`); - console.log(`2. FBX hierarchy differs from the bone hierarchy`); - console.log(`3. Some bones have additional transforms not captured`); -} else { - console.log(`\n✅ All TransformLinks match calculated world matrices!`); -} - -console.log('\nDone!'); diff --git a/scripts/debug-channels.mjs b/scripts/debug-channels.mjs deleted file mode 100644 index d65c72a3..00000000 --- a/scripts/debug-channels.mjs +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Debug Animation Channels Building - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - const startOffset = offset; - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': - properties.push(view.getInt16(offset, true)); - offset += 2; - break; - case 'C': - properties.push(buffer[offset] !== 0); - offset += 1; - break; - case 'I': - properties.push(view.getInt32(offset, true)); - offset += 4; - break; - case 'F': - properties.push(view.getFloat32(offset, true)); - offset += 4; - break; - case 'D': - properties.push(view.getFloat64(offset, true)); - offset += 8; - break; - case 'L': - properties.push(view.getBigInt64(offset, true)); - offset += 8; - break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); - offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': - case 'd': - case 'l': - case 'i': - case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse AnimationCurveNodes -const animCurveNodes = objectsNode.children.filter(n => n.name === 'AnimationCurveNode'); - -console.log(`AnimationCurveNodes count: ${animCurveNodes.length}`); -console.log(`First 3 AnimationCurveNodes:`); -animCurveNodes.slice(0, 3).forEach((cn, i) => { - console.log(` [${i}] properties:`, cn.properties); - console.log(` id type: ${typeof cn.properties[0]}`); - console.log(` id value: ${cn.properties[0]}`); -}); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -console.log(`\nConnections count: ${connections.length}`); - -// Find OP connections with Lcl property -const lclConnections = connections.filter(c => c.type === 'OP' && c.property?.includes('Lcl')); -console.log(`OP connections with Lcl: ${lclConnections.length}`); -console.log(`First 3 Lcl connections:`); -lclConnections.slice(0, 3).forEach((c, i) => { - console.log(` [${i}] fromId=${c.fromId} (type: ${typeof c.fromId}), toId=${c.toId}, prop=${c.property}`); -}); - -// Check if any AnimationCurveNode id matches connection fromId -console.log(`\nChecking AnimationCurveNode ID matches:`); -const cnIds = new Set(animCurveNodes.map(cn => cn.properties[0])); -const lclFromIds = lclConnections.map(c => c.fromId); - -let matchCount = 0; -for (const fromId of lclFromIds) { - // Check different ID formats - const matchesDirect = cnIds.has(fromId); - const matchesBigInt = cnIds.has(BigInt(fromId)); - - if (matchesDirect || matchesBigInt) { - matchCount++; - } -} - -console.log(`Matches found: ${matchCount}/${lclConnections.length}`); - -// The issue might be that animCurveNodes doesn't have an 'id' property -// Let's check how we should reference them -console.log(`\nAnimationCurveNode structure check:`); -const firstCN = animCurveNodes[0]; -if (firstCN) { - console.log(` Has 'id' property: ${'id' in firstCN}`); - console.log(` properties[0] type: ${typeof firstCN.properties[0]}`); - console.log(` properties[0] value: ${firstCN.properties[0]}`); -} - -// The fix: we need to use cn.properties[0] as the ID, not cn.id -// Let's verify by creating a proper map -const curveNodeMap = new Map(); -for (const cn of animCurveNodes) { - curveNodeMap.set(cn.properties[0], cn); -} - -console.log(`\nBuilt curveNodeMap with ${curveNodeMap.size} entries`); - -// Now check matches -let matchCount2 = 0; -for (const conn of lclConnections) { - if (curveNodeMap.has(conn.fromId)) { - matchCount2++; - } -} -console.log(`Matches using proper lookup: ${matchCount2}/${lclConnections.length}`); - -console.log('\nDone!'); diff --git a/scripts/debug-fbx-animation.mjs b/scripts/debug-fbx-animation.mjs deleted file mode 100644 index 684c3ead..00000000 --- a/scripts/debug-fbx-animation.mjs +++ /dev/null @@ -1,328 +0,0 @@ -/** - * FBX Animation-Skeleton Debug Script - * 调试 FBX 动画和骨骼的对应关系 - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const FBX_TIME_SECOND = 46186158000n; -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Analyzing: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); - -// Parse FBX header -const version = view.getUint32(23, true); -console.log(`FBX Version: ${version}`); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - const startOffset = offset; - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - // Read properties - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': - properties.push(view.getInt16(offset, true)); - offset += 2; - break; - case 'C': - properties.push(buffer[offset] !== 0); - offset += 1; - break; - case 'I': - properties.push(view.getInt32(offset, true)); - offset += 4; - break; - case 'F': - properties.push(view.getFloat32(offset, true)); - offset += 4; - break; - case 'D': - properties.push(view.getFloat64(offset, true)); - offset += 8; - break; - case 'L': - properties.push(view.getBigInt64(offset, true)); - offset += 8; - break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); - offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': - case 'd': - case 'l': - case 'i': - case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - // Compressed - decompress with pako - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - // Read children - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -console.log(`Root nodes: ${rootNodes.map(n => n.name).join(', ')}\n`); - -// Find Objects and Connections -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -if (!objectsNode || !connectionsNode) { - console.log('Missing Objects or Connections node!'); - process.exit(1); -} - -// Parse all connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// Find Models (bones are usually LimbNode type) -const models = objectsNode.children - .filter(n => n.name === 'Model') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Model', - type: n.properties[2]?.split?.('\0')[0] || '' - })); - -console.log(`=== MODELS (${models.length}) ===`); -models.forEach((m, i) => { - console.log(` [${i}] ID=${m.id}, name="${m.name}", type="${m.type}"`); -}); - -// Find AnimationCurveNodes -const curveNodes = objectsNode.children - .filter(n => n.name === 'AnimationCurveNode') - .map(n => { - const id = n.properties[0]; - const name = n.properties[1]?.split?.('\0')[0] || ''; - return { id, name }; - }); - -console.log(`\n=== ANIMATION CURVE NODES (${curveNodes.length}) ===`); - -// Find which models each AnimationCurveNode targets -const curveNodeTargets = new Map(); -for (const conn of connections) { - if (conn.type === 'OP' && conn.property?.includes('Lcl')) { - // AnimationCurveNode -> Model connection - const curveNode = curveNodes.find(cn => cn.id === conn.fromId); - const model = models.find(m => m.id === conn.toId); - if (curveNode && model) { - const modelIndex = models.indexOf(model); - if (!curveNodeTargets.has(conn.toId)) { - curveNodeTargets.set(conn.toId, { - modelId: conn.toId, - modelIndex, - modelName: model.name, - properties: [] - }); - } - curveNodeTargets.get(conn.toId).properties.push({ - curveNodeId: curveNode.id, - curveNodeName: curveNode.name, - property: conn.property - }); - } - } -} - -console.log(`Animation targets ${curveNodeTargets.size} models:`); -for (const [modelId, info] of curveNodeTargets) { - console.log(` Model[${info.modelIndex}] "${info.modelName}" ID=${modelId}:`); - for (const p of info.properties) { - console.log(` - ${p.property} (CurveNode: ${p.curveNodeName})`); - } -} - -// Find Deformers (Clusters) -const clusters = objectsNode.children - .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Cluster' - })); - -console.log(`\n=== CLUSTERS (Skin Deformers) (${clusters.length}) ===`); - -// Find which models each Cluster is linked to (via Cluster -> Model connection) -const clusterToBone = new Map(); - -// First, let's see all connections involving clusters -console.log(`\nAll connections involving clusters (first 20):`); -let clusterConnCount = 0; -for (const conn of connections) { - const clusterAsFrom = clusters.find(c => c.id === conn.fromId); - const clusterAsTo = clusters.find(c => c.id === conn.toId); - if (clusterAsFrom || clusterAsTo) { - if (clusterConnCount < 20) { - const fromName = clusterAsFrom?.name || models.find(m => m.id === conn.fromId)?.name || `ID=${conn.fromId}`; - const toName = clusterAsTo?.name || models.find(m => m.id === conn.toId)?.name || `ID=${conn.toId}`; - console.log(` [${conn.type}] ${fromName} -> ${toName} (prop: ${conn.property || 'none'})`); - } - clusterConnCount++; - } -} -console.log(`Total cluster connections: ${clusterConnCount}`); - -// Try both directions for Cluster <-> Model connections -for (const conn of connections) { - if (conn.type === 'OO') { - // Cluster -> Model - const clusterFrom = clusters.find(c => c.id === conn.fromId); - const modelTo = models.find(m => m.id === conn.toId); - if (clusterFrom && modelTo) { - clusterToBone.set(clusterFrom.id, { - clusterId: clusterFrom.id, - clusterName: clusterFrom.name, - boneModelId: conn.toId, - boneModelIndex: models.indexOf(modelTo), - boneModelName: modelTo.name - }); - } - - // Model -> Cluster (reversed) - const modelFrom = models.find(m => m.id === conn.fromId); - const clusterTo = clusters.find(c => c.id === conn.toId); - if (modelFrom && clusterTo) { - clusterToBone.set(clusterTo.id, { - clusterId: clusterTo.id, - clusterName: clusterTo.name, - boneModelId: conn.fromId, - boneModelIndex: models.indexOf(modelFrom), - boneModelName: modelFrom.name - }); - } - } -} - -console.log(`Cluster -> Bone mappings (${clusterToBone.size}):`); -for (const [clusterId, info] of clusterToBone) { - const hasAnimation = curveNodeTargets.has(info.boneModelId); - console.log(` Cluster "${info.clusterName}" -> Model[${info.boneModelIndex}] "${info.boneModelName}" ${hasAnimation ? '✓ HAS ANIMATION' : '✗ NO ANIMATION'}`); -} - -// Summary -console.log(`\n=== SUMMARY ===`); -const animatedModels = [...curveNodeTargets.keys()]; -const boneModels = [...clusterToBone.values()].map(b => b.boneModelId); - -const bonesWithAnimation = boneModels.filter(id => curveNodeTargets.has(id)); -const bonesWithoutAnimation = boneModels.filter(id => !curveNodeTargets.has(id)); - -console.log(`Total animated models: ${animatedModels.length}`); -console.log(`Total bone models: ${boneModels.length}`); -console.log(`Bones WITH animation: ${bonesWithAnimation.length}`); -console.log(`Bones WITHOUT animation: ${bonesWithoutAnimation.length}`); - -if (bonesWithoutAnimation.length > 0) { - console.log(`\nBones missing animation:`); - for (const id of bonesWithoutAnimation.slice(0, 10)) { - const info = [...clusterToBone.values()].find(b => b.boneModelId === id); - console.log(` - Model[${info.boneModelIndex}] "${info.boneModelName}"`); - } - if (bonesWithoutAnimation.length > 10) { - console.log(` ... and ${bonesWithoutAnimation.length - 10} more`); - } -} - -console.log('\nDone!'); diff --git a/scripts/debug-runtime-anim.mjs b/scripts/debug-runtime-anim.mjs deleted file mode 100644 index 5d0d8048..00000000 --- a/scripts/debug-runtime-anim.mjs +++ /dev/null @@ -1,564 +0,0 @@ -/** - * Debug Runtime Animation Flow - * 调试运行时动画流程 - * - * This script mimics exactly what ModelPreview3D does when rendering - * and outputs detailed debug info at each step. - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const FBX_TIME_SECOND = 46186158000n; -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Debug Runtime Animation: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': - properties.push(view.getInt16(offset, true)); - offset += 2; - break; - case 'C': - properties.push(buffer[offset] !== 0); - offset += 1; - break; - case 'I': - properties.push(view.getInt32(offset, true)); - offset += 4; - break; - case 'F': - properties.push(view.getFloat32(offset, true)); - offset += 4; - break; - case 'D': - properties.push(view.getFloat64(offset, true)); - offset += 8; - break; - case 'L': - properties.push(view.getBigInt64(offset, true)); - offset += 8; - break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); - offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': - case 'd': - case 'l': - case 'i': - case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// Parse Models with their transforms -const models = objectsNode.children - .filter(n => n.name === 'Model') - .map(n => { - const position = [0, 0, 0]; - const rotation = [0, 0, 0]; - const scale = [1, 1, 1]; - const preRotation = null; - - for (const child of n.children) { - if (child.name === 'Properties70') { - for (const prop of child.children) { - if (prop.properties[0] === 'Lcl Translation') { - position[0] = prop.properties[4]; - position[1] = prop.properties[5]; - position[2] = prop.properties[6]; - } else if (prop.properties[0] === 'Lcl Rotation') { - rotation[0] = prop.properties[4]; - rotation[1] = prop.properties[5]; - rotation[2] = prop.properties[6]; - } else if (prop.properties[0] === 'Lcl Scaling') { - scale[0] = prop.properties[4]; - scale[1] = prop.properties[5]; - scale[2] = prop.properties[6]; - } - } - } - } - - return { - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Model', - position, - rotation, - scale, - preRotation - }; - }); - -console.log(`Models: ${models.length}`); - -// Parse Clusters with TransformLink -const clusters = objectsNode.children - .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') - .map(n => { - const cluster = { - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Cluster', - transformLink: null - }; - - for (const child of n.children) { - if (child.name === 'TransformLink') { - const data = child.properties[0]?.data; - if (data && data.length === 16) { - cluster.transformLink = new Float32Array(data); - } - } - } - - return cluster; - }); - -// Build cluster to bone mapping -const clusterToBone = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - const cluster = clusters.find(c => c.id === conn.toId); - if (cluster) { - clusterToBone.set(cluster.id, conn.fromId); - } - } -} - -// Build model ID to index -const modelToIndex = new Map(); -models.forEach((m, i) => modelToIndex.set(m.id, i)); - -// Build parent relationships -const modelParent = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - if (modelToIndex.has(conn.fromId) && modelToIndex.has(conn.toId)) { - modelParent.set(conn.fromId, conn.toId); - } - } -} - -// Euler to quaternion (XYZ intrinsic) -function eulerToQuaternion(x, y, z) { - const cx = Math.cos(x / 2), sx = Math.sin(x / 2); - const cy = Math.cos(y / 2), sy = Math.sin(y / 2); - const cz = Math.cos(z / 2), sz = Math.sin(z / 2); - return [ - sx * cy * cz - cx * sy * sz, - cx * sy * cz + sx * cy * sz, - cx * cy * sz - sx * sy * cz, - cx * cy * cz + sx * sy * sz - ]; -} - -// Create transform matrix from position, rotation (quaternion), scale -function createTransformMatrix(position, rotation, scale) { - const [qx, qy, qz, qw] = rotation; - const [sx, sy, sz] = scale; - - const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; - const yy = qy * qy, yz = qy * qz, yw = qy * qw; - const zz = qz * qz, zw = qz * qw; - - return new Float32Array([ - (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, - 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, - 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, - position[0], position[1], position[2], 1 - ]); -} - -// Invert matrix -function invertMatrix4(m) { - const out = new Float32Array(16); - const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3]; - const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7]; - const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11]; - const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15]; - - const b00 = m00 * m11 - m01 * m10; - const b01 = m00 * m12 - m02 * m10; - const b02 = m00 * m13 - m03 * m10; - const b03 = m01 * m12 - m02 * m11; - const b04 = m01 * m13 - m03 * m11; - const b05 = m02 * m13 - m03 * m12; - const b06 = m20 * m31 - m21 * m30; - const b07 = m20 * m32 - m22 * m30; - const b08 = m20 * m33 - m23 * m30; - const b09 = m21 * m32 - m22 * m31; - const b10 = m21 * m33 - m23 * m31; - const b11 = m22 * m33 - m23 * m32; - - let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; - if (Math.abs(det) < 1e-8) return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); - - det = 1.0 / det; - - out[0] = (m11 * b11 - m12 * b10 + m13 * b09) * det; - out[1] = (m02 * b10 - m01 * b11 - m03 * b09) * det; - out[2] = (m31 * b05 - m32 * b04 + m33 * b03) * det; - out[3] = (m22 * b04 - m21 * b05 - m23 * b03) * det; - out[4] = (m12 * b08 - m10 * b11 - m13 * b07) * det; - out[5] = (m00 * b11 - m02 * b08 + m03 * b07) * det; - out[6] = (m32 * b02 - m30 * b05 - m33 * b01) * det; - out[7] = (m20 * b05 - m22 * b02 + m23 * b01) * det; - out[8] = (m10 * b10 - m11 * b08 + m13 * b06) * det; - out[9] = (m01 * b08 - m00 * b10 - m03 * b06) * det; - out[10] = (m30 * b04 - m31 * b02 + m33 * b00) * det; - out[11] = (m21 * b02 - m20 * b04 - m23 * b00) * det; - out[12] = (m11 * b07 - m10 * b09 - m12 * b06) * det; - out[13] = (m00 * b09 - m01 * b07 + m02 * b06) * det; - out[14] = (m31 * b01 - m30 * b03 - m32 * b00) * det; - out[15] = (m20 * b03 - m21 * b01 + m22 * b00) * det; - - return out; -} - -// Multiply matrices -function multiplyMatrices(a, b) { - const result = new Float32Array(16); - for (let row = 0; row < 4; row++) { - for (let col = 0; col < 4; col++) { - let sum = 0; - for (let k = 0; k < 4; k++) { - sum += a[row + k * 4] * b[k + col * 4]; - } - result[row + col * 4] = sum; - } - } - return result; -} - -function identity() { - return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); -} - -// Build skeleton (simulating FBXLoader.buildSkeletonData) -const joints = []; -const boneModelIdToJointIndex = new Map(); - -for (const cluster of clusters) { - const boneModelId = clusterToBone.get(cluster.id); - if (!boneModelId) continue; - - const nodeIndex = modelToIndex.get(boneModelId); - if (nodeIndex === undefined) continue; - - const model = models[nodeIndex]; - const jointIndex = joints.length; - boneModelIdToJointIndex.set(boneModelId, jointIndex); - - const inverseBindMatrix = cluster.transformLink - ? invertMatrix4(cluster.transformLink) - : identity(); - - joints.push({ - name: model.name, - nodeIndex, - parentIndex: -1, - inverseBindMatrix - }); -} - -// Set parent indices -for (const cluster of clusters) { - const boneModelId = clusterToBone.get(cluster.id); - if (!boneModelId) continue; - - const jointIndex = boneModelIdToJointIndex.get(boneModelId); - if (jointIndex === undefined) continue; - - let parentModelId = modelParent.get(boneModelId); - while (parentModelId) { - const parentJointIndex = boneModelIdToJointIndex.get(parentModelId); - if (parentJointIndex !== undefined) { - joints[jointIndex].parentIndex = parentJointIndex; - break; - } - parentModelId = modelParent.get(parentModelId); - } -} - -console.log(`Skeleton joints: ${joints.length}`); - -// Build nodes (simulating FBXLoader node building) -const nodes = models.map(model => { - const rx = model.rotation[0] * Math.PI / 180; - const ry = model.rotation[1] * Math.PI / 180; - const rz = model.rotation[2] * Math.PI / 180; - const quat = eulerToQuaternion(rx, ry, rz); - - return { - name: model.name, - transform: { - position: model.position, - rotation: quat, - scale: model.scale - } - }; -}); - -console.log(`\n=== KEY DEBUG INFO ===`); - -// Check a specific joint -const jointToDebug = 0; -const joint = joints[jointToDebug]; -const node = nodes[joint.nodeIndex]; - -console.log(`\nJoint[${jointToDebug}] "${joint.name}":`); -console.log(` nodeIndex: ${joint.nodeIndex}`); -console.log(` parentIndex: ${joint.parentIndex}`); -console.log(` node.transform.position: [${node.transform.position.join(', ')}]`); -console.log(` node.transform.rotation: [${node.transform.rotation.map(v => v.toFixed(4)).join(', ')}]`); -console.log(` node.transform.scale: [${node.transform.scale.join(', ')}]`); - -// Create local matrix from node transform -const localMatrix = createTransformMatrix( - node.transform.position, - node.transform.rotation, - node.transform.scale -); -console.log(`\n localMatrix (from node.transform):`); -console.log(` [${localMatrix.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); -console.log(` [${localMatrix.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); -console.log(` [${localMatrix.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); -console.log(` [${localMatrix.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); - -// Show inverseBindMatrix -console.log(`\n inverseBindMatrix:`); -console.log(` [${joint.inverseBindMatrix.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); -console.log(` [${joint.inverseBindMatrix.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); -console.log(` [${joint.inverseBindMatrix.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); -console.log(` [${joint.inverseBindMatrix.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); - -// Calculate skinMatrix = worldMatrix * inverseBindMatrix (for root, worldMatrix = localMatrix) -const skinMatrix = multiplyMatrices(localMatrix, joint.inverseBindMatrix); -console.log(`\n skinMatrix = worldMatrix * IBM (should be near identity at bind pose):`); -console.log(` [${skinMatrix.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); -console.log(` [${skinMatrix.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); -console.log(` [${skinMatrix.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); -console.log(` [${skinMatrix.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); - -// Check if skinMatrix is identity -function isNearIdentity(m, tol = 0.001) { - const id = [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; - for (let i = 0; i < 16; i++) { - if (Math.abs(m[i] - id[i]) > tol) return false; - } - return true; -} - -console.log(`\n Is skinMatrix near identity? ${isNearIdentity(skinMatrix) ? 'YES ✅' : 'NO ❌'}`); - -// Now simulate what happens when no animation is playing -// In ModelPreview3D, when there's no animTransform for a joint, it uses node.transform -console.log(`\n=== SIMULATING ModelPreview3D calculateBoneMatrices (no animation) ===`); - -// This is what ModelPreview3D does: -// 1. For each joint, get animTransform or fall back to node.transform -// 2. Create localMatrix from pos/rot/scale -// 3. Calculate worldMatrix = parent.worldMatrix * localMatrix -// 4. Calculate skinMatrix = worldMatrix * inverseBindMatrix - -const worldMatrices = new Array(joints.length); -const skinMatrices = new Array(joints.length); - -// Build processing order -const processingOrder = []; -const processed = new Set(); - -function addJoint(jointIndex) { - if (processed.has(jointIndex)) return; - const joint = joints[jointIndex]; - if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { - addJoint(joint.parentIndex); - } - processingOrder.push(jointIndex); - processed.add(jointIndex); -} - -for (let i = 0; i < joints.length; i++) { - addJoint(i); -} - -for (const jointIndex of processingOrder) { - const joint = joints[jointIndex]; - const node = nodes[joint.nodeIndex]; - - const pos = node.transform.position; - const rot = node.transform.rotation; - const scl = node.transform.scale; - - const localMatrix = createTransformMatrix(pos, rot, scl); - - if (joint.parentIndex >= 0) { - worldMatrices[jointIndex] = multiplyMatrices(worldMatrices[joint.parentIndex], localMatrix); - } else { - worldMatrices[jointIndex] = localMatrix; - } - - skinMatrices[jointIndex] = multiplyMatrices(worldMatrices[jointIndex], joint.inverseBindMatrix); -} - -// Count how many are near identity -let identityCount = 0; -let maxDiff = 0; - -for (let i = 0; i < joints.length; i++) { - const sm = skinMatrices[i]; - const id = [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; - let diff = 0; - for (let j = 0; j < 16; j++) { - diff = Math.max(diff, Math.abs(sm[j] - id[j])); - } - if (diff < 0.001) identityCount++; - if (diff > maxDiff) maxDiff = diff; -} - -console.log(`\nAt bind pose (no animation):`); -console.log(` Identity matrices: ${identityCount}/${joints.length}`); -console.log(` Max diff from identity: ${maxDiff.toFixed(6)}`); - -if (identityCount !== joints.length) { - console.log(`\n ⚠️ WARNING: Not all skin matrices are identity at bind pose!`); - console.log(` This suggests the node.transform doesn't match the TransformLink.`); - - // Show first non-identity matrix - for (let i = 0; i < joints.length; i++) { - const sm = skinMatrices[i]; - const id = [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; - let diff = 0; - for (let j = 0; j < 16; j++) { - diff = Math.max(diff, Math.abs(sm[j] - id[j])); - } - if (diff >= 0.001) { - const joint = joints[i]; - const node = nodes[joint.nodeIndex]; - console.log(`\n First non-identity: Joint[${i}] "${joint.name}"`); - console.log(` node.transform: pos=[${node.transform.position.join(',')}]`); - console.log(` skinMatrix:`); - console.log(` [${sm.slice(0, 4).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` [${sm.slice(4, 8).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` [${sm.slice(8, 12).map(v => v.toFixed(4)).join(', ')}]`); - console.log(` [${sm.slice(12, 16).map(v => v.toFixed(4)).join(', ')}]`); - break; - } - } -} else { - console.log(` ✅ All skin matrices are identity - bind pose is correct!`); -} - -console.log('\nDone!'); diff --git a/scripts/simple-fbx-test.mjs b/scripts/simple-fbx-test.mjs deleted file mode 100644 index bb2a644f..00000000 --- a/scripts/simple-fbx-test.mjs +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Simple FBX Test - * 简单 FBX 测试 - */ - -import { readFileSync } from 'fs'; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; -console.log(`Testing: ${filePath}`); - -async function main() { - // Dynamic import to handle the module - const { FBXLoader } = await import('../packages/asset-system/dist/index.js'); - - const binaryData = readFileSync(filePath); - const loader = new FBXLoader(); - - const context = { - metadata: { - path: filePath, - name: filePath.split(/[\\/]/).pop(), - type: 'model/fbx', - guid: '', - size: binaryData.length, - hash: '', - dependencies: [], - lastModified: Date.now(), - importerVersion: '1.0.0', - labels: [], - tags: [], - version: 1 - }, - loadDependency: async () => null - }; - - const content = { - type: 'binary', - binary: binaryData.buffer - }; - - try { - const asset = await loader.parse(content, context); - - console.log(`\nMeshes: ${asset.meshes?.length || 0}`); - console.log(`Nodes: ${asset.nodes?.length || 0}`); - console.log(`Skeleton joints: ${asset.skeleton?.joints?.length || 0}`); - - if (asset.skeleton && asset.skeleton.joints.length > 0) { - console.log(`\nFirst 3 joints:`); - for (let i = 0; i < 3 && i < asset.skeleton.joints.length; i++) { - const joint = asset.skeleton.joints[i]; - const node = asset.nodes?.[joint.nodeIndex]; - console.log(` [${i}] "${joint.name}" nodeIndex=${joint.nodeIndex}`); - if (node) { - console.log(` position: [${node.transform.position.map(v => v.toFixed(2)).join(', ')}]`); - console.log(` rotation: [${node.transform.rotation.map(v => v.toFixed(4)).join(', ')}]`); - } - } - } - - console.log('\nDone!'); - } catch (error) { - console.error('Error:', error.message); - console.error(error.stack); - } -} - -main(); diff --git a/scripts/test-animation-t0.mjs b/scripts/test-animation-t0.mjs deleted file mode 100644 index b85b6069..00000000 --- a/scripts/test-animation-t0.mjs +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Test Animation at t=0 - * 测试 t=0 时的动画值 - * - * Compare animation values at t=0 with node.transform - */ - -import { readFileSync } from 'fs'; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; -console.log(`Testing animation at t=0: ${filePath}\n`); - -async function main() { - const { FBXLoader } = await import('../packages/asset-system/dist/index.js'); - - const binaryData = readFileSync(filePath); - const loader = new FBXLoader(); - - const context = { - metadata: { - path: filePath, - name: filePath.split(/[\\/]/).pop(), - type: 'model/fbx', - guid: '', - size: binaryData.length, - hash: '', - dependencies: [], - lastModified: Date.now(), - importerVersion: '1.0.0', - labels: [], - tags: [], - version: 1 - }, - loadDependency: async () => null - }; - - const content = { - type: 'binary', - binary: binaryData.buffer - }; - - const asset = await loader.parse(content, context); - - if (!asset.animations || asset.animations.length === 0) { - console.log('No animation data!'); - return; - } - - const clip = asset.animations[0]; - const nodes = asset.nodes; - const skeleton = asset.skeleton; - - console.log(`Animation: "${clip.name}", duration: ${clip.duration}s`); - console.log(`Channels: ${clip.channels.length}, Samplers: ${clip.samplers.length}`); - - // Sample animation at t=0 - function sampleAtT0(sampler, componentCount) { - if (!sampler.output || sampler.output.length === 0) return null; - const result = []; - for (let i = 0; i < componentCount; i++) { - result.push(sampler.output[i]); - } - return result; - } - - // Get animated transforms at t=0 - const animTransforms = new Map(); - for (const channel of clip.channels) { - const sampler = clip.samplers[channel.samplerIndex]; - if (!sampler) continue; - - const nodeIndex = channel.target.nodeIndex; - const path = channel.target.path; - - let value; - if (path === 'rotation') { - value = sampleAtT0(sampler, 4); - } else { - value = sampleAtT0(sampler, 3); - } - if (!value) continue; - - if (!animTransforms.has(nodeIndex)) { - animTransforms.set(nodeIndex, {}); - } - const t = animTransforms.get(nodeIndex); - if (path === 'translation') t.position = value; - else if (path === 'rotation') t.rotation = value; - else if (path === 'scale') t.scale = value; - } - - console.log(`\nAnimated node count at t=0: ${animTransforms.size}`); - - // Compare with node.transform for first few skeleton joints - if (skeleton) { - console.log(`\n=== COMPARING ANIMATION vs NODE.TRANSFORM ===\n`); - - let matchCount = 0; - let mismatchCount = 0; - const mismatches = []; - - for (let i = 0; i < skeleton.joints.length; i++) { - const joint = skeleton.joints[i]; - const node = nodes[joint.nodeIndex]; - const animT = animTransforms.get(joint.nodeIndex); - - if (!node || !animT) continue; - - // Compare rotation (most important) - const nodeRot = node.transform.rotation; - const animRot = animT.rotation; - - if (animRot) { - const rotMatch = nodeRot.every((v, idx) => Math.abs(v - animRot[idx]) < 0.001); - if (rotMatch) { - matchCount++; - } else { - mismatchCount++; - mismatches.push({ jointIndex: i, name: joint.name, nodeRot, animRot }); - } - } - } - - console.log(`Rotation matches: ${matchCount}/${matchCount + mismatchCount}`); - - if (mismatches.length > 0) { - console.log(`\n❌ MISMATCHES found!`); - console.log(`First 5 mismatches:`); - for (let i = 0; i < 5 && i < mismatches.length; i++) { - const m = mismatches[i]; - console.log(`\n Joint[${m.jointIndex}] "${m.name}":`); - console.log(` node.rotation: [${m.nodeRot.map(v => v.toFixed(4)).join(', ')}]`); - console.log(` anim.rotation: [${m.animRot.map(v => v.toFixed(4)).join(', ')}]`); - } - } else { - console.log(`\n✅ All rotations match at t=0!`); - } - } - - console.log('\nDone!'); -} - -main().catch(console.error); diff --git a/scripts/test-animation-times.mjs b/scripts/test-animation-times.mjs deleted file mode 100644 index f42cbf83..00000000 --- a/scripts/test-animation-times.mjs +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Test Animation at Different Times - * 测试不同时间点的动画 - * - * Verify animation is producing different bone matrices at different times - */ - -import { readFileSync } from 'fs'; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; -console.log(`Testing animation at different times: ${filePath}\n`); - -// Matrix math utilities -function createTransformMatrix(position, rotation, scale) { - const [qx, qy, qz, qw] = rotation; - const [sx, sy, sz] = scale; - const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; - const yy = qy * qy, yz = qy * qz, yw = qy * qw; - const zz = qz * qz, zw = qz * qw; - - return new Float32Array([ - (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, - 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, - 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, - position[0], position[1], position[2], 1 - ]); -} - -function multiplyMatrices(a, b) { - const result = new Float32Array(16); - for (let row = 0; row < 4; row++) { - for (let col = 0; col < 4; col++) { - let sum = 0; - for (let k = 0; k < 4; k++) { - sum += a[row + k * 4] * b[k + col * 4]; - } - result[row + col * 4] = sum; - } - } - return result; -} - -function identity() { - return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); -} - -function slerpQuaternion(q1, q2, t) { - let dot = q1[0] * q2[0] + q1[1] * q2[1] + q1[2] * q2[2] + q1[3] * q2[3]; - if (dot < 0) { - q2 = [-q2[0], -q2[1], -q2[2], -q2[3]]; - dot = -dot; - } - if (dot > 0.9995) { - const result = [ - q1[0] + t * (q2[0] - q1[0]), - q1[1] + t * (q2[1] - q1[1]), - q1[2] + t * (q2[2] - q1[2]), - q1[3] + t * (q2[3] - q1[3]) - ]; - const len = Math.sqrt(result[0] * result[0] + result[1] * result[1] + result[2] * result[2] + result[3] * result[3]); - return [result[0] / len, result[1] / len, result[2] / len, result[3] / len]; - } - const theta0 = Math.acos(dot); - const theta = theta0 * t; - const sinTheta = Math.sin(theta); - const sinTheta0 = Math.sin(theta0); - const s0 = Math.cos(theta) - dot * sinTheta / sinTheta0; - const s1 = sinTheta / sinTheta0; - return [ - s0 * q1[0] + s1 * q2[0], - s0 * q1[1] + s1 * q2[1], - s0 * q1[2] + s1 * q2[2], - s0 * q1[3] + s1 * q2[3] - ]; -} - -function sampleSampler(sampler, time, path) { - const input = sampler.input; - const output = sampler.output; - if (!input || !output || input.length === 0) return null; - - const minTime = input[0]; - const maxTime = input[input.length - 1]; - time = Math.max(minTime, Math.min(maxTime, time)); - - let i0 = 0; - for (let i = 0; i < input.length - 1; i++) { - if (time >= input[i] && time <= input[i + 1]) { - i0 = i; - break; - } - if (time < input[i]) break; - i0 = i; - } - const i1 = Math.min(i0 + 1, input.length - 1); - - const t0 = input[i0]; - const t1 = input[i1]; - const t = t1 > t0 ? (time - t0) / (t1 - t0) : 0; - - const componentCount = path === 'rotation' ? 4 : 3; - - if (path === 'rotation') { - const q0 = [output[i0 * 4], output[i0 * 4 + 1], output[i0 * 4 + 2], output[i0 * 4 + 3]]; - const q1 = [output[i1 * 4], output[i1 * 4 + 1], output[i1 * 4 + 2], output[i1 * 4 + 3]]; - return slerpQuaternion(q0, q1, t); - } - - const result = []; - for (let c = 0; c < componentCount; c++) { - const v0 = output[i0 * componentCount + c]; - const v1 = output[i1 * componentCount + c]; - result.push(v0 + (v1 - v0) * t); - } - return result; -} - -function sampleAnimation(clip, time, nodes) { - const nodeTransforms = new Map(); - for (const channel of clip.channels) { - const sampler = clip.samplers[channel.samplerIndex]; - if (!sampler) continue; - - const nodeIndex = channel.target.nodeIndex; - const path = channel.target.path; - const value = sampleSampler(sampler, time, path); - if (!value) continue; - - if (!nodeTransforms.has(nodeIndex)) { - nodeTransforms.set(nodeIndex, {}); - } - const t = nodeTransforms.get(nodeIndex); - if (path === 'translation') t.position = value; - else if (path === 'rotation') t.rotation = value; - else if (path === 'scale') t.scale = value; - } - return nodeTransforms; -} - -function calculateBoneMatrices(skeleton, nodes, animTransforms) { - const { joints } = skeleton; - const boneCount = joints.length; - const localMatrices = new Array(boneCount); - const worldMatrices = new Array(boneCount); - const skinMatrices = new Array(boneCount); - - const processed = new Set(); - const processingOrder = []; - - function addJoint(jointIndex) { - if (processed.has(jointIndex)) return; - const joint = joints[jointIndex]; - if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { - addJoint(joint.parentIndex); - } - processingOrder.push(jointIndex); - processed.add(jointIndex); - } - - for (let i = 0; i < boneCount; i++) addJoint(i); - - for (const jointIndex of processingOrder) { - const joint = joints[jointIndex]; - const node = nodes[joint.nodeIndex]; - - if (!node) { - localMatrices[jointIndex] = identity(); - worldMatrices[jointIndex] = identity(); - skinMatrices[jointIndex] = identity(); - continue; - } - - const animTransform = animTransforms.get(joint.nodeIndex); - const pos = animTransform?.position || node.transform.position; - const rot = animTransform?.rotation || node.transform.rotation; - const scl = animTransform?.scale || node.transform.scale; - - localMatrices[jointIndex] = createTransformMatrix(pos, rot, scl); - - if (joint.parentIndex >= 0) { - worldMatrices[jointIndex] = multiplyMatrices( - worldMatrices[joint.parentIndex], - localMatrices[jointIndex] - ); - } else { - worldMatrices[jointIndex] = localMatrices[jointIndex]; - } - - skinMatrices[jointIndex] = multiplyMatrices( - worldMatrices[jointIndex], - joint.inverseBindMatrix - ); - } - - return skinMatrices; -} - -function matrixDifference(a, b) { - let maxDiff = 0; - for (let i = 0; i < 16; i++) { - maxDiff = Math.max(maxDiff, Math.abs(a[i] - b[i])); - } - return maxDiff; -} - -async function main() { - const { FBXLoader } = await import('../packages/asset-system/dist/index.js'); - - const binaryData = readFileSync(filePath); - const loader = new FBXLoader(); - - const context = { - metadata: { - path: filePath, - name: filePath.split(/[\\/]/).pop(), - type: 'model/fbx', - guid: '', - size: binaryData.length, - hash: '', - dependencies: [], - lastModified: Date.now(), - importerVersion: '1.0.0', - labels: [], - tags: [], - version: 1 - }, - loadDependency: async () => null - }; - - const content = { - type: 'binary', - binary: binaryData.buffer - }; - - const asset = await loader.parse(content, context); - - if (!asset.skeleton || !asset.animations?.length) { - console.log('No skeleton or animation data!'); - return; - } - - const clip = asset.animations[0]; - const nodes = asset.nodes; - const skeleton = asset.skeleton; - - console.log(`Animation: "${clip.name}", duration: ${clip.duration}s`); - console.log(`Joints: ${skeleton.joints.length}`); - - // Test at different times - const times = [0, clip.duration * 0.25, clip.duration * 0.5, clip.duration * 0.75, clip.duration]; - - let prevMatrices = null; - for (const time of times) { - const animTransforms = sampleAnimation(clip, time, nodes); - const skinMatrices = calculateBoneMatrices(skeleton, nodes, animTransforms); - - if (prevMatrices) { - // Count how many bones changed - let changedCount = 0; - let maxChange = 0; - for (let i = 0; i < skinMatrices.length; i++) { - const diff = matrixDifference(skinMatrices[i], prevMatrices[i]); - if (diff > 0.001) { - changedCount++; - maxChange = Math.max(maxChange, diff); - } - } - console.log(`t=${time.toFixed(2)}s: ${changedCount}/${skinMatrices.length} bones changed, maxChange=${maxChange.toFixed(4)}`); - } else { - // Check identity at t=0 - let identityCount = 0; - const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - for (const m of skinMatrices) { - let isId = true; - for (let i = 0; i < 16; i++) { - if (Math.abs(m[i] - id[i]) > 0.01) { - isId = false; - break; - } - } - if (isId) identityCount++; - } - console.log(`t=${time.toFixed(2)}s (bind pose): ${identityCount}/${skinMatrices.length} identity matrices`); - } - - prevMatrices = skinMatrices.map(m => new Float32Array(m)); - } - - // Show specific bone at different times - const testJointIndex = 5; // Pick a bone that should animate - const joint = skeleton.joints[testJointIndex]; - console.log(`\n=== Joint[${testJointIndex}] "${joint.name}" at different times ===`); - - for (const time of times) { - const animTransforms = sampleAnimation(clip, time, nodes); - const nodeTransform = animTransforms.get(joint.nodeIndex); - - if (nodeTransform?.rotation) { - const rot = nodeTransform.rotation; - console.log(`t=${time.toFixed(2)}s: rotation=[${rot.map(v => v.toFixed(4)).join(', ')}]`); - } else { - console.log(`t=${time.toFixed(2)}s: using node.transform (no animation data)`); - } - } - - console.log('\nDone!'); -} - -main().catch(console.error); diff --git a/scripts/test-fbx-animation.mjs b/scripts/test-fbx-animation.mjs deleted file mode 100644 index 50296027..00000000 --- a/scripts/test-fbx-animation.mjs +++ /dev/null @@ -1,741 +0,0 @@ -/** - * FBX Animation Pipeline Test Script - * 完整模拟 FBX 动画管线:解析 -> 采样 -> 骨骼矩阵计算 - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; -console.log(`=== FBX Animation Pipeline Test ===\n`); -console.log(`File: ${filePath}\n`); - -// ===== FBX Parser ===== -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; -let offset = 27; - -function readNode() { - let endOffset, numProperties, propertyListLen, nameLen; - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset++]); - switch (typeCode) { - case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; - case 'C': properties.push(buffer[offset++] !== 0); break; - case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; - case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; - case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; - case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; - case 'S': case 'R': { - const len = view.getUint32(offset, true); offset += 4; - properties.push(typeCode === 'S' ? new TextDecoder().decode(buffer.slice(offset, offset + len)) : buffer.slice(offset, offset + len)); - offset += len; - break; - } - case 'f': case 'd': case 'l': case 'i': case 'b': { - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - let dataView = view; - let dataOffset = offset; - if (encoding === 1) { - const decompressed = pako.inflate(buffer.slice(offset, offset + compressedLen)); - dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - dataOffset = 0; - offset += compressedLen; - } else { - offset += arrayLen * elemSize; - } - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(dataOffset + i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(dataOffset + i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(dataOffset + i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(dataOffset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - break; - } - default: offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0]?.split?.('\0')[0] || c.properties[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// Parse Models -const models = []; -const modelIdToIndex = new Map(); -for (const node of objectsNode.children) { - if (node.name === 'Model') { - const id = node.properties[0]; - const name = node.properties[1]?.split?.('\0')[0] || 'Model'; - const type = node.properties[2]?.split?.('\0')[0] || ''; - - // Parse properties - let position = [0, 0, 0], rotation = [0, 0, 0], scale = [1, 1, 1], preRotation = null; - const props70 = node.children.find(c => c.name === 'Properties70'); - if (props70) { - for (const p of props70.children) { - if (p.name === 'P') { - const propName = p.properties[0]?.split?.('\0')[0]; - if (propName === 'Lcl Translation') position = [p.properties[4], p.properties[5], p.properties[6]]; - else if (propName === 'Lcl Rotation') rotation = [p.properties[4], p.properties[5], p.properties[6]]; - else if (propName === 'Lcl Scaling') scale = [p.properties[4], p.properties[5], p.properties[6]]; - else if (propName === 'PreRotation') preRotation = [p.properties[4], p.properties[5], p.properties[6]]; - } - } - } - - modelIdToIndex.set(id, models.length); - models.push({ id, name, type, position, rotation, scale, preRotation }); - } -} - -// Parse Deformers (Clusters) -const clusters = []; -for (const node of objectsNode.children) { - if (node.name === 'Deformer' && node.properties[2]?.split?.('\0')[0] === 'Cluster') { - const id = node.properties[0]; - const name = node.properties[1]?.split?.('\0')[0] || 'Cluster'; - let transformLink = null; - for (const child of node.children) { - if (child.name === 'TransformLink') { - const arr = child.properties[0]?.data || child.properties[0]; - if (arr && arr.length === 16) { - transformLink = new Float32Array(arr); - } - } - } - clusters.push({ id, name, transformLink }); - } -} - -// Build cluster -> bone mapping -// In FBX, Model (bone) -> Cluster connection means the cluster deforms that bone -const clusterToBone = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - // Try: cluster is fromId, bone is toId - let cluster = clusters.find(c => c.id === conn.fromId); - let boneModel = cluster ? models.find(m => m.id === conn.toId) : null; - - // Also try: bone is fromId, cluster is toId (reversed) - if (!cluster || !boneModel) { - cluster = clusters.find(c => c.id === conn.toId); - boneModel = cluster ? models.find(m => m.id === conn.fromId) : null; - } - - if (cluster && boneModel && boneModel.type === 'LimbNode') { - clusterToBone.set(cluster.id, { - clusterId: cluster.id, - boneModelId: boneModel.id, - boneModelIndex: modelIdToIndex.get(boneModel.id), - boneName: boneModel.name - }); - } - } -} -console.log(`Cluster -> Bone mappings: ${clusterToBone.size}`); -if (clusterToBone.size === 0) { - console.log(`WARNING: No cluster-bone mappings found! Checking connection types...`); - // Debug: show some cluster-related connections - let count = 0; - for (const conn of connections) { - const isClusterFrom = clusters.some(c => c.id === conn.fromId); - const isClusterTo = clusters.some(c => c.id === conn.toId); - if (isClusterFrom || isClusterTo) { - if (count++ < 10) { - console.log(` [${conn.type}] ${conn.fromId} -> ${conn.toId} (prop: ${conn.property || 'none'})`); - } - } - } -} - -// Parse AnimationCurveNodes and Curves -const curveNodes = new Map(); -const curves = new Map(); - -for (const node of objectsNode.children) { - if (node.name === 'AnimationCurveNode') { - const id = node.properties[0]; - const name = node.properties[1]?.split?.('\0')[0] || ''; - curveNodes.set(id, { id, name, attribute: name, targetModelId: null, curves: [] }); - } - if (node.name === 'AnimationCurve') { - const id = node.properties[0]; - let keyTimes = [], keyValues = []; - for (const child of node.children) { - if (child.name === 'KeyTime') { - const arr = child.properties[0]?.data || child.properties[0]; - keyTimes = arr.map(t => Number(t) / 46186158000); - } - if (child.name === 'KeyValueFloat') { - keyValues = child.properties[0]?.data || child.properties[0]; - } - } - curves.set(id, { id, keyTimes, keyValues, componentIndex: 0 }); - } -} - -// Link curves to curveNodes and curveNodes to models -for (const conn of connections) { - if (conn.type === 'OP') { - const curveNode = curveNodes.get(conn.fromId); - if (curveNode && conn.property?.includes('Lcl')) { - curveNode.targetModelId = conn.toId; - if (conn.property.includes('Translation')) curveNode.attribute = 'T'; - else if (conn.property.includes('Rotation')) curveNode.attribute = 'R'; - else if (conn.property.includes('Scaling')) curveNode.attribute = 'S'; - } - } - if (conn.type === 'OP' || conn.type === 'OO') { - const curve = curves.get(conn.fromId); - const curveNode = curveNodes.get(conn.toId); - if (curve && curveNode) { - if (conn.property === 'd|X') curve.componentIndex = 0; - else if (conn.property === 'd|Y') curve.componentIndex = 1; - else if (conn.property === 'd|Z') curve.componentIndex = 2; - curveNode.curves.push(curve); - } - } -} - -// ===== Build Animation Clips ===== -function eulerToQuaternion(rx, ry, rz) { - const cx = Math.cos(rx / 2), sx = Math.sin(rx / 2); - const cy = Math.cos(ry / 2), sy = Math.sin(ry / 2); - const cz = Math.cos(rz / 2), sz = Math.sin(rz / 2); - return [ - sx * cy * cz - cx * sy * sz, - cx * sy * cz + sx * cy * sz, - cx * cy * sz - sx * sy * cz, - cx * cy * cz + sx * sy * sz - ]; -} - -function multiplyQuaternion(a, b) { - return [ - a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], - a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], - a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], - a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - ]; -} - -function sampleCurve(curve, time) { - const { keyTimes, keyValues } = curve; - if (!keyTimes.length) return 0; - if (time <= keyTimes[0]) return keyValues[0]; - if (time >= keyTimes[keyTimes.length - 1]) return keyValues[keyValues.length - 1]; - - for (let i = 0; i < keyTimes.length - 1; i++) { - if (time >= keyTimes[i] && time <= keyTimes[i + 1]) { - const t = (time - keyTimes[i]) / (keyTimes[i + 1] - keyTimes[i]); - return keyValues[i] + (keyValues[i + 1] - keyValues[i]) * t; - } - } - return keyValues[keyValues.length - 1]; -} - -// Build animation samplers -const animationSamplers = []; -const animationChannels = []; - -for (const [id, cn] of curveNodes) { - if (!cn.targetModelId || cn.curves.length === 0) continue; - - const nodeIndex = modelIdToIndex.get(cn.targetModelId); - if (nodeIndex === undefined) continue; - - const xCurve = cn.curves.find(c => c.componentIndex === 0); - const yCurve = cn.curves.find(c => c.componentIndex === 1); - const zCurve = cn.curves.find(c => c.componentIndex === 2); - - const refCurve = [xCurve, yCurve, zCurve].filter(Boolean).reduce((a, b) => - a.keyTimes.length > b.keyTimes.length ? a : b); - - const keyCount = refCurve.keyTimes.length; - const input = refCurve.keyTimes; - - // Get model for PreRotation - const model = models[nodeIndex]; - let preRotQuat = null; - if (model?.preRotation) { - const [prx, pry, prz] = model.preRotation.map(v => v * Math.PI / 180); - preRotQuat = eulerToQuaternion(prx, pry, prz); - } - - let output, path; - if (cn.attribute === 'R') { - path = 'rotation'; - output = new Float32Array(keyCount * 4); - for (let i = 0; i < keyCount; i++) { - const t = input[i]; - const rx = (xCurve ? sampleCurve(xCurve, t) : 0) * Math.PI / 180; - const ry = (yCurve ? sampleCurve(yCurve, t) : 0) * Math.PI / 180; - const rz = (zCurve ? sampleCurve(zCurve, t) : 0) * Math.PI / 180; - let q = eulerToQuaternion(rx, ry, rz); - if (preRotQuat) q = multiplyQuaternion(preRotQuat, q); - output[i * 4] = q[0]; output[i * 4 + 1] = q[1]; - output[i * 4 + 2] = q[2]; output[i * 4 + 3] = q[3]; - } - } else if (cn.attribute === 'T') { - path = 'translation'; - output = new Float32Array(keyCount * 3); - for (let i = 0; i < keyCount; i++) { - const t = input[i]; - output[i * 3] = xCurve ? sampleCurve(xCurve, t) : 0; - output[i * 3 + 1] = yCurve ? sampleCurve(yCurve, t) : 0; - output[i * 3 + 2] = zCurve ? sampleCurve(zCurve, t) : 0; - } - } else { - path = 'scale'; - output = new Float32Array(keyCount * 3); - for (let i = 0; i < keyCount; i++) { - const t = input[i]; - output[i * 3] = xCurve ? sampleCurve(xCurve, t) : 1; - output[i * 3 + 1] = yCurve ? sampleCurve(yCurve, t) : 1; - output[i * 3 + 2] = zCurve ? sampleCurve(zCurve, t) : 1; - } - } - - const samplerIndex = animationSamplers.length; - animationSamplers.push({ input: Float32Array.from(input), output }); - animationChannels.push({ samplerIndex, target: { nodeIndex, path } }); -} - -const duration = Math.max(...animationSamplers.map(s => s.input[s.input.length - 1] || 0)); - -console.log(`=== Animation Data ===`); -console.log(`Channels: ${animationChannels.length}`); -console.log(`Duration: ${duration.toFixed(2)}s`); - -// ===== Build Skeleton ===== -const joints = []; -const boneModelIdToJointIndex = new Map(); - -for (const cluster of clusters) { - const boneInfo = clusterToBone.get(cluster.id); - if (!boneInfo) continue; - - const nodeIndex = boneInfo.boneModelIndex; - const model = models[nodeIndex]; - - const jointIndex = joints.length; - boneModelIdToJointIndex.set(boneInfo.boneModelId, jointIndex); - - // Invert TransformLink for inverseBindMatrix - let inverseBindMatrix = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); - if (cluster.transformLink) { - inverseBindMatrix = invertMatrix4(cluster.transformLink); - } - - joints.push({ - name: model?.name || `Joint_${jointIndex}`, - nodeIndex, - parentIndex: -1, - inverseBindMatrix - }); -} - -// Set parent indices -const modelParentMap = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - const childIdx = modelIdToIndex.get(conn.fromId); - const parentIdx = modelIdToIndex.get(conn.toId); - if (childIdx !== undefined && parentIdx !== undefined) { - // fromId (child) -> toId (parent) - const childModel = models[childIdx]; - const parentModel = models[parentIdx]; - if (childModel && parentModel) { - modelParentMap.set(conn.fromId, conn.toId); - } - } - } -} - -for (let i = 0; i < joints.length; i++) { - const joint = joints[i]; - const boneModelId = [...boneModelIdToJointIndex.entries()].find(([k, v]) => v === i)?.[0]; - if (!boneModelId) continue; - - let parentModelId = modelParentMap.get(boneModelId); - while (parentModelId) { - const parentJointIdx = boneModelIdToJointIndex.get(parentModelId); - if (parentJointIdx !== undefined) { - joint.parentIndex = parentJointIdx; - break; - } - parentModelId = modelParentMap.get(parentModelId); - } -} - -console.log(`\n=== Skeleton ===`); -console.log(`Joints: ${joints.length}`); -console.log(`First 5 joints:`); -for (let i = 0; i < Math.min(5, joints.length); i++) { - const j = joints[i]; - console.log(` [${i}] "${j.name}" nodeIndex=${j.nodeIndex}, parent=${j.parentIndex}`); -} - -// Check animation channel targets vs skeleton joint nodeIndices -const animChannelNodeIndices = new Set(animationChannels.map(c => c.target.nodeIndex)); -const jointNodeIndices = new Set(joints.map(j => j.nodeIndex)); - -console.log(`\n=== Animation vs Skeleton Mapping ===`); -console.log(`Animation channel target nodes: ${animChannelNodeIndices.size}`); -console.log(`Skeleton joint nodes: ${jointNodeIndices.size}`); - -// Find intersection -const intersection = [...jointNodeIndices].filter(idx => animChannelNodeIndices.has(idx)); -console.log(`Joints with animation: ${intersection.length}/${joints.length}`); - -// Find joints without animation -const jointsWithoutAnim = joints.filter(j => !animChannelNodeIndices.has(j.nodeIndex)); -if (jointsWithoutAnim.length > 0) { - console.log(`Joints WITHOUT animation:`); - for (const j of jointsWithoutAnim.slice(0, 5)) { - console.log(` "${j.name}" nodeIndex=${j.nodeIndex}`); - } -} - -// ===== Test Animation Sampling ===== -console.log(`\n=== Animation Sampling Test ===`); - -function slerpQuaternion(q0, q1, t) { - let [x0, y0, z0, w0] = q0; - let [x1, y1, z1, w1] = q1; - let dot = x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1; - if (dot < 0) { x1 = -x1; y1 = -y1; z1 = -z1; w1 = -w1; dot = -dot; } - if (dot > 0.9995) { - const r = [x0 + t * (x1 - x0), y0 + t * (y1 - y0), z0 + t * (z1 - z0), w0 + t * (w1 - w0)]; - const len = Math.sqrt(r[0]**2 + r[1]**2 + r[2]**2 + r[3]**2); - return [r[0]/len, r[1]/len, r[2]/len, r[3]/len]; - } - const theta0 = Math.acos(dot); - const theta = theta0 * t; - const sinTheta = Math.sin(theta); - const sinTheta0 = Math.sin(theta0); - const s0 = Math.cos(theta) - dot * sinTheta / sinTheta0; - const s1 = sinTheta / sinTheta0; - return [s0*x0 + s1*x1, s0*y0 + s1*y1, s0*z0 + s1*z1, s0*w0 + s1*w1]; -} - -function sampleAnimation(time) { - const transforms = new Map(); - for (const channel of animationChannels) { - const sampler = animationSamplers[channel.samplerIndex]; - const { input, output } = sampler; - const { nodeIndex, path } = channel.target; - - // Find keyframes - let i0 = 0; - for (let i = 0; i < input.length - 1; i++) { - if (time >= input[i] && time <= input[i + 1]) { i0 = i; break; } - if (time < input[i]) break; - i0 = i; - } - const i1 = Math.min(i0 + 1, input.length - 1); - const t = input[i1] > input[i0] ? (time - input[i0]) / (input[i1] - input[i0]) : 0; - - let value; - if (path === 'rotation') { - const q0 = [output[i0*4], output[i0*4+1], output[i0*4+2], output[i0*4+3]]; - const q1 = [output[i1*4], output[i1*4+1], output[i1*4+2], output[i1*4+3]]; - value = slerpQuaternion(q0, q1, t); - } else { - const count = path === 'rotation' ? 4 : 3; - value = []; - for (let c = 0; c < count; c++) { - value.push(output[i0 * count + c] + (output[i1 * count + c] - output[i0 * count + c]) * t); - } - } - - if (!transforms.has(nodeIndex)) transforms.set(nodeIndex, {}); - transforms.get(nodeIndex)[path] = value; - } - return transforms; -} - -// Check animation data at different times -const testTimes = [0, 0.5, 1.0, 1.5, 2.0]; -for (const time of testTimes) { - const transforms = sampleAnimation(time); - - // Count how many joints have animation - let matchCount = 0; - for (const joint of joints) { - if (transforms.has(joint.nodeIndex)) matchCount++; - } - - console.log(`\nt=${time.toFixed(1)}s: ${transforms.size} node transforms, ${matchCount}/${joints.length} joints have animation`); - - // Sample first 3 joints - for (let i = 0; i < Math.min(3, joints.length); i++) { - const j = joints[i]; - const t = transforms.get(j.nodeIndex); - if (t) { - const pos = t.translation ? `[${t.translation.map(v => v.toFixed(2)).join(',')}]` : 'none'; - const rot = t.rotation ? `[${t.rotation.map(v => v.toFixed(3)).join(',')}]` : 'none'; - console.log(` Joint[${i}] "${j.name}": pos=${pos} rot=${rot}`); - } else { - console.log(` Joint[${i}] "${j.name}": NO ANIMATION DATA`); - } - } -} - -// ===== Check if animation changes over time ===== -console.log(`\n=== Animation Value Changes ===`); - -// Find a rotation channel and check value changes -const rotChannels = animationChannels.filter(c => c.target.path === 'rotation'); -console.log(`Rotation channels: ${rotChannels.length}`); - -if (rotChannels.length > 0) { - // Find one with varying values - for (const ch of rotChannels.slice(0, 5)) { - const sampler = animationSamplers[ch.samplerIndex]; - const firstQ = [sampler.output[0], sampler.output[1], sampler.output[2], sampler.output[3]]; - const lastQ = [ - sampler.output[(sampler.input.length-1)*4], - sampler.output[(sampler.input.length-1)*4+1], - sampler.output[(sampler.input.length-1)*4+2], - sampler.output[(sampler.input.length-1)*4+3] - ]; - const diff = Math.abs(firstQ[0]-lastQ[0]) + Math.abs(firstQ[1]-lastQ[1]) + - Math.abs(firstQ[2]-lastQ[2]) + Math.abs(firstQ[3]-lastQ[3]); - const nodeIdx = ch.target.nodeIndex; - const model = models[nodeIdx]; - console.log(` Node[${nodeIdx}] "${model?.name}": ${sampler.input.length} keyframes, diff=${diff.toFixed(4)}`); - if (diff > 0.01) { - console.log(` First: [${firstQ.map(v=>v.toFixed(4)).join(', ')}]`); - console.log(` Last: [${lastQ.map(v=>v.toFixed(4)).join(', ')}]`); - } - } -} - -// ===== Calculate Bone Matrices ===== -console.log(`\n=== Bone Matrix Test ===`); - -// Test at t=0 (should be bind pose - identity matrices) -// 在 t=0 测试(应该是绑定姿势 - 单位矩阵) - -function createTransformMatrix(pos, rot, scale) { - const [qx, qy, qz, qw] = rot; - const [sx, sy, sz] = scale; - const xx = qx*qx, xy = qx*qy, xz = qx*qz, xw = qx*qw; - const yy = qy*qy, yz = qy*qz, yw = qy*qw; - const zz = qz*qz, zw = qz*qw; - return new Float32Array([ - (1 - 2*(yy+zz))*sx, 2*(xy+zw)*sx, 2*(xz-yw)*sx, 0, - 2*(xy-zw)*sy, (1 - 2*(xx+zz))*sy, 2*(yz+xw)*sy, 0, - 2*(xz+yw)*sz, 2*(yz-xw)*sz, (1 - 2*(xx+yy))*sz, 0, - pos[0], pos[1], pos[2], 1 - ]); -} - -function multiplyMatrices(a, b) { - const result = new Float32Array(16); - for (let row = 0; row < 4; row++) { - for (let col = 0; col < 4; col++) { - let sum = 0; - for (let k = 0; k < 4; k++) { - sum += a[row + k * 4] * b[k + col * 4]; - } - result[row + col * 4] = sum; - } - } - return result; -} - -function invertMatrix4(m) { - const out = new Float32Array(16); - const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3]; - const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7]; - const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11]; - const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15]; - - const b00 = m00*m11 - m01*m10, b01 = m00*m12 - m02*m10; - const b02 = m00*m13 - m03*m10, b03 = m01*m12 - m02*m11; - const b04 = m01*m13 - m03*m11, b05 = m02*m13 - m03*m12; - const b06 = m20*m31 - m21*m30, b07 = m20*m32 - m22*m30; - const b08 = m20*m33 - m23*m30, b09 = m21*m32 - m22*m31; - const b10 = m21*m33 - m23*m31, b11 = m22*m33 - m23*m32; - - let det = b00*b11 - b01*b10 + b02*b09 + b03*b08 - b04*b07 + b05*b06; - if (!det) return out; - det = 1.0 / det; - - out[0] = (m11*b11 - m12*b10 + m13*b09) * det; - out[1] = (m02*b10 - m01*b11 - m03*b09) * det; - out[2] = (m31*b05 - m32*b04 + m33*b03) * det; - out[3] = (m22*b04 - m21*b05 - m23*b03) * det; - out[4] = (m12*b08 - m10*b11 - m13*b07) * det; - out[5] = (m00*b11 - m02*b08 + m03*b07) * det; - out[6] = (m32*b02 - m30*b05 - m33*b01) * det; - out[7] = (m20*b05 - m22*b02 + m23*b01) * det; - out[8] = (m10*b10 - m11*b08 + m13*b06) * det; - out[9] = (m01*b08 - m00*b10 - m03*b06) * det; - out[10] = (m30*b04 - m31*b02 + m33*b00) * det; - out[11] = (m21*b02 - m20*b04 - m23*b00) * det; - out[12] = (m11*b07 - m10*b09 - m12*b06) * det; - out[13] = (m00*b09 - m01*b07 + m02*b06) * det; - out[14] = (m31*b01 - m30*b03 - m32*b00) * det; - out[15] = (m20*b03 - m21*b01 + m22*b00) * det; - return out; -} - -// Test multiple times including t=0 (bind pose) -const testTimesForMatrix = [0, 1.0, 7.5]; - -// Build node default transforms from models -const nodeTransforms = []; -for (const model of models) { - const rx = model.rotation[0] * Math.PI / 180; - const ry = model.rotation[1] * Math.PI / 180; - const rz = model.rotation[2] * Math.PI / 180; - let quat = eulerToQuaternion(rx, ry, rz); - if (model.preRotation) { - const prx = model.preRotation[0] * Math.PI / 180; - const pry = model.preRotation[1] * Math.PI / 180; - const prz = model.preRotation[2] * Math.PI / 180; - const preQuat = eulerToQuaternion(prx, pry, prz); - quat = multiplyQuaternion(preQuat, quat); - } - nodeTransforms.push({ - position: model.position, - rotation: quat, - scale: model.scale - }); -} - -// Calculate bone matrices for different times -function calculateBoneMatrices(time) { - const transforms = sampleAnimation(time); - const localMatrices = [], worldMatrices = [], skinMatrices = []; - const processed = new Set(); - const processingOrder = []; - - function addJoint(idx) { - if (processed.has(idx)) return; - if (joints[idx].parentIndex >= 0 && !processed.has(joints[idx].parentIndex)) { - addJoint(joints[idx].parentIndex); - } - processingOrder.push(idx); - processed.add(idx); - } - for (let i = 0; i < joints.length; i++) addJoint(i); - - for (const jointIdx of processingOrder) { - const joint = joints[jointIdx]; - const nodeIdx = joint.nodeIndex; - const node = nodeTransforms[nodeIdx]; - - // Get animated or default transform - const animT = transforms.get(nodeIdx); - const pos = animT?.translation || node.position; - const rot = animT?.rotation || node.rotation; - const scl = animT?.scale || node.scale; - - localMatrices[jointIdx] = createTransformMatrix(pos, rot, scl); - - if (joint.parentIndex >= 0) { - worldMatrices[jointIdx] = multiplyMatrices(worldMatrices[joint.parentIndex], localMatrices[jointIdx]); - } else { - worldMatrices[jointIdx] = localMatrices[jointIdx]; - } - - skinMatrices[jointIdx] = multiplyMatrices(worldMatrices[jointIdx], joint.inverseBindMatrix); - } - - return skinMatrices; -} - -// Test at multiple times -for (const time of testTimesForMatrix) { - console.log(`\n--- t=${time.toFixed(1)}s ---`); - const skinMatrices = calculateBoneMatrices(time); - - // Check skin matrices - how many are NOT identity? - let nonIdentityCount = 0; - let maxDiff = 0; - for (let i = 0; i < skinMatrices.length; i++) { - const m = skinMatrices[i]; - const diff = Math.abs(m[0]-1) + Math.abs(m[5]-1) + Math.abs(m[10]-1) + Math.abs(m[15]-1) + - Math.abs(m[1]) + Math.abs(m[2]) + Math.abs(m[3]) + - Math.abs(m[4]) + Math.abs(m[6]) + Math.abs(m[7]) + - Math.abs(m[8]) + Math.abs(m[9]) + Math.abs(m[11]) + - Math.abs(m[12]) + Math.abs(m[13]) + Math.abs(m[14]); - if (diff > 0.001) { - nonIdentityCount++; - if (diff > maxDiff) maxDiff = diff; - } - } - - console.log(` Non-identity: ${nonIdentityCount}/${skinMatrices.length}, max diff: ${maxDiff.toFixed(4)}`); - if (time === 0) { - console.log(` (t=0 should have mostly identity matrices if bind pose is correct)`); - } - - // Show first 3 skin matrices - for (let i = 0; i < Math.min(3, skinMatrices.length); i++) { - const m = skinMatrices[i]; - console.log(` Joint[${i}] "${joints[i].name}":`); - console.log(` diagonal: [${m[0].toFixed(4)}, ${m[5].toFixed(4)}, ${m[10].toFixed(4)}, ${m[15].toFixed(4)}]`); - console.log(` translation: [${m[12].toFixed(4)}, ${m[13].toFixed(4)}, ${m[14].toFixed(4)}]`); - } -} - -console.log(`\n=== Done ===`); diff --git a/scripts/test-fbxloader-bindpose.mjs b/scripts/test-fbxloader-bindpose.mjs deleted file mode 100644 index dfb7c686..00000000 --- a/scripts/test-fbxloader-bindpose.mjs +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Test FBXLoader Bind Pose - * 测试 FBXLoader 绑定姿势 - * - * Verify: worldMatrix * inverseBindMatrix = Identity at bind pose - */ - -import { readFileSync } from 'fs'; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; -console.log(`Testing bind pose: ${filePath}\n`); - -// Matrix math utilities -function createTransformMatrix(position, rotation, scale) { - const [qx, qy, qz, qw] = rotation; - const [sx, sy, sz] = scale; - const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; - const yy = qy * qy, yz = qy * qz, yw = qy * qw; - const zz = qz * qz, zw = qz * qw; - - return new Float32Array([ - (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, - 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, - 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, - position[0], position[1], position[2], 1 - ]); -} - -function multiplyMatrices(a, b) { - const result = new Float32Array(16); - for (let row = 0; row < 4; row++) { - for (let col = 0; col < 4; col++) { - let sum = 0; - for (let k = 0; k < 4; k++) { - sum += a[row + k * 4] * b[k + col * 4]; - } - result[row + col * 4] = sum; - } - } - return result; -} - -function identity() { - return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); -} - -function isIdentity(m, tolerance = 0.01) { - const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - for (let i = 0; i < 16; i++) { - if (Math.abs(m[i] - id[i]) > tolerance) return false; - } - return true; -} - -function maxDiffFromIdentity(m) { - const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - let maxDiff = 0; - for (let i = 0; i < 16; i++) { - maxDiff = Math.max(maxDiff, Math.abs(m[i] - id[i])); - } - return maxDiff; -} - -async function main() { - const { FBXLoader } = await import('../packages/asset-system/dist/index.js'); - - const binaryData = readFileSync(filePath); - const loader = new FBXLoader(); - - const context = { - metadata: { - path: filePath, - name: filePath.split(/[\\/]/).pop(), - type: 'model/fbx', - guid: '', - size: binaryData.length, - hash: '', - dependencies: [], - lastModified: Date.now(), - importerVersion: '1.0.0', - labels: [], - tags: [], - version: 1 - }, - loadDependency: async () => null - }; - - const content = { - type: 'binary', - binary: binaryData.buffer - }; - - const asset = await loader.parse(content, context); - - if (!asset.skeleton) { - console.log('No skeleton data!'); - return; - } - - const { joints, rootJointIndex } = asset.skeleton; - const nodes = asset.nodes; - - console.log(`Skeleton: ${joints.length} joints, rootJointIndex=${rootJointIndex}`); - - // Build parent index map (node hierarchy) - const nodeParentMap = new Map(); - for (const node of nodes) { - if (node.children) { - for (const childIdx of node.children) { - nodeParentMap.set(childIdx, nodes.indexOf(node)); - } - } - } - - // Calculate world matrices for each joint using node.transform hierarchy - const worldMatrices = new Array(joints.length); - - // Processing order: root first, then children - const processed = new Set(); - const processingOrder = []; - - function addJoint(jointIndex) { - if (processed.has(jointIndex)) return; - const joint = joints[jointIndex]; - if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { - addJoint(joint.parentIndex); - } - processingOrder.push(jointIndex); - processed.add(jointIndex); - } - - for (let i = 0; i < joints.length; i++) addJoint(i); - - for (const jointIndex of processingOrder) { - const joint = joints[jointIndex]; - const node = nodes[joint.nodeIndex]; - - if (!node) { - worldMatrices[jointIndex] = identity(); - continue; - } - - const { position, rotation, scale } = node.transform; - const localMatrix = createTransformMatrix(position, rotation, scale); - - if (joint.parentIndex >= 0) { - worldMatrices[jointIndex] = multiplyMatrices( - worldMatrices[joint.parentIndex], - localMatrix - ); - } else { - worldMatrices[jointIndex] = localMatrix; - } - } - - // Calculate skin matrices and check if they are identity - let identityCount = 0; - let nonIdentityJoints = []; - - for (let i = 0; i < joints.length; i++) { - const joint = joints[i]; - const skinMatrix = multiplyMatrices(worldMatrices[i], joint.inverseBindMatrix); - - if (isIdentity(skinMatrix)) { - identityCount++; - } else { - const diff = maxDiffFromIdentity(skinMatrix); - nonIdentityJoints.push({ index: i, name: joint.name, diff, skinMatrix }); - } - } - - console.log(`\n=== BIND POSE VERIFICATION ===`); - console.log(`Identity skin matrices: ${identityCount}/${joints.length}`); - - if (nonIdentityJoints.length > 0) { - console.log(`\n❌ NOT at bind pose! ${nonIdentityJoints.length} joints have non-identity skin matrices.`); - - // Show first 3 problematic joints - nonIdentityJoints.sort((a, b) => b.diff - a.diff); - console.log(`\nTop 3 worst joints:`); - for (let i = 0; i < 3 && i < nonIdentityJoints.length; i++) { - const { index, name, diff, skinMatrix } = nonIdentityJoints[i]; - console.log(` Joint[${index}] "${name}": maxDiff=${diff.toFixed(4)}`); - console.log(` skinMatrix diagonal: [${skinMatrix[0].toFixed(2)}, ${skinMatrix[5].toFixed(2)}, ${skinMatrix[10].toFixed(2)}, ${skinMatrix[15].toFixed(2)}]`); - console.log(` skinMatrix translation: [${skinMatrix[12].toFixed(2)}, ${skinMatrix[13].toFixed(2)}, ${skinMatrix[14].toFixed(2)}]`); - } - - console.log(`\n=== ANALYSIS ===`); - console.log(`The skin matrix should be Identity at bind pose (t=0).`); - console.log(`This means: worldMatrix * inverseBindMatrix = Identity`); - console.log(`If not identity, the mesh will appear deformed at rest.`); - } else { - console.log(`\n✅ All skin matrices are identity at bind pose!`); - } - - console.log('\nDone!'); -} - -main().catch(console.error); diff --git a/scripts/test-full-pipeline.mjs b/scripts/test-full-pipeline.mjs deleted file mode 100644 index 2840e15f..00000000 --- a/scripts/test-full-pipeline.mjs +++ /dev/null @@ -1,806 +0,0 @@ -/** - * Test Full Animation Pipeline - * 测试完整的动画管道 - * - * This script exactly mimics what ModelPreview3D does: - * 1. Parse FBX data (like FBXLoader) - * 2. Sample animation (like sampleAnimation) - * 3. Calculate bone matrices (like calculateBoneMatrices) - * 4. Output visual verification data - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const FBX_TIME_SECOND = 46186158000n; -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Full Pipeline Test: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - const startOffset = offset; - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': - properties.push(view.getInt16(offset, true)); - offset += 2; - break; - case 'C': - properties.push(buffer[offset] !== 0); - offset += 1; - break; - case 'I': - properties.push(view.getInt32(offset, true)); - offset += 4; - break; - case 'F': - properties.push(view.getFloat32(offset, true)); - offset += 4; - break; - case 'D': - properties.push(view.getFloat64(offset, true)); - offset += 8; - break; - case 'L': - properties.push(view.getBigInt64(offset, true)); - offset += 8; - break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); - offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': - case 'd': - case 'l': - case 'i': - case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// ============ STEP 1: Parse Models (like FBXLoader) ============ - -// FBX uses XYZ Euler order (same as test-fbx-animation.mjs) -// FBX 使用 XYZ 欧拉角顺序 -function eulerToQuaternion(rx, ry, rz) { - const cx = Math.cos(rx / 2), sx = Math.sin(rx / 2); - const cy = Math.cos(ry / 2), sy = Math.sin(ry / 2); - const cz = Math.cos(rz / 2), sz = Math.sin(rz / 2); - return [ - sx * cy * cz - cx * sy * sz, - cx * sy * cz + sx * cy * sz, - cx * cy * sz - sx * sy * cz, - cx * cy * cz + sx * sy * sz - ]; -} - -function multiplyQuaternion(a, b) { - return [ - a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], - a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], - a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], - a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - ]; -} - -const modelNodes = objectsNode.children.filter(n => n.name === 'Model'); -const models = modelNodes.map(n => { - const id = n.properties[0]; - const name = n.properties[1]?.split?.('\0')[0] || 'Model'; - const type = n.properties[2]?.split?.('\0')[0] || ''; - - let position = [0, 0, 0]; - let rotation = [0, 0, 0]; - let scale = [1, 1, 1]; - let preRotation = null; - - // Parse Properties70 - const props = n.children.find(c => c.name === 'Properties70'); - if (props) { - for (const p of props.children) { - if (p.name === 'P') { - const propName = p.properties[0]?.split?.('\0')[0]; - if (propName === 'Lcl Translation') { - position = [p.properties[4], p.properties[5], p.properties[6]]; - } else if (propName === 'Lcl Rotation') { - rotation = [p.properties[4], p.properties[5], p.properties[6]]; - } else if (propName === 'Lcl Scaling') { - scale = [p.properties[4], p.properties[5], p.properties[6]]; - } else if (propName === 'PreRotation') { - preRotation = [p.properties[4], p.properties[5], p.properties[6]]; - } - } - } - } - - return { id, name, type, position, rotation, scale, preRotation }; -}); - -const modelToIndex = new Map(); -models.forEach((m, i) => modelToIndex.set(m.id, i)); - -// Build nodes array (like FBXLoader line 244) -const nodes = models.map(model => { - let quat; - if (model.preRotation) { - const preRx = model.preRotation[0] * Math.PI / 180; - const preRy = model.preRotation[1] * Math.PI / 180; - const preRz = model.preRotation[2] * Math.PI / 180; - const preQuat = eulerToQuaternion(preRx, preRy, preRz); - - const rx = model.rotation[0] * Math.PI / 180; - const ry = model.rotation[1] * Math.PI / 180; - const rz = model.rotation[2] * Math.PI / 180; - const lclQuat = eulerToQuaternion(rx, ry, rz); - - quat = multiplyQuaternion(preQuat, lclQuat); - } else { - const rx = model.rotation[0] * Math.PI / 180; - const ry = model.rotation[1] * Math.PI / 180; - const rz = model.rotation[2] * Math.PI / 180; - quat = eulerToQuaternion(rx, ry, rz); - } - - return { - name: model.name, - children: [], - transform: { - position: model.position, - rotation: quat, - scale: model.scale - } - }; -}); - -// Build parent-child relationships -for (const conn of connections) { - if (conn.type === 'OO') { - const childIdx = modelToIndex.get(conn.fromId); - const parentIdx = modelToIndex.get(conn.toId); - if (childIdx !== undefined && parentIdx !== undefined) { - nodes[parentIdx].children.push(childIdx); - } - } -} - -console.log(`Built ${nodes.length} nodes`); - -// ============ STEP 2: Parse Clusters and Build Skeleton ============ - -const clusterNodes = objectsNode.children.filter(n => - n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster' -); - -const clusters = clusterNodes.map(n => { - const id = n.properties[0]; - const name = n.properties[1]?.split?.('\0')[0] || 'Cluster'; - let transformLink = null; - - for (const child of n.children) { - if (child.name === 'TransformLink' && child.properties[0]?.data?.length === 16) { - // Store as Float32Array directly (like FBXLoader) - transformLink = new Float32Array(child.properties[0].data); - } - } - - return { id, name, transformLink }; -}); - -// Find cluster to bone connections -const clusterToBone = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - const cluster = clusters.find(c => c.id === conn.toId); - if (cluster) { - clusterToBone.set(cluster.id, conn.fromId); - } - } -} - -// Build skeleton (like FBXLoader buildSkeletonData) -function invertMatrix4(m) { - const out = new Float32Array(16); - const m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3]; - const m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7]; - const m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11]; - const m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15]; - - const b00 = m00 * m11 - m01 * m10; - const b01 = m00 * m12 - m02 * m10; - const b02 = m00 * m13 - m03 * m10; - const b03 = m01 * m12 - m02 * m11; - const b04 = m01 * m13 - m03 * m11; - const b05 = m02 * m13 - m03 * m12; - const b06 = m20 * m31 - m21 * m30; - const b07 = m20 * m32 - m22 * m30; - const b08 = m20 * m33 - m23 * m30; - const b09 = m21 * m32 - m22 * m31; - const b10 = m21 * m33 - m23 * m31; - const b11 = m22 * m33 - m23 * m32; - - let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; - if (Math.abs(det) < 1e-8) { - return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); - } - det = 1.0 / det; - - out[0] = (m11 * b11 - m12 * b10 + m13 * b09) * det; - out[1] = (m02 * b10 - m01 * b11 - m03 * b09) * det; - out[2] = (m31 * b05 - m32 * b04 + m33 * b03) * det; - out[3] = (m22 * b04 - m21 * b05 - m23 * b03) * det; - out[4] = (m12 * b08 - m10 * b11 - m13 * b07) * det; - out[5] = (m00 * b11 - m02 * b08 + m03 * b07) * det; - out[6] = (m32 * b02 - m30 * b05 - m33 * b01) * det; - out[7] = (m20 * b05 - m22 * b02 + m23 * b01) * det; - out[8] = (m10 * b10 - m11 * b08 + m13 * b06) * det; - out[9] = (m01 * b08 - m00 * b10 - m03 * b06) * det; - out[10] = (m30 * b04 - m31 * b02 + m33 * b00) * det; - out[11] = (m21 * b02 - m20 * b04 - m23 * b00) * det; - out[12] = (m11 * b07 - m10 * b09 - m12 * b06) * det; - out[13] = (m00 * b09 - m01 * b07 + m02 * b06) * det; - out[14] = (m31 * b01 - m30 * b03 - m32 * b00) * det; - out[15] = (m20 * b03 - m21 * b01 + m22 * b00) * det; - - return out; -} - -const joints = []; -const boneModelIdToJointIndex = new Map(); -const modelParentMap = new Map(); - -for (const conn of connections) { - if (conn.type === 'OO') { - const childModel = models.find(m => m.id === conn.fromId); - const parentModel = models.find(m => m.id === conn.toId); - if (childModel && parentModel) { - modelParentMap.set(conn.fromId, conn.toId); - } - } -} - -for (const cluster of clusters) { - const boneModelId = clusterToBone.get(cluster.id); - if (!boneModelId) continue; - - const nodeIndex = modelToIndex.get(boneModelId); - if (nodeIndex === undefined) continue; - - const model = models[nodeIndex]; - const jointIndex = joints.length; - boneModelIdToJointIndex.set(boneModelId, jointIndex); - - const inverseBindMatrix = cluster.transformLink - ? invertMatrix4(cluster.transformLink) - : new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); - - joints.push({ - name: model.name, - nodeIndex, - parentIndex: -1, - inverseBindMatrix - }); -} - -// Set parent indices -for (const cluster of clusters) { - const boneModelId = clusterToBone.get(cluster.id); - if (!boneModelId) continue; - - const jointIndex = boneModelIdToJointIndex.get(boneModelId); - if (jointIndex === undefined) continue; - - let parentModelId = modelParentMap.get(boneModelId); - while (parentModelId) { - const parentJointIndex = boneModelIdToJointIndex.get(parentModelId); - if (parentJointIndex !== undefined) { - joints[jointIndex].parentIndex = parentJointIndex; - break; - } - parentModelId = modelParentMap.get(parentModelId); - } -} - -console.log(`Built ${joints.length} skeleton joints`); - -// ============ STEP 3: Parse Animation ============ - -const animCurves = objectsNode.children.filter(n => n.name === 'AnimationCurve'); -const animCurveNodes = objectsNode.children.filter(n => n.name === 'AnimationCurveNode'); - -// Map curve ID to curve data -const curveMap = new Map(); -for (const curve of animCurves) { - const id = curve.properties[0]; - let keyTimes = null; - let keyValues = null; - - for (const child of curve.children) { - if (child.name === 'KeyTime') { - const data = child.properties[0]?.data; - if (data) { - keyTimes = data.map(t => Number(t) / Number(FBX_TIME_SECOND)); - } - } else if (child.name === 'KeyValueFloat') { - keyValues = child.properties[0]?.data; - } - } - - if (keyTimes && keyValues) { - curveMap.set(id, { keyTimes, keyValues }); - } -} - -// Build curveNode map (ID is in properties[0], not .id) -const curveNodeMap = new Map(); -for (const cn of animCurveNodes) { - curveNodeMap.set(cn.properties[0], cn); -} - -// Map curveNode to model and build animation channels -const curveNodeToModel = new Map(); -const curveNodeToCurves = new Map(); - -for (const conn of connections) { - if (conn.type === 'OP') { - if (conn.property?.includes('Lcl')) { - const curveNode = curveNodeMap.get(conn.fromId); - if (curveNode) { - curveNodeToModel.set(conn.fromId, conn.toId); - } - } else if (conn.property === 'd|X' || conn.property === 'd|Y' || conn.property === 'd|Z') { - const curveNode = curveNodeMap.get(conn.toId); - if (curveNode) { - if (!curveNodeToCurves.has(conn.toId)) { - curveNodeToCurves.set(conn.toId, { x: null, y: null, z: null }); - } - const curves = curveNodeToCurves.get(conn.toId); - const curveData = curveMap.get(conn.fromId); - if (curveData) { - if (conn.property === 'd|X') curves.x = curveData; - if (conn.property === 'd|Y') curves.y = curveData; - if (conn.property === 'd|Z') curves.z = curveData; - } - } - } - } -} - -// Build animation channels -const channels = []; -const samplers = []; - -for (const cn of animCurveNodes) { - const cnId = cn.properties[0]; // ID is in properties[0] - const targetModelId = curveNodeToModel.get(cnId); - if (!targetModelId) continue; - - const nodeIndex = modelToIndex.get(targetModelId); - if (nodeIndex === undefined) continue; - - const targetModel = models[nodeIndex]; - const curves = curveNodeToCurves.get(cnId); - if (!curves) continue; - - // Attribute is in properties[1], but has null bytes - const attr = cn.properties[1]?.split?.('\0')[0]; - if (!attr) continue; - - const xCurve = curves.x; - const yCurve = curves.y; - const zCurve = curves.z; - - if (!xCurve && !yCurve && !zCurve) continue; - - const refCurve = xCurve || yCurve || zCurve; - const keyCount = refCurve.keyTimes.length; - const input = new Float32Array(refCurve.keyTimes); - - let output; - let path; - - if (attr === 'T') { - path = 'translation'; - output = new Float32Array(keyCount * 3); - for (let i = 0; i < keyCount; i++) { - output[i * 3] = xCurve?.keyValues[i] ?? 0; - output[i * 3 + 1] = yCurve?.keyValues[i] ?? 0; - output[i * 3 + 2] = zCurve?.keyValues[i] ?? 0; - } - } else if (attr === 'R') { - path = 'rotation'; - output = new Float32Array(keyCount * 4); - - let preRotQuat = null; - if (targetModel.preRotation) { - const preRx = targetModel.preRotation[0] * Math.PI / 180; - const preRy = targetModel.preRotation[1] * Math.PI / 180; - const preRz = targetModel.preRotation[2] * Math.PI / 180; - preRotQuat = eulerToQuaternion(preRx, preRy, preRz); - } - - for (let i = 0; i < keyCount; i++) { - const rx = (xCurve?.keyValues[i] ?? 0) * Math.PI / 180; - const ry = (yCurve?.keyValues[i] ?? 0) * Math.PI / 180; - const rz = (zCurve?.keyValues[i] ?? 0) * Math.PI / 180; - const lclQuat = eulerToQuaternion(rx, ry, rz); - - const finalQuat = preRotQuat - ? multiplyQuaternion(preRotQuat, lclQuat) - : lclQuat; - - output[i * 4] = finalQuat[0]; - output[i * 4 + 1] = finalQuat[1]; - output[i * 4 + 2] = finalQuat[2]; - output[i * 4 + 3] = finalQuat[3]; - } - } else if (attr === 'S') { - path = 'scale'; - output = new Float32Array(keyCount * 3); - for (let i = 0; i < keyCount; i++) { - output[i * 3] = xCurve?.keyValues[i] ?? 1; - output[i * 3 + 1] = yCurve?.keyValues[i] ?? 1; - output[i * 3 + 2] = zCurve?.keyValues[i] ?? 1; - } - } else { - continue; - } - - const samplerIndex = samplers.length; - samplers.push({ input, output, interpolation: 'LINEAR' }); - channels.push({ samplerIndex, target: { nodeIndex, path } }); -} - -console.log(`Built ${channels.length} animation channels`); - -// ============ STEP 4: Sample Animation (like ModelPreview3D sampleAnimation) ============ - -function slerpQuaternion(q0, q1, t) { - let [x0, y0, z0, w0] = q0; - let [x1, y1, z1, w1] = q1; - - let cosHalfTheta = x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1; - - if (cosHalfTheta < 0) { - x1 = -x1; y1 = -y1; z1 = -z1; w1 = -w1; - cosHalfTheta = -cosHalfTheta; - } - - if (cosHalfTheta > 0.9995) { - const result = [ - x0 + t * (x1 - x0), y0 + t * (y1 - y0), - z0 + t * (z1 - z0), w0 + t * (w1 - w0) - ]; - const len = Math.sqrt(result[0]**2 + result[1]**2 + result[2]**2 + result[3]**2); - return [result[0]/len, result[1]/len, result[2]/len, result[3]/len]; - } - - const theta0 = Math.acos(cosHalfTheta); - const theta = theta0 * t; - const sinTheta = Math.sin(theta); - const sinTheta0 = Math.sin(theta0); - - const s0 = Math.cos(theta) - cosHalfTheta * sinTheta / sinTheta0; - const s1 = sinTheta / sinTheta0; - - return [s0 * x0 + s1 * x1, s0 * y0 + s1 * y1, s0 * z0 + s1 * z1, s0 * w0 + s1 * w1]; -} - -function sampleSampler(sampler, time, path) { - const input = sampler.input; - const output = sampler.output; - - if (!input || !output || input.length === 0) return null; - - const minTime = input[0]; - const maxTime = input[input.length - 1]; - time = Math.max(minTime, Math.min(maxTime, time)); - - let i0 = 0; - for (let i = 0; i < input.length - 1; i++) { - if (time >= input[i] && time <= input[i + 1]) { - i0 = i; - break; - } - if (time < input[i]) break; - i0 = i; - } - const i1 = Math.min(i0 + 1, input.length - 1); - - const t0 = input[i0]; - const t1 = input[i1]; - const t = t1 > t0 ? (time - t0) / (t1 - t0) : 0; - - const componentCount = path === 'rotation' ? 4 : 3; - - if (path === 'rotation') { - const q0 = [output[i0*4], output[i0*4+1], output[i0*4+2], output[i0*4+3]]; - const q1 = [output[i1*4], output[i1*4+1], output[i1*4+2], output[i1*4+3]]; - return slerpQuaternion(q0, q1, t); - } - - const result = []; - for (let c = 0; c < componentCount; c++) { - const v0 = output[i0 * componentCount + c]; - const v1 = output[i1 * componentCount + c]; - result.push(v0 + (v1 - v0) * t); - } - return result; -} - -function sampleAnimation(time) { - const nodeTransforms = new Map(); - - for (const channel of channels) { - const sampler = samplers[channel.samplerIndex]; - const nodeIndex = channel.target.nodeIndex; - const path = channel.target.path; - - const value = sampleSampler(sampler, time, path); - if (!value) continue; - - if (!nodeTransforms.has(nodeIndex)) { - nodeTransforms.set(nodeIndex, {}); - } - - const transform = nodeTransforms.get(nodeIndex); - if (path === 'translation') transform.position = value; - else if (path === 'rotation') transform.rotation = value; - else if (path === 'scale') transform.scale = value; - } - - return nodeTransforms; -} - -// ============ STEP 5: Calculate Bone Matrices (like ModelPreview3D) ============ - -function createTransformMatrix(position, rotation, scale) { - const [qx, qy, qz, qw] = rotation; - const [sx, sy, sz] = scale; - - const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; - const yy = qy * qy, yz = qy * qz, yw = qy * qw; - const zz = qz * qz, zw = qz * qw; - - return new Float32Array([ - (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, - 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, - 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, - position[0], position[1], position[2], 1 - ]); -} - -function multiplyMatrices(a, b) { - const result = new Float32Array(16); - for (let row = 0; row < 4; row++) { - for (let col = 0; col < 4; col++) { - let sum = 0; - for (let k = 0; k < 4; k++) { - sum += a[row + k * 4] * b[k + col * 4]; - } - result[row + col * 4] = sum; - } - } - return result; -} - -function calculateBoneMatrices(animTransforms) { - const boneCount = joints.length; - const localMatrices = new Array(boneCount); - const worldMatrices = new Array(boneCount); - const skinMatrices = new Array(boneCount); - - // Build processing order - const processed = new Set(); - const processingOrder = []; - - function addJoint(jointIndex) { - if (processed.has(jointIndex)) return; - const joint = joints[jointIndex]; - if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { - addJoint(joint.parentIndex); - } - processingOrder.push(jointIndex); - processed.add(jointIndex); - } - - for (let i = 0; i < boneCount; i++) { - addJoint(i); - } - - // Calculate transforms - for (const jointIndex of processingOrder) { - const joint = joints[jointIndex]; - const node = nodes[joint.nodeIndex]; - - if (!node) { - localMatrices[jointIndex] = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); - worldMatrices[jointIndex] = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); - skinMatrices[jointIndex] = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); - continue; - } - - const animTransform = animTransforms.get(joint.nodeIndex); - const pos = animTransform?.position || node.transform.position; - const rot = animTransform?.rotation || node.transform.rotation; - const scl = animTransform?.scale || node.transform.scale; - - localMatrices[jointIndex] = createTransformMatrix(pos, rot, scl); - - if (joint.parentIndex >= 0) { - worldMatrices[jointIndex] = multiplyMatrices( - worldMatrices[joint.parentIndex], - localMatrices[jointIndex] - ); - } else { - worldMatrices[jointIndex] = localMatrices[jointIndex]; - } - - skinMatrices[jointIndex] = multiplyMatrices( - worldMatrices[jointIndex], - joint.inverseBindMatrix - ); - } - - return skinMatrices; -} - -// ============ STEP 6: Test at different times ============ - -function isIdentity(m) { - const identity = [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; - let maxDiff = 0; - for (let i = 0; i < 16; i++) { - maxDiff = Math.max(maxDiff, Math.abs(m[i] - identity[i])); - } - return { isIdentity: maxDiff < 0.001, maxDiff }; -} - -console.log('\n=== BONE MATRIX TEST ===\n'); - -for (const time of [0.0, 0.5, 1.0, 2.0]) { - const animTransforms = sampleAnimation(time); - const skinMatrices = calculateBoneMatrices(animTransforms); - - let identityCount = 0; - let maxDiff = 0; - - for (const m of skinMatrices) { - const check = isIdentity(m); - if (check.isIdentity) identityCount++; - if (check.maxDiff > maxDiff) maxDiff = check.maxDiff; - } - - console.log(`t=${time.toFixed(1)}s: Identity: ${identityCount}/${skinMatrices.length}, Max diff: ${maxDiff.toFixed(4)}`); - - // Show first non-identity matrix at t=1 - if (time === 1.0) { - for (let i = 0; i < skinMatrices.length; i++) { - const check = isIdentity(skinMatrices[i]); - if (!check.isIdentity) { - const m = skinMatrices[i]; - console.log(`\n First non-identity matrix (joint ${i} "${joints[i].name}"):`); - console.log(` Col 0: ${m[0].toFixed(4)}, ${m[1].toFixed(4)}, ${m[2].toFixed(4)}, ${m[3].toFixed(4)}`); - console.log(` Col 1: ${m[4].toFixed(4)}, ${m[5].toFixed(4)}, ${m[6].toFixed(4)}, ${m[7].toFixed(4)}`); - console.log(` Col 2: ${m[8].toFixed(4)}, ${m[9].toFixed(4)}, ${m[10].toFixed(4)}, ${m[11].toFixed(4)}`); - console.log(` Col 3: ${m[12].toFixed(4)}, ${m[13].toFixed(4)}, ${m[14].toFixed(4)}, ${m[15].toFixed(4)}`); - break; - } - } - } -} - -console.log('\n=== SUMMARY ==='); -console.log('This script exactly mimics FBXLoader + ModelPreview3D pipeline.'); -console.log('If t=0 shows identity matrices and t>0 shows non-identity,'); -console.log('the algorithm is correct and the issue is elsewhere (React, GPU, etc.).'); - -console.log('\nDone!'); diff --git a/scripts/trace-fbxloader-output.mjs b/scripts/trace-fbxloader-output.mjs deleted file mode 100644 index c10aec17..00000000 --- a/scripts/trace-fbxloader-output.mjs +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Trace FBXLoader Output - * 追踪 FBXLoader 输出 - * - * Load the FBX with actual FBXLoader and compare with expected values - */ - -import { readFileSync } from 'fs'; -import { FBXLoader } from '../packages/asset-system/dist/index.js'; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -// Suppress console.log temporarily to hide FBXLoader debug output -const originalLog = console.log; -let suppressLogs = true; -console.log = (...args) => { - if (!suppressLogs) originalLog(...args); -}; - -originalLog(`=== Trace FBXLoader Output: ${filePath} ===\n`); - -const binaryData = readFileSync(filePath); - -const loader = new FBXLoader(); - -const context = { - metadata: { - path: filePath, - name: filePath.split(/[\\/]/).pop(), - type: 'model/fbx', - guid: '', - size: binaryData.length, - hash: '', - dependencies: [], - lastModified: Date.now(), - importerVersion: '1.0.0', - labels: [], - tags: [], - version: 1 - }, - loadDependency: async () => null -}; - -const content = { - type: 'binary', - binary: binaryData.buffer -}; - -try { - const asset = await loader.parse(content, context); - - console.log(`Meshes: ${asset.meshes?.length || 0}`); - console.log(`Nodes: ${asset.nodes?.length || 0}`); - console.log(`Animations: ${asset.animations?.length || 0}`); - - if (asset.skeleton) { - console.log(`Skeleton joints: ${asset.skeleton.joints.length}`); - console.log(`Root joint index: ${asset.skeleton.rootJointIndex}`); - - // Check first few joints - console.log(`\nFirst 3 skeleton joints:`); - for (let i = 0; i < 3 && i < asset.skeleton.joints.length; i++) { - const joint = asset.skeleton.joints[i]; - console.log(` Joint[${i}] "${joint.name}":`); - console.log(` nodeIndex: ${joint.nodeIndex}`); - console.log(` parentIndex: ${joint.parentIndex}`); - - // Check inverseBindMatrix - const ibm = joint.inverseBindMatrix; - if (ibm) { - console.log(` inverseBindMatrix diagonal: [${ibm[0].toFixed(4)}, ${ibm[5].toFixed(4)}, ${ibm[10].toFixed(4)}, ${ibm[15].toFixed(4)}]`); - console.log(` inverseBindMatrix last row: [${ibm[12].toFixed(4)}, ${ibm[13].toFixed(4)}, ${ibm[14].toFixed(4)}, ${ibm[15].toFixed(4)}]`); - } - } - - // Check corresponding nodes - console.log(`\nCorresponding nodes:`); - for (let i = 0; i < 3 && i < asset.skeleton.joints.length; i++) { - const joint = asset.skeleton.joints[i]; - const node = asset.nodes?.[joint.nodeIndex]; - if (node) { - console.log(` Node[${joint.nodeIndex}] "${node.name}":`); - console.log(` position: [${node.transform.position.map(v => v.toFixed(4)).join(', ')}]`); - console.log(` rotation: [${node.transform.rotation.map(v => v.toFixed(4)).join(', ')}]`); - console.log(` scale: [${node.transform.scale.map(v => v.toFixed(4)).join(', ')}]`); - } - } - } else { - console.log(`No skeleton data!`); - } - - // Check animation channels - if (asset.animations && asset.animations.length > 0) { - const clip = asset.animations[0]; - console.log(`\nAnimation "${clip.name}":`); - console.log(` Duration: ${clip.duration}s`); - console.log(` Channels: ${clip.channels.length}`); - console.log(` Samplers: ${clip.samplers.length}`); - - // Find channels targeting first few skeleton joints - if (asset.skeleton) { - console.log(`\nChannels for first 3 joints:`); - for (let i = 0; i < 3 && i < asset.skeleton.joints.length; i++) { - const joint = asset.skeleton.joints[i]; - const channels = clip.channels.filter(c => c.target.nodeIndex === joint.nodeIndex); - console.log(` Joint[${i}] nodeIndex=${joint.nodeIndex}: ${channels.length} channels`); - channels.forEach(c => { - const sampler = clip.samplers[c.samplerIndex]; - console.log(` - ${c.target.path}: ${sampler.input.length} keyframes, first value at t=0:`); - if (c.target.path === 'rotation') { - const q = [sampler.output[0], sampler.output[1], sampler.output[2], sampler.output[3]]; - console.log(` quaternion: [${q.map(v => v.toFixed(4)).join(', ')}]`); - } else { - const v = [sampler.output[0], sampler.output[1], sampler.output[2]]; - console.log(` vec3: [${v.map(v => v.toFixed(4)).join(', ')}]`); - } - }); - } - } - } - - // Now test bone matrix calculation - if (asset.skeleton && asset.animations && asset.animations.length > 0) { - console.log(`\n=== TESTING BONE MATRIX CALCULATION ===`); - - const skeleton = asset.skeleton; - const nodes = asset.nodes; - const clip = asset.animations[0]; - - // Sample animation at t=0 - function sampleAnimation(clip, time) { - const nodeTransforms = new Map(); - - for (const channel of clip.channels) { - const sampler = clip.samplers[channel.samplerIndex]; - if (!sampler) continue; - - const nodeIndex = channel.target.nodeIndex; - const path = channel.target.path; - - // Get first keyframe value (t=0) - let value; - if (path === 'rotation') { - value = [sampler.output[0], sampler.output[1], sampler.output[2], sampler.output[3]]; - } else { - value = [sampler.output[0], sampler.output[1], sampler.output[2]]; - } - - let transform = nodeTransforms.get(nodeIndex); - if (!transform) { - transform = {}; - nodeTransforms.set(nodeIndex, transform); - } - - if (path === 'translation') transform.position = value; - else if (path === 'rotation') transform.rotation = value; - else if (path === 'scale') transform.scale = value; - } - - return nodeTransforms; - } - - function createTransformMatrix(position, rotation, scale) { - const [qx, qy, qz, qw] = rotation; - const [sx, sy, sz] = scale; - const xx = qx * qx, xy = qx * qy, xz = qx * qz, xw = qx * qw; - const yy = qy * qy, yz = qy * qz, yw = qy * qw; - const zz = qz * qz, zw = qz * qw; - - return new Float32Array([ - (1 - 2 * (yy + zz)) * sx, 2 * (xy + zw) * sx, 2 * (xz - yw) * sx, 0, - 2 * (xy - zw) * sy, (1 - 2 * (xx + zz)) * sy, 2 * (yz + xw) * sy, 0, - 2 * (xz + yw) * sz, 2 * (yz - xw) * sz, (1 - 2 * (xx + yy)) * sz, 0, - position[0], position[1], position[2], 1 - ]); - } - - function multiplyMatrices(a, b) { - const result = new Float32Array(16); - for (let row = 0; row < 4; row++) { - for (let col = 0; col < 4; col++) { - let sum = 0; - for (let k = 0; k < 4; k++) { - sum += a[row + k * 4] * b[k + col * 4]; - } - result[row + col * 4] = sum; - } - } - return result; - } - - function identity() { - return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); - } - - const animTransforms = sampleAnimation(clip, 0); - console.log(`Sampled ${animTransforms.size} node transforms at t=0`); - - // Calculate bone matrices - const { joints } = skeleton; - const boneCount = joints.length; - const localMatrices = new Array(boneCount); - const worldMatrices = new Array(boneCount); - const skinMatrices = new Array(boneCount); - - // Build processing order - const processed = new Set(); - const processingOrder = []; - - function addJoint(jointIndex) { - if (processed.has(jointIndex)) return; - const joint = joints[jointIndex]; - if (joint.parentIndex >= 0 && !processed.has(joint.parentIndex)) { - addJoint(joint.parentIndex); - } - processingOrder.push(jointIndex); - processed.add(jointIndex); - } - - for (let i = 0; i < boneCount; i++) addJoint(i); - - for (const jointIndex of processingOrder) { - const joint = joints[jointIndex]; - const node = nodes[joint.nodeIndex]; - - if (!node) { - localMatrices[jointIndex] = identity(); - worldMatrices[jointIndex] = identity(); - skinMatrices[jointIndex] = identity(); - continue; - } - - // Get animated or default transform - const animTransform = animTransforms.get(joint.nodeIndex); - const pos = animTransform?.position || node.transform.position; - const rot = animTransform?.rotation || node.transform.rotation; - const scl = animTransform?.scale || node.transform.scale; - - localMatrices[jointIndex] = createTransformMatrix(pos, rot, scl); - - if (joint.parentIndex >= 0) { - worldMatrices[jointIndex] = multiplyMatrices( - worldMatrices[joint.parentIndex], - localMatrices[jointIndex] - ); - } else { - worldMatrices[jointIndex] = localMatrices[jointIndex]; - } - - skinMatrices[jointIndex] = multiplyMatrices( - worldMatrices[jointIndex], - joint.inverseBindMatrix - ); - } - - // Count identity matrices - let identityCount = 0; - let maxDiff = 0; - const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - - for (let i = 0; i < boneCount; i++) { - const sm = skinMatrices[i]; - let diff = 0; - for (let j = 0; j < 16; j++) { - diff = Math.max(diff, Math.abs(sm[j] - id[j])); - } - if (diff < 0.001) identityCount++; - if (diff > maxDiff) maxDiff = diff; - } - - console.log(`\nAt t=0 with animation data:`); - console.log(` Identity matrices: ${identityCount}/${boneCount}`); - console.log(` Max diff from identity: ${maxDiff.toFixed(4)}`); - - if (identityCount !== boneCount) { - console.log(`\n⚠️ NOT all skin matrices are identity at bind pose!`); - - // Show first problematic joint - for (let i = 0; i < boneCount; i++) { - const sm = skinMatrices[i]; - let diff = 0; - for (let j = 0; j < 16; j++) { - diff = Math.max(diff, Math.abs(sm[j] - id[j])); - } - if (diff >= 0.001) { - const joint = joints[i]; - const node = nodes[joint.nodeIndex]; - const animT = animTransforms.get(joint.nodeIndex); - console.log(`\n First non-identity: Joint[${i}] "${joint.name}"`); - console.log(` nodeIndex: ${joint.nodeIndex}`); - console.log(` parentIndex: ${joint.parentIndex}`); - console.log(` animTransform exists: ${!!animT}`); - if (animT) { - console.log(` animTransform.rotation: [${animT.rotation?.map(v => v.toFixed(4)).join(', ') || 'null'}]`); - } - console.log(` node.transform.rotation: [${node.transform.rotation.map(v => v.toFixed(4)).join(', ')}]`); - break; - } - } - } else { - console.log(`\n✅ All skin matrices are identity at bind pose!`); - } - } - -} catch (error) { - console.error('Error:', error); -} - -console.log('\nDone!'); diff --git a/scripts/verify-anim-t0.mjs b/scripts/verify-anim-t0.mjs deleted file mode 100644 index 48d6e5a4..00000000 --- a/scripts/verify-anim-t0.mjs +++ /dev/null @@ -1,377 +0,0 @@ -/** - * Verify Animation at t=0 - * 验证 t=0 时的动画值 - * - * Check if animation values at t=0 produce correct bind pose - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const FBX_TIME_SECOND = 46186158000n; -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Verify Animation at t=0: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': properties.push(view.getInt16(offset, true)); offset += 2; break; - case 'C': properties.push(buffer[offset] !== 0); offset += 1; break; - case 'I': properties.push(view.getInt32(offset, true)); offset += 4; break; - case 'F': properties.push(view.getFloat32(offset, true)); offset += 4; break; - case 'D': properties.push(view.getFloat64(offset, true)); offset += 8; break; - case 'L': properties.push(view.getBigInt64(offset, true)); offset += 8; break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': case 'd': case 'l': case 'i': case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// Parse Models with PreRotation -const models = objectsNode.children - .filter(n => n.name === 'Model') - .map(n => { - const position = [0, 0, 0]; - const rotation = [0, 0, 0]; - const scale = [1, 1, 1]; - let preRotation = null; - - for (const child of n.children) { - if (child.name === 'Properties70') { - for (const prop of child.children) { - if (prop.properties[0] === 'Lcl Translation') { - position[0] = prop.properties[4]; - position[1] = prop.properties[5]; - position[2] = prop.properties[6]; - } else if (prop.properties[0] === 'Lcl Rotation') { - rotation[0] = prop.properties[4]; - rotation[1] = prop.properties[5]; - rotation[2] = prop.properties[6]; - } else if (prop.properties[0] === 'Lcl Scaling') { - scale[0] = prop.properties[4]; - scale[1] = prop.properties[5]; - scale[2] = prop.properties[6]; - } else if (prop.properties[0] === 'PreRotation') { - preRotation = [prop.properties[4], prop.properties[5], prop.properties[6]]; - } - } - } - } - - return { - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Model', - position, rotation, scale, preRotation - }; - }); - -const modelToIndex = new Map(); -const modelById = new Map(); -models.forEach((m, i) => { - modelToIndex.set(m.id, i); - modelById.set(m.id, m); -}); - -// Parse AnimationCurves -const animCurves = objectsNode.children - .filter(n => n.name === 'AnimationCurve') - .map(n => { - const keyTimeNode = n.children.find(c => c.name === 'KeyTime'); - const keyValueNode = n.children.find(c => c.name === 'KeyValueFloat'); - - const keyTimes = keyTimeNode?.properties[0]?.data?.map(t => Number(t) / Number(FBX_TIME_SECOND)) || []; - const keyValues = keyValueNode?.properties[0]?.data || []; - - return { - id: n.properties[0], - keyTimes, - keyValues - }; - }); - -// Parse AnimationCurveNodes -const curveNodes = objectsNode.children - .filter(n => n.name === 'AnimationCurveNode') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || '' - })); - -// Build curveNode to model mapping -const curveNodeToModel = new Map(); -for (const conn of connections) { - if (conn.type === 'OP' && conn.property?.includes('Lcl')) { - const cn = curveNodes.find(c => c.id === conn.fromId); - if (cn) { - curveNodeToModel.set(cn.id, { modelId: conn.toId, property: conn.property }); - } - } -} - -// Build curveNode to curves mapping -const curveNodeToCurves = new Map(); -for (const conn of connections) { - if (conn.type === 'OP' && (conn.property === 'd|X' || conn.property === 'd|Y' || conn.property === 'd|Z')) { - const curve = animCurves.find(c => c.id === conn.fromId); - const cn = curveNodes.find(c => c.id === conn.toId); - if (curve && cn) { - if (!curveNodeToCurves.has(cn.id)) { - curveNodeToCurves.set(cn.id, { x: null, y: null, z: null }); - } - const curves = curveNodeToCurves.get(cn.id); - if (conn.property === 'd|X') curves.x = curve; - if (conn.property === 'd|Y') curves.y = curve; - if (conn.property === 'd|Z') curves.z = curve; - } - } -} - -// Sample animation at t=0 -console.log(`=== SAMPLING ANIMATION AT t=0 ===\n`); - -function eulerToQuaternion(x, y, z) { - const cx = Math.cos(x / 2), sx = Math.sin(x / 2); - const cy = Math.cos(y / 2), sy = Math.sin(y / 2); - const cz = Math.cos(z / 2), sz = Math.sin(z / 2); - return [ - sx * cy * cz - cx * sy * sz, - cx * sy * cz + sx * cy * sz, - cx * cy * sz - sx * sy * cz, - cx * cy * cz + sx * sy * sz - ]; -} - -function multiplyQuaternion(a, b) { - const [ax, ay, az, aw] = a; - const [bx, by, bz, bw] = b; - return [ - aw * bx + ax * bw + ay * bz - az * by, - aw * by - ax * bz + ay * bw + az * bx, - aw * bz + ax * by - ay * bx + az * bw, - aw * bw - ax * bx - ay * by - az * bz - ]; -} - -function sampleCurveAtT0(curve) { - if (!curve || !curve.keyValues || curve.keyValues.length === 0) return 0; - return curve.keyValues[0]; // Value at first keyframe (t=0) -} - -// For each curveNode, sample at t=0 -const sampledTransforms = new Map(); - -for (const [cnId, target] of curveNodeToModel) { - const nodeIndex = modelToIndex.get(target.modelId); - if (nodeIndex === undefined) continue; - - const curves = curveNodeToCurves.get(cnId); - if (!curves) continue; - - const model = modelById.get(target.modelId); - - if (!sampledTransforms.has(nodeIndex)) { - sampledTransforms.set(nodeIndex, { - position: null, - rotation: null, - scale: null - }); - } - const transform = sampledTransforms.get(nodeIndex); - - if (target.property.includes('Translation')) { - transform.position = [ - sampleCurveAtT0(curves.x), - sampleCurveAtT0(curves.y), - sampleCurveAtT0(curves.z) - ]; - } else if (target.property.includes('Rotation')) { - // Get rotation in degrees - const rx = sampleCurveAtT0(curves.x); - const ry = sampleCurveAtT0(curves.y); - const rz = sampleCurveAtT0(curves.z); - - // Convert to radians - const rxRad = rx * Math.PI / 180; - const ryRad = ry * Math.PI / 180; - const rzRad = rz * Math.PI / 180; - - // Apply PreRotation if model has it - let quat; - if (model?.preRotation) { - const preRx = model.preRotation[0] * Math.PI / 180; - const preRy = model.preRotation[1] * Math.PI / 180; - const preRz = model.preRotation[2] * Math.PI / 180; - const preQuat = eulerToQuaternion(preRx, preRy, preRz); - const lclQuat = eulerToQuaternion(rxRad, ryRad, rzRad); - quat = multiplyQuaternion(preQuat, lclQuat); - } else { - quat = eulerToQuaternion(rxRad, ryRad, rzRad); - } - - transform.rotation = quat; - } else if (target.property.includes('Scaling')) { - transform.scale = [ - sampleCurveAtT0(curves.x) || 1, - sampleCurveAtT0(curves.y) || 1, - sampleCurveAtT0(curves.z) || 1 - ]; - } -} - -// Compare with node.transform for first joint -const firstJointNodeIndex = 1; // Bone001 is at index 1 - -const sampledT = sampledTransforms.get(firstJointNodeIndex); -const model = models[firstJointNodeIndex]; - -console.log(`First bone: "${model.name}" (nodeIndex=${firstJointNodeIndex})`); -console.log(`\nnode.transform (from Lcl*):`); -console.log(` position: [${model.position.join(', ')}]`); -console.log(` rotation: [${model.rotation.join(', ')}] (degrees)`); -console.log(` scale: [${model.scale.join(', ')}]`); -if (model.preRotation) { - console.log(` preRotation: [${model.preRotation.join(', ')}] (degrees)`); -} - -console.log(`\nAnimation at t=0:`); -if (sampledT) { - console.log(` position: [${sampledT.position?.join(', ') || 'null'}]`); - console.log(` rotation: [${sampledT.rotation?.map(v => v.toFixed(4)).join(', ') || 'null'}]`); - console.log(` scale: [${sampledT.scale?.join(', ') || 'null'}]`); -} else { - console.log(` No animation data!`); -} - -// Now build quaternion from node.transform for comparison -const nodeRotRad = model.rotation.map(v => v * Math.PI / 180); -let nodeQuat; -if (model.preRotation) { - const preRad = model.preRotation.map(v => v * Math.PI / 180); - const preQuat = eulerToQuaternion(preRad[0], preRad[1], preRad[2]); - const lclQuat = eulerToQuaternion(nodeRotRad[0], nodeRotRad[1], nodeRotRad[2]); - nodeQuat = multiplyQuaternion(preQuat, lclQuat); -} else { - nodeQuat = eulerToQuaternion(nodeRotRad[0], nodeRotRad[1], nodeRotRad[2]); -} - -console.log(`\nnode.transform rotation as quaternion: [${nodeQuat.map(v => v.toFixed(4)).join(', ')}]`); -if (sampledT?.rotation) { - console.log(`animation rotation quaternion: [${sampledT.rotation.map(v => v.toFixed(4)).join(', ')}]`); - - // Check if they match - const match = nodeQuat.every((v, i) => Math.abs(v - sampledT.rotation[i]) < 0.001); - console.log(`\nDo they match? ${match ? 'YES ✅' : 'NO ❌'}`); -} - -console.log('\nDone!'); diff --git a/scripts/verify-animation-skeleton-mapping.mjs b/scripts/verify-animation-skeleton-mapping.mjs deleted file mode 100644 index 7527375e..00000000 --- a/scripts/verify-animation-skeleton-mapping.mjs +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Verify Animation-Skeleton Mapping - * 验证动画通道和骨骼关节的 nodeIndex 映射关系 - * - * This script simulates the exact data flow from FBXLoader to ModelPreview3D - * 此脚本模拟 FBXLoader 到 ModelPreview3D 的完整数据流 - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const FBX_TIME_SECOND = 46186158000n; -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Analyzing: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - const startOffset = offset; - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': - properties.push(view.getInt16(offset, true)); - offset += 2; - break; - case 'C': - properties.push(buffer[offset] !== 0); - offset += 1; - break; - case 'I': - properties.push(view.getInt32(offset, true)); - offset += 4; - break; - case 'F': - properties.push(view.getFloat32(offset, true)); - offset += 4; - break; - case 'D': - properties.push(view.getFloat64(offset, true)); - offset += 8; - break; - case 'L': - properties.push(view.getBigInt64(offset, true)); - offset += 8; - break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); - offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': - case 'd': - case 'l': - case 'i': - case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// Parse Models (this creates the 'models' array - same as in FBXLoader) -const models = objectsNode.children - .filter(n => n.name === 'Model') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Model', - type: n.properties[2]?.split?.('\0')[0] || '' - })); - -// Build modelToIndex (simulating FBXLoader line 237-240) -const modelToIndex = new Map(); -models.forEach((model, index) => { - modelToIndex.set(model.id, index); -}); - -console.log(`Total models: ${models.length}`); -console.log(`First 10 models:`); -models.slice(0, 10).forEach((m, i) => { - console.log(` [${i}] ID=${m.id}, name="${m.name}", type="${m.type}"`); -}); - -// Parse Clusters -const clusters = objectsNode.children - .filter(n => n.name === 'Deformer' && n.properties[2]?.split?.('\0')[0] === 'Cluster') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Cluster' - })); - -// Build cluster to bone mapping (simulating FBXLoader line 1658-1670) -const clusterToBone = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - const cluster = clusters.find(c => c.id === conn.toId); - if (cluster) { - clusterToBone.set(cluster.id, conn.fromId); - } - } -} - -// Build skeleton joints (simulating FBXLoader line 1682-1717) -const joints = []; -const boneModelIdToJointIndex = new Map(); - -for (const cluster of clusters) { - const boneModelId = clusterToBone.get(cluster.id); - if (!boneModelId) continue; - - const nodeIndex = modelToIndex.get(boneModelId); - if (nodeIndex === undefined) continue; - - const model = models[nodeIndex]; - const jointIndex = joints.length; - boneModelIdToJointIndex.set(boneModelId, jointIndex); - - joints.push({ - name: model.name, - nodeIndex, // This is model index in models array - boneModelId - }); -} - -console.log(`\n=== SKELETON JOINTS (${joints.length}) ===`); -console.log(`First 10 joints:`); -joints.slice(0, 10).forEach((j, i) => { - console.log(` Joint[${i}] nodeIndex=${j.nodeIndex}, name="${j.name}"`); -}); - -// Parse AnimationCurveNodes -const curveNodes = objectsNode.children - .filter(n => n.name === 'AnimationCurveNode') - .map(n => ({ - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || '' - })); - -// Build animation channel targets (simulating FBXLoader line 1337-1443) -// For each curveNode, find which model it targets -const curveNodeToModel = new Map(); -for (const conn of connections) { - if (conn.type === 'OP' && conn.property?.includes('Lcl')) { - const curveNode = curveNodes.find(cn => cn.id === conn.fromId); - if (curveNode) { - curveNodeToModel.set(curveNode.id, conn.toId); - } - } -} - -// Build animation channels (simulating FBXLoader buildAnimations) -const animationChannels = []; -for (const curveNode of curveNodes) { - const targetModelId = curveNodeToModel.get(curveNode.id); - if (!targetModelId) continue; - - const nodeIndex = modelToIndex.get(targetModelId); - if (nodeIndex === undefined) continue; - - animationChannels.push({ - curveNodeName: curveNode.name, - targetModelId, - nodeIndex, // This should match joint.nodeIndex - targetModelName: models[nodeIndex]?.name - }); -} - -console.log(`\n=== ANIMATION CHANNELS (${animationChannels.length}) ===`); -const uniqueTargetIndices = [...new Set(animationChannels.map(c => c.nodeIndex))]; -console.log(`Unique target nodeIndices: ${uniqueTargetIndices.length}`); -console.log(`First 10 channel targets:`); -animationChannels.slice(0, 10).forEach((c, i) => { - console.log(` Channel[${i}] nodeIndex=${c.nodeIndex}, target="${c.targetModelName}", type="${c.curveNodeName}"`); -}); - -// NOW THE KEY CHECK: Do animation channel nodeIndices match joint nodeIndices? -console.log(`\n=== CRITICAL CHECK: Animation-Skeleton Mapping ===`); - -const jointNodeIndices = new Set(joints.map(j => j.nodeIndex)); -const animNodeIndices = new Set(animationChannels.map(c => c.nodeIndex)); - -console.log(`Skeleton joint nodeIndices: ${jointNodeIndices.size}`); -console.log(`Animation target nodeIndices: ${animNodeIndices.size}`); - -// Check intersection -const matchingIndices = [...jointNodeIndices].filter(idx => animNodeIndices.has(idx)); -const jointsWithoutAnim = [...jointNodeIndices].filter(idx => !animNodeIndices.has(idx)); -const animWithoutJoint = [...animNodeIndices].filter(idx => !jointNodeIndices.has(idx)); - -console.log(`\nJoints WITH matching animation: ${matchingIndices.length}/${joints.length}`); -console.log(`Joints WITHOUT animation: ${jointsWithoutAnim.length}`); -console.log(`Animation targets that are NOT joints: ${animWithoutJoint.length}`); - -if (jointsWithoutAnim.length > 0) { - console.log(`\n⚠️ WARNING: Some joints have no animation!`); - console.log(`Missing animation for joints:`); - jointsWithoutAnim.slice(0, 10).forEach(idx => { - const joint = joints.find(j => j.nodeIndex === idx); - console.log(` nodeIndex=${idx}, name="${joint?.name}"`); - }); -} - -if (animWithoutJoint.length > 0) { - console.log(`\nAnimation targets that are not skeleton joints:`); - animWithoutJoint.slice(0, 10).forEach(idx => { - const model = models[idx]; - console.log(` nodeIndex=${idx}, name="${model?.name}", type="${model?.type}"`); - }); -} - -// Simulate ModelPreview3D's sampleAnimation lookup -console.log(`\n=== SIMULATING ModelPreview3D LOOKUP ===`); -console.log(`When ModelPreview3D calls: animTransforms.get(joint.nodeIndex)`); - -// Create a mock animTransforms map (like sampleAnimation returns) -const mockAnimTransforms = new Map(); -for (const channel of animationChannels) { - if (!mockAnimTransforms.has(channel.nodeIndex)) { - mockAnimTransforms.set(channel.nodeIndex, { hasData: true }); - } -} - -let matchCount = 0; -let missCount = 0; -for (const joint of joints) { - if (mockAnimTransforms.has(joint.nodeIndex)) { - matchCount++; - } else { - missCount++; - if (missCount <= 5) { - console.log(` ❌ Joint "${joint.name}" (nodeIndex=${joint.nodeIndex}) has NO animation data!`); - } - } -} - -console.log(`\n✅ Joints with animation data: ${matchCount}/${joints.length}`); -console.log(`❌ Joints WITHOUT animation data: ${missCount}/${joints.length}`); - -if (missCount === 0) { - console.log(`\n🎉 All joints have matching animation data! The mapping is correct.`); - console.log(`The issue must be elsewhere in the pipeline.`); -} else { - console.log(`\n⚠️ PROBLEM FOUND: ${missCount} joints have no animation data!`); - console.log(`This explains why the animation doesn't work correctly.`); -} - -console.log('\nDone!'); diff --git a/scripts/verify-mesh-skinning.mjs b/scripts/verify-mesh-skinning.mjs deleted file mode 100644 index 3f2695ba..00000000 --- a/scripts/verify-mesh-skinning.mjs +++ /dev/null @@ -1,388 +0,0 @@ -/** - * Verify Mesh Skinning Data - * 验证网格蒙皮数据 - * - * Check if joints/weights arrays in the mesh are correctly mapped - * to skeleton joint indices. - */ - -import { readFileSync } from 'fs'; -import pako from 'pako'; -const { inflate } = pako; - -const filePath = process.argv[2] || 'F:\\MyProject4\\assets\\octopus.fbx'; - -console.log(`=== Verifying Mesh Skinning Data: ${filePath} ===\n`); - -const buffer = readFileSync(filePath); -const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); -const version = view.getUint32(23, true); -const is64Bit = version >= 7500; - -let offset = 27; - -function readNode() { - let endOffset, numProperties, propertyListLen, nameLen; - - if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); - nameLen = view.getUint8(offset + 24); - offset += 25; - } else { - endOffset = view.getUint32(offset, true); - numProperties = view.getUint32(offset + 4, true); - propertyListLen = view.getUint32(offset + 8, true); - nameLen = view.getUint8(offset + 12); - offset += 13; - } - - if (endOffset === 0) return null; - - const name = new TextDecoder().decode(buffer.slice(offset, offset + nameLen)); - offset += nameLen; - - const properties = []; - const propsEnd = offset + propertyListLen; - - while (offset < propsEnd) { - const typeCode = String.fromCharCode(buffer[offset]); - offset++; - - switch (typeCode) { - case 'Y': - properties.push(view.getInt16(offset, true)); - offset += 2; - break; - case 'C': - properties.push(buffer[offset] !== 0); - offset += 1; - break; - case 'I': - properties.push(view.getInt32(offset, true)); - offset += 4; - break; - case 'F': - properties.push(view.getFloat32(offset, true)); - offset += 4; - break; - case 'D': - properties.push(view.getFloat64(offset, true)); - offset += 8; - break; - case 'L': - properties.push(view.getBigInt64(offset, true)); - offset += 8; - break; - case 'S': - case 'R': - const strLen = view.getUint32(offset, true); - offset += 4; - if (typeCode === 'S') { - properties.push(new TextDecoder().decode(buffer.slice(offset, offset + strLen))); - } else { - properties.push(buffer.slice(offset, offset + strLen)); - } - offset += strLen; - break; - case 'f': - case 'd': - case 'l': - case 'i': - case 'b': - const arrayLen = view.getUint32(offset, true); - const encoding = view.getUint32(offset + 4, true); - const compressedLen = view.getUint32(offset + 8, true); - offset += 12; - - if (encoding === 0) { - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(view.getFloat64(offset + i * 8, true)); - else if (typeCode === 'f') arr.push(view.getFloat32(offset + i * 4, true)); - else if (typeCode === 'l') arr.push(view.getBigInt64(offset + i * 8, true)); - else if (typeCode === 'i') arr.push(view.getInt32(offset + i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - offset += arrayLen * elemSize; - } else { - const compData = buffer.slice(offset, offset + compressedLen); - try { - const decompressed = inflate(compData); - const elemSize = typeCode === 'd' || typeCode === 'l' ? 8 : 4; - const dataView = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength); - const arr = []; - for (let i = 0; i < arrayLen; i++) { - if (typeCode === 'd') arr.push(dataView.getFloat64(i * 8, true)); - else if (typeCode === 'f') arr.push(dataView.getFloat32(i * 4, true)); - else if (typeCode === 'l') arr.push(dataView.getBigInt64(i * 8, true)); - else if (typeCode === 'i') arr.push(dataView.getInt32(i * 4, true)); - } - properties.push({ type: typeCode, data: arr }); - } catch (e) { - properties.push({ type: typeCode, compressed: true, len: arrayLen }); - } - offset += compressedLen; - } - break; - default: - offset = propsEnd; - } - } - - const children = []; - while (offset < endOffset) { - const child = readNode(); - if (child) children.push(child); - else break; - } - - offset = endOffset; - return { name, properties, children }; -} - -// Parse root nodes -const rootNodes = []; -while (offset < buffer.length - 100) { - const node = readNode(); - if (node) rootNodes.push(node); - else break; -} - -const objectsNode = rootNodes.find(n => n.name === 'Objects'); -const connectionsNode = rootNodes.find(n => n.name === 'Connections'); - -// Parse connections -const connections = connectionsNode.children.map(c => ({ - type: c.properties[0].split('\0')[0], - fromId: c.properties[1], - toId: c.properties[2], - property: c.properties[3]?.split?.('\0')[0] -})); - -// Parse Geometries -const geometries = objectsNode.children - .filter(n => n.name === 'Geometry') - .map(n => { - const verticesNode = n.children.find(c => c.name === 'Vertices'); - const vertices = verticesNode?.properties[0]?.data || []; - return { - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || 'Geometry', - vertexCount: vertices.length / 3 - }; - }); - -console.log(`Found ${geometries.length} geometries`); -geometries.forEach(g => { - console.log(` Geometry: "${g.name}", ${g.vertexCount} vertices`); -}); - -// Parse Deformers (Skin and Cluster) -const deformers = objectsNode.children - .filter(n => n.name === 'Deformer') - .map(n => { - const deformer = { - id: n.properties[0], - name: n.properties[1]?.split?.('\0')[0] || '', - type: n.properties[2]?.split?.('\0')[0] || '' - }; - - if (deformer.type === 'Cluster') { - const indexesNode = n.children.find(c => c.name === 'Indexes'); - const weightsNode = n.children.find(c => c.name === 'Weights'); - deformer.indexes = indexesNode?.properties[0]?.data || []; - deformer.weights = weightsNode?.properties[0]?.data || []; - } - - return deformer; - }); - -const skins = deformers.filter(d => d.type === 'Skin'); -const clusters = deformers.filter(d => d.type === 'Cluster'); - -console.log(`\nFound ${skins.length} skins, ${clusters.length} clusters`); - -// Build cluster-to-skeleton-joint mapping (same as FBXLoader) -// First, find which bone each cluster is connected to -const clusterToBone = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - const cluster = clusters.find(c => c.id === conn.toId); - if (cluster) { - clusterToBone.set(cluster.id, conn.fromId); - } - } -} - -// Build skeleton joints (same order as FBXLoader) -const joints = []; -const clusterToJointIndex = new Map(); - -for (const cluster of clusters) { - const boneModelId = clusterToBone.get(cluster.id); - if (!boneModelId) continue; - - const jointIndex = joints.length; - clusterToJointIndex.set(cluster.id, jointIndex); - joints.push({ - name: cluster.name, - clusterId: cluster.id, - boneModelId - }); -} - -console.log(`\nBuilt ${joints.length} skeleton joints`); -console.log(`First 5 joints:`); -joints.slice(0, 5).forEach((j, i) => { - console.log(` Joint[${i}] name="${j.name}"`); -}); - -// Build Skin -> Clusters mapping -const skinClusters = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - const skin = skins.find(s => s.id === conn.toId); - const cluster = clusters.find(c => c.id === conn.fromId); - if (skin && cluster) { - if (!skinClusters.has(skin.id)) { - skinClusters.set(skin.id, []); - } - skinClusters.get(skin.id).push(cluster); - } - } -} - -// Build Geometry -> Skin mapping -const geometrySkin = new Map(); -for (const conn of connections) { - if (conn.type === 'OO') { - const geom = geometries.find(g => g.id === conn.toId); - const skin = skins.find(s => s.id === conn.fromId); - if (geom && skin) { - geometrySkin.set(geom.id, skin.id); - } - } -} - -// Now simulate buildSkinningData -console.log(`\n=== SIMULATING buildSkinningData ===`); - -for (const [geomId, skinId] of geometrySkin) { - const geom = geometries.find(g => g.id === geomId); - const clusterList = skinClusters.get(skinId); - - if (!geom || !clusterList || clusterList.length === 0) continue; - - console.log(`\nProcessing geometry "${geom.name}" with ${clusterList.length} clusters`); - - const vertexCount = geom.vertexCount; - const joints4 = new Uint8Array(vertexCount * 4); - const weights4 = new Float32Array(vertexCount * 4); - - // Temporary storage for per-vertex influences - const vertexInfluences = []; - for (let i = 0; i < vertexCount; i++) { - vertexInfluences.push([]); - } - - // Collect influences from each cluster - for (const cluster of clusterList) { - if (!cluster.indexes || !cluster.weights) continue; - - const jointIndex = clusterToJointIndex.get(cluster.id); - if (jointIndex === undefined) { - console.warn(` WARNING: Cluster ${cluster.id} not found in skeleton`); - continue; - } - - for (let i = 0; i < cluster.indexes.length; i++) { - const vertexIndex = cluster.indexes[i]; - const weight = cluster.weights[i]; - if (vertexIndex < vertexCount && weight > 0.001) { - vertexInfluences[vertexIndex].push({ - joint: jointIndex, - weight - }); - } - } - } - - // Convert to fixed 4-influence format and normalize - let maxJointIndex = 0; - let totalInfluences = 0; - let verticesWithInfluences = 0; - - for (let v = 0; v < vertexCount; v++) { - const influences = vertexInfluences[v]; - if (influences.length === 0) continue; - - verticesWithInfluences++; - totalInfluences += influences.length; - - // Sort by weight descending - influences.sort((a, b) => b.weight - a.weight); - - // Take top 4 influences - let totalWeight = 0; - for (let i = 0; i < 4 && i < influences.length; i++) { - joints4[v * 4 + i] = influences[i].joint; - weights4[v * 4 + i] = influences[i].weight; - totalWeight += influences[i].weight; - if (influences[i].joint > maxJointIndex) { - maxJointIndex = influences[i].joint; - } - } - - // Normalize weights - if (totalWeight > 0) { - for (let i = 0; i < 4; i++) { - weights4[v * 4 + i] /= totalWeight; - } - } - } - - console.log(` Vertices with skinning: ${verticesWithInfluences}/${vertexCount}`); - console.log(` Max joint index used: ${maxJointIndex}`); - console.log(` Total skeleton joints: ${joints.length}`); - console.log(` Avg influences per vertex: ${(totalInfluences / verticesWithInfluences).toFixed(2)}`); - - // Check if max joint index exceeds skeleton size - if (maxJointIndex >= joints.length) { - console.log(` ⚠️ ERROR: Max joint index (${maxJointIndex}) >= skeleton size (${joints.length})`); - } else { - console.log(` ✅ Joint indices are within valid range`); - } - - // Sample some vertex data - console.log(`\n Sample vertex skinning data (first 5 skinned vertices):`); - let sampleCount = 0; - for (let v = 0; v < vertexCount && sampleCount < 5; v++) { - const w0 = weights4[v * 4]; - if (w0 > 0) { - const j0 = joints4[v * 4]; - const j1 = joints4[v * 4 + 1]; - const j2 = joints4[v * 4 + 2]; - const j3 = joints4[v * 4 + 3]; - const w1 = weights4[v * 4 + 1]; - const w2 = weights4[v * 4 + 2]; - const w3 = weights4[v * 4 + 3]; - console.log(` Vertex[${v}]: joints=[${j0},${j1},${j2},${j3}], weights=[${w0.toFixed(3)},${w1.toFixed(3)},${w2.toFixed(3)},${w3.toFixed(3)}]`); - sampleCount++; - } - } - - // Check weight normalization - let badWeights = 0; - for (let v = 0; v < vertexCount; v++) { - const sum = weights4[v * 4] + weights4[v * 4 + 1] + weights4[v * 4 + 2] + weights4[v * 4 + 3]; - if (sum > 0 && Math.abs(sum - 1.0) > 0.01) { - badWeights++; - } - } - console.log(`\n Weight normalization check: ${badWeights} vertices with bad weights`); -} - -console.log('\nDone!');