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:
西风逍遥游 committed 2026-09-15 15:48:48 +08:00
commit 37376899a5
204 files changed
+30565 -639

No files matched your search

+4 -2
View File
@@ -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
+9 -6
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+116
View File
@@ -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 只镜像成员关系。
+8 -1
View File
@@ -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()`
## 使用要点
+8 -5
View File
@@ -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

+64 -18
View File
@@ -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");
+185 -51
View File
@@ -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");
+27
View File
@@ -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;
}
+866
View File
@@ -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
+172
View File
@@ -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
+82
View File
@@ -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;
}
+50
View File
@@ -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;
}
+39
View File
@@ -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;
}
+50
View File
@@ -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);
}
+27
View File
@@ -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;
+24
View File
@@ -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;
}
+6
View File
@@ -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;
}
+29
View File
@@ -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";
+21
View File
@@ -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");
+88
View File
@@ -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");
+27
View File
@@ -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");
+20
View File
@@ -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";
+18
View File
@@ -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";
+1
View File
@@ -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
+21
View File
@@ -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";
+21
View File
@@ -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;
}
+42
View File
@@ -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);
}
+43
View File
@@ -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;
}
+41
View File
@@ -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.
+78
View File
@@ -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'.
+65
View File
@@ -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;
}
+17
View File
@@ -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;
+16
View File
@@ -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;
+22
View File
@@ -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]));
}
+37
View File
@@ -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};
}
+26
View File
@@ -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;
}
+699
View File
@@ -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);
};
+87
View File
@@ -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;
}
+98
View File
@@ -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()};
}
+35
View File
@@ -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;
}
+24
View File
@@ -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;
}
+25
View File
@@ -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";
+20
View File
@@ -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;
}
+14
View File
@@ -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";
+242
View File
@@ -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;
}
+11
View File
@@ -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";
}
+23
View File
@@ -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();
+19
View File
@@ -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;
}
+32
View File
@@ -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";
}
+20
View File
@@ -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";
+9
View File
@@ -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;
+11
View File
@@ -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;
}
+28
View File
@@ -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;
}
+93
View File
@@ -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;
+53
View File
@@ -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);
}
+15
View File
@@ -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";
+6
View File
@@ -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]];
}
+33
View File
@@ -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;
}
}
+40
View File
@@ -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;
}
+20
View File
@@ -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;
}
+11
View File
@@ -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)
+189 -9
View File
@@ -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
+65 -5
View File
@@ -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