Merge remote-tracking branch 'refs/remotes/origin/dev' into codex/pcg-terrain-world-generation
# Conflicts: # src/modules/graphics/RenderSystem3D.cpp # src/modules/graphics/RenderSystem3D.h
This commit is contained in:
commit
37376899a5
204 files changed
+30565
-639
No files matched your search
@@ -61,7 +61,8 @@ eve_declare_module(NAME particles LAYER 5 SCRIPT Particles SLOT particles
|
||||
# surfaces, droplet coalescence) with screen-space surface reconstruction. Its
|
||||
# accelerator provider has an independent lifetime from physics_cloth.
|
||||
eve_declare_module(NAME fluids LAYER 5 SCRIPT Fluids SLOT fluids
|
||||
DEPS gpgpu graphics physics physics_backend
|
||||
DEPS gpgpu graphics image physics physics_backend
|
||||
OPTIONAL_DEPS model3d
|
||||
GROUP 3d web)
|
||||
eve_declare_module(NAME procgen LAYER 5 SCRIPT Procgen SLOT procgen
|
||||
DEPS gpgpu graphics image map transaction
|
||||
@@ -150,7 +151,8 @@ eve_declare_module(NAME definitions_editor LAYER 7 DEPS definitions_editing edit
|
||||
eve_declare_module(NAME dialogue_editor LAYER 7 DEPS audio_editor dialogue_editing editor GROUP 3d web)
|
||||
eve_declare_module(NAME graphics_editor LAYER 7 SCRIPT GraphicsEditorModule SLOT graphicsEditor
|
||||
DEPS editor graphics graphics_editing GROUP 3d web)
|
||||
eve_declare_module(NAME fluids_editor LAYER 7 DEPS editor fluids fluids_editing graphics_editor GROUP 3d web)
|
||||
eve_declare_module(NAME fluids_editor LAYER 7 SCRIPT FluidsEditorModule SLOT fluidsEditor
|
||||
DEPS editor fluids fluids_editing graphics_editor GROUP 3d web)
|
||||
eve_declare_module(NAME hd2d_editor LAYER 7 DEPS editor hd2d_editing GROUP 3d)
|
||||
eve_declare_module(NAME housegen_editor LAYER 7 DEPS domain_gizmo_editor editor housegen_editing GROUP 3d)
|
||||
eve_declare_module(NAME archspace_editor LAYER 7
|
||||
|
||||
@@ -97,7 +97,7 @@ eve_declare_module(NAME vehicle LAYER 5 SCRIPT Vehicle SLOT vehicle
|
||||
GROUP 2d 3d)
|
||||
# L4 -- rendering extensions and simulation (continued)
|
||||
eve_declare_module(NAME animation LAYER 4 SCRIPT Animation SLOT anim
|
||||
DEPS data filesystem graphics image model3d
|
||||
DEPS action data filesystem graphics image model3d
|
||||
THIRDPARTY poco assimp
|
||||
GROUP 2d 3d)
|
||||
eve_declare_module(NAME daynight LIB EVDayNight LAYER 4 SCRIPT DayNight SLOT daynight
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
# 感知与目标选择系统设计
|
||||
|
||||
日期:2026-09-14(2026-09-15 修订:统一查询面)
|
||||
状态:提案(对照 UE5 GameplayTargetingSystem,基于现有 `sensing` / `Targeting*` 协议演进)
|
||||
适用范围:`sensing`、`action` 组合、RTS/武器/AI 等消费者;不含编辑器 `EditableTarget`、动画 retarget。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
EVEngine **已经有**与 UE5 Targeting System 同层的协议骨架,而不是“完全没有对应功能”:
|
||||
|
||||
| 层 | 现状 | 对标 UE5 |
|
||||
|---|---|---|
|
||||
| 候选事实镜像 | `SensingWorld`(2D circle/box + tag/faction/visibleTo) | Selection 粗查的一部分 |
|
||||
| 坐标安全约束解析 | `TargetingSpec` / `TargetingQuery` / `TargetingResolver` + `TargetSet` | Filter 管线的一部分 |
|
||||
| 能力注入 | `ISensingCandidateProvider`、`ILineOfSightQuery` | Task 依赖的 World/Subsystem 服务 |
|
||||
| Action 组合 | `TargetingMode::{None,Explicit,Query}` + `SensingTargetingAdapter` | GA `PerformTargetingRequest` 的薄适配 |
|
||||
|
||||
真正缺的不是再造一套索敌器,而是把双轨收成:
|
||||
|
||||
> **一份候选事实(`SensingWorld`)+ 一套可配置查询(`QuerySpec` / 日后 Preset)→ 有序候选结果**
|
||||
|
||||
终态上,`SensingWorld` 是**完整、可配置的候选查询组件**;`ISensingCandidateProvider` / 现有 `TargetingResolver` 降为兼容壳或同构前端,而不是长期并行的第二真相源。
|
||||
|
||||
**完整**指查询能力完整(形状、过滤轴、排序/截断、可选 LOS),**不是**玩法决策完整。架构宪章不变:`sensing` 只返回候选与可解释分数;技能 / 武器 / 卡牌 / RTS 命令保留最终选择语义(checklist §23、`组合式游戏机制架构.md` §5.7)。
|
||||
|
||||
**不要**照搬 UE5 的 Actor/`FHitResult` 中心模型,也不要把威胁表、软锁、职业克制写死进 `SensingWorld`。
|
||||
|
||||
## 2. UE5 GameplayTargetingSystem 要点
|
||||
|
||||
插件提供数据驱动请求:
|
||||
|
||||
1. **`UTargetingPreset`**:有序 Task 列表(Selection → Filter → Sort)。
|
||||
2. **`FTargetingSourceContext`**:源 Actor / 位置 / Instigator / Socket。
|
||||
3. **`UTargetingSubsystem`**:同步 `Execute*` 或异步 `StartAsync*`,用 `FTargetingRequestHandle` 取结果并释放。
|
||||
4. **内置任务**:AOE 选择、基础过滤、按距离排序等;可扩展自定义 Filter/Sort。
|
||||
5. **结果**:候选列表 +(Sort 后)每项 `Score`;Gameplay Ability 可走 `PerformTargetingRequest` / `PerformFilteringRequest`。
|
||||
|
||||
价值不在“能查到附近单位”(我们已有),而在:
|
||||
|
||||
- 同一套配置可被技能、AI、投射物、UI 复用;
|
||||
- 选择 / 过滤 / 打分可插拔,不改调用方;
|
||||
- 排序分数可调试、可解释。
|
||||
|
||||
## 3. 现状与差距
|
||||
|
||||
### 3.1 已具备(保留)
|
||||
|
||||
- 强类型坐标空间:`World2D/3D`、`Grid2D/3D`,禁止隐式混用。
|
||||
- `TargetingSpec`:domain、min/maxCount、range、required/excluded tags、zone、world/grid area、LOS 模式。
|
||||
- `TargetSet`:subjects + 可选 primary / point / area(resolver **从不**隐式设 primary)。
|
||||
- Capability:缺 provider → `Unsupported`,不假装空成功。
|
||||
- Physics LOS adapter(World3D);Action Query 模式已接入。
|
||||
- RTS `CombatFireSystem` 用 `SensingWorld.circle` + 局内 priority 表做自动索敌(策略在 RTS,不在 sensing)。
|
||||
|
||||
### 3.2 主要缺口
|
||||
|
||||
| 缺口 | 说明 |
|
||||
|---|---|
|
||||
| 双轨语义不一致 | `SensingWorld` 用 faction/`visibleTo`/distance+limit;`Targeting*` 用 `TargetDomain`/zone/LOS/`maxCount` 硬失败。不是同构 API,不能“薄桥即等价” |
|
||||
| 无统一 QuerySpec | circle/box 参数与 `TargetingSpec` 字段分裂;调用方无法用同一配置描述需求 |
|
||||
| 无 Preset / Task 管线 | 约束写死在 Spec + resolver;无法像 UE5 那样组合 Selection/Filter/Sort |
|
||||
| 无排序分数 / top-K 协议 | World 有 limit 截断;Resolver 的 maxCount 是整组失败;两套结果策略 |
|
||||
| 查询形状偏少 | World 仅 2D circle/box;无 cone/sector/capsule;无朝向 |
|
||||
| 未用 `spatial` | 模块 `DEPS spatial`,实现仍是线性扫描 |
|
||||
| 无脚本 Targeting 绑定 | 仅 World 脚本面;`Targeting*` 无 Squirrel API |
|
||||
| 周边未接满 | 武器 lock-on、npc_ai PerceptionMemory、Zone→sensing 绑定仍在各自 gap 文档中 |
|
||||
|
||||
### 3.3 明确不放进 sensing 核心
|
||||
|
||||
- Soft-lock / sticky aim / magnet / 手柄吸附
|
||||
- 威胁表、仇恨、职业克制
|
||||
- 导弹制导轨迹(武器模块)
|
||||
- UI 卡牌拖拽合法性(`card` 只管手势)
|
||||
|
||||
## 4. 目标架构:统一可配置查询面
|
||||
|
||||
```text
|
||||
upsert/remove 镜像事实
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ SensingWorld(唯一候选事实库) │
|
||||
│ + QuerySpec / 日后 TargetingPreset │
|
||||
│ + 可选注入:阵营关系、LOS、spatial │
|
||||
└──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
CandidateSet / RankedCandidateSet ← 无隐式 primary
|
||||
│
|
||||
├─ Action(兼容 TargetingQuery 壳)
|
||||
├─ RTS 索敌(priority 仍可在外层)
|
||||
├─ Weapon / AI / Script
|
||||
└─ ISensingCandidateProvider 适配器(兼容旧 Capability 消费者)
|
||||
```
|
||||
|
||||
### 4.1 SensingWorld 的职责边界
|
||||
|
||||
**负责:**
|
||||
|
||||
- 持有镜像事实:`SubjectRef`/稳定 id、位置、faction、tags、`visibleTo`、可选 zones
|
||||
- 按 `QuerySpec`(或 Preset)执行查询并返回**有序候选**
|
||||
- 可选 capability:LOS、spatial 广相
|
||||
- 快照 / 恢复
|
||||
|
||||
**不负责:**
|
||||
|
||||
- 选定 primary / “最佳目标”
|
||||
- 题材威胁分、软锁、技能合法性
|
||||
- 隐式 World↔Grid 坐标换算
|
||||
|
||||
### 4.2 QuerySpec(Phase 1 核心类型)
|
||||
|
||||
一次查询的完整、可配置描述;替换“circle/box 一长串 CSV 参数”与“另一套 TargetingSpec”的分裂。
|
||||
|
||||
```cpp
|
||||
/** @brief How overflow relative to maxCount is handled. */
|
||||
enum class CountPolicy : std::uint8_t {
|
||||
FailIfOutOfRange, // current TargetingResolver behavior (default for Action compat)
|
||||
TruncateToMax, // current SensingWorld limit behavior
|
||||
};
|
||||
|
||||
/** @brief One configurable candidate query against SensingWorld. */
|
||||
struct QuerySpec {
|
||||
CoordinateSpace space = CoordinateSpace::World2D;
|
||||
|
||||
// Geometry (exactly one broad shape, or range-only around origin)
|
||||
std::optional<WorldArea> worldArea;
|
||||
std::optional<GridArea> gridArea;
|
||||
float minRange = 0.f;
|
||||
float maxRange = std::numeric_limits<float>::infinity();
|
||||
|
||||
// Fact filters
|
||||
TargetDomain domain = TargetDomain::Any; // needs relation projection when used
|
||||
std::vector<std::string> requiredTags;
|
||||
std::vector<std::string> excludedTags;
|
||||
std::vector<std::string> includeFactions; // string facts as stored
|
||||
std::vector<std::string> excludeFactions;
|
||||
std::optional<std::string> visibleTo; // observer key; not geometric LOS
|
||||
std::optional<ZoneRef> zone;
|
||||
|
||||
// Result policy
|
||||
std::uint32_t minCount = 0;
|
||||
std::uint32_t maxCount = std::numeric_limits<std::uint32_t>::max();
|
||||
CountPolicy countPolicy = CountPolicy::TruncateToMax;
|
||||
LineOfSightMode lineOfSight = LineOfSightMode::NotRequired;
|
||||
|
||||
// Ordering (Phase 1: distance only; Phase 2: named sort tasks)
|
||||
enum class SortKey : std::uint8_t { None, DistanceAscending };
|
||||
SortKey sortKey = SortKey::DistanceAscending;
|
||||
|
||||
[[nodiscard]] Result<void> validate() const;
|
||||
};
|
||||
|
||||
struct QuerySource {
|
||||
SubjectRef origin; // required when domain≠Any or LOS required
|
||||
TargetLocation originLocation; // space must match spec.space
|
||||
// optional: opaque socket token for adapters
|
||||
};
|
||||
|
||||
struct RankedCandidate {
|
||||
SubjectRef subject;
|
||||
TargetLocation location;
|
||||
float score = 0.f; // Phase 1: -distance when SortKey::DistanceAscending
|
||||
std::string scoreReason; // e.g. "distance"
|
||||
};
|
||||
|
||||
class CandidateQueryResult {
|
||||
public:
|
||||
[[nodiscard]] std::span<const RankedCandidate> ranked() const noexcept;
|
||||
[[nodiscard]] TargetSet asTargetSet() const; // strips scores; no primary
|
||||
};
|
||||
```
|
||||
|
||||
`SensingWorld` 新入口:
|
||||
|
||||
```cpp
|
||||
[[nodiscard]] Result<CandidateQueryResult> query(const QuerySource& source,
|
||||
const QuerySpec& spec) const;
|
||||
```
|
||||
|
||||
保留现有 `circle`/`box` 为 **QuerySpec 的语法糖**(内部组 spec,不维护第二套过滤实现)。
|
||||
|
||||
### 4.3 语义裂缝:必须显式处理,不能默认同构
|
||||
|
||||
| 轴 | SensingWorld 旧语义 | Targeting* 旧语义 | 统一后约定 |
|
||||
|---|---|---|---|
|
||||
| 敌我 | faction 字符串 include/exclude | `TargetDomain` 相对关系 | 两者都保留;`domain≠Any` 时要求注入 `IFactionRelation`(或等价回调),缺则 `Unsupported` |
|
||||
| 可见 | `visibleTo` 观察者键 | 几何 LOS capability | **正交**:可同时配置;禁止互相冒充 |
|
||||
| 数量 | `limit` 截断 | `maxCount` 硬失败 | `CountPolicy` 显式选择 |
|
||||
| 空间 | 仅 World2D | 多空间 | 非 World2D 且 World 无该空间事实 → `Unsupported`,不静默降维 |
|
||||
| 身份 | `string id` | `SubjectRef` | 存储与结果统一为可解析的 `SubjectRef`;非法 id → 结构化失败 |
|
||||
|
||||
### 4.4 与 TargetingResolver / Provider 的关系
|
||||
|
||||
```text
|
||||
SensingWorld::query(QuerySpec) ← 唯一实现真相
|
||||
│
|
||||
├─ SensingWorldCandidateProvider ← 实现 ISensingCandidateProvider(兼容 Action Capability)
|
||||
├─ TargetingResolver ← 薄封装:TargetingQuery → QuerySpec → TargetSet
|
||||
└─ 脚本 circle/box ← 薄封装:参数 → QuerySpec
|
||||
```
|
||||
|
||||
Phase 1 即以此为目标:**先统一实现,再留兼容适配器**;不是“先永远双轨再薄桥”。
|
||||
|
||||
### 4.5 Phase 2+:Preset / Task 管线
|
||||
|
||||
在 QuerySpec 稳定后,把“固定字段配置”演进为 UE5 式有序任务:
|
||||
|
||||
```text
|
||||
SourceContext + TargetingPreset
|
||||
→ Selection / Filter / Sort tasks
|
||||
→ RankedCandidateSet
|
||||
```
|
||||
|
||||
内置任务最小集:
|
||||
|
||||
| Kind | Task id | 行为 |
|
||||
|---|---|---|
|
||||
| Selection | `sensing.select.world` | 从 `SensingWorld` 广相 |
|
||||
| Filter | `sensing.filter.spec` | 复用 QuerySpec 字段子集 |
|
||||
| Filter | `sensing.filter.los` | `ILineOfSightQuery` |
|
||||
| Filter | `sensing.filter.cone` | 朝向 + 半角 + 距离 |
|
||||
| Sort | `sensing.sort.distance` | 近→远 |
|
||||
| Sort | `sensing.sort.truncate` | top-K |
|
||||
|
||||
题材 Sort(威胁、职业)留在游戏模块注册表。
|
||||
|
||||
### 4.6 几何、spatial、异步
|
||||
|
||||
- 扩展 `WorldArea`:`cone2D` / `sector3D`;广相逐步换 `spatial`。
|
||||
- 第一期只做**同步**查询;异步 request handle 后置,且不得隐式写 ECS。
|
||||
|
||||
## 5. 与现有架构规则的对齐
|
||||
|
||||
| 规则 | 本设计如何满足 |
|
||||
|---|---|
|
||||
| sensing 不决定目标价值 | 只返回候选/分数;primary 仍由消费者显式设置 |
|
||||
| 短根 + Link | `SubjectRef` / `ZoneRef`;无 Actor 大根 |
|
||||
| Capability 注入 | 阵营关系、LOS、spatial、自定义 task 均可选注入;缺则 `Unsupported` |
|
||||
| `Result` + `[[nodiscard]]` | `query` / validate / 适配器全部结构化失败 |
|
||||
| 可选依赖可观测 | 不静默空成功 |
|
||||
| 确定性 | 同步路径;同分 tie-break 用 `SubjectRef` 字典序 |
|
||||
|
||||
## 6. 分阶段实施
|
||||
|
||||
### Phase 0 — 文档与契约(本提案)
|
||||
|
||||
- 固定终态:`SensingWorld` = 唯一事实库 + 可配置查询面。
|
||||
- 固定词汇:`QuerySource` / `QuerySpec` / `CountPolicy` / `CandidateQueryResult`。
|
||||
- 标明 Provider/Resolver 为兼容层,不是第二实现。
|
||||
|
||||
### Phase 1 — QuerySpec 统一查询面(已落地)
|
||||
|
||||
1. ✅ 在 `sensing` 引入 `QuerySpec` / `QueryOrigin` / `CandidateQueryResult` / `CountPolicy`。
|
||||
2. ✅ `SensingWorld::query` 成为唯一过滤/排序实现;`circle`/`box` 改为糖。
|
||||
3. ✅ `CountPolicy::{FailIfOutOfRange, TruncateToMax}`;糖路径保持 Truncate(兼容 RTS/脚本)。
|
||||
4. ✅ `TargetingSpec.countPolicy` + `TargetingResolver` 截断路径;Action 默认 `FailIfOutOfRange`。
|
||||
5. ✅ `SensingWorldCandidateProvider`:Capability 兼容适配器(读同一 World)。
|
||||
6. ✅ `domain≠Any` 且无 `FactionRelationFn` → `Unsupported`。
|
||||
7. ✅ 测试:`sensing.querySpecRanksAndHonorsCountPolicy`、`targeting.sensingWorldProviderSharesFactsAndRequiresDomainRelation`。
|
||||
|
||||
### Phase 2 — Preset / Task 注册表(已落地)
|
||||
|
||||
1. ✅ `ITargetingTask` + `TargetingPipeline` 注册表;内存 Preset(完整 Definition schema 后置)。
|
||||
2. ✅ `QueryCone` + `WorldArea::cone2D`;脚本 `executePreset`。
|
||||
3. ✅ 用户文档 `docs/usr/modules/sensing.md`。
|
||||
4. ✅ 内置任务:`sensing.select.world` / `filter.spec` / `filter.cone` / `filter.los` / `sort.distance` / `sort.truncate`;Preset `sensing.builtin.coneSelect`。
|
||||
|
||||
### Phase 3 — 消费者迁移(进行中)
|
||||
|
||||
1. ✅ RTS `CombatFireSystem` 索敌改为 `QuerySpec`;priority / stance 策略仍留在 RTS。
|
||||
2. ⏳ Weapon lock-on:当前 weapon 模块无独立 lock 获取路径(见武器差距分析 §H);待实现时走 `QuerySpec`/preset,aim assist 仍游戏侧。
|
||||
3. ✅ `PerceptionFact` / `perceptionFactsFrom` 中立投影;✅ npc_ai `perceptionMemoriesFrom` 适配;✅ Zone 成员镜像:`SensingWorld::setZones` → `TargetCandidate.zones`(`SensingWorldCandidateProvider` 支持 `TargetingSpec.zone` 过滤)。
|
||||
|
||||
### Phase 4 — 性能与调试(已落地核心)
|
||||
|
||||
1. ✅ 可选 `SpatialHash2D` 广相:`SensingWorld::setSpatialIndexEnabled`(默认关);`query` 在 circle/box/cone/有限 maxRange 时先广相再 `accepts`+range。
|
||||
2. ✅ MCP/debug 钩子:`debugLastQueryJson()`(schema `eve.sensing.lastQuery`:origin/shape/spatial stats/ranked scores);完整 editor overlay 仍可后置,不向上依赖 editor。
|
||||
3. ✅ 异步:当前同步路径足够;异步 request handle 仍后置,且不得隐式写 ECS。
|
||||
|
||||
## 7. API 草图(脚本)
|
||||
|
||||
```squirrel
|
||||
local world = eve.Sensing().newWorld().value;
|
||||
|
||||
// 糖:内部转为 QuerySpec(TruncateToMax + distance sort)
|
||||
local n = world.circle(x, y, 12.0, "combat-target", "", "", "player", "", 8).value;
|
||||
|
||||
// 显式配置面
|
||||
local ranked = world.query({
|
||||
origin = "unit.hero",
|
||||
x = x, y = y, space = "world2d",
|
||||
maxRange = 12.0,
|
||||
requiredTags = ["combat-target"],
|
||||
excludeFactions = ["player"],
|
||||
countPolicy = "truncate",
|
||||
maxCount = 8,
|
||||
sortKey = "distance"
|
||||
}).value;
|
||||
|
||||
local best = ranked.at(0); // score + subject;primary 仍不自动产生
|
||||
```
|
||||
|
||||
C++ Action 兼容:
|
||||
|
||||
```cpp
|
||||
// TargetingQuery 继续可用;内部映射到 QuerySpec + CountPolicy::FailIfOutOfRange
|
||||
definition.targetingMode = TargetingMode::Query;
|
||||
```
|
||||
|
||||
## 8. 测试与验收
|
||||
|
||||
- 同一 `SensingWorld` 事实:`circle` 糖与等价 `QuerySpec` 结果一致(含顺序)。
|
||||
- `TruncateToMax` vs `FailIfOutOfRange` 对照。
|
||||
- `domain≠Any` 且无关系注入 → `Unsupported`。
|
||||
- `visibleTo` 与 LOS 正交组合用例。
|
||||
- 非 World2D 查询在仅 2D 事实上 → `Unsupported`。
|
||||
- Action / RTS 回归:旧默认行为不变。
|
||||
- 架构门:无 sensing→physics/scene 泄漏;LOS 仍 capability。
|
||||
|
||||
## 9. 非目标
|
||||
|
||||
- 复制 UE5 `FHitResult` / Actor 数组作为一等结果。
|
||||
- 在 sensing 内实现完整瞄准辅助或摄像机 soft-lock。
|
||||
- 把 `SensingWorld` 做成通用 AI 决策 / 威胁评估组件。
|
||||
- 用一个 `gameplay` 大模块吞掉 sensing/action/weapon。
|
||||
- 把 `EditableTarget` / 动画 retarget 混进本协议。
|
||||
|
||||
## 10. 建议落地顺序(工程判断)
|
||||
|
||||
优先 **Phase 1:QuerySpec 统一查询面**,而不是“仅做 World→Provider 薄桥”。
|
||||
|
||||
理由:
|
||||
|
||||
1. 双轨的根因是**过滤语义不一致**,薄桥无法消除 faction↔domain、limit↔maxCount 裂缝。
|
||||
2. 把实现收进 `SensingWorld::query` 后,Provider/Resolver/脚本糖都变薄,后续 Preset 只是把 QuerySpec 字段拆成可插拔 Task。
|
||||
3. 仍可用适配器满足现有 Action Capability,不强迫所有消费者同 PR 迁移。
|
||||
|
||||
## 11. 相关文档
|
||||
|
||||
- `docs/dev/组合式游戏机制架构.md` §5.7 / §15
|
||||
- `docs/dev/2026-08-26-architecture-consolidation-checklist.md` §23
|
||||
- `docs/dev/领域短根继承与跨域组合架构.md`
|
||||
- `docs/dev/武器系统-对比Unity插件-差距分析.md` §H(lock-on)
|
||||
- `docs/dev/superpowers/specs/2026-08-30-npc-ai-framework.md`(perception adapter)
|
||||
- `docs/dev/editor-module-gap-analysis.md`(Zone→sensing)
|
||||
- 实现入口:`src/modules/sensing/Targeting.h`、`Sensing.h`;消费者:`action`、`rts`、`physics` LOS adapter
|
||||
@@ -17,7 +17,7 @@ EVEngine 的差异是:运行时和编辑器共享同一个版本化资产模
|
||||
并可附带一个 `ActionTimeline`:
|
||||
|
||||
- schema id:`eve.action.timeline`
|
||||
- 当前版本:`1`
|
||||
- 当前版本:`3`;v1/v2 资产在解码候选中迁移,保存时统一规范化为 v3
|
||||
- 动作总时长必须精确等于 `windup + active + recover`
|
||||
- 轨道类型:Animation、Gameplay、Effect、Audio、Camera、Movement、Tag、Custom
|
||||
- `ActionNotify` 表示单点事件;`ActionNotifyState` 表示有明确 Enter/Exit 的区间
|
||||
@@ -29,8 +29,9 @@ EVEngine 的差异是:运行时和编辑器共享同一个版本化资产模
|
||||
|
||||
## 持久化约定
|
||||
|
||||
编码器只接受 schema version 1,其他版本返回结构化 `Result` 错误;同版本未知字段在读取时忽略,
|
||||
规范化保存只写出 v1 已知字段。根对象的 `metadata` 与每个事件的 `payload` 是扩展数据的正式位置。
|
||||
编码器接受 v1-v3,未知更高版本返回结构化 `Result` 错误;同版本未知字段在读取时忽略,
|
||||
规范化保存只写出 v3 已知字段。v3 增加多动画分段的源裁剪、混合曲线、物理分段、循环/层/Foot IK 和 Root Motion 遮罩。
|
||||
根对象的 `metadata` 与每个事件的 `payload` 是扩展数据的正式位置。
|
||||
解码先构造并完整校验候选值,再发布结果,不会留下部分修改。
|
||||
|
||||
稳定 id 在轨道、Notify 和 NotifyState 范围内全局唯一。时间必须非负且落在动作时长内;区间要求
|
||||
@@ -53,10 +54,12 @@ EVEngine 的差异是:运行时和编辑器共享同一个版本化资产模
|
||||
- 每次修改通过 `action.timeline.replace` 可逆 DomainOperation 提交
|
||||
- Undo/Redo 通过 `LocalWorldAuthority` 与 `LocalTransactionBackend` 执行,不维护第二份资产状态
|
||||
- Play/Pause/Seek/Update 预览传输,以及本帧跨越的拥有型事件列表
|
||||
- 时间标尺、帧/时间吸附、围绕锚点缩放与有界平移
|
||||
- 轨道增删改名、静音和锁定,以及动画分段的 Trim In/Out 和 Blend Curve Inspector
|
||||
|
||||
预览宿主读取 `animationUri`、`previewTime()` 和 `previewEvents()`,驱动已有动画/场景/VFX/音频/相机适配器。
|
||||
当前切片提供完整的语义时间线和预览事件桥,但没有把某一种 3D Viewport 或根运动曲线渲染方式硬编码进
|
||||
Editor 模块;这些属于宿主表现层。
|
||||
运行宿主由 `ActionRuntime` 唯一负责 gameplay Enter/Update/Exit,`MontagePlayer` 只消费其 advance 并驱动多 Clip 姿态。
|
||||
`MontageCoordinator` 每层固定两个槽位做 ping-pong 混合,generation handle 在槽位复用后拒绝旧引用。运行时支持跳转、物理分段同步、
|
||||
暂停、中断混出、低帧率边界扫描和循环尾/头两段扫描。Clip 热更新先完整验证候选,再原子替换并保留当前时间、权重与执行身份。
|
||||
|
||||
## 生命周期与线程约定
|
||||
|
||||
|
||||
+1467
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,116 @@
|
||||
# Sensing 模块
|
||||
|
||||
**脚本入口:** `eve.Sensing()`
|
||||
|
||||
`Sensing` 持有独立的 `SensingWorld` 事实镜像,并提供可配置的候选查询(`QuerySpec`)与
|
||||
可组合的 Targeting Preset 管线。模块只返回有序候选与分数,**不会**选定 primary /
|
||||
“最佳目标”;技能、武器、RTS 命令等消费者保留最终选择语义。
|
||||
|
||||
## 基本用法
|
||||
|
||||
```squirrel
|
||||
local sensing = eve.Sensing();
|
||||
local created = sensing.newWorld();
|
||||
local world = created.value;
|
||||
|
||||
world.upsert("hero", 0.0, 0.0, "player", "unit", "");
|
||||
world.upsert("foe", 5.0, 0.0, "enemy", "unit,combat-target", "player");
|
||||
|
||||
// 糖:内部转为 QuerySpec(TruncateToMax + distance sort)
|
||||
local n = world.circle(0.0, 0.0, 12.0, "combat-target", "", "", "player", "", 8).value;
|
||||
local first = world.resultAt(0);
|
||||
|
||||
// Preset 管线(内置 cone 选择)
|
||||
local m = world.executePreset("sensing.builtin.coneSelect", 0.0, 0.0, 1.0, 0.0).value;
|
||||
```
|
||||
|
||||
## QuerySpec 与形状
|
||||
|
||||
`SensingWorld.query(origin, spec)` 是唯一过滤/排序实现面。`circle` / `box` 是糖。
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `shape` | `QueryCircle` / `QueryBox` / `QueryCone` / 空(仅按 origin 距离) |
|
||||
| `minRange` / `maxRange` | 相对 origin 的距离窗 |
|
||||
| `requiredTags` / `excludedTags` | 标签包含 / 排除 |
|
||||
| `includeFactions` / `excludeFactions` | 阵营字符串过滤 |
|
||||
| `visibleTo` | 观察者键(事实可见性,不是几何 LOS) |
|
||||
| `minCount` / `maxCount` + `countPolicy` | `TruncateToMax` 或 `FailIfOutOfRange` |
|
||||
| `sortKey` | `None` 或 `DistanceAscending`(分数为 `-distance`) |
|
||||
|
||||
`QueryCone`:顶点 `(x,y)`,朝向 `(dirX,dirY)`,`halfAngle` 为弧度 `[0, π]`,`range` 为非负距离。
|
||||
|
||||
## Targeting Preset 管线
|
||||
|
||||
`TargetingPipeline` 把有序 Select / Filter / Sort 任务组成 Preset:
|
||||
|
||||
| Task id | 行为 |
|
||||
|---|---|
|
||||
| `sensing.select.world` | 从 `SensingWorld` 广相(复用 QuerySpec) |
|
||||
| `sensing.filter.spec` | 按 QuerySpec 字段子集再过滤 |
|
||||
| `sensing.filter.cone` | 朝向 + 半角 + 距离 |
|
||||
| `sensing.filter.los` | 需要注入的 `ILineOfSightQuery` capability |
|
||||
| `sensing.sort.distance` | 近→远,写入 score |
|
||||
| `sensing.sort.truncate` | top-K |
|
||||
|
||||
内置 Preset:`sensing.builtin.coneSelect`(广相 → 45°锥 → 距离排序 → 截断 8)。
|
||||
|
||||
脚本 `world.executePreset(id, originX, originY, dirX, dirY)` 走共享 builtins 管线,并刷新 `resultAt` 缓存。
|
||||
|
||||
C++ 可自建管线:
|
||||
|
||||
```cpp
|
||||
auto pipeline = eve::sensing::TargetingPipeline::withBuiltins();
|
||||
eve::sensing::TargetingSourceContext ctx;
|
||||
ctx.world = &world;
|
||||
ctx.origin = {x, y, std::nullopt};
|
||||
ctx.dirX = dirX;
|
||||
ctx.dirY = dirY;
|
||||
auto ranked = pipeline.executePreset(ctx, "sensing.builtin.coneSelect");
|
||||
```
|
||||
|
||||
## 与 Targeting* 协议的关系
|
||||
|
||||
- `SensingWorld` = 候选事实 + QuerySpec / Preset 查询面。
|
||||
- `SensingWorldCandidateProvider` / `TargetingResolver` = 兼容壳(Capability / Action)。
|
||||
- `visibleTo` 与几何 LOS **正交**;`domain≠Any` 需要注入阵营关系,否则 `Unsupported`。
|
||||
- 结果从不隐式设置 primary。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- 把 `visibleTo` 当成 LOS——那是事实键;几何遮挡走 `ILineOfSightQuery`。
|
||||
- 期望 Preset 选出“最佳目标”——sensing 只排序;primary 由消费者显式 `setPrimary`。
|
||||
- 在非 World2D 事实上跑 2D QuerySpec——World 事实面目前是 2D。
|
||||
|
||||
## API 快查
|
||||
|
||||
| 对象 | API | 说明 |
|
||||
|---|---|---|
|
||||
| `Sensing` | `getName()` / `newWorld()` | 查询模块名,或创建模块拥有的 `SensingWorld` Result。 |
|
||||
| 拥有型 World | `ownership()` / `ownerEpoch()` / `handle()` / `isStale()` / `release()` | 查询世代生命周期或释放 World。 |
|
||||
| World | `upsert` / `remove` / `setZones` | 写入/删除主体事实,或更新逻辑 Zone 成员。 |
|
||||
| World | `query` / `circle` / `box` / `executePreset` / `resultAt` | 查询候选并读取缓存结果。 |
|
||||
| World | `setSpatialIndexEnabled` / `spatialIndexEnabled` / `debugLastQueryJson` | 可选空间广相与上次查询调试 JSON。 |
|
||||
| World | `snapshotJson` / `restoreJson` | 确定性快照或事务性恢复 Result。 |
|
||||
| `SensingCandidate` | `getId()` / `getX()` / `getY()` / `getDistance()` | `resultAt` 返回的只读候选字段。 |
|
||||
| 管线 | `TargetingPipeline::withBuiltins` / `registerTask` / `registerPreset` / `execute` / `executePreset` | C++ Preset 注册与执行。 |
|
||||
|
||||
**源码:** [`src/modules/sensing/`](../../../src/modules/sensing/)
|
||||
**设计文档:** [`docs/dev/感知与目标选择系统设计.md`](../../dev/感知与目标选择系统设计.md)
|
||||
**相关测试:** [`test/sensing.cpp`](../../../test/sensing.cpp)、[`test/targeting.cpp`](../../../test/targeting.cpp)
|
||||
|
||||
## 消费者迁移(Phase 3)
|
||||
|
||||
- RTS `CombatFireSystem` 自动索敌已改为组装 `QuerySpec`(`QueryCircle` + tags/factions + `TruncateToMax`);目标 priority / stance 仍在 RTS。
|
||||
- `perceptionFactsFrom(CandidateQueryResult)` 把有序候选投影为中立 `PerceptionFact`;npc_ai 侧 `perceptionMemoriesFrom` / `perceptionMemoryFrom`(`npc_ai/SensingPerception.h`)再写入 `PerceptionMemory`。sensing 不依赖 npc_ai。
|
||||
- Weapon soft-lock / lock-on 获取路径尚未在 weapon 模块落地;落地时应走同一 QuerySpec/preset,aim assist 留游戏侧。
|
||||
|
||||
## 性能与调试(Phase 4)
|
||||
|
||||
- `setSpatialIndexEnabled(true, cellSize)` 打开可选 `SpatialHash2D` 广相(默认关)。过滤/排序仍由 `QuerySpec` 负责;索引只做候选裁剪。
|
||||
- `debugLastQueryJson()` 返回上次成功查询的确定性 JSON(`eve.sensing.lastQuery`):origin、shape、spatial.enabled/used/scanned/accepted、ranked scores。可供 MCP/debug overlay 消费;sensing 不依赖 editor。
|
||||
- 当前查询路径保持同步;异步 request 未引入。
|
||||
|
||||
## Zone 成员
|
||||
|
||||
`setZones(id, "ns:name,...")` 写入主体的逻辑 Zone 成员(`LogicalId` 文本)。`SensingWorldCandidateProvider` 将其投影为 `TargetCandidate.zones`,并支持 `TargetingSpec.zone` 过滤。几何 Zone 文档仍由 crowd/editor 等模块拥有;sensing 只镜像成员关系。
|
||||
@@ -28,6 +28,13 @@ print(window.getWidth() + "x" + window.getHeight() + "\n");
|
||||
|
||||
调用 `setSize(width, height)`,成功后重新读取窗口尺寸,并同步摄像机 viewport 与 UI 布局。退出按钮应调用 `close()`,不要直接终止进程。
|
||||
|
||||
### 适配高 DPI 编辑器布局
|
||||
|
||||
`getWidth()` / `getHeight()` 返回窗口的逻辑尺寸,适合游戏摄像机和普通 UI 坐标;
|
||||
`getPixelWidth()` / `getPixelHeight()` 返回交换链使用的实际像素尺寸,适合截图、
|
||||
离屏目标和像素级渲染。`getDPIScale()` 返回实际像素宽度与逻辑宽度的比例。
|
||||
编辑器应只选择一种坐标空间完成布局,不要同时使用像素尺寸并再次乘以 DPI 比例。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- `setWindowSettings()` 失败:通常是 Graphics 尚未关联,或平台不支持请求的模式。
|
||||
@@ -38,7 +45,7 @@ print(window.getWidth() + "x" + window.getHeight() + "\n");
|
||||
|
||||
下列方法名来自当前 Squirrel 绑定;同一模块创建的辅助对象(例如 `World`、`Body`、`Source`)的方法也列在这里。
|
||||
|
||||
- `close()`、`getHeight()`、`getName()`、`getWidth()`、`getWindowSettings()`、`setGraphics()`、`setSize()`、`setWindowSettings()`
|
||||
- `close()`、`getDPIScale()`、`getHeight()`、`getName()`、`getPixelHeight()`、`getPixelWidth()`、`getWidth()`、`getWindowSettings()`、`hasFocus()`、`setGraphics()`、`setSize()`、`setWindowSettings()`
|
||||
|
||||
## 使用要点
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Animation Clip Editor example
|
||||
|
||||
This example composes EVEngine's UI-neutral editor SDK into a project-specific
|
||||
animation clip editor. Native code owns the clip document, pose scrub preview,
|
||||
skeleton overlay, dope-sheet layout and undo/redo. The Squirrel presenter draws
|
||||
animation clip editor. Native code owns the clip document, joint hierarchy, pose scrub preview,
|
||||
skeleton overlay, transform keys, dope-sheet layout and undo/redo. The Squirrel presenter draws
|
||||
the four workspace panels and forwards pointer/transport input.
|
||||
|
||||
Run on Windows:
|
||||
@@ -15,7 +15,10 @@ Controls:
|
||||
|
||||
- Click a bone name in the Skeleton list or the dope-sheet label column to select a track.
|
||||
- Click the timeline to seek; Play / Pause / Stop (Space) advance the pose preview.
|
||||
- Change **Duration**, **Loop**, or **Mask** in the inspector; Undo/Redo (Ctrl+Z / Ctrl+Y)
|
||||
uses the native clip transaction history.
|
||||
- Use the Position, Rotation and Scale controls to edit the selected joint at the playhead.
|
||||
Editing automatically creates a stable transform key when the frame has none.
|
||||
- Use **Set Key**, **Delete Key**, and **Key time** to insert, remove or retime a key. All edits,
|
||||
including joint properties, use the native clip transaction history and Undo/Redo.
|
||||
- Change **Duration**, **Sample rate**, **Loop**, or **Mask** in the clip section.
|
||||
- The center viewport draws `joint` / `bone-line` overlay primitives from
|
||||
`AnimationClipPreview`. No KayKit mesh is required: the editor seeds a two-bone walk.
|
||||
`AnimationClipPreview`. No KayKit mesh is required: the example seeds a six-joint hierarchy.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 174 KiB |
@@ -19,16 +19,20 @@ function buildWorkspace() {
|
||||
"Create animation clip editor");
|
||||
requireResult(animUi.clip.configureWorkspace(animUi.workspace), "Compose workspace");
|
||||
animUi.status = "Seeded two-bone walk clip";
|
||||
animUi.workspace.setRegionSize("left", 220.0);
|
||||
animUi.workspace.setRegionSize("right", 280.0);
|
||||
animUi.workspace.setRegionSize("bottom", 180.0);
|
||||
animUi.workspace.layout(config.width.tofloat(), config.height.tofloat());
|
||||
local dpi = win.getDPIScale();
|
||||
animUi.workspace.setRegionSize("left", 220.0 / dpi);
|
||||
animUi.workspace.setRegionSize("right", 340.0 / dpi);
|
||||
animUi.workspace.setRegionSize("bottom", 230.0 / dpi);
|
||||
animUi.workspace.layout(win.getWidth().tofloat() / dpi, win.getHeight().tofloat() / dpi);
|
||||
}
|
||||
|
||||
function panelSkeleton() {
|
||||
ui.text("Skeleton", "skeleton-title");
|
||||
ui.listItem("Hips", "bone-Hips");
|
||||
ui.listItem("Spine", "bone-Spine");
|
||||
for (local i = 0; i < animUi.clip.getBoneCount(); ++i) {
|
||||
local bone = animUi.clip.getBoneName(i);
|
||||
local parent = animUi.clip.getBoneParent(i);
|
||||
ui.listItem((parent == "" ? "" : " ") + bone, "bone-" + bone);
|
||||
}
|
||||
ui.text("", "bone-selection");
|
||||
ui.separator("skeleton-sep");
|
||||
ui.textWrapped("Selecting a bone highlights its overlay axes and dope-sheet row.",
|
||||
@@ -43,26 +47,41 @@ function panelPreview() {
|
||||
}
|
||||
|
||||
function panelInspector() {
|
||||
ui.text("Clip Inspector", "inspector-title");
|
||||
ui.text("Bone Transform", "inspector-title");
|
||||
ui.text("", "selected-bone");
|
||||
ui.text("", "revision");
|
||||
ui.separator("inspector-sep-1");
|
||||
ui.text("Position", "position-label");
|
||||
ui.slider("X", 0.0, -4.0, 4.0, "position-x");
|
||||
ui.slider("Y", 0.0, -4.0, 4.0, "position-y");
|
||||
ui.slider("Z", 0.0, -4.0, 4.0, "position-z");
|
||||
ui.text("Rotation (degrees)", "rotation-label");
|
||||
ui.slider("X", 0.0, -180.0, 180.0, "rotation-x");
|
||||
ui.slider("Y", 0.0, -180.0, 180.0, "rotation-y");
|
||||
ui.slider("Z", 0.0, -180.0, 180.0, "rotation-z");
|
||||
ui.text("Scale", "scale-label");
|
||||
ui.slider("X", 1.0, 0.05, 3.0, "scale-x");
|
||||
ui.slider("Y", 1.0, 0.05, 3.0, "scale-y");
|
||||
ui.slider("Z", 1.0, 0.05, 3.0, "scale-z");
|
||||
ui.beginToolbar("key-tools");
|
||||
ui.iconButton("plus", "Set Key", "set-key");
|
||||
ui.iconButton("minus", "Delete Key", "delete-key");
|
||||
ui.end();
|
||||
ui.slider("Key time", 0.0, 0.0, 2.0, "key-time");
|
||||
ui.separator("inspector-sep-2");
|
||||
ui.text("Clip", "clip-label");
|
||||
ui.slider("Duration", animUi.clip.getDuration(), 0.25, 8.0, "duration");
|
||||
ui.slider("Sample rate", animUi.clip.getSampleRate(), 1.0, 120.0, "sample-rate");
|
||||
ui.checkbox("Loop", animUi.clip.getLoop(), "loop");
|
||||
ui.slider("Mask", animUi.clip.getSelectedMaskWeight(), 0.0, 1.0, "mask");
|
||||
ui.beginRow("inspector-history", 8.0); ui.button("Undo", "undo"); ui.button("Redo", "redo"); ui.end();
|
||||
ui.textWrapped("Mask weight dims the selected bone in the overlay on the next sample.",
|
||||
245.0, "inspector-help");
|
||||
ui.beginToolbar("inspector-history"); ui.iconButton("undo", "", "undo"); ui.iconButton("redo", "", "redo"); ui.end();
|
||||
}
|
||||
|
||||
function panelTimeline() {
|
||||
ui.beginRow("transport", 8.0);
|
||||
ui.button("Play / Pause", "play-pause");
|
||||
ui.button("Stop", "stop");
|
||||
ui.button("Undo", "undo");
|
||||
ui.button("Redo", "redo");
|
||||
ui.end();
|
||||
ui.beginToolbar("transport");
|
||||
ui.iconButton("play", "", "play-pause"); ui.iconButton("stop", "", "stop");
|
||||
ui.iconButton("plus", "Set Key", "set-key"); ui.iconButton("minus", "", "delete-key");
|
||||
ui.iconButton("undo", "", "undo"); ui.iconButton("redo", "", "redo"); ui.end();
|
||||
ui.text("", "timeline-status");
|
||||
ui.viewport("anim-dopesheet", animUi.workspace.getRegionW("bottom") - 20.0,
|
||||
animUi.workspace.getRegionH("bottom") - 88.0);
|
||||
@@ -84,6 +103,8 @@ function mountPanels() {
|
||||
local region = animUi.workspace.getPanelRegion(i);
|
||||
ui.setHostPos(animUi.workspace.getRegionX(region), animUi.workspace.getRegionY(region), 0.0, 0.0);
|
||||
ui.setHostSize(animUi.workspace.getRegionW(region), animUi.workspace.getRegionH(region));
|
||||
ui.setHostMovable(false);
|
||||
ui.setHostResizable(false);
|
||||
ui.setHostOverlay(false);
|
||||
}
|
||||
requireResult(animUi.clip.setViewport(animUi.workspace.getRegionW("bottom") - 20.0, 36.0, 120.0),
|
||||
@@ -114,8 +135,14 @@ function handleUiEvents() {
|
||||
animUi.clip.stop(); animUi.status = "Preview stopped";
|
||||
} else if (id == "undo" || id == "redo") {
|
||||
applyHistory(id);
|
||||
} else if (id == "set-key") {
|
||||
local result = animUi.clip.keySelectedBone();
|
||||
animUi.status = result.ok ? "Key set at playhead" : result.status.summary;
|
||||
} else if (id == "delete-key") {
|
||||
local result = animUi.clip.deleteSelectedKey();
|
||||
animUi.status = result.ok ? "Selected key deleted" : result.status.summary;
|
||||
} else if (host == "animation.skeleton") {
|
||||
local bone = id == "bone-Hips" ? "Hips" : id == "bone-Spine" ? "Spine" : "";
|
||||
local bone = id.find("bone-") == 0 ? id.slice(5) : "";
|
||||
if (bone != "") {
|
||||
requireResult(animUi.clip.selectBone(bone), "Select bone");
|
||||
animUi.status = "Selected " + bone;
|
||||
@@ -134,6 +161,13 @@ function handleUiEvents() {
|
||||
else if (id == "sample-rate") result = animUi.clip.setSampleRate(ui.getValue("sample-rate"));
|
||||
else if (id == "loop") result = animUi.clip.setLoop(ui.getChecked("loop"));
|
||||
else if (id == "mask") result = animUi.clip.setMaskWeight(ui.getValue("mask"));
|
||||
else if (id == "key-time") result = animUi.clip.moveSelectedKey(ui.getValue("key-time"));
|
||||
else if (id.find("position-") == 0) result = animUi.clip.setSelectedPosition(
|
||||
ui.getValue("position-x"), ui.getValue("position-y"), ui.getValue("position-z"));
|
||||
else if (id.find("rotation-") == 0) result = animUi.clip.setSelectedRotation(
|
||||
ui.getValue("rotation-x"), ui.getValue("rotation-y"), ui.getValue("rotation-z"));
|
||||
else if (id.find("scale-") == 0) result = animUi.clip.setSelectedScale(
|
||||
ui.getValue("scale-x"), ui.getValue("scale-y"), ui.getValue("scale-z"));
|
||||
animUi.status = result.ok ? id + " committed · revision " + animUi.clip.getRevision()
|
||||
: result.status.summary;
|
||||
}
|
||||
@@ -182,6 +216,17 @@ function updateLabels() {
|
||||
ui.setValue("sample-rate", animUi.clip.getSampleRate());
|
||||
ui.setChecked("loop", animUi.clip.getLoop());
|
||||
ui.setValue("mask", animUi.clip.getSelectedMaskWeight());
|
||||
ui.setValue("position-x", animUi.clip.getSelectedPositionX());
|
||||
ui.setValue("position-y", animUi.clip.getSelectedPositionY());
|
||||
ui.setValue("position-z", animUi.clip.getSelectedPositionZ());
|
||||
ui.setValue("rotation-x", animUi.clip.getSelectedRotationX());
|
||||
ui.setValue("rotation-y", animUi.clip.getSelectedRotationY());
|
||||
ui.setValue("rotation-z", animUi.clip.getSelectedRotationZ());
|
||||
ui.setValue("scale-x", animUi.clip.getSelectedScaleX());
|
||||
ui.setValue("scale-y", animUi.clip.getSelectedScaleY());
|
||||
ui.setValue("scale-z", animUi.clip.getSelectedScaleZ());
|
||||
ui.setValue("key-time", animUi.clip.getSelectedKeyTime());
|
||||
ui.setEnabled("delete-key", animUi.clip.hasSelectedKey());
|
||||
ui.setEnabled("undo", animUi.clip.canUndo());
|
||||
ui.setEnabled("redo", animUi.clip.canRedo());
|
||||
ui.select("animation.skeleton");
|
||||
@@ -266,7 +311,8 @@ function drawDopeSheet() {
|
||||
|
||||
eve_init = function() {
|
||||
gfx.setBackgroundColor(0.09, 0.11, 0.14, 1.0);
|
||||
buildWorkspace(); ui.setTheme("dark"); ui.setNavKeyboard(true); mountPanels();
|
||||
buildWorkspace(); ui.setTheme("dark"); ui.setScale(0.75);
|
||||
ui.setNavKeyboard(true); mountPanels();
|
||||
animUi.clip.play();
|
||||
print("animation-clip-editor: duration=" + animUi.clip.getDuration() +
|
||||
"s tracks=" + animUi.clip.getTrackCount() + " primitives=" + animUi.clip.getPrimitiveCount() + "\n");
|
||||
|
||||
@@ -5,7 +5,9 @@ const HITBOX_ID = "kaykit-state:hitbox";
|
||||
|
||||
persist combatEditor = {
|
||||
workspace = null, timeline = null, camera = null, skeleton = null,
|
||||
clipEditor = null, boneMouseDown = false,
|
||||
clip = null, player = null, knight = null, knightParts = [], skins = [],
|
||||
generalLibrary = null, meleeLibrary = null,
|
||||
swordParts = [], knightTexture = null, ground = null, mouseDown = false,
|
||||
keyLight = null, fillLight = null,
|
||||
status = "Loading KayKit assets...", lastEvent = "No preview event yet",
|
||||
@@ -26,9 +28,27 @@ function requireResult(result, context) {
|
||||
|
||||
function attackTimeline(duration) {
|
||||
return {
|
||||
schema="eve.action.timeline", schemaVersion=1,
|
||||
schema="eve.action.timeline", schemaVersion=3,
|
||||
actionId="combat:light-attack-kaykit", durationNs=ns(duration),
|
||||
animationUri="asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop",
|
||||
animationSections=[
|
||||
{ id="kaykit-section:anticipation",
|
||||
animationUri="asset://kaykit/Rig_Medium_General.glb#Idle_A",
|
||||
startNs=0, endNs=ns(duration * 0.30), blendInNs=0,
|
||||
sourceStartNs=0, sourceEndNs=0, blendCurve="ease-in-out" },
|
||||
{ id="kaykit-section:strike",
|
||||
animationUri="asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop",
|
||||
startNs=ns(duration * 0.30), endNs=ns(duration * 0.62), blendInNs=ns(duration * 0.06),
|
||||
sourceStartNs=0, sourceEndNs=0, blendCurve="ease-in-out" },
|
||||
{ id="kaykit-section:recovery",
|
||||
animationUri="asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_Block",
|
||||
startNs=ns(duration * 0.62), endNs=ns(duration), blendInNs=ns(duration * 0.05),
|
||||
sourceStartNs=0, sourceEndNs=0, blendCurve="ease-in-out" },
|
||||
],
|
||||
splitTimestampsNs=[ns(duration * 0.30), ns(duration * 0.62)],
|
||||
montage={ basePlayRate=1.0, looping=false, footIk=false, animationLayer=0,
|
||||
defaultBlendInNs=ns(0.12), defaultBlendOutNs=ns(0.15), blendOutOffsetNs=0,
|
||||
rootMotionHorizontal=true, rootMotionVertical=false, rootMotionRotation=true },
|
||||
metadata={ character="asset://kaykit/Knight.glb", weapon="asset://kaykit/sword_1handed.gltf",
|
||||
sourceLicense="CC0-1.0" },
|
||||
tracks=[
|
||||
@@ -101,11 +121,12 @@ function buildCharacterPreview() {
|
||||
if (anim == null) anim = eve.Animation();
|
||||
combatEditor.knight = model3d.newModelDataFromFile("assets/kaykit/Knight.glb");
|
||||
combatEditor.knightTexture = gfx.newTextureFromFile("assets/kaykit/knight_texture.png");
|
||||
local library = model3d.newModelDataFromFile("assets/kaykit/Rig_Medium_CombatMelee.glb");
|
||||
local attackIndex = findAnimation(library, "Melee_1H_Attack_Chop");
|
||||
combatEditor.generalLibrary = model3d.newModelDataFromFile("assets/kaykit/Rig_Medium_General.glb");
|
||||
combatEditor.meleeLibrary = model3d.newModelDataFromFile("assets/kaykit/Rig_Medium_CombatMelee.glb");
|
||||
local attackIndex = findAnimation(combatEditor.meleeLibrary, "Melee_1H_Attack_Chop");
|
||||
if (attackIndex < 0) throw "KayKit attack clip Melee_1H_Attack_Chop was not found";
|
||||
combatEditor.skeleton = anim.newSkeletonFromModel(combatEditor.knight);
|
||||
combatEditor.clip = anim.newClipFromModel(library, combatEditor.skeleton, attackIndex);
|
||||
combatEditor.clip = anim.newClipFromModel(combatEditor.meleeLibrary, combatEditor.skeleton, attackIndex);
|
||||
combatEditor.clip.setLoop(false);
|
||||
combatEditor.player = anim.newPlayer(combatEditor.skeleton);
|
||||
combatEditor.player.play(combatEditor.clip);
|
||||
@@ -148,24 +169,50 @@ function buildCharacterPreview() {
|
||||
combatEditor.fillLight.setColor(0.55, 0.70, 1.0, 1.3);
|
||||
}
|
||||
|
||||
function startMontageRuntime() {
|
||||
local idle = findAnimation(combatEditor.generalLibrary, "Idle_A");
|
||||
local attack = findAnimation(combatEditor.meleeLibrary, "Melee_1H_Attack_Chop");
|
||||
local block = findAnimation(combatEditor.meleeLibrary, "Melee_Block");
|
||||
if (idle < 0 || attack < 0 || block < 0) throw "KayKit montage source clips are incomplete";
|
||||
requireResult(combatEditor.timeline.registerRuntimeClip(
|
||||
"asset://kaykit/Rig_Medium_General.glb#Idle_A", combatEditor.generalLibrary,
|
||||
combatEditor.skeleton, idle), "Register anticipation clip");
|
||||
requireResult(combatEditor.timeline.registerRuntimeClip(
|
||||
"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop", combatEditor.meleeLibrary,
|
||||
combatEditor.skeleton, attack), "Register strike clip");
|
||||
requireResult(combatEditor.timeline.registerRuntimeClip(
|
||||
"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_Block", combatEditor.meleeLibrary,
|
||||
combatEditor.skeleton, block), "Register recovery clip");
|
||||
requireResult(combatEditor.timeline.beginRuntime(combatEditor.skeleton), "Start action montage runtime");
|
||||
}
|
||||
|
||||
function buildWorkspace() {
|
||||
combatEditor.workspace = editor.newWorkspace("kaykit.combat", "KayKit Combat Action Editor");
|
||||
local timelineData = attackTimeline(combatEditor.clip.getDuration());
|
||||
combatEditor.timeline = requireResult(
|
||||
eve.ActionEditorModule().create("asset.kaykit.light-attack", timelineData),
|
||||
"Create action timeline editor");
|
||||
combatEditor.clipEditor = requireResult(
|
||||
eve.AnimationEditorModule().create("asset.kaykit.light-attack.clip"),
|
||||
"Create KayKit clip editor");
|
||||
requireResult(combatEditor.clipEditor.loadRuntimeClip(combatEditor.skeleton, combatEditor.clip),
|
||||
"Import KayKit skeleton and clip keys");
|
||||
requireResult(combatEditor.timeline.configureWorkspace(combatEditor.workspace), "Compose workspace");
|
||||
combatEditor.workspace.setRegionSize("left", 225.0);
|
||||
combatEditor.workspace.setRegionSize("right", 285.0);
|
||||
combatEditor.workspace.setRegionSize("bottom", 255.0);
|
||||
combatEditor.workspace.layout(config.width.tofloat(), config.height.tofloat());
|
||||
local dpi = win.getDPIScale();
|
||||
combatEditor.workspace.setRegionSize("left", 225.0 / dpi);
|
||||
combatEditor.workspace.setRegionSize("right", 340.0 / dpi);
|
||||
combatEditor.workspace.setRegionSize("bottom", 330.0 / dpi);
|
||||
combatEditor.workspace.layout(win.getWidth().tofloat() / dpi, win.getHeight().tofloat() / dpi);
|
||||
startMontageRuntime();
|
||||
}
|
||||
|
||||
function panelAssets() {
|
||||
ui.text("KayKit Adventurers", "assets-title");
|
||||
ui.textWrapped("Knight.glb + sword_1handed.gltf", 190.0, "assets-models");
|
||||
ui.separator("assets-sep-1"); ui.text("Animation", "assets-animation-label");
|
||||
ui.listItem("Melee 1H Attack Chop", "clip-attack");
|
||||
ui.text("KayKit Skeleton", "assets-title");
|
||||
ui.text("", "bone-count");
|
||||
for (local i = 0; i < combatEditor.clipEditor.getBoneCount(); ++i) {
|
||||
local parent = combatEditor.clipEditor.getBoneParent(i);
|
||||
ui.listItem((parent == "" ? "" : " ") + combatEditor.clipEditor.getBoneName(i), "bone-" + i);
|
||||
}
|
||||
ui.text("", "asset-selection");
|
||||
ui.textWrapped("CC0 assets are bundled so the example and regression test use identical files.",
|
||||
190.0, "assets-license");
|
||||
@@ -182,14 +229,19 @@ function panelPreview() {
|
||||
}
|
||||
|
||||
function panelInspector() {
|
||||
ui.text("Action Inspector", "inspector-title"); ui.text("", "action-uri"); ui.text("", "revision");
|
||||
ui.text("Joint Inspector", "inspector-title"); ui.text("", "action-uri"); ui.text("", "revision");
|
||||
ui.text("", "selected-item");
|
||||
ui.separator("inspector-sep-1"); ui.text("Hitbox window", "hitbox-title");
|
||||
ui.slider("Start", combatEditor.timeline.getStateStart(HITBOX_ID), 0.0,
|
||||
combatEditor.timeline.getDuration(), "hitbox-start");
|
||||
ui.slider("End", combatEditor.timeline.getStateEnd(HITBOX_ID), 0.0,
|
||||
combatEditor.timeline.getDuration(), "hitbox-end");
|
||||
ui.beginRow("inspector-history", 8.0); ui.button("Undo", "undo"); ui.button("Redo", "redo"); ui.end();
|
||||
ui.separator("inspector-sep-1"); ui.text("Position", "position-label");
|
||||
ui.slider("X", 0.0, -2.0, 2.0, "position-x"); ui.slider("Y", 0.0, -2.0, 2.0, "position-y");
|
||||
ui.slider("Z", 0.0, -2.0, 2.0, "position-z"); ui.text("Rotation", "rotation-label");
|
||||
ui.slider("X", 0.0, -180.0, 180.0, "rotation-x"); ui.slider("Y", 0.0, -180.0, 180.0, "rotation-y");
|
||||
ui.slider("Z", 0.0, -180.0, 180.0, "rotation-z"); ui.text("Scale", "scale-label");
|
||||
ui.slider("X", 1.0, 0.05, 3.0, "scale-x"); ui.slider("Y", 1.0, 0.05, 3.0, "scale-y");
|
||||
ui.slider("Z", 1.0, 0.05, 3.0, "scale-z");
|
||||
ui.beginToolbar("joint-tools"); ui.iconButton("plus", "Set Key", "set-key");
|
||||
ui.iconButton("minus", "Delete", "delete-key"); ui.iconButton("undo", "", "clip-undo");
|
||||
ui.iconButton("redo", "", "clip-redo"); ui.end();
|
||||
ui.slider("Key time", 0.0, 0.0, combatEditor.clip.getDuration(), "key-time");
|
||||
ui.separator("inspector-sep-2"); ui.text("", "last-event");
|
||||
ui.textWrapped("Drag a state body to move it, or drag either edge to resize. Empty-space click seeks.",
|
||||
245.0, "interaction-help");
|
||||
@@ -197,9 +249,13 @@ function panelInspector() {
|
||||
|
||||
function panelTimeline() {
|
||||
ui.beginRow("transport", 8.0); ui.button("Play / Pause", "play-pause");
|
||||
ui.button("Restart", "restart"); ui.button("Undo", "undo"); ui.button("Redo", "redo"); ui.end();
|
||||
ui.button("Restart", "restart"); ui.button("Strike", "jump-strike");
|
||||
ui.button("Blend Out", "blend-out"); ui.button("Undo", "undo"); ui.button("Redo", "redo"); ui.end();
|
||||
ui.text("Animation keys · click a diamond to select, empty space to scrub", "bone-timeline-label");
|
||||
ui.viewport("bone-timeline", combatEditor.workspace.getRegionW("bottom") - 20.0, 118.0);
|
||||
ui.text("Montage gameplay tracks", "action-timeline-label");
|
||||
ui.viewport("action-timeline", combatEditor.workspace.getRegionW("bottom") - 20.0, 92.0);
|
||||
ui.text("", "timeline-status");
|
||||
ui.viewport("action-timeline", combatEditor.workspace.getRegionW("bottom") - 20.0, 112.0);
|
||||
}
|
||||
|
||||
panelBuilders <- { ["action.assets"]=panelAssets, ["action.preview"]=panelPreview,
|
||||
@@ -214,10 +270,15 @@ function mountPanels() {
|
||||
local region = combatEditor.workspace.getPanelRegion(i);
|
||||
ui.setHostPos(combatEditor.workspace.getRegionX(region), combatEditor.workspace.getRegionY(region), 0.0, 0.0);
|
||||
ui.setHostSize(combatEditor.workspace.getRegionW(region), combatEditor.workspace.getRegionH(region));
|
||||
ui.setHostMovable(false);
|
||||
ui.setHostResizable(false);
|
||||
ui.setHostOverlay(false);
|
||||
}
|
||||
requireResult(combatEditor.timeline.setViewport(combatEditor.workspace.getRegionW("bottom") - 20.0,
|
||||
36.0, 145.0), "Configure timeline viewport");
|
||||
requireResult(combatEditor.clipEditor.setViewport(combatEditor.workspace.getRegionW("bottom") - 20.0,
|
||||
22.0, 145.0), "Configure bone timeline viewport");
|
||||
requireResult(combatEditor.timeline.setSnapSeconds(1.0 / 30.0), "Configure frame snapping");
|
||||
}
|
||||
|
||||
function applyHistory(command) {
|
||||
@@ -244,13 +305,29 @@ function handleUiEvents() {
|
||||
}
|
||||
} else if (id == "restart") {
|
||||
requireResult(combatEditor.timeline.seekSeconds(0.0), "Restart preview");
|
||||
requireResult(combatEditor.timeline.jumpRuntimeSeconds(0.0), "Restart montage runtime");
|
||||
combatEditor.timeline.play(); combatEditor.status = "Preview restarted at 0.000 s";
|
||||
} else if (id == "jump-strike") {
|
||||
requireResult(combatEditor.timeline.jumpRuntimeSection(1), "Jump montage to strike section");
|
||||
requireResult(combatEditor.timeline.seekSeconds(combatEditor.timeline.getDuration() * 0.30),
|
||||
"Align editor preview to strike section");
|
||||
combatEditor.status = "Runtime jumped to physical strike section";
|
||||
} else if (id == "blend-out") {
|
||||
requireResult(combatEditor.timeline.beginRuntimeBlendOut(0.20), "Blend out montage runtime");
|
||||
combatEditor.status = "Runtime interruption · paired state exits + 0.20 s blend";
|
||||
} else if (id == "undo" || id == "redo") {
|
||||
applyHistory(id);
|
||||
} else if (host == "action.assets" && id == "clip-attack") {
|
||||
combatEditor.selectedAsset = "Melee 1H Attack Chop";
|
||||
requireResult(combatEditor.timeline.seekSeconds(0.0), "Select attack clip");
|
||||
combatEditor.timeline.play(); combatEditor.status = "Selected KayKit melee attack asset";
|
||||
} else if (id == "set-key" || id == "delete-key" || id == "clip-undo" || id == "clip-redo") {
|
||||
local result = id == "set-key" ? combatEditor.clipEditor.keySelectedBone() :
|
||||
id == "delete-key" ? combatEditor.clipEditor.deleteSelectedKey() :
|
||||
id == "clip-undo" ? combatEditor.clipEditor.undo() : combatEditor.clipEditor.redo();
|
||||
if (result.ok) requireResult(combatEditor.clipEditor.writeRuntimeClip(combatEditor.clip, combatEditor.skeleton),
|
||||
"Rebuild edited KayKit clip");
|
||||
combatEditor.status = result.ok ? "Animation key edit committed" : result.status.summary;
|
||||
} else if (host == "action.assets" && id.find("bone-") == 0) {
|
||||
local index = id.slice(5).tointeger();
|
||||
requireResult(combatEditor.clipEditor.selectBone(combatEditor.clipEditor.getBoneName(index)), "Select joint");
|
||||
combatEditor.status = "Selected joint " + combatEditor.clipEditor.getSelectedBone();
|
||||
}
|
||||
click = ui.consumeClick();
|
||||
}
|
||||
@@ -258,25 +335,47 @@ function handleUiEvents() {
|
||||
local changed = ui.consumeChange();
|
||||
while (changed != "") {
|
||||
local event = eventParts(changed); local host = event[0]; local id = event[1];
|
||||
if (host == "action.inspector" && (id == "hitbox-start" || id == "hitbox-end")) {
|
||||
if (host == "action.inspector") {
|
||||
ui.select("action.inspector");
|
||||
local start = combatEditor.timeline.getStateStart(HITBOX_ID);
|
||||
local finish = combatEditor.timeline.getStateEnd(HITBOX_ID);
|
||||
if (id == "hitbox-start") start = ui.getValue("hitbox-start");
|
||||
if (id == "hitbox-end") finish = ui.getValue("hitbox-end");
|
||||
local result = combatEditor.timeline.resizeState(HITBOX_ID, start, finish);
|
||||
combatEditor.status = result.ok ? "Hitbox " + id.slice(7) + " committed · revision " +
|
||||
combatEditor.timeline.getRevision() : result.status.summary;
|
||||
local result = { ok=true, status={summary=""} };
|
||||
if (id.find("position-") == 0) result = combatEditor.clipEditor.setSelectedPosition(
|
||||
ui.getValue("position-x"), ui.getValue("position-y"), ui.getValue("position-z"));
|
||||
else if (id.find("rotation-") == 0) result = combatEditor.clipEditor.setSelectedRotation(
|
||||
ui.getValue("rotation-x"), ui.getValue("rotation-y"), ui.getValue("rotation-z"));
|
||||
else if (id.find("scale-") == 0) result = combatEditor.clipEditor.setSelectedScale(
|
||||
ui.getValue("scale-x"), ui.getValue("scale-y"), ui.getValue("scale-z"));
|
||||
else if (id == "key-time") result = combatEditor.clipEditor.moveSelectedKey(ui.getValue("key-time"));
|
||||
if (result.ok) requireResult(combatEditor.clipEditor.writeRuntimeClip(combatEditor.clip, combatEditor.skeleton),
|
||||
"Rebuild edited KayKit clip");
|
||||
combatEditor.status = result.ok ? "Joint transform keyed · revision " + combatEditor.clipEditor.getRevision()
|
||||
: result.status.summary;
|
||||
}
|
||||
changed = ui.consumeChange();
|
||||
}
|
||||
}
|
||||
|
||||
function updateBoneTimelinePointer() {
|
||||
ui.select("action.timeline");
|
||||
local hovered = ui.viewportHovered("bone-timeline"); local down = hovered && mouse.isDown(1);
|
||||
if (down && !combatEditor.boneMouseDown) {
|
||||
local result = combatEditor.clipEditor.pointerDown(ui.viewportMouseX("bone-timeline"),
|
||||
ui.viewportMouseY("bone-timeline"));
|
||||
combatEditor.status = result.ok ? "Animation timeline selection" : result.status.summary;
|
||||
}
|
||||
combatEditor.boneMouseDown = down;
|
||||
}
|
||||
|
||||
function updateTimelinePointer() {
|
||||
ui.select("action.timeline");
|
||||
local hovered = ui.viewportHovered("action-timeline");
|
||||
local down = hovered && mouse.isDown(1);
|
||||
local x = ui.viewportMouseX("action-timeline"); local y = ui.viewportMouseY("action-timeline");
|
||||
local wheel = hovered ? ui.viewportWheel("action-timeline") : 0.0;
|
||||
if (wheel != 0.0) {
|
||||
local width = combatEditor.timeline.getLayoutWidth();
|
||||
local anchor = clampf((x - 145.0) / (width - 145.0), 0.0, 1.0);
|
||||
requireResult(combatEditor.timeline.zoomTimeline(pow(1.18, wheel), anchor), "Zoom timeline");
|
||||
}
|
||||
if (down && !combatEditor.mouseDown) {
|
||||
combatEditor.timeline.pointerDown(x, y, false);
|
||||
if (!combatEditor.timeline.isDragging()) {
|
||||
@@ -330,8 +429,9 @@ function updateKeyboardShortcuts() {
|
||||
}
|
||||
|
||||
function updatePose() {
|
||||
combatEditor.player.setTime(combatEditor.timeline.getPreviewTime());
|
||||
local pose = combatEditor.player.getPose(); pose.computeWorld(combatEditor.skeleton);
|
||||
combatEditor.player.setTime(combatEditor.clipEditor.getPlayhead());
|
||||
local pose = combatEditor.player.getPose();
|
||||
pose.computeWorld(combatEditor.skeleton);
|
||||
foreach (binding in combatEditor.skins) binding.skin.applyToMesh(gfx, binding.part.getMesh(), pose);
|
||||
}
|
||||
|
||||
@@ -340,19 +440,17 @@ function updateLabels() {
|
||||
ui.setText("preview-status", format("%s %.3f / %.3f s",
|
||||
combatEditor.timeline.isPlaying() ? "PLAYING" : "PAUSED",
|
||||
combatEditor.timeline.getPreviewTime(), combatEditor.timeline.getDuration()));
|
||||
ui.select("action.inspector"); ui.setText("action-uri", "combat:light-attack-kaykit");
|
||||
ui.setText("revision", "Revision " + combatEditor.timeline.getRevision());
|
||||
combatEditor.selectedItem = "Nothing selected";
|
||||
for (local i = 0; i < combatEditor.timeline.getItemCount(); ++i)
|
||||
if (combatEditor.timeline.getItemSelected(i))
|
||||
combatEditor.selectedItem = combatEditor.timeline.getItemType(i);
|
||||
ui.setText("selected-item", "Selected: " + combatEditor.selectedItem);
|
||||
ui.select("action.inspector"); ui.setText("action-uri", "Clip: Melee_1H_Attack_Chop");
|
||||
ui.setText("revision", "Revision " + combatEditor.clipEditor.getRevision());
|
||||
ui.setText("selected-item", "Joint: " + combatEditor.clipEditor.getSelectedBone());
|
||||
ui.setText("last-event", combatEditor.lastEvent);
|
||||
ui.setValue("hitbox-start", combatEditor.timeline.getStateStart(HITBOX_ID));
|
||||
ui.setValue("hitbox-end", combatEditor.timeline.getStateEnd(HITBOX_ID));
|
||||
ui.setEnabled("undo", combatEditor.timeline.canUndo());
|
||||
ui.setEnabled("redo", combatEditor.timeline.canRedo());
|
||||
ui.select("action.assets"); ui.setText("asset-selection", "Active: " + combatEditor.selectedAsset);
|
||||
ui.setValue("position-x", combatEditor.clipEditor.getSelectedPositionX()); ui.setValue("position-y", combatEditor.clipEditor.getSelectedPositionY()); ui.setValue("position-z", combatEditor.clipEditor.getSelectedPositionZ());
|
||||
ui.setValue("rotation-x", combatEditor.clipEditor.getSelectedRotationX()); ui.setValue("rotation-y", combatEditor.clipEditor.getSelectedRotationY()); ui.setValue("rotation-z", combatEditor.clipEditor.getSelectedRotationZ());
|
||||
ui.setValue("scale-x", combatEditor.clipEditor.getSelectedScaleX()); ui.setValue("scale-y", combatEditor.clipEditor.getSelectedScaleY()); ui.setValue("scale-z", combatEditor.clipEditor.getSelectedScaleZ());
|
||||
ui.setValue("key-time", combatEditor.clipEditor.getSelectedKeyTime()); ui.setEnabled("delete-key", combatEditor.clipEditor.hasSelectedKey());
|
||||
ui.setEnabled("clip-undo", combatEditor.clipEditor.canUndo()); ui.setEnabled("clip-redo", combatEditor.clipEditor.canRedo());
|
||||
ui.select("action.assets"); ui.setText("bone-count", combatEditor.clipEditor.getBoneCount() + " joints");
|
||||
ui.setText("asset-selection", "Active: " + combatEditor.clipEditor.getSelectedBone());
|
||||
ui.select("action.timeline"); ui.setText("timeline-status", combatEditor.status);
|
||||
ui.setEnabled("undo", combatEditor.timeline.canUndo());
|
||||
ui.setEnabled("redo", combatEditor.timeline.canRedo());
|
||||
@@ -370,6 +468,13 @@ function drawTimeline() {
|
||||
gfx.drawSolidRect(145.0, y + 34.0, width - 145.0, 1.0, 0.22, 0.24, 0.29, 1.0);
|
||||
}
|
||||
gfx.drawSolidRect(144.0, 0.0, 1.0, height, 0.34, 0.37, 0.45, 1.0);
|
||||
for (local tick = 0; tick < combatEditor.timeline.getRulerTickCount(); ++tick) {
|
||||
local x = combatEditor.timeline.getRulerTickX(tick);
|
||||
local major = combatEditor.timeline.getRulerTickMajor(tick);
|
||||
gfx.drawSolidRect(x, 0.0, major ? 1.5 : 1.0, height,
|
||||
major ? 0.30 : 0.18, major ? 0.34 : 0.20, major ? 0.42 : 0.26,
|
||||
major ? 0.65 : 0.35);
|
||||
}
|
||||
for (local i = 0; i < combatEditor.timeline.getItemCount(); ++i) {
|
||||
local selected = combatEditor.timeline.getItemSelected(i);
|
||||
local isState = combatEditor.timeline.getItemState(i);
|
||||
@@ -389,8 +494,21 @@ function drawTimeline() {
|
||||
gfx.setCanvas(null);
|
||||
}
|
||||
|
||||
function drawBoneTimeline() {
|
||||
ui.select("action.timeline"); local canvas = ui.viewportCanvas("bone-timeline"); if (canvas == null) return;
|
||||
gfx.setCanvas(canvas); gfx.clear(); local width=combatEditor.clipEditor.getLayoutWidth(); local height=118.0;
|
||||
gfx.drawSolidRect(0.0,0.0,width,height,0.04,0.05,0.07,1.0);
|
||||
for(local i=0;i<combatEditor.clipEditor.getKeyCount();++i) {
|
||||
local selected=combatEditor.clipEditor.getKeySelected(i);
|
||||
gfx.drawSolidRect(combatEditor.clipEditor.getKeyX(i)-3.0,combatEditor.clipEditor.getKeyY(i)-3.0,7.0,7.0,
|
||||
selected?1.0:0.35,selected?0.72:0.62,selected?0.18:0.95,1.0);
|
||||
}
|
||||
gfx.drawSolidRect(combatEditor.clipEditor.getPlayheadX(),0.0,2.0,height,0.98,0.28,0.24,1.0); gfx.setCanvas(null);
|
||||
}
|
||||
|
||||
eve_init = function() {
|
||||
buildCharacterPreview(); buildWorkspace(); ui.setTheme("dark"); ui.setNavKeyboard(true); mountPanels();
|
||||
buildCharacterPreview(); buildWorkspace(); ui.setTheme("dark"); ui.setScale(0.75);
|
||||
ui.setNavKeyboard(true); mountPanels();
|
||||
combatEditor.timeline.play();
|
||||
combatEditor.status = "KayKit clip loaded · drag timeline items or use the inspector";
|
||||
print("combat-action-editor: clip=" + combatEditor.clip.getDuration() + "s tracks=" +
|
||||
@@ -398,10 +516,26 @@ eve_init = function() {
|
||||
};
|
||||
|
||||
eve_update = function(dt) {
|
||||
handleUiEvents(); updateTimelinePointer(); updatePreviewCamera(); updateKeyboardShortcuts();
|
||||
handleUiEvents(); updateTimelinePointer(); updateBoneTimelinePointer(); updatePreviewCamera(); updateKeyboardShortcuts();
|
||||
requireResult(combatEditor.clipEditor.seekSeconds(combatEditor.timeline.getPreviewTime()), "Sync animation playhead");
|
||||
if (combatEditor.timeline.isPlaying()) {
|
||||
local advanced = combatEditor.timeline.update(dt);
|
||||
if (!advanced.ok) combatEditor.status = advanced.status.summary;
|
||||
if (combatEditor.timeline.isRuntimePlaying()) {
|
||||
local runtimeAdvanced = combatEditor.timeline.advanceRuntime(dt);
|
||||
if (!runtimeAdvanced.ok) combatEditor.status = runtimeAdvanced.status.summary;
|
||||
else {
|
||||
if (runtimeAdvanced.value.sectionId != "")
|
||||
combatEditor.status = "Runtime · " + runtimeAdvanced.value.sectionId;
|
||||
local count = combatEditor.timeline.getRuntimeEventCount();
|
||||
if (count > 0) {
|
||||
local eventIndex = count - 1;
|
||||
combatEditor.lastEvent = combatEditor.timeline.getRuntimeEventKind(eventIndex) + " · " +
|
||||
combatEditor.timeline.getRuntimeEventType(eventIndex) + " @ " +
|
||||
format("%.3f s", combatEditor.timeline.getRuntimeEventSeconds(eventIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combatEditor.timeline.getEventCount() > 0) {
|
||||
local i = combatEditor.timeline.getEventCount() - 1;
|
||||
@@ -414,8 +548,8 @@ eve_update = function(dt) {
|
||||
eve_render = function() {
|
||||
gfx.clear(); ui.select("action.preview"); local preview = ui.viewportCanvas("combat-preview");
|
||||
if (preview != null) gfx.renderScene3DToCanvas(preview, combatEditor.camera);
|
||||
drawTimeline(); ui.beginFrameAndRender(); combatEditor.frame += 1;
|
||||
if (!combatEditor.screenshotSaved && combatEditor.frame > 90 &&
|
||||
drawTimeline(); drawBoneTimeline(); ui.beginFrameAndRender(); combatEditor.frame += 1;
|
||||
if (!combatEditor.screenshotSaved && combatEditor.frame > 10 &&
|
||||
gfx.saveFramePng("combat-action-editor.png")) {
|
||||
combatEditor.screenshotSaved = true;
|
||||
print("combat-action-editor: saved combat-action-editor.png\n");
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Opt-in check for the package ActorCOMTransform mass-center dependency.
|
||||
function verifyVolumeFluidActorCOM() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.spacing = 0.1;
|
||||
local first = clone checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
first.position = [0.0, 1.0, 0.0];
|
||||
first.actorGroup = 7;
|
||||
first.material.density = 1000.0;
|
||||
local second = clone first;
|
||||
second.material = clone first.material;
|
||||
second.position = [1.0, 1.0, 0.0];
|
||||
second.material.density = 500.0;
|
||||
local excluded = clone first;
|
||||
excluded.position = [1.5, 1.0, 0.0];
|
||||
excluded.actorGroup = 8;
|
||||
definition.particles = [first, second, excluded];
|
||||
local source = checked(fluids.newVolumeSimulator(definition));
|
||||
local properties = checked(source.actorMassProperties(7));
|
||||
if (properties.particleCount != 2 || fabs(properties.mass - 1.5) > 0.00001)
|
||||
throw "Actor mass summary is incorrect";
|
||||
if (fabs(properties.centerOfMass[0] - 1.0 / 3.0) > 0.00001 ||
|
||||
fabs(properties.centerOfMass[1] - 1.0) > 0.00001)
|
||||
throw "Actor center of mass is incorrect";
|
||||
if (source.actorMassProperties(9).ok)
|
||||
throw "Missing actor group was accepted";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
# Procedural torus fixture generated for EVEngine volume-fluid example.
|
||||
o emission_ring
|
||||
v 0.63000000 0.00000000 0.00000000
|
||||
v 0.60588457 0.09000000 0.00000000
|
||||
v 0.54000000 0.15588457 0.00000000
|
||||
v 0.45000000 0.18000000 0.00000000
|
||||
v 0.36000000 0.15588457 0.00000000
|
||||
v 0.29411543 0.09000000 0.00000000
|
||||
v 0.27000000 0.00000000 0.00000000
|
||||
v 0.29411543 -0.09000000 0.00000000
|
||||
v 0.36000000 -0.15588457 0.00000000
|
||||
v 0.45000000 -0.18000000 0.00000000
|
||||
v 0.54000000 -0.15588457 0.00000000
|
||||
v 0.60588457 -0.09000000 0.00000000
|
||||
v 0.60853327 0.00000000 0.16305600
|
||||
v 0.58523956 0.09000000 0.15681447
|
||||
v 0.52159995 0.15588457 0.13976228
|
||||
v 0.43466662 0.18000000 0.11646857
|
||||
v 0.34773330 0.15588457 0.09317486
|
||||
v 0.28409369 0.09000000 0.07612267
|
||||
v 0.26079997 0.00000000 0.06988114
|
||||
v 0.28409369 -0.09000000 0.07612267
|
||||
v 0.34773330 -0.15588457 0.09317486
|
||||
v 0.43466662 -0.18000000 0.11646857
|
||||
v 0.52159995 -0.15588457 0.13976228
|
||||
v 0.58523956 -0.09000000 0.15681447
|
||||
v 0.54559600 0.00000000 0.31500000
|
||||
v 0.52471143 0.09000000 0.30294229
|
||||
v 0.46765372 0.15588457 0.27000000
|
||||
v 0.38971143 0.18000000 0.22500000
|
||||
v 0.31176915 0.15588457 0.18000000
|
||||
v 0.25471143 0.09000000 0.14705771
|
||||
v 0.23382686 0.00000000 0.13500000
|
||||
v 0.25471143 -0.09000000 0.14705771
|
||||
v 0.31176915 -0.15588457 0.18000000
|
||||
v 0.38971143 -0.18000000 0.22500000
|
||||
v 0.46765372 -0.15588457 0.27000000
|
||||
v 0.52471143 -0.09000000 0.30294229
|
||||
v 0.44547727 0.00000000 0.44547727
|
||||
v 0.42842509 0.09000000 0.42842509
|
||||
v 0.38183766 0.15588457 0.38183766
|
||||
v 0.31819805 0.18000000 0.31819805
|
||||
v 0.25455844 0.15588457 0.25455844
|
||||
v 0.20797101 0.09000000 0.20797101
|
||||
v 0.19091883 0.00000000 0.19091883
|
||||
v 0.20797101 -0.09000000 0.20797101
|
||||
v 0.25455844 -0.15588457 0.25455844
|
||||
v 0.31819805 -0.18000000 0.31819805
|
||||
v 0.38183766 -0.15588457 0.38183766
|
||||
v 0.42842509 -0.09000000 0.42842509
|
||||
v 0.31500000 0.00000000 0.54559600
|
||||
v 0.30294229 0.09000000 0.52471143
|
||||
v 0.27000000 0.15588457 0.46765372
|
||||
v 0.22500000 0.18000000 0.38971143
|
||||
v 0.18000000 0.15588457 0.31176915
|
||||
v 0.14705771 0.09000000 0.25471143
|
||||
v 0.13500000 0.00000000 0.23382686
|
||||
v 0.14705771 -0.09000000 0.25471143
|
||||
v 0.18000000 -0.15588457 0.31176915
|
||||
v 0.22500000 -0.18000000 0.38971143
|
||||
v 0.27000000 -0.15588457 0.46765372
|
||||
v 0.30294229 -0.09000000 0.52471143
|
||||
v 0.16305600 0.00000000 0.60853327
|
||||
v 0.15681447 0.09000000 0.58523956
|
||||
v 0.13976228 0.15588457 0.52159995
|
||||
v 0.11646857 0.18000000 0.43466662
|
||||
v 0.09317486 0.15588457 0.34773330
|
||||
v 0.07612267 0.09000000 0.28409369
|
||||
v 0.06988114 0.00000000 0.26079997
|
||||
v 0.07612267 -0.09000000 0.28409369
|
||||
v 0.09317486 -0.15588457 0.34773330
|
||||
v 0.11646857 -0.18000000 0.43466662
|
||||
v 0.13976228 -0.15588457 0.52159995
|
||||
v 0.15681447 -0.09000000 0.58523956
|
||||
v 0.00000000 0.00000000 0.63000000
|
||||
v 0.00000000 0.09000000 0.60588457
|
||||
v 0.00000000 0.15588457 0.54000000
|
||||
v 0.00000000 0.18000000 0.45000000
|
||||
v 0.00000000 0.15588457 0.36000000
|
||||
v 0.00000000 0.09000000 0.29411543
|
||||
v 0.00000000 0.00000000 0.27000000
|
||||
v 0.00000000 -0.09000000 0.29411543
|
||||
v 0.00000000 -0.15588457 0.36000000
|
||||
v 0.00000000 -0.18000000 0.45000000
|
||||
v 0.00000000 -0.15588457 0.54000000
|
||||
v 0.00000000 -0.09000000 0.60588457
|
||||
v -0.16305600 0.00000000 0.60853327
|
||||
v -0.15681447 0.09000000 0.58523956
|
||||
v -0.13976228 0.15588457 0.52159995
|
||||
v -0.11646857 0.18000000 0.43466662
|
||||
v -0.09317486 0.15588457 0.34773330
|
||||
v -0.07612267 0.09000000 0.28409369
|
||||
v -0.06988114 0.00000000 0.26079997
|
||||
v -0.07612267 -0.09000000 0.28409369
|
||||
v -0.09317486 -0.15588457 0.34773330
|
||||
v -0.11646857 -0.18000000 0.43466662
|
||||
v -0.13976228 -0.15588457 0.52159995
|
||||
v -0.15681447 -0.09000000 0.58523956
|
||||
v -0.31500000 0.00000000 0.54559600
|
||||
v -0.30294229 0.09000000 0.52471143
|
||||
v -0.27000000 0.15588457 0.46765372
|
||||
v -0.22500000 0.18000000 0.38971143
|
||||
v -0.18000000 0.15588457 0.31176915
|
||||
v -0.14705771 0.09000000 0.25471143
|
||||
v -0.13500000 0.00000000 0.23382686
|
||||
v -0.14705771 -0.09000000 0.25471143
|
||||
v -0.18000000 -0.15588457 0.31176915
|
||||
v -0.22500000 -0.18000000 0.38971143
|
||||
v -0.27000000 -0.15588457 0.46765372
|
||||
v -0.30294229 -0.09000000 0.52471143
|
||||
v -0.44547727 0.00000000 0.44547727
|
||||
v -0.42842509 0.09000000 0.42842509
|
||||
v -0.38183766 0.15588457 0.38183766
|
||||
v -0.31819805 0.18000000 0.31819805
|
||||
v -0.25455844 0.15588457 0.25455844
|
||||
v -0.20797101 0.09000000 0.20797101
|
||||
v -0.19091883 0.00000000 0.19091883
|
||||
v -0.20797101 -0.09000000 0.20797101
|
||||
v -0.25455844 -0.15588457 0.25455844
|
||||
v -0.31819805 -0.18000000 0.31819805
|
||||
v -0.38183766 -0.15588457 0.38183766
|
||||
v -0.42842509 -0.09000000 0.42842509
|
||||
v -0.54559600 0.00000000 0.31500000
|
||||
v -0.52471143 0.09000000 0.30294229
|
||||
v -0.46765372 0.15588457 0.27000000
|
||||
v -0.38971143 0.18000000 0.22500000
|
||||
v -0.31176915 0.15588457 0.18000000
|
||||
v -0.25471143 0.09000000 0.14705771
|
||||
v -0.23382686 0.00000000 0.13500000
|
||||
v -0.25471143 -0.09000000 0.14705771
|
||||
v -0.31176915 -0.15588457 0.18000000
|
||||
v -0.38971143 -0.18000000 0.22500000
|
||||
v -0.46765372 -0.15588457 0.27000000
|
||||
v -0.52471143 -0.09000000 0.30294229
|
||||
v -0.60853327 0.00000000 0.16305600
|
||||
v -0.58523956 0.09000000 0.15681447
|
||||
v -0.52159995 0.15588457 0.13976228
|
||||
v -0.43466662 0.18000000 0.11646857
|
||||
v -0.34773330 0.15588457 0.09317486
|
||||
v -0.28409369 0.09000000 0.07612267
|
||||
v -0.26079997 0.00000000 0.06988114
|
||||
v -0.28409369 -0.09000000 0.07612267
|
||||
v -0.34773330 -0.15588457 0.09317486
|
||||
v -0.43466662 -0.18000000 0.11646857
|
||||
v -0.52159995 -0.15588457 0.13976228
|
||||
v -0.58523956 -0.09000000 0.15681447
|
||||
v -0.63000000 0.00000000 0.00000000
|
||||
v -0.60588457 0.09000000 0.00000000
|
||||
v -0.54000000 0.15588457 0.00000000
|
||||
v -0.45000000 0.18000000 0.00000000
|
||||
v -0.36000000 0.15588457 0.00000000
|
||||
v -0.29411543 0.09000000 0.00000000
|
||||
v -0.27000000 0.00000000 0.00000000
|
||||
v -0.29411543 -0.09000000 0.00000000
|
||||
v -0.36000000 -0.15588457 0.00000000
|
||||
v -0.45000000 -0.18000000 0.00000000
|
||||
v -0.54000000 -0.15588457 0.00000000
|
||||
v -0.60588457 -0.09000000 0.00000000
|
||||
v -0.60853327 0.00000000 -0.16305600
|
||||
v -0.58523956 0.09000000 -0.15681447
|
||||
v -0.52159995 0.15588457 -0.13976228
|
||||
v -0.43466662 0.18000000 -0.11646857
|
||||
v -0.34773330 0.15588457 -0.09317486
|
||||
v -0.28409369 0.09000000 -0.07612267
|
||||
v -0.26079997 0.00000000 -0.06988114
|
||||
v -0.28409369 -0.09000000 -0.07612267
|
||||
v -0.34773330 -0.15588457 -0.09317486
|
||||
v -0.43466662 -0.18000000 -0.11646857
|
||||
v -0.52159995 -0.15588457 -0.13976228
|
||||
v -0.58523956 -0.09000000 -0.15681447
|
||||
v -0.54559600 0.00000000 -0.31500000
|
||||
v -0.52471143 0.09000000 -0.30294229
|
||||
v -0.46765372 0.15588457 -0.27000000
|
||||
v -0.38971143 0.18000000 -0.22500000
|
||||
v -0.31176915 0.15588457 -0.18000000
|
||||
v -0.25471143 0.09000000 -0.14705771
|
||||
v -0.23382686 0.00000000 -0.13500000
|
||||
v -0.25471143 -0.09000000 -0.14705771
|
||||
v -0.31176915 -0.15588457 -0.18000000
|
||||
v -0.38971143 -0.18000000 -0.22500000
|
||||
v -0.46765372 -0.15588457 -0.27000000
|
||||
v -0.52471143 -0.09000000 -0.30294229
|
||||
v -0.44547727 0.00000000 -0.44547727
|
||||
v -0.42842509 0.09000000 -0.42842509
|
||||
v -0.38183766 0.15588457 -0.38183766
|
||||
v -0.31819805 0.18000000 -0.31819805
|
||||
v -0.25455844 0.15588457 -0.25455844
|
||||
v -0.20797101 0.09000000 -0.20797101
|
||||
v -0.19091883 0.00000000 -0.19091883
|
||||
v -0.20797101 -0.09000000 -0.20797101
|
||||
v -0.25455844 -0.15588457 -0.25455844
|
||||
v -0.31819805 -0.18000000 -0.31819805
|
||||
v -0.38183766 -0.15588457 -0.38183766
|
||||
v -0.42842509 -0.09000000 -0.42842509
|
||||
v -0.31500000 0.00000000 -0.54559600
|
||||
v -0.30294229 0.09000000 -0.52471143
|
||||
v -0.27000000 0.15588457 -0.46765372
|
||||
v -0.22500000 0.18000000 -0.38971143
|
||||
v -0.18000000 0.15588457 -0.31176915
|
||||
v -0.14705771 0.09000000 -0.25471143
|
||||
v -0.13500000 0.00000000 -0.23382686
|
||||
v -0.14705771 -0.09000000 -0.25471143
|
||||
v -0.18000000 -0.15588457 -0.31176915
|
||||
v -0.22500000 -0.18000000 -0.38971143
|
||||
v -0.27000000 -0.15588457 -0.46765372
|
||||
v -0.30294229 -0.09000000 -0.52471143
|
||||
v -0.16305600 0.00000000 -0.60853327
|
||||
v -0.15681447 0.09000000 -0.58523956
|
||||
v -0.13976228 0.15588457 -0.52159995
|
||||
v -0.11646857 0.18000000 -0.43466662
|
||||
v -0.09317486 0.15588457 -0.34773330
|
||||
v -0.07612267 0.09000000 -0.28409369
|
||||
v -0.06988114 0.00000000 -0.26079997
|
||||
v -0.07612267 -0.09000000 -0.28409369
|
||||
v -0.09317486 -0.15588457 -0.34773330
|
||||
v -0.11646857 -0.18000000 -0.43466662
|
||||
v -0.13976228 -0.15588457 -0.52159995
|
||||
v -0.15681447 -0.09000000 -0.58523956
|
||||
v -0.00000000 0.00000000 -0.63000000
|
||||
v -0.00000000 0.09000000 -0.60588457
|
||||
v -0.00000000 0.15588457 -0.54000000
|
||||
v -0.00000000 0.18000000 -0.45000000
|
||||
v -0.00000000 0.15588457 -0.36000000
|
||||
v -0.00000000 0.09000000 -0.29411543
|
||||
v -0.00000000 0.00000000 -0.27000000
|
||||
v -0.00000000 -0.09000000 -0.29411543
|
||||
v -0.00000000 -0.15588457 -0.36000000
|
||||
v -0.00000000 -0.18000000 -0.45000000
|
||||
v -0.00000000 -0.15588457 -0.54000000
|
||||
v -0.00000000 -0.09000000 -0.60588457
|
||||
v 0.16305600 0.00000000 -0.60853327
|
||||
v 0.15681447 0.09000000 -0.58523956
|
||||
v 0.13976228 0.15588457 -0.52159995
|
||||
v 0.11646857 0.18000000 -0.43466662
|
||||
v 0.09317486 0.15588457 -0.34773330
|
||||
v 0.07612267 0.09000000 -0.28409369
|
||||
v 0.06988114 0.00000000 -0.26079997
|
||||
v 0.07612267 -0.09000000 -0.28409369
|
||||
v 0.09317486 -0.15588457 -0.34773330
|
||||
v 0.11646857 -0.18000000 -0.43466662
|
||||
v 0.13976228 -0.15588457 -0.52159995
|
||||
v 0.15681447 -0.09000000 -0.58523956
|
||||
v 0.31500000 0.00000000 -0.54559600
|
||||
v 0.30294229 0.09000000 -0.52471143
|
||||
v 0.27000000 0.15588457 -0.46765372
|
||||
v 0.22500000 0.18000000 -0.38971143
|
||||
v 0.18000000 0.15588457 -0.31176915
|
||||
v 0.14705771 0.09000000 -0.25471143
|
||||
v 0.13500000 0.00000000 -0.23382686
|
||||
v 0.14705771 -0.09000000 -0.25471143
|
||||
v 0.18000000 -0.15588457 -0.31176915
|
||||
v 0.22500000 -0.18000000 -0.38971143
|
||||
v 0.27000000 -0.15588457 -0.46765372
|
||||
v 0.30294229 -0.09000000 -0.52471143
|
||||
v 0.44547727 0.00000000 -0.44547727
|
||||
v 0.42842509 0.09000000 -0.42842509
|
||||
v 0.38183766 0.15588457 -0.38183766
|
||||
v 0.31819805 0.18000000 -0.31819805
|
||||
v 0.25455844 0.15588457 -0.25455844
|
||||
v 0.20797101 0.09000000 -0.20797101
|
||||
v 0.19091883 0.00000000 -0.19091883
|
||||
v 0.20797101 -0.09000000 -0.20797101
|
||||
v 0.25455844 -0.15588457 -0.25455844
|
||||
v 0.31819805 -0.18000000 -0.31819805
|
||||
v 0.38183766 -0.15588457 -0.38183766
|
||||
v 0.42842509 -0.09000000 -0.42842509
|
||||
v 0.54559600 0.00000000 -0.31500000
|
||||
v 0.52471143 0.09000000 -0.30294229
|
||||
v 0.46765372 0.15588457 -0.27000000
|
||||
v 0.38971143 0.18000000 -0.22500000
|
||||
v 0.31176915 0.15588457 -0.18000000
|
||||
v 0.25471143 0.09000000 -0.14705771
|
||||
v 0.23382686 0.00000000 -0.13500000
|
||||
v 0.25471143 -0.09000000 -0.14705771
|
||||
v 0.31176915 -0.15588457 -0.18000000
|
||||
v 0.38971143 -0.18000000 -0.22500000
|
||||
v 0.46765372 -0.15588457 -0.27000000
|
||||
v 0.52471143 -0.09000000 -0.30294229
|
||||
v 0.60853327 0.00000000 -0.16305600
|
||||
v 0.58523956 0.09000000 -0.15681447
|
||||
v 0.52159995 0.15588457 -0.13976228
|
||||
v 0.43466662 0.18000000 -0.11646857
|
||||
v 0.34773330 0.15588457 -0.09317486
|
||||
v 0.28409369 0.09000000 -0.07612267
|
||||
v 0.26079997 0.00000000 -0.06988114
|
||||
v 0.28409369 -0.09000000 -0.07612267
|
||||
v 0.34773330 -0.15588457 -0.09317486
|
||||
v 0.43466662 -0.18000000 -0.11646857
|
||||
v 0.52159995 -0.15588457 -0.13976228
|
||||
v 0.58523956 -0.09000000 -0.15681447
|
||||
f 1 13 14
|
||||
f 1 14 2
|
||||
f 2 14 15
|
||||
f 2 15 3
|
||||
f 3 15 16
|
||||
f 3 16 4
|
||||
f 4 16 17
|
||||
f 4 17 5
|
||||
f 5 17 18
|
||||
f 5 18 6
|
||||
f 6 18 19
|
||||
f 6 19 7
|
||||
f 7 19 20
|
||||
f 7 20 8
|
||||
f 8 20 21
|
||||
f 8 21 9
|
||||
f 9 21 22
|
||||
f 9 22 10
|
||||
f 10 22 23
|
||||
f 10 23 11
|
||||
f 11 23 24
|
||||
f 11 24 12
|
||||
f 12 24 13
|
||||
f 12 13 1
|
||||
f 13 25 26
|
||||
f 13 26 14
|
||||
f 14 26 27
|
||||
f 14 27 15
|
||||
f 15 27 28
|
||||
f 15 28 16
|
||||
f 16 28 29
|
||||
f 16 29 17
|
||||
f 17 29 30
|
||||
f 17 30 18
|
||||
f 18 30 31
|
||||
f 18 31 19
|
||||
f 19 31 32
|
||||
f 19 32 20
|
||||
f 20 32 33
|
||||
f 20 33 21
|
||||
f 21 33 34
|
||||
f 21 34 22
|
||||
f 22 34 35
|
||||
f 22 35 23
|
||||
f 23 35 36
|
||||
f 23 36 24
|
||||
f 24 36 25
|
||||
f 24 25 13
|
||||
f 25 37 38
|
||||
f 25 38 26
|
||||
f 26 38 39
|
||||
f 26 39 27
|
||||
f 27 39 40
|
||||
f 27 40 28
|
||||
f 28 40 41
|
||||
f 28 41 29
|
||||
f 29 41 42
|
||||
f 29 42 30
|
||||
f 30 42 43
|
||||
f 30 43 31
|
||||
f 31 43 44
|
||||
f 31 44 32
|
||||
f 32 44 45
|
||||
f 32 45 33
|
||||
f 33 45 46
|
||||
f 33 46 34
|
||||
f 34 46 47
|
||||
f 34 47 35
|
||||
f 35 47 48
|
||||
f 35 48 36
|
||||
f 36 48 37
|
||||
f 36 37 25
|
||||
f 37 49 50
|
||||
f 37 50 38
|
||||
f 38 50 51
|
||||
f 38 51 39
|
||||
f 39 51 52
|
||||
f 39 52 40
|
||||
f 40 52 53
|
||||
f 40 53 41
|
||||
f 41 53 54
|
||||
f 41 54 42
|
||||
f 42 54 55
|
||||
f 42 55 43
|
||||
f 43 55 56
|
||||
f 43 56 44
|
||||
f 44 56 57
|
||||
f 44 57 45
|
||||
f 45 57 58
|
||||
f 45 58 46
|
||||
f 46 58 59
|
||||
f 46 59 47
|
||||
f 47 59 60
|
||||
f 47 60 48
|
||||
f 48 60 49
|
||||
f 48 49 37
|
||||
f 49 61 62
|
||||
f 49 62 50
|
||||
f 50 62 63
|
||||
f 50 63 51
|
||||
f 51 63 64
|
||||
f 51 64 52
|
||||
f 52 64 65
|
||||
f 52 65 53
|
||||
f 53 65 66
|
||||
f 53 66 54
|
||||
f 54 66 67
|
||||
f 54 67 55
|
||||
f 55 67 68
|
||||
f 55 68 56
|
||||
f 56 68 69
|
||||
f 56 69 57
|
||||
f 57 69 70
|
||||
f 57 70 58
|
||||
f 58 70 71
|
||||
f 58 71 59
|
||||
f 59 71 72
|
||||
f 59 72 60
|
||||
f 60 72 61
|
||||
f 60 61 49
|
||||
f 61 73 74
|
||||
f 61 74 62
|
||||
f 62 74 75
|
||||
f 62 75 63
|
||||
f 63 75 76
|
||||
f 63 76 64
|
||||
f 64 76 77
|
||||
f 64 77 65
|
||||
f 65 77 78
|
||||
f 65 78 66
|
||||
f 66 78 79
|
||||
f 66 79 67
|
||||
f 67 79 80
|
||||
f 67 80 68
|
||||
f 68 80 81
|
||||
f 68 81 69
|
||||
f 69 81 82
|
||||
f 69 82 70
|
||||
f 70 82 83
|
||||
f 70 83 71
|
||||
f 71 83 84
|
||||
f 71 84 72
|
||||
f 72 84 73
|
||||
f 72 73 61
|
||||
f 73 85 86
|
||||
f 73 86 74
|
||||
f 74 86 87
|
||||
f 74 87 75
|
||||
f 75 87 88
|
||||
f 75 88 76
|
||||
f 76 88 89
|
||||
f 76 89 77
|
||||
f 77 89 90
|
||||
f 77 90 78
|
||||
f 78 90 91
|
||||
f 78 91 79
|
||||
f 79 91 92
|
||||
f 79 92 80
|
||||
f 80 92 93
|
||||
f 80 93 81
|
||||
f 81 93 94
|
||||
f 81 94 82
|
||||
f 82 94 95
|
||||
f 82 95 83
|
||||
f 83 95 96
|
||||
f 83 96 84
|
||||
f 84 96 85
|
||||
f 84 85 73
|
||||
f 85 97 98
|
||||
f 85 98 86
|
||||
f 86 98 99
|
||||
f 86 99 87
|
||||
f 87 99 100
|
||||
f 87 100 88
|
||||
f 88 100 101
|
||||
f 88 101 89
|
||||
f 89 101 102
|
||||
f 89 102 90
|
||||
f 90 102 103
|
||||
f 90 103 91
|
||||
f 91 103 104
|
||||
f 91 104 92
|
||||
f 92 104 105
|
||||
f 92 105 93
|
||||
f 93 105 106
|
||||
f 93 106 94
|
||||
f 94 106 107
|
||||
f 94 107 95
|
||||
f 95 107 108
|
||||
f 95 108 96
|
||||
f 96 108 97
|
||||
f 96 97 85
|
||||
f 97 109 110
|
||||
f 97 110 98
|
||||
f 98 110 111
|
||||
f 98 111 99
|
||||
f 99 111 112
|
||||
f 99 112 100
|
||||
f 100 112 113
|
||||
f 100 113 101
|
||||
f 101 113 114
|
||||
f 101 114 102
|
||||
f 102 114 115
|
||||
f 102 115 103
|
||||
f 103 115 116
|
||||
f 103 116 104
|
||||
f 104 116 117
|
||||
f 104 117 105
|
||||
f 105 117 118
|
||||
f 105 118 106
|
||||
f 106 118 119
|
||||
f 106 119 107
|
||||
f 107 119 120
|
||||
f 107 120 108
|
||||
f 108 120 109
|
||||
f 108 109 97
|
||||
f 109 121 122
|
||||
f 109 122 110
|
||||
f 110 122 123
|
||||
f 110 123 111
|
||||
f 111 123 124
|
||||
f 111 124 112
|
||||
f 112 124 125
|
||||
f 112 125 113
|
||||
f 113 125 126
|
||||
f 113 126 114
|
||||
f 114 126 127
|
||||
f 114 127 115
|
||||
f 115 127 128
|
||||
f 115 128 116
|
||||
f 116 128 129
|
||||
f 116 129 117
|
||||
f 117 129 130
|
||||
f 117 130 118
|
||||
f 118 130 131
|
||||
f 118 131 119
|
||||
f 119 131 132
|
||||
f 119 132 120
|
||||
f 120 132 121
|
||||
f 120 121 109
|
||||
f 121 133 134
|
||||
f 121 134 122
|
||||
f 122 134 135
|
||||
f 122 135 123
|
||||
f 123 135 136
|
||||
f 123 136 124
|
||||
f 124 136 137
|
||||
f 124 137 125
|
||||
f 125 137 138
|
||||
f 125 138 126
|
||||
f 126 138 139
|
||||
f 126 139 127
|
||||
f 127 139 140
|
||||
f 127 140 128
|
||||
f 128 140 141
|
||||
f 128 141 129
|
||||
f 129 141 142
|
||||
f 129 142 130
|
||||
f 130 142 143
|
||||
f 130 143 131
|
||||
f 131 143 144
|
||||
f 131 144 132
|
||||
f 132 144 133
|
||||
f 132 133 121
|
||||
f 133 145 146
|
||||
f 133 146 134
|
||||
f 134 146 147
|
||||
f 134 147 135
|
||||
f 135 147 148
|
||||
f 135 148 136
|
||||
f 136 148 149
|
||||
f 136 149 137
|
||||
f 137 149 150
|
||||
f 137 150 138
|
||||
f 138 150 151
|
||||
f 138 151 139
|
||||
f 139 151 152
|
||||
f 139 152 140
|
||||
f 140 152 153
|
||||
f 140 153 141
|
||||
f 141 153 154
|
||||
f 141 154 142
|
||||
f 142 154 155
|
||||
f 142 155 143
|
||||
f 143 155 156
|
||||
f 143 156 144
|
||||
f 144 156 145
|
||||
f 144 145 133
|
||||
f 145 157 158
|
||||
f 145 158 146
|
||||
f 146 158 159
|
||||
f 146 159 147
|
||||
f 147 159 160
|
||||
f 147 160 148
|
||||
f 148 160 161
|
||||
f 148 161 149
|
||||
f 149 161 162
|
||||
f 149 162 150
|
||||
f 150 162 163
|
||||
f 150 163 151
|
||||
f 151 163 164
|
||||
f 151 164 152
|
||||
f 152 164 165
|
||||
f 152 165 153
|
||||
f 153 165 166
|
||||
f 153 166 154
|
||||
f 154 166 167
|
||||
f 154 167 155
|
||||
f 155 167 168
|
||||
f 155 168 156
|
||||
f 156 168 157
|
||||
f 156 157 145
|
||||
f 157 169 170
|
||||
f 157 170 158
|
||||
f 158 170 171
|
||||
f 158 171 159
|
||||
f 159 171 172
|
||||
f 159 172 160
|
||||
f 160 172 173
|
||||
f 160 173 161
|
||||
f 161 173 174
|
||||
f 161 174 162
|
||||
f 162 174 175
|
||||
f 162 175 163
|
||||
f 163 175 176
|
||||
f 163 176 164
|
||||
f 164 176 177
|
||||
f 164 177 165
|
||||
f 165 177 178
|
||||
f 165 178 166
|
||||
f 166 178 179
|
||||
f 166 179 167
|
||||
f 167 179 180
|
||||
f 167 180 168
|
||||
f 168 180 169
|
||||
f 168 169 157
|
||||
f 169 181 182
|
||||
f 169 182 170
|
||||
f 170 182 183
|
||||
f 170 183 171
|
||||
f 171 183 184
|
||||
f 171 184 172
|
||||
f 172 184 185
|
||||
f 172 185 173
|
||||
f 173 185 186
|
||||
f 173 186 174
|
||||
f 174 186 187
|
||||
f 174 187 175
|
||||
f 175 187 188
|
||||
f 175 188 176
|
||||
f 176 188 189
|
||||
f 176 189 177
|
||||
f 177 189 190
|
||||
f 177 190 178
|
||||
f 178 190 191
|
||||
f 178 191 179
|
||||
f 179 191 192
|
||||
f 179 192 180
|
||||
f 180 192 181
|
||||
f 180 181 169
|
||||
f 181 193 194
|
||||
f 181 194 182
|
||||
f 182 194 195
|
||||
f 182 195 183
|
||||
f 183 195 196
|
||||
f 183 196 184
|
||||
f 184 196 197
|
||||
f 184 197 185
|
||||
f 185 197 198
|
||||
f 185 198 186
|
||||
f 186 198 199
|
||||
f 186 199 187
|
||||
f 187 199 200
|
||||
f 187 200 188
|
||||
f 188 200 201
|
||||
f 188 201 189
|
||||
f 189 201 202
|
||||
f 189 202 190
|
||||
f 190 202 203
|
||||
f 190 203 191
|
||||
f 191 203 204
|
||||
f 191 204 192
|
||||
f 192 204 193
|
||||
f 192 193 181
|
||||
f 193 205 206
|
||||
f 193 206 194
|
||||
f 194 206 207
|
||||
f 194 207 195
|
||||
f 195 207 208
|
||||
f 195 208 196
|
||||
f 196 208 209
|
||||
f 196 209 197
|
||||
f 197 209 210
|
||||
f 197 210 198
|
||||
f 198 210 211
|
||||
f 198 211 199
|
||||
f 199 211 212
|
||||
f 199 212 200
|
||||
f 200 212 213
|
||||
f 200 213 201
|
||||
f 201 213 214
|
||||
f 201 214 202
|
||||
f 202 214 215
|
||||
f 202 215 203
|
||||
f 203 215 216
|
||||
f 203 216 204
|
||||
f 204 216 205
|
||||
f 204 205 193
|
||||
f 205 217 218
|
||||
f 205 218 206
|
||||
f 206 218 219
|
||||
f 206 219 207
|
||||
f 207 219 220
|
||||
f 207 220 208
|
||||
f 208 220 221
|
||||
f 208 221 209
|
||||
f 209 221 222
|
||||
f 209 222 210
|
||||
f 210 222 223
|
||||
f 210 223 211
|
||||
f 211 223 224
|
||||
f 211 224 212
|
||||
f 212 224 225
|
||||
f 212 225 213
|
||||
f 213 225 226
|
||||
f 213 226 214
|
||||
f 214 226 227
|
||||
f 214 227 215
|
||||
f 215 227 228
|
||||
f 215 228 216
|
||||
f 216 228 217
|
||||
f 216 217 205
|
||||
f 217 229 230
|
||||
f 217 230 218
|
||||
f 218 230 231
|
||||
f 218 231 219
|
||||
f 219 231 232
|
||||
f 219 232 220
|
||||
f 220 232 233
|
||||
f 220 233 221
|
||||
f 221 233 234
|
||||
f 221 234 222
|
||||
f 222 234 235
|
||||
f 222 235 223
|
||||
f 223 235 236
|
||||
f 223 236 224
|
||||
f 224 236 237
|
||||
f 224 237 225
|
||||
f 225 237 238
|
||||
f 225 238 226
|
||||
f 226 238 239
|
||||
f 226 239 227
|
||||
f 227 239 240
|
||||
f 227 240 228
|
||||
f 228 240 229
|
||||
f 228 229 217
|
||||
f 229 241 242
|
||||
f 229 242 230
|
||||
f 230 242 243
|
||||
f 230 243 231
|
||||
f 231 243 244
|
||||
f 231 244 232
|
||||
f 232 244 245
|
||||
f 232 245 233
|
||||
f 233 245 246
|
||||
f 233 246 234
|
||||
f 234 246 247
|
||||
f 234 247 235
|
||||
f 235 247 248
|
||||
f 235 248 236
|
||||
f 236 248 249
|
||||
f 236 249 237
|
||||
f 237 249 250
|
||||
f 237 250 238
|
||||
f 238 250 251
|
||||
f 238 251 239
|
||||
f 239 251 252
|
||||
f 239 252 240
|
||||
f 240 252 241
|
||||
f 240 241 229
|
||||
f 241 253 254
|
||||
f 241 254 242
|
||||
f 242 254 255
|
||||
f 242 255 243
|
||||
f 243 255 256
|
||||
f 243 256 244
|
||||
f 244 256 257
|
||||
f 244 257 245
|
||||
f 245 257 258
|
||||
f 245 258 246
|
||||
f 246 258 259
|
||||
f 246 259 247
|
||||
f 247 259 260
|
||||
f 247 260 248
|
||||
f 248 260 261
|
||||
f 248 261 249
|
||||
f 249 261 262
|
||||
f 249 262 250
|
||||
f 250 262 263
|
||||
f 250 263 251
|
||||
f 251 263 264
|
||||
f 251 264 252
|
||||
f 252 264 253
|
||||
f 252 253 241
|
||||
f 253 265 266
|
||||
f 253 266 254
|
||||
f 254 266 267
|
||||
f 254 267 255
|
||||
f 255 267 268
|
||||
f 255 268 256
|
||||
f 256 268 269
|
||||
f 256 269 257
|
||||
f 257 269 270
|
||||
f 257 270 258
|
||||
f 258 270 271
|
||||
f 258 271 259
|
||||
f 259 271 272
|
||||
f 259 272 260
|
||||
f 260 272 273
|
||||
f 260 273 261
|
||||
f 261 273 274
|
||||
f 261 274 262
|
||||
f 262 274 275
|
||||
f 262 275 263
|
||||
f 263 275 276
|
||||
f 263 276 264
|
||||
f 264 276 265
|
||||
f 264 265 253
|
||||
f 265 277 278
|
||||
f 265 278 266
|
||||
f 266 278 279
|
||||
f 266 279 267
|
||||
f 267 279 280
|
||||
f 267 280 268
|
||||
f 268 280 281
|
||||
f 268 281 269
|
||||
f 269 281 282
|
||||
f 269 282 270
|
||||
f 270 282 283
|
||||
f 270 283 271
|
||||
f 271 283 284
|
||||
f 271 284 272
|
||||
f 272 284 285
|
||||
f 272 285 273
|
||||
f 273 285 286
|
||||
f 273 286 274
|
||||
f 274 286 287
|
||||
f 274 287 275
|
||||
f 275 287 288
|
||||
f 275 288 276
|
||||
f 276 288 277
|
||||
f 276 277 265
|
||||
f 277 1 2
|
||||
f 277 2 278
|
||||
f 278 2 3
|
||||
f 278 3 279
|
||||
f 279 3 4
|
||||
f 279 4 280
|
||||
f 280 4 5
|
||||
f 280 5 281
|
||||
f 281 5 6
|
||||
f 281 6 282
|
||||
f 282 6 7
|
||||
f 282 7 283
|
||||
f 283 7 8
|
||||
f 283 8 284
|
||||
f 284 8 9
|
||||
f 284 9 285
|
||||
f 285 9 10
|
||||
f 285 10 286
|
||||
f 286 10 11
|
||||
f 286 11 287
|
||||
f 287 11 12
|
||||
f 287 12 288
|
||||
f 288 12 1
|
||||
f 288 1 277
|
||||
@@ -0,0 +1,172 @@
|
||||
# Low-poly watertight glass shell with an open neck. Y up, metres.
|
||||
o WhiskeyBottle
|
||||
v 0.5500 0.0000 0.0000
|
||||
v 0.3889 0.0000 0.3889
|
||||
v 0.0000 0.0000 0.5500
|
||||
v -0.3889 0.0000 0.3889
|
||||
v -0.5500 0.0000 0.0000
|
||||
v -0.3889 0.0000 -0.3889
|
||||
v 0.0000 0.0000 -0.5500
|
||||
v 0.3889 0.0000 -0.3889
|
||||
v 0.5500 1.2000 0.0000
|
||||
v 0.3889 1.2000 0.3889
|
||||
v 0.0000 1.2000 0.5500
|
||||
v -0.3889 1.2000 0.3889
|
||||
v -0.5500 1.2000 0.0000
|
||||
v -0.3889 1.2000 -0.3889
|
||||
v 0.0000 1.2000 -0.5500
|
||||
v 0.3889 1.2000 -0.3889
|
||||
v 0.3500 1.5000 0.0000
|
||||
v 0.2475 1.5000 0.2475
|
||||
v 0.0000 1.5000 0.3500
|
||||
v -0.2475 1.5000 0.2475
|
||||
v -0.3500 1.5000 0.0000
|
||||
v -0.2475 1.5000 -0.2475
|
||||
v 0.0000 1.5000 -0.3500
|
||||
v 0.2475 1.5000 -0.2475
|
||||
v 0.2800 1.7000 0.0000
|
||||
v 0.1980 1.7000 0.1980
|
||||
v 0.0000 1.7000 0.2800
|
||||
v -0.1980 1.7000 0.1980
|
||||
v -0.2800 1.7000 0.0000
|
||||
v -0.1980 1.7000 -0.1980
|
||||
v 0.0000 1.7000 -0.2800
|
||||
v 0.1980 1.7000 -0.1980
|
||||
v 0.2800 2.1000 0.0000
|
||||
v 0.1980 2.1000 0.1980
|
||||
v 0.0000 2.1000 0.2800
|
||||
v -0.1980 2.1000 0.1980
|
||||
v -0.2800 2.1000 0.0000
|
||||
v -0.1980 2.1000 -0.1980
|
||||
v 0.0000 2.1000 -0.2800
|
||||
v 0.1980 2.1000 -0.1980
|
||||
v 0.1800 2.1000 0.0000
|
||||
v 0.1273 2.1000 0.1273
|
||||
v 0.0000 2.1000 0.1800
|
||||
v -0.1273 2.1000 0.1273
|
||||
v -0.1800 2.1000 0.0000
|
||||
v -0.1273 2.1000 -0.1273
|
||||
v 0.0000 2.1000 -0.1800
|
||||
v 0.1273 2.1000 -0.1273
|
||||
v 0.1800 1.6500 0.0000
|
||||
v 0.1273 1.6500 0.1273
|
||||
v 0.0000 1.6500 0.1800
|
||||
v -0.1273 1.6500 0.1273
|
||||
v -0.1800 1.6500 0.0000
|
||||
v -0.1273 1.6500 -0.1273
|
||||
v 0.0000 1.6500 -0.1800
|
||||
v 0.1273 1.6500 -0.1273
|
||||
v 0.2700 1.4200 0.0000
|
||||
v 0.1909 1.4200 0.1909
|
||||
v 0.0000 1.4200 0.2700
|
||||
v -0.1909 1.4200 0.1909
|
||||
v -0.2700 1.4200 0.0000
|
||||
v -0.1909 1.4200 -0.1909
|
||||
v 0.0000 1.4200 -0.2700
|
||||
v 0.1909 1.4200 -0.1909
|
||||
v 0.4500 1.1500 0.0000
|
||||
v 0.3182 1.1500 0.3182
|
||||
v 0.0000 1.1500 0.4500
|
||||
v -0.3182 1.1500 0.3182
|
||||
v -0.4500 1.1500 0.0000
|
||||
v -0.3182 1.1500 -0.3182
|
||||
v 0.0000 1.1500 -0.4500
|
||||
v 0.3182 1.1500 -0.3182
|
||||
v 0.4500 0.1200 0.0000
|
||||
v 0.3182 0.1200 0.3182
|
||||
v 0.0000 0.1200 0.4500
|
||||
v -0.3182 0.1200 0.3182
|
||||
v -0.4500 0.1200 0.0000
|
||||
v -0.3182 0.1200 -0.3182
|
||||
v 0.0000 0.1200 -0.4500
|
||||
v 0.3182 0.1200 -0.3182
|
||||
v 0.0000 0.1200 0.0000
|
||||
v 0.0000 0.0000 0.0000
|
||||
f 1 2 10 9
|
||||
f 2 3 11 10
|
||||
f 3 4 12 11
|
||||
f 4 5 13 12
|
||||
f 5 6 14 13
|
||||
f 6 7 15 14
|
||||
f 7 8 16 15
|
||||
f 8 1 9 16
|
||||
f 9 10 18 17
|
||||
f 10 11 19 18
|
||||
f 11 12 20 19
|
||||
f 12 13 21 20
|
||||
f 13 14 22 21
|
||||
f 14 15 23 22
|
||||
f 15 16 24 23
|
||||
f 16 9 17 24
|
||||
f 17 18 26 25
|
||||
f 18 19 27 26
|
||||
f 19 20 28 27
|
||||
f 20 21 29 28
|
||||
f 21 22 30 29
|
||||
f 22 23 31 30
|
||||
f 23 24 32 31
|
||||
f 24 17 25 32
|
||||
f 25 26 34 33
|
||||
f 26 27 35 34
|
||||
f 27 28 36 35
|
||||
f 28 29 37 36
|
||||
f 29 30 38 37
|
||||
f 30 31 39 38
|
||||
f 31 32 40 39
|
||||
f 32 25 33 40
|
||||
f 41 42 50 49
|
||||
f 42 43 51 50
|
||||
f 43 44 52 51
|
||||
f 44 45 53 52
|
||||
f 45 46 54 53
|
||||
f 46 47 55 54
|
||||
f 47 48 56 55
|
||||
f 48 41 49 56
|
||||
f 49 50 58 57
|
||||
f 50 51 59 58
|
||||
f 51 52 60 59
|
||||
f 52 53 61 60
|
||||
f 53 54 62 61
|
||||
f 54 55 63 62
|
||||
f 55 56 64 63
|
||||
f 56 49 57 64
|
||||
f 57 58 66 65
|
||||
f 58 59 67 66
|
||||
f 59 60 68 67
|
||||
f 60 61 69 68
|
||||
f 61 62 70 69
|
||||
f 62 63 71 70
|
||||
f 63 64 72 71
|
||||
f 64 57 65 72
|
||||
f 65 66 74 73
|
||||
f 66 67 75 74
|
||||
f 67 68 76 75
|
||||
f 68 69 77 76
|
||||
f 69 70 78 77
|
||||
f 70 71 79 78
|
||||
f 71 72 80 79
|
||||
f 72 65 73 80
|
||||
f 33 34 42 41
|
||||
f 34 35 43 42
|
||||
f 35 36 44 43
|
||||
f 36 37 45 44
|
||||
f 37 38 46 45
|
||||
f 38 39 47 46
|
||||
f 39 40 48 47
|
||||
f 40 33 41 48
|
||||
f 82 2 1
|
||||
f 82 3 2
|
||||
f 82 4 3
|
||||
f 82 5 4
|
||||
f 82 6 5
|
||||
f 82 7 6
|
||||
f 82 8 7
|
||||
f 82 1 8
|
||||
f 81 73 74
|
||||
f 81 74 75
|
||||
f 81 75 76
|
||||
f 81 76 77
|
||||
f 81 77 78
|
||||
f 81 78 79
|
||||
f 81 79 80
|
||||
f 81 80 73
|
||||
@@ -0,0 +1,82 @@
|
||||
// Opt-in bounded solid attachment check, no rendering inside the loop.
|
||||
function verifyVolumeFluidAttachments() {
|
||||
rebuild(11);
|
||||
advanceAttachment(0.0);
|
||||
local initial = checked(solver.snapshot());
|
||||
if (initial.version != 20 || initial.attachments.len() != 16) throw "Missing solid attachments";
|
||||
for (local frame = 1; frame <= 60; ++frame) advanceAttachment(frame / 60.0);
|
||||
local final = checked(solver.snapshot());
|
||||
if (final.attachments.len() != 16) throw "Attachment count changed";
|
||||
local angle = 0.35 * sin(1.0);
|
||||
foreach (anchor in final.attachments) {
|
||||
local localPoint = anchor.localPosition;
|
||||
local p = final.particles[anchor.particleIndex];
|
||||
if (fabs(p.color[0] - 0.95) > 0.00001 || fabs(p.color[1] - 0.65) > 0.00001 || fabs(p.color[2] - 0.2) > 0.00001)
|
||||
throw "Solid color not applied";
|
||||
local expectedX = 0.3 * sin(1.0) + cos(angle)*localPoint[0] - sin(angle)*localPoint[1];
|
||||
local expectedY = 0.5 + sin(angle)*localPoint[0] + cos(angle)*localPoint[1];
|
||||
if (fabs(p.position[0]-expectedX) > 0.00001 || fabs(p.position[1]-expectedY) > 0.00001)
|
||||
throw "Solid attachment lost local point";
|
||||
local localOrientation = anchor.localOrientation;
|
||||
local expectedZ = sin(angle * 0.5) * localOrientation[3] + cos(angle * 0.5) * localOrientation[2];
|
||||
local expectedW = cos(angle * 0.5) * localOrientation[3] - sin(angle * 0.5) * localOrientation[2];
|
||||
if (fabs(p.orientation[2]-expectedZ) > 0.00001 || fabs(p.orientation[3]-expectedW) > 0.00001)
|
||||
throw "Solid attachment lost local orientation";
|
||||
}
|
||||
checked(solver.restore(final));
|
||||
if (checked(solver.snapshot()).attachments.len() != 16) throw "Attachment restore failed";
|
||||
|
||||
local emptyDefinition=checked(fluids.volumeDefaults());
|
||||
emptyDefinition.settings.capacity=final.settings.capacity;
|
||||
local colliderFirst=checked(fluids.newVolumeSimulator(emptyDefinition));
|
||||
checked(colliderFirst.restore(final));
|
||||
checked(colliderFirst.setColliders([]));
|
||||
local withoutCollider=checked(colliderFirst.snapshot());
|
||||
if(withoutCollider.attachments.len()!=0||withoutCollider.particles.len()!=final.particles.len())
|
||||
throw "Collider-first destruction left stale attachments or removed particles";
|
||||
|
||||
local particlesFirst=checked(fluids.newVolumeSimulator(emptyDefinition));
|
||||
checked(particlesFirst.restore(final));
|
||||
particlesFirst.clear();
|
||||
local withoutParticles=checked(particlesFirst.snapshot());
|
||||
if(withoutParticles.attachments.len()!=0||withoutParticles.particles.len()!=0)
|
||||
throw "Particle-first destruction left stale attachments";
|
||||
checked(particlesFirst.setColliders([]));
|
||||
paused = true; dirty = true;
|
||||
return "VOLUME_SOLID_ATTACHMENT_PASS attached="+final.attachments.len()+" colliderFirst=0 particleFirst=0";
|
||||
}
|
||||
|
||||
// Seed from an actual collider contact, then freeze one adjoining layer per call.
|
||||
function verifyVolumeFluidPropagation() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
prototype.material.viscosity = 0.0; prototype.material.cohesion = 0.0;
|
||||
for (local i = 0; i < 4; ++i) {
|
||||
local p = clone prototype;
|
||||
p.position = [0.0, 1.2999 + i * 0.1, 0.0];
|
||||
definition.particles.append(p);
|
||||
}
|
||||
local sim = checked(fluids.newVolumeSimulator(definition));
|
||||
local collider = checked(fluids.volumeColliderDefaults());
|
||||
collider.label = 17; collider.solidify = true;
|
||||
checked(sim.setColliders([collider]));
|
||||
for (local wave = 0; wave < 3; ++wave) {
|
||||
checked(sim.step(1.0 / 120.0, 1));
|
||||
checked(sim.applySolidColor(0.95, 0.65, 0.2, 1.0));
|
||||
if (checked(sim.snapshot()).attachments.len() != wave + 2)
|
||||
throw "Solid contact propagation did not advance one layer";
|
||||
}
|
||||
collider.center[0] = 0.1;
|
||||
checked(sim.setColliders([collider]));
|
||||
checked(sim.step(1.0 / 120.0, 1));
|
||||
foreach (p in checked(sim.snapshot()).particles)
|
||||
if (fabs(p.position[0] - 0.1) > 0.00001) throw "Propagated solid did not follow collider";
|
||||
return "VOLUME_SOLID_PROPAGATION_PASS waves=4 moved=4 colored=4";
|
||||
}
|
||||
|
||||
function verifySolidifyOnContact() {
|
||||
local attachment=verifyVolumeFluidAttachments();
|
||||
local propagation=verifyVolumeFluidPropagation();
|
||||
return attachment+" | "+propagation;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// WhiskeyBottle helpers. The mesh SDF is baked once in fluidBottleScene(); frame updates change only its pose.
|
||||
const FLUID_BOTTLE_PIVOT_Y = 0.65;
|
||||
const FLUID_BOTTLE_BASE_Y = 0.15;
|
||||
|
||||
function fluidBottlePose(angle, angularVelocity = 0.0) {
|
||||
local c=cos(angle); local s=sin(angle);
|
||||
local pose=checked(fluids.volumeSdfPoseDefaults());
|
||||
pose.label=301;
|
||||
pose.position=[s*FLUID_BOTTLE_PIVOT_Y,
|
||||
FLUID_BOTTLE_BASE_Y+FLUID_BOTTLE_PIVOT_Y-c*FLUID_BOTTLE_PIVOT_Y,0.0];
|
||||
pose.rotation=[0.0,0.0,sin(angle*0.5),cos(angle*0.5)];
|
||||
pose.angularVelocity=[0.0,0.0,angularVelocity];
|
||||
return pose;
|
||||
}
|
||||
|
||||
function fluidBottlePoint(localPoint,angle) {
|
||||
local pose=fluidBottlePose(angle); local c=cos(angle); local s=sin(angle);
|
||||
return [pose.position[0]+c*localPoint[0]-s*localPoint[1],
|
||||
pose.position[1]+s*localPoint[0]+c*localPoint[1],localPoint[2]];
|
||||
}
|
||||
|
||||
function fluidBottleScene() {
|
||||
local definition=checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity=48; definition.settings.spacing=0.09;
|
||||
definition.settings.minimum=[-1.8,-0.25,-0.65]; definition.settings.maximum=[1.8,2.5,0.65];
|
||||
definition.settings.iterations=3;
|
||||
local prototype=checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for(local z=0;z<2;++z) for(local y=0;y<3;++y) for(local x=0;x<4;++x) {
|
||||
local p=clone prototype; p.material=clone prototype.material;
|
||||
p.material.viscosity=0.8; p.material.cohesion=0.35;
|
||||
p.position=[-0.18+x*0.12,FLUID_BOTTLE_BASE_Y+0.22+y*0.12,-0.06+z*0.12];
|
||||
p.color=[0.78,0.28,0.035,0.92]; definition.particles.append(p);
|
||||
}
|
||||
local model=model3d.newModelDataFromFile("assets/whiskey-bottle.obj");
|
||||
local collider=checked(fluids.volumeSdfColliderFromModel(model,0,1.0,1.0,1.0,24));
|
||||
collider.label=301; collider.friction=0.04;
|
||||
local pose=fluidBottlePose(0.0); collider.position=pose.position; collider.rotation=pose.rotation;
|
||||
definition.sdfColliders=[collider];
|
||||
return {solver=checked(fluids.newVolumeSimulator(definition)),angle=0.0,pose=pose};
|
||||
}
|
||||
|
||||
function fluidBottleOutsideCavity(particle,angle) {
|
||||
local pose=fluidBottlePose(angle); local dx=particle.position[0]-pose.position[0];
|
||||
local dy=particle.position[1]-pose.position[1]; local c=cos(angle); local s=sin(angle);
|
||||
local x=c*dx+s*dy; local y=-s*dx+c*dy; local radial=sqrt(x*x+particle.position[2]*particle.position[2]);
|
||||
if(y<0.08) return true;
|
||||
if(y<1.14) return radial>0.47;
|
||||
if(y<1.46) return radial>(0.47-(y-1.14)*0.75);
|
||||
return radial>0.20;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Opt-in FaucetAndBucket acceptance. Loading this file does not run it.
|
||||
function verifyVolumeFluidBucket() {
|
||||
paused=true;rebuild(16);
|
||||
local emittedTotal=0;local emittedAfterLifetime=0;local peakActive=0;
|
||||
local peakContacts=0;local uprightOutside=0;local overflowObserved=0;
|
||||
for(local frame=0;frame<360;++frame) {
|
||||
if(frame>=150) {
|
||||
local next=bucket.angle+0.012;
|
||||
if(next>1.45)next=1.45;
|
||||
if(next!=bucket.angle) {
|
||||
bucket.angle=next;
|
||||
fluidBucketUpdateColliderPoses(bucket.walls,bucket.colliders,bucket.angle);
|
||||
checked(solver.setColliders(bucket.colliders));
|
||||
}
|
||||
}
|
||||
checked(solver.step(1.0/120.0,1));
|
||||
local emitted=checked(emitter.advance(solver,nozzle,1.0/120.0,2,0.25));
|
||||
emittedTotal+=emitted;if(frame>230)emittedAfterLifetime+=emitted;
|
||||
local active=solver.getParticleCount();if(active>peakActive)peakActive=active;
|
||||
local contacts=checked(solver.contacts()).len();if(contacts>peakContacts)peakContacts=contacts;
|
||||
if(frame==145 || frame>240) {
|
||||
local outside=0;
|
||||
foreach(particle in checked(solver.snapshot()).particles)
|
||||
if(fluidBucketOutside(particle,bucket.angle))++outside;
|
||||
if(frame==145)uprightOutside=outside;
|
||||
if(outside>overflowObserved)overflowObserved=outside;
|
||||
}
|
||||
}
|
||||
bucket.emitted=emittedTotal;bucket.peakActive=peakActive;dirty=true;
|
||||
if(emittedTotal<=peakActive || emittedAfterLifetime<=0 || peakActive>=64)
|
||||
throw "Bucket emission pool did not remain bounded and reusable";
|
||||
if(peakContacts<=0 || bucket.angle<1.4)
|
||||
throw "Moving bucket did not produce contacts at its poured pose";
|
||||
if(uprightOutside!=0 || overflowObserved<=0)
|
||||
throw "Bucket did not retain upright fluid then overflow while tilted";
|
||||
return "VOLUME_FLUID_BUCKET_PASS emitted="+emittedTotal+" peak="+peakActive+
|
||||
" recycled="+(emittedTotal-peakActive)+" afterLifetime="+emittedAfterLifetime+
|
||||
" contacts="+peakContacts+" overflow="+overflowObserved;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Shared FaucetAndBucket construction helpers. Loading this file is inert.
|
||||
function fluidBucketPoint(localPoint, angle) {
|
||||
local c=cos(angle);local s=sin(angle);
|
||||
return [c*localPoint[0]-s*localPoint[1],0.72+s*localPoint[0]+c*localPoint[1],localPoint[2]];
|
||||
}
|
||||
|
||||
function fluidBucketWalls() {
|
||||
return [
|
||||
{center=[-0.55,0.0,0.0],half=[0.06,0.60,0.36],label=201},
|
||||
{center=[0.55,0.0,0.0],half=[0.06,0.60,0.36],label=202},
|
||||
{center=[0.0,-0.55,0.0],half=[0.60,0.06,0.36],label=203}
|
||||
];
|
||||
}
|
||||
|
||||
function fluidBucketUpdateColliderPoses(walls,colliders,angle) {
|
||||
local rotation=[0.0,0.0,sin(angle*0.5),cos(angle*0.5)];
|
||||
for(local i=0;i<walls.len();++i) {
|
||||
colliders[i].center=fluidBucketPoint(walls[i].center,angle);
|
||||
colliders[i].rotation=rotation;
|
||||
}
|
||||
}
|
||||
|
||||
function fluidBucketColliders(walls,angle) {
|
||||
local colliders=[];
|
||||
foreach(wall in walls) {
|
||||
local collider=checked(fluids.volumeColliderDefaults());
|
||||
collider.label=wall.label;collider.shape=1;collider.halfExtent=wall.half;
|
||||
collider.friction=0.12;colliders.append(collider);
|
||||
}
|
||||
fluidBucketUpdateColliderPoses(walls,colliders,angle);
|
||||
return colliders;
|
||||
}
|
||||
|
||||
function fluidBucketScene() {
|
||||
local definition=checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity=64;definition.settings.spacing=0.09;
|
||||
definition.settings.minimum=[-1.6,-0.25,-0.45];definition.settings.maximum=[1.6,2.5,0.45];
|
||||
definition.settings.iterations=3;
|
||||
local walls=fluidBucketWalls();local colliders=fluidBucketColliders(walls,0.0);
|
||||
definition.colliders=colliders;
|
||||
return {solver=checked(fluids.newVolumeSimulator(definition)),walls=walls,colliders=colliders,
|
||||
angle=0.0,emitted=0,peakActive=0};
|
||||
}
|
||||
|
||||
function fluidBucketOutside(particle,angle) {
|
||||
local dx=particle.position[0];local dy=particle.position[1]-0.72;
|
||||
local c=cos(angle);local s=sin(angle);
|
||||
local x=c*dx+s*dy;local y=-s*dx+c*dy;
|
||||
return fabs(x)>0.62 || y < -0.64 || (fabs(x)>0.48 && y>0.48);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Opt-in check for Fluid3DEmitter EmissionMethod.BURST lifecycle behavior.
|
||||
function verifyVolumeFluidBurstMode() {
|
||||
local defaults = fluids.volumeDefaults();
|
||||
if (!defaults.ok) throw defaults.error.message;
|
||||
defaults.value.settings.capacity = 8;
|
||||
defaults.value.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local created = fluids.newVolumeSimulator(defaults.value);
|
||||
if (!created.ok) throw created.error.message;
|
||||
local emission = fluids.volumeEmissionDefaults();
|
||||
if (!emission.ok) throw emission.error.message;
|
||||
emission.value.description.prototype.actorGroup = 9;
|
||||
emission.value.description.prototype.life = 0.01;
|
||||
local emitter = eve.VolumeFluidEmitter();
|
||||
local first = emitter.advanceBurst(created.value, emission.value, 2, 0.0);
|
||||
if (!first.ok || first.value != 2) throw "Initial burst failed";
|
||||
local waiting = emitter.advanceBurst(created.value, emission.value, 2, 0.0);
|
||||
if (!waiting.ok || waiting.value != 0) throw "Burst repeated while actor particles were alive";
|
||||
local killed = created.value.killParticle(1);
|
||||
if (!killed.ok) throw killed.error.message;
|
||||
killed = created.value.killParticle(0);
|
||||
if (!killed.ok) throw killed.error.message;
|
||||
local repeated = emitter.advanceBurst(created.value, emission.value, 2, 0.0);
|
||||
if (!repeated.ok || repeated.value != 2) throw "Burst did not resume after actor particles expired";
|
||||
if (emitter.advanceBurst(created.value, emission.value, 0, 0.0).ok)
|
||||
throw "Invalid burst count accepted";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Opt-in Fluid3D-compatible analytic capsule collision check.
|
||||
function verifyVolumeFluidCapsuleCollider() {
|
||||
local definition=checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity=[0.0,0.0,0.0];
|
||||
definition.settings.minimum=[-1.0,-1.0,-1.0];
|
||||
definition.settings.maximum=[1.0,1.0,1.0];
|
||||
local solver=checked(fluids.newVolumeSimulator(definition));
|
||||
local collider=checked(fluids.volumeColliderDefaults());
|
||||
collider.label=81;collider.shape=2;collider.center=[0.0,0.0,0.0];
|
||||
collider.radius=0.1;collider.halfExtent=[0.1,0.3,0.1];
|
||||
collider.rotation=[0.0,0.0,0.70710678,0.70710678];
|
||||
collider.angularVelocity=[0.0,0.0,4.0];collider.friction=1.0;
|
||||
checked(solver.setColliders([collider]));
|
||||
local particle=checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position=[0.15,0.11,0.0];particle.material.cohesion=0.0;particle.material.viscosity=0.0;
|
||||
checked(solver.emit([particle]));checked(solver.step(1.0/120.0,1));
|
||||
local contacts=checked(solver.contacts());local state=checked(solver.snapshot());
|
||||
if(contacts.len()!=1||contacts[0].colliderLabel!=81||contacts[0].normal[1]<0.99||
|
||||
state.particles[0].position[1]<0.1499||state.colliders[0].shape!=2)
|
||||
throw "Rotated capsule projection or codec mismatch";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
definition.particles = [];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
local particle = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.2, 0.5, 0.0];
|
||||
checked(localSolver.emit([particle]));
|
||||
local trigger = checked(fluids.volumeColliderDefaults());
|
||||
trigger.label = 81;
|
||||
trigger.center = [0.0, 0.5, 0.0];
|
||||
trigger.radius = 0.5;
|
||||
trigger.isTrigger = true;
|
||||
checked(localSolver.setColliders([trigger]));
|
||||
checked(localSolver.step(1.0 / 120.0, 1));
|
||||
local contacts = checked(localSolver.contacts());
|
||||
local state = checked(localSolver.snapshot());
|
||||
if (contacts.len() != 1) throw "trigger contact count: " + contacts.len();
|
||||
if (contacts[0].colliderLabel != 81) throw "trigger contact label mismatch";
|
||||
if (abs(state.particles[0].position[0] - 0.2) > 0.000001)
|
||||
throw "trigger enforced collision projection";
|
||||
::colliderTriggerPass <- true;
|
||||
@@ -0,0 +1,29 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
definition.settings.iterations = 1;
|
||||
definition.particles = [];
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
|
||||
local particle = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.56, 1.0, 0.0];
|
||||
particle.material.stickiness = 0.0;
|
||||
particle.material.stickDistance = 0.1;
|
||||
particle.material.stickinessCombine = 1;
|
||||
checked(solver.emit([particle]));
|
||||
|
||||
local sphere = checked(fluids.volumeColliderDefaults());
|
||||
sphere.label = 91;
|
||||
sphere.center = [0.0, 1.0, 0.0];
|
||||
sphere.radius = 0.5;
|
||||
sphere.stickiness = 10.0;
|
||||
sphere.stickDistance = 0.1;
|
||||
sphere.stickinessCombine = 3;
|
||||
checked(solver.setColliders([sphere]));
|
||||
checked(solver.step(1.0 / 120.0, 1));
|
||||
|
||||
local state = checked(solver.snapshot());
|
||||
local contacts = checked(solver.contacts());
|
||||
if (state.version != 20) throw "collision material snapshot version mismatch";
|
||||
if (state.particles[0].position[0] >= 0.56) throw "maximum-priority adhesion did not pull particle";
|
||||
if (contacts.len() != 1 || contacts[0].colliderLabel != 91) throw "adhesive contact mismatch";
|
||||
::collisionMaterialPass <- true;
|
||||
@@ -0,0 +1,24 @@
|
||||
// Opt-in checks for the reference ColorFromVelocity and ColorFromPhase utilities.
|
||||
function verifyVolumeFluidColorizers() {
|
||||
local solver=checked(fluids.newVolumeSimulator(checked(fluids.volumeDefaults())));
|
||||
local prototype=checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local a=clone prototype;local b=clone prototype;
|
||||
a.position=[0.0,1.0,0.0];a.velocity=[-0.2,0.0,0.2];a.actorGroup=0;
|
||||
b.position=[0.2,1.0,0.0];b.velocity=[0.2,-0.2,0.0];b.actorGroup=1;
|
||||
checked(solver.emit([a,b]));checked(solver.applyVelocityColors(0.2));
|
||||
local state=checked(solver.snapshot());
|
||||
if(state.particles[0].color[0]>0.001||state.particles[0].color[2]<0.999)
|
||||
throw "ColorFromVelocity mapping mismatch";
|
||||
checked(solver.applyActorGroupColors());state=checked(solver.snapshot());
|
||||
if(state.particles[0].color[0]<0.94||state.particles[1].color[2]<0.86)
|
||||
throw "ColorFromPhase alphabet mismatch";
|
||||
local gradient=[{coordinate=0.0,color=[1.0,0.0,0.0,1.0]},
|
||||
{coordinate=1.0,color=[0.0,0.0,1.0,1.0]}];
|
||||
checked(solver.applyDataColors(0,gradient));state=checked(solver.snapshot());
|
||||
if(state.particles[0].color[0]<0.999)throw "Fluid property colorizer mismatch";
|
||||
checked(solver.applyRandomColors(gradient,91));local first=checked(solver.snapshot());
|
||||
checked(solver.applyRandomColors(gradient,91));local second=checked(solver.snapshot());
|
||||
if(fabs(first.particles[0].color[0]-second.particles[0].color[0])>0.000001)
|
||||
throw "Seeded ColorRandomizer mismatch";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
config = {
|
||||
width = 960
|
||||
height = 720
|
||||
title = "EVEngine | Fluid3D Lab"
|
||||
hotReload = true
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, -10.0, 0.0];
|
||||
definition.settings.iterations = 2;
|
||||
definition.particles = [];
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
|
||||
local particle = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.2, 0.5, 0.0];
|
||||
checked(solver.emit([particle]));
|
||||
|
||||
local sphere = checked(fluids.volumeColliderDefaults());
|
||||
sphere.label = 92;
|
||||
sphere.center = [0.0, 0.5, 0.0];
|
||||
sphere.radius = 0.5;
|
||||
checked(solver.setColliders([sphere]));
|
||||
checked(solver.step(1.0 / 120.0, 1));
|
||||
|
||||
local captured = checked(solver.grabContactParticles(92, 0.0, 0.5, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0, 0.01));
|
||||
if (captured != 1) throw "contact grabber did not capture particle";
|
||||
local before = checked(solver.snapshot()).particles[0].position;
|
||||
local moved = checked(solver.updateGrabbedParticles(92, 0.3, 0.7, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0));
|
||||
if (moved != 1) throw "contact grabber did not update particle";
|
||||
checked(solver.step(1.0 / 120.0, 1));
|
||||
local held = checked(solver.snapshot()).particles[0];
|
||||
if (abs(held.position[0] - before[0] - 0.3) > 0.000001 ||
|
||||
abs(held.position[1] - before[1] - 0.2) > 0.000001)
|
||||
throw "contact grabber local pose was not preserved";
|
||||
if (checked(solver.releaseGrabbedParticles(92)) != 1) throw "contact grabber release mismatch";
|
||||
::contactGrabberPass <- true;
|
||||
@@ -0,0 +1,29 @@
|
||||
// Opt-in check for Fluid3DContactEventDispatcher identity data.
|
||||
function verifyVolumeFluidContactIdentity() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local particle = clone checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.0, 1.25, 0.0];
|
||||
particle.actorGroup = 23;
|
||||
definition.particles = [particle];
|
||||
local collider = checked(fluids.volumeColliderDefaults());
|
||||
collider.label = 9;
|
||||
definition.colliders = [collider];
|
||||
local source = checked(fluids.newVolumeSimulator(definition));
|
||||
checked(source.step(1.0 / 120.0, 1));
|
||||
local contacts = checked(source.contacts());
|
||||
if (contacts.len() == 0 || contacts[0].colliderLabel != 9 ||
|
||||
contacts[0].particleIndex != 0 || contacts[0].actorGroup != 23)
|
||||
throw "Contact dispatcher identity is incomplete";
|
||||
local tracker = eve.VolumeFluidContactTracker();
|
||||
local entered = checked(tracker.advance(source, 0.01));
|
||||
if (entered.len() != 1 || entered[0].type != 0)
|
||||
throw "Contact enter event is incorrect";
|
||||
local stayed = checked(tracker.advance(source, 0.01));
|
||||
if (stayed.len() != 1 || stayed[0].type != 1)
|
||||
throw "Contact stay event is incorrect";
|
||||
source.clear();
|
||||
local exited = checked(tracker.advance(source, 0.01));
|
||||
if (exited.len() != 1 || exited[0].type != 2)
|
||||
throw "Contact exit event is incorrect";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
function verifyCustomMaterialFastPath() {
|
||||
paused = true;
|
||||
rebuild(0);
|
||||
dirty = false;
|
||||
checked(surface.configureMaterial(true, 0.35, 0.4, 1.25, 0.3, 0.7));
|
||||
checked(surface.configureColors(0.1, 0.2, 0.3, 0.2, 0.7, 0.9));
|
||||
surface.renderVolume(solver);
|
||||
checked(surface.copyToTexture(gfx, texture));
|
||||
checked(surface.renderVolumeColorToTexture(solver, gfx, texture));
|
||||
|
||||
local fullStart = clock();
|
||||
for (local i = 0; i < 20; ++i) {
|
||||
surface.renderVolume(solver);
|
||||
checked(surface.copyToTexture(gfx, texture));
|
||||
}
|
||||
local fullMs = (clock() - fullStart) * 1000.0;
|
||||
local fastStart = clock();
|
||||
for (local i = 0; i < 20; ++i)
|
||||
checked(surface.renderVolumeColorToTexture(solver, gfx, texture));
|
||||
local fastMs = (clock() - fastStart) * 1000.0;
|
||||
if (!surface.usingGpu()) throw "custom material did not use GPU";
|
||||
if (fastMs >= fullMs) throw "device-local custom material path was not faster";
|
||||
return "VOLUME_CUSTOM_MATERIAL_FASTPATH_PASS fullMs=" + fullMs + " fastMs=" + fastMs;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Opt-in secondary-particle contract probe; no simulation/render loop.
|
||||
function verifyVolumeFluidDiffuse() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local p = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
p.position = [0.0, 1.0, 0.0];
|
||||
p.velocity = [1.0, 0.0, 0.0];
|
||||
definition.particles.append(p);
|
||||
local sim = checked(fluids.newVolumeSimulator(definition));
|
||||
local pool = eve.VolumeFluidDiffuse();
|
||||
local state = checked(pool.snapshot());
|
||||
state.capacity = 1;
|
||||
checked(pool.restore(state));
|
||||
local batch = [{position=[0.0,1.0,0.0], velocity=[0.0,0.0,0.0], life=0.025}];
|
||||
checked(pool.emit(batch));
|
||||
if (pool.emit(batch).ok) throw "Diffuse capacity exceeded";
|
||||
checked(pool.advance(sim, 0.01, 1));
|
||||
local next = checked(pool.snapshot()).particles[0];
|
||||
if (fabs(next.position[0]-0.01)>0.00001 || fabs(next.life-0.015)>0.00001)
|
||||
throw "Diffuse advection or lifetime mismatch";
|
||||
if (checked(sim.snapshot()).particles[0].position[0]!=0.0)
|
||||
throw "Diffuse advanced source fluid";
|
||||
if (pool.advance(sim, 0.01, -1).ok) throw "Invalid threshold accepted";
|
||||
state.extra <- 1;
|
||||
if (pool.restore(state).ok) throw "Unknown diffuse state field accepted";
|
||||
checked(pool.advance(sim, 0.02, 1));
|
||||
if (pool.getParticleCount()!=0 || pool.getAvailableCapacity()!=1)
|
||||
throw "Diffuse lifetime did not recycle capacity";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, -10.0, 0.0];
|
||||
definition.settings.capacity = 2;
|
||||
local particle = clone checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [-0.2, 1.0, 0.0];
|
||||
definition.particles = [particle];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
local target = checked(fluids.volumeColliderDefaults());
|
||||
target.label = 72; target.center = [0.0, 1.0, 0.0]; target.radius = 0.1;
|
||||
checked(localSolver.setColliders([target]));
|
||||
if (checked(localSolver.bindDynamicParticles([0], 72, 0.0, 1000000000000.0, false)) != 1)
|
||||
throw "Dynamic particle attachment was not installed";
|
||||
target.center = [0.25, 1.0, 0.0];
|
||||
checked(localSolver.setColliders([target]));
|
||||
checked(localSolver.step(1.0 / 120.0, 1));
|
||||
local attached = checked(localSolver.snapshot());
|
||||
if (attached.version != 20 || attached.attachments.len() != 1 ||
|
||||
!attached.attachments[0].dynamic || attached.attachments[0].compliance != 0.0 ||
|
||||
fabs(attached.particles[0].position[0] - 0.05) > 0.00001 ||
|
||||
attached.particles[0].material.phase != 0)
|
||||
throw "Dynamic particle attachment state mismatch";
|
||||
if (checked(localSolver.unbindStaticParticles([0])) != 1)
|
||||
throw "Dynamic particle attachment was not removed";
|
||||
return "VOLUME_DYNAMIC_ATTACHMENT_PASS";
|
||||
@@ -0,0 +1,21 @@
|
||||
// Opt-in check for EmitOnKeyPress/FluidJetController zero-speed behavior.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 16;
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
local emission = checked(fluids.volumeEmissionDefaults());
|
||||
emission.description.shape = 0;
|
||||
emission.description.extent = [0.0, 0.0, 0.0];
|
||||
local stream = eve.VolumeFluidEmitter();
|
||||
if (checked(stream.advance(localSolver, emission, 0.006, 100.0, 1, 0.0)) != 0)
|
||||
throw "Fractional stream emitted too early";
|
||||
emission.description.speed = 0.0;
|
||||
for (local i = 0; i < 20; ++i)
|
||||
if (checked(stream.advance(localSolver, emission, 1.0 / 30.0, 100.0, 1, 0.0)) != 0)
|
||||
throw "Disabled emitter produced particles";
|
||||
if (localSolver.getParticleCount() != 0 || checked(stream.snapshot()).phase != "0")
|
||||
throw "Disabled emitter retained catchup debt";
|
||||
emission.description.speed = 1.0;
|
||||
if (checked(stream.advance(localSolver, emission, 0.006, 100.0, 1, 0.0)) != 0 ||
|
||||
checked(stream.advance(localSolver, emission, 0.006, 100.0, 1, 0.0)) != 1)
|
||||
throw "Re-enabled emitter did not restart from zero credit";
|
||||
return "VOLUME_EMIT_ON_KEY_PASS";
|
||||
@@ -0,0 +1,23 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
definition.settings.capacity = 8;
|
||||
local source = checked(fluids.newVolumeSimulator(definition));
|
||||
local target = checked(fluids.newVolumeSimulator(definition));
|
||||
local emission = checked(fluids.volumeEmissionDefaults());
|
||||
emission.description.shape = 0;
|
||||
emission.description.extent = [0.0, 0.0, 0.0];
|
||||
emission.description.speed = 2.0;
|
||||
emission.description.prototype.actorGroup = 14;
|
||||
local sourceEmitter = eve.VolumeFluidEmitter();
|
||||
local targetEmitter = eve.VolumeFluidEmitter();
|
||||
if (checked(sourceEmitter.advance(source, emission, 0.02, 120.0, 2, 0.0)) != 2)
|
||||
throw "Checkpoint source did not emit";
|
||||
local checkpoint = checked(sourceEmitter.checkpoint(source, emission));
|
||||
emission.description.prototype.actorGroup = 3;
|
||||
checked(targetEmitter.advance(target, emission, 0.02, 60.0, 1, 0.0));
|
||||
local restoredEmission = checked(targetEmitter.restoreCheckpoint(target, checkpoint));
|
||||
local restored = checked(target.snapshot());
|
||||
if (restored.particles.len() != 2 || restored.particles[0].actorGroup != 14 ||
|
||||
restoredEmission.description.prototype.actorGroup != 14 || !targetEmitter.isEmitting())
|
||||
throw "Combined emitter checkpoint mismatch";
|
||||
print("VOLUME_FLUID_EMITTER_CHECKPOINT_PASS\n");
|
||||
@@ -0,0 +1,88 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local blueprintMetrics = checked(fluids.volumeEmitterBlueprintMetrics(8.0, 1000.0, 2.0));
|
||||
if (abs(blueprintMetrics.particleSize - 0.05) > 0.000001 ||
|
||||
abs(blueprintMetrics.particleMass - 0.125) > 0.000001 ||
|
||||
abs(blueprintMetrics.smoothingRadius - 0.1) > 0.000001)
|
||||
throw "3D emitter blueprint metrics mismatch";
|
||||
local fluidBlueprint = checked(fluids.volumeFluidEmitterBlueprintDefaults());
|
||||
fluidBlueprint.capacity = 8;
|
||||
fluidBlueprint.resolution = 8.0;
|
||||
fluidBlueprint.restDensity = 875.0;
|
||||
fluidBlueprint.smoothing = 3.0;
|
||||
fluidBlueprint.viscosity = 0.7;
|
||||
fluidBlueprint.surfaceTension = 1.5;
|
||||
fluidBlueprint.buoyancy = -0.25;
|
||||
fluidBlueprint.atmosphericDrag = 0.4;
|
||||
fluidBlueprint.atmosphericPressure = 2.5;
|
||||
fluidBlueprint.vorticity = 0.8;
|
||||
fluidBlueprint.diffusion = 0.6;
|
||||
fluidBlueprint.diffusionData = [1.0, 2.0, 3.0, 4.0];
|
||||
local preparedBlueprint = checked(fluids.prepareVolumeFluidEmitterBlueprint3D(fluidBlueprint));
|
||||
if (preparedBlueprint.solver.settings.capacity != 8 ||
|
||||
abs(preparedBlueprint.solver.settings.spacing - 0.05) > 0.000001 ||
|
||||
preparedBlueprint.emission.description.actorCapacity != 8 ||
|
||||
abs(preparedBlueprint.emission.description.prototype.material.density - 875.0) > 0.000001 ||
|
||||
abs(preparedBlueprint.emission.description.prototype.material.cohesion - 1.5) > 0.000001 ||
|
||||
abs(preparedBlueprint.emission.description.prototype.material.drag - 0.4) > 0.000001 ||
|
||||
preparedBlueprint.emission.description.prototype.data[3] != 4.0)
|
||||
throw "3D fluid emitter blueprint application mismatch";
|
||||
local granularBlueprint = checked(fluids.volumeGranularEmitterBlueprintDefaults());
|
||||
granularBlueprint.capacity = 7;
|
||||
granularBlueprint.resolution = 8.0;
|
||||
granularBlueprint.restDensity = 1450.0;
|
||||
granularBlueprint.randomness = 35.0;
|
||||
local preparedGranular = checked(fluids.prepareVolumeGranularEmitterBlueprint3D(granularBlueprint));
|
||||
if (preparedGranular.solver.settings.capacity != 7 ||
|
||||
abs(preparedGranular.solver.settings.spacing - 0.05) > 0.000001 ||
|
||||
preparedGranular.emission.description.actorCapacity != 7 ||
|
||||
preparedGranular.emission.description.granularRadiusRandomness != 35.0 ||
|
||||
preparedGranular.emission.description.prototype.material.phase != 2 ||
|
||||
preparedGranular.emission.description.prototype.material.density != 1450.0 ||
|
||||
preparedGranular.metrics.smoothingRadius != 0.0)
|
||||
throw "3D granular emitter blueprint application mismatch";
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
definition.settings.capacity = 8;
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
local emission = checked(fluids.volumeEmissionDefaults());
|
||||
emission.description.shape = 0;
|
||||
emission.description.extent = [0.0, 0.0, 0.0];
|
||||
emission.description.prototype.actorGroup = 21;
|
||||
emission.description.actorCapacity = 2;
|
||||
local emitter = eve.VolumeFluidEmitter();
|
||||
if (checked(emitter.activeParticleCount(solver, emission)) != 0)
|
||||
throw "Fresh emitter actor count was not zero";
|
||||
checked(emitter.advance(solver, emission, 0.02, 120.0, 2, 0.0));
|
||||
if (checked(emitter.activeParticleCount(solver, emission)) != 2)
|
||||
throw "Emitter active particle count mismatch";
|
||||
if (checked(emitter.advance(solver, emission, 0.02, 120.0, 2, 0.0)) != 0)
|
||||
throw "Emitter exceeded its actor capacity";
|
||||
local other = checked(fluids.volumeEmissionDefaults());
|
||||
other.description.shape = 0;
|
||||
other.description.extent = [0.0, 0.0, 0.0];
|
||||
other.description.prototype.actorGroup = 22;
|
||||
other.description.actorCapacity = 1;
|
||||
local otherEmitter = eve.VolumeFluidEmitter();
|
||||
if (checked(otherEmitter.advance(solver, other, 0.02, 120.0, 2, 0.0)) != 1)
|
||||
throw "Independent emitter actor capacity was not available";
|
||||
if (checked(emitter.activeParticleCount(solver, other)) != 1)
|
||||
throw "Independent emitter actor count mismatch";
|
||||
checked(emitter.killParticle(solver, emission, 0));
|
||||
if (checked(emitter.activeParticleCount(solver, emission)) != 1)
|
||||
throw "Emitter-local particle deletion did not remove exactly one particle";
|
||||
if (checked(emitter.activeParticleCount(solver, other)) != 1)
|
||||
throw "Emitter-local particle deletion crossed actor ownership";
|
||||
local direct = checked(fluids.volumeEmissionDefaults());
|
||||
direct.description.shape = 0;
|
||||
direct.description.extent = [0.0, 0.0, 0.0];
|
||||
direct.description.speed = 2.0;
|
||||
direct.description.actorCapacity = 1;
|
||||
direct.description.prototype.actorGroup = 23;
|
||||
local directEmitter = eve.VolumeFluidEmitter();
|
||||
if (checked(directEmitter.emitParticle(solver, direct, 0.5, 0.02)) != 1)
|
||||
throw "Direct emitter particle was not admitted";
|
||||
if (checked(directEmitter.emitParticle(solver, direct, 0.0, 0.02)) != 0)
|
||||
throw "Direct emitter particle exceeded actor capacity";
|
||||
checked(solver.killActorParticles(21));
|
||||
if (checked(emitter.activeParticleCount(solver, emission)) != 0)
|
||||
throw "Killed emitter actor still reports particles";
|
||||
print("VOLUME_FLUID_EMITTER_COUNT_PASS\n");
|
||||
@@ -0,0 +1,27 @@
|
||||
function checked(result) {
|
||||
if (!result.ok) throw result.error.message;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
local defaults = checked(fluids.volumeDefaults());
|
||||
defaults.settings.gravity = [0.0, 0.0, 0.0];
|
||||
defaults.settings.capacity = 8;
|
||||
local solver = checked(fluids.newVolumeSimulator(defaults));
|
||||
checked(solver.configureParticleEvents(2));
|
||||
|
||||
local emission = checked(fluids.volumeEmissionDefaults());
|
||||
emission.description.prototype.actorGroup = 12;
|
||||
emission.description.prototype.life = 0.01;
|
||||
checked(solver.emitBurst(emission, 2));
|
||||
local emitted = checked(solver.drainParticleEvents());
|
||||
if (emitted.events.len() != 2 || emitted.dropped != 0 ||
|
||||
emitted.events[0].type != 0 || emitted.events[0].actorGroup != 12)
|
||||
throw "Emission lifecycle events mismatch";
|
||||
|
||||
checked(solver.step(0.01, 2));
|
||||
local killed = checked(solver.drainParticleEvents());
|
||||
if (killed.events.len() != 2 || killed.dropped != 0 || killed.events[0].type != 1 ||
|
||||
killed.events[0].particle.actorGroup != 12)
|
||||
throw "Expiry lifecycle events mismatch";
|
||||
|
||||
print("VOLUME_FLUID_EMITTER_EVENTS_PASS\n");
|
||||
@@ -0,0 +1,20 @@
|
||||
// Opt-in check for Fluid3DEmitter.Filter updating every live particle in one actor.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 4;
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
local emission = checked(fluids.volumeEmissionDefaults());
|
||||
emission.description.shape = 0;
|
||||
emission.description.extent = [0.0, 0.0, 0.0];
|
||||
emission.description.prototype.actorGroup = 7;
|
||||
emission.description.prototype.collisionFilter = 983041;
|
||||
checked(solver.emitBurst(emission, 2));
|
||||
checked(solver.setActorCollisionFilter(7, 11141152));
|
||||
local particles = checked(solver.snapshot()).particles;
|
||||
if (particles.len() != 2 || particles[0].collisionFilter != 11141152 ||
|
||||
particles[1].collisionFilter != 11141152)
|
||||
throw "Emitter filter did not update every live actor particle";
|
||||
if (solver.setActorCollisionFilter(7, 11141120).ok)
|
||||
throw "Filter with an empty category was accepted";
|
||||
if (checked(solver.snapshot()).particles[0].collisionFilter != 11141152)
|
||||
throw "Rejected filter partially mutated the actor";
|
||||
return "VOLUME_EMITTER_FILTER_PASS";
|
||||
@@ -0,0 +1,24 @@
|
||||
// Opt-in check for Fluid3DEmitter.KillAll in a shared volume solver.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 8;
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
local first = checked(fluids.volumeEmissionDefaults());
|
||||
first.description.shape = 0;
|
||||
first.description.extent = [0.0, 0.0, 0.0];
|
||||
first.description.prototype.actorGroup = 7;
|
||||
checked(solver.emitBurst(first, 2));
|
||||
local second = checked(fluids.volumeEmissionDefaults());
|
||||
second.description.shape = 0;
|
||||
second.description.origin = [0.5, 1.0, 0.0];
|
||||
second.description.extent = [0.0, 0.0, 0.0];
|
||||
second.description.prototype.actorGroup = 8;
|
||||
checked(solver.emitBurst(second, 3));
|
||||
if (checked(solver.killActorParticles(7)) != 2)
|
||||
throw "Emitter KillAll returned the wrong count";
|
||||
local particles = checked(solver.snapshot()).particles;
|
||||
if (particles.len() != 3) throw "Emitter KillAll removed another actor";
|
||||
foreach (particle in particles)
|
||||
if (particle.actorGroup != 8) throw "Killed actor particle survived";
|
||||
if (solver.killActorParticles(7).ok)
|
||||
throw "Missing actor KillAll was accepted";
|
||||
return "VOLUME_EMITTER_KILL_ALL_PASS";
|
||||
@@ -0,0 +1,33 @@
|
||||
// Opt-in check for Fluid3DEmitter.UpdateParticleMaterial on existing actor particles.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 4;
|
||||
definition.settings.spacing = 0.2;
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
local source = checked(fluids.volumeEmissionDefaults());
|
||||
source.description.shape = 0;
|
||||
source.description.extent = [0.0, 0.0, 0.0];
|
||||
source.description.prototype.actorGroup = 7;
|
||||
source.description.prototype.color = [1.0, 0.0, 0.0, 1.0];
|
||||
source.description.prototype.life = 3.0;
|
||||
checked(solver.emitBurst(source, 2));
|
||||
|
||||
local material = checked(fluids.volumeEmissionDefaults());
|
||||
material.description.prototype.material.viscosity = 9.0;
|
||||
material.description.prototype.material.cohesion = 0.7;
|
||||
material.description.prototype.data = [1.0, 2.0, 3.0, 4.0];
|
||||
material.description.prototype.radii = [0.0, 0.0, 0.0];
|
||||
material.description.prototype.collisionFilter = 11141152;
|
||||
material.description.prototype.selfCollide = false;
|
||||
checked(solver.updateActorMaterial(7, material));
|
||||
foreach (particle in checked(solver.snapshot()).particles) {
|
||||
if (fabs(particle.material.viscosity - 9.0) > 0.00001 ||
|
||||
fabs(particle.radii[0] - 0.1) > 0.00001 || particle.collisionFilter != 11141152 ||
|
||||
particle.selfCollide || particle.color[0] != 1.0 || particle.life != 3.0)
|
||||
throw "Emitter material refresh mismatch";
|
||||
}
|
||||
material.description.prototype.material.viscosity = -1.0;
|
||||
if (solver.updateActorMaterial(7, material).ok)
|
||||
throw "Invalid material refresh was accepted";
|
||||
if (fabs(checked(solver.snapshot()).particles[0].material.viscosity - 9.0) > 0.00001)
|
||||
throw "Rejected material refresh partially mutated actor";
|
||||
return "VOLUME_EMITTER_MATERIAL_PASS";
|
||||
@@ -0,0 +1,18 @@
|
||||
// Opt-in check for Fluid3DEmitter.SetSelfCollisions on existing actor particles.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 4;
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
local emission = checked(fluids.volumeEmissionDefaults());
|
||||
emission.description.shape = 0;
|
||||
emission.description.extent = [0.0, 0.0, 0.0];
|
||||
emission.description.prototype.actorGroup = 7;
|
||||
checked(solver.emitBurst(emission, 2));
|
||||
checked(solver.setActorSelfCollisions(7, false));
|
||||
foreach (particle in checked(solver.snapshot()).particles)
|
||||
if (particle.selfCollide) throw "Actor self-collision disable was not propagated";
|
||||
checked(solver.setActorSelfCollisions(7, true));
|
||||
foreach (particle in checked(solver.snapshot()).particles)
|
||||
if (!particle.selfCollide) throw "Actor self-collision enable was not propagated";
|
||||
if (solver.setActorSelfCollisions(9, false).ok)
|
||||
throw "Missing actor self-collision update was accepted";
|
||||
return "VOLUME_EMITTER_SELF_COLLISION_PASS";
|
||||
@@ -0,0 +1,18 @@
|
||||
// Opt-in check for allocation-free Fluid3DEmitter.isEmitting projection.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 4;
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
local emission = checked(fluids.volumeEmissionDefaults());
|
||||
emission.description.shape = 0;
|
||||
emission.description.extent = [0.0, 0.0, 0.0];
|
||||
local emitter = eve.VolumeFluidEmitter();
|
||||
if (emitter.isEmitting()) throw "Fresh emitter reported active";
|
||||
if (checked(emitter.advance(solver, emission, 0.02, 100.0, 1, 0.0)) != 1)
|
||||
throw "Emitter state probe did not emit";
|
||||
if (!emitter.isEmitting() || emitter.isEmitting() != checked(emitter.snapshot()).emitting)
|
||||
throw "Direct emitter state differs from snapshot";
|
||||
local saved = checked(emitter.snapshot());
|
||||
saved.emitting = false;
|
||||
checked(emitter.restore(saved));
|
||||
if (emitter.isEmitting()) throw "Restored inactive state was ignored";
|
||||
return "VOLUME_EMITTER_STATE_PASS";
|
||||
@@ -0,0 +1 @@
|
||||
{"native":{"anim":[],"card":{"cards":[]},"dialogue":[{"characters":[],"choices":[],"currentLineId":"","fullText":"","globalVars":{},"lipSyncAmplitude":0.8500000238418579,"lipSyncEnabled":true,"lipSyncParameter":"mouthOpen","phase":"idle","rngState":2654435769,"sceneVars":{},"selectedChoiceId":"","slotX":{},"speakerId":"","typeSpeed":40,"typed":0}],"rpg":[]},"roots":{"PI":3.1415927410125732,"RAND_MAX":32767,"_async":{"continuations":[],"draining":false,"eventWaits":[],"generation":3,"maxDrain":1000,"nextId":1,"nextTicks":[],"taskWaits":[],"timers":[],"waits":[]},"_boot_module_slots":["fs","hot","timer","platform_event","win","gfx"],"_charsize_":1,"_floatsize_":4,"_frame_count":15262,"_input_edge_state":{},"_intsize_":8,"_startup_first_frame":true,"_startup_first_present":true,"_startup_t0":0.6039999723434448,"_versionnumber_":310,"accumulator":0,"dirty":false,"displayAccumulator":0.03333333507180214,"droppedTime":0,"eve_module_contract":[{"classes":[],"deps":[],"dir":"common","enabled":true,"layer":-1,"name":"common","optionalDeps":[],"profiles":[],"required":true,"slots":[]},{"classes":[],"deps":["filesystem"],"dir":"cmdline","enabled":true,"layer":-1,"name":"cmdline","optionalDeps":[],"profiles":[],"required":true,"slots":[]},{"classes":[],"deps":["platform_event"],"dir":"devtools","enabled":true,"layer":-1,"name":"devtools","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["Math"],"deps":[],"dir":"math","enabled":true,"layer":0,"name":"math","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["math"]},{"classes":[],"deps":[],"dir":"grid","enabled":true,"layer":0,"name":"grid","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":["Agent"],"deps":[],"dir":"agent","enabled":true,"layer":0,"name":"agent","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["agent"]},{"classes":[],"deps":[],"dir":"property_access","enabled":true,"layer":0,"name":"property_access","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["property_access","schema","transaction"],"dir":"editing","enabled":true,"layer":1,"name":"editing","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":["DataModule"],"deps":[],"dir":"data","enabled":true,"layer":0,"name":"data","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["Filesystem","HotReload"],"deps":[],"dir":"filesystem","enabled":true,"layer":0,"name":"filesystem","optionalDeps":[],"profiles":[],"required":true,"slots":["fs","hot"]},{"classes":[],"deps":["data"],"dir":"asset","enabled":true,"layer":0,"name":"asset","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["PlatformEvent"],"deps":[],"dir":"platform_event","enabled":true,"layer":0,"name":"platform_event","optionalDeps":[],"profiles":[],"required":true,"slots":["platform_event"]},{"classes":["Timer"],"deps":[],"dir":"timer","enabled":true,"layer":0,"name":"timer","optionalDeps":[],"profiles":[],"required":true,"slots":["timer"]},{"classes":["HostSystem"],"deps":[],"dir":"system","enabled":true,"layer":0,"name":"system","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["system"]},{"classes":["Profiler"],"deps":[],"dir":"profiler","enabled":true,"layer":0,"name":"profiler","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["profiler"]},{"classes":["Thread"],"deps":[],"dir":"thread","enabled":true,"layer":0,"name":"thread","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["thread"]},{"classes":["Spatial"],"deps":[],"dir":"spatial","enabled":true,"layer":0,"name":"spatial","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["spatial"]},{"classes":["Crowd"],"deps":[],"dir":"crowd","enabled":true,"layer":0,"name":"crowd","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["crowd"]},{"classes":["PixelWorldModule"],"deps":[],"dir":"pixelworld","enabled":true,"layer":0,"name":"pixelworld","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["pixelworld"]},{"classes":["IK"],"deps":[],"dir":"ik","enabled":true,"layer":0,"name":"ik","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":["Editor"],"deps":["asset","editing","property_access","rx","tags","transaction"],"dir":"editor","enabled":true,"layer":6,"name":"editor","optionalDeps":["graphics"],"profiles":["3d","web"],"required":false,"slots":["editor"]},{"classes":["Plugins"],"deps":[],"dir":"plugins","enabled":true,"layer":0,"name":"plugins","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["Database"],"deps":[],"dir":"database","enabled":true,"layer":0,"name":"database","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":[],"deps":["pixelworld","thread"],"dir":"pixelworld/Line truncated
|
||||
@@ -0,0 +1,21 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
definition.settings.capacity = 1;
|
||||
local particle = clone checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.0, 1.0, 0.0]; particle.material.density = 1000.0;
|
||||
definition.particles = [particle];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
local ambient = checked(fluids.volumeWindZoneDefaults());
|
||||
ambient.direction = [1.0, 0.0, 0.0]; ambient.intensity = 8.0;
|
||||
checked(localSolver.accumulateExternalForceZones([ambient], 0.0));
|
||||
checked(localSolver.step(1.0 / 120.0, 1));
|
||||
local first = checked(localSolver.snapshot()).particles[0].velocity[0];
|
||||
if (first <= 0.0) throw "External force zone did not accelerate particle";
|
||||
checked(localSolver.step(1.0 / 120.0, 1));
|
||||
local second = checked(localSolver.snapshot()).particles[0].velocity[0];
|
||||
if (fabs(second - first) > 0.00001) throw "One-step external force was retained";
|
||||
checked(localSolver.setParticleExternalForces([[0.0, 8.0, 0.0]]));
|
||||
checked(localSolver.step(1.0 / 120.0, 1));
|
||||
if (checked(localSolver.snapshot()).particles[0].velocity[1] <= 0.0)
|
||||
throw "Per-particle external force was not applied";
|
||||
return "VOLUME_EXTERNAL_FORCE_PASS";
|
||||
@@ -0,0 +1,21 @@
|
||||
// Diffuse-particle field contract probe; does not advance or render the solver.
|
||||
function verifyVolumeFluidField() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for (local i = 0; i < 2; ++i) {
|
||||
local p = clone prototype;
|
||||
p.position = [i == 0 ? -0.05 : 0.05, 1.0, 0.0];
|
||||
p.velocity = [0.0, i == 0 ? -1.0 : 1.0, 0.0];
|
||||
definition.particles.append(p);
|
||||
}
|
||||
local sim = checked(fluids.newVolumeSimulator(definition));
|
||||
local samples = checked(sim.sampleField([[0.0,1.0,0.0],[100.0,100.0,100.0]]));
|
||||
if (samples.len()!=2 || samples[0].neighborCount!=2 || fabs(samples[0].vorticity[2]-8.0)>0.0001)
|
||||
throw "Field projection mismatch";
|
||||
if (samples[1].neighborCount!=0 || samples[1].density!=0.0)
|
||||
throw "Empty field support mismatch";
|
||||
if (sim.sampleField([[0.0,1.0]]).ok || sim.sampleField([[0.0,1.0,0.0,9.0]]).ok)
|
||||
throw "Malformed field positions accepted";
|
||||
if (sim.getParticleCount()!=2) throw "Field query changed solver state";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Opt-in check for the package SetCategory actor helper.
|
||||
function verifyVolumeFluidFilterCategory() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local first = clone checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
first.position = [0.0, 1.0, 0.0];
|
||||
first.actorGroup = 7;
|
||||
first.collisionFilter = 15728641; // 0x00f00001
|
||||
local second = clone first;
|
||||
second.position = [0.2, 1.0, 0.0];
|
||||
second.actorGroup = 8;
|
||||
second.collisionFilter = 267386884; // 0x0ff00004
|
||||
definition.particles = [first, second];
|
||||
local source = checked(fluids.newVolumeSimulator(definition));
|
||||
checked(source.setActorFilterCategory(7, 3));
|
||||
local state = checked(source.snapshot());
|
||||
if (state.particles[0].collisionFilter != 15728648 ||
|
||||
state.particles[1].collisionFilter != 267386884)
|
||||
throw "Actor filter category or mask preservation is incorrect";
|
||||
if (source.setActorFilterCategory(7, 16).ok || source.setActorFilterCategory(9, 1).ok)
|
||||
throw "Invalid actor filter update was accepted";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Opt-in FluidJet acceptance probe. Loading this file has no side effects.
|
||||
function verifyVolumeFluidJet() {
|
||||
paused = true;
|
||||
rebuild(6);
|
||||
|
||||
local emittedTotal = 0;
|
||||
local peakActive = 0;
|
||||
local emittedAfterFirstLifetime = 0;
|
||||
local sawLateralMotion = false;
|
||||
local sawTurnedVelocity = false;
|
||||
for (local frame = 0; frame < 240; ++frame) {
|
||||
local t = frame / 60.0;
|
||||
checked(solver.step(1.0 / 60.0, 2));
|
||||
local emitted = checked(emitter.advanceMoving(solver, nozzle,
|
||||
movingPose(t), movingPose(t + 1.0 / 60.0),
|
||||
1.0 / 60.0, 16, 0.5, 1.0));
|
||||
emittedTotal += emitted;
|
||||
if (frame > 100) emittedAfterFirstLifetime += emitted;
|
||||
|
||||
local state = checked(solver.snapshot());
|
||||
if (state.particles.len() > peakActive) peakActive = state.particles.len();
|
||||
foreach (particle in state.particles) {
|
||||
if (abs(particle.position[0]) > 0.2) sawLateralMotion = true;
|
||||
if (abs(particle.velocity[0]) > 0.15 && abs(particle.velocity[2]) > 0.15)
|
||||
sawTurnedVelocity = true;
|
||||
}
|
||||
}
|
||||
|
||||
local active = checked(solver.snapshot()).particles.len();
|
||||
if (emittedTotal <= peakActive)
|
||||
throw "Jet did not reuse expired particle capacity";
|
||||
if (emittedAfterFirstLifetime <= 0)
|
||||
throw "Jet did not resume emission after its first particle lifetime";
|
||||
if (active <= 0 || active > peakActive || peakActive >= 512)
|
||||
throw "Jet active-particle pool was not bounded";
|
||||
if (!sawLateralMotion || !sawTurnedVelocity)
|
||||
throw "Moving/rotating nozzle was not observable in emitted particles";
|
||||
|
||||
return "VOLUME_FLUID_JET_PASS emitted=" + emittedTotal +
|
||||
" peak=" + peakActive + " active=" + active +
|
||||
" recycled=" + (emittedTotal - peakActive);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Opt-in check for Fluid3DFoamGenerator's owning-emitter source restriction.
|
||||
function verifyVolumeFluidFoamActorSource() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for (local i=0; i<4; ++i) {
|
||||
local particle = clone prototype;
|
||||
local right = i >= 2;
|
||||
local upper = (i % 2) == 1;
|
||||
particle.position = [right ? 0.55 : -0.55, upper ? 1.05 : 0.95, 0.0];
|
||||
particle.velocity = [upper ? 1.0 : -1.0, 0.0, 0.0];
|
||||
particle.actorGroup = right ? 9 : 3;
|
||||
definition.particles.append(particle);
|
||||
}
|
||||
local source = checked(fluids.newVolumeSimulator(definition));
|
||||
local foam = eve.VolumeFluidFoam();
|
||||
local state = checked(foam.snapshot());
|
||||
state.settings.rate = 100.0;
|
||||
state.settings.randomness = 0.0;
|
||||
state.settings.vorticityThreshold = 0.0;
|
||||
state.settings.densityThreshold = 1000000000.0;
|
||||
state.settings.maxPerStep = 1;
|
||||
checked(foam.restore(state));
|
||||
local pool = eve.VolumeFluidDiffuse();
|
||||
if (checked(foam.advanceFromActor(source, pool, 0.02, 9)) != 1)
|
||||
throw "Foam actor source count mismatch";
|
||||
local result = checked(pool.snapshot()).particles[0];
|
||||
if (result.position[0] < 0.4)
|
||||
throw "Foam escaped its owning emitter actor";
|
||||
checked(source.step(1.0 / 120.0, 1));
|
||||
local interpolated = eve.VolumeFluidFoam();
|
||||
checked(interpolated.restore(state));
|
||||
local interpolatedPool = eve.VolumeFluidDiffuse();
|
||||
if (checked(interpolated.advanceFromActorInterpolated(source, interpolatedPool, 0.02, 9, 0.0)) != 1)
|
||||
throw "Interpolated foam source failed";
|
||||
local oldEndpoint = checked(interpolatedPool.snapshot()).particles[0].position[0];
|
||||
if (fabs(oldEndpoint - 0.55) > 0.00001)
|
||||
throw "Foam did not use the previous fixed-step position";
|
||||
if (interpolated.advanceFromActorInterpolated(source, interpolatedPool, 0.02, 9, 1.01).ok)
|
||||
throw "Invalid foam interpolation accepted";
|
||||
if (foam.advanceFromActor(source, pool, 0.02, -1).ok)
|
||||
throw "Negative actor group accepted";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Opt-in automatic foam generation/replay probe. Does not run a render loop.
|
||||
function verifyVolumeFluidFoam() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for (local i=0; i<2; ++i) {
|
||||
local p = clone prototype;
|
||||
p.position = [i==0 ? -0.05 : 0.05, 1.0, 0.0];
|
||||
p.velocity = [0.0, i==0 ? -1.0 : 1.0, 0.0];
|
||||
definition.particles.append(p);
|
||||
}
|
||||
local sim = checked(fluids.newVolumeSimulator(definition));
|
||||
local foam = eve.VolumeFluidFoam();
|
||||
local state = checked(foam.snapshot());
|
||||
state.settings.rate = 100.0;
|
||||
state.settings.vorticityThreshold = 0.0;
|
||||
state.settings.densityThreshold = 1000000.0;
|
||||
state.settings.maxPerStep = 1;
|
||||
checked(foam.restore(state));
|
||||
local pool = eve.VolumeFluidDiffuse();
|
||||
if (checked(foam.advance(sim, pool, 0.02))!=1) throw "Foam source selection failed";
|
||||
local generated = checked(pool.snapshot()).particles[0];
|
||||
local replay = eve.VolumeFluidFoam();
|
||||
local replayPool = eve.VolumeFluidDiffuse();
|
||||
checked(replay.restore(state));
|
||||
checked(replay.advance(sim, replayPool, 0.02));
|
||||
local repeated = checked(replayPool.snapshot()).particles[0];
|
||||
for(local i=0; i<3; ++i)
|
||||
if (generated.position[i]!=repeated.position[i]) throw "Foam replay mismatch";
|
||||
checked(pool.advance(sim, 0.01, 1));
|
||||
if (checked(pool.snapshot()).particles[0].life>=generated.life) throw "Foam did not advect/age";
|
||||
local agedLife = checked(pool.snapshot()).particles[0].life;
|
||||
for (local i=0; i<201; ++i) checked(pool.advance(sim, 0.01, 0));
|
||||
if (checked(pool.snapshot()).particles.len()!=0) throw "Expired foam was not returned to the pool";
|
||||
state.settings.densityThreshold = 0.0;
|
||||
checked(foam.restore(state));
|
||||
if (checked(foam.advance(sim, pool, 0.02))!=0) throw "Density filter ignored";
|
||||
state.randomState = 0;
|
||||
if (foam.restore(state).ok) throw "Invalid foam RNG accepted";
|
||||
if (foam.advance(sim, pool, -0.01).ok) throw "Invalid foam dt accepted";
|
||||
return "VOLUME_FLUID_FOAM_PASS emitted=1 agedLife=" + agedLife + " expired=1";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Opt-in check for Fluid3D generateFoam and foamDownsample renderer settings.
|
||||
function verifyFoamRenderSettings() {
|
||||
local localSurface = fluids.newSurfaceRenderer(64, 64);
|
||||
local configured = localSurface.configureFoam(false, 4);
|
||||
if (!configured.ok) throw configured.error.message;
|
||||
configured = localSurface.configureFoam(true, 2);
|
||||
if (!configured.ok) throw configured.error.message;
|
||||
if (localSurface.configureFoam(true, 0).ok)
|
||||
throw "Invalid foam downsample was accepted";
|
||||
return "VOLUME_FOAM_RENDER_SETTINGS_PASS";
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: DejaVu fonts
|
||||
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
|
||||
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
|
||||
Source: https://dejavu-fonts.github.io/
|
||||
|
||||
Files: *
|
||||
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
||||
Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
DejaVu changes are in public domain.
|
||||
License: bitstream-vera
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
.
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
.
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
.
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
.
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
.
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
.
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
||||
Files: debian/*
|
||||
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
|
||||
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
|
||||
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
|
||||
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
|
||||
License: GPL-2+
|
||||
This program is free software; you can redistribute it
|
||||
and/or modify it under the terms of the GNU General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2 of the License, or (at your option) any later
|
||||
version.
|
||||
.
|
||||
This program is distributed in the hope that it will be
|
||||
useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
PURPOSE. See the GNU General Public License for more
|
||||
details.
|
||||
.
|
||||
You should have received a copy of the GNU General Public
|
||||
License along with this package; if not, write to the Free
|
||||
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
|
||||
Boston, MA 02110-1301 USA
|
||||
.
|
||||
On Debian systems, the full text of the GNU General Public
|
||||
License version 2 can be found in the file
|
||||
/usr/share/common-licenses/GPL-2'.
|
||||
@@ -0,0 +1,65 @@
|
||||
// Opt-in FluidGranular acceptance probe. Loading this file has no side effects.
|
||||
function granularPair(friction, rolling) {
|
||||
local definition=checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity=[0.0,0.0,0.0];
|
||||
definition.settings.iterations=1;
|
||||
local prototype=checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local particles=[];
|
||||
for(local i=0;i<2;++i) {
|
||||
local particle=clone prototype;
|
||||
particle.material=clone prototype.material;
|
||||
particle.position=[i==0 ? -0.045 : 0.045,1.0,0.0];
|
||||
particle.velocity=[0.0,0.0,i==0 ? 1.0 : -1.0];
|
||||
particle.material.phase=2;
|
||||
particle.material.cohesion=0.0;
|
||||
particle.material.staticFriction=friction;
|
||||
particle.material.dynamicFriction=friction;
|
||||
particle.material.rollingContacts=rolling;
|
||||
particle.material.rollingFriction=0.0;
|
||||
particles.append(particle);
|
||||
}
|
||||
definition.particles=particles;
|
||||
local sim=checked(fluids.newVolumeSimulator(definition));
|
||||
checked(sim.step(1.0/120.0,1));
|
||||
return checked(sim.snapshot()).particles;
|
||||
}
|
||||
|
||||
function verifyVolumeFluidGranular() {
|
||||
local definition=checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity=[0.0,0.0,0.0];
|
||||
definition.settings.capacity=128;
|
||||
local sim=checked(fluids.newVolumeSimulator(definition));
|
||||
local emission=checked(fluids.volumeEmissionDefaults());
|
||||
emission.description.origin=[0.0,1.0,0.0];
|
||||
emission.description.extent=[0.0,0.0,0.0];
|
||||
emission.description.seed=91;
|
||||
emission.description.prototype.material.phase=2;
|
||||
emission.description.granularRadiusRandomness=100.0;
|
||||
checked(sim.emitBurst(emission,64));
|
||||
local particles=checked(sim.snapshot()).particles;
|
||||
local minimumRadius=1000.0,maximumRadius=0.0,varied=0;
|
||||
foreach(particle in particles) {
|
||||
local radius=particle.radii[0];
|
||||
if(radius<0.001||radius>0.051001||radius!=particle.radii[1]||radius!=particle.radii[2])
|
||||
throw "Granular radius outside deterministic isotropic bounds";
|
||||
if(radius<minimumRadius)minimumRadius=radius;
|
||||
if(radius>maximumRadius)maximumRadius=radius;
|
||||
if(radius<0.049)++varied;
|
||||
}
|
||||
if(varied<32||maximumRadius-minimumRadius<0.02)
|
||||
throw "Granular radius randomness produced insufficient variation";
|
||||
local before=sim.getParticleCount();
|
||||
emission.description.granularRadiusRandomness=100.01;
|
||||
if(sim.emitBurst(emission,1).ok||sim.getParticleCount()!=before)
|
||||
throw "Invalid granular randomness was not atomic";
|
||||
|
||||
local free=granularPair(0.0,false),rough=granularPair(1.0,true);
|
||||
local freeRelative=fabs(free[0].velocity[2]-free[1].velocity[2]);
|
||||
local roughRelative=fabs(rough[0].velocity[2]-rough[1].velocity[2]);
|
||||
if(roughRelative>=freeRelative-0.1)
|
||||
throw "Granular static/dynamic friction did not reduce sliding";
|
||||
if(fabs(rough[0].angularVelocity[1])<0.1||fabs(rough[1].angularVelocity[1])<0.1)
|
||||
throw "Granular rolling contact did not generate spin";
|
||||
return "VOLUME_FLUID_GRANULAR_PASS varied="+varied+" minRadius="+minimumRadius+
|
||||
" maxRadius="+maximumRadius+" freeSlip="+freeRelative+" roughSlip="+roughRelative;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.particles = [];
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local a = clone prototype;
|
||||
local b = clone prototype;
|
||||
local c = clone prototype;
|
||||
a.position = [-1.90, 0.10, -0.90];
|
||||
b.position = [-1.85, 0.15, -0.85];
|
||||
c.position = [-1.50, 0.10, -0.90];
|
||||
checked(solver.emit([a, b, c]));
|
||||
local cells = checked(solver.debugParticleGrid(2));
|
||||
if (cells.len() != 2) throw "particle grid occupied-cell count mismatch";
|
||||
if (cells[0].particleCount != 2 || cells[1].particleCount != 1)
|
||||
throw "particle grid population mismatch";
|
||||
if (solver.debugParticleGrid(1).ok) throw "particle grid budget accepted partial output";
|
||||
::gridDebuggerPass <- true;
|
||||
@@ -0,0 +1,16 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
definition.particles = [];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
local terrain = checked(fluids.volumeHeightFieldColliderDefaults());
|
||||
terrain.label = 71;
|
||||
terrain.resolution = [2, 2];
|
||||
terrain.size = [1.0, 1.0, 1.0];
|
||||
terrain.heights = [0.0, 0.5, 0.0, 0.5];
|
||||
checked(localSolver.setHeightFieldColliders([terrain]));
|
||||
local particle = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.5, 0.1, 0.5];
|
||||
checked(localSolver.emit([particle]));
|
||||
checked(localSolver.step(1.0 / 120.0, 1));
|
||||
if (checked(localSolver.contacts()).len() != 1) throw "height-field contact missing";
|
||||
::heightFieldPass <- true;
|
||||
@@ -0,0 +1,22 @@
|
||||
// Opt-in FluidKarmanVortex acceptance. Loading this file is inert.
|
||||
function verifyVolumeFluidKarmanVortex() {
|
||||
paused=true;rebuild(18);
|
||||
local positive=0;local negative=0;local peak=0.0;local peakContacts=0;
|
||||
for(local frame=0;frame<180;++frame) {
|
||||
checked(solver.step(1.0/120.0,1));
|
||||
if(frame%12==11) {
|
||||
local wake=fluidKarmanSampleWake(karman,solver);
|
||||
positive+=wake.positive;negative+=wake.negative;if(wake.peak>peak)peak=wake.peak;
|
||||
local contacts=checked(solver.contacts()).len();if(contacts>peakContacts)peakContacts=contacts;
|
||||
}
|
||||
}
|
||||
local upstream=checked(solver.sampleField([[-0.95,0.765,0.0]]))[0];
|
||||
if(peakContacts<=0 || positive<=0 || negative<=0 || peak<0.1 ||
|
||||
peak<=fabs(upstream.vorticity[2]))
|
||||
throw "Karman wake validation failed contacts="+peakContacts+" positive="+positive+
|
||||
" negative="+negative+" peak="+peak+" upstream="+fabs(upstream.vorticity[2]);
|
||||
karman.peakContacts=peakContacts;karman.peakWakeCurl=peak;dirty=true;
|
||||
return "VOLUME_FLUID_KARMAN_PASS particles="+solver.getParticleCount()+" contacts="+
|
||||
peakContacts+" positive="+positive+" negative="+negative+" peakCurl="+format("%.3f",peak)+
|
||||
" upstreamCurl="+format("%.3f",fabs(upstream.vorticity[2]));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Bounded Karman wake helper. Loading this file is inert.
|
||||
function fluidKarmanScene() {
|
||||
local definition=checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity=192;definition.settings.spacing=0.085;
|
||||
definition.settings.gravity=[0.0,0.0,0.0];
|
||||
definition.settings.minimum=[-1.3,0.15,-0.14];definition.settings.maximum=[1.3,1.45,0.14];
|
||||
definition.settings.iterations=2;
|
||||
local prototype=checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for(local y=0;y<10;++y) for(local x=0;x<18;++x) {
|
||||
local p=clone prototype;p.material=clone prototype.material;
|
||||
p.material.viscosity=0.08;p.material.cohesion=0.0;p.material.vorticity=0.2;
|
||||
p.position=[-1.12+x*0.09,0.36+y*0.09,((x+y)%2 ? -0.018 : 0.018)];
|
||||
p.velocity=[1.05,0.025*sin(y*2.3+x*0.7),0.0];
|
||||
p.color=[0.04,0.42+0.025*y,0.95,0.92];
|
||||
definition.particles.append(p);
|
||||
}
|
||||
local obstacle=checked(fluids.volumeColliderDefaults());
|
||||
obstacle.label=401;obstacle.shape=0;obstacle.center=[-0.22,0.765,0.0];
|
||||
obstacle.radius=0.19;obstacle.friction=0.02;
|
||||
definition.colliders=[obstacle];
|
||||
return {solver=checked(fluids.newVolumeSimulator(definition)),obstacle=obstacle,
|
||||
peakContacts=0,peakWakeCurl=0.0};
|
||||
}
|
||||
|
||||
function fluidKarmanSampleWake(scene,fluid) {
|
||||
local points=[];
|
||||
foreach(x in [0.08,0.28,0.48,0.68]) foreach(y in [0.50,0.63,0.765,0.90,1.03])
|
||||
points.append([x,y,0.0]);
|
||||
local samples=checked(fluid.sampleField(points));
|
||||
local positive=0;local negative=0;local peak=0.0;
|
||||
foreach(sample in samples) {
|
||||
local curl=sample.vorticity[2];if(curl>0.1)++positive;if(curl < -0.1)++negative;
|
||||
if(fabs(curl)>peak)peak=fabs(curl);
|
||||
}
|
||||
if(peak>scene.peakWakeCurl)scene.peakWakeCurl=peak;
|
||||
return {positive=positive,negative=negative,peak=peak};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Opt-in owning particle-pool lifecycle check.
|
||||
function verifyVolumeFluidLifecycle() {
|
||||
local definition=checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity=[0.0,0.0,0.0];
|
||||
local prototype=checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local particles=[];
|
||||
for(local i=0;i<3;++i) {
|
||||
local particle=clone prototype;
|
||||
particle.position=[-0.2+i*0.2,1.0,0.0];
|
||||
particles.append(particle);
|
||||
}
|
||||
local testSolver=checked(fluids.newVolumeSimulator(definition));
|
||||
checked(testSolver.emit(particles));
|
||||
checked(testSolver.killParticle(1));
|
||||
local state=checked(testSolver.snapshot());
|
||||
if(testSolver.getParticleCount()!=2||state.particles.len()!=2||
|
||||
fabs(state.particles[0].position[0]+0.2)>0.00001||
|
||||
fabs(state.particles[1].position[0]-0.2)>0.00001)
|
||||
throw "Particle kill did not compact the owning pool";
|
||||
local invalid=testSolver.killParticle(2);
|
||||
if(invalid.ok||testSolver.getParticleCount()!=2)
|
||||
throw "Stale particle kill was not atomic";
|
||||
testSolver.clear();
|
||||
if(testSolver.getParticleCount()!=0)throw "Kill-all clear failed";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
persist solver = null;
|
||||
persist surface = null;
|
||||
persist pixels = null;
|
||||
persist imageModule = null;
|
||||
persist texture = null;
|
||||
persist accumulator = 0.0;
|
||||
persist paused = true;
|
||||
persist dirty = true;
|
||||
persist displayAccumulator = 0.0;
|
||||
persist droppedTime = 0.0;
|
||||
persist failure = "";
|
||||
persist keys = {};
|
||||
persist mode = 0;
|
||||
persist labelFont = null;
|
||||
persist emitter = null;
|
||||
persist nozzle = null;
|
||||
persist simulationTime = 0.0;
|
||||
persist wheelWorld = null;
|
||||
persist wheelBody = null;
|
||||
persist wheelCoupling = null;
|
||||
persist wheelTexture = null;
|
||||
persist wheelContacts = 0;
|
||||
persist simulationMs = 0.0;
|
||||
persist reconstructionMs = 0.0;
|
||||
persist uploadMs = 0.0;
|
||||
persist slowWorkCount = 0;
|
||||
persist thermalRules = [];
|
||||
persist viscosityColors = [];
|
||||
persist attachmentCollider = null;
|
||||
persist foamGenerator = null;
|
||||
persist foamPool = null;
|
||||
persist foamCompositeMs = 0.0;
|
||||
persist sdfPose = null;
|
||||
persist overlapQueries = [];
|
||||
persist overlapCounts = [];
|
||||
persist maze = null;
|
||||
persist bucket = null;
|
||||
persist bottle = null;
|
||||
persist karman = null;
|
||||
persist refractionBackground = null;
|
||||
persist obstacleTexture = null;
|
||||
|
||||
dofile("maze-support.nut");
|
||||
dofile("bucket-support.nut");
|
||||
dofile("bottle-support.nut");
|
||||
dofile("karman-support.nut");
|
||||
dofile("wave-support.nut");
|
||||
|
||||
function updateOverlapTriggers(t) {
|
||||
overlapQueries[0].center = [-0.38 + 0.24 * sin(t), 0.72, 0.0];
|
||||
overlapQueries[1].center = [0.38 + 0.24 * cos(t * 0.8), 0.72, 0.0];
|
||||
overlapCounts = checked(solver.applyQueryColors(overlapQueries,
|
||||
[[1.0, 0.08, 0.04, 1.0], [1.0, 0.85, 0.04, 1.0]],
|
||||
0.04, 0.75, 0.9, 1.0, 1024));
|
||||
}
|
||||
|
||||
function advanceAttachment(t) {
|
||||
attachmentCollider.center = fluidWavePosition([0.0, 0.5, 0.0], 0.3, 1.0, t);
|
||||
local angle = 0.35 * sin(t);
|
||||
attachmentCollider.rotation = [0.0, 0.0, sin(angle * 0.5), cos(angle * 0.5)];
|
||||
checked(solver.setColliders([attachmentCollider]));
|
||||
checked(solver.step(1.0 / 60.0, 2));
|
||||
checked(solver.applySolidColor(0.95, 0.65, 0.2, 1.0));
|
||||
}
|
||||
|
||||
// Stop sustained overload before submitting more fluid work. This cannot
|
||||
// interrupt an in-flight GPU command or recover a lost device.
|
||||
function checkWorkBudget(milliseconds) {
|
||||
slowWorkCount = milliseconds > 50.0 ? slowWorkCount + 1 : 0;
|
||||
if (milliseconds > 250.0 || slowWorkCount >= 3)
|
||||
throw "Fluid work budget exceeded: " + format("%.1f ms", milliseconds);
|
||||
}
|
||||
|
||||
function movingPose(t) {
|
||||
local halfAngle = 0.25 * sin(2.0 * t);
|
||||
local c = 0.70710678118 * cos(halfAngle);
|
||||
local s = 0.70710678118 * sin(halfAngle);
|
||||
return { position = [0.35 * sin(t), 1.7, 0.0], rotation = [c, s, s, c] };
|
||||
}
|
||||
|
||||
function drawMillStructure(angle, foreground) {
|
||||
local wood = foreground ? 0.72 : 0.30;
|
||||
local green = foreground ? 0.43 : 0.20;
|
||||
local blue = foreground ? 0.16 : 0.10;
|
||||
if (!foreground) {
|
||||
// Static frame and tail race. These are presentation geometry only;
|
||||
// the fluid solver still sees the same two bounded paddle colliders.
|
||||
gfx.drawTexturedRectRotated(wheelTexture,318.0,496.0,28.0,274.0,-18.0,0.24,0.20,0.16,1.0);
|
||||
gfx.drawTexturedRectRotated(wheelTexture,642.0,496.0,28.0,274.0,18.0,0.24,0.20,0.16,1.0);
|
||||
gfx.drawTexturedRectRotated(wheelTexture,480.0,565.0,390.0,22.0,0.0,0.20,0.25,0.28,1.0);
|
||||
gfx.drawTexturedRectRotated(wheelTexture,650.0,248.0,250.0,14.0,-6.0,0.16,0.19,0.21,1.0);
|
||||
}
|
||||
// A segmented rim gives the original mill silhouette without adding rigid
|
||||
// bodies or fluid colliders. Four visible paddles match the two crossbars.
|
||||
for(local i=0;i<24;++i) {
|
||||
local a=angle+i*15.0;
|
||||
local r=a*3.14159265359/180.0;
|
||||
gfx.drawTexturedRectRotated(wheelTexture,480.0+cos(r)*82.0,360.0-sin(r)*82.0,
|
||||
24.0,10.0,-a+90.0,wood,green,blue,foreground ? 0.96 : 0.55);
|
||||
}
|
||||
if(foreground) {
|
||||
gfx.drawTexturedRectRotated(wheelTexture,480.0,360.0,161.7,18.0,angle,0.68,0.40,0.14,0.92);
|
||||
gfx.drawTexturedRectRotated(wheelTexture,480.0,360.0,161.7,18.0,angle+90.0,0.68,0.40,0.14,0.92);
|
||||
gfx.drawTexturedRect(wheelTexture,468.0,348.0,24.0,24.0,0.28,0.18,0.10,1.0);
|
||||
}
|
||||
}
|
||||
|
||||
function checked(result) {
|
||||
if (!result.ok) throw result.status.summary;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function advanceWheelJet(dt) {
|
||||
return checked(emitter.advance(solver,nozzle,dt,90.0,4,0.2));
|
||||
}
|
||||
|
||||
function mazeStateValid() {
|
||||
return maze != null && "walls" in maze && typeof maze.walls == "array" &&
|
||||
maze.walls.len() > 0 && "center" in maze.walls[0] &&
|
||||
typeof maze.walls[0].center == "array" && maze.walls[0].center.len() >= 2 &&
|
||||
"half" in maze.walls[0] && typeof maze.walls[0].half == "array" &&
|
||||
maze.walls[0].half.len() >= 2;
|
||||
}
|
||||
|
||||
function bucketStateValid() {
|
||||
return bucket != null && "walls" in bucket && typeof bucket.walls == "array" &&
|
||||
bucket.walls.len() > 0 && "center" in bucket.walls[0] &&
|
||||
typeof bucket.walls[0].center == "array" && bucket.walls[0].center.len() >= 2 &&
|
||||
"half" in bucket.walls[0] && typeof bucket.walls[0].half == "array" &&
|
||||
bucket.walls[0].half.len() >= 2;
|
||||
}
|
||||
|
||||
function bottleStateValid() {
|
||||
return bottle != null && "pose" in bottle && typeof bottle.pose == "table" &&
|
||||
"position" in bottle.pose && typeof bottle.pose.position == "array" && bottle.pose.position.len() == 3;
|
||||
}
|
||||
|
||||
function pressed(key) {
|
||||
local now = keyboard.isDown(key);
|
||||
local previous = key in keys ? keys[key] : false;
|
||||
keys[key] <- now;
|
||||
return now && !previous;
|
||||
}
|
||||
|
||||
function rebuild(selected) {
|
||||
wheelCoupling = null;
|
||||
wheelBody = null;
|
||||
if(wheelWorld != null) {wheelWorld.destroy();wheelWorld=null;}
|
||||
wheelContacts = 0;
|
||||
mode = selected;
|
||||
thermalRules = [];
|
||||
viscosityColors = [];
|
||||
attachmentCollider = null;
|
||||
foamGenerator = null;
|
||||
foamPool = null;
|
||||
foamCompositeMs = 0.0;
|
||||
sdfPose = null;
|
||||
overlapQueries = [];
|
||||
overlapCounts = [];
|
||||
maze = null;
|
||||
bucket = null;
|
||||
bottle = null;
|
||||
karman = null;
|
||||
if(surface != null) {
|
||||
checked(surface.configureSurface(1.0,0.0,0.03,2));
|
||||
checked(surface.configureSurfaceBlurRadius(0.02));
|
||||
checked(surface.configureSurfaceDownsample(1));
|
||||
checked(surface.configureThicknessDownsample(1));
|
||||
checked(surface.configureAnisotropy(mode == 3));
|
||||
if(mode == 3) checked(surface.configureSurfaceDownsample(2));
|
||||
checked(surface.configureMaterial(true,0.8,0.0,0.55,0.75,0.35));
|
||||
checked(surface.configureReflection(true));
|
||||
checked(surface.configureFoam(true,1));
|
||||
checked(surface.configureColors(0.05,0.32,0.72,0.55,0.72,1.0));
|
||||
checked(surface.configureRefraction(1.0,5.0,0.01,1));
|
||||
checked(surface.configureRefractionEnabled(true));
|
||||
if(mode == 5) surface.setCameraXYZ(0.0, 4.0, 0.1, 0.0, 1.3, 0.0, 48.0);
|
||||
else surface.setCameraXYZ(3.4, 3.0, 5.0, 0.0, 0.9, 0.0, 48.0);
|
||||
}
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 2048;
|
||||
definition.settings.spacing = 0.12;
|
||||
definition.settings.minimum = [-1.2, 0.0, -0.75];
|
||||
definition.settings.maximum = [1.2, 2.4, 0.75];
|
||||
// Interactive default: two stable 1/120 s substeps and two PBF iterations.
|
||||
// The lab runs the CPU reference solver, so extra quality passes are opt-in
|
||||
// per scenario instead of consuming the presentation budget in every mode.
|
||||
definition.settings.iterations = 2;
|
||||
local defaultParticle = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for (local z = 0; z < (mode >= 4 && mode != 14 ? 0 : 8); ++z)
|
||||
for (local y = 0; y < 10; ++y)
|
||||
for (local x = 0; x < 8; ++x) {
|
||||
local particle = clone defaultParticle;
|
||||
local material = clone defaultParticle.material;
|
||||
if (mode == 1) { material.viscosity = 70.0; material.yieldStress = 5.0; }
|
||||
if (mode == 2) { material.phase = 1; material.buoyancy = -0.4; material.drag = 1.0; }
|
||||
if (mode == 3) { material.phase = 2; material.cohesion = 0.0; }
|
||||
particle.position = [mode == 14 ? -0.42 + x * 0.12 : -1.0 + x * 0.12,
|
||||
0.12 + y * 0.12, -0.42 + z * 0.12];
|
||||
if (mode == 2) particle.color = [0.55, 0.58, 0.62, 0.35];
|
||||
particle.material = material;
|
||||
definition.particles.append(particle);
|
||||
}
|
||||
if(mode == 7) {
|
||||
definition.settings.spacing = 0.1;
|
||||
definition.settings.minimum = [-0.3,0.0,-0.3];
|
||||
definition.settings.maximum = [0.3,1.4,0.3];
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for(local z=0;z<5;++z) for(local y=0;y<10;++y) for(local x=0;x<5;++x) {
|
||||
local p=clone prototype;
|
||||
p.material=clone prototype.material;
|
||||
p.material.density=y<5 ? 700.0 : 1300.0;
|
||||
p.material.cohesion=0.0;p.material.viscosity=0.05;
|
||||
p.position=[-0.2+x*0.1+0.015*sin(y+z),0.071+y*0.1+0.015*sin(x+z),-0.2+z*0.1+0.015*cos(x+y)];
|
||||
p.color=y<5 ? [0.1,0.5,1.0,1.0] : [1.0,0.2,0.08,1.0];
|
||||
definition.particles.append(p);
|
||||
}
|
||||
surface.setCameraXYZ(1.5,1.6,2.4,0.0,0.5,0.0,48.0);
|
||||
}
|
||||
if (mode == 9) {
|
||||
definition.settings.spacing = 0.1;
|
||||
definition.settings.minimum = [-0.6, 0.0, -0.3];
|
||||
definition.settings.maximum = [0.6, 1.2, 0.3];
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for (local z = 0; z < 5; ++z) for (local y = 0; y < 5; ++y) for (local x = 0; x < 10; ++x) {
|
||||
local p = clone prototype;
|
||||
p.material = clone prototype.material;
|
||||
p.material.diffusion = 20.0;
|
||||
p.material.cohesion = x < 5 ? 0.5 : 2.0;
|
||||
p.material.viscosity = x < 5 ? 2.0 : 50.0;
|
||||
p.data = [p.material.viscosity, p.material.cohesion, 0.0, 0.0];
|
||||
p.position = [-0.45 + x * 0.1, 0.1 + y * 0.1, -0.2 + z * 0.1];
|
||||
p.velocity = [x < 5 ? 0.4 : -0.4, 0.0, 0.0];
|
||||
p.color = x < 5 ? [1.0, 0.1, 0.05, 1.0] : [0.05, 0.2, 1.0, 1.0];
|
||||
definition.particles.append(p);
|
||||
}
|
||||
surface.setCameraXYZ(0.0, 1.4, 2.1, 0.0, 0.25, 0.0, 48.0);
|
||||
}
|
||||
solver = checked(fluids.newVolumeSimulator(definition));
|
||||
emitter = eve.VolumeFluidJetEmitter();
|
||||
if (mode == 14) {
|
||||
overlapQueries = [
|
||||
{type=1,center=[-0.38,0.72,0.0],size=[0.65,0.85,1.1],rotation=[0.0,0.0,0.0,1.0],contactOffset=0.0,maxDistance=0.0,phaseMask=1,collisionFilter=4294901761},
|
||||
{type=1,center=[0.38,0.72,0.0],size=[0.65,0.85,1.1],rotation=[0.0,0.0,0.258819,0.965926],contactOffset=0.0,maxDistance=0.0,phaseMask=1,collisionFilter=4294901761}
|
||||
];
|
||||
updateOverlapTriggers(0.0);
|
||||
surface.setCameraXYZ(2.7,2.1,3.8,0.0,0.65,0.0,46.0);
|
||||
}
|
||||
if (mode == 10) {
|
||||
local thermal = checked(fluids.volumeDefaults());
|
||||
thermal.settings.capacity = 128;
|
||||
thermal.settings.spacing = 0.1;
|
||||
thermal.settings.minimum = [-1.0, 0.0, -0.4];
|
||||
thermal.settings.maximum = [1.0, 1.2, 0.4];
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for (local side = 0; side < 2; ++side)
|
||||
for (local z = 0; z < 4; ++z) for (local y = 0; y < 3; ++y) for (local x = 0; x < 4; ++x) {
|
||||
local p = clone prototype;
|
||||
p.material = clone prototype.material;
|
||||
p.material.viscosity = 5.0; p.material.cohesion = 1.0; p.material.diffusion = 8.0;
|
||||
p.position = [(side == 0 ? -0.45 : 0.45) - 0.15 + x * 0.1, 0.34 + y * 0.1, -0.15 + z * 0.1];
|
||||
p.data = [5.0, 1.0, 0.0, 0.0];
|
||||
thermal.particles.append(p);
|
||||
}
|
||||
solver = checked(fluids.newVolumeSimulator(thermal));
|
||||
local colliders = [];
|
||||
for (local side = 0; side < 2; ++side) {
|
||||
local plate = checked(fluids.volumeColliderDefaults());
|
||||
plate.label = side + 1; plate.shape = 1;
|
||||
plate.center = [side == 0 ? -0.45 : 0.45, 0.25, 0.0];
|
||||
plate.halfExtent = [0.35, 0.05, 0.3];
|
||||
colliders.append(plate);
|
||||
local rule = checked(fluids.volumeThermalRuleDefaults());
|
||||
rule.colliderLabel = plate.label; rule.rate = side == 0 ? -2.0 : 2.0;
|
||||
thermalRules.append(rule);
|
||||
}
|
||||
checked(solver.setColliders(colliders));
|
||||
viscosityColors = [
|
||||
{viscosity = 0.05, color = [1.0, 0.12, 0.02, 1.0]},
|
||||
{viscosity = 5.0, color = [1.0, 0.85, 0.15, 1.0]},
|
||||
{viscosity = 10.0, color = [0.08, 0.35, 1.0, 1.0]}
|
||||
];
|
||||
checked(solver.applyMaterialChannelsWithColors(viscosityColors));
|
||||
surface.setCameraXYZ(0.0, 1.4, 3.0, 0.0, 0.35, 0.0, 48.0);
|
||||
}
|
||||
if (mode == 11) {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 32;
|
||||
definition.settings.spacing = 0.1;
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for (local z = 0; z < 4; ++z) for (local x = 0; x < 4; ++x) {
|
||||
local p = clone prototype;
|
||||
p.position = [-0.15 + x * 0.1, 0.59, -0.15 + z * 0.1];
|
||||
definition.particles.append(p);
|
||||
}
|
||||
solver = checked(fluids.newVolumeSimulator(definition));
|
||||
attachmentCollider = checked(fluids.volumeColliderDefaults());
|
||||
attachmentCollider.label = 31; attachmentCollider.shape = 1;
|
||||
attachmentCollider.center = [0.0, 0.5, 0.0];
|
||||
attachmentCollider.halfExtent = [0.4, 0.05, 0.4];
|
||||
attachmentCollider.solidify = true;
|
||||
checked(solver.setColliders([attachmentCollider]));
|
||||
surface.setCameraXYZ(1.0, 1.4, 2.4, 0.0, 0.55, 0.0, 48.0);
|
||||
}
|
||||
if (mode == 12) {
|
||||
checked(surface.configureFoam(true,2));
|
||||
local foamDefinition = checked(fluids.volumeDefaults());
|
||||
foamDefinition.settings.capacity = 256;
|
||||
foamDefinition.settings.spacing = 0.1;
|
||||
foamDefinition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for (local z=0; z<8; ++z) for (local y=0; y<3; ++y) for (local x=0; x<8; ++x) {
|
||||
local p = clone prototype;
|
||||
p.material = clone prototype.material;
|
||||
p.material.viscosity = 0.05;
|
||||
p.position = [-0.35+x*0.1, 0.7+y*0.1, -0.35+z*0.1];
|
||||
p.velocity = [-2.0*p.position[2], 0.0, 2.0*p.position[0]];
|
||||
p.color = [0.05, 0.3, 0.7, 1.0];
|
||||
foamDefinition.particles.append(p);
|
||||
}
|
||||
solver = checked(fluids.newVolumeSimulator(foamDefinition));
|
||||
foamPool = eve.VolumeFluidDiffuse();
|
||||
local poolState = checked(foamPool.snapshot());
|
||||
poolState.capacity = 512;
|
||||
checked(foamPool.restore(poolState));
|
||||
foamGenerator = eve.VolumeFluidFoam();
|
||||
local foamState = checked(foamGenerator.snapshot());
|
||||
foamState.settings.rate = 400.0;
|
||||
foamState.settings.vorticityThreshold = 0.5;
|
||||
// Native field calibration: surface sites are about 520-700 kg/m3 in
|
||||
// this bounded vortex, while the interior reaches about 1010 kg/m3.
|
||||
foamState.settings.densityThreshold = 700.0;
|
||||
foamState.settings.randomness = 0.1;
|
||||
foamState.settings.maxPerStep = 8;
|
||||
checked(foamGenerator.restore(foamState));
|
||||
checked(foamGenerator.advance(solver, foamPool, 0.02));
|
||||
surface.setCameraXYZ(0.0, 2.0, 2.0, 0.0, 0.8, 0.0, 42.0);
|
||||
}
|
||||
if (mode == 13) {
|
||||
local sdfDefinition = checked(fluids.volumeDefaults());
|
||||
sdfDefinition.settings.capacity = 512;
|
||||
sdfDefinition.settings.spacing = 0.1;
|
||||
sdfDefinition.settings.minimum = [-1.0, 0.0, -0.65];
|
||||
sdfDefinition.settings.maximum = [1.0, 2.2, 0.65];
|
||||
sdfDefinition.settings.iterations = 3;
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for (local z = 0; z < 6; ++z)
|
||||
for (local y = 0; y < 6; ++y)
|
||||
for (local x = 0; x < 8; ++x) {
|
||||
local p = clone prototype;
|
||||
p.material = clone prototype.material;
|
||||
p.material.viscosity = 1.0;
|
||||
p.position = [-0.38 + x * 0.1, 1.25 + y * 0.1, -0.25 + z * 0.1];
|
||||
p.color = [0.04, 0.38, 0.9, 1.0];
|
||||
sdfDefinition.particles.append(p);
|
||||
}
|
||||
solver = checked(fluids.newVolumeSimulator(sdfDefinition));
|
||||
local obstacle = checked(fluids.volumeSdfColliderDefaults());
|
||||
obstacle.label = 51;
|
||||
obstacle.position = [0.0, 0.72, 0.0];
|
||||
obstacle.scale = 0.85;
|
||||
obstacle.friction = 0.05;
|
||||
checked(solver.setSdfColliders([obstacle]));
|
||||
sdfPose = checked(fluids.volumeSdfPoseDefaults());
|
||||
sdfPose.label = obstacle.label;
|
||||
sdfPose.position = obstacle.position;
|
||||
sdfPose.scale = obstacle.scale;
|
||||
surface.setCameraXYZ(2.6, 2.2, 3.8, 0.0, 0.85, 0.0, 46.0);
|
||||
}
|
||||
if (mode == 15) {
|
||||
maze = fluidMazeScene();
|
||||
solver = maze.solver;
|
||||
delete maze.solver;
|
||||
surface.setCameraXYZ(0.0,1.2,5.0,0.0,1.2,0.0,48.0);
|
||||
}
|
||||
if (mode == 16) {
|
||||
bucket=fluidBucketScene();
|
||||
solver=bucket.solver;delete bucket.solver;
|
||||
surface.setCameraXYZ(0.0,1.1,5.0,0.0,1.1,0.0,48.0);
|
||||
}
|
||||
if (mode == 17) {
|
||||
bottle=fluidBottleScene();
|
||||
solver=bottle.solver;delete bottle.solver;
|
||||
checked(surface.configureSurface(1.15,0.008,0.025,2));
|
||||
checked(surface.configureMaterial(true,0.92,0.05,0.62,0.32,0.55));
|
||||
checked(surface.configureColors(0.48,0.16,0.025,0.95,0.72,0.30));
|
||||
checked(surface.configureRefraction(0.9,3.5,0.012,2));
|
||||
surface.setCameraXYZ(0.0,1.35,4.2,0.0,0.95,0.0,43.0);
|
||||
}
|
||||
if(mode == 18) {
|
||||
karman=fluidKarmanScene();solver=karman.solver;delete karman.solver;
|
||||
surface.setCameraXYZ(0.0,1.65,3.4,0.0,0.78,0.0,42.0);
|
||||
}
|
||||
nozzle = checked(fluids.volumeEmissionDefaults());
|
||||
nozzle.description.origin = [-0.65, 1.6, 0.0];
|
||||
nozzle.description.direction = [1.0, 0.2, 0.0];
|
||||
nozzle.description.extent = [0.12, 0.12, 0.12];
|
||||
nozzle.description.speed = 1.5;
|
||||
nozzle.description.prototype.life = 1.5;
|
||||
if(mode == 16) {
|
||||
nozzle.description.origin=[0.0,2.05,0.0];nozzle.description.direction=[0.0,-1.0,0.0];
|
||||
nozzle.description.extent=[0.0,0.0,0.0];nozzle.description.speed=1.2;
|
||||
nozzle.description.prototype.life=1.8;
|
||||
}
|
||||
if(mode == 8) {
|
||||
// FluidMill is a sustained emitter scene. Cap active storage and lower
|
||||
// only the render targets; physics spacing and the shared SSF algorithm
|
||||
// stay unchanged.
|
||||
definition.settings.capacity=384;
|
||||
definition.settings.spacing=0.1;
|
||||
definition.settings.spacing=0.08;
|
||||
checked(surface.configureSurfaceDownsample(2));
|
||||
checked(surface.configureThicknessDownsample(4));
|
||||
surface.setCameraXYZ(0.0,1.0,4.0,0.0,1.0,0.0,48.0);
|
||||
wheelWorld=physics.newWorld3D(0.0,0.0,0.0,false);
|
||||
local anchor=wheelWorld.newBody("static",0.0,1.0,0.0);
|
||||
wheelBody=wheelWorld.newBody("dynamic",0.0,1.0,0.0);
|
||||
wheelBody.newBoxShape(0.8,0.1,0.2,1000.0,0.5,0.0);
|
||||
wheelBody.newBoxShape(0.1,0.8,0.2,1000.0,0.5,0.0);
|
||||
if(wheelWorld.newRevoluteJoint(anchor,wheelBody,0.0,1.0,0.0,0.0,0.0,1.0,false)==null)
|
||||
throw "Wheel hinge creation failed";
|
||||
wheelCoupling=eve.VolumeFluidCoupling();
|
||||
local paddle=checked(fluids.volumeColliderDefaults());
|
||||
paddle.shape=1;paddle.center=[0.0,0.0,0.0];paddle.halfExtent=[0.4,0.05,0.1];paddle.friction=0.5;
|
||||
paddle.label=1;checked(wheelCoupling.attach(wheelBody,paddle));
|
||||
paddle.label=2;paddle.rotation=[0.0,0.0,0.70710678118,0.70710678118];
|
||||
checked(wheelCoupling.attach(wheelBody,paddle));
|
||||
nozzle.description.origin=[0.25,2.0,0.0];nozzle.description.direction=[0.0,-1.0,0.0];
|
||||
nozzle.description.speed=2.0;nozzle.description.extent=[0.05,0.05,0.05];
|
||||
// The original FluidMill uses a sustained particle emitter. A bounded
|
||||
// rate controller forms a continuous sheet while capping each step.
|
||||
emitter=eve.VolumeFluidEmitter();
|
||||
}
|
||||
if(mode == 5) {
|
||||
local model = model3d.newModelDataFromFile("assets/emission-ring.obj");
|
||||
local shape = checked(fluids.volumeEmissionFromModel(model, 0, 1.0, 1.0, 1.0, definition.settings.spacing));
|
||||
shape.description.origin = [0.0, 1.3, 0.0];
|
||||
shape.description.direction = [0.0, 0.0, 1.0];
|
||||
shape.description.speed = 0.0;
|
||||
checked(solver.emitBurst(shape, shape.description.distribution.len()));
|
||||
}
|
||||
accumulator = 0.0;
|
||||
simulationTime = 0.0;
|
||||
dirty = true;
|
||||
droppedTime = 0.0;
|
||||
failure = "";
|
||||
simulationMs = 0.0;
|
||||
reconstructionMs = 0.0;
|
||||
uploadMs = 0.0;
|
||||
slowWorkCount = 0;
|
||||
paused = true;
|
||||
}
|
||||
|
||||
eve_init = function() {
|
||||
gfx.setBackgroundColor(0.025, 0.04, 0.065, 1.0);
|
||||
surface = fluids.newSurfaceRenderer(384, 288);
|
||||
// Build the existing fluids SSF backend before per-frame timing starts.
|
||||
checked(surface.prepare());
|
||||
surface.setCameraXYZ(3.4, 3.0, 5.0, 0.0, 0.9, 0.0, 48.0);
|
||||
imageModule = eve.Image();
|
||||
pixels = imageModule.newEmptyImageData(384, 288, "RGBA8");
|
||||
refractionBackground=imageModule.newEmptyImageData(384,288,"RGBA8");
|
||||
// Sparse high-contrast grid: fewer than 10k script/native calls at startup,
|
||||
// while still making normal-driven refraction displacement obvious.
|
||||
for(local x=0;x<384;x+=24) for(local y=0;y<288;++y)
|
||||
refractionBackground.setPixel(x,y,0.22,0.48,0.72,1.0);
|
||||
for(local y=0;y<288;y+=24) for(local x=0;x<384;++x)
|
||||
refractionBackground.setPixel(x,y,0.42,0.24,0.16,1.0);
|
||||
texture = gfx.newTexture(pixels, false, false);
|
||||
local white=imageModule.newEmptyImageData(1,1,"RGBA8");white.setPixel(0,0,1.0,1.0,1.0,1.0);
|
||||
wheelTexture=gfx.newTexture(white,false,false);
|
||||
local obstaclePixels=imageModule.newEmptyImageData(32,32,"RGBA8");
|
||||
for(local y=0;y<32;++y)for(local x=0;x<32;++x) {
|
||||
local dx=x-15.5;local dy=y-15.5;
|
||||
if(dx*dx+dy*dy<=240.25)obstaclePixels.setPixel(x,y,0.72,0.35,0.12,1.0);
|
||||
}
|
||||
obstacleTexture=gfx.newTexture(obstaclePixels,false,false);
|
||||
local fontData = eve.Font().newFontDataFromFile("fonts/DejaVuSans-Bold.ttf", 18);
|
||||
labelFont = gfx.newFont(fontData, " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789|:.-");
|
||||
gfx.setFont(labelFont);
|
||||
rebuild(0);
|
||||
};
|
||||
|
||||
eve_update = function(dt) {
|
||||
if (mode == 15 && !mazeStateValid()) rebuild(15);
|
||||
if (mode == 16 && !bucketStateValid()) rebuild(16);
|
||||
if (mode == 17 && !bottleStateValid()) rebuild(17);
|
||||
if (pressed("space")) paused = !paused;
|
||||
if (pressed("r")) rebuild(mode);
|
||||
for (local i = 0; i < 9; ++i) if (pressed((i + 1).tostring())) rebuild(i);
|
||||
if (pressed("0")) rebuild(9);
|
||||
if (pressed("t")) rebuild(10);
|
||||
if (pressed("s")) rebuild(11);
|
||||
if (pressed("f")) rebuild(12);
|
||||
if (mode != 16 && mode != 17 && pressed("d")) rebuild(13);
|
||||
if (pressed("q")) rebuild(14);
|
||||
if (pressed("m")) rebuild(15);
|
||||
if (pressed("b")) rebuild(16);
|
||||
if (pressed("w")) rebuild(17);
|
||||
if (pressed("k")) rebuild(18);
|
||||
// Bound work per presentation frame. Overload slows simulation time instead
|
||||
// of accumulating an unbounded catch-up queue; show discarded wall time.
|
||||
local presentationInterval = mode == 2 ? 1.0 / 15.0 : 1.0 / 30.0;
|
||||
displayAccumulator += dt;
|
||||
if (displayAccumulator > presentationInterval) displayAccumulator = presentationInterval;
|
||||
try {
|
||||
local frameWorkMs = 0.0;
|
||||
local simulatedThisFrame = false;
|
||||
// Alternate expensive CPU simulation and synchronous SSF readback.
|
||||
// `dirty` means the latest simulated state still needs presenting;
|
||||
// never let another simulation step starve that reconstruction.
|
||||
if (!paused && failure == "" && !dirty) {
|
||||
accumulator += dt;
|
||||
if (accumulator >= 1.0 / 60.0) {
|
||||
local simulationStart = clock();
|
||||
if(mode == 8) {
|
||||
wheelContacts+=checked(wheelCoupling.step(wheelWorld,solver,1.0/60.0,2));
|
||||
wheelWorld.update(1.0/60.0);
|
||||
advanceWheelJet(1.0/60.0);
|
||||
} else if (mode == 10) {
|
||||
checked(solver.stepWithThermalContacts(1.0 / 60.0, 4, thermalRules));
|
||||
checked(solver.applyMaterialChannelsWithColors(viscosityColors));
|
||||
} else if (mode == 11) advanceAttachment(simulationTime);
|
||||
else if (mode == 13) {
|
||||
sdfPose.position = [0.2 * sin(simulationTime), 0.72, 0.0];
|
||||
sdfPose.velocity = [0.2 * cos(simulationTime), 0.0, 0.0];
|
||||
checked(solver.updateSdfColliderPoses([sdfPose]));
|
||||
checked(solver.step(1.0 / 60.0, 2));
|
||||
}
|
||||
else if (mode == 15) {
|
||||
if (keyboard.isDown("a")) maze.angularSpeed += 5.0 / 60.0;
|
||||
if (keyboard.isDown("d")) maze.angularSpeed -= 5.0 / 60.0;
|
||||
maze.angularSpeed *= pow(0.8,1.0/60.0);
|
||||
if (fabs(maze.angularSpeed) < 0.0001) maze.angularSpeed = 0.0;
|
||||
maze.angle += maze.angularSpeed / 60.0;
|
||||
if (maze.angularSpeed != 0.0) {
|
||||
fluidMazeUpdateColliderPoses(maze.walls,maze.colliders,maze.angle);
|
||||
checked(solver.setColliders(maze.colliders));
|
||||
}
|
||||
checked(solver.step(1.0/60.0,2));
|
||||
fluidMazeApplyTriggers(maze,solver);
|
||||
}
|
||||
else if(mode == 16) {
|
||||
local target=keyboard.isDown("d") ? 1.45 : 0.0;
|
||||
local limit=(target>bucket.angle ? 0.872664626 : 1.745329252)/60.0;
|
||||
local delta=target-bucket.angle;
|
||||
if(delta>limit)delta=limit;else if(delta < -limit)delta=-limit;
|
||||
if(fabs(delta)>0.000001) {
|
||||
bucket.angle+=delta;
|
||||
fluidBucketUpdateColliderPoses(bucket.walls,bucket.colliders,bucket.angle);
|
||||
checked(solver.setColliders(bucket.colliders));
|
||||
}
|
||||
checked(solver.step(1.0/60.0,2));
|
||||
bucket.emitted+=checked(emitter.advance(solver,nozzle,1.0/60.0,2,0.25));
|
||||
if(solver.getParticleCount()>bucket.peakActive)bucket.peakActive=solver.getParticleCount();
|
||||
}
|
||||
else if(mode == 17) {
|
||||
local target=keyboard.isDown("d") ? 2.05 : 0.0;
|
||||
local maxSpeed=target>bottle.angle ? 0.785398163 : 1.570796327;
|
||||
local delta=target-bottle.angle; local limit=maxSpeed/60.0;
|
||||
if(delta>limit)delta=limit;else if(delta < -limit)delta=-limit;
|
||||
if(fabs(delta)>0.000001) {
|
||||
bottle.angle+=delta;bottle.pose=fluidBottlePose(bottle.angle,delta*60.0);
|
||||
checked(solver.updateSdfColliderPoses([bottle.pose]));
|
||||
}
|
||||
checked(solver.step(1.0/60.0,2));
|
||||
}
|
||||
else if(mode == 18) {
|
||||
checked(solver.step(1.0/60.0,2));
|
||||
local contacts=checked(solver.contacts()).len();
|
||||
if(contacts>karman.peakContacts)karman.peakContacts=contacts;
|
||||
if((simulationTime*60.0).tointeger()%6==0)fluidKarmanSampleWake(karman,solver);
|
||||
}
|
||||
else checked(solver.step(1.0 / 60.0, 2));
|
||||
if (mode == 14) updateOverlapTriggers(simulationTime);
|
||||
if (mode == 9) checked(solver.applyMaterialChannels());
|
||||
if (mode == 12) {
|
||||
checked(foamPool.advance(solver, 1.0/60.0, 4));
|
||||
checked(foamGenerator.advance(solver, foamPool, 1.0/60.0));
|
||||
}
|
||||
if (mode == 4) checked(emitter.advance(solver, nozzle, 1.0 / 60.0, 16, 0.5));
|
||||
if (mode == 6) checked(emitter.advanceMoving(solver, nozzle,
|
||||
movingPose(simulationTime), movingPose(simulationTime + 1.0 / 60.0),
|
||||
1.0 / 60.0, 16, 0.5, 1.0));
|
||||
simulationMs = (clock() - simulationStart) * 1000.0;
|
||||
frameWorkMs = simulationMs;
|
||||
simulatedThisFrame = true;
|
||||
simulationTime += 1.0 / 60.0;
|
||||
accumulator -= 1.0 / 60.0;
|
||||
if (accumulator >= 1.0 / 60.0) {
|
||||
droppedTime += accumulator;
|
||||
accumulator = 0.0;
|
||||
}
|
||||
dirty = true;
|
||||
// Do not add a synchronous reconstruction to an already
|
||||
// expensive simulation frame.
|
||||
if (simulationMs > 250.0)
|
||||
throw "Fluid simulation exceeded 250 ms budget";
|
||||
}
|
||||
} else {
|
||||
accumulator = 0.0;
|
||||
}
|
||||
// A paused/static frame reuses its texture. Reconstruction/readback is
|
||||
// currently synchronous, so submit it at most 30 times per second.
|
||||
if (dirty && !simulatedThisFrame && displayAccumulator >= presentationInterval && failure == "") {
|
||||
local reconstructionStart = clock();
|
||||
local presented = false;
|
||||
if (mode == 2) checked(surface.renderGasVolume(solver, 5.0));
|
||||
else if (mode == 12 || mode == 17) surface.renderVolume(solver);
|
||||
else {
|
||||
checked(surface.renderVolumeColorToTexture(solver, gfx, texture));
|
||||
presented = true;
|
||||
}
|
||||
if (mode == 12) {
|
||||
local foamStart = clock();
|
||||
checked(surface.compositeDiffuse(foamPool, 0.035, 0.9, 0.5));
|
||||
foamCompositeMs = (clock()-foamStart)*1000.0;
|
||||
}
|
||||
if(mode == 17) checked(surface.compositeConfiguredSceneRefraction(refractionBackground));
|
||||
reconstructionMs = (clock() - reconstructionStart) * 1000.0;
|
||||
local uploadStart = clock();
|
||||
if (!presented) checked(surface.copyToTexture(gfx, texture));
|
||||
uploadMs = (clock() - uploadStart) * 1000.0;
|
||||
frameWorkMs += reconstructionMs + uploadMs;
|
||||
dirty = false;
|
||||
displayAccumulator = 0.0;
|
||||
}
|
||||
if (frameWorkMs > 0.0) checkWorkBudget(frameWorkMs);
|
||||
} catch (error) {
|
||||
failure = error.tostring();
|
||||
paused = true;
|
||||
print("VOLUME_FLUID_STOPPED: " + failure + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
eve_render = function() {
|
||||
gfx.clear();
|
||||
if(mode == 15 && mazeStateValid()) {
|
||||
foreach(wall in maze.walls) {
|
||||
local center=fluidMazePoint(wall.center,maze.angle);
|
||||
gfx.drawTexturedRectRotated(wheelTexture,480.0+center[0]*161.0,
|
||||
360.0-(center[1]-1.2)*161.0,wall.half[0]*322.0,wall.half[1]*322.0,
|
||||
-maze.angle*180.0/3.14159265359,0.22,0.28,0.34,1.0);
|
||||
}
|
||||
}
|
||||
if(mode == 16 && bucketStateValid()) {
|
||||
foreach(wall in bucket.walls) {
|
||||
local center=fluidBucketPoint(wall.center,bucket.angle);
|
||||
gfx.drawTexturedRectRotated(wheelTexture,480.0+center[0]*161.0,
|
||||
360.0-(center[1]-1.1)*161.0,wall.half[0]*322.0,wall.half[1]*322.0,
|
||||
-bucket.angle*180.0/3.14159265359,0.30,0.36,0.42,1.0);
|
||||
}
|
||||
}
|
||||
if(mode == 8 && wheelBody != null) {
|
||||
local angle=-2.0*atan2(wheelBody.getRotZ(),wheelBody.getRotW())*180.0/3.14159265359;
|
||||
drawMillStructure(angle,false);
|
||||
}
|
||||
gfx.drawTexturedRect(texture, 0.0, 0.0, 960.0, 720.0, 1.0, 1.0, 1.0, 1.0);
|
||||
if(mode == 8 && wheelBody != null) {
|
||||
local angle=-2.0*atan2(wheelBody.getRotZ(),wheelBody.getRotW())*180.0/3.14159265359;
|
||||
drawMillStructure(angle,true);
|
||||
}
|
||||
if(mode == 18)gfx.drawTexturedRect(obstacleTexture,440.0,325.0,70.0,70.0,1.0,1.0,1.0,1.0);
|
||||
if(mode == 17 && bottleStateValid()) {
|
||||
local body=fluidBottlePoint([0.0,0.68,0.0],bottle.angle);
|
||||
local neck=fluidBottlePoint([0.0,1.62,0.0],bottle.angle);
|
||||
gfx.drawTexturedRectRotated(wheelTexture,480.0+body[0]*183.0,360.0-(body[1]-0.95)*183.0,
|
||||
201.0,250.0,-bottle.angle*180.0/3.14159265359,0.42,0.65,0.76,0.18);
|
||||
gfx.drawTexturedRectRotated(wheelTexture,480.0+neck[0]*183.0,360.0-(neck[1]-0.95)*183.0,
|
||||
67.0,118.0,-bottle.angle*180.0/3.14159265359,0.48,0.72,0.82,0.22);
|
||||
}
|
||||
gfx.print("FLUID3D LAB", 24.0, 20.0, 0.8, 0.9, 1.0, 1.0, 1.4);
|
||||
gfx.print((paused ? "Paused" : "Running") + " | particles: " + solver.getParticleCount() +
|
||||
" | dropped seconds: " + format("%.2f", droppedTime), 24.0, 58.0, 0.8, 0.9, 1.0, 1.0, 1.0);
|
||||
gfx.print(format("Last work ms | Sim %.1f | Surface %.1f | Upload %.1f",
|
||||
simulationMs, reconstructionMs, uploadMs), 24.0, 90.0, 0.8, 0.9, 1.0, 1.0, 1.0);
|
||||
if (failure != "") gfx.print("Simulation stopped after error. See log.", 24.0, 125.0, 1.0, 0.4, 0.3, 1.0, 1.0);
|
||||
gfx.print("1 Water 2 Viscous 3 Smoke 4 Granular 5 Jet 6 Mesh 7 Moving", 24.0, 650.0, 0.7, 0.8, 0.9, 1.0, 1.0);
|
||||
gfx.print("8 Multiphase 9 Wheel 0 Mixing T Thermal S Solid F Foam D SDF Q Query M Maze B Bucket W Bottle K Wake", 24.0, 680.0, 0.7, 0.8, 0.9, 1.0, 0.72);
|
||||
if (mode == 10) gfx.print("Left: heating | Right: cooling | Color follows viscosity", 24.0, 150.0, 0.9, 0.8, 0.5, 1.0, 1.0);
|
||||
if (mode == 11) gfx.print("S Solid attachment: follows moving collider", 24.0, 150.0, 0.9, 0.8, 0.5, 1.0, 1.0);
|
||||
if (mode == 12) gfx.print("Foam: "+foamPool.getParticleCount()+" | Composite ms: "+format("%.2f",foamCompositeMs),
|
||||
24.0, 150.0, 0.9, 0.9, 1.0, 1.0, 1.0);
|
||||
if (mode == 13) gfx.print("D Moving SDF obstacle: transform-only updates, existing SSF surface", 24.0, 150.0, 0.9, 0.8, 0.5, 1.0, 1.0);
|
||||
if (mode == 14) gfx.print("Q Overlap triggers | red: "+overlapCounts[0]+" | yellow: "+overlapCounts[1],
|
||||
24.0,150.0,0.9,0.8,0.5,1.0,1.0);
|
||||
if (mode == 15) gfx.print("M FluidMaze | A/D rotate | completed: "+maze.finished.len()+
|
||||
" | contaminated: "+maze.colored.len(),24.0,150.0,0.9,0.8,0.5,1.0,1.0);
|
||||
if(mode == 16)gfx.print("B FaucetAndBucket | hold D to pour | emitted: "+bucket.emitted+
|
||||
" | active: "+solver.getParticleCount()+" | peak: "+bucket.peakActive,
|
||||
24.0,150.0,0.9,0.8,0.5,1.0,1.0);
|
||||
if(mode == 17)gfx.print("W WhiskeyBottle | hold D to pour | pose-only SDF | scene refraction",
|
||||
24.0,150.0,0.9,0.8,0.5,1.0,1.0);
|
||||
if(mode == 18)gfx.print("K FluidKarmanVortex | sphere wake | contacts: "+karman.peakContacts+
|
||||
" | peak curl: "+format("%.2f",karman.peakWakeCurl),24.0,150.0,0.9,0.8,0.5,1.0,1.0);
|
||||
if (mode == 2) gfx.print("Smoke: gas-only blurred thickness, CPU bounded path", 24.0, 150.0, 0.9, 0.8, 0.5, 1.0, 1.0);
|
||||
if(mode == 8) gfx.print("Wheel rad per sec: "+format("%.3f",wheelBody.getAngularVelocityZ())+
|
||||
" | contacts: "+wheelContacts,24.0,125.0,0.9,0.7,0.4,1.0,1.0);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
// Opt-in FluidMaze acceptance. Loading this file does not run it.
|
||||
function verifyVolumeFluidMaze() {
|
||||
paused = true;
|
||||
|
||||
local walls = fluidMazeWalls();
|
||||
local colliders = fluidMazeColliders(walls,0.0);
|
||||
fluidMazeUpdateColliderPoses(walls,colliders,0.45);
|
||||
local expected = fluidMazePoint(walls[5].center,0.45);
|
||||
local actual = colliders[5].center;
|
||||
if (fabs(actual[0]-expected[0])>0.00001 || fabs(actual[1]-expected[1])>0.00001 ||
|
||||
fabs(colliders[5].rotation[2]-sin(0.225))>0.00001)
|
||||
throw "Maze collider group transform mismatch";
|
||||
|
||||
local collisionDefinition = checked(fluids.volumeDefaults());
|
||||
collisionDefinition.settings.gravity = [0.0,0.0,0.0];
|
||||
collisionDefinition.settings.minimum = [-2.0,-1.0,-0.5];
|
||||
collisionDefinition.settings.maximum = [2.0,3.0,0.5];
|
||||
collisionDefinition.settings.iterations = 1;
|
||||
local collisionParticle = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
collisionParticle.position = expected;
|
||||
collisionParticle.material = clone collisionParticle.material;
|
||||
collisionParticle.material.cohesion = 0.0;
|
||||
collisionParticle.material.viscosity = 0.0;
|
||||
collisionDefinition.particles = [collisionParticle];
|
||||
collisionDefinition.colliders = [colliders[5]];
|
||||
local collisionSolver = checked(fluids.newVolumeSimulator(collisionDefinition));
|
||||
checked(collisionSolver.step(1.0/120.0,1));
|
||||
local contacts = checked(collisionSolver.contacts());
|
||||
if (contacts.len()!=1 || contacts[0].colliderLabel!=walls[5].label)
|
||||
throw "Rotated maze wall did not produce labelled contact";
|
||||
|
||||
local triggerDefinition = checked(fluids.volumeDefaults());
|
||||
triggerDefinition.settings.gravity = [0.0,0.0,0.0];
|
||||
triggerDefinition.settings.minimum = [-2.0,-1.0,-0.5];
|
||||
triggerDefinition.settings.maximum = [2.0,3.0,0.5];
|
||||
local triggerPrototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
triggerPrototype.material = clone triggerPrototype.material;
|
||||
triggerPrototype.material.cohesion = 0.0;
|
||||
triggerPrototype.material.viscosity = 0.0;
|
||||
local triggerParticles = [];
|
||||
foreach (position in [[0.82,2.02,0.0],[-0.82,1.24,0.0],[0.0,0.65,0.0]]) {
|
||||
local particle = clone triggerPrototype;
|
||||
particle.material = clone triggerPrototype.material;
|
||||
particle.position = position;
|
||||
triggerParticles.append(particle);
|
||||
}
|
||||
triggerDefinition.particles = triggerParticles;
|
||||
local triggerScene = {solver=checked(fluids.newVolumeSimulator(triggerDefinition)),angle=0.0,
|
||||
finished={},colored={}};
|
||||
local first = fluidMazeApplyTriggers(triggerScene);
|
||||
if (first.color0!=1 || first.color1!=1 || first.finish!=1 ||
|
||||
first.coloredTotal!=2 || first.finishedTotal!=1)
|
||||
throw "Maze color/finish trigger counts mismatch";
|
||||
local colored = checked(triggerScene.solver.snapshot()).particles;
|
||||
triggerScene.angle = 1.4;
|
||||
local second = fluidMazeApplyTriggers(triggerScene);
|
||||
local preserved = checked(triggerScene.solver.snapshot()).particles;
|
||||
for (local channel=0;channel<4;++channel)
|
||||
if (preserved[0].color[channel]!=colored[0].color[channel] ||
|
||||
preserved[1].color[channel]!=colored[1].color[channel])
|
||||
throw "Maze contamination did not persist outside trigger";
|
||||
if (preserved[0].color[0]<0.9 || preserved[1].color[2]<0.9)
|
||||
throw "Maze contamination color mismatch";
|
||||
if (second.finishedTotal < 1 || second.coloredTotal < 2)
|
||||
throw "Maze cumulative score lost prior particle identities";
|
||||
|
||||
rebuild(15);
|
||||
maze.angularSpeed = 0.7;
|
||||
local peakContacts = 0;
|
||||
for (local step = 0; step < 180; ++step) {
|
||||
maze.angle += maze.angularSpeed/120.0;
|
||||
if (step==90) maze.angularSpeed=-0.7;
|
||||
fluidMazeUpdateColliderPoses(maze.walls,maze.colliders,maze.angle);
|
||||
checked(solver.setColliders(maze.colliders));
|
||||
checked(solver.step(1.0/120.0,1));
|
||||
local contactCount = checked(solver.contacts()).len();
|
||||
if (contactCount>peakContacts) peakContacts=contactCount;
|
||||
fluidMazeApplyTriggers(maze,solver);
|
||||
}
|
||||
if (peakContacts<=0 || solver.getParticleCount()!=18)
|
||||
throw "Interactive maze did not retain bounded fluid/collider contacts";
|
||||
dirty = true;
|
||||
return "VOLUME_FLUID_MAZE_PASS walls="+walls.len()+" transformedContact="+
|
||||
contacts.len()+" colored="+first.coloredTotal+" finished="+first.finishedTotal+
|
||||
" persistent="+second.coloredTotal+" sceneFinished="+maze.finished.len()+
|
||||
" scenePeakContacts="+peakContacts;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Shared FluidMaze construction and trigger helpers. Loading this file is inert.
|
||||
function fluidMazePoint(localPoint, angle) {
|
||||
local c = cos(angle);
|
||||
local s = sin(angle);
|
||||
return [c * localPoint[0] - s * localPoint[1],
|
||||
1.2 + s * localPoint[0] + c * localPoint[1], localPoint[2]];
|
||||
}
|
||||
|
||||
function fluidMazeWalls() {
|
||||
return [
|
||||
{center=[-1.05,0.0,0.0],half=[0.06,1.1,0.34],label=101},
|
||||
{center=[1.05,0.0,0.0],half=[0.06,1.1,0.34],label=102},
|
||||
{center=[0.0,1.08,0.0],half=[1.1,0.06,0.34],label=103},
|
||||
{center=[-0.70,-1.08,0.0],half=[0.40,0.06,0.34],label=104},
|
||||
{center=[0.70,-1.08,0.0],half=[0.40,0.06,0.34],label=105},
|
||||
{center=[-0.30,0.64,0.0],half=[0.72,0.055,0.34],label=106},
|
||||
{center=[0.30,0.25,0.0],half=[0.72,0.055,0.34],label=107},
|
||||
{center=[-0.30,-0.14,0.0],half=[0.72,0.055,0.34],label=108},
|
||||
{center=[0.30,-0.53,0.0],half=[0.72,0.055,0.34],label=109},
|
||||
{center=[-0.30,-0.88,0.0],half=[0.72,0.055,0.34],label=110}
|
||||
];
|
||||
}
|
||||
|
||||
function fluidMazeUpdateColliderPoses(walls, colliders, angle) {
|
||||
local halfAngle = angle * 0.5;
|
||||
local rotation = [0.0, 0.0, sin(halfAngle), cos(halfAngle)];
|
||||
for (local i = 0; i < walls.len(); ++i) {
|
||||
colliders[i].center = fluidMazePoint(walls[i].center, angle);
|
||||
colliders[i].rotation = rotation;
|
||||
}
|
||||
}
|
||||
|
||||
function fluidMazeColliders(walls, angle) {
|
||||
local colliders = [];
|
||||
foreach (wall in walls) {
|
||||
local collider = checked(fluids.volumeColliderDefaults());
|
||||
collider.label = wall.label;
|
||||
collider.shape = 1;
|
||||
collider.halfExtent = wall.half;
|
||||
collider.friction = 0.08;
|
||||
colliders.append(collider);
|
||||
}
|
||||
fluidMazeUpdateColliderPoses(walls, colliders, angle);
|
||||
return colliders;
|
||||
}
|
||||
|
||||
function fluidMazeQueries(angle) {
|
||||
local halfAngle = angle * 0.5;
|
||||
local rotation = [0.0, 0.0, sin(halfAngle), cos(halfAngle)];
|
||||
local locals = [[0.82,0.82,0.0],[-0.82,0.04,0.0],[0.0,-0.55,0.0]];
|
||||
local sizes = [[0.28,0.28,0.68],[0.28,0.28,0.68],[2.0,1.00,0.68]];
|
||||
local queries = [];
|
||||
for (local i = 0; i < locals.len(); ++i)
|
||||
queries.append({type=1,center=fluidMazePoint(locals[i],angle),size=sizes[i],rotation=rotation,
|
||||
contactOffset=0.0,maxDistance=0.0,phaseMask=1,collisionFilter=4294901761});
|
||||
return queries;
|
||||
}
|
||||
|
||||
function fluidMazeScene() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 128;
|
||||
definition.settings.spacing = 0.09;
|
||||
definition.settings.minimum = [-1.55,-0.25,-0.45];
|
||||
definition.settings.maximum = [1.55,2.65,0.45];
|
||||
definition.settings.iterations = 3;
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
prototype.material = clone prototype.material;
|
||||
prototype.material.viscosity = 1.5;
|
||||
prototype.color = [0.08,0.62,1.0,1.0];
|
||||
for (local z = 0; z < 1; ++z) for (local y = 0; y < 3; ++y) for (local x = 0; x < 6; ++x) {
|
||||
local particle = clone prototype;
|
||||
particle.material = clone prototype.material;
|
||||
particle.position = [-0.225+x*0.09,2.02+y*0.09,0.0];
|
||||
definition.particles.append(particle);
|
||||
}
|
||||
local walls = fluidMazeWalls();
|
||||
local colliders = fluidMazeColliders(walls,0.0);
|
||||
definition.colliders = colliders;
|
||||
return {solver=checked(fluids.newVolumeSimulator(definition)),walls=walls,
|
||||
colliders=colliders,angle=0.0,angularSpeed=0.0,finished={},colored={}};
|
||||
}
|
||||
|
||||
function fluidMazeApplyTriggers(scene, solverOverride = null) {
|
||||
local activeSolver = solverOverride == null ? scene.solver : solverOverride;
|
||||
local queries = fluidMazeQueries(scene.angle);
|
||||
local colorCounts = checked(activeSolver.applyQueryColorsPreservingOutside(
|
||||
[queries[0],queries[1]],[[1.0,0.16,0.08,1.0],[0.9,0.15,1.0,1.0]],128));
|
||||
local colorHits = checked(activeSolver.queryBatch([queries[0],queries[1]],128));
|
||||
foreach (hit in colorHits) if (hit.distance < 0.0) scene.colored[hit.particleIndex.tostring()] <- true;
|
||||
local finishHits = checked(activeSolver.queryBatch([queries[2]],128));
|
||||
local immediateFinish = 0;
|
||||
foreach (hit in finishHits) if (hit.distance < 0.0) {
|
||||
scene.finished[hit.particleIndex.tostring()] <- true;
|
||||
++immediateFinish;
|
||||
}
|
||||
return {color0=colorCounts[0],color1=colorCounts[1],finish=immediateFinish,
|
||||
coloredTotal=scene.colored.len(),finishedTotal=scene.finished.len()};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Opt-in bounded mixing check. Leaves the resulting scene paused.
|
||||
function verifyVolumeFluidMixing() {
|
||||
paused = true;
|
||||
rebuild(9);
|
||||
local initial = checked(solver.snapshot()).particles;
|
||||
for (local i = 0; i < 120; ++i) {
|
||||
checked(solver.step(1.0 / 60.0, 4));
|
||||
checked(solver.applyMaterialChannels());
|
||||
}
|
||||
local final = checked(solver.snapshot()).particles;
|
||||
if (final.len() != 250) throw "Mixing changed particle count";
|
||||
local changed = 0;
|
||||
local materialChanged = 0;
|
||||
local cohesionChanged = 0;
|
||||
for (local i = 0; i < final.len(); ++i) {
|
||||
local p = final[i];
|
||||
if (fabs(p.color[0] - initial[i].color[0]) > 0.05) changed++;
|
||||
if (fabs(p.material.viscosity - initial[i].material.viscosity) > 1.0) materialChanged++;
|
||||
if (fabs(p.material.cohesion - initial[i].material.cohesion) > 0.03) cohesionChanged++;
|
||||
if (!(p.material.viscosity >= 2.0 && p.material.viscosity <= 50.0))
|
||||
throw "Mixed viscosity outside initial range";
|
||||
if (p.material.viscosity != p.data[0]) throw "Material mapping not applied";
|
||||
if (!(p.material.cohesion >= 0.5 && p.material.cohesion <= 2.0))
|
||||
throw "Mixed surface tension outside initial range";
|
||||
if (p.material.cohesion != p.data[1]) throw "Surface tension mapping not applied";
|
||||
for (local c = 0; c < 4; ++c)
|
||||
if (!(p.color[c] >= 0.0 && p.color[c] <= 1.0)) throw "Invalid mixed color";
|
||||
}
|
||||
if (changed < 50) throw "Insufficient color transfer";
|
||||
if (materialChanged < 50) throw "Insufficient material transfer";
|
||||
if (cohesionChanged < 50) throw "Insufficient surface-tension transfer";
|
||||
dirty = true;
|
||||
return "VOLUME_FLUID_MIXING_PASS color=" + changed +
|
||||
" viscosity=" + materialChanged + " surfaceTension=" + cohesionChanged;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Opt-in runtime check for Fluid3DEmitter AddShape/RemoveShape/UpdateEmitterDistribution parity.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 8;
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
local emitterBase = checked(fluids.volumeEmissionDefaults());
|
||||
emitterBase.description.speed = 0.0;
|
||||
emitterBase.description.actorCapacity = 8;
|
||||
emitterBase.description.prototype.color = [0.2, 0.3, 0.4, 1.0];
|
||||
local left = checked(fluids.volumeEmissionFromEdge(0.0, 0.1, 0.0));
|
||||
left.description.origin = [-0.25, 1.0, 0.0];
|
||||
left.description.distribution[0].color = [1.0, 0.0, 0.0, 1.0];
|
||||
local right = checked(fluids.volumeEmissionFromEdge(0.0, 0.1, 0.0));
|
||||
right.description.origin = [0.25, 1.0, 0.0];
|
||||
right.description.distribution[0].color = [0.0, 0.0, 1.0, 1.0];
|
||||
local combined = checked(fluids.composeVolumeEmitterShapes(emitterBase, [left, right]));
|
||||
if (combined.description.distribution.len() != 2) throw "multi-shape composition count mismatch";
|
||||
checked(localSolver.emitBurst(combined, 2));
|
||||
local particles = checked(localSolver.snapshot()).particles;
|
||||
if (particles.len() != 2 || particles[0].position[0] > -0.249 || particles[1].position[0] < 0.249 ||
|
||||
particles[0].color[0] < 0.99 || particles[1].color[2] < 0.99)
|
||||
throw "multi-shape order, transform or color mismatch";
|
||||
local removed = checked(fluids.composeVolumeEmitterShapes(emitterBase, [right]));
|
||||
if (removed.description.distribution.len() != 1 || removed.description.distribution[0].position[0] < 0.249)
|
||||
throw "multi-shape remove/update mismatch";
|
||||
print("VOLUME_MULTI_SHAPE_EMITTER_PASS\n");
|
||||
@@ -0,0 +1,23 @@
|
||||
function verifyMulticolorFastPath() {
|
||||
paused = true;
|
||||
rebuild(9);
|
||||
dirty = false;
|
||||
checked(surface.configureAnisotropy(true));
|
||||
surface.renderVolume(solver);
|
||||
checked(surface.copyToTexture(gfx, texture));
|
||||
checked(surface.renderVolumeColorToTexture(solver, gfx, texture));
|
||||
|
||||
local fullStart = clock();
|
||||
for (local i = 0; i < 20; ++i) {
|
||||
surface.renderVolume(solver);
|
||||
checked(surface.copyToTexture(gfx, texture));
|
||||
}
|
||||
local fullMs = (clock() - fullStart) * 1000.0;
|
||||
local fastStart = clock();
|
||||
for (local i = 0; i < 20; ++i)
|
||||
checked(surface.renderVolumeColorToTexture(solver, gfx, texture));
|
||||
local fastMs = (clock() - fastStart) * 1000.0;
|
||||
if (!surface.usingGpu()) throw "multicolor fluid did not use GPU";
|
||||
if (fastMs >= fullMs) throw "device-local multicolor path was not faster";
|
||||
return "VOLUME_MULTICOLOR_FASTPATH_PASS fullMs=" + fullMs + " fastMs=" + fastMs;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
function verifyMulticolorSoak() {
|
||||
paused = true;
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 4096;
|
||||
definition.settings.spacing = 0.05;
|
||||
definition.settings.minimum = [-0.5, 0.0, -0.5];
|
||||
definition.settings.maximum = [0.5, 1.0, 0.5];
|
||||
definition.settings.iterations = 2;
|
||||
solver = checked(fluids.newVolumeSimulator(definition));
|
||||
local red = checked(fluids.volumeEmissionDefaults());
|
||||
red.description.shape = 2;
|
||||
red.description.extent = [0.17, 0.37, 0.37];
|
||||
red.description.actorCapacity = 4096;
|
||||
red.description.origin = [-0.2, 0.5, 0.0];
|
||||
red.description.speed = 0.0;
|
||||
red.description.useShapeColor = false;
|
||||
red.description.prototype.radii = [0.0, 0.0, 0.0];
|
||||
red.description.prototype.color = [0.95, 0.08, 0.03, 1.0];
|
||||
local blue = checked(fluids.volumeEmissionDefaults());
|
||||
blue.description.shape = 2;
|
||||
blue.description.extent = [0.17, 0.37, 0.37];
|
||||
blue.description.actorCapacity = 4096;
|
||||
blue.description.origin = [0.2, 0.5, 0.0];
|
||||
blue.description.speed = 0.0;
|
||||
blue.description.useShapeColor = false;
|
||||
blue.description.prototype.radii = [0.0, 0.0, 0.0];
|
||||
blue.description.prototype.color = [0.03, 0.2, 0.95, 0.75];
|
||||
local redCount = 2048;
|
||||
local blueCount = 2048;
|
||||
checked(solver.emitBurst(red, redCount));
|
||||
checked(solver.emitBurst(blue, blueCount));
|
||||
checked(surface.configureAnisotropy(true));
|
||||
surface.setCameraXYZ(1.7, 1.5, 2.5, 0.0, 0.45, 0.0, 48.0);
|
||||
checked(surface.renderVolumeColorToTexture(solver, gfx, texture));
|
||||
|
||||
local maximumMs = 0.0;
|
||||
local start = clock();
|
||||
for (local frame = 0; frame < 600; ++frame) {
|
||||
local frameStart = clock();
|
||||
checked(surface.renderVolumeColorToTexture(solver, gfx, texture));
|
||||
local frameMs = (clock() - frameStart) * 1000.0;
|
||||
if (frameMs > maximumMs) maximumMs = frameMs;
|
||||
}
|
||||
local elapsedMs = (clock() - start) * 1000.0;
|
||||
if (!surface.usingGpu()) throw "multicolor soak did not use GPU";
|
||||
if (elapsedMs > 60000.0) throw "multicolor soak exceeded bounded runtime";
|
||||
return "VOLUME_MULTICOLOR_SOAK_PASS particles=4096 frames=600 totalMs=" + elapsedMs +
|
||||
" averageMs=" + (elapsedMs / 600.0) + " maxMs=" + maximumMs;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Opt-in multiphase buoyancy acceptance. Runs bounded numerical scenes without rendering in the loop.
|
||||
function phaseHeights(particles) {
|
||||
local blueY=0.0,redY=0.0,blueCount=0,redCount=0;
|
||||
foreach(particle in particles) {
|
||||
if(particle.color[2]>particle.color[0]) {blueY+=particle.position[1];++blueCount;}
|
||||
else {redY+=particle.position[1];++redCount;}
|
||||
}
|
||||
if(blueCount==0||redCount==0)throw "Multiphase color groups missing";
|
||||
return {blue=blueY/blueCount,red=redY/redCount};
|
||||
}
|
||||
|
||||
function verifyMultiphaseFluidBuoyancy() {
|
||||
paused=true;
|
||||
rebuild(7);
|
||||
local initialState=checked(solver.snapshot());
|
||||
local initial=phaseHeights(initialState.particles);
|
||||
|
||||
local empty=checked(fluids.volumeDefaults());
|
||||
empty.settings.capacity=initialState.settings.capacity;
|
||||
local control=checked(fluids.newVolumeSimulator(empty));
|
||||
local controlState=clone initialState;
|
||||
controlState.particles=[];
|
||||
foreach(source in initialState.particles) {
|
||||
local particle=clone source;
|
||||
particle.material=clone source.material;
|
||||
particle.material.density=1000.0;
|
||||
controlState.particles.append(particle);
|
||||
}
|
||||
checked(control.restore(controlState));
|
||||
|
||||
for(local frame=0;frame<360;++frame) {
|
||||
checked(solver.step(1.0/60.0,2));
|
||||
checked(control.step(1.0/60.0,2));
|
||||
}
|
||||
local buoyant=phaseHeights(checked(solver.snapshot()).particles);
|
||||
local neutral=phaseHeights(checked(control.snapshot()).particles);
|
||||
if(buoyant.red>=buoyant.blue)
|
||||
throw "Dense phase did not sink below light phase";
|
||||
if(buoyant.red>=initial.red-0.1||buoyant.blue<=initial.blue+0.1)
|
||||
throw "Both density phases did not move in their expected relative directions";
|
||||
if(neutral.red<=neutral.blue)
|
||||
throw "Equal-density control inverted without a density contrast";
|
||||
dirty=true;
|
||||
return "VOLUME_MULTIPHASE_BUOYANCY_PASS initialLight="+initial.blue+
|
||||
" initialHeavy="+initial.red+" finalLight="+buoyant.blue+
|
||||
" finalHeavy="+buoyant.red+" neutralLight="+neutral.blue+
|
||||
" neutralHeavy="+neutral.red;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Opt-in check for the Fluid3D-compatible orthographic SSF projection.
|
||||
function verifyVolumeFluidOrthographicProjection() {
|
||||
local defaults = fluids.volumeDefaults();
|
||||
if (!defaults.ok) throw defaults.error.message;
|
||||
local created = fluids.newVolumeSimulator(defaults.value);
|
||||
if (!created.ok) throw created.error.message;
|
||||
local solver = created.value;
|
||||
local emission = fluids.volumeEmissionDefaults();
|
||||
if (!emission.ok) throw emission.error.message;
|
||||
local particle = clone emission.value.description.prototype;
|
||||
particle.position = [0.0, 1.0, 0.0];
|
||||
local emitted = solver.emit([particle]);
|
||||
if (!emitted.ok) throw emitted.error.message;
|
||||
|
||||
local surface = fluids.newSurfaceRenderer(64, 64);
|
||||
local configured = surface.configureProjection(true, 1.0);
|
||||
if (!configured.ok) throw configured.error.message;
|
||||
surface.setCameraXYZ(0.0, 1.0, 3.0, 0.0, 1.0, 0.0, 45.0);
|
||||
surface.renderVolume(solver);
|
||||
if (!surface.usingGpu()) throw "Orthographic SSF did not use the GPU path";
|
||||
if (surface.configureProjection(true, 0.0).ok)
|
||||
throw "Invalid orthographic half size was accepted";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Opt-in acceptance probe. Loading this file has no side effects.
|
||||
function verifyVolumeFluidOverlapTriggers() {
|
||||
paused = true;
|
||||
rebuild(14);
|
||||
updateOverlapTriggers(0.0);
|
||||
if (overlapCounts.len() != 2 || overlapCounts[0] <= 0 || overlapCounts[1] <= 0)
|
||||
throw "Expected both overlap triggers to contain liquid";
|
||||
local state = checked(solver.snapshot());
|
||||
local red = 0, yellow = 0, cyan = 0;
|
||||
foreach (particle in state.particles) {
|
||||
if (particle.color[0] > 0.9 && particle.color[1] < 0.2) ++red;
|
||||
else if (particle.color[0] > 0.9 && particle.color[1] > 0.7) ++yellow;
|
||||
else if (particle.color[1] > 0.7 && particle.color[2] > 0.8) ++cyan;
|
||||
}
|
||||
if (red <= 0 || yellow <= 0 || cyan <= 0)
|
||||
throw "Overlap trigger colors were not observable";
|
||||
local before = state.particles[0].color;
|
||||
local bad = clone overlapQueries[0]; bad.rotation = [0.0, 0.0, 0.0, 2.0];
|
||||
if (solver.applyQueryColors([bad], [[1.0,0.0,0.0,1.0]], 0.0,1.0,1.0,1.0,1024).ok)
|
||||
throw "Invalid overlap trigger accepted";
|
||||
local after = checked(solver.snapshot()).particles[0].color;
|
||||
if (before[0] != after[0] || before[1] != after[1] || before[2] != after[2] || before[3] != after[3])
|
||||
throw "Failed overlap trigger changed particle colors";
|
||||
return "VOLUME_OVERLAP_TRIGGER_PASS red="+red+" yellow="+yellow+" cyan="+cyan;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, -10.0, 0.0];
|
||||
definition.settings.capacity = 4;
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local a = clone prototype; a.position = [-0.2, 1.0, 0.0];
|
||||
local b = clone prototype; b.position = [0.2, 1.0, 0.0];
|
||||
definition.particles = [a, b];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
local target = checked(fluids.volumeColliderDefaults());
|
||||
target.label = 71; target.center = [0.0, 1.0, 0.0]; target.radius = 0.1;
|
||||
checked(localSolver.setColliders([target]));
|
||||
if (checked(localSolver.bindStaticParticles([0], 71, false)) != 1)
|
||||
throw "Static particle attachment was not installed";
|
||||
target.center = [0.25, 1.2, 0.0];
|
||||
checked(localSolver.setColliders([target]));
|
||||
checked(localSolver.step(1.0 / 120.0, 1));
|
||||
local attached = checked(localSolver.snapshot());
|
||||
if (attached.version != 20 || attached.attachments.len() != 1 ||
|
||||
attached.attachments[0].constrainOrientation || attached.particles[0].material.phase != 0 ||
|
||||
fabs(attached.particles[0].position[0] - 0.05) > 0.00001 ||
|
||||
fabs(attached.particles[0].position[1] - 1.2) > 0.00001)
|
||||
throw "Static particle attachment state mismatch";
|
||||
if (checked(localSolver.unbindStaticParticles([0])) != 1)
|
||||
throw "Static particle attachment was not removed";
|
||||
return "VOLUME_PARTICLE_ATTACHMENT_PASS";
|
||||
@@ -0,0 +1,20 @@
|
||||
// Opt-in check for Fluid3DParticlePicker + Fluid3DParticleDragger behavior.
|
||||
function verifyVolumeFluidParticleDrag() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local particle = clone checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.0, 1.0, 0.0];
|
||||
particle.velocity = [1.0, 0.0, 0.0];
|
||||
definition.particles = [particle];
|
||||
local source = checked(fluids.newVolumeSimulator(definition));
|
||||
local hits = checked(source.raycast(-1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 4.0, 1, 15));
|
||||
if (hits.len() != 1 || hits[0].particleIndex != 0)
|
||||
throw "Particle picker did not return the nearest particle";
|
||||
checked(source.applyParticleDrag(hits[0].particleIndex, 1.0, 1.0, 0.0, 12.0, 2.0, 0.01));
|
||||
local state = checked(source.snapshot());
|
||||
if (fabs(state.particles[0].velocity[0] - 1.1) > 0.00001)
|
||||
throw "Particle drag spring response is incorrect";
|
||||
if (source.applyParticleDrag(1, 1.0, 1.0, 0.0, 12.0, 2.0, 0.01).ok)
|
||||
throw "Stale drag index was accepted";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 2;
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local a = clone prototype; a.position = [0.5, 1.0, 0.5]; a.actorGroup = 7;
|
||||
a.orientation = [0.0, 0.0, 0.70710678, 0.70710678];
|
||||
local b = clone prototype; b.position = [-0.5, 1.0, 0.0]; b.actorGroup = 8;
|
||||
definition.particles = [a, b];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
local frames = checked(localSolver.debugParticleFrames(7, 2.0, 1));
|
||||
if (frames.len() != 1 || frames[0].particleIndex != 0 ||
|
||||
fabs(frames[0].x[0] - 0.5) > 0.00001 || fabs(frames[0].x[1] - 3.0) > 0.00001 ||
|
||||
fabs(frames[0].y[0] + 1.5) > 0.00001 || fabs(frames[0].z[2] - 2.5) > 0.00001)
|
||||
throw "Particle frame debug axes mismatch";
|
||||
return "VOLUME_PARTICLE_FRAME_PASS";
|
||||
@@ -0,0 +1,18 @@
|
||||
// Opt-in check for Fluid3DParticleRenderer radiusScale/particleColor semantics.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
definition.settings.capacity = 2;
|
||||
local particle = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.0, 1.0, 0.0];
|
||||
particle.velocity = [2.0, 0.0, 0.0];
|
||||
particle.radii = [0.1, 0.2, 0.3];
|
||||
particle.color = [0.8, 0.5, 0.25, 0.75];
|
||||
particle.actorGroup = 9;
|
||||
definition.particles = [particle];
|
||||
local solver = checked(fluids.newVolumeSimulator(definition));
|
||||
checked(solver.step(1.0 / 120.0, 1));
|
||||
local rows = checked(solver.particleImpostors(9, 2.0, 0.5, 0.25, 1.0, 0.5, 0.5, 1));
|
||||
if (rows.len() != 1 || abs(rows[0].position[0] - (1.0 / 120.0)) > 0.0001 ||
|
||||
abs(rows[0].scale[0] - 0.2) > 0.0001 || abs(rows[0].scale[2] - 0.6) > 0.0001 ||
|
||||
abs(rows[0].color[0] - 0.4) > 0.0001 || abs(rows[0].color[3] - 0.375) > 0.0001)
|
||||
throw "particle impostor projection mismatch";
|
||||
@@ -0,0 +1,23 @@
|
||||
// Opt-in check for Fluid3DInstancedParticleRenderer-style actor instance data.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local first = clone prototype;
|
||||
first.position = [0.0, 1.0, 0.0]; first.velocity = [1.0, 0.0, 0.0];
|
||||
first.radii = [0.1, 0.2, 0.3]; first.actorGroup = 7; first.color = [0.2, 0.3, 0.4, 0.5];
|
||||
local second = clone prototype;
|
||||
second.position = [0.5, 1.0, 0.0]; second.actorGroup = 8;
|
||||
definition.particles = [first, second];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
checked(localSolver.step(1.0 / 120.0, 1));
|
||||
local instances = checked(localSolver.particleInstances(7, 2.0, 3.0, 4.0, 0.5, 1));
|
||||
if (instances.len() != 1 || instances[0].particleIndex != 0 ||
|
||||
fabs(instances[0].position[0] - 1.0 / 240.0) > 0.00001 ||
|
||||
fabs(instances[0].scale[0] - 0.2) > 0.00001 ||
|
||||
fabs(instances[0].scale[1] - 0.6) > 0.00001 ||
|
||||
fabs(instances[0].scale[2] - 1.2) > 0.00001 ||
|
||||
fabs(instances[0].color[3] - 0.5) > 0.00001)
|
||||
throw "Particle instance data mismatch";
|
||||
if (localSolver.particleInstances(7, 1.0, 1.0, 1.0, 1.0, 0).ok)
|
||||
throw "Particle instance budget was not enforced";
|
||||
return "VOLUME_PARTICLE_INSTANCES_PASS";
|
||||
@@ -0,0 +1,242 @@
|
||||
// Opt-in check for the paused example. Loading this file does not run it.
|
||||
function verifyVolumeFluidQueryMaterial() {
|
||||
paused = true;
|
||||
rebuild(0);
|
||||
local query = function() { return checked(solver.overlapSphere(-1.0, 0.12, -0.42, 0.01)); };
|
||||
local hits = query();
|
||||
if (hits.len() != 1) throw "Expected one particle in brush";
|
||||
local boxed = checked(solver.overlapBox(-1.0, 0.12, -0.42,
|
||||
0.01, 0.01, 0.01, 0.0, 0.0, 0.70710678, 0.70710678));
|
||||
if (boxed.len() != 1) throw "Rotated box query failed";
|
||||
if (solver.overlapBox(0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 2.0).ok)
|
||||
throw "Invalid box rotation accepted";
|
||||
local ray = checked(solver.raycast(-1.0, 0.12, -1.0, 0.0, 0.0, 1.0, 2.0, 2, 1));
|
||||
if (ray.len()!=2 || ray[0].particleIndex!=0 || ray[0].distance<=0.0 || ray[0].normal[2]>=0.0)
|
||||
throw "Bounded ray query mismatch";
|
||||
if (ray[0].particle.position[2]!=-0.42 || ray[1].distance<=ray[0].distance)
|
||||
throw "Ray hits are not owning and distance sorted";
|
||||
if (solver.raycast(0.0,0.0,0.0,0.0,0.0,0.0,1.0,1,15).ok ||
|
||||
solver.raycast(0.0,0.0,0.0,0.0,0.0,1.0,1.0,-1,15).ok ||
|
||||
solver.raycast(0.0,0.0,0.0,0.0,0.0,1.0,1.0,1,16).ok)
|
||||
throw "Invalid ray query accepted";
|
||||
local distances = checked(solver.querySphere(-1.0,0.12,-0.42,0.0,0.02,0.0,4,1));
|
||||
if (distances.len()!=1 || distances[0].particleIndex!=0 ||
|
||||
fabs(distances[0].distance+0.08)>0.00001 ||
|
||||
fabs(distances[0].queryPoint[1]-0.14)>0.00001)
|
||||
throw "Signed sphere distance query mismatch";
|
||||
distances[0].particle.position[0]=9.0;
|
||||
if (query()[0].position[0]!=-1.0) throw "Distance query leaked mutable state";
|
||||
if (solver.querySphere(0.0,0.0,0.0,-1.0,0.0,1.0,1,15).ok ||
|
||||
solver.querySphere(0.0,0.0,0.0,1.0,-1.0,1.0,1,15).ok ||
|
||||
solver.querySphere(0.0,0.0,0.0,1.0,0.0,1.0,1,16).ok)
|
||||
throw "Invalid sphere distance query accepted";
|
||||
local boxDistances = checked(solver.queryBox(-1.0,0.12,-0.42,
|
||||
0.01,0.01,0.01, 0.0,0.0,0.70710678,0.70710678, 0.02,0.0,4,1));
|
||||
if (boxDistances.len()!=1 || boxDistances[0].particleIndex!=0 ||
|
||||
fabs(boxDistances[0].distance+0.09)>0.00001 ||
|
||||
fabs(boxDistances[0].queryPoint[1]-0.09)>0.00001 || boxDistances[0].normal[1]>=0.0)
|
||||
throw "Oriented box distance query mismatch";
|
||||
boxDistances[0].particle.position[0]=9.0;
|
||||
if (query()[0].position[0]!=-1.0) throw "Box distance query leaked mutable state";
|
||||
if (solver.queryBox(0.0,0.0,0.0,-1.0,1.0,1.0,0.0,0.0,0.0,1.0,0.0,1.0,1,15).ok ||
|
||||
solver.queryBox(0.0,0.0,0.0,1.0,1.0,1.0,0.0,0.0,0.0,2.0,0.0,1.0,1,15).ok)
|
||||
throw "Invalid box distance query accepted";
|
||||
local batch = checked(solver.queryBatch([
|
||||
{type=0,center=[-1.0,0.12,-0.42],size=[0.0,0.0,0.0],rotation=[0.0,0.0,0.0,1.0],contactOffset=0.0,maxDistance=0.0,phaseMask=1,collisionFilter=4294901761},
|
||||
{type=1,center=[-1.0,0.12,-0.42],size=[0.02,0.02,0.02],rotation=[0.0,0.0,0.70710678,0.70710678],contactOffset=0.0,maxDistance=0.0,phaseMask=1,collisionFilter=4294901761},
|
||||
{type=2,center=[-1.0,0.12,-1.0],size=[-1.0,0.12,1.0],rotation=[0.0,0.0,0.0,1.0],contactOffset=0.0,maxDistance=0.0,phaseMask=1,collisionFilter=4294901761}
|
||||
], 2));
|
||||
if (batch.len()!=4 || batch[0].queryIndex!=0 || batch[1].queryIndex!=1 ||
|
||||
batch[2].queryIndex!=2 || batch[2].particleIndex!=0 || batch[3].particleIndex<=0)
|
||||
throw "Mixed query batch mismatch";
|
||||
checked(solver.setSimplexes([{particleIndices=[0,1,2],size=3}]));
|
||||
local simplexHits = checked(solver.querySimplexes([{
|
||||
type=0,center=[-1.0,0.12,-0.42],size=[0.0,0.0,0.0],rotation=[0.0,0.0,0.0,1.0],
|
||||
contactOffset=0.0,maxDistance=1.0,phaseMask=1,collisionFilter=4294901761
|
||||
}], 2));
|
||||
if (simplexHits.len()!=1 || simplexHits[0].simplexIndex!=0 || simplexHits[0].queryIndex!=0 ||
|
||||
fabs(simplexHits[0].simplexBary[0]+simplexHits[0].simplexBary[1]+simplexHits[0].simplexBary[2]-1.0)>0.00001)
|
||||
throw "Simplex barycentric query mismatch";
|
||||
local ellipsoidDefinition = checked(fluids.volumeDefaults());
|
||||
ellipsoidDefinition.settings.spacing = 0.2;
|
||||
local ellipsoid = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
ellipsoid.position = [0.0,1.0,0.0];
|
||||
ellipsoid.radii = [0.2,0.05,0.05];
|
||||
ellipsoid.orientation = [0.0,0.0,0.70710678,0.70710678];
|
||||
ellipsoidDefinition.particles = [ellipsoid];
|
||||
local ellipsoidSolver = checked(fluids.newVolumeSimulator(ellipsoidDefinition));
|
||||
local ellipsoidDistance = checked(ellipsoidSolver.querySphere(0.0,1.3,0.0,0.0,0.0,1.0,1,15));
|
||||
if (ellipsoidDistance.len()!=1 || fabs(ellipsoidDistance[0].distance-0.1)>0.00001)
|
||||
throw "Oriented ellipsoid distance mismatch";
|
||||
local ellipsoidRay = checked(ellipsoidSolver.raycast(0.0,0.7,0.0,0.0,1.0,0.0,1.0,1,15));
|
||||
if (ellipsoidRay.len()!=1 || fabs(ellipsoidRay[0].distance-0.1)>0.00001 || ellipsoidRay[0].normal[1]>=0.0)
|
||||
throw "Oriented ellipsoid ray mismatch";
|
||||
local material = clone hits[0].material;
|
||||
hits[0].material.phase = 3;
|
||||
if (query()[0].material.phase != 0) throw "Query leaked mutable state";
|
||||
material.phase = 3;
|
||||
checked(solver.paintSphere(-1.0, 0.12, -0.42, 0.01, material));
|
||||
if (query()[0].material.phase != 3) throw "Brush did not freeze particle";
|
||||
local outside = checked(solver.overlapSphere(-0.88, 0.12, -0.42, 0.01));
|
||||
if (outside.len() != 1 || outside[0].material.phase != 0) throw "Brush changed outside particle";
|
||||
material.viscosity = -1.0;
|
||||
if (solver.paintSphere(-1.0, 0.12, -0.42, 0.01, material).ok) throw "Invalid material accepted";
|
||||
if (query()[0].material.viscosity != 2.0) throw "Failed brush mutated state";
|
||||
material.viscosity = 2.0;
|
||||
material.unrecognized <- 1;
|
||||
if (solver.paintSphere(-1.0, 0.12, -0.42, 0.01, material).ok) throw "Unknown field accepted";
|
||||
delete material.unrecognized;
|
||||
material.phase = 0;
|
||||
checked(solver.paintSphere(-1.0, 0.12, -0.42, 0.01, material));
|
||||
if (query()[0].material.phase != 0) throw "Brush did not melt particle";
|
||||
if (solver.overlapSphere(0.0, 0.0, 0.0, -1.0).ok) throw "Negative radius accepted";
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local particle = query()[0];
|
||||
particle.data = [20.0, 1.5, 0.0, 0.0];
|
||||
definition.particles = [particle];
|
||||
local mapped = checked(fluids.newVolumeSimulator(definition));
|
||||
checked(mapped.applyMaterialChannels());
|
||||
local mappedMaterial = checked(mapped.snapshot()).particles[0].material;
|
||||
if (mappedMaterial.viscosity != 20.0 || mappedMaterial.cohesion != 1.5)
|
||||
throw "Material channel mapping failed";
|
||||
local filterDefinition = checked(fluids.volumeDefaults());
|
||||
filterDefinition.settings.gravity = [0.0,0.0,0.0]; filterDefinition.settings.iterations = 2;
|
||||
local filterA = clone particle; local filterB = clone particle;
|
||||
filterA.position = [0.0,1.0,0.0]; filterB.position = [0.0,1.0,0.0];
|
||||
filterA.actorGroup = 1; filterB.actorGroup = 2;
|
||||
filterA.collisionFilter = 131073; filterB.collisionFilter = 131074;
|
||||
filterDefinition.particles = [filterA,filterB];
|
||||
local filtered = checked(fluids.newVolumeSimulator(filterDefinition)); checked(filtered.step(1.0/120.0,1));
|
||||
local filteredState = checked(filtered.snapshot()).particles;
|
||||
local filteredDelta = [filteredState[0].position[0]-filteredState[1].position[0],
|
||||
filteredState[0].position[1]-filteredState[1].position[1],filteredState[0].position[2]-filteredState[1].position[2]];
|
||||
if (filteredDelta[0]*filteredDelta[0]+filteredDelta[1]*filteredDelta[1]+filteredDelta[2]*filteredDelta[2] != 0.0)
|
||||
throw "One-way particle filters interacted";
|
||||
filterB.collisionFilter = 65538; filterDefinition.particles = [filterA,filterB];
|
||||
local reciprocal = checked(fluids.newVolumeSimulator(filterDefinition)); checked(reciprocal.step(1.0/120.0,1));
|
||||
local reciprocalState = checked(reciprocal.snapshot()).particles;
|
||||
local reciprocalDelta = [reciprocalState[0].position[0]-reciprocalState[1].position[0],
|
||||
reciprocalState[0].position[1]-reciprocalState[1].position[1],reciprocalState[0].position[2]-reciprocalState[1].position[2]];
|
||||
if (reciprocalDelta[0]*reciprocalDelta[0]+reciprocalDelta[1]*reciprocalDelta[1]+
|
||||
reciprocalDelta[2]*reciprocalDelta[2] < 0.000001)
|
||||
throw "Reciprocal particle filters did not interact";
|
||||
filterB.actorGroup = 1; filterB.collisionFilter = 131074; filterA.selfCollide = true; filterB.selfCollide = false;
|
||||
filterDefinition.particles = [filterA,filterB];
|
||||
local noSelfCollision = checked(fluids.newVolumeSimulator(filterDefinition));checked(noSelfCollision.step(1.0/120.0,1));
|
||||
local noSelfState = checked(noSelfCollision.snapshot()).particles;
|
||||
local noSelfDelta = [noSelfState[0].position[0]-noSelfState[1].position[0],
|
||||
noSelfState[0].position[1]-noSelfState[1].position[1],noSelfState[0].position[2]-noSelfState[1].position[2]];
|
||||
if (noSelfDelta[0]*noSelfDelta[0]+noSelfDelta[1]*noSelfDelta[1]+noSelfDelta[2]*noSelfDelta[2] != 0.0)
|
||||
throw "Same actor group ignored self-collision flag";
|
||||
local colliderDefinition = checked(fluids.volumeDefaults());
|
||||
colliderDefinition.settings.gravity = [0.0,0.0,0.0]; colliderDefinition.settings.iterations = 1;
|
||||
local colliderParticle = clone particle; colliderParticle.position = [0.0,1.0,0.0];
|
||||
colliderParticle.collisionFilter = 131073; colliderDefinition.particles = [colliderParticle];
|
||||
local collider = checked(fluids.volumeColliderDefaults()); collider.center = [0.0,1.0,0.0];
|
||||
collider.collisionFilter = 131074; colliderDefinition.colliders = [collider];
|
||||
local ignoredCollider = checked(fluids.newVolumeSimulator(colliderDefinition));
|
||||
checked(ignoredCollider.step(1.0/120.0,1));
|
||||
local ignoredPosition = checked(ignoredCollider.snapshot()).particles[0].position;
|
||||
local ignoredDelta = [ignoredPosition[0],ignoredPosition[1]-1.0,ignoredPosition[2]];
|
||||
if (ignoredDelta[0]*ignoredDelta[0]+ignoredDelta[1]*ignoredDelta[1]+ignoredDelta[2]*ignoredDelta[2] != 0.0)
|
||||
throw "One-way collider filter projected particle";
|
||||
collider.collisionFilter = 65538; colliderDefinition.colliders = [collider];
|
||||
local matchingCollider = checked(fluids.newVolumeSimulator(colliderDefinition));
|
||||
checked(matchingCollider.step(1.0/120.0,1));
|
||||
local matchingPosition = checked(matchingCollider.snapshot()).particles[0].position;
|
||||
local matchingDelta = [matchingPosition[0],matchingPosition[1]-1.0,matchingPosition[2]];
|
||||
if (matchingDelta[0]*matchingDelta[0]+matchingDelta[1]*matchingDelta[1]+matchingDelta[2]*matchingDelta[2] < 0.000001)
|
||||
throw "Reciprocal collider filters did not interact";
|
||||
local atmosphereDefinition = checked(fluids.volumeDefaults());
|
||||
atmosphereDefinition.settings.gravity = [0.0,0.0,0.0]; atmosphereDefinition.settings.iterations = 1;
|
||||
local atmosphereA = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local atmosphereB = clone atmosphereA; atmosphereA.material = clone atmosphereA.material; atmosphereB.material = clone atmosphereB.material;
|
||||
atmosphereA.position = [-0.075,1.0,0.0]; atmosphereB.position = [0.075,1.0,0.0];
|
||||
atmosphereA.material.viscosity = 0.0; atmosphereB.material.viscosity = 0.0;
|
||||
atmosphereA.material.cohesion = 0.0; atmosphereB.material.cohesion = 0.0;
|
||||
atmosphereA.material.atmosphericPressure = 10.0; atmosphereB.material.atmosphericPressure = 10.0;
|
||||
atmosphereDefinition.particles = [atmosphereA,atmosphereB];
|
||||
local atmosphere = checked(fluids.newVolumeSimulator(atmosphereDefinition));checked(atmosphere.step(1.0/120.0,1));
|
||||
local atmosphereState = checked(atmosphere.snapshot()).particles;
|
||||
if (atmosphereState[0].velocity[0] <= 0.0 || atmosphereState[1].velocity[0] >= 0.0)
|
||||
throw "Atmospheric pressure did not compress the free surface";
|
||||
local windDefinition=checked(fluids.volumeDefaults());windDefinition.settings.gravity=[0.0,0.0,0.0];windDefinition.settings.iterations=1;
|
||||
local windParticle=checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
windParticle.position=[0.0,1.0,0.0];windParticle.material.cohesion=0.0;
|
||||
windParticle.material.viscosity=0.0;windParticle.material.drag=60.0;windDefinition.particles=[windParticle];
|
||||
local windSolver=checked(fluids.newVolumeSimulator(windDefinition));checked(windSolver.setParticleWinds([[6.0,0.0,0.0]]));
|
||||
if (windSolver.setParticleWinds([[0.0,0.0,0.0],[0.0,0.0,0.0]]).ok) throw "Invalid wind count accepted";
|
||||
if (windSolver.step(-1.0,1).ok) throw "Invalid wind step accepted";
|
||||
checked(windSolver.step(1.0/120.0,1));local windDriven=checked(windSolver.snapshot()).particles[0].velocity[0];
|
||||
if (windDriven<=2.0 || windDriven>=3.0) throw "Relative wind drag did not accelerate particle";
|
||||
checked(windSolver.step(1.0/120.0,1));
|
||||
if (checked(windSolver.snapshot()).particles[0].velocity[0]>=windDriven) throw "Wind did not clear after successful step";
|
||||
local zoneSolver=checked(fluids.newVolumeSimulator(windDefinition));
|
||||
local ambient=checked(fluids.volumeWindZoneDefaults());ambient.direction=[0.0,1.0,0.0];ambient.intensity=2.0;
|
||||
local spherical=checked(fluids.volumeWindZoneDefaults());spherical.type=1;spherical.center=[-1.0,1.0,0.0];
|
||||
spherical.radius=2.0;spherical.intensity=4.0;spherical.radial=true;
|
||||
checked(zoneSolver.accumulateWindZones([ambient,spherical],0.0));
|
||||
local invalidZone=clone spherical;invalidZone.radius=0.0;
|
||||
if(zoneSolver.accumulateWindZones([invalidZone],0.0).ok)throw "Invalid spherical wind radius accepted";
|
||||
checked(zoneSolver.step(1.0/120.0,1));local zoneVelocity=checked(zoneSolver.snapshot()).particles[0].velocity;
|
||||
if(zoneVelocity[0]<=0.0||zoneVelocity[1]<=0.0||abs(zoneVelocity[0]/zoneVelocity[1]-1.5)>0.001)
|
||||
throw "Ambient/spherical wind-zone accumulation mismatch";
|
||||
local smoothingDefinition = checked(fluids.volumeDefaults());
|
||||
if (smoothingDefinition.version != 20) throw "Smoothing snapshot version mismatch";
|
||||
smoothingDefinition.settings.gravity = [0.0,0.0,0.0]; smoothingDefinition.settings.iterations = 1;
|
||||
local smoothingA = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local smoothingB = clone smoothingA;smoothingA.material=clone smoothingA.material;smoothingB.material=clone smoothingB.material;
|
||||
smoothingA.position=[-0.125,1.0,0.0];smoothingB.position=[0.125,1.0,0.0];
|
||||
smoothingA.velocity=[1.0,0.0,0.0];smoothingB.velocity=[-1.0,0.0,0.0];
|
||||
smoothingA.material.cohesion=0.0;smoothingB.material.cohesion=0.0;
|
||||
smoothingA.material.viscosity=100.0;smoothingB.material.viscosity=100.0;
|
||||
smoothingA.material.smoothing=1.0;smoothingB.material.smoothing=4.0;
|
||||
smoothingDefinition.particles=[smoothingA,smoothingB];
|
||||
local smoothingSolver=checked(fluids.newVolumeSimulator(smoothingDefinition));checked(smoothingSolver.step(1.0/120.0,1));
|
||||
local smoothingState=checked(smoothingSolver.snapshot()).particles;
|
||||
if (smoothingState[0].velocity[0] >= 0.999 || fabs(smoothingState[0].velocity[0]+smoothingState[1].velocity[0])>0.00001)
|
||||
throw "Per-particle smoothing did not expand symmetric SPH support";
|
||||
smoothingDefinition.particles[0].material.smoothing=4.01;
|
||||
if (fluids.newVolumeSimulator(smoothingDefinition).ok) throw "Invalid smoothing accepted";
|
||||
local granularDefinition = checked(fluids.volumeDefaults());
|
||||
granularDefinition.settings.gravity = [0.0,0.0,0.0];
|
||||
local granularSolver = checked(fluids.newVolumeSimulator(granularDefinition));
|
||||
local granularEmission = checked(fluids.volumeEmissionDefaults());
|
||||
if (granularEmission.version != 10 || granularEmission.description.granularRadiusRandomness != 0.0 ||
|
||||
granularEmission.description.prototype.material.smoothing != 2.0 ||
|
||||
granularEmission.description.prototype.material.rollingContacts ||
|
||||
granularEmission.description.prototype.material.rollingFriction != 0.0 ||
|
||||
abs(granularEmission.description.prototype.material.dynamicFriction - 0.2) > 0.00001 ||
|
||||
abs(granularEmission.description.prototype.material.staticFriction - 0.2) > 0.00001 ||
|
||||
granularEmission.description.prototype.angularVelocity[0] != 0.0 ||
|
||||
granularEmission.description.prototype.angularVelocity[1] != 0.0 ||
|
||||
granularEmission.description.prototype.angularVelocity[2] != 0.0)
|
||||
throw "Granular randomness schema/default mismatch";
|
||||
granularEmission.description.origin = [0.0,1.0,0.0];
|
||||
granularEmission.description.extent = [0.0,0.0,0.0];
|
||||
granularEmission.description.prototype.material.phase = 2;
|
||||
granularEmission.description.granularRadiusRandomness = 100.0;
|
||||
checked(granularSolver.emitBurst(granularEmission,8));
|
||||
local granularParticles = checked(granularSolver.snapshot()).particles;
|
||||
local granularVaried = false;
|
||||
foreach (granularParticle in granularParticles) {
|
||||
if (granularParticle.radii[0] < 0.001 || granularParticle.radii[0] > 0.051001 ||
|
||||
granularParticle.radii[0] != granularParticle.radii[1] || granularParticle.radii[1] != granularParticle.radii[2])
|
||||
throw "Granular radius outside deterministic isotropic bounds";
|
||||
if (granularParticle.radii[0] < 0.049) granularVaried = true;
|
||||
}
|
||||
if (!granularVaried) throw "Granular radius randomness produced no variation";
|
||||
local sdfDefinition=checked(fluids.volumeDefaults());sdfDefinition.settings.gravity=[0.0,0.0,0.0];
|
||||
sdfDefinition.settings.iterations=1;
|
||||
local sdfParticle=checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
sdfParticle.position=[0.45,0.1,0.0];sdfParticle.material=clone sdfParticle.material;
|
||||
sdfParticle.material.cohesion=0.0;sdfParticle.material.viscosity=0.0;sdfDefinition.particles=[sdfParticle];
|
||||
local sdfSolver=checked(fluids.newVolumeSimulator(sdfDefinition));
|
||||
local sdfCollider=checked(fluids.volumeSdfColliderDefaults());sdfCollider.label=88;sdfCollider.friction=0.0;
|
||||
checked(sdfSolver.setSdfColliders([sdfCollider]));checked(sdfSolver.step(1.0/120.0,1));
|
||||
local sdfState=checked(sdfSolver.snapshot());
|
||||
if(sdfState.version!=14||sdfState.sdfColliders.len()!=1||sdfState.particles[0].position[0]<=0.45)
|
||||
throw "Owned SDF collider runtime/schema mismatch";
|
||||
dirty = true;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Opt-in check for the package AddRandomVelocity actor helper.
|
||||
function verifyVolumeFluidRandomVelocity() {
|
||||
local defaults = fluids.volumeDefaults();
|
||||
if (!defaults.ok) throw defaults.error.message;
|
||||
local created = fluids.newVolumeSimulator(defaults.value);
|
||||
if (!created.ok) throw created.error.message;
|
||||
local solver = created.value;
|
||||
local emission = fluids.volumeEmissionDefaults();
|
||||
if (!emission.ok) throw emission.error.message;
|
||||
local a = clone emission.value.description.prototype;
|
||||
local b = clone emission.value.description.prototype;
|
||||
a.position = [-0.2, 1.0, 0.0]; a.velocity = [1.0, 0.0, 0.0];
|
||||
b.position = [0.2, 1.0, 0.0]; b.velocity = [0.0, 1.0, 0.0];
|
||||
local admitted = solver.emit([a, b]);
|
||||
if (!admitted.ok) throw admitted.error.message;
|
||||
local applied = solver.addRandomVelocity(5.0, 73);
|
||||
if (!applied.ok) throw applied.error.message;
|
||||
local state = solver.snapshot();
|
||||
if (!state.ok) throw state.error.message;
|
||||
local da = [state.value.particles[0].velocity[0] - 1.0,
|
||||
state.value.particles[0].velocity[1],
|
||||
state.value.particles[0].velocity[2]];
|
||||
local db = [state.value.particles[1].velocity[0],
|
||||
state.value.particles[1].velocity[1] - 1.0,
|
||||
state.value.particles[1].velocity[2]];
|
||||
for (local i = 0; i < 3; ++i)
|
||||
if (fabs(da[i] - db[i]) > 0.00001) throw "Actor impulse differs per particle";
|
||||
if (solver.addRandomVelocity(-1.0, 73).ok) throw "Invalid impulse intensity accepted";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Opt-in check for Fluid3D's independent generateReflection setting.
|
||||
function verifyReflectionToggle() {
|
||||
local localSurface = fluids.newSurfaceRenderer(64, 64);
|
||||
local material = localSurface.configureMaterial(false, 0.0, 0.0, 0.0, 0.85, 1.0);
|
||||
if (!material.ok) throw material.error.message;
|
||||
local disabled = localSurface.configureReflection(false);
|
||||
if (!disabled.ok) throw disabled.error.message;
|
||||
local enabled = localSurface.configureReflection(true);
|
||||
if (!enabled.ok) throw enabled.error.message;
|
||||
return "VOLUME_REFLECTION_TOGGLE_PASS";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Opt-in check for Fluid3D's independent generateRefraction setting.
|
||||
function verifyRefractionToggle() {
|
||||
local localSurface = fluids.newSurfaceRenderer(64, 64);
|
||||
local configured = localSurface.configureRefraction(0.8, 4.0, 0.015, 2);
|
||||
if (!configured.ok) throw configured.error.message;
|
||||
local disabled = localSurface.configureRefractionEnabled(false);
|
||||
if (!disabled.ok) throw disabled.error.message;
|
||||
local enabled = localSurface.configureRefractionEnabled(true);
|
||||
if (!enabled.ok) throw enabled.error.message;
|
||||
return "VOLUME_REFRACTION_TOGGLE_PASS";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Opt-in check for Fluid3DDistanceFieldRenderer-style SDF cutaway data.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
local collider = checked(fluids.volumeSdfColliderDefaults());
|
||||
collider.label = 93;
|
||||
collider.position = [1.0, 2.0, 3.0];
|
||||
collider.scale = 2.0;
|
||||
checked(localSolver.setSdfColliders([collider]));
|
||||
local cutaway = checked(localSolver.debugSdfSlice(93, 2, 0.5, 0.5, 65536));
|
||||
if (cutaway.width < 2 || cutaway.height < 2 ||
|
||||
cutaway.values.len() != cutaway.width * cutaway.height)
|
||||
throw "SDF slice dimensions mismatch";
|
||||
local hasInside = false;
|
||||
local hasOutside = false;
|
||||
foreach (value in cutaway.values) {
|
||||
if (value < 0.5) hasInside = true;
|
||||
if (value > 0.5) hasOutside = true;
|
||||
}
|
||||
if (!hasInside || !hasOutside) throw "SDF slice did not preserve signed regions";
|
||||
local rejected = localSolver.debugSdfSlice(93, 2, 0.5, 0.5, 1);
|
||||
if (rejected.ok) throw "SDF slice budget was not enforced";
|
||||
return format("VOLUME_SDF_SLICE_PASS %dx%d", cutaway.width, cutaway.height);
|
||||
@@ -0,0 +1,35 @@
|
||||
// Opt-in check for Fluid3DEmitter.useShapeColor across burst and moving-jet emission.
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.capacity = 16;
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local emission = checked(fluids.volumeEmissionDefaults());
|
||||
emission.description.shape = 5;
|
||||
emission.description.direction = [0.0, 0.0, 1.0];
|
||||
emission.description.speed = 6.0;
|
||||
emission.description.prototype.color = [0.8, 0.6, 0.4, 1.0];
|
||||
emission.description.distribution = [{
|
||||
position = [0.0, 0.0, 0.0],
|
||||
color = [0.5, 0.25, 0.1, 0.5],
|
||||
direction = [0.0, 0.0, 1.0]
|
||||
}];
|
||||
|
||||
local colored = checked(fluids.newVolumeSimulator(definition));
|
||||
if (checked(colored.emitBurst(emission, 1)) != 1) throw "Shape-colored burst failed";
|
||||
local color = checked(colored.snapshot()).particles[0].color;
|
||||
if (fabs(color[0] - 0.5) > 0.00001 || fabs(color[1] - 0.25) > 0.00001 ||
|
||||
fabs(color[2] - 0.1) > 0.00001 || fabs(color[3] - 0.5) > 0.00001)
|
||||
throw "Shape color was not assigned";
|
||||
|
||||
emission.description.useShapeColor = false;
|
||||
local plain = checked(fluids.newVolumeSimulator(definition));
|
||||
local jet = eve.VolumeFluidJetEmitter();
|
||||
if (checked(jet.advance(plain, emission, 1.0 / 30.0, 4, 0.0)) < 1)
|
||||
throw "Uncolored moving jet failed";
|
||||
foreach (particle in checked(plain.snapshot()).particles) {
|
||||
color = particle.color;
|
||||
if (fabs(color[0] - 0.8) > 0.00001 || fabs(color[1] - 0.6) > 0.00001 ||
|
||||
fabs(color[2] - 0.4) > 0.00001 || fabs(color[3] - 1.0) > 0.00001)
|
||||
throw "Disabled shape color changed the prototype color";
|
||||
}
|
||||
print("VOLUME_SHAPE_COLOR_ASSIGNMENT_PASS\n");
|
||||
return "VOLUME_SHAPE_COLOR_PASS";
|
||||
@@ -0,0 +1,33 @@
|
||||
// Opt-in setup/runtime contract probe for Fluid3D sphere/cube distribution shapes.
|
||||
function runShapeDistributionCheck() {
|
||||
local sphere=checked(fluids.volumeEmissionFromSphere(0.2,0.1,true));
|
||||
local cube=checked(fluids.volumeEmissionFromCube(0.2,0.3,0.4,0.1,false));
|
||||
local edge=checked(fluids.volumeEmissionFromEdge(0.5,0.1,30.0));
|
||||
local disk=checked(fluids.volumeEmissionFromDisk(0.2,0.1,true));
|
||||
if(sphere.version!=11||cube.version!=11||sphere.description.randomVelocity!=0.0||sphere.description.distribution.len()==0||
|
||||
cube.description.distribution.len()!=60||edge.description.distribution.len()!=5||
|
||||
disk.description.distribution.len()==0)
|
||||
throw "Analytic distribution shape mismatch";
|
||||
local point=sphere.description.distribution[0];
|
||||
if(point.direction.len()!=3)throw "Distribution direction missing";
|
||||
local definition=checked(fluids.volumeDefaults());definition.settings.gravity=[0.0,0.0,0.0];
|
||||
local testSolver=checked(fluids.newVolumeSimulator(definition));
|
||||
sphere.description.origin=[0.0,1.0,0.0];sphere.description.speed=2.0;
|
||||
if(checked(testSolver.emitBurst(sphere,1))!=1||testSolver.getParticleCount()!=1)
|
||||
throw "Sphere distribution burst failed";
|
||||
if(edge.description.distribution[3].direction[1]>-0.999||
|
||||
fabs(disk.description.distribution[0].position[0])>0.201)
|
||||
throw "Edge radial velocity or disk edge emission mismatch";
|
||||
local randomNozzle=checked(fluids.volumeEmissionDefaults());randomNozzle.description.origin=[0.2,1.0,0.0];
|
||||
randomNozzle.description.direction=[0.0,0.0,1.0];randomNozzle.description.speed=2.0;
|
||||
randomNozzle.description.randomVelocity=1.0;randomNozzle.description.seed=77;
|
||||
if(checked(testSolver.emitBurst(randomNozzle,1))!=1)throw "Random velocity burst failed";
|
||||
local randomParticle=checked(testSolver.snapshot()).particles[1];
|
||||
local speed=sqrt(randomParticle.velocity[0]*randomParticle.velocity[0]+randomParticle.velocity[1]*randomParticle.velocity[1]+randomParticle.velocity[2]*randomParticle.velocity[2]);
|
||||
if(fabs(speed-2.0)>0.0001||randomParticle.velocity[2]>1.999)throw "Fluid3D randomVelocity blend mismatch";
|
||||
print("VOLUME_SHAPE_DISTRIBUTION_PASS sphere="+sphere.description.distribution.len()+
|
||||
" cube="+cube.description.distribution.len()+" edge="+edge.description.distribution.len()+
|
||||
" disk="+disk.description.distribution.len()+"\n");
|
||||
}
|
||||
|
||||
runShapeDistributionCheck();
|
||||
@@ -0,0 +1,19 @@
|
||||
// Opt-in SimpleFluid numerical acceptance probe. Leaves the final surface paused.
|
||||
function verifySimpleFluid() {
|
||||
paused = true;
|
||||
rebuild(0);
|
||||
local initial = checked(solver.snapshot()).particles;
|
||||
for (local frame=0; frame<120; ++frame) checked(solver.step(1.0/60.0, 2));
|
||||
local current = checked(solver.snapshot()).particles;
|
||||
local moved=0, minimumY=1000.0, maximumY=-1000.0;
|
||||
for (local i=0; i<current.len(); ++i) {
|
||||
if (fabs(current[i].position[1]-initial[i].position[1])>0.05) ++moved;
|
||||
if (current[i].position[1]<minimumY) minimumY=current[i].position[1];
|
||||
if (current[i].position[1]>maximumY) maximumY=current[i].position[1];
|
||||
}
|
||||
if (current.len()!=640 || moved<500 || minimumY<0.0 || maximumY>2.4)
|
||||
throw "SimpleFluid numerical acceptance failed";
|
||||
dirty=true;
|
||||
return "VOLUME_SIMPLE_FLUID_PASS particles="+current.len()+" moved="+moved+
|
||||
" minY="+minimumY+" maxY="+maximumY;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Opt-in bounded stability soak. Drive volumeFluidSoakChunk() from MCP in short calls.
|
||||
persist volumeFluidSoakFrames = 0;
|
||||
|
||||
function beginVolumeFluidSoak() {
|
||||
paused = true;
|
||||
rebuild(0);
|
||||
volumeFluidSoakFrames = 0;
|
||||
dirty = false;
|
||||
if (solver.getParticleCount() != 640)
|
||||
throw "volume fluid soak requires the 640-particle SimpleFluid scene";
|
||||
return "VOLUME_FLUID_SOAK_READY particles=640";
|
||||
}
|
||||
|
||||
function volumeFluidSoakChunk(frameCount) {
|
||||
if (frameCount < 1 || frameCount > 10)
|
||||
throw "volume fluid soak chunk must contain 1..10 frames";
|
||||
for (local frame = 0; frame < frameCount; ++frame) {
|
||||
checked(solver.step(1.0 / 120.0, 1));
|
||||
// Periodically cover the host readback/upload path. Other frames exercise
|
||||
// the device-local uniform-color path without creating per-frame resources.
|
||||
if ((volumeFluidSoakFrames % 10) == 0) {
|
||||
surface.renderVolume(solver);
|
||||
checked(surface.copyToTexture(gfx, texture));
|
||||
} else {
|
||||
checked(surface.renderVolumeColorToTexture(solver, gfx, texture));
|
||||
}
|
||||
++volumeFluidSoakFrames;
|
||||
}
|
||||
if (solver.getParticleCount() != 640)
|
||||
throw "volume fluid soak particle count changed";
|
||||
return "VOLUME_FLUID_SOAK_CHUNK frames=" + volumeFluidSoakFrames + " particles=640";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0]; definition.settings.capacity = 2;
|
||||
local prototype = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
local a = clone prototype; a.material = clone prototype.material;
|
||||
a.position = [-0.2, 1.0, 0.0]; a.material.density = 1000.0; a.selfCollide = false;
|
||||
local b = clone prototype; b.material = clone prototype.material;
|
||||
b.position = [0.2, 1.0, 0.0]; b.material.density = 2000.0; b.selfCollide = false;
|
||||
definition.particles = [a, b];
|
||||
local localSolver = checked(fluids.newVolumeSimulator(definition));
|
||||
checked(localSolver.setStitches([{particleIndex1=0,particleIndex2=1,compliance=0.0}]));
|
||||
checked(localSolver.step(1.0 / 120.0, 1));
|
||||
local state = checked(localSolver.snapshot());
|
||||
if (state.version != 20 || state.stitches.len() != 1 ||
|
||||
fabs(state.particles[0].position[0] - state.particles[1].position[0]) > 0.00001 ||
|
||||
fabs(state.particles[0].position[0] - 0.06666667) > 0.00001)
|
||||
throw format("Stitch mass weighting mismatch: %.7f %.7f", state.particles[0].position[0],
|
||||
state.particles[1].position[0]);
|
||||
checked(localSolver.killParticle(0));
|
||||
if (checked(localSolver.snapshot()).stitches.len() != 0) throw "Dead-particle stitch survived";
|
||||
return "VOLUME_STITCH_PASS";
|
||||
@@ -0,0 +1,9 @@
|
||||
local localSurface = fluids.newSurfaceRenderer(17, 17);
|
||||
local configured = localSurface.configureSurfaceBlend(5, 10);
|
||||
if (!configured.ok) throw "default surface blend rejected";
|
||||
if (localSurface.configureSurfaceBlend(-1, 10).ok) throw "negative blend factor accepted";
|
||||
if (localSurface.configureSurfaceBlend(5, 11).ok) throw "out-of-range blend factor accepted";
|
||||
if (!localSurface.configureParticleBlend(2, 0, false).ok) throw "particle blend rejected";
|
||||
if (localSurface.configureParticleBlend(11, 0, false).ok) throw "particle source factor accepted";
|
||||
if (localSurface.configureParticleBlend(2, -1, true).ok) throw "particle destination factor accepted";
|
||||
::surfaceBlendSettingsPass <- true;
|
||||
@@ -0,0 +1,11 @@
|
||||
// Opt-in check for Fluid3D's world-space surface blur radius.
|
||||
function verifySurfaceBlurRadius() {
|
||||
local localSurface = fluids.newSurfaceRenderer(64, 64);
|
||||
local configured = localSurface.configureSurfaceBlurRadius(0.02);
|
||||
if (!configured.ok) throw configured.error.message;
|
||||
if (localSurface.configureSurfaceBlurRadius(-0.01).ok)
|
||||
throw "Negative surface blur radius was accepted";
|
||||
if (localSurface.configureSurfaceBlurRadius(0.1001).ok)
|
||||
throw "Oversized surface blur radius was accepted";
|
||||
return "VOLUME_SURFACE_BLUR_PASS";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Opt-in check for Fluid3D's generateSurface=false all-phase volume presentation.
|
||||
function verifySurfaceDisabledVolume() {
|
||||
local defaults = fluids.volumeDefaults();
|
||||
if (!defaults.ok) throw defaults.error.message;
|
||||
local created = fluids.newVolumeSimulator(defaults.value);
|
||||
if (!created.ok) throw created.error.message;
|
||||
local localSolver = created.value;
|
||||
local emission = fluids.volumeEmissionDefaults();
|
||||
if (!emission.ok) throw emission.error.message;
|
||||
local particle = clone emission.value.description.prototype;
|
||||
particle.position = [0.0, 1.0, 0.0];
|
||||
particle.color = [0.9, 0.1, 0.05, 1.0];
|
||||
particle.material.phase = 0; // VolumeFluidPhase::Liquid
|
||||
local emitted = localSolver.emit([particle]);
|
||||
if (!emitted.ok) throw emitted.error.message;
|
||||
local localSurface = fluids.newSurfaceRenderer(64, 64);
|
||||
localSurface.setCameraXYZ(0.0, 1.0, 3.0, 0.0, 1.0, 0.0, 45.0);
|
||||
local rendererSettings = checked(fluids.fluidRendererSettingsDefaults());
|
||||
if (rendererSettings.blendSource != 5 || rendererSettings.blendDestination != 10 ||
|
||||
rendererSettings.thicknessCutoff != 1.2 || rendererSettings.thicknessDownsample != 2 ||
|
||||
rendererSettings.blurRadius != 0.02)
|
||||
throw "Fluid3D renderer defaults mismatch";
|
||||
rendererSettings.generateSurface = false;
|
||||
rendererSettings.generateReflection = false;
|
||||
rendererSettings.generateRefraction = false;
|
||||
rendererSettings.generateFoam = false;
|
||||
checked(localSurface.configureRendererSettings(rendererSettings));
|
||||
if (checked(localSurface.rendererSettings()).generateSurface)
|
||||
throw "Complete renderer settings were not applied";
|
||||
local rendered = localSurface.renderConfiguredVolume(localSolver);
|
||||
if (!rendered.ok) throw rendered.error.message;
|
||||
checked(localSurface.configureSurfaceEnabled(true));
|
||||
checked(localSurface.renderConfiguredVolume(localSolver));
|
||||
checked(localSurface.configureSurfaceEnabled(false));
|
||||
if (localSurface.renderVolumeWithoutSurface(localSolver, 0.0).ok)
|
||||
throw "Invalid surface-disabled absorption was accepted";
|
||||
return "VOLUME_SURFACE_DISABLED_PASS";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Opt-in real-renderer check for Fluid3D renderablePositions timing.
|
||||
function verifyVolumeFluidSurfaceInterpolation() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local particle = clone checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [-0.5, 1.0, 0.0];
|
||||
particle.velocity = [60.0, 0.0, 0.0];
|
||||
definition.particles.append(particle);
|
||||
local source = checked(fluids.newVolumeSimulator(definition));
|
||||
checked(source.step(1.0 / 120.0, 1));
|
||||
local renderer = surface;
|
||||
checked(renderer.configureProjection(true, 1.0));
|
||||
renderer.setCameraXYZ(0.0, 1.0, 3.0, 0.0, 1.0, 0.0, 48.0);
|
||||
checked(renderer.prepare());
|
||||
checked(renderer.renderVolumeInterpolated(source, 0.0));
|
||||
checked(renderer.renderVolumeInterpolated(source, 1.0));
|
||||
if (renderer.renderVolumeInterpolated(source, -0.01).ok)
|
||||
throw "Invalid surface interpolation accepted";
|
||||
checked(renderer.renderVolumeInterpolated(source, 0.0));
|
||||
checked(renderer.copyToImage(pixels));
|
||||
gfx.updateTextureFromImageData(texture, pixels);
|
||||
paused = true;
|
||||
dirty = false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Opt-in check for the package Fluid3DActorTeleport behavior.
|
||||
function verifyVolumeFluidTeleport() {
|
||||
local defaults = fluids.volumeDefaults();
|
||||
if (!defaults.ok) throw defaults.error.message;
|
||||
local created = fluids.newVolumeSimulator(defaults.value);
|
||||
if (!created.ok) throw created.error.message;
|
||||
local solver = created.value;
|
||||
local emission = fluids.volumeEmissionDefaults();
|
||||
if (!emission.ok) throw emission.error.message;
|
||||
local particle = clone emission.value.description.prototype;
|
||||
particle.position = [0.0, 1.0, 0.0];
|
||||
particle.velocity = [1.0, 2.0, 3.0];
|
||||
local admitted = solver.emit([particle]);
|
||||
if (!admitted.ok) throw admitted.error.message;
|
||||
local current = {position=[0.0,1.0,0.0], rotation=[0.0,0.0,0.0,1.0]};
|
||||
local target = {position=[1.0,1.0,0.0], rotation=[0.0,0.0,0.0,1.0]};
|
||||
local moved = solver.teleportActor(current, target);
|
||||
if (!moved.ok) throw moved.error.message;
|
||||
local state = solver.snapshot();
|
||||
if (!state.ok) throw state.error.message;
|
||||
local result = state.value.particles[0];
|
||||
if (fabs(result.position[0]-1.0)>0.00001 || fabs(result.velocity[0])>0.00001 ||
|
||||
fabs(result.velocity[1])>0.00001 || fabs(result.velocity[2])>0.00001)
|
||||
throw "Actor teleport state mismatch";
|
||||
target.position = [4.0, 1.0, 0.0];
|
||||
if (solver.teleportActor(current, target).ok) throw "Out-of-bounds teleport accepted";
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Opt-in one-step contact heating check; no render calls or callbacks.
|
||||
function verifyVolumeFluidThermalContact() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local particle = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.0, 1.25, 0.0];
|
||||
particle.data = [5.0, 1.0, 7.0, -2.0];
|
||||
definition.particles = [particle];
|
||||
local sim = checked(fluids.newVolumeSimulator(definition));
|
||||
local collider = checked(fluids.volumeColliderDefaults());
|
||||
collider.label = 17;
|
||||
checked(sim.setColliders([collider]));
|
||||
local rule = checked(fluids.volumeThermalRuleDefaults());
|
||||
rule.colliderLabel = 17;
|
||||
rule.rate = -6.0;
|
||||
checked(sim.stepWithThermalContacts(1.0 / 60.0, 4, [rule]));
|
||||
local state = checked(sim.snapshot()).particles[0];
|
||||
if (fabs(state.data[0] - 4.9) > 0.00001 || fabs(state.data[1] - 0.9) > 0.00001)
|
||||
throw "Contact heating did not apply exactly once";
|
||||
checked(sim.applyMaterialChannels());
|
||||
if (fabs(checked(sim.snapshot()).particles[0].material.viscosity - 4.9) > 0.00001)
|
||||
throw "Heated channel did not drive viscosity";
|
||||
if (sim.stepWithThermalContacts(1.0 / 60.0, 4, [rule, rule]).ok)
|
||||
throw "Duplicate thermal labels accepted";
|
||||
rule.unknown <- 1;
|
||||
if (sim.stepWithThermalContacts(1.0 / 60.0, 4, [rule]).ok)
|
||||
throw "Unknown thermal rule field accepted";
|
||||
return true;
|
||||
}
|
||||
|
||||
// Heat and rigid feedback share one simulation step; world integration stays caller-owned.
|
||||
function verifyVolumeFluidThermalCoupling() {
|
||||
local world = physics.newWorld3D(0.0, 0.0, 0.0, false);
|
||||
try {
|
||||
local body = world.newBody("dynamic", 0.0, 0.0, 0.0);
|
||||
body.newBoxShape(0.5, 0.5, 0.5, 1000.0, 0.5, 0.0);
|
||||
local bridge = eve.VolumeFluidCoupling();
|
||||
local collider = checked(fluids.volumeColliderDefaults());
|
||||
collider.label = 17;
|
||||
checked(bridge.attach(body, collider));
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
local particle = checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.0, 1.25, 0.0];
|
||||
particle.data = [5.0, 1.0, 7.0, -2.0];
|
||||
particle.life = 1.0;
|
||||
definition.particles = [particle];
|
||||
local sim = checked(fluids.newVolumeSimulator(definition));
|
||||
local rule = checked(fluids.volumeThermalRuleDefaults());
|
||||
rule.colliderLabel = 99;
|
||||
rule.rate = -6.0;
|
||||
if (bridge.stepWithThermalContacts(world, sim, 1.0/60.0, 4, [rule]).ok)
|
||||
throw "Unlinked thermal label accepted";
|
||||
local before = checked(sim.snapshot());
|
||||
if (before.colliders.len() != 0 || before.particles[0].life != 1.0 || body.getLinearVelocityY() != 0.0)
|
||||
throw "Rejected coupling mutated state";
|
||||
rule.colliderLabel = 17;
|
||||
local contacts = checked(bridge.stepWithThermalContacts(world, sim, 1.0/60.0, 4, [rule]));
|
||||
local after = checked(sim.snapshot()).particles[0];
|
||||
if (contacts <= 0 || fabs(body.getLinearVelocityY()) < 0.000001)
|
||||
throw "Coupled thermal step omitted rigid feedback";
|
||||
if (fabs(after.data[0]-4.9) > 0.00001 || fabs(after.life-(1.0-1.0/60.0)) > 0.000001)
|
||||
throw "Heat and fluid did not share exactly one step";
|
||||
world.destroy();
|
||||
if (bridge.stepWithThermalContacts(world, sim, 1.0/60.0, 4, [rule]).ok)
|
||||
throw "Destroyed world accepted";
|
||||
} catch (error) {
|
||||
world.destroy();
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Small, bounded thermal presentation check; no render call inside the loop.
|
||||
function verifyVolumeFluidThermalColors() {
|
||||
rebuild(10);
|
||||
for (local i = 0; i < 120; ++i) {
|
||||
checked(solver.stepWithThermalContacts(1.0/60.0, 4, thermalRules));
|
||||
checked(solver.applyMaterialChannelsWithColors(viscosityColors));
|
||||
}
|
||||
local particles = checked(solver.snapshot()).particles;
|
||||
if (particles.len() != 96) throw "Thermal scene count changed";
|
||||
local hot = 0.0; local cold = 0.0;
|
||||
local hotBlue = 0.0; local coldBlue = 0.0;
|
||||
for (local i = 0; i < particles.len(); ++i) {
|
||||
local p = particles[i];
|
||||
if (p.material.viscosity != p.data[0]) throw "Thermal material mapping missing";
|
||||
if (i < 48) { hot += p.material.viscosity; hotBlue += p.color[2]; }
|
||||
else { cold += p.material.viscosity; coldBlue += p.color[2]; }
|
||||
}
|
||||
if (!(hot / 48.0 < 4.9 && cold / 48.0 > 5.1 && coldBlue > hotBlue))
|
||||
throw "Hot and cold material colors did not diverge";
|
||||
paused = true; dirty = true;
|
||||
return [hot / 48.0, cold / 48.0];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Opt-in contract check for Fluid3D's normalized ThicknessClip convention.
|
||||
local localSurface = fluids.newSurfaceRenderer(32, 32);
|
||||
local configured = localSurface.configureSurface(1.0, 1.2, 0.03, 2);
|
||||
if (!configured.ok) throw "Fluid3D thickness cutoff was rejected";
|
||||
if (localSurface.configureSurface(1.0, -0.001, 0.03, 2).ok)
|
||||
throw "Negative thickness cutoff was accepted";
|
||||
if (localSurface.configureSurface(1.0, 5.001, 0.03, 2).ok)
|
||||
throw "Oversized thickness cutoff was accepted";
|
||||
::thicknessCutoffSettingsPass <- true;
|
||||
@@ -0,0 +1,53 @@
|
||||
// Opt-in FluidViscosity acceptance probe. Loading this file has no side effects.
|
||||
function viscosityScene(viscosity) {
|
||||
local definition=checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity=[0.0,0.0,0.0];
|
||||
definition.settings.spacing=0.1;
|
||||
definition.settings.minimum=[-0.5,0.0,-0.5];
|
||||
definition.settings.maximum=[0.5,1.2,0.5];
|
||||
definition.settings.iterations=2;
|
||||
local prototype=checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
for(local z=0;z<4;++z)for(local y=0;y<4;++y)for(local x=0;x<4;++x) {
|
||||
local particle=clone prototype;
|
||||
particle.material=clone prototype.material;
|
||||
particle.position=[-0.15+x*0.1,0.45+y*0.1,-0.15+z*0.1];
|
||||
particle.velocity=[0.0,0.0,y<2 ? 0.5 : -0.5];
|
||||
particle.material.viscosity=viscosity;
|
||||
particle.material.yieldStress=0.0;
|
||||
particle.material.cohesion=0.0;
|
||||
definition.particles.append(particle);
|
||||
}
|
||||
return checked(fluids.newVolumeSimulator(definition));
|
||||
}
|
||||
|
||||
function relativeFlowEnergy(particles) {
|
||||
local mean=[0.0,0.0,0.0];
|
||||
foreach(particle in particles)for(local c=0;c<3;++c)mean[c]+=particle.velocity[c];
|
||||
for(local c=0;c<3;++c)mean[c]/=particles.len();
|
||||
local energy=0.0;
|
||||
foreach(particle in particles)for(local c=0;c<3;++c) {
|
||||
local relative=particle.velocity[c]-mean[c];
|
||||
energy+=relative*relative;
|
||||
}
|
||||
return energy/particles.len();
|
||||
}
|
||||
|
||||
function verifyVolumeFluidViscosity() {
|
||||
local low=viscosityScene(0.0),high=viscosityScene(70.0);
|
||||
local initialLow=relativeFlowEnergy(checked(low.snapshot()).particles);
|
||||
local initialHigh=relativeFlowEnergy(checked(high.snapshot()).particles);
|
||||
for(local frame=0;frame<120;++frame) {
|
||||
checked(low.step(1.0/120.0,1));
|
||||
checked(high.step(1.0/120.0,1));
|
||||
}
|
||||
local finalLow=relativeFlowEnergy(checked(low.snapshot()).particles);
|
||||
local finalHigh=relativeFlowEnergy(checked(high.snapshot()).particles);
|
||||
if(fabs(initialLow-initialHigh)>0.000001)
|
||||
throw "Viscosity controls did not start identically";
|
||||
if(finalHigh>=finalLow*0.5)
|
||||
throw "High viscosity did not materially damp relative flow";
|
||||
if(finalLow<=initialLow*0.25)
|
||||
throw "Low-viscosity control lost most relative flow";
|
||||
return "VOLUME_FLUID_VISCOSITY_PASS initial="+initialLow+
|
||||
" low="+finalLow+" high="+finalHigh+" ratio="+(finalHigh/finalLow);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
dofile("wave-support.nut");
|
||||
|
||||
local original = [1.0, 2.0, 3.0];
|
||||
local quarter = fluidWavePosition(original, 0.5, 2.0, 3.14159265359 / 4.0);
|
||||
if (fabs(quarter[0] - 1.5) > 0.00001 || quarter[1] != 2.0 || quarter[2] != 3.0)
|
||||
throw "WaveGenerator position mismatch";
|
||||
local replay = fluidWavePosition(original, 0.5, 2.0, 3.14159265359 / 4.0);
|
||||
if (replay[0] != quarter[0] || replay[1] != quarter[1] || replay[2] != quarter[2])
|
||||
throw "WaveGenerator replay mismatch";
|
||||
rebuild(11);
|
||||
advanceAttachment(3.14159265359 / 2.0);
|
||||
local integrated = checked(solver.snapshot());
|
||||
if (fabs(integrated.colliders[0].center[0] - 0.3) > 0.00001)
|
||||
throw "WaveGenerator moving collider integration mismatch";
|
||||
return "VOLUME_WAVE_GENERATOR_PASS";
|
||||
@@ -0,0 +1,6 @@
|
||||
// Deterministic native equivalent of the package WaveGenerator sample.
|
||||
// fixedTime is injected by the simulation owner, so pause/replay never observes wall time.
|
||||
function fluidWavePosition(originalPosition, amplitude, frequency, fixedTime) {
|
||||
return [originalPosition[0] + sin(fixedTime * frequency) * amplitude,
|
||||
originalPosition[1], originalPosition[2]];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Load from the paused volume-fluid example with dofile("wheel-check.nut").
|
||||
// This defines an opt-in bounded check; loading alone performs no simulation.
|
||||
function verifyVolumeFluidWheel() {
|
||||
paused = true;
|
||||
local run = function(offset, ticks, emitWater) {
|
||||
rebuild(8);
|
||||
nozzle.description.origin[0] = offset;
|
||||
for (local i = 0; i < ticks; ++i) {
|
||||
wheelContacts += checked(wheelCoupling.step(wheelWorld, solver, 1.0 / 60.0, 4));
|
||||
wheelWorld.update(1.0 / 60.0);
|
||||
if (emitWater) advanceWheelJet(1.0 / 60.0);
|
||||
}
|
||||
return { speed = wheelBody.getAngularVelocityZ(), contacts = wheelContacts,
|
||||
particles = solver.getParticleCount() };
|
||||
};
|
||||
try {
|
||||
local dry = run(0.25, 60, false);
|
||||
if (!(fabs(dry.speed) <= 0.00001) || dry.contacts != 0 || dry.particles != 0)
|
||||
throw "Dry wheel moved or produced fluid contacts";
|
||||
local right = run(0.25, 240, true);
|
||||
if (!(right.speed < -0.001 && right.speed > -100.0) || right.contacts == 0)
|
||||
throw "Right jet did not drive a finite clockwise wheel motion";
|
||||
local left = run(-0.25, 240, true);
|
||||
if (!(left.speed > 0.001 && left.speed < 100.0) || left.contacts == 0)
|
||||
throw "Left jet did not reverse wheel motion";
|
||||
dirty = true;
|
||||
return { dry = dry, right = right, left = left };
|
||||
} catch (error) {
|
||||
failure = error.tostring();
|
||||
dirty = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Opt-in WhiskeyBottle acceptance. Loading this file is inert.
|
||||
function verifyVolumeFluidWhiskeyBottle() {
|
||||
paused=true;rebuild(17);
|
||||
local initial=checked(solver.snapshot());
|
||||
if(initial.sdfColliders.len()!=1 || initial.sdfColliders[0].sdf.distances.len()==0)
|
||||
throw "Bottle model SDF was not baked";
|
||||
local sampleCount=initial.sdfColliders[0].sdf.distances.len();
|
||||
local sampleFirst=initial.sdfColliders[0].sdf.distances[0];
|
||||
local uprightOutside=0;local peakContacts=0;
|
||||
for(local frame=0;frame<150;++frame) {
|
||||
checked(solver.step(1.0/120.0,1));
|
||||
if(frame%30==29) {local contacts=checked(solver.contacts()).len();if(contacts>peakContacts)peakContacts=contacts;}
|
||||
}
|
||||
foreach(particle in checked(solver.snapshot()).particles)
|
||||
if(fluidBottleOutsideCavity(particle,0.0))++uprightOutside;
|
||||
for(local frame=0;frame<300;++frame) {
|
||||
local next=bottle.angle+0.007;if(next>2.05)next=2.05;
|
||||
local velocity=(next-bottle.angle)*120.0;bottle.angle=next;
|
||||
bottle.pose=fluidBottlePose(bottle.angle,velocity);
|
||||
checked(solver.updateSdfColliderPoses([bottle.pose]));
|
||||
checked(solver.step(1.0/120.0,1));
|
||||
if(frame%30==29) {local contacts=checked(solver.contacts()).len();if(contacts>peakContacts)peakContacts=contacts;}
|
||||
}
|
||||
for(local frame=0;frame<300;++frame) {
|
||||
checked(solver.step(1.0/120.0,1));
|
||||
if(frame%30==29) {local contacts=checked(solver.contacts()).len();if(contacts>peakContacts)peakContacts=contacts;}
|
||||
}
|
||||
local poured=0;foreach(particle in checked(solver.snapshot()).particles)
|
||||
if(fluidBottleOutsideCavity(particle,bottle.angle))++poured;
|
||||
local after=checked(solver.snapshot());
|
||||
if(after.sdfColliders[0].sdf.distances.len()!=sampleCount ||
|
||||
after.sdfColliders[0].sdf.distances[0]!=sampleFirst)
|
||||
throw "Bottle pose update rebuilt or changed SDF samples";
|
||||
if(uprightOutside!=0 || peakContacts<=0 || poured<=0)
|
||||
throw "Bottle containment/pour mismatch upright="+uprightOutside+" contacts="+peakContacts+" poured="+poured;
|
||||
dirty=true;
|
||||
return "VOLUME_FLUID_WHISKEY_BOTTLE_PASS particles="+solver.getParticleCount()+
|
||||
" samples="+sampleCount+" uprightOutside="+uprightOutside+
|
||||
" contacts="+peakContacts+" poured="+poured;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Opt-in check for the package WorldSpaceGravity component behavior.
|
||||
function verifyVolumeFluidWorldGravity() {
|
||||
local definition = checked(fluids.volumeDefaults());
|
||||
definition.settings.gravity = [0.0, 0.0, 0.0];
|
||||
local particle = clone checked(fluids.volumeEmissionDefaults()).description.prototype;
|
||||
particle.position = [0.0, 1.0, 0.0];
|
||||
definition.particles.append(particle);
|
||||
local source = checked(fluids.newVolumeSimulator(definition));
|
||||
checked(source.setGravity(0.0, 9.81, 0.0));
|
||||
checked(source.step(1.0 / 120.0, 1));
|
||||
local state = checked(source.snapshot());
|
||||
if (state.particles[0].velocity[1] <= 0.0 || state.settings.gravity[1] != 9.81)
|
||||
throw "Dynamic world gravity was not applied";
|
||||
if (source.setGravity(1001.0, 0.0, 0.0).ok)
|
||||
throw "Out-of-range gravity accepted";
|
||||
local unchanged = checked(source.snapshot());
|
||||
if (unchanged.settings.gravity[1] != 9.81)
|
||||
throw "Invalid gravity changed solver settings";
|
||||
return true;
|
||||
}
|
||||
@@ -277,6 +277,12 @@ foreach(_mod IN LISTS EVE_ENABLED_MODULES)
|
||||
create_module(${EVE_MODULE_${_mod}_LIB} ${_mod})
|
||||
endforeach()
|
||||
|
||||
if(TARGET EVAnimation_editor AND _has_animation_editor_bridge)
|
||||
# Runtime clip/skeleton import is an optional bridge. Web authoring profiles
|
||||
# retain the standalone clip editor without pulling Assimp/model3d into WASM.
|
||||
target_compile_definitions(EVAnimation_editor PRIVATE EVE_ANIMATION_EDITOR_RUNTIME=1)
|
||||
endif()
|
||||
|
||||
# create_module() globs both physics bridge TUs; keep exactly one.
|
||||
eve_module_enabled(vehicle _has_vehicle)
|
||||
eve_module_enabled(physics _has_physics)
|
||||
@@ -303,6 +309,11 @@ foreach(_mod IN LISTS EVE_ENABLED_MODULES)
|
||||
target_compile_definitions(${EVE_MODULE_${_mod}_LIB} PUBLIC POCO_STATIC POCO_NO_AUTOMATIC_LIBS)
|
||||
endforeach()
|
||||
|
||||
eve_module_enabled(model3d _has_fluids_model3d)
|
||||
if(TARGET EVFluids AND _has_fluids_model3d)
|
||||
target_compile_definitions(EVFluids PRIVATE EVE_FLUIDS_HAS_MODEL3D=1)
|
||||
endif()
|
||||
|
||||
# --- per-module build settings ----------------------------------------------
|
||||
|
||||
if(TARGET EVAsset_graphics)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "action/ActionTimeline.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <set>
|
||||
#include <tuple>
|
||||
#include <utility>
|
||||
@@ -73,6 +74,51 @@ Value encodeState(const ActionNotifyState& state) {
|
||||
return Value(std::move(object));
|
||||
}
|
||||
|
||||
Value encodeAnimationSection(const ActionAnimationSection& section) {
|
||||
Value::Object object;
|
||||
object["id"] = section.id.format();
|
||||
object["animationUri"] = section.animationUri;
|
||||
object["startNs"] = section.start.nanoseconds();
|
||||
object["endNs"] = section.end.nanoseconds();
|
||||
object["blendInNs"] = section.blendIn.nanoseconds();
|
||||
object["sourceStartNs"] = section.sourceStart.nanoseconds();
|
||||
object["sourceEndNs"] = section.sourceEnd.nanoseconds();
|
||||
object["blendCurve"] = std::string(actionBlendCurveName(section.blendCurve));
|
||||
return Value(std::move(object));
|
||||
}
|
||||
|
||||
Result<ActionAnimationSection> decodeAnimationSection(const Value& value, const std::string& path,
|
||||
std::int64_t schemaVersion) {
|
||||
if (!value.isObject()) return invalid<ActionAnimationSection>("expected animation section object", path);
|
||||
auto id = logicalIdField(value, "id", path + ".id");
|
||||
if (!id) return Result<ActionAnimationSection>::failure(id.status());
|
||||
auto uri = stringField(value, "animationUri", path + ".animationUri");
|
||||
if (!uri) return Result<ActionAnimationSection>::failure(uri.status());
|
||||
auto start = intField(value, "startNs", path + ".startNs");
|
||||
if (!start) return Result<ActionAnimationSection>::failure(start.status());
|
||||
auto end = intField(value, "endNs", path + ".endNs");
|
||||
if (!end) return Result<ActionAnimationSection>::failure(end.status());
|
||||
auto blend = intField(value, "blendInNs", path + ".blendInNs");
|
||||
if (!blend) return Result<ActionAnimationSection>::failure(blend.status());
|
||||
ActionAnimationSection section{std::move(id).takeValue(), std::move(uri).takeValue(),
|
||||
Duration::fromNanoseconds(start.value()), Duration::fromNanoseconds(end.value()),
|
||||
Duration::fromNanoseconds(blend.value())};
|
||||
if (schemaVersion >= 3) {
|
||||
auto sourceStart = intField(value, "sourceStartNs", path + ".sourceStartNs");
|
||||
if (!sourceStart) return Result<ActionAnimationSection>::failure(sourceStart.status());
|
||||
auto sourceEnd = intField(value, "sourceEndNs", path + ".sourceEndNs");
|
||||
if (!sourceEnd) return Result<ActionAnimationSection>::failure(sourceEnd.status());
|
||||
auto blendCurve = stringField(value, "blendCurve", path + ".blendCurve");
|
||||
if (!blendCurve) return Result<ActionAnimationSection>::failure(blendCurve.status());
|
||||
auto parsedCurve = actionBlendCurveFromName(blendCurve.value());
|
||||
if (!parsedCurve) return invalid<ActionAnimationSection>("unknown animation blend curve", path + ".blendCurve");
|
||||
section.sourceStart = Duration::fromNanoseconds(sourceStart.value());
|
||||
section.sourceEnd = Duration::fromNanoseconds(sourceEnd.value());
|
||||
section.blendCurve = *parsedCurve;
|
||||
}
|
||||
return Result<ActionAnimationSection>::success(std::move(section));
|
||||
}
|
||||
|
||||
Result<ActionNotify> decodeNotify(const Value& value, const std::string& path) {
|
||||
if (!value.isObject()) return invalid<ActionNotify>("expected notify object", path);
|
||||
auto id = logicalIdField(value, "id", path + ".id");
|
||||
@@ -111,6 +157,20 @@ bool inWindow(Duration value, Duration previous, Duration current, bool includeP
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string_view actionBlendCurveName(ActionBlendCurve curve) noexcept {
|
||||
switch (curve) {
|
||||
case ActionBlendCurve::Linear: return "linear";
|
||||
case ActionBlendCurve::EaseInOut: return "ease-in-out";
|
||||
}
|
||||
return "ease-in-out";
|
||||
}
|
||||
|
||||
std::optional<ActionBlendCurve> actionBlendCurveFromName(std::string_view name) noexcept {
|
||||
if (name == "linear") return ActionBlendCurve::Linear;
|
||||
if (name == "ease-in-out") return ActionBlendCurve::EaseInOut;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string_view actionTrackKindName(ActionTrackKind kind) noexcept {
|
||||
switch (kind) {
|
||||
case ActionTrackKind::Animation: return "animation";
|
||||
@@ -138,8 +198,40 @@ Result<void> ActionTimeline::validate() const {
|
||||
return invalid("unsupported action timeline schema version", "schemaVersion");
|
||||
if (!actionId.isValid()) return invalid("action timeline requires a valid action id", "actionId");
|
||||
if (duration.nanoseconds() < 0) return invalid("timeline duration must be non-negative", "durationNs");
|
||||
if (!std::isfinite(montage.basePlayRate) || montage.basePlayRate <= 0.0)
|
||||
return invalid("montage base play rate must be positive and finite", "montage.basePlayRate");
|
||||
if (montage.defaultBlendIn < Duration::zero() || montage.defaultBlendOut < Duration::zero() ||
|
||||
montage.blendOutOffset < Duration::zero())
|
||||
return invalid("montage blend durations must be non-negative", "montage");
|
||||
for (std::size_t index = 0; index < splitTimestamps.size(); ++index) {
|
||||
if (splitTimestamps[index] <= Duration::zero() || splitTimestamps[index] >= duration ||
|
||||
(index > 0 && splitTimestamps[index - 1] >= splitTimestamps[index]))
|
||||
return invalid("physical section splits must be strictly ordered inside the timeline",
|
||||
"splitTimestampsNs[" + std::to_string(index) + "]");
|
||||
}
|
||||
|
||||
std::set<std::string> ids;
|
||||
for (std::size_t index = 0; index < animationSections.size(); ++index) {
|
||||
const auto& section = animationSections[index];
|
||||
const std::string path = "animationSections[" + std::to_string(index) + "]";
|
||||
if (!section.id.isValid()) return invalid("animation section id is invalid", path + ".id");
|
||||
if (!ids.insert(section.id.format()).second) return invalid("timeline item ids must be unique", path + ".id");
|
||||
if (section.animationUri.empty()) return invalid("animation section requires a URI", path + ".animationUri");
|
||||
if (section.start < Duration::zero() || section.end <= section.start || section.end > duration)
|
||||
return invalid("animation section range is invalid", path);
|
||||
if (section.blendIn < Duration::zero() ||
|
||||
section.blendIn.nanoseconds() > section.end.nanoseconds() - section.start.nanoseconds())
|
||||
return invalid("animation section blend-in is outside its range", path + ".blendInNs");
|
||||
if (section.sourceStart < Duration::zero() || section.sourceEnd < Duration::zero() ||
|
||||
(!section.sourceEnd.isZero() && section.sourceEnd <= section.sourceStart))
|
||||
return invalid("animation section source trim is invalid", path + ".sourceStartNs");
|
||||
if (index > 0) {
|
||||
const auto& previous = animationSections[index - 1];
|
||||
if (previous.start > section.start)
|
||||
return invalid("animation sections must be sorted by start time", path + ".startNs");
|
||||
if (previous.end > section.start) return invalid("animation sections must not overlap", path + ".startNs");
|
||||
}
|
||||
}
|
||||
for (std::size_t trackIndex = 0; trackIndex < tracks.size(); ++trackIndex) {
|
||||
const auto& track = tracks[trackIndex];
|
||||
const std::string path = "tracks[" + std::to_string(trackIndex) + "]";
|
||||
@@ -207,6 +299,12 @@ Result<Value> ActionTimeline::toValue() const {
|
||||
auto valid = validate();
|
||||
if (!valid) return Result<Value>::failure(valid.status());
|
||||
Value::Array encodedTracks;
|
||||
Value::Array encodedSections;
|
||||
Value::Array encodedSplits;
|
||||
encodedSections.reserve(animationSections.size());
|
||||
for (const auto& section : animationSections) encodedSections.push_back(encodeAnimationSection(section));
|
||||
encodedSplits.reserve(splitTimestamps.size());
|
||||
for (const Duration split : splitTimestamps) encodedSplits.emplace_back(split.nanoseconds());
|
||||
encodedTracks.reserve(tracks.size());
|
||||
for (const auto& track : tracks) {
|
||||
Value::Array notifies;
|
||||
@@ -224,13 +322,25 @@ Result<Value> ActionTimeline::toValue() const {
|
||||
encodedTracks.emplace_back(std::move(object));
|
||||
}
|
||||
Value::Object root;
|
||||
root["schema"] = std::string(kActionTimelineSchemaId);
|
||||
root["schemaVersion"] = static_cast<std::int64_t>(schemaVersion.value());
|
||||
root["actionId"] = actionId.format();
|
||||
root["durationNs"] = duration.nanoseconds();
|
||||
root["animationUri"] = animationUri;
|
||||
root["tracks"] = Value(std::move(encodedTracks));
|
||||
root["metadata"] = Value(metadata);
|
||||
root["schema"] = std::string(kActionTimelineSchemaId);
|
||||
root["schemaVersion"] = static_cast<std::int64_t>(schemaVersion.value());
|
||||
root["actionId"] = actionId.format();
|
||||
root["durationNs"] = duration.nanoseconds();
|
||||
root["animationUri"] = animationSections.empty() ? animationUri : animationSections.front().animationUri;
|
||||
root["animationSections"] = Value(std::move(encodedSections));
|
||||
root["splitTimestampsNs"] = Value(std::move(encodedSplits));
|
||||
root["montage"] = Value::Object{{"basePlayRate", montage.basePlayRate},
|
||||
{"looping", montage.looping},
|
||||
{"footIk", montage.footIk},
|
||||
{"animationLayer", static_cast<std::int64_t>(montage.animationLayer)},
|
||||
{"defaultBlendInNs", montage.defaultBlendIn.nanoseconds()},
|
||||
{"defaultBlendOutNs", montage.defaultBlendOut.nanoseconds()},
|
||||
{"blendOutOffsetNs", montage.blendOutOffset.nanoseconds()},
|
||||
{"rootMotionHorizontal", montage.rootMotionHorizontal},
|
||||
{"rootMotionVertical", montage.rootMotionVertical},
|
||||
{"rootMotionRotation", montage.rootMotionRotation}};
|
||||
root["tracks"] = Value(std::move(encodedTracks));
|
||||
root["metadata"] = Value(metadata);
|
||||
return Result<Value>::success(Value(std::move(root)));
|
||||
}
|
||||
|
||||
@@ -255,12 +365,74 @@ Result<ActionTimeline> ActionTimeline::fromValue(const Value& value) {
|
||||
return invalid<ActionTimeline>("expected metadata object", "metadata");
|
||||
|
||||
ActionTimeline candidate;
|
||||
candidate.schemaVersion = SchemaVersion(static_cast<std::uint64_t>(version.value()));
|
||||
if (version.value() < 1 || version.value() > static_cast<std::int64_t>(kActionTimelineSchemaVersion))
|
||||
return invalid<ActionTimeline>("unsupported action timeline schema version", "schemaVersion");
|
||||
candidate.schemaVersion = SchemaVersion(kActionTimelineSchemaVersion);
|
||||
candidate.actionId = std::move(actionId).takeValue();
|
||||
candidate.duration = Duration::fromNanoseconds(duration.value());
|
||||
candidate.animationUri = std::move(animationUri).takeValue();
|
||||
candidate.metadata = *metadataValue->getIf<Value::Object>();
|
||||
const auto& tracks = *tracksValue->getIf<Value::Array>();
|
||||
if (version.value() == 1) {
|
||||
if (!candidate.animationUri.empty() && !candidate.duration.isZero()) {
|
||||
candidate.animationSections.push_back({*LogicalId::fromParts("animation-section", "legacy"),
|
||||
candidate.animationUri, Duration::zero(), candidate.duration,
|
||||
Duration::zero()});
|
||||
}
|
||||
} else {
|
||||
const Value* sectionsValue = field(value, "animationSections");
|
||||
if (!sectionsValue || !sectionsValue->isArray())
|
||||
return invalid<ActionTimeline>("expected animationSections array", "animationSections");
|
||||
const auto& sections = *sectionsValue->getIf<Value::Array>();
|
||||
for (std::size_t index = 0; index < sections.size(); ++index) {
|
||||
auto decoded = decodeAnimationSection(sections[index], "animationSections[" + std::to_string(index) + "]",
|
||||
version.value());
|
||||
if (!decoded) return Result<ActionTimeline>::failure(decoded.status());
|
||||
candidate.animationSections.push_back(std::move(decoded).takeValue());
|
||||
}
|
||||
if (!candidate.animationSections.empty())
|
||||
candidate.animationUri = candidate.animationSections.front().animationUri;
|
||||
if (version.value() >= 3) {
|
||||
const Value* splitsValue = field(value, "splitTimestampsNs");
|
||||
if (!splitsValue || !splitsValue->isArray())
|
||||
return invalid<ActionTimeline>("expected splitTimestampsNs array", "splitTimestampsNs");
|
||||
const auto& splits = *splitsValue->getIf<Value::Array>();
|
||||
for (std::size_t index = 0; index < splits.size(); ++index) {
|
||||
if (!splits[index].isInt64())
|
||||
return invalid<ActionTimeline>("expected physical section timestamp",
|
||||
"splitTimestampsNs[" + std::to_string(index) + "]");
|
||||
candidate.splitTimestamps.push_back(Duration::fromNanoseconds(splits[index].asInt()));
|
||||
}
|
||||
const Value* montageValue = field(value, "montage");
|
||||
if (!montageValue || !montageValue->isObject())
|
||||
return invalid<ActionTimeline>("expected montage settings object", "montage");
|
||||
const Value* basePlayRate = field(*montageValue, "basePlayRate");
|
||||
auto looping = boolField(*montageValue, "looping", "montage.looping");
|
||||
auto footIk = boolField(*montageValue, "footIk", "montage.footIk");
|
||||
auto layer = intField(*montageValue, "animationLayer", "montage.animationLayer");
|
||||
auto blendIn = intField(*montageValue, "defaultBlendInNs", "montage.defaultBlendInNs");
|
||||
auto blendOut = intField(*montageValue, "defaultBlendOutNs", "montage.defaultBlendOutNs");
|
||||
auto blendOutOffset = intField(*montageValue, "blendOutOffsetNs", "montage.blendOutOffsetNs");
|
||||
auto rootHorizontal = boolField(*montageValue, "rootMotionHorizontal", "montage.rootMotionHorizontal");
|
||||
auto rootVertical = boolField(*montageValue, "rootMotionVertical", "montage.rootMotionVertical");
|
||||
auto rootRotation = boolField(*montageValue, "rootMotionRotation", "montage.rootMotionRotation");
|
||||
if (!basePlayRate || (!basePlayRate->isDouble() && !basePlayRate->isInt64()) || !looping || !footIk ||
|
||||
!layer || !blendIn || !blendOut || !blendOutOffset || !rootHorizontal || !rootVertical ||
|
||||
!rootRotation || layer.value() < 0)
|
||||
return invalid<ActionTimeline>("montage settings are invalid", "montage");
|
||||
candidate.montage.basePlayRate =
|
||||
basePlayRate->isDouble() ? basePlayRate->asDouble() : static_cast<double>(basePlayRate->asInt());
|
||||
candidate.montage.looping = looping.value();
|
||||
candidate.montage.footIk = footIk.value();
|
||||
candidate.montage.animationLayer = static_cast<std::uint32_t>(layer.value());
|
||||
candidate.montage.defaultBlendIn = Duration::fromNanoseconds(blendIn.value());
|
||||
candidate.montage.defaultBlendOut = Duration::fromNanoseconds(blendOut.value());
|
||||
candidate.montage.blendOutOffset = Duration::fromNanoseconds(blendOutOffset.value());
|
||||
candidate.montage.rootMotionHorizontal = rootHorizontal.value();
|
||||
candidate.montage.rootMotionVertical = rootVertical.value();
|
||||
candidate.montage.rootMotionRotation = rootRotation.value();
|
||||
}
|
||||
}
|
||||
const auto& tracks = *tracksValue->getIf<Value::Array>();
|
||||
for (std::size_t trackIndex = 0; trackIndex < tracks.size(); ++trackIndex) {
|
||||
const Value& trackValue = tracks[trackIndex];
|
||||
const std::string path = "tracks[" + std::to_string(trackIndex) + "]";
|
||||
@@ -307,4 +479,12 @@ Result<ActionTimeline> ActionTimeline::fromValue(const Value& value) {
|
||||
return Result<ActionTimeline>::success(std::move(candidate));
|
||||
}
|
||||
|
||||
Result<std::pair<Duration, Duration>> ActionTimeline::sectionRange(std::size_t index) const {
|
||||
if (index >= sectionCount())
|
||||
return invalid<std::pair<Duration, Duration>>("physical section index is outside the timeline", "index");
|
||||
const Duration start = index == 0 ? Duration::zero() : splitTimestamps[index - 1];
|
||||
const Duration end = index < splitTimestamps.size() ? splitTimestamps[index] : duration;
|
||||
return Result<std::pair<Duration, Duration>>::success({start, end});
|
||||
}
|
||||
|
||||
} // namespace eve::action
|
||||
@@ -12,7 +12,9 @@
|
||||
#include "common/Value.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::action {
|
||||
@@ -20,7 +22,56 @@ namespace eve::action {
|
||||
/** @brief Canonical schema identifier for persisted action timelines. */
|
||||
inline constexpr std::string_view kActionTimelineSchemaId = "eve.action.timeline";
|
||||
/** @brief Current action timeline schema version. */
|
||||
inline constexpr std::uint64_t kActionTimelineSchemaVersion = 1;
|
||||
inline constexpr std::uint64_t kActionTimelineSchemaVersion = 3;
|
||||
|
||||
/** @brief Built-in deterministic cross-fade curve evaluated without asset callbacks. */
|
||||
enum class ActionBlendCurve : std::uint8_t {
|
||||
Linear,
|
||||
EaseInOut,
|
||||
};
|
||||
|
||||
/** @brief Montage-wide presentation settings persisted with the action timeline. */
|
||||
struct ActionMontageSettings {
|
||||
double basePlayRate = 1.0;
|
||||
bool looping = false;
|
||||
bool footIk = false;
|
||||
std::uint32_t animationLayer = 0;
|
||||
Duration defaultBlendIn = Duration::zero();
|
||||
Duration defaultBlendOut = Duration::zero();
|
||||
Duration blendOutOffset = Duration::zero();
|
||||
bool rootMotionHorizontal = true;
|
||||
bool rootMotionVertical = true;
|
||||
bool rootMotionRotation = true;
|
||||
|
||||
auto operator<=>(const ActionMontageSettings&) const = default;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Objective animation segment placed on the montage presentation lane.
|
||||
*
|
||||
* Sections describe only clip geometry and blending. Gameplay phase meaning
|
||||
* remains owned by ActionRuntime and must not be inferred from section names.
|
||||
*/
|
||||
struct ActionAnimationSection {
|
||||
LogicalId id;
|
||||
std::string animationUri;
|
||||
Duration start = Duration::zero();
|
||||
Duration end = Duration::zero();
|
||||
Duration blendIn = Duration::zero();
|
||||
/** @brief Clip-local trim-in timestamp. */
|
||||
Duration sourceStart = Duration::zero();
|
||||
/** @brief Clip-local trim-out timestamp; zero means the physical clip end. */
|
||||
Duration sourceEnd = Duration::zero();
|
||||
/** @brief Curve used for the incoming cross-fade. */
|
||||
ActionBlendCurve blendCurve = ActionBlendCurve::EaseInOut;
|
||||
|
||||
auto operator<=>(const ActionAnimationSection&) const = default;
|
||||
};
|
||||
|
||||
/** @brief Stable persisted name for a montage cross-fade curve. */
|
||||
[[nodiscard]] std::string_view actionBlendCurveName(ActionBlendCurve curve) noexcept;
|
||||
/** @brief Parse a persisted montage cross-fade curve name. */
|
||||
[[nodiscard]] std::optional<ActionBlendCurve> actionBlendCurveFromName(std::string_view name) noexcept;
|
||||
|
||||
/** @brief Semantic lane kind; hosts choose presentation without changing data. */
|
||||
enum class ActionTrackKind : std::uint8_t {
|
||||
@@ -101,15 +152,24 @@ struct ActionTimelineEvent {
|
||||
* adapters; this L1 module never depends on those presentation modules.
|
||||
*/
|
||||
struct ActionTimeline {
|
||||
SchemaVersion schemaVersion{kActionTimelineSchemaVersion};
|
||||
LogicalId actionId;
|
||||
Duration duration = Duration::zero();
|
||||
std::string animationUri;
|
||||
SchemaVersion schemaVersion{kActionTimelineSchemaVersion};
|
||||
LogicalId actionId;
|
||||
Duration duration = Duration::zero();
|
||||
/** @brief Compatibility-only single-clip projection; v2 runtime uses animationSections. */
|
||||
std::string animationUri;
|
||||
std::vector<ActionAnimationSection> animationSections;
|
||||
ActionMontageSettings montage;
|
||||
/** @brief Sorted objective split timestamps forming N+1 physical sections. */
|
||||
std::vector<Duration> splitTimestamps;
|
||||
std::vector<ActionTrack> tracks;
|
||||
Value::Object metadata;
|
||||
|
||||
/** @brief Validate ids, ranges, uniqueness and deterministic ordering. */
|
||||
[[nodiscard]] Result<void> validate() const;
|
||||
/** @brief Number of objective physical sections. */
|
||||
[[nodiscard]] std::size_t sectionCount() const noexcept { return splitTimestamps.size() + 1; }
|
||||
/** @brief Resolve one physical section to its inclusive start and exclusive end. */
|
||||
[[nodiscard]] Result<std::pair<Duration, Duration>> sectionRange(std::size_t index) const;
|
||||
|
||||
/**
|
||||
* @brief Sample all boundaries in `(previous,current]` in stable order.
|
||||
|
||||
@@ -49,16 +49,23 @@ Result<action::ActionPreviewFrame> ActionPreviewController::buildFrame(
|
||||
action::ActionPreviewReason reason, Duration previous, Duration current,
|
||||
const std::vector<action::ActionTimelineEvent>& events) const {
|
||||
action::ActionPreviewFrame frame;
|
||||
frame.reason = reason;
|
||||
frame.animationUri = editor_.target().timeline().animationUri;
|
||||
frame.previous = previous;
|
||||
frame.current = current;
|
||||
frame.reason = reason;
|
||||
frame.animationUri = editor_.target().timeline().animationUri;
|
||||
Duration rootMotionDuration = editor_.target().timeline().duration;
|
||||
for (const auto& section : editor_.target().timeline().animationSections) {
|
||||
if (current >= section.start && current < section.end) {
|
||||
frame.animationUri = section.animationUri;
|
||||
rootMotionDuration = Duration::fromNanoseconds(section.end.nanoseconds() - section.start.nanoseconds());
|
||||
break;
|
||||
}
|
||||
}
|
||||
frame.previous = previous;
|
||||
frame.current = current;
|
||||
frame.cues.reserve(events.size());
|
||||
for (const auto& event : events)
|
||||
frame.cues.push_back({cueKind(event), event.itemId, event.type, event.time, event.payload});
|
||||
if (rootMotion_) {
|
||||
auto path = rootMotion_->sampleRootMotion(frame.animationUri, editor_.target().timeline().duration,
|
||||
rootMotionSampleCount_);
|
||||
auto path = rootMotion_->sampleRootMotion(frame.animationUri, rootMotionDuration, rootMotionSampleCount_);
|
||||
if (!path) return Result<action::ActionPreviewFrame>::failure(path.status());
|
||||
frame.rootMotionState = action::RootMotionPreviewState::Available;
|
||||
frame.rootMotionPath = std::move(path).takeValue();
|
||||
@@ -109,8 +116,8 @@ EditorResult<std::size_t> ActionPreviewController::update(Duration delta) {
|
||||
"Could not prepare timeline preview advance");
|
||||
if (plan.code() == EditorStatus::NoOp)
|
||||
return EditorResult<std::size_t>::success(0, Status::success(EditorStatus::NoOp));
|
||||
auto frame = buildFrame(action::ActionPreviewReason::Advance, plan.value().previous,
|
||||
plan.value().current, plan.value().events);
|
||||
auto frame = buildFrame(action::ActionPreviewReason::Advance, plan.value().previous, plan.value().current,
|
||||
plan.value().events);
|
||||
if (!frame)
|
||||
return previewErrorValue<std::size_t>(EditorStatus::Rejected, "editor.action.preview.root-motion",
|
||||
diagnosticMessage(frame.status(), "Could not sample root motion"));
|
||||
|
||||
@@ -43,6 +43,13 @@ void sortTrack(action::ActionTrack& track) {
|
||||
});
|
||||
}
|
||||
|
||||
void sortAnimationSections(action::ActionTimeline& timeline) {
|
||||
std::stable_sort(
|
||||
timeline.animationSections.begin(), timeline.animationSections.end(), [](const auto& left, const auto& right) {
|
||||
return left.start == right.start ? left.id.format() < right.id.format() : left.start < right.start;
|
||||
});
|
||||
}
|
||||
|
||||
EditorValue replacementPayload(const std::string& json) {
|
||||
EditorValue::Object payload;
|
||||
payload["json"] = json;
|
||||
@@ -72,12 +79,12 @@ action::ActionTimeline seedTimeline(const std::string& targetId) {
|
||||
PropertyDescriptor property(const char* path, const char* label, PropertyType type, EditorValue defaultValue,
|
||||
double minimum = 0.0, double maximum = 1e9) {
|
||||
PropertyDescriptor descriptor;
|
||||
descriptor.path = PropertyPath(path);
|
||||
descriptor.displayNameKey = label;
|
||||
descriptor.category = "timeline";
|
||||
descriptor.type = type;
|
||||
descriptor.flags = PropertyFlag::Runtime;
|
||||
descriptor.defaultValue = std::move(defaultValue);
|
||||
descriptor.path = PropertyPath(path);
|
||||
descriptor.displayNameKey = label;
|
||||
descriptor.category = "timeline";
|
||||
descriptor.type = type;
|
||||
descriptor.flags = PropertyFlag::Runtime;
|
||||
descriptor.defaultValue = std::move(defaultValue);
|
||||
descriptor.numeric.minimum = minimum;
|
||||
descriptor.numeric.maximum = maximum;
|
||||
return descriptor;
|
||||
@@ -94,7 +101,10 @@ ActionTimelineTarget::ActionTimelineTarget(std::string targetId, action::ActionT
|
||||
: targetId_(std::move(targetId)), timeline_(std::move(timeline)) {}
|
||||
|
||||
TargetDescriptor ActionTimelineTarget::describe() const {
|
||||
return {TargetId(targetId_), "action-timeline", revision_, false,
|
||||
return {TargetId(targetId_),
|
||||
"action-timeline",
|
||||
revision_,
|
||||
false,
|
||||
{propertyCapabilityId(), eve::editing::IEditingSnapshotProvider::editingCapabilityId()}};
|
||||
}
|
||||
|
||||
@@ -200,26 +210,26 @@ eve::Result<eve::Revision> ActionTimelineTarget::currentRevision(const Selection
|
||||
|
||||
PropertySchema ActionTimelineTarget::schema(const SelectionSnapshot&) const {
|
||||
PropertySchema schema;
|
||||
schema.typeId = "action.timeline";
|
||||
schema.version = 1;
|
||||
auto animation = property("timeline.animationUri", "editor.action.animation-uri",
|
||||
PropertyType::AssetRef, "");
|
||||
animation.assetTypeFilters = {"animation", "model3d"};
|
||||
schema.properties = {property("timeline.actionId", "editor.action.action-id", PropertyType::String,
|
||||
seedTimeline(targetId_).actionId.format()),
|
||||
property("timeline.durationSeconds", "editor.action.duration", PropertyType::Float,
|
||||
1.0, 0.0, 3600.0),
|
||||
std::move(animation)};
|
||||
schema.typeId = "action.timeline";
|
||||
schema.version = 1;
|
||||
auto animation = property("timeline.animationUri", "editor.action.animation-uri", PropertyType::AssetRef, "");
|
||||
animation.assetTypeFilters = {"animation", "model3d"};
|
||||
schema.properties = {
|
||||
property("timeline.actionId", "editor.action.action-id", PropertyType::String,
|
||||
seedTimeline(targetId_).actionId.format()),
|
||||
property("timeline.durationSeconds", "editor.action.duration", PropertyType::Float, 1.0, 0.0, 3600.0),
|
||||
std::move(animation)};
|
||||
return schema;
|
||||
}
|
||||
|
||||
PropertyReadResult ActionTimelineTarget::read(const SelectionSnapshot& selection, const PropertyPath& path) const {
|
||||
if (!matches(selection) || !schema(selection).find(path)) return {};
|
||||
if (path == PropertyPath("timeline.actionId"))
|
||||
return {PropertyReadState::Value, timeline_.actionId.format(), {}};
|
||||
if (path == PropertyPath("timeline.actionId")) return {PropertyReadState::Value, timeline_.actionId.format(), {}};
|
||||
if (path == PropertyPath("timeline.durationSeconds"))
|
||||
return {PropertyReadState::Value, timeline_.duration.seconds(), {}};
|
||||
return {PropertyReadState::Value, timeline_.animationUri, {}};
|
||||
const std::string& projectedUri =
|
||||
timeline_.animationSections.empty() ? timeline_.animationUri : timeline_.animationSections.front().animationUri;
|
||||
return {PropertyReadState::Value, projectedUri, {}};
|
||||
}
|
||||
|
||||
EditorResult<DomainOperation> ActionTimelineTarget::makeSet(const SelectionSnapshot& selection,
|
||||
@@ -255,6 +265,7 @@ EditorResult<DomainOperation> ActionTimelineTarget::makeSet(const SelectionSnaps
|
||||
return editorError<DomainOperation>(EditorStatus::Rejected, "editor.action.timeline.animation-uri",
|
||||
"Animation URI must be a string");
|
||||
candidate.animationUri = *uri;
|
||||
if (!candidate.animationSections.empty()) candidate.animationSections.front().animationUri = *uri;
|
||||
}
|
||||
auto valid = candidate.validate();
|
||||
if (!valid)
|
||||
@@ -390,6 +401,108 @@ EditorResult<void> ActionTimelineEditor::addTrack(action::ActionTrack track) {
|
||||
return commit(std::move(candidate), "Add action track", "action.timeline.track.add");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::removeTrack(const LogicalId& trackId) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
const auto found = std::find_if(candidate.tracks.begin(), candidate.tracks.end(),
|
||||
[&](const auto& track) { return track.id == trackId; });
|
||||
if (found == candidate.tracks.end())
|
||||
return rejected("editor.action.timeline.track-not-found", "Action track was not found");
|
||||
for (const auto& notify : found->notifies) selection_.erase(notify.id.format());
|
||||
for (const auto& state : found->states) selection_.erase(state.id.format());
|
||||
candidate.tracks.erase(found);
|
||||
return commit(std::move(candidate), "Delete action track", "action.timeline.track.delete");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::renameTrack(const LogicalId& trackId, std::string label) {
|
||||
if (label.empty()) return rejected("editor.action.timeline.track-label", "Action track label must not be empty");
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
auto* track = findTrack(candidate, trackId);
|
||||
if (!track) return rejected("editor.action.timeline.track-not-found", "Action track was not found");
|
||||
track->label = std::move(label);
|
||||
return commit(std::move(candidate), "Rename action track", "action.timeline.track.rename");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::addAnimationSection(action::ActionAnimationSection section) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
candidate.animationSections.push_back(std::move(section));
|
||||
sortAnimationSections(candidate);
|
||||
return commit(std::move(candidate), "Add montage animation section", "action.timeline.animation-section.add");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::moveAnimationSection(const LogicalId& sectionId, Duration delta) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
auto found = std::find_if(candidate.animationSections.begin(), candidate.animationSections.end(),
|
||||
[&](const auto& section) { return section.id == sectionId; });
|
||||
if (found == candidate.animationSections.end())
|
||||
return rejected("editor.action.timeline.animation-section-not-found", "Animation section was not found");
|
||||
auto start = found->start.tryAdd(delta);
|
||||
auto end = found->end.tryAdd(delta);
|
||||
if (!start || !end) return rejected("editor.action.timeline.time-overflow", "Animation section time overflowed");
|
||||
found->start = std::move(start).takeValue();
|
||||
found->end = std::move(end).takeValue();
|
||||
sortAnimationSections(candidate);
|
||||
return commit(std::move(candidate), "Move montage animation section", "action.timeline.animation-section.move");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::resizeAnimationSection(const LogicalId& sectionId, Duration start,
|
||||
Duration end, Duration blendIn) {
|
||||
const auto found =
|
||||
std::find_if(target_.timeline().animationSections.begin(), target_.timeline().animationSections.end(),
|
||||
[&](const auto& section) { return section.id == sectionId; });
|
||||
if (found == target_.timeline().animationSections.end())
|
||||
return rejected("editor.action.timeline.animation-section-not-found", "Animation section was not found");
|
||||
return editAnimationSection(sectionId, start, end, blendIn, found->animationUri);
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::editAnimationSection(const LogicalId& sectionId, Duration start, Duration end,
|
||||
Duration blendIn, std::string animationUri) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
auto found = std::find_if(candidate.animationSections.begin(), candidate.animationSections.end(),
|
||||
[&](const auto& section) { return section.id == sectionId; });
|
||||
if (found == candidate.animationSections.end())
|
||||
return rejected("editor.action.timeline.animation-section-not-found", "Animation section was not found");
|
||||
found->start = start;
|
||||
found->end = end;
|
||||
found->blendIn = blendIn;
|
||||
found->animationUri = std::move(animationUri);
|
||||
sortAnimationSections(candidate);
|
||||
return commit(std::move(candidate), "Resize montage animation section", "action.timeline.animation-section.resize");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::editAnimationSectionSource(const LogicalId& sectionId, Duration sourceStart,
|
||||
Duration sourceEnd,
|
||||
action::ActionBlendCurve blendCurve) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
auto found = std::find_if(candidate.animationSections.begin(), candidate.animationSections.end(),
|
||||
[&](const auto& section) { return section.id == sectionId; });
|
||||
if (found == candidate.animationSections.end())
|
||||
return rejected("editor.action.timeline.animation-section-not-found", "Animation section was not found");
|
||||
found->sourceStart = sourceStart;
|
||||
found->sourceEnd = sourceEnd;
|
||||
found->blendCurve = blendCurve;
|
||||
return commit(std::move(candidate), "Edit montage clip trim", "action.timeline.animation-section.trim");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::editAnimationSectionFull(action::ActionAnimationSection section) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
auto found = std::find_if(candidate.animationSections.begin(), candidate.animationSections.end(),
|
||||
[&](const auto& value) { return value.id == section.id; });
|
||||
if (found == candidate.animationSections.end())
|
||||
return rejected("editor.action.timeline.animation-section-not-found", "Animation section was not found");
|
||||
*found = std::move(section);
|
||||
sortAnimationSections(candidate);
|
||||
return commit(std::move(candidate), "Edit montage animation section", "action.timeline.animation-section.edit");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::removeAnimationSection(const LogicalId& sectionId) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
const auto before = candidate.animationSections.size();
|
||||
std::erase_if(candidate.animationSections, [&](const auto& section) { return section.id == sectionId; });
|
||||
if (candidate.animationSections.size() == before)
|
||||
return rejected("editor.action.timeline.animation-section-not-found", "Animation section was not found");
|
||||
return commit(std::move(candidate), "Remove montage animation section", "action.timeline.animation-section.remove");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::addNotify(const LogicalId& trackId, action::ActionNotify notify) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
auto* track = findTrack(candidate, trackId);
|
||||
@@ -510,6 +623,14 @@ EditorResult<void> ActionTimelineEditor::editItem(const LogicalId& itemId, Durat
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::removeItem(const LogicalId& itemId) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
const auto section = std::find_if(candidate.animationSections.begin(), candidate.animationSections.end(),
|
||||
[&](const auto& value) { return value.id == itemId; });
|
||||
if (section != candidate.animationSections.end()) {
|
||||
candidate.animationSections.erase(section);
|
||||
selection_.erase(itemId.format());
|
||||
return commit(std::move(candidate), "Delete montage animation section",
|
||||
"action.timeline.animation-section.delete");
|
||||
}
|
||||
for (auto& track : candidate.tracks) {
|
||||
const auto notify = std::find_if(track.notifies.begin(), track.notifies.end(),
|
||||
[&](const auto& value) { return value.id == itemId; });
|
||||
@@ -540,12 +661,23 @@ EditorResult<void> ActionTimelineEditor::setTrackMuted(const LogicalId& trackId,
|
||||
"action.timeline.track.mute");
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::setTrackLocked(const LogicalId& trackId, bool locked) {
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
auto* track = findTrack(candidate, trackId);
|
||||
if (!track) return rejected("editor.action.timeline.track-not-found", "Action track was not found");
|
||||
track->locked = locked;
|
||||
return commit(std::move(candidate), locked ? "Lock action track" : "Unlock action track",
|
||||
"action.timeline.track.lock");
|
||||
}
|
||||
|
||||
EditorResult<std::size_t> ActionTimelineEditor::boxSelect(Duration start, Duration end) {
|
||||
if (start < Duration::zero() || end < start || end > target_.timeline().duration)
|
||||
return eve::editing::failed<std::size_t>(EditorStatus::Rejected,
|
||||
RuleId("editor.action.timeline.selection-range"),
|
||||
"Selection range is outside the timeline");
|
||||
RuleId("editor.action.timeline.selection-range"),
|
||||
"Selection range is outside the timeline");
|
||||
selection_.clear();
|
||||
for (const auto& section : target_.timeline().animationSections)
|
||||
if (section.end >= start && section.start <= end) selection_.insert(section.id.format());
|
||||
for (const auto& track : target_.timeline().tracks) {
|
||||
for (const auto& notify : track.notifies)
|
||||
if (notify.time >= start && notify.time <= end) selection_.insert(notify.id.format());
|
||||
@@ -556,7 +688,8 @@ EditorResult<std::size_t> ActionTimelineEditor::boxSelect(Duration start, Durati
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineEditor::selectItem(const LogicalId& itemId, bool additive) {
|
||||
bool found = false;
|
||||
bool found = std::any_of(target_.timeline().animationSections.begin(), target_.timeline().animationSections.end(),
|
||||
[&](const auto& section) { return section.id == itemId; });
|
||||
for (const auto& track : target_.timeline().tracks) {
|
||||
found = found || std::any_of(track.notifies.begin(), track.notifies.end(),
|
||||
[&](const auto& item) { return item.id == itemId; });
|
||||
@@ -581,11 +714,16 @@ std::vector<LogicalId> ActionTimelineEditor::selectedItemIds() const {
|
||||
|
||||
EditorResult<std::size_t> ActionTimelineEditor::copySelection() {
|
||||
clipboard_.clear();
|
||||
for (const auto& section : target_.timeline().animationSections)
|
||||
if (selection_.contains(section.id.format()))
|
||||
clipboard_.push_back({ClipboardItem::Kind::AnimationSection, {}, section, {}, {}});
|
||||
for (const auto& track : target_.timeline().tracks) {
|
||||
for (const auto& notify : track.notifies)
|
||||
if (selection_.contains(notify.id.format())) clipboard_.push_back({track.id, false, notify, {}});
|
||||
if (selection_.contains(notify.id.format()))
|
||||
clipboard_.push_back({ClipboardItem::Kind::Notify, track.id, {}, notify, {}});
|
||||
for (const auto& state : track.states)
|
||||
if (selection_.contains(state.id.format())) clipboard_.push_back({track.id, true, {}, state});
|
||||
if (selection_.contains(state.id.format()))
|
||||
clipboard_.push_back({ClipboardItem::Kind::NotifyState, track.id, {}, {}, state});
|
||||
}
|
||||
return eve::editing::applied<std::size_t>(clipboard_.size());
|
||||
}
|
||||
@@ -599,28 +737,45 @@ LogicalId ActionTimelineEditor::copiedId(const LogicalId& source) {
|
||||
EditorResult<std::size_t> ActionTimelineEditor::paste(Duration offset) {
|
||||
if (clipboard_.empty())
|
||||
return eve::editing::failed<std::size_t>(EditorStatus::Rejected,
|
||||
RuleId("editor.action.timeline.clipboard-empty"),
|
||||
"Action timeline clipboard is empty");
|
||||
RuleId("editor.action.timeline.clipboard-empty"),
|
||||
"Action timeline clipboard is empty");
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
std::set<std::string> pastedSelection;
|
||||
for (const auto& item : clipboard_) {
|
||||
if (item.kind == ClipboardItem::Kind::AnimationSection) {
|
||||
auto copy = item.animationSection;
|
||||
copy.id = copiedId(copy.id);
|
||||
auto start = copy.start.tryAdd(offset);
|
||||
auto end = copy.end.tryAdd(offset);
|
||||
if (!start || !end)
|
||||
return eve::editing::failed<std::size_t>(EditorStatus::Rejected,
|
||||
RuleId("editor.action.timeline.time-overflow"),
|
||||
"Pasted animation-section time overflowed");
|
||||
copy.start = std::move(start).takeValue();
|
||||
copy.end = std::move(end).takeValue();
|
||||
pastedSelection.insert(copy.id.format());
|
||||
candidate.animationSections.push_back(std::move(copy));
|
||||
std::sort(candidate.animationSections.begin(), candidate.animationSections.end(),
|
||||
[](const auto& left, const auto& right) { return left.start < right.start; });
|
||||
continue;
|
||||
}
|
||||
auto* track = findTrack(candidate, item.trackId);
|
||||
if (!track)
|
||||
return eve::editing::failed<std::size_t>(EditorStatus::Conflict,
|
||||
RuleId("editor.action.timeline.track-not-found"),
|
||||
"Clipboard track no longer exists");
|
||||
RuleId("editor.action.timeline.track-not-found"),
|
||||
"Clipboard track no longer exists");
|
||||
if (track->locked)
|
||||
return eve::editing::failed<std::size_t>(
|
||||
EditorStatus::Rejected, RuleId("editor.action.timeline.track-locked"), "Clipboard track is locked");
|
||||
if (item.state) {
|
||||
if (item.kind == ClipboardItem::Kind::NotifyState) {
|
||||
auto copy = item.notifyState;
|
||||
copy.id = copiedId(copy.id);
|
||||
auto start = copy.start.tryAdd(offset);
|
||||
auto end = copy.end.tryAdd(offset);
|
||||
if (!start || !end)
|
||||
return eve::editing::failed<std::size_t>(EditorStatus::Rejected,
|
||||
RuleId("editor.action.timeline.time-overflow"),
|
||||
"Pasted notify-state time overflowed");
|
||||
RuleId("editor.action.timeline.time-overflow"),
|
||||
"Pasted notify-state time overflowed");
|
||||
copy.start = std::move(start).takeValue();
|
||||
copy.end = std::move(end).takeValue();
|
||||
pastedSelection.insert(copy.id.format());
|
||||
@@ -631,8 +786,8 @@ EditorResult<std::size_t> ActionTimelineEditor::paste(Duration offset) {
|
||||
auto time = copy.time.tryAdd(offset);
|
||||
if (!time)
|
||||
return eve::editing::failed<std::size_t>(EditorStatus::Rejected,
|
||||
RuleId("editor.action.timeline.time-overflow"),
|
||||
"Pasted notify time overflowed");
|
||||
RuleId("editor.action.timeline.time-overflow"),
|
||||
"Pasted notify time overflowed");
|
||||
copy.time = std::move(time).takeValue();
|
||||
pastedSelection.insert(copy.id.format());
|
||||
track->notifies.push_back(std::move(copy));
|
||||
@@ -643,7 +798,7 @@ EditorResult<std::size_t> ActionTimelineEditor::paste(Duration offset) {
|
||||
auto committed = commit(std::move(candidate), "Paste action timeline items", "action.timeline.item.paste");
|
||||
if (!committed.ok())
|
||||
return eve::editing::failed<std::size_t>(committed.code(), RuleId("editor.action.timeline.paste-failed"),
|
||||
"Could not paste action timeline items");
|
||||
"Could not paste action timeline items");
|
||||
selection_ = std::move(pastedSelection);
|
||||
return eve::editing::applied<std::size_t>(count);
|
||||
}
|
||||
@@ -651,6 +806,8 @@ EditorResult<std::size_t> ActionTimelineEditor::paste(Duration offset) {
|
||||
EditorResult<void> ActionTimelineEditor::deleteSelection() {
|
||||
if (selection_.empty()) return rejected("editor.action.timeline.selection-empty", "No timeline items are selected");
|
||||
action::ActionTimeline candidate = target_.timeline();
|
||||
std::erase_if(candidate.animationSections,
|
||||
[&](const auto& section) { return selection_.contains(section.id.format()); });
|
||||
for (auto& track : candidate.tracks) {
|
||||
const bool touchesLocked =
|
||||
track.locked && (std::any_of(track.notifies.begin(), track.notifies.end(),
|
||||
@@ -684,7 +841,7 @@ EditorResult<std::size_t> ActionTimelineEditor::update(Duration delta) {
|
||||
auto plan = planPreview(delta);
|
||||
if (!plan.ok())
|
||||
return eve::editing::failed<std::size_t>(plan.code(), RuleId("editor.action.timeline.preview-plan"),
|
||||
"Could not prepare preview advance");
|
||||
"Could not prepare preview advance");
|
||||
if (plan.code() == EditorStatus::NoOp) {
|
||||
previewEvents_.clear();
|
||||
return EditorResult<std::size_t>::success(0, Status::success(EditorStatus::NoOp));
|
||||
@@ -698,13 +855,12 @@ EditorResult<ActionTimelinePreviewPlan> ActionTimelineEditor::planPreview(Durati
|
||||
plan.timelineRevision = target_.revision();
|
||||
plan.previous = previewTime_;
|
||||
plan.current = previewTime_;
|
||||
return EditorResult<ActionTimelinePreviewPlan>::success(
|
||||
std::move(plan), Status::success(EditorStatus::NoOp));
|
||||
return EditorResult<ActionTimelinePreviewPlan>::success(std::move(plan), Status::success(EditorStatus::NoOp));
|
||||
}
|
||||
if (delta < Duration::zero())
|
||||
return eve::editing::failed<ActionTimelinePreviewPlan>(EditorStatus::Rejected,
|
||||
RuleId("editor.action.timeline.preview-delta"),
|
||||
"Preview delta must be non-negative");
|
||||
RuleId("editor.action.timeline.preview-delta"),
|
||||
"Preview delta must be non-negative");
|
||||
auto next = previewTime_.tryAdd(delta);
|
||||
if (!next)
|
||||
return eve::editing::failed<ActionTimelinePreviewPlan>(
|
||||
@@ -730,14 +886,14 @@ EditorResult<std::size_t> ActionTimelineEditor::applyPreviewPlan(ActionTimelineP
|
||||
if (!playing_ || plan.timelineRevision != target_.revision() || plan.previous != previewTime_ ||
|
||||
plan.includeStart == previewStarted_)
|
||||
return eve::editing::failed<std::size_t>(EditorStatus::Conflict,
|
||||
RuleId("editor.action.timeline.preview-plan-stale"),
|
||||
"Prepared preview advance no longer matches transport state");
|
||||
RuleId("editor.action.timeline.preview-plan-stale"),
|
||||
"Prepared preview advance no longer matches transport state");
|
||||
auto expected = target_.timeline().sample(plan.previous, plan.current, plan.includeStart);
|
||||
if (!expected || expected.value() != plan.events ||
|
||||
plan.reachesEnd != (plan.current == target_.timeline().duration))
|
||||
return eve::editing::failed<std::size_t>(EditorStatus::Rejected,
|
||||
RuleId("editor.action.timeline.preview-plan-invalid"),
|
||||
"Prepared preview advance does not match the authoritative timeline");
|
||||
RuleId("editor.action.timeline.preview-plan-invalid"),
|
||||
"Prepared preview advance does not match the authoritative timeline");
|
||||
previewEvents_ = std::move(plan.events);
|
||||
previewTime_ = plan.current;
|
||||
previewStarted_ = true;
|
||||
|
||||
@@ -92,7 +92,7 @@ public:
|
||||
[[nodiscard]] EditorResult<void> loadSnapshot(const EditorValue& snapshot);
|
||||
|
||||
private:
|
||||
bool matches(const SelectionSnapshot& selection) const;
|
||||
bool matches(const SelectionSnapshot& selection) const;
|
||||
[[nodiscard]] EditorResult<DomainOperation> replacement(const action::ActionTimeline& candidate,
|
||||
std::string property) const;
|
||||
[[nodiscard]] EditorResult<void> assign(action::ActionTimeline candidate);
|
||||
@@ -121,6 +121,28 @@ public:
|
||||
[[nodiscard]] EditorResult<void> configureWorkspace(EditorWorkspace& workspace) const;
|
||||
/** @brief Add an empty semantic track. */
|
||||
[[nodiscard]] EditorResult<void> addTrack(action::ActionTrack track);
|
||||
/** @brief Remove one semantic track and all of its items in one undoable transaction. */
|
||||
[[nodiscard]] EditorResult<void> removeTrack(const LogicalId& trackId);
|
||||
/** @brief Rename one track without changing its stable identity. */
|
||||
[[nodiscard]] EditorResult<void> renameTrack(const LogicalId& trackId, std::string label);
|
||||
/** @brief Add one objective animation section to the montage lane. */
|
||||
[[nodiscard]] EditorResult<void> addAnimationSection(action::ActionAnimationSection section);
|
||||
/** @brief Move an animation section while preserving its duration. */
|
||||
[[nodiscard]] EditorResult<void> moveAnimationSection(const LogicalId& sectionId, Duration delta);
|
||||
/** @brief Resize an animation section and its incoming blend window. */
|
||||
[[nodiscard]] EditorResult<void> resizeAnimationSection(const LogicalId& sectionId, Duration start, Duration end,
|
||||
Duration blendIn);
|
||||
/** @brief Atomically edit section timing, blend and animation resource URI. */
|
||||
[[nodiscard]] EditorResult<void> editAnimationSection(const LogicalId& sectionId, Duration start, Duration end,
|
||||
Duration blendIn, std::string animationUri);
|
||||
/** @brief Atomically edit clip-local trim and incoming blend curve. */
|
||||
[[nodiscard]] EditorResult<void> editAnimationSectionSource(const LogicalId& sectionId, Duration sourceStart,
|
||||
Duration sourceEnd,
|
||||
action::ActionBlendCurve blendCurve);
|
||||
/** @brief Replace every editable animation-section field in one undo step. */
|
||||
[[nodiscard]] EditorResult<void> editAnimationSectionFull(action::ActionAnimationSection section);
|
||||
/** @brief Remove one animation section from the montage lane. */
|
||||
[[nodiscard]] EditorResult<void> removeAnimationSection(const LogicalId& sectionId);
|
||||
/** @brief Add an instantaneous notify to an unlocked track. */
|
||||
[[nodiscard]] EditorResult<void> addNotify(const LogicalId& trackId, action::ActionNotify notify);
|
||||
/** @brief Add a notify state interval to an unlocked track. */
|
||||
@@ -138,6 +160,8 @@ public:
|
||||
[[nodiscard]] EditorResult<void> removeItem(const LogicalId& itemId);
|
||||
/** @brief Mute/unmute one track through an undoable operation. */
|
||||
[[nodiscard]] EditorResult<void> setTrackMuted(const LogicalId& trackId, bool muted);
|
||||
/** @brief Lock/unlock one track through an undoable operation. */
|
||||
[[nodiscard]] EditorResult<void> setTrackLocked(const LogicalId& trackId, bool locked);
|
||||
|
||||
/** @brief Replace selection with items intersecting a time interval. */
|
||||
[[nodiscard]] EditorResult<std::size_t> boxSelect(Duration start, Duration end);
|
||||
@@ -186,10 +210,12 @@ public:
|
||||
|
||||
private:
|
||||
struct ClipboardItem {
|
||||
LogicalId trackId;
|
||||
bool state = false;
|
||||
action::ActionNotify notify;
|
||||
action::ActionNotifyState notifyState;
|
||||
enum class Kind : std::uint8_t { AnimationSection, Notify, NotifyState };
|
||||
Kind kind = Kind::Notify;
|
||||
LogicalId trackId;
|
||||
action::ActionAnimationSection animationSection;
|
||||
action::ActionNotify notify;
|
||||
action::ActionNotifyState notifyState;
|
||||
};
|
||||
|
||||
[[nodiscard]] EditorResult<void> commit(action::ActionTimeline candidate, std::string label, std::string mergeKey);
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
#include "action/editor/ActionTimelineScriptBindings.h"
|
||||
|
||||
#include "action/ActionNotifyRegistry.h"
|
||||
#include "common/SquirrelBinding.h"
|
||||
#include "common/SquirrelOwnership.h"
|
||||
#include "action/editor/ActionEditorModule.h"
|
||||
#include "action/editor/ActionTimelineEditor.h"
|
||||
#include "action/editor/ActionTimelineWidget.h"
|
||||
#include "action/editor/ActionEditorModule.h"
|
||||
#include "animation/AnimClip.h"
|
||||
#include "animation/AnimImporter.h"
|
||||
#include "animation/AnimPose.h"
|
||||
#include "animation/MontagePlayer.h"
|
||||
#include "common/SquirrelBinding.h"
|
||||
#include "common/SquirrelOwnership.h"
|
||||
#include "editor/EditorWorkspace.h"
|
||||
#include "model3d/ModelData.h"
|
||||
|
||||
#include <simplesquirrel/simplesquirrel.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
namespace eve::editor {
|
||||
@@ -21,9 +28,7 @@ namespace {
|
||||
|
||||
constexpr const char* kBindingSource = "editor.action.timeline.squirrel";
|
||||
|
||||
Status statusFrom(const EditorResult<void>& result) {
|
||||
return result.status();
|
||||
}
|
||||
Status statusFrom(const EditorResult<void>& result) { return result.status(); }
|
||||
|
||||
template <class T>
|
||||
Status statusFrom(const EditorResult<T>& result) {
|
||||
@@ -57,6 +62,11 @@ const action::ActionTrack* trackAt(const action::ActionTimeline& timeline, int i
|
||||
return &timeline.tracks[static_cast<std::size_t>(index)];
|
||||
}
|
||||
|
||||
const action::ActionAnimationSection* sectionAt(const action::ActionTimeline& timeline, int index) {
|
||||
if (index < 0 || static_cast<std::size_t>(index) >= timeline.animationSections.size()) return nullptr;
|
||||
return &timeline.animationSections[static_cast<std::size_t>(index)];
|
||||
}
|
||||
|
||||
const TimelineItemGeometry* itemAt(const TimelineWidgetLayout& layout, int index) {
|
||||
if (index < 0 || static_cast<std::size_t>(index) >= layout.items.size()) return nullptr;
|
||||
return &layout.items[static_cast<std::size_t>(index)];
|
||||
@@ -71,6 +81,35 @@ const action::ActionNotifyState* findState(const action::ActionTimeline& timelin
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Value montageAdvanceValue(const animation::MontageAdvance& value) {
|
||||
Value::Object projected;
|
||||
projected["previousSeconds"] = value.previous.seconds();
|
||||
projected["currentSeconds"] = value.current.seconds();
|
||||
projected["sectionId"] = value.sectionId ? value.sectionId->format() : "";
|
||||
projected["eventCount"] = static_cast<std::int64_t>(value.events.size());
|
||||
projected["activeBlockCount"] = static_cast<std::int64_t>(value.activeBlocks.size());
|
||||
projected["rootMotionX"] = static_cast<double>(value.rootMotion.px);
|
||||
projected["rootMotionY"] = static_cast<double>(value.rootMotion.py);
|
||||
projected["rootMotionZ"] = static_cast<double>(value.rootMotion.pz);
|
||||
projected["weight"] = value.weight;
|
||||
projected["completed"] = value.completed;
|
||||
return Value(std::move(projected));
|
||||
}
|
||||
|
||||
void accumulateRootMotion(animation::TransformTRS& total, const animation::TransformTRS& delta) {
|
||||
total.px += delta.px;
|
||||
total.py += delta.py;
|
||||
total.pz += delta.pz;
|
||||
const float tx = total.qx;
|
||||
const float ty = total.qy;
|
||||
const float tz = total.qz;
|
||||
const float tw = total.qw;
|
||||
total.qx = tw * delta.qx + tx * delta.qw + ty * delta.qz - tz * delta.qy;
|
||||
total.qy = tw * delta.qy - tx * delta.qz + ty * delta.qw + tz * delta.qx;
|
||||
total.qz = tw * delta.qz + tx * delta.qy - ty * delta.qx + tz * delta.qw;
|
||||
total.qw = tw * delta.qw - tx * delta.qx - ty * delta.qy - tz * delta.qz;
|
||||
}
|
||||
|
||||
class ScriptActionTimelineEditor {
|
||||
public:
|
||||
ScriptActionTimelineEditor(std::string targetId, action::ActionTimeline timeline,
|
||||
@@ -85,10 +124,247 @@ public:
|
||||
const ActionTimelineEditor& editor() const noexcept { return editor_; }
|
||||
const ActionTimelineWidget& widget() const noexcept { return widget_; }
|
||||
|
||||
[[nodiscard]] Result<void> registerRuntimeClip(std::string uri, model3d::ModelData& model,
|
||||
animation::AnimSkeleton& skeleton, int animationIndex) {
|
||||
if (uri.empty())
|
||||
return Result<void>::failure(
|
||||
Diagnostic::error(DiagnosticCode::InvalidArgument, "montage clip URI must not be empty", "uri"));
|
||||
if (animationIndex < 0 || animationIndex >= animation::AnimImporter::getAnimationCountFromModel(&model))
|
||||
return Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument,
|
||||
"montage animation index is outside the model", "index"));
|
||||
std::unique_ptr<animation::AnimClip> clip(
|
||||
animation::AnimImporter::loadClipFromModel(&model, &skeleton, animationIndex));
|
||||
if (!clip)
|
||||
return Result<void>::failure(
|
||||
Diagnostic::error(DiagnosticCode::Failed, "montage clip import produced no animation", uri));
|
||||
if (montage_) return montage_->replaceClip(uri, std::move(clip));
|
||||
const auto found = std::find_if(runtimeClips_.begin(), runtimeClips_.end(),
|
||||
[&](const auto& asset) { return asset.uri == uri; });
|
||||
if (found == runtimeClips_.end())
|
||||
runtimeClips_.push_back({std::move(uri), std::move(clip)});
|
||||
else
|
||||
found->clip = std::move(clip);
|
||||
return Result<void>::success(Status::success(StatusCode::Applied));
|
||||
}
|
||||
|
||||
[[nodiscard]] Result<void> beginRuntime(animation::AnimSkeleton& skeleton) {
|
||||
auto montage = std::make_unique<animation::MontagePlayer>(skeleton);
|
||||
auto timeline = editor_.target().timeline();
|
||||
auto prepared = montage->prepare(timeline, std::move(runtimeClips_));
|
||||
if (!prepared) return Result<void>::failure(prepared.status());
|
||||
runtimeTimeline_ = timeline;
|
||||
montage_ = std::move(montage);
|
||||
tick_ = SimulationTick::zero();
|
||||
runtimeAdvance_.reset();
|
||||
runtimeRate_ = timeline.montage.basePlayRate;
|
||||
runtimePaused_ = false;
|
||||
sectionRates_.clear();
|
||||
return restartExecution();
|
||||
}
|
||||
|
||||
[[nodiscard]] Result<animation::MontageAdvance> advanceRuntime(Duration delta) {
|
||||
if (!runtime_ || !montage_)
|
||||
return Result<animation::MontageAdvance>::failure(
|
||||
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
|
||||
if (montage_->isBlendingOut()) {
|
||||
tick_ = SimulationTick(tick_.value() + 1);
|
||||
auto settled = montage_->advanceBlendOut(delta, tick_);
|
||||
if (settled) runtimeAdvance_ = settled.value();
|
||||
return settled;
|
||||
}
|
||||
if (runtimePaused_) {
|
||||
animation::MontageAdvance paused;
|
||||
paused.previous = paused.current = montage_->time();
|
||||
paused.weight = montage_->weight();
|
||||
return Result<animation::MontageAdvance>::success(std::move(paused), Status::success(StatusCode::NoOp));
|
||||
}
|
||||
double sectionRate = 1.0;
|
||||
auto sectionIndex = montage_->physicalSectionIndex();
|
||||
if (sectionIndex) {
|
||||
const auto found = sectionRates_.find(sectionIndex.value());
|
||||
if (found != sectionRates_.end()) sectionRate = found->second;
|
||||
}
|
||||
auto scaledDelta = Duration::fromSeconds(delta.seconds() * runtimeRate_ * sectionRate);
|
||||
if (!scaledDelta) return Result<animation::MontageAdvance>::failure(scaledDelta.status());
|
||||
Duration remainingDelta = std::move(scaledDelta).takeValue();
|
||||
animation::MontageAdvance combined;
|
||||
combined.previous = montage_->time();
|
||||
bool firstSlice = true;
|
||||
while (true) {
|
||||
Duration remainingTimeline =
|
||||
Duration::fromNanoseconds(runtimeTimeline_->duration.nanoseconds() - montage_->time().nanoseconds());
|
||||
if (runtimeTimeline_->montage.looping && remainingTimeline.isZero()) {
|
||||
auto restarted = restartExecution();
|
||||
if (!restarted) return Result<animation::MontageAdvance>::failure(restarted.status());
|
||||
remainingTimeline = runtimeTimeline_->duration;
|
||||
}
|
||||
const Duration slice =
|
||||
runtimeTimeline_->montage.looping ? std::min(remainingDelta, remainingTimeline) : remainingDelta;
|
||||
tick_ = SimulationTick(tick_.value() + 1);
|
||||
auto actionAdvance = runtime_->advance(executionId_, tick_, slice);
|
||||
if (!actionAdvance) return Result<animation::MontageAdvance>::failure(actionAdvance.status());
|
||||
auto presented = montage_->present(actionAdvance.value(), tick_);
|
||||
if (!presented) return Result<animation::MontageAdvance>::failure(presented.status());
|
||||
auto value = std::move(presented).takeValue();
|
||||
if (firstSlice) {
|
||||
combined = std::move(value);
|
||||
firstSlice = false;
|
||||
} else {
|
||||
combined.current = value.current;
|
||||
combined.sectionId = value.sectionId;
|
||||
combined.events.insert(combined.events.end(), std::make_move_iterator(value.events.begin()),
|
||||
std::make_move_iterator(value.events.end()));
|
||||
combined.activeBlocks = std::move(value.activeBlocks);
|
||||
accumulateRootMotion(combined.rootMotion, value.rootMotion);
|
||||
combined.weight = value.weight;
|
||||
combined.completed = value.completed;
|
||||
}
|
||||
if (!runtimeTimeline_->montage.looping || remainingDelta <= slice) break;
|
||||
|
||||
remainingDelta = Duration::fromNanoseconds(remainingDelta.nanoseconds() - slice.nanoseconds());
|
||||
auto restarted = restartExecution();
|
||||
if (!restarted) return Result<animation::MontageAdvance>::failure(restarted.status());
|
||||
}
|
||||
if (runtimeTimeline_->montage.looping && combined.completed) {
|
||||
auto restarted = restartExecution();
|
||||
if (!restarted) return Result<animation::MontageAdvance>::failure(restarted.status());
|
||||
combined.current = Duration::zero();
|
||||
combined.sectionId.reset();
|
||||
combined.activeBlocks.clear();
|
||||
combined.completed = false;
|
||||
combined.weight = montage_->weight();
|
||||
}
|
||||
runtimeAdvance_ = combined;
|
||||
return Result<animation::MontageAdvance>::success(std::move(combined), Status::success(StatusCode::Pending));
|
||||
}
|
||||
|
||||
[[nodiscard]] Result<animation::MontageAdvance> jumpRuntime(Duration target) {
|
||||
if (!runtimeTimeline_ || !montage_)
|
||||
return Result<animation::MontageAdvance>::failure(
|
||||
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
|
||||
auto restarted = restartExecution(false);
|
||||
if (!restarted) return Result<animation::MontageAdvance>::failure(restarted.status());
|
||||
auto rebound = montage_->rebindExecution(executionId_);
|
||||
if (!rebound) return Result<animation::MontageAdvance>::failure(rebound.status());
|
||||
tick_ = SimulationTick(tick_.value() + 1);
|
||||
auto advanced = runtime_->advance(executionId_, tick_, target);
|
||||
if (!advanced) return Result<animation::MontageAdvance>::failure(advanced.status());
|
||||
auto jumped = montage_->jumpToTime(executionId_, target, tick_);
|
||||
if (jumped) runtimeAdvance_ = jumped.value();
|
||||
return jumped;
|
||||
}
|
||||
|
||||
[[nodiscard]] Result<animation::MontageAdvance> jumpRuntimeSection(std::size_t index) {
|
||||
if (!runtimeTimeline_)
|
||||
return Result<animation::MontageAdvance>::failure(
|
||||
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
|
||||
auto range = runtimeTimeline_->sectionRange(index);
|
||||
if (!range) return Result<animation::MontageAdvance>::failure(range.status());
|
||||
return jumpRuntime(range.value().first);
|
||||
}
|
||||
|
||||
[[nodiscard]] Result<void> syncRuntimeSection(std::size_t index, Duration targetDuration) {
|
||||
if (!runtimeTimeline_)
|
||||
return Result<void>::failure(
|
||||
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
|
||||
if (targetDuration <= Duration::zero())
|
||||
return Result<void>::failure(Diagnostic::error(DiagnosticCode::InvalidArgument,
|
||||
"target section duration must be positive", "duration"));
|
||||
auto range = runtimeTimeline_->sectionRange(index);
|
||||
if (!range) return Result<void>::failure(range.status());
|
||||
const double raw = (range.value().second.seconds() - range.value().first.seconds());
|
||||
sectionRates_[index] = raw / targetDuration.seconds();
|
||||
return Result<void>::success(Status::success(StatusCode::Applied));
|
||||
}
|
||||
|
||||
[[nodiscard]] Result<animation::MontageAdvance> beginRuntimeBlendOut(Duration duration) {
|
||||
if (!montage_)
|
||||
return Result<animation::MontageAdvance>::failure(
|
||||
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
|
||||
tick_ = SimulationTick(tick_.value() + 1);
|
||||
auto stopped = montage_->beginBlendOut(duration, tick_);
|
||||
if (stopped) runtimeAdvance_ = stopped.value();
|
||||
return stopped;
|
||||
}
|
||||
|
||||
void setRuntimePaused(bool paused) noexcept { runtimePaused_ = paused; }
|
||||
[[nodiscard]] bool runtimePaused() const noexcept { return runtimePaused_; }
|
||||
|
||||
[[nodiscard]] Result<void> cancelRuntime() {
|
||||
if (!runtime_ || !montage_) return Result<void>::success(Status::success(StatusCode::NoOp));
|
||||
tick_ = SimulationTick(tick_.value() + 1);
|
||||
auto cancelled = runtime_->cancel(executionId_, tick_);
|
||||
if (!cancelled) return cancelled;
|
||||
montage_->stop();
|
||||
return Result<void>::success(Status::success(StatusCode::Applied));
|
||||
}
|
||||
|
||||
[[nodiscard]] animation::AnimPose* runtimePose() noexcept { return montage_ ? &montage_->pose() : nullptr; }
|
||||
[[nodiscard]] const std::optional<animation::MontageAdvance>& runtimeAdvance() const noexcept {
|
||||
return runtimeAdvance_;
|
||||
}
|
||||
[[nodiscard]] bool runtimePlaying() const noexcept { return montage_ && montage_->isPlaying(); }
|
||||
|
||||
[[nodiscard]] Result<std::size_t> runtimePhysicalSection() const {
|
||||
if (!montage_)
|
||||
return Result<std::size_t>::failure(
|
||||
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
|
||||
return montage_->physicalSectionIndex();
|
||||
}
|
||||
|
||||
[[nodiscard]] Result<double> runtimeSectionProgress(std::size_t index) const {
|
||||
if (!montage_)
|
||||
return Result<double>::failure(
|
||||
Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime"));
|
||||
return montage_->physicalSectionProgress(index);
|
||||
}
|
||||
|
||||
private:
|
||||
[[nodiscard]] action::ActionDefinition runtimeDefinition() const {
|
||||
action::ActionDefinition definition;
|
||||
definition.id = runtimeTimeline_->actionId;
|
||||
if (runtimeTimeline_->splitTimestamps.size() >= 2) {
|
||||
definition.timing.windup = runtimeTimeline_->splitTimestamps[0];
|
||||
definition.timing.active = Duration::fromNanoseconds(runtimeTimeline_->splitTimestamps[1].nanoseconds() -
|
||||
runtimeTimeline_->splitTimestamps[0].nanoseconds());
|
||||
definition.timing.recover = Duration::fromNanoseconds(runtimeTimeline_->duration.nanoseconds() -
|
||||
runtimeTimeline_->splitTimestamps[1].nanoseconds());
|
||||
} else {
|
||||
definition.timing.active = runtimeTimeline_->duration;
|
||||
}
|
||||
definition.timeline = *runtimeTimeline_;
|
||||
return definition;
|
||||
}
|
||||
|
||||
[[nodiscard]] Result<void> restartExecution(bool playMontage = true) {
|
||||
auto runtime = std::make_unique<action::ActionRuntime>();
|
||||
auto definition = runtimeDefinition();
|
||||
action::ActionRequest request;
|
||||
request.actionId = definition.id;
|
||||
auto submitted = runtime->submit(std::move(definition), std::move(request));
|
||||
if (!submitted) return Result<void>::failure(submitted.status());
|
||||
if (playMontage) {
|
||||
auto played = montage_->play(submitted.value());
|
||||
if (!played) return Result<void>::failure(played.status());
|
||||
}
|
||||
runtime_ = std::move(runtime);
|
||||
executionId_ = submitted.value();
|
||||
return Result<void>::success(Status::success(StatusCode::Applied));
|
||||
}
|
||||
|
||||
action::ActionNotifyRegistry registry_;
|
||||
ActionTimelineEditor editor_;
|
||||
ActionTimelineWidget widget_;
|
||||
std::vector<animation::MontageClipAsset> runtimeClips_;
|
||||
std::unique_ptr<action::ActionRuntime> runtime_;
|
||||
std::unique_ptr<animation::MontagePlayer> montage_;
|
||||
action::ActionExecutionId executionId_{};
|
||||
SimulationTick tick_ = SimulationTick::zero();
|
||||
std::optional<animation::MontageAdvance> runtimeAdvance_;
|
||||
std::optional<action::ActionTimeline> runtimeTimeline_;
|
||||
std::unordered_map<std::size_t, double> sectionRates_;
|
||||
double runtimeRate_ = 1.0;
|
||||
bool runtimePaused_ = false;
|
||||
};
|
||||
|
||||
std::string eventKind(action::ActionTimelineEventKind kind) {
|
||||
@@ -120,6 +396,75 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
return project(vm, self->widget().setViewport(width, rowHeight, labelWidth));
|
||||
});
|
||||
actionEditor.addFunc("addTrack", [vm](ScriptActionTimelineEditor* self, const std::string& trackId,
|
||||
const std::string& label, const std::string& kind) {
|
||||
if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null");
|
||||
auto parsedId = LogicalId::parse(trackId);
|
||||
auto parsedKind = action::parseActionTrackKind(kind);
|
||||
if (!parsedId) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId");
|
||||
if (!parsedKind) return script::projectStatusResult(vm, parsedKind.status(), false, false);
|
||||
action::ActionTrack track;
|
||||
track.id = std::move(*parsedId);
|
||||
track.label = label;
|
||||
track.kind = parsedKind.value();
|
||||
return project(vm, self->editor().addTrack(std::move(track)));
|
||||
});
|
||||
actionEditor.addFunc("removeTrack", [vm](ScriptActionTimelineEditor* self, const std::string& trackId) {
|
||||
if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null");
|
||||
auto parsed = LogicalId::parse(trackId);
|
||||
if (!parsed) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId");
|
||||
return project(vm, self->editor().removeTrack(*parsed));
|
||||
});
|
||||
actionEditor.addFunc(
|
||||
"renameTrack", [vm](ScriptActionTimelineEditor* self, const std::string& trackId, const std::string& label) {
|
||||
if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null");
|
||||
auto parsed = LogicalId::parse(trackId);
|
||||
if (!parsed) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId");
|
||||
return project(vm, self->editor().renameTrack(*parsed, label));
|
||||
});
|
||||
actionEditor.addFunc(
|
||||
"setTrackMuted", [vm](ScriptActionTimelineEditor* self, const std::string& trackId, bool muted) {
|
||||
if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null");
|
||||
auto parsed = LogicalId::parse(trackId);
|
||||
if (!parsed) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId");
|
||||
return project(vm, self->editor().setTrackMuted(*parsed, muted));
|
||||
});
|
||||
actionEditor.addFunc(
|
||||
"setTrackLocked", [vm](ScriptActionTimelineEditor* self, const std::string& trackId, bool locked) {
|
||||
if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null");
|
||||
auto parsed = LogicalId::parse(trackId);
|
||||
if (!parsed) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId");
|
||||
return project(vm, self->editor().setTrackLocked(*parsed, locked));
|
||||
});
|
||||
actionEditor.addFunc("setSnapSeconds", [vm](ScriptActionTimelineEditor* self, float intervalSeconds) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto interval = seconds(intervalSeconds);
|
||||
if (!interval) return script::projectStatusResult(vm, interval.status(), false, false);
|
||||
return project(vm, self->widget().setSnapInterval(std::move(interval).takeValue()));
|
||||
});
|
||||
actionEditor.addFunc(
|
||||
"setVisibleSeconds", [vm](ScriptActionTimelineEditor* self, float startSeconds, float endSeconds) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto start = seconds(startSeconds);
|
||||
auto end = seconds(endSeconds);
|
||||
if (!start) return script::projectStatusResult(vm, start.status(), false, false);
|
||||
if (!end) return script::projectStatusResult(vm, end.status(), false, false);
|
||||
return project(vm, self->widget().setVisibleRange(start.value(), end.value()));
|
||||
});
|
||||
actionEditor.addFunc("zoomTimeline", [vm](ScriptActionTimelineEditor* self, float factor, float anchor) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
return project(vm, self->widget().zoom(factor, anchor));
|
||||
});
|
||||
actionEditor.addFunc("panTimelineSeconds", [vm](ScriptActionTimelineEditor* self, float deltaSeconds) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto delta = seconds(deltaSeconds);
|
||||
if (!delta) return script::projectStatusResult(vm, delta.status(), false, false);
|
||||
return project(vm, self->widget().pan(delta.value()));
|
||||
});
|
||||
actionEditor.addFunc("pointerDown", [vm](ScriptActionTimelineEditor* self, float x, float y, bool additive) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
@@ -187,6 +532,134 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla
|
||||
return script::projectResult(vm, self->editor().target().timeline().toValue(),
|
||||
[](Value value) { return value; });
|
||||
});
|
||||
actionEditor.addFunc(
|
||||
"registerRuntimeClip", [vm](ScriptActionTimelineEditor* self, const std::string& uri, model3d::ModelData* model,
|
||||
animation::AnimSkeleton* skeleton, int animationIndex) {
|
||||
if (!self || !model || !skeleton)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument,
|
||||
"editor, model and skeleton must not be null", "runtimeClip");
|
||||
return script::projectResult(vm, self->registerRuntimeClip(uri, *model, *skeleton, animationIndex));
|
||||
});
|
||||
actionEditor.addFunc("beginRuntime", [vm](ScriptActionTimelineEditor* self, animation::AnimSkeleton* skeleton) {
|
||||
if (!self || !skeleton)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "editor and skeleton must not be null",
|
||||
"skeleton");
|
||||
return script::projectResult(vm, self->beginRuntime(*skeleton));
|
||||
});
|
||||
actionEditor.addFunc("advanceRuntime", [vm](ScriptActionTimelineEditor* self, float deltaSeconds) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto delta = seconds(deltaSeconds);
|
||||
if (!delta) return script::projectStatusResult(vm, delta.status(), false, false);
|
||||
return script::projectResult(vm, self->advanceRuntime(std::move(delta).takeValue()), montageAdvanceValue);
|
||||
});
|
||||
actionEditor.addFunc("jumpRuntimeSeconds", [vm](ScriptActionTimelineEditor* self, float targetSeconds) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto target = seconds(targetSeconds);
|
||||
if (!target) return script::projectStatusResult(vm, target.status(), false, false);
|
||||
return script::projectResult(vm, self->jumpRuntime(std::move(target).takeValue()), montageAdvanceValue);
|
||||
});
|
||||
actionEditor.addFunc("jumpRuntimeSection", [vm](ScriptActionTimelineEditor* self, int sectionIndex) {
|
||||
if (!self || sectionIndex < 0)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "runtime section index is invalid", "index");
|
||||
return script::projectResult(vm, self->jumpRuntimeSection(static_cast<std::size_t>(sectionIndex)),
|
||||
montageAdvanceValue);
|
||||
});
|
||||
actionEditor.addFunc(
|
||||
"syncRuntimeSection", [vm](ScriptActionTimelineEditor* self, int sectionIndex, float targetSeconds) {
|
||||
if (!self || sectionIndex < 0)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "runtime section index is invalid", "index");
|
||||
auto target = seconds(targetSeconds);
|
||||
if (!target) return script::projectStatusResult(vm, target.status(), false, false);
|
||||
return script::projectResult(
|
||||
vm, self->syncRuntimeSection(static_cast<std::size_t>(sectionIndex), std::move(target).takeValue()));
|
||||
});
|
||||
actionEditor.addFunc("beginRuntimeBlendOut", [vm](ScriptActionTimelineEditor* self, float durationSeconds) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto duration = seconds(durationSeconds);
|
||||
if (!duration) return script::projectStatusResult(vm, duration.status(), false, false);
|
||||
return script::projectResult(vm, self->beginRuntimeBlendOut(std::move(duration).takeValue()),
|
||||
montageAdvanceValue);
|
||||
});
|
||||
actionEditor.addFunc("setRuntimePaused", [](ScriptActionTimelineEditor* self, bool paused) {
|
||||
if (self) self->setRuntimePaused(paused);
|
||||
});
|
||||
actionEditor.addFunc("isRuntimePaused",
|
||||
[](ScriptActionTimelineEditor* self) { return self && self->runtimePaused(); });
|
||||
actionEditor.addFunc("cancelRuntime", [vm](ScriptActionTimelineEditor* self) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
return script::projectResult(vm, self->cancelRuntime());
|
||||
});
|
||||
actionEditor.addFunc("getRuntimePose",
|
||||
[](ScriptActionTimelineEditor* self) { return self ? self->runtimePose() : nullptr; });
|
||||
actionEditor.addFunc("isRuntimePlaying",
|
||||
[](ScriptActionTimelineEditor* self) { return self && self->runtimePlaying(); });
|
||||
actionEditor.addFunc("getRuntimeSectionId", [](ScriptActionTimelineEditor* self) {
|
||||
if (!self || !self->runtimeAdvance() || !self->runtimeAdvance()->sectionId) return std::string{};
|
||||
return self->runtimeAdvance()->sectionId->format();
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeRootMotionX", [](ScriptActionTimelineEditor* self) {
|
||||
return self && self->runtimeAdvance() ? self->runtimeAdvance()->rootMotion.px : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeRootMotionY", [](ScriptActionTimelineEditor* self) {
|
||||
return self && self->runtimeAdvance() ? self->runtimeAdvance()->rootMotion.py : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeRootMotionZ", [](ScriptActionTimelineEditor* self) {
|
||||
return self && self->runtimeAdvance() ? self->runtimeAdvance()->rootMotion.pz : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeWeight", [](ScriptActionTimelineEditor* self) {
|
||||
return self && self->runtimeAdvance() ? static_cast<float>(self->runtimeAdvance()->weight) : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeEventCount", [](ScriptActionTimelineEditor* self) {
|
||||
return self && self->runtimeAdvance() ? static_cast<int>(self->runtimeAdvance()->events.size()) : 0;
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeEventKind", [](ScriptActionTimelineEditor* self, int index) {
|
||||
if (!self || !self->runtimeAdvance() || index < 0 ||
|
||||
static_cast<std::size_t>(index) >= self->runtimeAdvance()->events.size())
|
||||
return std::string{};
|
||||
return eventKind(self->runtimeAdvance()->events[static_cast<std::size_t>(index)].kind);
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeEventType", [](ScriptActionTimelineEditor* self, int index) {
|
||||
if (!self || !self->runtimeAdvance() || index < 0 ||
|
||||
static_cast<std::size_t>(index) >= self->runtimeAdvance()->events.size())
|
||||
return std::string{};
|
||||
return self->runtimeAdvance()->events[static_cast<std::size_t>(index)].type.format();
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeEventSeconds", [](ScriptActionTimelineEditor* self, int index) {
|
||||
if (!self || !self->runtimeAdvance() || index < 0 ||
|
||||
static_cast<std::size_t>(index) >= self->runtimeAdvance()->events.size())
|
||||
return 0.0f;
|
||||
return static_cast<float>(self->runtimeAdvance()->events[static_cast<std::size_t>(index)].time.seconds());
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeActiveBlockCount", [](ScriptActionTimelineEditor* self) {
|
||||
return self && self->runtimeAdvance() ? static_cast<int>(self->runtimeAdvance()->activeBlocks.size()) : 0;
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeActiveBlockType", [](ScriptActionTimelineEditor* self, int index) {
|
||||
if (!self || !self->runtimeAdvance() || index < 0 ||
|
||||
static_cast<std::size_t>(index) >= self->runtimeAdvance()->activeBlocks.size())
|
||||
return std::string{};
|
||||
return self->runtimeAdvance()->activeBlocks[static_cast<std::size_t>(index)].type.format();
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeActiveBlockLocalSeconds", [](ScriptActionTimelineEditor* self, int index) {
|
||||
if (!self || !self->runtimeAdvance() || index < 0 ||
|
||||
static_cast<std::size_t>(index) >= self->runtimeAdvance()->activeBlocks.size())
|
||||
return 0.0f;
|
||||
return static_cast<float>(
|
||||
self->runtimeAdvance()->activeBlocks[static_cast<std::size_t>(index)].localTime.seconds());
|
||||
});
|
||||
actionEditor.addFunc("getRuntimePhysicalSection", [](ScriptActionTimelineEditor* self) {
|
||||
if (!self) return -1;
|
||||
auto result = self->runtimePhysicalSection();
|
||||
return result ? static_cast<int>(result.value()) : -1;
|
||||
});
|
||||
actionEditor.addFunc("getRuntimeSectionProgress", [](ScriptActionTimelineEditor* self, int sectionIndex) {
|
||||
if (!self || sectionIndex < 0) return 0.0f;
|
||||
auto result = self->runtimeSectionProgress(static_cast<std::size_t>(sectionIndex));
|
||||
return result ? static_cast<float>(result.value()) : 0.0f;
|
||||
});
|
||||
|
||||
actionEditor.addFunc("play", [](ScriptActionTimelineEditor* self) {
|
||||
if (self) self->editor().play();
|
||||
@@ -213,6 +686,104 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla
|
||||
actionEditor.addFunc("getAnimationUri", [](ScriptActionTimelineEditor* self) {
|
||||
return self ? self->editor().target().timeline().animationUri : std::string{};
|
||||
});
|
||||
actionEditor.addFunc("getAnimationSectionCount", [](ScriptActionTimelineEditor* self) {
|
||||
return self ? static_cast<int>(self->editor().target().timeline().animationSections.size()) : 0;
|
||||
});
|
||||
actionEditor.addFunc("getAnimationSectionId", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto* section = self ? sectionAt(self->editor().target().timeline(), index) : nullptr;
|
||||
return section ? section->id.format() : std::string{};
|
||||
});
|
||||
actionEditor.addFunc("getAnimationSectionUri", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto* section = self ? sectionAt(self->editor().target().timeline(), index) : nullptr;
|
||||
return section ? section->animationUri : std::string{};
|
||||
});
|
||||
actionEditor.addFunc("getAnimationSectionStart", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto* section = self ? sectionAt(self->editor().target().timeline(), index) : nullptr;
|
||||
return section ? static_cast<float>(section->start.seconds()) : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getAnimationSectionEnd", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto* section = self ? sectionAt(self->editor().target().timeline(), index) : nullptr;
|
||||
return section ? static_cast<float>(section->end.seconds()) : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getAnimationSectionBlendIn", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto* section = self ? sectionAt(self->editor().target().timeline(), index) : nullptr;
|
||||
return section ? static_cast<float>(section->blendIn.seconds()) : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getAnimationSectionSourceStart", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto* section = self ? sectionAt(self->editor().target().timeline(), index) : nullptr;
|
||||
return section ? static_cast<float>(section->sourceStart.seconds()) : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getAnimationSectionSourceEnd", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto* section = self ? sectionAt(self->editor().target().timeline(), index) : nullptr;
|
||||
return section ? static_cast<float>(section->sourceEnd.seconds()) : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getAnimationSectionBlendCurve", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto* section = self ? sectionAt(self->editor().target().timeline(), index) : nullptr;
|
||||
return section ? std::string(action::actionBlendCurveName(section->blendCurve)) : std::string{};
|
||||
});
|
||||
actionEditor.addFunc("addAnimationSection", [vm](ScriptActionTimelineEditor* self, const std::string& sectionId,
|
||||
const std::string& animationUri, float startSeconds,
|
||||
float endSeconds, float blendInSeconds) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto parsed = LogicalId::parse(sectionId);
|
||||
if (!parsed)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation section id is not canonical",
|
||||
"sectionId");
|
||||
auto start = seconds(startSeconds);
|
||||
auto end = seconds(endSeconds);
|
||||
auto blend = seconds(blendInSeconds);
|
||||
if (!start) return script::projectStatusResult(vm, start.status(), false, false);
|
||||
if (!end) return script::projectStatusResult(vm, end.status(), false, false);
|
||||
if (!blend) return script::projectStatusResult(vm, blend.status(), false, false);
|
||||
action::ActionAnimationSection section{*parsed, animationUri, start.value(), end.value(), blend.value()};
|
||||
return project(vm, self->editor().addAnimationSection(std::move(section)));
|
||||
});
|
||||
actionEditor.addFunc("editAnimationSection", [vm](ScriptActionTimelineEditor* self, const std::string& sectionId,
|
||||
float startSeconds, float endSeconds, float blendInSeconds,
|
||||
const std::string& animationUri) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto parsed = LogicalId::parse(sectionId);
|
||||
if (!parsed)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation section id is not canonical",
|
||||
"sectionId");
|
||||
auto start = seconds(startSeconds);
|
||||
auto end = seconds(endSeconds);
|
||||
auto blend = seconds(blendInSeconds);
|
||||
if (!start) return script::projectStatusResult(vm, start.status(), false, false);
|
||||
if (!end) return script::projectStatusResult(vm, end.status(), false, false);
|
||||
if (!blend) return script::projectStatusResult(vm, blend.status(), false, false);
|
||||
return project(
|
||||
vm, self->editor().editAnimationSection(*parsed, start.value(), end.value(), blend.value(), animationUri));
|
||||
});
|
||||
actionEditor.addFunc(
|
||||
"removeAnimationSection", [vm](ScriptActionTimelineEditor* self, const std::string& sectionId) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto parsed = LogicalId::parse(sectionId);
|
||||
if (!parsed)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation section id is not canonical",
|
||||
"sectionId");
|
||||
return project(vm, self->editor().removeAnimationSection(*parsed));
|
||||
});
|
||||
actionEditor.addFunc("editAnimationSectionSource", [vm](ScriptActionTimelineEditor* self,
|
||||
const std::string& sectionId, float sourceStartSeconds,
|
||||
float sourceEndSeconds, const std::string& blendCurve) {
|
||||
if (!self)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null");
|
||||
auto parsed = LogicalId::parse(sectionId);
|
||||
auto curve = action::actionBlendCurveFromName(blendCurve);
|
||||
if (!parsed || !curve)
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "section id or blend curve is invalid",
|
||||
"sectionSource");
|
||||
auto sourceStart = seconds(sourceStartSeconds);
|
||||
auto sourceEnd = seconds(sourceEndSeconds);
|
||||
if (!sourceStart) return script::projectStatusResult(vm, sourceStart.status(), false, false);
|
||||
if (!sourceEnd) return script::projectStatusResult(vm, sourceEnd.status(), false, false);
|
||||
return project(
|
||||
vm, self->editor().editAnimationSectionSource(*parsed, sourceStart.value(), sourceEnd.value(), *curve));
|
||||
});
|
||||
actionEditor.addFunc("getTrackCount", [](ScriptActionTimelineEditor* self) {
|
||||
return self ? static_cast<int>(self->editor().target().timeline().tracks.size()) : 0;
|
||||
});
|
||||
@@ -240,6 +811,20 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla
|
||||
actionEditor.addFunc("getPlayheadX", [](ScriptActionTimelineEditor* self) {
|
||||
return self ? self->widget().layout().playheadX : 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getRulerTickCount", [](ScriptActionTimelineEditor* self) {
|
||||
return self ? static_cast<int>(self->widget().layout().rulerTicks.size()) : 0;
|
||||
});
|
||||
actionEditor.addFunc("getRulerTickX", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto layout = self ? self->widget().layout() : TimelineWidgetLayout{};
|
||||
return index >= 0 && static_cast<std::size_t>(index) < layout.rulerTicks.size()
|
||||
? layout.rulerTicks[static_cast<std::size_t>(index)].x
|
||||
: 0.0f;
|
||||
});
|
||||
actionEditor.addFunc("getRulerTickMajor", [](ScriptActionTimelineEditor* self, int index) {
|
||||
const auto layout = self ? self->widget().layout() : TimelineWidgetLayout{};
|
||||
return index >= 0 && static_cast<std::size_t>(index) < layout.rulerTicks.size() &&
|
||||
layout.rulerTicks[static_cast<std::size_t>(index)].major;
|
||||
});
|
||||
actionEditor.addFunc("getItemCount", [](ScriptActionTimelineEditor* self) {
|
||||
return self ? static_cast<int>(self->widget().layout().items.size()) : 0;
|
||||
});
|
||||
@@ -325,30 +910,29 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla
|
||||
: std::string{};
|
||||
});
|
||||
|
||||
moduleClass.addFunc(
|
||||
"create", [vm](eve::action_editor::ActionEditorModule*, const std::string& targetId,
|
||||
const ssq::Object& timelineObject) {
|
||||
if (targetId.empty())
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument,
|
||||
"action timeline target id must not be empty", "targetId");
|
||||
script::SquirrelValueOptions options;
|
||||
options.source = kBindingSource;
|
||||
auto value = script::valueFromSquirrel(timelineObject, options);
|
||||
if (!value) return script::projectStatusResult(vm, value.status(), false, false);
|
||||
auto timeline = action::ActionTimeline::fromValue(value.value());
|
||||
if (!timeline) return script::projectStatusResult(vm, timeline.status(), false, false);
|
||||
auto registry = action::ActionNotifyRegistry::withBuiltins();
|
||||
if (!registry) return script::projectStatusResult(vm, registry.status(), false, false);
|
||||
auto object = script::makeOwnedSquirrelInstance<ScriptActionTimelineEditor>(
|
||||
vm, std::make_unique<ScriptActionTimelineEditor>(targetId, std::move(timeline).takeValue(),
|
||||
std::move(registry).takeValue()));
|
||||
if (!object) return script::projectStatusResult(vm, object.status(), false, false);
|
||||
ssq::Object owned = std::move(object).takeValue();
|
||||
auto result = script::projectStatusResult(vm, Status::success(StatusCode::Applied), true, false);
|
||||
result.set("value", owned);
|
||||
result.set("ownership", std::string("owned"));
|
||||
return result;
|
||||
});
|
||||
moduleClass.addFunc("create", [vm](eve::action_editor::ActionEditorModule*, const std::string& targetId,
|
||||
const ssq::Object& timelineObject) {
|
||||
if (targetId.empty())
|
||||
return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline target id must not be empty",
|
||||
"targetId");
|
||||
script::SquirrelValueOptions options;
|
||||
options.source = kBindingSource;
|
||||
auto value = script::valueFromSquirrel(timelineObject, options);
|
||||
if (!value) return script::projectStatusResult(vm, value.status(), false, false);
|
||||
auto timeline = action::ActionTimeline::fromValue(value.value());
|
||||
if (!timeline) return script::projectStatusResult(vm, timeline.status(), false, false);
|
||||
auto registry = action::ActionNotifyRegistry::withBuiltins();
|
||||
if (!registry) return script::projectStatusResult(vm, registry.status(), false, false);
|
||||
auto object = script::makeOwnedSquirrelInstance<ScriptActionTimelineEditor>(
|
||||
vm, std::make_unique<ScriptActionTimelineEditor>(targetId, std::move(timeline).takeValue(),
|
||||
std::move(registry).takeValue()));
|
||||
if (!object) return script::projectStatusResult(vm, object.status(), false, false);
|
||||
ssq::Object owned = std::move(object).takeValue();
|
||||
auto result = script::projectStatusResult(vm, Status::success(StatusCode::Applied), true, false);
|
||||
result.set("value", owned);
|
||||
result.set("ownership", std::string("owned"));
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace eve::editor
|
||||
@@ -22,11 +22,25 @@ struct ItemView {
|
||||
Duration start;
|
||||
Duration end;
|
||||
Value::Object payload;
|
||||
bool state = false;
|
||||
bool locked = false;
|
||||
bool state = false;
|
||||
bool locked = false;
|
||||
bool animationSection = false;
|
||||
};
|
||||
|
||||
std::optional<ItemView> findItem(const action::ActionTimeline& timeline, const LogicalId& itemId) {
|
||||
for (const auto& section : timeline.animationSections) {
|
||||
if (section.id != itemId) continue;
|
||||
return ItemView{
|
||||
*LogicalId::fromParts("action-track", "animation"),
|
||||
section.id,
|
||||
*LogicalId::fromParts("animation", "section"),
|
||||
section.start,
|
||||
section.end,
|
||||
{{"animationUri", Value(section.animationUri)}, {"blendInNs", Value(section.blendIn.nanoseconds())}},
|
||||
true,
|
||||
false,
|
||||
true};
|
||||
}
|
||||
for (const auto& track : timeline.tracks) {
|
||||
for (const auto& notify : track.notifies)
|
||||
if (notify.id == itemId)
|
||||
@@ -65,32 +79,123 @@ EditorResult<void> ActionTimelineWidget::setViewport(float width, float rowHeigh
|
||||
return eve::editing::applied<void>();
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineWidget::setSnapInterval(Duration interval) {
|
||||
if (interval < Duration::zero())
|
||||
return widgetError("editor.action.timeline.widget.snap", "Timeline snap interval must be non-negative");
|
||||
snapInterval_ = interval;
|
||||
return eve::editing::applied<void>();
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineWidget::setVisibleRange(Duration start, Duration end) {
|
||||
const Duration duration = editor_.target().timeline().duration;
|
||||
if (start < Duration::zero() || end <= start || end > duration)
|
||||
return widgetError("editor.action.timeline.widget.visible-range",
|
||||
"Timeline visible range must be ordered and inside the asset duration");
|
||||
visibleStart_ = start;
|
||||
visibleEnd_ = end;
|
||||
return eve::editing::applied<void>();
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineWidget::zoom(double factor, double normalizedAnchor) {
|
||||
if (!std::isfinite(factor) || factor <= 0.0 || !std::isfinite(normalizedAnchor) || normalizedAnchor < 0.0 ||
|
||||
normalizedAnchor > 1.0)
|
||||
return widgetError("editor.action.timeline.widget.zoom", "Timeline zoom factor or anchor is invalid");
|
||||
const Duration duration = editor_.target().timeline().duration;
|
||||
const Duration start = visibleEnd_ > visibleStart_ ? visibleStart_ : Duration::zero();
|
||||
const Duration end = visibleEnd_ > visibleStart_ ? visibleEnd_ : duration;
|
||||
const double oldSpan = static_cast<double>(end.nanoseconds() - start.nanoseconds());
|
||||
const double minimumSpan = std::max(1.0, static_cast<double>(duration.nanoseconds()) / 10000.0);
|
||||
const auto newSpan = static_cast<std::int64_t>(
|
||||
std::llround(std::clamp(oldSpan / factor, minimumSpan, static_cast<double>(duration.nanoseconds()))));
|
||||
const auto anchor = start.nanoseconds() + static_cast<std::int64_t>(std::llround(oldSpan * normalizedAnchor));
|
||||
auto newStart = anchor - static_cast<std::int64_t>(std::llround(newSpan * normalizedAnchor));
|
||||
newStart = std::clamp<std::int64_t>(newStart, 0, duration.nanoseconds() - newSpan);
|
||||
visibleStart_ = Duration::fromNanoseconds(newStart);
|
||||
visibleEnd_ = Duration::fromNanoseconds(newStart + newSpan);
|
||||
return eve::editing::applied<void>();
|
||||
}
|
||||
|
||||
EditorResult<void> ActionTimelineWidget::pan(Duration delta) {
|
||||
const Duration duration = editor_.target().timeline().duration;
|
||||
const Duration start = visibleEnd_ > visibleStart_ ? visibleStart_ : Duration::zero();
|
||||
const Duration end = visibleEnd_ > visibleStart_ ? visibleEnd_ : duration;
|
||||
const auto span = end.nanoseconds() - start.nanoseconds();
|
||||
const auto next =
|
||||
std::clamp<std::int64_t>(start.nanoseconds() + delta.nanoseconds(), 0, duration.nanoseconds() - span);
|
||||
visibleStart_ = Duration::fromNanoseconds(next);
|
||||
visibleEnd_ = Duration::fromNanoseconds(next + span);
|
||||
return eve::editing::applied<void>();
|
||||
}
|
||||
|
||||
float ActionTimelineWidget::timeToX(Duration time) const noexcept {
|
||||
const auto duration = editor_.target().timeline().duration.nanoseconds();
|
||||
if (duration <= 0) return labelWidth_;
|
||||
const double fraction = static_cast<double>(time.nanoseconds()) / static_cast<double>(duration);
|
||||
const auto duration = editor_.target().timeline().duration;
|
||||
const auto start = visibleEnd_ > visibleStart_ ? visibleStart_ : Duration::zero();
|
||||
const auto end = visibleEnd_ > visibleStart_ ? visibleEnd_ : duration;
|
||||
const auto span = end.nanoseconds() - start.nanoseconds();
|
||||
if (span <= 0) return labelWidth_;
|
||||
const double fraction = static_cast<double>(time.nanoseconds() - start.nanoseconds()) / static_cast<double>(span);
|
||||
return labelWidth_ + static_cast<float>(fraction * static_cast<double>(width_ - labelWidth_));
|
||||
}
|
||||
|
||||
Duration ActionTimelineWidget::xToTime(float x) const noexcept {
|
||||
const float clamped = std::clamp(x, labelWidth_, width_);
|
||||
const float fraction = (clamped - labelWidth_) / (width_ - labelWidth_);
|
||||
const auto duration = editor_.target().timeline().duration.nanoseconds();
|
||||
return Duration::fromNanoseconds(static_cast<std::int64_t>(std::llround(static_cast<double>(fraction) * duration)));
|
||||
const auto assetDuration = editor_.target().timeline().duration;
|
||||
const auto start = visibleEnd_ > visibleStart_ ? visibleStart_ : Duration::zero();
|
||||
const auto end = visibleEnd_ > visibleStart_ ? visibleEnd_ : assetDuration;
|
||||
const auto span = end.nanoseconds() - start.nanoseconds();
|
||||
std::int64_t time =
|
||||
start.nanoseconds() + static_cast<std::int64_t>(std::llround(static_cast<double>(fraction) * span));
|
||||
if (snapInterval_.nanoseconds() > 0) {
|
||||
const double steps = static_cast<double>(time) / static_cast<double>(snapInterval_.nanoseconds());
|
||||
time = static_cast<std::int64_t>(std::llround(steps)) * snapInterval_.nanoseconds();
|
||||
time = std::clamp<std::int64_t>(time, start.nanoseconds(), end.nanoseconds());
|
||||
}
|
||||
return Duration::fromNanoseconds(time);
|
||||
}
|
||||
|
||||
TimelineWidgetLayout ActionTimelineWidget::layout() const {
|
||||
TimelineWidgetLayout result;
|
||||
result.width = width_;
|
||||
result.height = rowHeight_ * static_cast<float>(editor_.target().timeline().tracks.size());
|
||||
result.width = width_;
|
||||
const bool hasAnimationLane = !editor_.target().timeline().animationSections.empty();
|
||||
const std::size_t rowOffset = hasAnimationLane ? 1U : 0U;
|
||||
result.height = rowHeight_ * static_cast<float>(editor_.target().timeline().tracks.size() + rowOffset);
|
||||
result.playheadX = timeToX(editor_.previewTime());
|
||||
const auto selected = editor_.selectedItemIds();
|
||||
auto isSelected = [&](const LogicalId& id) {
|
||||
return std::find(selected.begin(), selected.end(), id) != selected.end();
|
||||
};
|
||||
if (snapInterval_.nanoseconds() > 0) {
|
||||
const std::int64_t duration = editor_.target().timeline().duration.nanoseconds();
|
||||
const std::int64_t interval = snapInterval_.nanoseconds();
|
||||
const auto visibleStart = visibleEnd_ > visibleStart_ ? visibleStart_.nanoseconds() : 0;
|
||||
const auto visibleEnd = visibleEnd_ > visibleStart_ ? visibleEnd_.nanoseconds() : duration;
|
||||
std::int64_t time = (visibleStart / interval) * interval;
|
||||
if (time < visibleStart) time += interval;
|
||||
for (std::int64_t index = time / interval; time <= visibleEnd; time += interval, ++index) {
|
||||
const Duration tick = Duration::fromNanoseconds(time);
|
||||
result.rulerTicks.push_back({tick, timeToX(tick), index % 5 == 0});
|
||||
if (duration - time < interval) break;
|
||||
}
|
||||
}
|
||||
if (hasAnimationLane) {
|
||||
const auto animationTrack = *LogicalId::fromParts("action-track", "animation");
|
||||
const auto animationType = *LogicalId::fromParts("animation", "section");
|
||||
for (const auto& section : editor_.target().timeline().animationSections) {
|
||||
Duration start = section.start;
|
||||
Duration end = section.end;
|
||||
if (drag_ && drag_->itemId == section.id) {
|
||||
start = drag_->previewStart;
|
||||
end = drag_->previewEnd;
|
||||
}
|
||||
result.items.push_back({animationTrack, section.id, animationType, true, isSelected(section.id),
|
||||
timeToX(start), std::max(timeToX(end), timeToX(start) + 4.0f), 3.0f,
|
||||
rowHeight_ - 3.0f});
|
||||
}
|
||||
}
|
||||
for (std::size_t row = 0; row < editor_.target().timeline().tracks.size(); ++row) {
|
||||
const auto& track = editor_.target().timeline().tracks[row];
|
||||
const float top = static_cast<float>(row) * rowHeight_;
|
||||
const float top = static_cast<float>(row + rowOffset) * rowHeight_;
|
||||
for (const auto& notify : track.notifies) {
|
||||
Duration time = notify.time;
|
||||
if (drag_ && drag_->itemId == notify.id) time = drag_->previewStart;
|
||||
@@ -116,9 +221,16 @@ TimelineWidgetLayout ActionTimelineWidget::layout() const {
|
||||
}
|
||||
|
||||
void ActionTimelineWidget::draw(IEditorOverlay& overlay) const {
|
||||
const auto& timeline = editor_.target().timeline();
|
||||
const auto& timeline = editor_.target().timeline();
|
||||
const bool hasAnimationLane = !timeline.animationSections.empty();
|
||||
const std::size_t rowOffset = hasAnimationLane ? 1U : 0U;
|
||||
if (hasAnimationLane) {
|
||||
overlay.rectangle({0.0f, 0.0f, 0.0f}, {width_, rowHeight_, 0.0f}, {0x20252cffU, 1.0f, true});
|
||||
overlay.line({0.0f, rowHeight_, 0.0f}, {width_, rowHeight_, 0.0f}, {0x4a5260ffU, 1.0f, false});
|
||||
overlay.text({4.0f, 4.0f, 0.0f}, "Animation", {0xd8dee9ffU, 1.0f, false});
|
||||
}
|
||||
for (std::size_t row = 0; row < timeline.tracks.size(); ++row) {
|
||||
const float top = static_cast<float>(row) * rowHeight_;
|
||||
const float top = static_cast<float>(row + rowOffset) * rowHeight_;
|
||||
const float bottom = top + rowHeight_;
|
||||
overlay.rectangle({0.0f, top, 0.0f}, {width_, bottom, 0.0f}, {0x20252cffU, 1.0f, true});
|
||||
overlay.line({0.0f, bottom, 0.0f}, {width_, bottom, 0.0f}, {0x4a5260ffU, 1.0f, false});
|
||||
@@ -126,7 +238,9 @@ void ActionTimelineWidget::draw(IEditorOverlay& overlay) const {
|
||||
{timeline.tracks[row].muted ? 0x7f8792ffU : 0xd8dee9ffU, 1.0f, false});
|
||||
}
|
||||
for (const auto& item : layout().items) {
|
||||
const unsigned int color = item.selected ? 0xf2b84bffU : (item.state ? 0x568bd7ffU : 0x61c28bffU);
|
||||
const bool animationSection = item.type.format() == "animation:section";
|
||||
const unsigned int color =
|
||||
item.selected ? 0xf2b84bffU : (animationSection ? 0xa66bd4ffU : (item.state ? 0x568bd7ffU : 0x61c28bffU));
|
||||
overlay.rectangle({item.minimumX, item.minimumY, 0.0f}, {item.maximumX, item.maximumY, 0.0f},
|
||||
{color, 1.0f, true});
|
||||
if (item.state) {
|
||||
@@ -136,7 +250,7 @@ void ActionTimelineWidget::draw(IEditorOverlay& overlay) const {
|
||||
{0xffffffffU, 2.0f, false});
|
||||
}
|
||||
}
|
||||
const float height = rowHeight_ * static_cast<float>(timeline.tracks.size());
|
||||
const float height = rowHeight_ * static_cast<float>(timeline.tracks.size() + rowOffset);
|
||||
overlay.line({timeToX(editor_.previewTime()), 0.0f, 0.0f}, {timeToX(editor_.previewTime()), height, 0.0f},
|
||||
{0xff5b5bffU, 2.0f, false});
|
||||
}
|
||||
@@ -216,6 +330,20 @@ EditorResult<void> ActionTimelineWidget::pointerUp(float x) {
|
||||
if (!updated.ok()) return updated;
|
||||
const DragState completed = *drag_;
|
||||
drag_.reset();
|
||||
const auto item = findItem(editor_.target().timeline(), completed.itemId);
|
||||
if (item && item->animationSection) {
|
||||
if (completed.part == TimelineHitPart::Body)
|
||||
return editor_.moveAnimationSection(completed.itemId,
|
||||
difference(completed.previewStart, completed.originalStart));
|
||||
const auto section = std::find_if(editor_.target().timeline().animationSections.begin(),
|
||||
editor_.target().timeline().animationSections.end(),
|
||||
[&](const auto& value) { return value.id == completed.itemId; });
|
||||
if (section == editor_.target().timeline().animationSections.end())
|
||||
return widgetError("editor.action.timeline.animation-section-not-found",
|
||||
"Animation section no longer exists", EditorStatus::Conflict);
|
||||
return editor_.resizeAnimationSection(completed.itemId, completed.previewStart, completed.previewEnd,
|
||||
section->blendIn);
|
||||
}
|
||||
if (completed.state && completed.part != TimelineHitPart::Body)
|
||||
return editor_.resizeState(completed.itemId, completed.previewStart, completed.previewEnd);
|
||||
return editor_.moveItem(completed.itemId, difference(completed.previewStart, completed.originalStart));
|
||||
@@ -231,6 +359,54 @@ EditorResult<void> ActionTimelineWidget::inspectSelection(IEditorInspector& insp
|
||||
return widgetError("editor.action.timeline.widget.item-missing", "Selected item no longer exists",
|
||||
EditorStatus::Conflict);
|
||||
|
||||
if (item->animationSection) {
|
||||
const auto section = std::find_if(editor_.target().timeline().animationSections.begin(),
|
||||
editor_.target().timeline().animationSections.end(),
|
||||
[&](const auto& value) { return value.id == item->itemId; });
|
||||
if (section == editor_.target().timeline().animationSections.end())
|
||||
return widgetError("editor.action.timeline.animation-section-not-found",
|
||||
"Animation section no longer exists", EditorStatus::Conflict);
|
||||
float startSeconds = static_cast<float>(section->start.seconds());
|
||||
float endSeconds = static_cast<float>(section->end.seconds());
|
||||
float blendSeconds = static_cast<float>(section->blendIn.seconds());
|
||||
float sourceStartSeconds = static_cast<float>(section->sourceStart.seconds());
|
||||
float sourceEndSeconds = static_cast<float>(section->sourceEnd.seconds());
|
||||
std::string animationUri = section->animationUri;
|
||||
std::string blendCurve = std::string(action::actionBlendCurveName(section->blendCurve));
|
||||
const float maximum = static_cast<float>(editor_.target().timeline().duration.seconds());
|
||||
inspector.beginGroup("action.timeline.animation-section", "Animation Section");
|
||||
bool changed = inspector.scalar("start", "Start", startSeconds, 0.0f, maximum);
|
||||
changed = inspector.scalar("end", "End", endSeconds, 0.0f, maximum) || changed;
|
||||
changed = inspector.scalar("blendIn", "Blend In", blendSeconds, 0.0f, maximum) || changed;
|
||||
changed =
|
||||
inspector.scalar("sourceStart", "Trim In", sourceStartSeconds, 0.0f, std::numeric_limits<float>::max()) ||
|
||||
changed;
|
||||
changed = inspector.scalar("sourceEnd", "Trim Out (0 = clip end)", sourceEndSeconds, 0.0f,
|
||||
std::numeric_limits<float>::max()) ||
|
||||
changed;
|
||||
changed = inspector.string("blendCurve", "Blend Curve", blendCurve) || changed;
|
||||
changed = inspector.string("animationUri", "Animation", animationUri) || changed;
|
||||
inspector.endGroup();
|
||||
if (!changed) return eve::editing::applied<void>();
|
||||
auto start = Duration::fromSeconds(startSeconds);
|
||||
auto end = Duration::fromSeconds(endSeconds);
|
||||
auto blend = Duration::fromSeconds(blendSeconds);
|
||||
auto sourceStart = Duration::fromSeconds(sourceStartSeconds);
|
||||
auto sourceEnd = Duration::fromSeconds(sourceEndSeconds);
|
||||
auto parsedCurve = action::actionBlendCurveFromName(blendCurve);
|
||||
if (!start || !end || !blend || !sourceStart || !sourceEnd || !parsedCurve)
|
||||
return widgetError("editor.action.timeline.widget.time-invalid", "Animation section time is invalid");
|
||||
action::ActionAnimationSection edited = *section;
|
||||
edited.start = start.value();
|
||||
edited.end = end.value();
|
||||
edited.blendIn = blend.value();
|
||||
edited.sourceStart = sourceStart.value();
|
||||
edited.sourceEnd = sourceEnd.value();
|
||||
edited.blendCurve = *parsedCurve;
|
||||
edited.animationUri = std::move(animationUri);
|
||||
return editor_.editAnimationSectionFull(std::move(edited));
|
||||
}
|
||||
|
||||
float startSeconds = static_cast<float>(item->start.seconds());
|
||||
float endSeconds = static_cast<float>(item->end.seconds());
|
||||
std::string type = item->type.format();
|
||||
|
||||
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user