Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions[bot]
c902dd7291 chore: release packages (#439)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-05 11:25:48 +08:00
YHH
0d33cf0097 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
2026-01-05 11:23:42 +08:00
yhh
45de62e453 docs(blueprint): add beta testing notice with QQ group 481923584 2026-01-04 18:15:44 +08:00
yhh
b983cbf87a docs(blueprint): fix editor interface description 2026-01-04 18:06:43 +08:00
yhh
34583b23af docs(blueprint): add editor user guide with download link
- Add Chinese and English editor guide for Cocos Creator blueprint plugin
- Add download link to GitHub Release in blueprint index pages
- Add editor guide to sidebar navigation
- Clarify blueprint files must be saved in resources directory
2026-01-04 17:59:43 +08:00
github-actions[bot]
f2c3a24404 chore: release packages (#437)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-04 17:27:10 +08:00
yhh
3bfb8a1c9b chore: add changeset for node-editor box selection feature 2026-01-04 17:24:20 +08:00
yhh
2ee8d87647 feat(node-editor): add box selection and variable node error states
- Add box selection (drag on empty canvas to select multiple nodes)
- Support Ctrl+drag for additive selection
- Add error state styling for invalid variable references (red border, warning icon)
- Support dynamic node title via data.displayTitle
- Support hiding inputs via data.hiddenInputs array
2026-01-04 17:22:20 +08:00
61 changed files with 2866 additions and 6515 deletions

View File

@@ -232,6 +232,7 @@ export default defineConfig({
translations: { en: 'Blueprint' },
items: [
{ label: '概述', slug: 'modules/blueprint', translations: { en: 'Overview' } },
{ label: '编辑器使用指南', slug: 'modules/blueprint/editor-guide', translations: { en: 'Editor Guide' } },
{ label: '虚拟机 API', slug: 'modules/blueprint/vm', translations: { en: 'VM API' } },
{ label: '自定义节点', slug: 'modules/blueprint/custom-nodes', translations: { en: 'Custom Nodes' } },
{ label: '内置节点', slug: 'modules/blueprint/nodes', translations: { en: 'Built-in Nodes' } },

View File

@@ -0,0 +1,415 @@
---
title: "Blueprint Editor User Guide"
description: "Complete guide for using the Cocos Creator Blueprint Visual Scripting Editor"
---
This guide covers how to use the Blueprint Visual Scripting Editor in Cocos Creator.
## Download & Installation
### Download
> **Beta Testing**: The blueprint editor is currently in beta. An activation code is required.
> Please join QQ Group **481923584** and message the group owner to get your activation code.
Download the latest version from GitHub Release:
**[Download Cocos Node Editor v1.0.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.0.0)**
### Installation Steps
1. Extract `cocos-node-editor.zip` to your project's `extensions` directory:
```
your-project/
├── assets/
├── extensions/
│ └── cocos-node-editor/ ← Extract here
└── ...
```
2. Restart Cocos Creator
3. Confirm the plugin is enabled via **Extensions → Extension Manager**
4. Open the editor via **Panel → Node Editor**
## Interface Overview
- **Toolbar** - Located at the top, contains New, Open, Save, Undo, Redo operations
- **Variables Panel** - Located at the top-left, for defining and managing blueprint variables
- **Canvas Area** - Main area for placing and connecting nodes
- **Node Menu** - Right-click on empty canvas to open, lists all available nodes by category
## Canvas Operations
| Operation | Method |
|-----------|--------|
| Pan canvas | Middle-click drag / Alt + Left-click drag |
| Zoom canvas | Mouse wheel |
| Open node menu | Right-click on empty space |
| Box select nodes | Drag on empty canvas |
| Additive select | Ctrl + Drag |
| Delete selected | Delete key |
## Node Operations
### Adding Nodes
1. **Drag from Node Panel** - Drag nodes from the left panel onto the canvas
2. **Right-click Menu** - Right-click on empty canvas space to select nodes
### Connecting Nodes
1. Drag from an output pin to an input pin
2. Compatible pins will highlight
3. Release to complete the connection
**Pin Type Reference:**
| Pin Color | Type | Description |
|-----------|------|-------------|
| White ▶ | Exec | Execution flow (controls order) |
| Cyan ◆ | Entity | Entity reference |
| Purple ◆ | Component | Component reference |
| Light Blue ◆ | String | String value |
| Green ◆ | Number | Numeric value |
| Red ◆ | Boolean | Boolean value |
| Gray ◆ | Any | Any type |
### Deleting Connections
Click a connection line to select it, then press Delete.
## Node Types Reference
### Event Nodes
Event nodes are entry points for blueprint execution, triggered when specific events occur.
| Node | Trigger | Outputs |
|------|---------|---------|
| **Event BeginPlay** | When blueprint starts | Exec, Self (Entity) |
| **Event Tick** | Every frame | Exec, Delta Time |
| **Event EndPlay** | When blueprint stops | Exec |
**Example: Print message on game start**
```
[Event BeginPlay] ──Exec──→ [Print]
└─ Message: "Game Started!"
```
### Entity Nodes
Nodes for operating on ECS entities.
| Node | Function | Inputs | Outputs |
|------|----------|--------|---------|
| **Get Self** | Get current entity | - | Entity |
| **Create Entity** | Create new entity | Exec, Name | Exec, Entity |
| **Destroy Entity** | Destroy entity | Exec, Entity | Exec |
| **Find Entity By Name** | Find by name | Name | Entity |
| **Find Entities By Tag** | Find by tag | Tag | Entity[] |
| **Is Valid** | Check entity validity | Entity | Boolean |
| **Get/Set Entity Name** | Get/Set name | Entity | String |
| **Set Active** | Set active state | Exec, Entity, Active | Exec |
**Example: Create new entity**
```
[Event BeginPlay] ──→ [Create Entity] ──→ [Add Component]
└─ Name: "Bullet" └─ Type: Transform
```
### Component Nodes
Access and manipulate ECS components.
| Node | Function |
|------|----------|
| **Has Component** | Check if entity has component |
| **Get Component** | Get component instance |
| **Add Component** | Add component to entity |
| **Remove Component** | Remove component |
| **Get/Set Property** | Get/Set component property |
**Example: Modify Transform component**
```
[Get Self] ─Entity─→ [Get Component: Transform] ─Component─→ [Set Property]
├─ Property: x
└─ Value: 100
```
### Flow Control Nodes
Nodes that control execution flow.
#### Branch
Conditional branching, similar to if/else.
```
┌─ True ──→ [DoSomething]
[Branch]─┤
└─ False ─→ [DoOtherThing]
```
#### Sequence
Execute multiple branches in order.
```
┌─ Then 0 ──→ [Step1]
[Sequence]─┼─ Then 1 ──→ [Step2]
└─ Then 2 ──→ [Step3]
```
#### For Loop
Execute a specified number of times.
```
[For Loop] ─Loop Body─→ [Execute each iteration]
└─ Completed ────→ [Execute after loop ends]
```
| Input | Description |
|-------|-------------|
| First Index | Starting index |
| Last Index | Ending index |
| Output | Description |
|--------|-------------|
| Loop Body | Execute each iteration |
| Index | Current index |
| Completed | Execute after loop ends |
#### For Each
Iterate over array elements.
#### While Loop
Loop while condition is true.
#### Do Once
Execute only once, skip afterwards.
#### Flip Flop
Alternate between A and B outputs each execution.
#### Gate
Control whether execution passes through via Open/Close/Toggle.
### Time Nodes
| Node | Function | Output Type |
|------|----------|-------------|
| **Delay** | Delay execution by specified time | Exec |
| **Get Delta Time** | Get frame delta time | Number |
| **Get Time** | Get total runtime | Number |
**Example: Execute after 2 second delay**
```
[Event BeginPlay] ──→ [Delay] ──→ [Print]
└─ Duration: 2.0 └─ "Executed after 2s"
```
### Math Nodes
| Node | Function |
|------|----------|
| **Add / Subtract / Multiply / Divide** | Arithmetic operations |
| **Abs** | Absolute value |
| **Clamp** | Clamp to range |
| **Lerp** | Linear interpolation |
| **Min / Max** | Minimum/Maximum value |
| **Random Range** | Random number |
| **Sin / Cos / Tan** | Trigonometric functions |
### Debug Nodes
| Node | Function |
|------|----------|
| **Print** | Output to console |
## Variable System
Variables store and share data within a blueprint.
### Creating Variables
1. Click the **+** button in the Variables panel
2. Enter variable name
3. Select variable type
4. Set default value (optional)
### Using Variables
- **Drag to canvas** - Creates Get or Set node
- **Get Node** - Read variable value
- **Set Node** - Write variable value
### Variable Types
| Type | Description | Default |
|------|-------------|---------|
| Boolean | Boolean value | false |
| Number | Numeric value | 0 |
| String | String value | "" |
| Entity | Entity reference | null |
| Vector2 | 2D vector | (0, 0) |
| Vector3 | 3D vector | (0, 0, 0) |
### Variable Node Error State
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 |
|----------|----------|
| `Ctrl + S` | Save blueprint |
| `Ctrl + Z` | Undo |
| `Ctrl + Shift + Z` | Redo |
| `Ctrl + C` | Copy selected nodes |
| `Ctrl + X` | Cut selected nodes |
| `Ctrl + V` | Paste nodes |
| `Delete` | Delete selected items |
| `Ctrl + A` | Select all |
## Save & Load
### Saving Blueprints
1. Click the **Save** button in the toolbar
2. Choose save location (**must be saved in `assets/resources` directory**, otherwise Cocos Creator cannot load dynamically)
3. File extension is `.blueprint.json`
> **Important**: Blueprint files must be placed in the `resources` directory for runtime loading via `cc.resources.load()`.
### Loading Blueprints
1. Click the **Open** button in the toolbar
2. Select a `.blueprint.json` file
### Blueprint File Format
Blueprints are saved as JSON, compatible with `@esengine/blueprint` runtime:
```json
{
"version": 1,
"type": "blueprint",
"metadata": {
"name": "PlayerController",
"description": "Player control logic"
},
"variables": [],
"nodes": [],
"connections": []
}
```
## Practical Examples
### Example 1: Movement Control
Move entity every frame:
```
[Event Tick] ─Exec─→ [Get Self] ─Entity─→ [Get Component: Transform]
[Get Delta Time] ▼
│ [Set Property: x]
│ │
[Multiply] ◄──────────────┘
└─ Speed: 100
```
### Example 2: Health System
Check death after taking damage:
```
[On Damage Event] ─→ [Get Component: Health] ─→ [Get Property: current]
[Subtract]
[Set Property: current]
┌─ True ─→ [Destroy Self]
[Branch]─┤
└─ False ─→ (continue)
[Less Or Equal]
current <= 0
```
### Example 3: Delayed Spawning
Spawn an enemy every 2 seconds:
```
[Event BeginPlay] ─→ [Do N Times] ─Loop─→ [Delay: 2.0] ─→ [Create Entity: Enemy]
└─ N: 10
```
## Troubleshooting
### Q: Nodes won't connect?
Check if pin types are compatible. Execution pins (white) can only connect to execution pins. Data pins need matching types.
### Q: Blueprint not executing?
1. Ensure entity has `BlueprintComponent` attached
2. Ensure scene has `BlueprintSystem` added
3. Check if `autoStart` is `true`
### Q: How to debug?
Use **Print** nodes to output variable values to the console.
## Next Steps
- [ECS Node Reference](./nodes) - Complete node list
- [Custom Nodes](./custom-nodes) - Create custom nodes
- [Runtime Integration](./vm) - Blueprint VM API
- [Examples](./examples) - More game logic examples

View File

@@ -5,7 +5,18 @@ description: "Visual scripting system deeply integrated with ECS framework"
`@esengine/blueprint` provides a visual scripting system deeply integrated with the ECS framework, supporting node-based programming to control entity behavior.
## Installation
## Editor Download
> **Beta Testing**: The blueprint editor is currently in beta. An activation code is required.
> Please join QQ Group **481923584** and message the group owner to get your activation code.
Blueprint Editor Plugin for Cocos Creator:
**[Download Cocos Node Editor v1.0.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.0.0)**
For detailed usage instructions, see [Editor User Guide](./editor-guide).
## Runtime Installation
```bash
npm install @esengine/blueprint
@@ -144,6 +155,7 @@ interface BlueprintAsset {
## Documentation Navigation
- [Editor User Guide](./editor-guide) - Cocos Creator Blueprint Editor tutorial
- [Virtual Machine API](./vm) - BlueprintVM and ECS integration
- [ECS Node Reference](./nodes) - Built-in ECS operation nodes
- [Custom Nodes](./custom-nodes) - Create custom ECS nodes

View File

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

View File

@@ -0,0 +1,415 @@
---
title: "蓝图编辑器使用指南"
description: "Cocos Creator 蓝图可视化脚本编辑器完整使用教程"
---
本指南介绍如何在 Cocos Creator 中使用蓝图可视化脚本编辑器。
## 下载与安装
### 下载
> **内测中**:蓝图编辑器目前处于内测阶段,需要激活码才能使用。
> 请加入 QQ 群 **481923584** 后私聊群主获取激活码。
从 GitHub Release 下载最新版本:
**[下载 Cocos Node Editor v1.0.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.0.0)**
### 安装步骤
1. 解压 `cocos-node-editor.zip` 到项目的 `extensions` 目录:
```
your-project/
├── assets/
├── extensions/
│ └── cocos-node-editor/ ← 解压到这里
└── ...
```
2. 重启 Cocos Creator
3. 通过菜单 **扩展 → 扩展管理器** 确认插件已启用
4. 通过菜单 **面板 → Node Editor** 打开编辑器
## 界面介绍
- **工具栏** - 位于顶部,包含新建、打开、保存、撤销、重做等操作
- **变量面板** - 位于左上角,用于定义和管理蓝图变量
- **画布区域** - 主区域,用于放置和连接节点
- **节点菜单** - 右键点击画布空白处打开,按分类列出所有可用节点
## 画布操作
| 操作 | 方式 |
|------|------|
| 平移画布 | 鼠标中键拖拽 / Alt + 左键拖拽 |
| 缩放画布 | 鼠标滚轮 |
| 打开节点菜单 | 右键点击空白处 |
| 框选多个节点 | 在空白处拖拽 |
| 追加框选 | Ctrl + 拖拽 |
| 删除选中 | Delete 键 |
## 节点操作
### 添加节点
1. **从节点面板拖拽** - 将节点从左侧面板拖到画布
2. **右键菜单** - 右键点击画布空白处,选择节点
### 连接节点
1. 从输出引脚拖拽到输入引脚
2. 兼容类型的引脚会高亮显示
3. 松开鼠标完成连接
**引脚类型说明:**
| 引脚颜色 | 类型 | 说明 |
|---------|------|------|
| 白色 ▶ | Exec | 执行流程(控制执行顺序) |
| 青色 ◆ | Entity | 实体引用 |
| 紫色 ◆ | Component | 组件引用 |
| 浅蓝 ◆ | String | 字符串 |
| 绿色 ◆ | Number | 数值 |
| 红色 ◆ | Boolean | 布尔值 |
| 灰色 ◆ | Any | 任意类型 |
### 删除连接
点击连接线选中,按 Delete 键删除。
## 节点类型详解
### 事件节点 (Event)
事件节点是蓝图的入口点,当特定事件发生时触发执行。
| 节点 | 触发时机 | 输出 |
|------|---------|------|
| **Event BeginPlay** | 蓝图开始运行时 | Exec, Self (实体) |
| **Event Tick** | 每帧执行 | Exec, Delta Time |
| **Event EndPlay** | 蓝图停止时 | Exec |
**示例:游戏开始时打印消息**
```
[Event BeginPlay] ──Exec──→ [Print]
└─ Message: "游戏开始!"
```
### 实体节点 (Entity)
操作 ECS 实体的节点。
| 节点 | 功能 | 输入 | 输出 |
|------|------|------|------|
| **Get Self** | 获取当前实体 | - | Entity |
| **Create Entity** | 创建新实体 | Exec, Name | Exec, Entity |
| **Destroy Entity** | 销毁实体 | Exec, Entity | Exec |
| **Find Entity By Name** | 按名称查找 | Name | Entity |
| **Find Entities By Tag** | 按标签查找 | Tag | Entity[] |
| **Is Valid** | 检查实体有效性 | Entity | Boolean |
| **Get/Set Entity Name** | 获取/设置名称 | Entity | String |
| **Set Active** | 设置激活状态 | Exec, Entity, Active | Exec |
**示例:创建新实体**
```
[Event BeginPlay] ──→ [Create Entity] ──→ [Add Component]
└─ Name: "Bullet" └─ Type: Transform
```
### 组件节点 (Component)
访问和操作 ECS 组件。
| 节点 | 功能 |
|------|------|
| **Has Component** | 检查实体是否有指定组件 |
| **Get Component** | 获取组件实例 |
| **Add Component** | 添加组件到实体 |
| **Remove Component** | 移除组件 |
| **Get/Set Property** | 获取/设置组件属性 |
**示例:修改 Transform 组件**
```
[Get Self] ─Entity─→ [Get Component: Transform] ─Component─→ [Set Property]
├─ Property: x
└─ Value: 100
```
### 流程控制节点 (Flow)
控制执行流程的节点。
#### Branch (分支)
条件判断,类似 if/else。
```
┌─ True ──→ [DoSomething]
[Branch]─┤
└─ False ─→ [DoOtherThing]
```
#### Sequence (序列)
按顺序执行多个分支。
```
┌─ Then 0 ──→ [Step1]
[Sequence]─┼─ Then 1 ──→ [Step2]
└─ Then 2 ──→ [Step3]
```
#### For Loop (循环)
循环执行指定次数。
```
[For Loop] ─Loop Body─→ [每次迭代执行]
└─ Completed ────→ [循环结束后执行]
```
| 输入 | 说明 |
|------|------|
| First Index | 起始索引 |
| Last Index | 结束索引 |
| 输出 | 说明 |
|------|------|
| Loop Body | 每次迭代执行 |
| Index | 当前索引 |
| Completed | 循环结束后执行 |
#### For Each (遍历)
遍历数组元素。
#### While Loop (条件循环)
当条件为真时持续循环。
#### Do Once (单次执行)
只执行一次,之后跳过。
#### Flip Flop (交替执行)
每次执行时交替触发 A 和 B 输出。
#### Gate (门)
可通过 Open/Close/Toggle 控制是否允许执行通过。
### 时间节点 (Time)
| 节点 | 功能 | 输出类型 |
|------|------|---------|
| **Delay** | 延迟指定时间后继续执行 | Exec |
| **Get Delta Time** | 获取帧间隔时间 | Number |
| **Get Time** | 获取运行总时间 | Number |
**示例:延迟 2 秒后执行**
```
[Event BeginPlay] ──→ [Delay] ──→ [Print]
└─ Duration: 2.0 └─ "2秒后执行"
```
### 数学节点 (Math)
| 节点 | 功能 |
|------|------|
| **Add / Subtract / Multiply / Divide** | 四则运算 |
| **Abs** | 绝对值 |
| **Clamp** | 限制在范围内 |
| **Lerp** | 线性插值 |
| **Min / Max** | 最小/最大值 |
| **Random Range** | 随机数 |
| **Sin / Cos / Tan** | 三角函数 |
### 调试节点 (Debug)
| 节点 | 功能 |
|------|------|
| **Print** | 输出到控制台 |
## 变量系统
变量用于在蓝图中存储和共享数据。
### 创建变量
1. 在变量面板点击 **+** 按钮
2. 输入变量名称
3. 选择变量类型
4. 设置默认值(可选)
### 使用变量
- **拖拽到画布** - 创建 Get 或 Set 节点
- **Get 节点** - 读取变量值
- **Set 节点** - 写入变量值
### 变量类型
| 类型 | 说明 | 默认值 |
|------|------|--------|
| Boolean | 布尔值 | false |
| Number | 数值 | 0 |
| String | 字符串 | "" |
| Entity | 实体引用 | null |
| Vector2 | 二维向量 | (0, 0) |
| Vector3 | 三维向量 | (0, 0, 0) |
### 变量节点错误状态
如果删除了一个变量,但画布上还有引用该变量的节点:
- 节点会显示 **红色边框****警告图标**
- 需要重新创建变量或删除这些节点
## 节点分组
可以将多个节点组织到一个可视化组框中,便于整理复杂蓝图。
### 创建组
1. 框选或 Ctrl+点击 选中多个节点(至少 2 个)
2. 右键点击选中的节点
3. 选择 **创建分组**
4. 组框会自动包裹所有选中的节点
### 组操作
| 操作 | 方式 |
|------|------|
| 移动组 | 拖拽组框头部,所有节点一起移动 |
| 取消分组 | 右键点击组框 → **取消分组** |
### 特性
- **动态大小**:组框会自动调整大小以包裹所有节点
- **独立移动**:可以单独移动组内的节点,组框会自动调整
- **仅编辑器**:组是纯视觉组织,不影响运行时逻辑
## 快捷键
| 快捷键 | 功能 |
|--------|------|
| `Ctrl + S` | 保存蓝图 |
| `Ctrl + Z` | 撤销 |
| `Ctrl + Shift + Z` | 重做 |
| `Ctrl + C` | 复制选中节点 |
| `Ctrl + X` | 剪切选中节点 |
| `Ctrl + V` | 粘贴节点 |
| `Delete` | 删除选中项 |
| `Ctrl + A` | 全选 |
## 保存与加载
### 保存蓝图
1. 点击工具栏 **保存** 按钮
2. 选择保存位置(**必须保存在 `assets/resources` 目录下**,否则 Cocos Creator 无法动态加载)
3. 文件扩展名为 `.blueprint.json`
> **重要提示**:蓝图文件必须放在 `resources` 目录下,游戏运行时才能通过 `cc.resources.load()` 加载。
### 加载蓝图
1. 点击工具栏 **打开** 按钮
2. 选择 `.blueprint.json` 文件
### 蓝图文件格式
蓝图保存为 JSON 格式,可与 `@esengine/blueprint` 运行时兼容:
```json
{
"version": 1,
"type": "blueprint",
"metadata": {
"name": "PlayerController",
"description": "玩家控制逻辑"
},
"variables": [],
"nodes": [],
"connections": []
}
```
## 实战示例
### 示例 1移动控制
实现每帧移动实体:
```
[Event Tick] ─Exec─→ [Get Self] ─Entity─→ [Get Component: Transform]
[Get Delta Time] ▼
│ [Set Property: x]
│ │
[Multiply] ◄──────────────┘
└─ Speed: 100
```
### 示例 2生命值系统
受伤后检查死亡:
```
[On Damage Event] ─→ [Get Component: Health] ─→ [Get Property: current]
[Subtract]
[Set Property: current]
┌─ True ─→ [Destroy Self]
[Branch]─┤
└─ False ─→ (继续)
[Less Or Equal]
current <= 0
```
### 示例 3延迟生成
每 2 秒生成一个敌人:
```
[Event BeginPlay] ─→ [Do N Times] ─Loop─→ [Delay: 2.0] ─→ [Create Entity: Enemy]
└─ N: 10
```
## 常见问题
### Q: 节点无法连接?
检查引脚类型是否匹配。执行引脚(白色)只能连接执行引脚,数据引脚需要类型兼容。
### Q: 蓝图不执行?
1. 确保实体添加了 `BlueprintComponent`
2. 确保场景添加了 `BlueprintSystem`
3. 检查 `autoStart` 是否为 `true`
### Q: 如何调试?
使用 **Print** 节点输出变量值到控制台。
## 下一步
- [ECS 节点参考](./nodes) - 完整节点列表
- [自定义节点](./custom-nodes) - 创建自定义节点
- [运行时集成](./vm) - 蓝图虚拟机 API
- [实际示例](./examples) - 更多游戏逻辑示例

View File

@@ -5,7 +5,18 @@ description: "与 ECS 框架深度集成的可视化脚本系统"
`@esengine/blueprint` 提供与 ECS 框架深度集成的可视化脚本系统,支持通过节点式编程控制实体行为。
## 安装
## 编辑器下载
> **内测中**:蓝图编辑器目前处于内测阶段,需要激活码才能使用。
> 请加入 QQ 群 **481923584** 后私聊群主获取激活码。
Cocos Creator 蓝图编辑器插件:
**[下载 Cocos Node Editor v1.0.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.0.0)**
详细使用教程请参考 [编辑器使用指南](./editor-guide)。
## 安装运行时
```bash
npm install @esengine/blueprint
@@ -144,6 +155,7 @@ interface BlueprintAsset {
## 文档导航
- [编辑器使用指南](./editor-guide) - Cocos Creator 蓝图编辑器教程
- [虚拟机 API](./vm) - BlueprintVM 与 ECS 集成
- [ECS 节点参考](./nodes) - 内置 ECS 操作节点
- [自定义节点](./custom-nodes) - 创建自定义 ECS 节点

View File

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

View File

@@ -1,5 +1,43 @@
# @esengine/node-editor
## 1.4.0
### Minor Changes
- [#438](https://github.com/esengine/esengine/pull/438) [`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b) Thanks [@esengine](https://github.com/esengine)! - 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)
## 1.3.0
### Minor Changes
- [`3bfb8a1`](https://github.com/esengine/esengine/commit/3bfb8a1c9baba18373717910d29f266a71c1f63e) Thanks [@esengine](https://github.com/esengine)! - feat(node-editor): add box selection and variable node error states
- Add box selection: drag on empty canvas to select multiple nodes
- Support Ctrl+drag for additive selection (add to existing selection)
- Add error state styling for invalid variable references (red border, warning icon)
- Support dynamic node title via `data.displayTitle`
- Support hiding inputs via `data.hiddenInputs` array
## 1.2.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/node-editor",
"version": "1.2.2",
"version": "1.4.0",
"description": "Universal node-based visual editor for blueprint, shader graph, and state machine",
"main": "dist/index.js",
"module": "dist/index.js",

View File

@@ -50,6 +50,15 @@ export interface GraphCanvasProps {
/** Canvas context menu callback (画布右键菜单回调) */
onContextMenu?: (position: Position, e: React.MouseEvent) => void;
/** Canvas mouse down callback for box selection (画布鼠标按下回调,用于框选) */
onMouseDown?: (position: Position, e: React.MouseEvent) => void;
/** Canvas mouse move callback (画布鼠标移动回调) */
onCanvasMouseMove?: (position: Position, e: React.MouseEvent) => void;
/** Canvas mouse up callback (画布鼠标释放回调) */
onCanvasMouseUp?: (position: Position, e: React.MouseEvent) => void;
/** Children to render (要渲染的子元素) */
children?: React.ReactNode;
}
@@ -75,6 +84,9 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
onZoomChange,
onClick,
onContextMenu,
onMouseDown: onMouseDownProp,
onCanvasMouseMove,
onCanvasMouseUp,
children
}) => {
const containerRef = useRef<HTMLDivElement>(null);
@@ -132,22 +144,30 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
}, [zoom, pan, minZoom, maxZoom, updateZoom, updatePan]);
/**
* Handles mouse down for panning
* 处理鼠标按下开始平移
* Handles mouse down for panning or box selection
* 处理鼠标按下开始平移或框选
*/
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Middle mouse button or space + left click for panning
// 中键或空格+左键平移
// Middle mouse button or Alt + left click for panning
// 中键或 Alt+左键平移
if (e.button === 1 || (e.button === 0 && e.altKey)) {
e.preventDefault();
setIsPanning(true);
lastMousePos.current = new Position(e.clientX, e.clientY);
} else if (e.button === 0) {
// Left click on canvas background - start box selection
// 左键点击画布背景 - 开始框选
const target = e.target as HTMLElement;
if (target === containerRef.current || target.classList.contains('ne-canvas-content')) {
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onMouseDownProp?.(canvasPos, e);
}
}
}, []);
}, [screenToCanvas, onMouseDownProp]);
/**
* Handles mouse move for panning
* 处理鼠标移动进行平移
* Handles mouse move for panning or box selection
* 处理鼠标移动进行平移或框选
*/
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isPanning) {
@@ -157,13 +177,27 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
const newPan = new Position(pan.x + dx, pan.y + dy);
updatePan(newPan);
}
}, [isPanning, pan, updatePan]);
// Always call canvas mouse move for box selection
// 始终调用画布鼠标移动回调用于框选
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onCanvasMouseMove?.(canvasPos, e);
}, [isPanning, pan, updatePan, screenToCanvas, onCanvasMouseMove]);
/**
* Handles mouse up to stop panning
* 处理鼠标释放停止平移
* Handles mouse up to stop panning or box selection
* 处理鼠标释放停止平移或框选
*/
const handleMouseUp = useCallback(() => {
const handleMouseUp = useCallback((e: React.MouseEvent) => {
setIsPanning(false);
const canvasPos = screenToCanvas(e.clientX, e.clientY);
onCanvasMouseUp?.(canvasPos, e);
}, [screenToCanvas, onCanvasMouseUp]);
/**
* Handles mouse leave to stop panning
* 处理鼠标离开停止平移
*/
const handleMouseLeave = useCallback(() => {
setIsPanning(false);
}, []);
@@ -276,7 +310,7 @@ export const GraphCanvas: React.FC<GraphCanvasProps> = ({
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onContextMenu={handleContextMenu}
>

View File

@@ -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;
}
/**
@@ -80,6 +88,16 @@ interface ConnectionDragState {
isValid?: boolean;
}
/**
* Box selection state
* 框选状态
*/
interface BoxSelectState {
startPos: Position;
currentPos: Position;
additive: boolean;
}
/**
* NodeEditor - Complete node graph editor component
* NodeEditor - 完整的节点图编辑器组件
@@ -102,7 +120,9 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onNodeDoubleClick: _onNodeDoubleClick,
onCanvasContextMenu,
onNodeContextMenu,
onConnectionContextMenu
onConnectionContextMenu,
onGroupContextMenu,
onGroupDoubleClick
}) => {
// Silence unused variable warnings (消除未使用变量警告)
void _templates;
@@ -126,6 +146,11 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
const [dragState, setDragState] = useState<DragState | null>(null);
const [connectionDrag, setConnectionDrag] = useState<ConnectionDragState | null>(null);
const [hoveredPin, setHoveredPin] = useState<Pin | null>(null);
const [boxSelectState, setBoxSelectState] = useState<BoxSelectState | null>(null);
// Track if box selection just ended to prevent click from clearing selection
// 跟踪框选是否刚刚结束,以防止 click 清除选择
const boxSelectJustEndedRef = useRef(false);
// Force re-render after mount to ensure connections are drawn correctly
// 挂载后强制重渲染以确保连接线正确绘制
@@ -137,6 +162,64 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
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<Set<string>>(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<string, Position>;
} | 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 完全渲染
@@ -144,7 +227,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
forceUpdate(n => n + 1);
});
return () => cancelAnimationFrame(rafId);
}, [graph.id, collapsedNodesKey]);
}, [graph.id, collapsedNodesKey, groupsKey]);
/**
* Converts screen coordinates to canvas coordinates
@@ -166,6 +249,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
*
* 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量
* 当节点收缩时,返回节点头部的位置
* 当节点在折叠组中时,返回组节点的位置
*/
const getPinPosition = useCallback((pinId: string): Position | undefined => {
// First, find which node this pin belongs to
@@ -304,6 +388,22 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
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;
@@ -315,7 +415,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
isValid
} : null);
}
}, [graph, dragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
/**
* Handles mouse up to end dragging
@@ -327,6 +427,11 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
setDragState(null);
}
// End group dragging (结束组拖拽)
if (groupDragState) {
setGroupDragState(null);
}
// End connection dragging (结束连接拖拽)
if (connectionDrag) {
// Use hoveredPin directly instead of relying on async state update
@@ -361,7 +466,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
setConnectionDrag(null);
}
}, [graph, dragState, connectionDrag, hoveredPin, onGraphChange]);
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, onGraphChange]);
/**
* Handles pin mouse down
@@ -472,13 +577,95 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
}
}, [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<string, Position>();
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
* 处理画布点击取消选择
*/
const handleCanvasClick = useCallback((_position: Position, _e: React.MouseEvent) => {
// Skip if box selection just ended (click fires after mouseup)
// 如果框选刚刚结束则跳过click 在 mouseup 之后触发)
if (boxSelectJustEndedRef.current) {
boxSelectJustEndedRef.current = false;
return;
}
if (!readOnly) {
onSelectionChange?.(new Set(), new Set());
setSelectedGroupIds(new Set());
}
}, [readOnly, onSelectionChange]);
@@ -490,6 +677,79 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onCanvasContextMenu?.(position, e);
}, [onCanvasContextMenu]);
/**
* Handles box selection start
* 处理框选开始
*/
const handleBoxSelectStart = useCallback((position: Position, e: React.MouseEvent) => {
if (readOnly) return;
setBoxSelectState({
startPos: position,
currentPos: position,
additive: e.ctrlKey || e.metaKey
});
}, [readOnly]);
/**
* Handles box selection move
* 处理框选移动
*/
const handleBoxSelectMove = useCallback((position: Position) => {
if (boxSelectState) {
setBoxSelectState(prev => prev ? { ...prev, currentPos: position } : null);
}
}, [boxSelectState]);
/**
* Handles box selection end
* 处理框选结束
*/
const handleBoxSelectEnd = useCallback(() => {
if (!boxSelectState) return;
const { startPos, currentPos, additive } = boxSelectState;
// Calculate selection box bounds
const minX = Math.min(startPos.x, currentPos.x);
const maxX = Math.max(startPos.x, currentPos.x);
const minY = Math.min(startPos.y, currentPos.y);
const maxY = Math.max(startPos.y, currentPos.y);
// Find nodes within the selection box
const nodesInBox: string[] = [];
const nodeWidth = 200; // Approximate node width
const nodeHeight = 100; // Approximate node height
for (const node of graph.nodes) {
const nodeLeft = node.position.x;
const nodeTop = node.position.y;
const nodeRight = nodeLeft + nodeWidth;
const nodeBottom = nodeTop + nodeHeight;
// Check if node intersects with selection box
const intersects = !(nodeRight < minX || nodeLeft > maxX || nodeBottom < minY || nodeTop > maxY);
if (intersects) {
nodesInBox.push(node.id);
}
}
// Update selection
if (additive) {
// Add to existing selection
const newSelection = new Set(selectedNodeIds);
nodesInBox.forEach(id => newSelection.add(id));
onSelectionChange?.(newSelection, new Set());
} else {
// Replace selection
onSelectionChange?.(new Set(nodesInBox), new Set());
}
// Mark that box selection just ended to prevent click from clearing selection
// 标记框选刚刚结束,以防止 click 清除选择
boxSelectJustEndedRef.current = true;
setBoxSelectState(null);
}, [boxSelectState, graph.nodes, selectedNodeIds, onSelectionChange]);
/**
* Handles pin value change
* 处理引脚值变化
@@ -546,7 +806,25 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onContextMenu={handleCanvasContextMenu}
onPanChange={handlePanChange}
onZoomChange={handleZoomChange}
onMouseDown={handleBoxSelectStart}
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 => (
<MemoizedGroupNodeComponent
key={group.id}
group={group}
isSelected={selectedGroupIds.has(group.id)}
isDragging={groupDragState?.groupId === group.id}
onSelect={handleGroupSelect}
onDragStart={handleGroupDragStart}
onContextMenu={handleGroupContextMenu}
onDoubleClick={handleGroupDoubleClick}
/>
))}
{/* Connection layer (连接层) */}
<ConnectionLayer
connections={graph.connections}
@@ -558,7 +836,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onConnectionContextMenu={handleConnectionContextMenu}
/>
{/* Nodes (节点) */}
{/* All Nodes (所有节点) */}
{graph.nodes.map(node => (
<MemoizedGraphNodeComponent
key={node.id}
@@ -580,6 +858,19 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onToggleCollapse={handleToggleCollapse}
/>
))}
{/* Box selection overlay (框选覆盖层) */}
{boxSelectState && (
<div
className="ne-selection-box"
style={{
left: Math.min(boxSelectState.startPos.x, boxSelectState.currentPos.x),
top: Math.min(boxSelectState.startPos.y, boxSelectState.currentPos.y),
width: Math.abs(boxSelectState.currentPos.x - boxSelectState.startPos.x),
height: Math.abs(boxSelectState.currentPos.y - boxSelectState.startPos.y)
}}
/>
)}
</GraphCanvas>
</div>
);

View File

@@ -64,6 +64,8 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
return draggingFromPin.canConnectTo(pin);
}, [draggingFromPin]);
const hasError = Boolean(node.data.invalidVariable);
const classNames = useMemo(() => {
const classes = ['ne-node'];
if (isSelected) classes.push('selected');
@@ -71,8 +73,9 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
if (node.isCollapsed) classes.push('collapsed');
if (node.category === 'comment') classes.push('comment');
if (executionState !== 'idle') classes.push(executionState);
if (hasError) classes.push('has-error');
return classes.join(' ');
}, [isSelected, isDragging, node.isCollapsed, node.category, executionState]);
}, [isSelected, isDragging, node.isCollapsed, node.category, executionState, hasError]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
@@ -102,8 +105,10 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
: undefined;
// Separate exec pins from data pins
// Also filter out pins listed in data.hiddenInputs
const hiddenInputs = (node.data.hiddenInputs as string[]) || [];
const inputExecPins = node.inputPins.filter(p => p.isExec && !p.hidden);
const inputDataPins = node.inputPins.filter(p => !p.isExec && !p.hidden);
const inputDataPins = node.inputPins.filter(p => !p.isExec && !p.hidden && !hiddenInputs.includes(p.name));
const outputExecPins = node.outputPins.filter(p => p.isExec && !p.hidden);
const outputDataPins = node.outputPins.filter(p => !p.isExec && !p.hidden);
@@ -129,13 +134,17 @@ export const GraphNodeComponent: React.FC<GraphNodeComponentProps> = ({
className={`ne-node-header ${node.category}`}
style={headerStyle}
>
{/* Diamond icon for event nodes, or custom icon */}
{/* Warning icon for invalid nodes, or diamond/custom icon */}
<span className="ne-node-header-icon">
{node.icon && renderIcon ? renderIcon(node.icon) : null}
{hasError ? (
<span className="ne-node-warning-icon" title={`Variable '${node.data.variableName}' not found`}></span>
) : (
node.icon && renderIcon ? renderIcon(node.icon) : null
)}
</span>
<span className="ne-node-header-title">
{node.title}
{(node.data.displayTitle as string) || node.title}
{node.subtitle && (
<span className="ne-node-header-subtitle">
{node.subtitle}

View File

@@ -0,0 +1,124 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { NodeGroup } from '../../domain/models/NodeGroup';
import '../../styles/GroupNode.css';
export interface GroupNodeComponentProps {
/** The group to render */
group: NodeGroup;
/** Whether the group is selected */
isSelected?: boolean;
/** Whether the group is being dragged */
isDragging?: boolean;
/** Selection handler */
onSelect?: (groupId: string, additive: boolean) => 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<GroupNodeComponentProps> = ({
group,
isSelected = false,
isDragging = false,
onSelect,
onDragStart,
onContextMenu,
onDoubleClick
}) => {
const groupRef = useRef<HTMLDivElement>(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 (
<div
ref={groupRef}
className={classNames}
style={style}
data-group-id={group.id}
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
>
<div className="ne-group-box-header">
<span className="ne-group-box-title">{group.name}</span>
</div>
</div>
);
};
/**
* 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;

View File

@@ -4,3 +4,9 @@ export {
type GraphNodeComponentProps,
type NodeExecutionState
} from './GraphNodeComponent';
export {
GroupNodeComponent,
MemoizedGroupNodeComponent,
type GroupNodeComponentProps
} from './GroupNodeComponent';

View File

@@ -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<string, GraphNode>;
private readonly _connections: Connection[];
private readonly _groups: NodeGroup[];
private readonly _metadata: Record<string, unknown>;
constructor(
@@ -22,12 +24,14 @@ export class Graph {
name: string,
nodes: GraphNode[] = [],
connections: Connection[] = [],
metadata: Record<string, unknown> = {}
metadata: Record<string, unknown> = {},
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
};
}

View File

@@ -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<string, unknown> {
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<string, unknown>): 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
};
}

View File

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

View File

@@ -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 (值对象)

View File

@@ -38,6 +38,24 @@
z-index: var(--ne-z-dragging);
}
/* Error state for invalid variable references */
.ne-node.has-error {
border-color: #e74c3c;
box-shadow: 0 0 0 1px #e74c3c,
0 0 12px rgba(231, 76, 60, 0.4),
0 4px 8px rgba(0, 0, 0, 0.4);
}
.ne-node.has-error .ne-node-header {
background: linear-gradient(180deg, #c0392b 0%, #962d22 100%) !important;
}
.ne-node-warning-icon {
color: #f1c40f;
font-size: 14px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
/* ==================== Node Header 节点头部 ==================== */
.ne-node-header {
display: flex;
@@ -263,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;
}

View File

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

View File

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

View File

@@ -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} 未被占用`);
}

View File

@@ -1,5 +1,32 @@
# @esengine/blueprint
## 4.4.0
### Minor Changes
- [#438](https://github.com/esengine/esengine/pull/438) [`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b) Thanks [@esengine](https://github.com/esengine)! - 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)
## 4.3.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/blueprint",
"version": "4.3.0",
"version": "4.4.0",
"description": "Visual scripting system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
"main": "dist/index.js",
"module": "dist/index.js",

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
/**
* @zh 逻辑节点 - 比较和逻辑运算节点
* @en Logic Nodes - Comparison and logical operation nodes
*/
export * from './ComparisonNodes';

View File

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

View File

@@ -1,5 +1,12 @@
# @esengine/fsm
## 8.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 7.0.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/fsm",
"version": "7.0.0",
"version": "8.0.0",
"description": "Finite State Machine for ECS Framework / ECS 框架的有限状态机",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,12 @@
# @esengine/network
## 9.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 8.0.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/network",
"version": "8.0.0",
"version": "9.0.0",
"description": "Network synchronization for multiplayer games",
"esengine": {
"plugin": true,

View File

@@ -1,5 +1,12 @@
# @esengine/pathfinding
## 8.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 7.0.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/pathfinding",
"version": "7.0.0",
"version": "8.0.0",
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,12 @@
# @esengine/procgen
## 8.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 7.0.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/procgen",
"version": "7.0.0",
"version": "8.0.0",
"description": "Procedural generation tools for ECS Framework / ECS 框架的程序化生成工具",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,12 @@
# @esengine/spatial
## 8.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 7.0.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/spatial",
"version": "7.0.0",
"version": "8.0.0",
"description": "Spatial query and indexing system for ECS Framework / ECS 框架的空间查询和索引系统",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,12 @@
# @esengine/timer
## 8.0.0
### Patch Changes
- Updated dependencies [[`0d33cf0`](https://github.com/esengine/esengine/commit/0d33cf00977d16e6282931aba2cf771ec2c84c6b)]:
- @esengine/blueprint@4.4.0
## 7.0.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/timer",
"version": "7.0.0",
"version": "8.0.0",
"description": "Timer and cooldown system for ECS Framework / ECS 框架的定时器和冷却系统",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,16 @@
# @esengine/demos
## 1.0.14
### Patch Changes
- Updated dependencies []:
- @esengine/fsm@8.0.0
- @esengine/pathfinding@8.0.0
- @esengine/procgen@8.0.0
- @esengine/spatial@8.0.0
- @esengine/timer@8.0.0
## 1.0.13
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/demos",
"version": "1.0.13",
"version": "1.0.14",
"private": true,
"description": "Demo tests for ESEngine modules documentation",
"type": "module",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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