diff --git a/.agents/secret-exceptions.json b/.agents/secret-exceptions.json index 7018143f5..45ebd4d59 100644 --- a/.agents/secret-exceptions.json +++ b/.agents/secret-exceptions.json @@ -1,6 +1,12 @@ { "version": 1, "exceptions": [ + { + "path": "Package.swift", + "type": "Hex High Entropy String", + "fingerprint": "sha1:eb7c49bf27318a1c351a6d03b59b194c80dc68d9", + "reason": "Public Swan Git commit pin 790df787281e9cf1c2358a11bb42b2ec2df01029, already used by Package.swift and Tools/ada-toolchain.json; not a credential" + }, { "path": "Tools/ada-toolchain.json", "type": "Hex High Entropy String", diff --git a/Documentation/AnimationGraphs.md b/Documentation/AnimationGraphs.md index 38d55d19b..0c19ee687 100644 --- a/Documentation/AnimationGraphs.md +++ b/Documentation/AnimationGraphs.md @@ -1,5 +1,23 @@ # Skeletal animation graphs +For editable project-owned clips, see [Animation authoring](../Editor/Documentation/AnimationAuthoring.md). +Bound `.animlib` clips use `library.runtimeName(for: clipID)` in existing graph clip nodes. + +In Studio, **Animator → Animation Graph** displays pose cards and wires on a canvas. +Drag an output to an input to connect cards, or drag a connected input to move its +connection while retaining its weight and bone mask. The right Inspector edits +the selected card and its input properties. **Fit**, middle-drag/**Pan**, and scroll +control canvas navigation. Node and wire edits support Undo/Redo; cyclic connections +are rejected. The card marked Output identifies the graph root. This visual editor +uses the clip/blend/additive model described here; state machines and blend spaces +remain separate extensions. + +The shared agent/Tools contract also exposes `graphInspect`, `graphApply`, +`graphUndo`, `graphRedo`, `graphPreview` and `graphBind` for an open model/library +workspace. Graph revisions are separate from clip-library revisions. See the +[request contract](../Editor/Documentation/AnimationAuthoring.md#agent-request-contract) +for paging, permissions and explicit scene publication. + `AnimationGraph` combines imported GLB/glTF clips into a single local pose. It is available through `AdaAnimation` and the `AdaEngine` facade. Each `SkeletalAnimationPlayer` owns its clock, graph buffers and pending events. diff --git a/Documentation/Designs/animation-authoring.md b/Documentation/Designs/animation-authoring.md new file mode 100644 index 000000000..f0e117719 --- /dev/null +++ b/Documentation/Designs/animation-authoring.md @@ -0,0 +1,707 @@ +# Animation Authoring, Shared Tools API, and IK Preparation + +Status: **Accepted design; implementation and validation in progress.** + +Amendment approval: explicit human approval in this conversation on 2026-11-05 (affirmative approval) +of section 11's visual workspace, hierarchy/Inspector, common timeline and node-canvas Graph. + +Approval: explicit human approval in this Codex conversation on 2026-11-04, followed by a request +for a staged implementation plan starting with skeletal animation editing and the shared agent/Tools API. +The local workflow record attests to that conversation message; it does not authenticate identity. + +Implementation plan: `Documentation/Plans/2026-11-04-AnimationAuthoring.md`. + +Follow-up request, 2026-11-06: the human requested implementation of advanced curves, +both confirmed 2D spline workflows, and actual IK. Curves and 2D/spline retain the scope +approved below. The separate [native IK design](animation-ik.md) proposes the solver +contract required by this design's explicit IK boundary; the human approved it on 2026-11-06. +This request does not close the existing M1 validation prerequisites. + +Date: 2026-11-04. Repository baseline: `385b8de9`. + +Classification: improvement of existing animation subsystems, with new authoring capabilities. +The original research records the inspected baseline, not the current implementation. M1 now has +local implementation and Windows evidence; its remaining validation prerequisites are recorded in +the implementation plan. The visual-workspace amendment below is proposed behavior, not shipped UI. +The screenshot is a Godot workflow reference, not a specification of AdaEngine's current capabilities. + +## 1. Intended Outcome and Scope + +Allow creators to extract and change an imported character animation, create additional clips from +scratch, and combine both through an Animation Graph. Provide the same authoring operations to +Studio's Animator, AI agents, and developer-authored project `Tools/` modules. + +The requested experience includes bone tracks, keyframes, many easing curves, manual tangents, +loops, 2D and 3D support, spatial splines, and an architecture suitable for later IK. Godot is the +primary conceptual and workflow reference; AdaEngine retains Swift, AdaScript, ECS, and AdaUI ownership. + +The research established this contract. The human has approved the design and requested the staged +implementation plan. Follow the +[contributor workflow](contributor-workflow.md) and +[feature-flow skill](../../.agents/skills/adaengine-feature-flow/SKILL.md). + +Confirmed requirement, 2026-11-04: in the research clarification, the human selected both spatial +path following and native 2D bones with deformation along a spline, delivered in separate stages. +Both belong to the intended capability scope. This answer resolves the 2D interpretation; it does +not by itself approve the design. The separate design approval is recorded above. + +Accepted scope boundaries: + +- Start with existing GLB/glTF imports and native authored resources. The FBX files visible in the + reference do not imply direct FBX import is part of this change. An external FBX-to-glTF workflow + remains possible; a native FBX importer is a separate proposal. +- Include spatial paths in both dimensions and native 2D bone/spline deformation. Separate their + delivery boundaries and prerequisites below; staging must not silently remove either confirmed workflow. +- Preserve the current clip/blend/additive graph and propose state machines and blend spaces as + extensions. Do not replace the existing graph with an unrelated subsystem. +- Prepare the procedural pose stage now; actual IK solvers, retargeting, automatic rigging, weight + painting, and root-motion extraction require subsequent scoped designs. + +## 2. Current Repository: Foundation and Gaps + +These are facts from checked-out source. Existing tests were inspected but were not executed for +this research; their presence does not establish current platform/runtime acceptance. + +| Area | Existing foundation | Gap relevant to the request | +| --- | --- | --- | +| Skeletal clips | `SkeletalRig`, named TRS tracks, LINEAR/STEP/CUBICSPLINE, morph tracks, independent players, seek, crossfade, reverse speed | No general editable skeletal-clip document or clip-authoring service | +| Pose graph | Codable acyclic graph with clip/blend/additive nodes, masks, events, atomic validation | No parameters/conditions, state machine, blend spaces, authored-library references, retargeting, or IK | +| Generic animation | Typed `KeyframeClip`, scalar/vector/quaternion sampling and version-2 JSON | Generic tracks and skeletal tracks are separate representations and players | +| Scene timeline | One target entity per clip, position XYZ and scale XYZ, scalar keys, three easing choices | No rotation/bone tracks, editable tangents, multi-target clips, or arbitrary registered properties | +| Loops | Generic once/loop/ping-pong/repeat count; skeletal repeat Boolean and signed speed | Authoring/playback settings and event semantics need a common explicit contract | +| Import | Native GLB/glTF TRS and morph animation; single influence set, maximum 128 joints per skin | No general edit/export round trip for an imported `ModelAsset3D` | +| Agent | Model inspection/validation, scene payload edits, procedural GLB generation with explicit joints and simple keys | No dedicated inspect/edit/sample/save API for existing skeletal clips or spatial curves | +| Developer Tools | Current macOS host contract: restricted AdaScript VM, `.ui` panel, document read and staged entity creation | No animation resources, curve/key editing, asset transactions, or animation preview API; Windows host parity is not established | +| Spatial splines | Navigation waypoint following and UI Bezier drawing helpers | Neither is an authorable animation path with arc-length sampling | +| 2D skeletons | Optional Spine adapter evaluates imported rigs, Bezier timelines, mixing, IK/path constraints | Spine exports are read-only; no general native 2D rig/spline authoring workflow | +| IK | Local/global pose buffers and an established animation-before-transform stage | No native Swift IK solver or ordered procedural pose-modifier API | + +Primary implementation evidence: + +- [Skeletal data and sampling](../../Sources/AdaAnimation/Skeletal/SkeletalAnimation.swift), + [player](../../Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift), + [graph](../../Sources/AdaAnimation/Skeletal/AnimationGraph.swift), and + [compiled graph](../../Sources/AdaAnimation/Skeletal/CompiledAnimationGraph.swift). +- [Generic keys and repeat modes](../../Sources/AdaAnimation/KeyframePrimitives.swift), + [JSON schema](../../Sources/AdaAnimation/KeyframeClipSchema.swift), and + [scene apply system](../../Sources/AdaScene/KeyframeAnimation/KeyframeAnimationApplySystem.swift). +- [Editor keyframes](../../Editor/Sources/AdaEditor/EditorKeyframeAnimation.swift), + [graph document bridge](../../Editor/Sources/AdaEditor/EditorAnimationGraphModel.swift), and + [current graph guide](../AnimationGraphs.md). +- [Native glTF animation import](../../Sources/AdaAssets/GLTF/NativeGLTFLoader+Animations.swift), + [runtime conversion](../../Sources/AdaScene/3D/ModelAsset3D+Animation.swift), and + [model resource](../../Sources/AdaScene/3D/ModelAsset3D.swift). +- [Procedural rig builder](../../Sources/AdaScripting/AdaScriptModelRigBuilder.swift), + [agent model service](../../Editor/Sources/AdaEditor/Agent/EditorAgentModelToolService.swift), + [Tool VM and bridge](../../Sources/AdaScripting/AdaScriptToolRuntime.swift), + [Studio Tools contract](../../Editor/Documentation/StudioTools.md), and + [Spine guide](../Spine2D.md). + +### Documentation conflict to repair during implementation + +The bundled [3D animation skill](../../Editor/Sources/AdaEditor/Assets/AgentSkills/ada-3d-animation/SKILL.md) +still says morph-weight animation is rejected. The native importer, runtime conversion, player and +rendering roadmap now implement morph animation. The bundled documentation snapshot repeats the older +limit. Do not use that text to remove supported behavior or treat it as the current importer contract. +Update the owning guide/skill and regenerate the offline snapshot when the implementation ships. + +Also distinguish procedural generation from editing: `editor.model.generate` can create a new GLB +with explicitly placed joints and linear translation/rotation keys. It does not expose an arbitrary +imported model as editable rig geometry. `ModelAsset3D` general encoding is unsupported; the procedural +GLB writer is not a lossless imported-model exporter. + +## 3. What to Adopt from Godot + +The official release archive identifies 4.7.2 as stable on the research date. Versioned 4.7 docs are +the baseline below; avoid silently basing the design on the moving `latest` branch. +[Godot release archive](https://godotengine.org/download/archive/). + +| Godot concept | Relevant behavior | AdaEngine recommendation | +| --- | --- | --- | +| Animation and AnimationLibrary | Resource data stores timed tracks; libraries name and group animations | Persist reusable authoring data separately from instance playback state | +| AnimationPlayer and AnimationTree | Clip authoring/playback and graph orchestration are separate; both 2D and 3D are supported | One clip library can feed direct playback or an extended graph | +| Transform/property tracks | Typed transforms coexist with general property animation | Efficient typed bone TRS plus a registered property-binding schema | +| Bezier editor | Keys have manually editable handles | Preserve curve data and handles in resources and the public API | +| Imported scenes | Customization is separated from the original source asset | Extract editable copies; retain provenance and report reimport conflicts | +| Curve2D/Curve3D | Spatial Bezier resources expose sampling and baked caches | Separate spatial paths from temporal easing and imported CUBICSPLINE channels | +| SkeletonModifier3D | Ordered procedural modification follows animation mixing | Define a post-blend pose pipeline before final transforms and skin palettes | + +Sources: [Animation](https://docs.godotengine.org/en/4.7/classes/class_animation.html), +[AnimationLibrary](https://docs.godotengine.org/en/4.7/classes/class_animationlibrary.html), +[AnimationTree](https://docs.godotengine.org/en/4.7/tutorials/animation/animation_tree.html), +[track types](https://docs.godotengine.org/en/stable/tutorials/animation/animation_track_types.html), +[import configuration](https://docs.godotengine.org/en/4.7/tutorials/assets_pipeline/importing_3d_scenes/import_configuration.html), +[Curve2D](https://docs.godotengine.org/en/4.7/classes/class_curve2d.html), +[Curve3D](https://docs.godotengine.org/en/4.7/classes/class_curve3d.html), and +[SkeletonModifier3D](https://docs.godotengine.org/en/4.7/classes/class_skeletonmodifier3d.html). + +Adopt the concepts selectively. Godot documents a limitation blending Bezier and property tracks; +AdaEngine should instead evaluate supported continuous tracks into typed values before mixing, with +explicit diagnostics for incompatible property domains. Do not promise that arbitrary values, events, +or method calls can be blended like a skeletal pose. + +### Modern IK reference + +The screenshot shows Godot 4.0.3. Its IK architecture is not the target. `SkeletonIK3D` is deprecated; +the modern design uses `SkeletonModifier3D` and `IKModifier3D`, including `TwoBoneIK3D` and chain solvers. +Godot's official IK article describes the incremental modifier work leading to Godot 4.6. +[IK design history](https://godotengine.org/article/inverse-kinematics-returns-to-godot-4-6/), +[IKModifier3D](https://docs.godotengine.org/en/stable/classes/class_ikmodifier3d.html), +[deprecated SkeletonIK3D](https://docs.godotengine.org/en/stable/classes/class_skeletonik3d.html). + +For 2D, Godot exposes skeleton modification resources such as CCDIK, but marks that API experimental. +Use its algorithms and user workflows as references rather than promising identical class contracts. +[SkeletonModification2DCCDIK](https://docs.godotengine.org/en/4.7/classes/class_skeletonmodification2dccdik.html). + +## 4. Proposed Ownership and Data Flow + +The names in the following sections are proposed API vocabulary, not available symbols. + +```mermaid +flowchart TD + UI[Animator timeline, curves, graph] --> Service[Editor animation authoring service] + Agent[Agent tools / MCP] --> Service + Tools[Developer Tools bridge] --> Service + Service --> Documents[Versioned library, graph, curve documents] + Documents --> Compiler[Validate and compile bindings and tracks] + Compiler --> Runtime[Instance-local animation evaluation] + Runtime --> Preview[Editor preview] + Runtime --> Game[Game runtime] +``` + +| Owner | Responsibility | +| --- | --- | +| `Math` | Reusable curve math and sampling primitives, without scene/editor dependencies | +| `AdaAnimation` | Sendable track/clip/graph data, validation, typed samplers, compiled graphs and pose-modifier contracts | +| `AdaAssets` | Resource serialization, provenance/import data and dependency discovery; no dependency on `AdaScene` | +| `AdaScene` | Entity/model bindings, ECS scheduling, instance playback, path following, transform and skin propagation | +| `AdaScripting` | Detached DTO conversion and scoped host bridges; no access to editor model objects | +| `Editor/` | Document service, resource history, timeline/curves/graph UX, preview sessions, Tool/agent adapters | + +Inspect manifest dependencies before implementation. Keep common resource DTOs in a lower shared owner; +do not create an `AdaAnimation`/`AdaAssets` cycle. Do not add editor dependencies to runtime modules. + +Keep existing generic and skeletal public APIs working. Introduce a common authoring schema with +compiler adapters into typed samplers, rather than immediately rewriting every runtime player. +Skeletal tracks need an adapter for editable per-segment curves; the current glTF interpolation enum +alone cannot represent the proposed temporal easing. Import sampling must remain equivalent. + +## 5. Resource, Target, and Reimport Contract + +Propose a versioned animation-library resource (`.animlib`, name subject to registration checks), +containing clip IDs/names, explicit duration, repeat metadata, typed tracks, stable key IDs, event +markers, source provenance and a schema version. Spatial curves and reusable graphs have their own +resource identities; exact file extensions must be settled in the approved implementation plan. + +Two creation operations cover the main user journeys: + +1. **Create clip:** start from the selected rig's rest pose or an explicitly captured pose and insert + local-space bone keys. A captured pose is copied data, not a live reference to playback buffers. +2. **Extract editable copy:** copy one imported clip into a project-owned library, retaining every + supported channel, source timing and cubic tangent. Editing never overwrites the GLB/glTF source. + A separate additive clip can correct an arm, head or other masked region while keeping the base. + +The initial editable copy is detached from later source-clip timing edits. Reimport updates the model +and imported clips; it does not overwrite the copy. Store source asset ID, source clip identity, +content fingerprint and rig-binding fingerprint to explain drift. Rebase is an explicit future +operation, not an automatic merge of conflicting animation keys. Always offer the original clip. + +Track targets identify an entity relative to an animation root, or a model/rig node plus its channel. +Persist authored IDs and an imported-node mapping; compile to indices for playback. Never persist +skin-palette offsets as bone identities. Names and ancestry paths help diagnostics and manual remap, +but duplicate names and changed hierarchy must produce unresolved-binding diagnostics rather than +guessing. A different skeleton requires an explicit compatibility check; this is not retargeting. + +Clip names are display labels; graphs reference stable clip IDs qualified by library, avoiding collisions +between imported and authored `Walk`. Keep compatibility for existing name-based `play` and embedded +`Model3DSource.animationGraph` payloads. Version migrations must preserve existing scene keyframes, +graph semantics and original curves. Unknown newer versions fail without rewriting resources. + +Reject non-finite values, negative key times, invalid quaternions, unsupported targets and invalid +durations. A track has at most one key at a timestamp: insertion at an occupied time must explicitly +replace or fail. Never silently sort away conflicting keys. Editing duration cannot silently delete +keys; offer an explicit trim or retime operation. An empty clip holds its declared rest/captured pose. + +## 6. Tracks, Curves, Keyframes, and Loops + +Initial authoring types: bone and entity translation/rotation/scale, scalar/vector/color properties +registered as animatable, morph weights, and named event markers. Property type and setter are resolved +through a schema, not arbitrary reflected writes. Unsupported properties are discoverable with reasons. +Events are data; arbitrary script/method/audio execution tracks are a separate extension. + +Separate interpolation from easing: + +- **Interpolation:** hold/step, linear, cubic Hermite with incoming/outgoing derivatives, and scalar + Bezier segments with editable handles. Preserve glTF CUBICSPLINE derivatives and time-span scaling. +- **Easing:** linear, quadratic/cubic/quartic/quintic, sine, exponential, circular, back, elastic and + bounce families, with in/out/in-out forms and relevant parameters. Back/elastic/bounce may overshoot; + validate constrained properties at the binding boundary rather than flattening every curve. +- **Manual editing:** free/broken, aligned, mirrored, auto, linear and flat tangent modes. Distinguish + Bezier handle coordinates from Hermite derivatives. Temporal Bezier X must be monotone, and the + evaluator solves time-to-parameter before sampling value. Invalid edits preserve the last valid draft. + +Use normalized quaternions for 3D rotation and shortest-path spherical interpolation for linear +segments, as today. Preserve imported quaternion cubic sampling exactly. Euler display controls are an +editing representation with explicit rotation order/units; do not interpolate raw quaternion components +using arbitrary scalar handles. More advanced native rotational splines require an explicit tangent +representation and tests, not an unchecked reuse of scalar Bezier code. In 2D, expose radians internally +and an explicit shortest-path versus continuous-turn setting for intentional multi-turn rotation. + +Key operations include insert/update/delete, move, multi-select, copy/paste, duplicate, snapping, +retime, channel mute/solo, capture pose and reset selected channels. Group a drag or bulk edit into one +undoable transaction. Sampling and scrubbing must use the production evaluator. + +Use explicit duration in seconds; FPS is display/snap metadata. Define once, loop, ping-pong, finite +repeat count, signed speed, pause, seek, hold-last/reset-to-rest stop policies and optional loop regions. +A loop region must have positive length within the clip. Retain current skeletal repeat Boolean as a +compatibility adapter. Degenerate one-key/zero-duration poses cannot divide by zero or emit endlessly. + +Preserve the existing graph event intervals: forward `(previous, current]`, reverse `[current, previous)`. +Seek, preview scrub and graph replacement do not dispatch gameplay events. Ping-pong turning points +must fire a marker once per actual crossing, without duplicates from adjacent segments. Large deltas +and multiple loops retain bounded event work and expose dropped occurrences, as the current graph does. +Any change to legacy generic clock clamping requires separate explicit compatibility evidence. + +## 7. Animation Graph Extensions + +Retain existing blend normalization, rest-pose fill, masks, additive rotation order and clip events. +Both imported and authored clips enter the same graph. Temporal curve shape is evaluated before pose +mixing, so it does not prevent blending an authored correction with an imported walk. + +Proposed additions, delivered incrementally: + +- Typed runtime parameters: bool, integer, scalar, Vector2 and one-shot triggers, with defaults and + deterministic snapshot updates. Parameters and playback state belong to each instance. +- State machine with named states, condition/trigger transitions, priority, crossfade curves, + optional exit time and interruption rules. Define deterministic tie-breaking and cap transitions + per tick. A state machine may contain transition cycles; its pose dependency graph remains acyclic. +- BlendSpace1D/2D for speed/direction, with explicit samples, validation and documented out-of-range + behavior. Preserve existing graph-time-derived clip phases; new per-state clocks and opt-in phase + synchronization need explicit policies rather than assuming today's graph has independent clocks. +- One-shot layers, masks and time scale/seek controls, with clear event contribution policies. +- A graph canvas for creation, wiring, parameter inspection and live state/weight visualization, + retaining the existing list editor as an accessible alternative where appropriate. + +Do not apply two independent animation writers to the same bone/property. A binding compiler must +report duplicate ownership or require explicit layer precedence. Generic property domains use typed +mixing policies; discrete values need selection rules rather than numeric blending. + +Root-motion extraction is reserved as a distinct output before owner transform application. It is not +included in the first authoring delivery and must not accidentally arise from applying a root track twice. + +## 8. Spatial Splines and Native 2D Animation + +Temporal curves map time to a value; spatial splines describe geometry. glTF CUBICSPLINE interpolation +does not provide a path-authoring system, a path follower, or a spline IK solver. + +Propose reusable 2D/3D cubic Bezier curves with point IDs, in/out handles, open/closed state and +local-space coordinates. Provide position/tangent sampling, approximate arc length, distance-based +sampling, and bounded-error baked caches invalidated by resource revision. Path followers animate +distance/progress using ordinary temporal tracks. Support forward/reverse/loop/ping-pong and optional +tangent orientation. Specify a stable up-vector/frame policy for 3D, including degenerate tangents, +closed seams and zero-length curves; do not promise constant speed from uniform Bezier parameter steps. + +The confirmed scope includes both path following and bone/spline deformation. Deliver it in stages: +first spatial paths, then native 2D bones and weighted deformation, then spline-driven bone deformation +using the procedural modifier stage. The following capabilities have distinct prerequisites: + +| Capability | Proposed relationship | +| --- | --- | +| 2D transform/cutout animation | Same library, timeline and graph concepts; 2D translation, angle and scale bindings | +| 2D path following | Shared spatial-curve resource and temporal progress track | +| 2D bone chains | Explicit hierarchy/rest pose and local pose tracks; reuse authoring concepts without pretending 3D skinning is sufficient | +| 2D weighted mesh deformation | Required for the confirmed deformation workflow; needs native mesh/weight authoring, triangulation and render support in a distinct delivery | +| Spline-driven bone chains | Required procedural modifier consuming a curve and driving deformation; separate from moving the whole character along a path | + +The optional Spine runtime continues to play exported content under its existing contract. Do not +edit its vendored C source or require Spine to use the new native authoring API. Native deformation +must not be claimed available merely because the Spine adapter already supports imported constraints. + +## 9. Preparing the Runtime for IK + +Proposed evaluation order: + +```text +advance clocks / graph state / collect marker crossings + -> sample clips and mix local poses + -> interpolate visual base pose when animation LOD requires it + -> ordered pose modifiers / future IK + -> rebuild final hierarchy transforms + -> publish node transforms and final skin palettes + -> transform propagation and render extraction +``` + +Today `SkeletalAnimation3DSystem` obtains a player that has already rebuilt transforms and then +updates node transforms and skin matrices. A modifier extension must create a deliberate finalization +boundary inside/beside evaluation; adding an unrelated system that edits bones afterwards would leave +player globals or skin palettes inconsistent. Private pose setters must remain protected by an API. + +Keep separate rest, evaluated base, and modified pose buffers. A modifier receives a validated rig, +local pose access and explicit transform-space conversion; it never rewrites the rig's rest pose or +feeds last frame's corrected result back as this frame's base. Modifiers run in stable order with +enabled state, influence and a bone mask. The pipeline blends each full-strength modifier result by +influence exactly once. Track final-pose generation separately from base sample generation. + +World-space targets must be resolved against the owner's current transform snapshot, not a stale +`GlobalTransform` from a previous update. Handle singular/nonuniform owner transforms with diagnostics +and a documented fallback. Mutable targets can invalidate a modifier while a base clip is paused. + +Start with a deterministic custom offset/look-at modifier to prove the stage. Future solvers can add +two-bone IK, CCD/FABRIK chains, pole targets, axis/angle limits, reach tolerances, iteration budgets, +reset behavior and explicit stretch policies. Different solvers need not share undocumented assumptions. +Spline chain constraints can consume the same spatial curves. Stateful/physics modifiers require +explicit simulation cadence and reset-on-seek rules; they are not automatically preview-safe. + +Animation LOD may reduce base-pose sampling while clocks/events continue. Initially run active stateless +modifiers after presentation interpolation on visible frames; do not double-apply them to LOD endpoints. +Offscreen/statistical/physics policies need explicit settings. Test a moving target with a paused clip, +seek, loop, instance duplication and LOD transitions before describing the architecture as IK-ready. + +## 10. Shared Authoring API: UI, Agents, and Developer Tools + +Create one editor document service. UI, MCP and developer Tools adapt requests into the same commands; +they must not implement three evaluators, schemas, validators or persistence paths. + +Proposed operations, available through capability discovery: + +| Operation group | Required operations | +| --- | --- | +| Inspect | Library/clip list, rig hierarchy/rest pose, compatible targets/channels, keys/curves, graph, diagnostics | +| Author | Create/extract/duplicate/rename clip; add/remove track; insert/update/move/remove keys; set duration/loop/curve/tangent; trim/retime explicitly | +| Graph | Create/edit nodes, connections, parameters, masks, markers and state transitions; validate atomically | +| Spatial curves | Create/read/edit points/handles and closed state; sample by parameter/distance | +| Evaluate | Sample clip/graph at time with parameter snapshot; return local/global poses and typed values without scene mutation | +| Persist | Commit resource revision, bind library/graph to scene owner, save/reopen, undo/redo | +| Preview | Start/update/end isolated preview session, seek and capture rendered results where supported | + +Suggested agent tool family: `editor.animation.inspect`, `.apply`, `.sample`, `.validate`, `.preview`, +with curve operations discoverable alongside them. These names are proposals. Agent metadata, +`editor.api.describe`, offline documentation and bundled skills must advertise only actually available +operations. Generic scene edits can continue to assign playback configuration, but cannot substitute +for authoring/validating clip data. + +Developer modules call the same contract through a proposed `editor.animations` host facade, with +detached request/result values. They do not invoke MCP or get unrestricted filesystem/native objects. +Retain ADR-0023's instance lifetime, project grants, revocation and generation checks. Extend the API +version/capabilities and permission model explicitly for resource read/write and preview; existing +document permission grants must not silently confer new asset-writing powers. + +The current Tool host contract is macOS-only; even its runtime schema gate requires the macOS platform. +Making this authoring workflow available to developer Tools in Windows Studio requires a host/platform +extension as part of that delivery, not merely animation methods or a Windows validation checkbox. +Keep that work explicit in the eventual approved plan and advertise actual platform capabilities. + +The present Tool bridge returns only staged entity creations and enforces synchronous callbacks with +a short execution budget. It needs typed command batches/results, not just extra methods in the static +language catalog. Small edits remain bounded; expensive compilation/import/preview tasks require host +jobs with a pollable result or a separately designed async extension. Do not silently increase or +remove VM budgets to fit long animation work. + +### Transaction and lifetime rules + +- Reads are revisioned detached snapshots, with pagination for large tracks/rigs. +- Every mutation includes the expected document/resource revision and typed IDs. Validate the entire + batch before applying it; failure leaves document and preview unchanged. Report errors by operation ID. +- A scene-plus-resource operation requires coordinated history. If the host cannot atomically commit + both, prepare the asset first and bind it explicitly afterwards, reporting that boundary. Never + advertise a cross-document atomic transaction before implementing it. +- Persistent edits enter normal dirty-state/Undo/Redo history. File writes reject conflicting dirty + open resources and stay inside project-owned paths. Separate an undoable document commit from Save. +- Preview is transient and restores the previous edit pose on close/cancel. Retired Tools cannot keep + mutating a preview or commit queued work. Preview/sampling never executes gameplay callbacks. +- Bounds on request size, keys, graph work and returned data are discoverable and diagnosed. Large + operations must not return a partially applied result disguised as success. + +Illustrative command envelope, **not an implemented invocation**: + +```json +{ + "resource": "Assets/Animations/Hero.animlib", + "expectedRevision": "r12", + "operations": [ + { + "id": "raise-hand", + "kind": "key.upsert", + "clipID": "wave", + "trackID": "right-hand.rotation", + "keyID": "hand-up", + "time": 0.35, + "value": { "type": "quaternion", "xyzw": [0, 0, 0.38268343, 0.92387953] }, + "easing": { "family": "sine", "mode": "inOut" } + } + ] +} +``` + +This assumes an already created library/clip/track bound to a compatible bone. UI, agent and Tool tests +must submit equivalent edits and obtain equal serialized data and sampled poses. + +## 11. Editor Experience + +Visual-control correction, specified by the human on 2026-11-06: follow the mandatory +[Studio control rules](../../Editor/Documentation/Design/Controls.md). All action controls +need an idle surface/border and interaction states; primary actions and active modes use +the accent. Text/number fields match the existing Inspector, TRS/quaternion fields use +inline colored X/Y/Z/W badges, and overflowing panels show scroll indicators. Apply these +rules to the asset workspace and scene Graph, retaining existing commands and history. + +### 11.1. Visual-workspace amendment, 2026-11-05 + +The human reviewed the local M1 UI and explicitly identified missing model hierarchy/settings, a +common skeletal timeline with transport and editable duration, and a node-canvas Graph. The provided +Godot screenshots are visual/workflow references. They do not request every Godot import option. + +This amendment refines the approved timeline/canvas intent and changes the earlier placement rule: +Timeline/Curves/Graph stay in the bottom **Animator**; hierarchy moves into a resizable **Model** +panel beside the center viewport, and selected-item properties use the existing right **Inspector**. +The amendment received approval because it changes panel ownership and brings visual graph editing +and basic grouped-key editing forward from later milestones. M1 cannot be presented as a finished +visual authoring workflow merely because its command API works. + +Source inspection at amendment time found a single selected-track key strip in +`EditorAnimationLibraryEditor`, a flat binding-node list, and a list/form-based +`EditorAnimationGraphEditor`. The asset workspace already owns one preview/player/camera and the +project service owns library revisions/history. Preserve those ownership boundaries. + +```text +Project files | Model hierarchy | Shared model viewport | Inspector + | search/tree | view / frame / grid / bones | selected item + | | | properties +--------------+----------------+-----------------------------+----------- +Animator: clip selector | transport | time | duration | loop | key | snap +Timeline / Curves / Graph +bone/channel rows | common seconds ruler + playhead + keyframes +``` + +The Model panel has its own horizontal divider, collapse/reopen action, and remembered width. It +does not replace the project file tree. The Animator retains its vertical divider and remembered +height. Inspector retains the workbench's existing resizing behavior. Clamp sizes after window +resize; shrinking the window must not push the viewport, timeline controls or Inspector outside +their panel bounds. At narrow widths, toolbar controls wrap or use labeled overflow; do not require +horizontal scrolling through a large form to reach transport. No second preview is mounted below. + +### 11.2. Model hierarchy, preview controls and Inspector + +- Show the complete imported node hierarchy, including skeleton/bones, transform parents and mesh + nodes, with type icons, expand/collapse and ancestor-preserving search. Bind by stable node IDs; + duplicate display names remain distinguishable. This is a model-asset hierarchy, not a mutable + copy of the project scene. Imported structure and rest data remain read-only. +- Share selection between tree, bone overlay, timeline rows and Inspector. Support additive/range + bone selection and explicit subtree selection. Selecting a track/key reveals the relevant tree + path. A viewport bone click selects its row; ordinary camera navigation does not change selection. +- Keep compact view controls beside the viewport: frame model/selection, perspective/orthographic, + grid/axes, bones and a preview-settings entry. Implement these controls through the actual preview + renderer/camera. Preview settings in Inspector include background and lighting intensity; changing + them changes the displayed image without modifying the model or authored clip. +- Inspector follows the most recent authoring selection: model summary and preview settings; node + path/type/rest versus sampled local TRS; clip duration/loop/origin; track channel/interpolation; + key time/value/tangents supported by the current evaluator; or graph node/edge properties. Show + mixed values for multi-selection. Clip selection and transport remain directly reachable below. +- Bone pose fields edit a temporary authoring pose at the current time. **Insert Key** commits the + chosen TRS channels for selected bones in one batch. Auto-key is explicit and off initially; + switching clip/time with an unkeyed pose offers key/discard/cancel. Numeric quaternion/Euler fields + preserve the established units/order and sampling semantics. Do not silently modify bind/rest pose. +- Read-only imported clips expose **Extract Editable Copy**. They still show all tracks and allow + scrubbing. Editable copies/new clips use the same hierarchy and timeline. Binding/load errors, + source origin, dirty state and the explicit save target stay visible. Import settings that lack + an AdaEngine implementation must not appear as working controls. + +### 11.3. Common skeletal timeline and transport + +- One vertically scrolling track tree and one synchronized key area show all rig bones. Each bone + expands to translation/rotation/scale channels, with existing morph tracks shown separately. + Empty bone rows remain available for creating tracks. Filters for selected bones, animated-only + rows, channel type and name are explicit; the initial view includes the full skeleton. +- Draw one shared seconds ruler, duration/end marker and playhead across the visible rows. Keep + labels pinned while horizontally scrolling/zooming time; keep ruler and rows aligned during + vertical scroll and resize. Collapsed bone rows summarize keyed times without hiding selection. +- Clicking/dragging the ruler or playhead pauses and immediately samples the real rendered model. + Numeric time entry uses the same seek path. During playback, the playhead/time field follow the + presented animation clock without rebuilding the model or feeding updates back into seek. +- Transport provides play/pause, stop-to-start, previous/next key, step backward/forward by the snap + interval, playback speed, current time, clip selection, duration, loop, snap and frame-all-keys. + Loop uses the existing repeat behavior; ping-pong/loop regions remain the later playback milestone. +- Duration is edited in seconds and may extend the final held pose. Reject non-finite/negative + duration or a reduction past existing keys with an actionable explanation; never silently trim + keys. Clip switching retains selection only for compatible IDs and clamps the playhead to range. +- Insert/update/delete keys at the playhead for selected bone/channel rows. Existing keys at the + same track/time are updated rather than duplicated. Support multi-key click/range/box selection + and a common horizontal drag with optional snapping. Preserve relative offsets across tracks; + reject collisions/out-of-range results atomically. One completed gesture is one Undo operation; + Escape cancels, and stale revisions abort without partially writing any track. +- Keep Curves as a separate view of the same selected tracks/keys. Current interpolation/tangents + remain editable through Inspector. Full easing families, new tangent modes and advanced retiming + remain M2; basic shared-ruler and grouped-key interaction belong to this correction. + +### 11.4. Graph as a node canvas + +- Make the bottom Graph view a pannable, zoomable canvas with draggable clip, blend and additive + cards, named input/output ports and visible curved wires. Include a clear final Output sink for + the graph root. Node insertion, connection drag, reconnect, wire/node selection and deletion, + frame-all and keyboard Undo/Redo must operate on the existing graph semantics. +- Inspector edits the selected node's clip/time/speed or the selected connection's weight and + bone/subtree mask. Connection order remains explicit for additive base versus contributions. + Offer a keyboard-accessible connection/property path alongside pointer wiring. +- Keep incomplete graph drafts editable with diagnostics. Reject cyclic/invalid publication and + continue rendering the last valid graph. Show validation feedback at the relevant card/connection. + Existing clip/blend/additive behavior is unchanged; state machines, blend spaces and parameters + remain M3 and are not prerequisites for delivering this canvas. +- Scene graph data continues to save through `Model3DSource.animationGraph` and scene history. + Asset-only graph preview drafts, card positions and pan/zoom persist in project workspace state, + keyed by asset/node IDs; label the graph as an asset preview draft. Applying it to a scene is an + explicit action through the current validated scene binding path. Saving a library must not imply + that an asset preview graph was written into the GLB or exported as a portable graph resource. + +### 11.5. State, shared API, performance and acceptance + +`EditorAnimationAssetWorkspace` owns active clip/time/selection, temporary pose, timeline view and +model-panel state; reuse contextual Inspector ownership for activation/cleanup. Project resource +drafts and semantic edits stay in the shared authoring service. Existing graph command/scene adapters +remain the semantic entry points; add a bounded transactional adapter where canvas gestures need it. +Use the existing Shader Graph canvas/input patterns where applicable without importing shader +semantics. Card layout, panel widths and selection are editor state, not new runtime graph nodes. + +Every clip/key/graph data edit exposed by these controls must remain accessible to agents and developer +Tools through the same validated operations. Preview seeks/pose sampling use the shared preview +boundary. Do not add a UI-only mutation path or relax permissions, revisions or source restrictions. +Dense imported clips draw and hit-test only visible rows/time ranges, with bounded caches. Record +interaction timings on a dense fixture before claiming acceptable scrub/drag performance. + +Acceptance requires real native input and rendered evidence on Windows and macOS: + +1. Open a GLB with no scene, expand its hierarchy, select multiple bones, resize/collapse/reopen the + Model panel and Animator, and inspect synchronized tree/timeline/overlay/Inspector selection. +2. Scrub to an exact second, play/step/stop, switch clips, extend duration and toggle loop. The + playhead and rendered pose agree; the viewport/camera instance survives all mode changes. +3. Extract a source clip, insert keys for a group of bones, box-select keys on several tracks, drag + them, cancel a drag, Undo/Redo and save/reopen. Verify atomic collision/duration/stale-edit errors, + unchanged source GLB bytes, sampled poses and equivalent agent/real-Tool edits. +4. Add two clip cards and a blend card, wire them into Output, adjust a weight/mask in Inspector, + pan/zoom, reconnect/delete/Undo, attempt a cycle and apply to a scene. Save/reopen both scene data + and asset preview/layout state with their distinct documented destinations. +5. Verify 1024-by-700 and large/high-DPI layouts, keyboard access, English/Russian labels, dense-row + scrolling/hit testing and context cleanup on document/project switches. Change preview settings + and capture their visible effect. Native UI screenshots alone do not establish game playback. + +Update `Editor/Documentation/AnimationAuthoring.md` and `Documentation/AnimationGraphs.md` with the +new layout and executable walkthrough; keep `Editor/Documentation/EditorGuide.md`, Studio Tools +navigation/example and the DocC authoring article accurate. Regenerate/check offline documentation, +run localization checks, applicable production-path tests and native acceptance, then obtain an +independent review. Approval of this amendment does not waive existing M1 validation blockers. + +## 12. Recommended Delivery Order and Alternatives + +These are research recommendations, not a post-approval implementation plan or time estimates. + +| Slice | Observable outcome | Dependency | +| --- | --- | --- | +| A. Editable skeletal clips and service | Extract a glTF clip, key a bone, create a second clip, save/reopen and preview through existing graph | Versioned resource, stable bindings, compiler adapters, shared command service | +| B. UI/agent/Tools parity | Perform the same bone edit from Animator, an agent and a project Tool, including revision errors and Undo | A; Tool permissions/command transport and discoverability must ship with exposed operations | +| C. Curve and playback depth | Manual tangents, easing families, rotation/property/morph tracks and full loop semantics | A/B and common evaluator contract | +| D. Graph orchestration | Parameterized locomotion, transitions, blend spaces and live graph inspection | Imported/authored clip resolution and per-instance state | +| E. Spatial and 2D authoring | Staged 2D/3D path animation, native 2D bones/weighted deformation, and spline-driven bone deformation | Common resources/bindings; mesh/weight/render support; spline-chain stage depends on F | +| F. Procedural pose readiness | Custom modifier composes with clips, graph and LOD without palette disagreement | Finalization boundary designed in A; actual IK solvers follow separately | + +Each slice exposes its usable operations to agents and Tools when it ships; API access is not deferred +until after a UI-only system is finished. Schema and scheduling decisions from E/F must be considered +in A even if their full implementations arrive later. + +Alternatives considered: + +- **Only improve the existing six-channel timeline:** smallest UI change, but cannot author imported + bone tracks or expose a coherent resource API. Insufficient for the requested workflow. +- **Edit/rewrite GLB directly:** would need a lossless importer/exporter for unrelated data and offers + poor reimport ownership. Keep source assets plus native authored copies as the initial contract. +- **Use Spine as the entire solution:** useful optional 2D playback, but does not solve native 3D + bone editing or developer Tool authoring. +- **Build separate agent generators:** generation already exists in a limited form; duplicating + semantics would make UI/Tool edits incompatible. Use one authoring service. +- **Replace all animation runtimes at once:** high compatibility risk across UI, scene, skeletal and + Spine animation. Prefer a shared schema with explicit adapters and regression coverage. + +## 13. Acceptance Evidence and Documentation Delivery + +Before implementation, declare exact task-owned paths and record the approved design through the +contribution runner. Use real Red/Green/Refactor cycles for testable behavior. Extend the nearest +Swift Testing suites and production editor/VM paths; do not use source-string assertions as evidence +that animation plays or authoring persists. + +Required observable scenarios for the corresponding delivered slices: + +1. Import a real skinned GLB, extract an animation, edit a named hand rotation key and save/reopen. + Original bytes remain unchanged. Imported LINEAR/STEP/CUBICSPLINE samples match the source before + edits, including quaternion/tangent behavior and morph channels. +2. Create a clip on the same rig from rest/captured pose, key it at several times, blend/add it over + an imported walk with a subtree mask, and demonstrate independent duplicated instances in Play. +3. Round-trip every supported curve/tangent mode and loop mode; test endpoints, overshoot, reverse, + ping-pong, multi-loop deltas, event boundaries, degenerate clips and invalid data rollback. +4. Apply equivalent edits via Animator, agent service and a real `.ada` Tool action. Compare resource + data and sampled poses; exercise stale revisions, revocation, reload, budgets, Undo/Redo and Save. +5. Reimport a changed rig with duplicate/renamed/reordered nodes. Keep authored data, diagnose binding + drift and require explicit remap. Existing name-based clips and embedded graphs still load. +6. Exercise state transitions, triggers, interruption, tie-breaking, blend spaces and graph validation + without sharing playback state between instances or allowing cyclic pose dependencies. +7. Move 2D/3D targets along edited curves using distance-based sampling; test speed tolerance, seams, + tangent frames and zero-length paths. For the native 2D stages, deform a real weighted mesh with + animated bones, then drive the chain along an editable spline and verify the rendered deformation. + Exercise curve edits, reverse playback, loops, degenerate paths, save/reopen and shared API parity. + Passing the path-following stage alone does not satisfy the confirmed bone/spline deformation scope. +8. Run a custom modifier over a paused/playing/LOD-interpolated pose with a moving target. Compare + final bones, globals and skin palettes, including seek/reset and independent instances. + +Use existing `AdaAnimationTests`, `AdaSceneTests`, glTF import tests and Editor keyframe/graph tests +as the starting regression set. Run new real Tool VM/UI tests and visible Play/capture checks. GPU +skin/deformation changes also require the repository's golden-frame/backend validation. Record CPU, +allocation and memory baselines for large key sets/rigs and repeated edits before setting budgets; +avoid compilation, binding resolution and curve-cache rebuilding in steady-state per-frame evaluation. + +Runtime data/math must remain portable across existing engine targets. Validate engine and editor +packages separately. Target primary Windows/macOS Studio authoring, including the required Windows +developer Tool host extension; prove those paths and runtime behavior on supported exported targets. +Declare mobile/browser host limitations explicitly. Missing toolchains, +GPU hosts or platform checks are blockers for those claims, not passing or inapplicable checks. + +User-facing documentation must ship with each implementation slice: + +- New DocC article at `Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationAuthoring.md`, + linked from `Sources/AdaEngine/AdaEngine.docc/AdaEngine.md`: prerequisites, Swift example, binding, + curves/loops/graph behavior, compatibility and runtime/platform limits. +- New Studio guide at `Editor/Documentation/AnimationAuthoring.md`, linked from the existing + `Documentation/AnimationGraphs.md` and `Editor/Documentation/StudioTools.md`: extract/edit/create, + timeline/curve workflow, agent operations and a complete project Tool example. +- Update `Editor/Documentation/Agent3DWorkflow.md`, the bundled `ada-3d-animation` skill and API + catalog as actual capabilities ship. Include the guide in the offline generator's maintained inputs, + regenerate its snapshot and run `python Editor/scripts/update-offline-documentation.py --check`. +- Compile/run the Swift example and the complete AdaScript Tool example against production APIs; + build DocC with the repository command. The proposed JSON above does not count as a verified example. + +Run `python script/check_contribution.py` at stage boundaries, obtain independent review after code +validation, fix findings and repeat affected checks/review. Future persistent architectural choices +that change accepted ADRs require an ADR rather than rewriting historical decisions. + +## 14. Research Validation and Remaining Decisions + +Research method: inspect current runtime/editor/importer/agent/Tool source and nearest tests, compare +official Godot 4.7 references, and obtain independent repository exploration. No engine/editor build, +Swift test, GPU run, live installed-editor API check or benchmark was performed. Findings concern +checked-out source; an already installed Studio may expose an older capability set. + +The original research change owned only this design document and passed the contribution +**design-stage content gate** and document hygiene checks. The follow-up planning change also owns +the implementation plan identified above and validates the plan-stage gate. These checks establish +document hygiene, not working animation. +Full validation requires provisioning the pinned analyzer environment under Python 3.11+ and the +relevant Swift/platform tools; the host's default Python is currently 3.9.13. + +Resolved requirement: both spatial path following and native 2D bone/spline deformation are included, +with staged implementation, per the human's 2026-11-04 clarification answer. + +Planning direction confirmed by the design approval and follow-up request: + +- Use the editable-copy/provenance workflow and staged graph extensions described above. +- Start with skeletal animation editing and the shared API for the agent and developer Tools. +- Deliver the confirmed 2D deformation stages later; retargeting, direct FBX import and actual IK + solvers remain separate scoped follow-ups. + +The design approval establishes the direction. Runtime acceptance, implementation estimates and +successful validation of the proposed APIs still require implementation evidence. diff --git a/Documentation/Designs/animation-ik.md b/Documentation/Designs/animation-ik.md new file mode 100644 index 000000000..876e2c0e3 --- /dev/null +++ b/Documentation/Designs/animation-ik.md @@ -0,0 +1,267 @@ +# Native Animation IK: Two-Bone and FABRIK + +Status: **Accepted; implementation and validation in progress.** + +Approval: explicit human approval in this Codex conversation on 2026-11-06, in the +annotation on the previously delivered design link, with permission to begin implementation. +The local workflow record attests to that message; it does not authenticate identity. + +Implementation plan: [Native Animation IK](../Plans/2026-11-06-AnimationIK.md). + +Date: 2026-11-06. Parent: [Animation Authoring](animation-authoring.md). + +Request: implement the remaining advanced curves, both confirmed interpretations of 2D +spline support, and actual IK. Curves and native 2D/spline work retain their existing +approval and [M2–M7 plan](../Plans/2026-11-04-AnimationAuthoring.md). This document supplies +the separate solver contract explicitly required by the parent design. Approval of the +parent's modifier preparation did not approve an unspecified solver. + +## 1. User outcome and delivery boundaries + +A creator can select a rig, add an IK constraint, choose bones and a target, and drag the +target while an imported or authored animation plays. The same result must be available +while paused and from an agent or a developer-authored Tool. A 2D tail can independently +follow a spline and deform its native weighted mesh; moving an entire object on a path +does not count as that deformation workflow. + +The requested work has these separately observable outcomes: + +| Work | Required result | Existing agreement | +| --- | --- | --- | +| Advanced curves | Named easing families, editable Hermite/Bezier handles and modes, production sampling and undoable edits | Parent section 6, M2 | +| Spatial splines | Editable 2D/3D paths; distance/progress following and optional tangent orientation | Parent section 8, M5 | +| Native 2D deformation | Native rig, weighted mesh, animation, and spline-driven chain deformation | Parent section 8, M6/M7 | +| Pose finalization | Ordered modifiers after mixing/LOD and before final node/skin publication | Parent section 9, M4 | +| Actual IK | Two-bone and FABRIK constraints in 3D and native 2D, specified below | New scope in this document | + +This does not withdraw M2's property/playback work or the existing M3 graph roadmap. +New state machines and blend spaces are independent of this solver extension; existing +clip/blend/additive graphs must already compose correctly with it. + +## 2. Repository facts and reference choices + +`SkeletalAnimationPlayer` owns local poses and global transforms. Sampling and +`presentPose` rebuild transforms internally. `AnimationLOD3DState` presents interpolated +poses, after which `SkeletalAnimation3DSystem` publishes node transforms and skin matrices. +Its paused-player fast path can skip evaluation. A separate system that edits node entities +afterward would disagree with the player, skin palettes and LOD buffers. + +There is an existing native `Mesh2D` renderer, but no native 2D skeleton/weight authoring +system in this worktree. The optional Spine integration remains a separate imported-content +runtime and is not a prerequisite for native IK or mesh authoring. + +The Godot references are conceptual, not a source/API compatibility promise: + +- [SkeletonModifier3D](https://docs.godotengine.org/en/stable/classes/class_skeletonmodifier3d.html) + applies modifiers after animation mixing and exposes influence. +- [TwoBoneIK3D](https://docs.godotengine.org/en/stable/classes/class_twoboneik3d.html) + uses a pole target to determine the bend plane. +- [FABRIK3D](https://docs.godotengine.org/en/stable/classes/class_fabrik3d.html) + solves chain positions; twist and near-root configurations need deliberate behavior. +- [Curve2D](https://docs.godotengine.org/en/stable/classes/class_curve2d.html) + separates editable Bezier control points from baked distance samples. + +These official pages were inspected on 2026-11-06. They are moving references; implementation +must record a source revision if any Godot code is actually reused and preserve its license. +The intended implementation is native Swift math and ECS integration. Do not copy an old +`SkeletonIK3D` workflow into an unrelated late transform system. + +## 3. Constraints and persistence + +An ordered modifier stack belongs to each rig instance. Entries have a stable ID, display +name, solver kind, bone IDs, enabled flag, influence in `[0, 1]`, target binding and solver +settings. Names are labels; authored bone IDs resolve through the saved rig binding. +Two instances may share immutable configuration but never mutable pose/solver buffers. + +Persist model-space solver configuration in a versioned `.animmod` resource owned by +`AdaAssets`; the extension must be registered through the normal resource/document path. +Its binding identifies either the existing complete 3D rig snapshot or M6's native 2D rig. +The scene instance stores the resource reference and scene-target overrides. A scene entity +reference must not leak into a reusable clip/library. Unknown schema versions and changed +rigs fail without rewriting data or guessing from duplicate names. + +Each target is either an explicit model-space point or a scene entity plus a local offset. +A detached asset preview supports model-space points and editable viewport target gizmos; +it diagnoses unavailable scene bindings. Scene bindings use stable scene identities and the +current transform hierarchy snapshot. They must not consume stale prior-frame globals. +Scene targets and pole bindings must lie outside all animated rig subtrees in this initial +delivery, including other rig instances. Reject feedback/cross-rig final-pose dependencies +rather than adding an implicit ordering or a previous-frame target. Ordinary scene marker +entities are supported. Resolve all owners, markers and pole offsets from one current +pre-modifier hierarchy snapshot before any instance's stack runs. + +Initial bounded limits: at most 32 modifiers per instance, at most 64 segments per chain, +1–64 FABRIK iterations, and a compiled total of at most 32,768 segment-iterations per stack. +Validate limits before allocating solver buffers. Ordinary `.animlib` files and source GLB +files are not rewritten when a modifier resource or scene binding changes. +Reject non-finite points, offsets, influence, tolerances and transform data; enforce exact +component counts and unique bounded IDs in decoded and programmatically edited DTOs. + +## 4. Solver behavior + +### Two-bone IK + +Select three distinct, directly parented joints: root, middle and end. There are exactly two +nonzero segments; imported helper joints require explicitly choosing a compatible chain or +using FABRIK. Validate animated nodes have editable TRS. The solve rotates root and middle; +it preserves local translations/scales and the end joint's authored local orientation. +The resulting world end orientation is not independently locked to the target. + +Segment lengths come from the current pre-modifier model-space pose. No implicit stretching +or scale edits are permitted. Clamp an unreachable target to the feasible annulus between +`abs(length1 - length2)` and `length1 + length2`; report reach error. A target at the root or +an exactly folded/straight chain uses the current base-chain direction and a deterministic +orthogonal fallback, never normalization of a zero vector. + +3D exposes a pole point determining the bend plane. If target/pole vectors are collinear, +derive the plane from the unmodified base pose; if that is degenerate, use a deterministic +axis chosen by smallest alignment with the chain direction. Do not use last frame's solved +pose as a hidden warm start. In 2D expose a signed bend direction; a pole gizmo selects that +side. Endpoint position is the goal; arbitrary wrist rotation and full-body balance are separate. + +### FABRIK + +Select an ordered contiguous chain and its target. Keep the root fixed and preserve each +current base-pose segment length. Run full-strength backward/forward passes until endpoint +error is within the configured positive tolerance or the validated iteration budget expires. +Defaults are 16 iterations and tolerance `max(1e-5, chainLength * 1e-4)` in model units. +For a target outside outer reach, extend toward it without changing lengths. An inner +unreachable gap exists if the longest segment exceeds all the others combined: its radius +is `max(0, 2 * longestLength - chainLength)`. Inside that gap, retain the best valid bounded +solve and report original-target error/nonconvergence; do not apply the outer-extension rule. + +A straight base chain aimed at an interior reachable target can be a false fixed point even +with nonzero vectors. Detect collinear configurations and introduce a deterministic bend +seed before the first pass: use the base bend plane when available, otherwise the least-aligned +axis (ties X, then Y, then Z), with positive planar bend in 2D. Use a displacement scaled by +chain length and tolerance; full forward passes restore lengths before accepting a candidate. +Near-root and zero-direction cases use the same deterministic policy. Keep the best valid +completed pass by endpoint error, with earliest-pass tie-breaking; never seed from prior +solved state. This does not promise convergence within every selected iteration budget. + +Reconstruct local rotations with each rig's actual bone directions and current parent frame; +never assume every bone points along the same exported axis. In 3D preserve base-pose twist +using minimal direction rotation. In 2D use planar signed angles. Leave local scale and +translations intact. Recompute affected globals as needed within the stack. +For a continuous-turn 2D angle, add the shortest corrective delta to this frame's base angle; +do not normalize away its authored full turns. Influence scales that delta once. + +The first FABRIK delivery has no general joint-angle or swing/twist constraint system and +no pole guarantee; use two-bone IK for explicitly controlled elbow/knee bending. CCD, +stretching, joint limits, physical/spring solvers, retargeting, automatic rigging, weight +painting and full-body IK remain separately scoped extensions. Do not label them supported. + +## 5. Pose pipeline, spaces and failure behavior + +The shared finalization order is: + +```text +advance playback/graph clocks and markers + -> sample/mix immutable base local poses + -> interpolate visual base pose for LOD + -> resolve current owner and target transforms + -> evaluate ordered modifiers on scratch poses + -> blend each modifier's rotation result by influence exactly once + -> rebuild final globals, publish node transforms and skin palettes + -> native 2D vertex deformation / render extraction +``` + +Keep rest, evaluated base and final modified pose buffers separate. Repeated evaluation at +the same base time and targets produces the same pose. Disabling a modifier restores the +current base contribution, not a cached earlier frame. Crossfade sources and LOD endpoints +contain base poses, never poses with IK already applied. Moving targets and changed influence +invalidate finalization even while clip time is paused or LOD skips base sampling. Clock and +marker semantics remain unchanged; target movement and scrubbing emit no gameplay markers. + +Solve in model space. World-space targets are converted using the current owner transform. +Initially support rigid transforms and positive uniform owner/ancestor scale. A singular, +reflected, sheared or nonuniform transform on a participating chain is unsupported for IK; +diagnose it and use the current base pose for that modifier. Other animation continues. +Local nonuniform scale elsewhere in the model must not disable an unrelated valid chain. + +Static invalid configuration fails atomically before replacing the previous valid draft. +If a scene target disappears or a runtime transform becomes unsupported, skip that modifier +for this evaluation and expose a bounded diagnostic. Never publish NaNs, reuse an old solved +pose, or partly modify the chain. Iteration exhaustion is a valid approximate solve with +`converged = false` and measured error, not a reason to suppress the entire animation. + +Initial solvers are stateless. Seek, reverse playback, looping, duplication, project reload +and target rebinding therefore require no undocumented simulation history. Overlapping +constraints execute in saved list order; a later modifier observes earlier blended results. + +## 6. Studio, agents and developer Tools + +Retain one shared viewport and the bottom Animator. Add a **Modifiers** section to the +resizable Model panel: select, add, remove, reorder and enable constraints. Selecting one +shows bone pickers, target/pole bindings, influence, tolerance/iterations, reach/convergence +and diagnostics in the existing Inspector. The viewport displays chain/target/pole gizmos; +dragging a gizmo is one reversible edit. No second detached animation preview is introduced. + +The Animation Graph canvas continues to evaluate the base pose; the Inspector makes the +ordered post-graph modifier stack explicit. Modifiers must not be presented as ordinary +cyclic pose-graph nodes. Influence and model-space target/pole coordinates are registered +animatable properties using M2's typed bindings; scene targets can also move by normal scene +animation. Auto Key uses the same transport/time as the clip timeline. + +Proposed shared operations are modifier inspect/apply/sample/save/bind and Undo/Redo, under +the existing animation host service. Typed commands cover add/remove/reorder/update and +target binding. Use resource and scene expected revisions; an atomic bind must include both. +Return capability discovery, typed errors and bounded pose/diagnostic responses. The agent +and real Tool VM consume the same DTOs and commands as Studio. A numerical-only helper that +does not update the scene, preview, history and persisted resource is not API parity. + +Reuse the existing project-relative path, import, permissions, conflict and job-size rules. +No unrestricted file writes or arbitrary callbacks are exposed through a solver or Tool. + +## 7. Ownership and compatibility + +| Owner | Responsibility | +| --- | --- | +| `Math` | Reusable robust vector/rotation/Bezier math; no editor/scene dependencies | +| `AdaAnimation` | Validated modifier DTOs, compiled chains, deterministic solvers and base/final pose boundary | +| `AdaAssets` | Versioned modifier resource IO and rig identity/dependencies | +| `AdaScene` | Current target transforms, per-instance stacks, LOD/scheduling and final publication | +| `AdaSprite` / existing 2D mesh path | M6 deformation output, updated instance bounds and rendering | +| `AdaScripting` | Bounded detached DTO/job bridge and capability catalog | +| `Editor` | Common service/history, Model panel, Inspector, viewport gizmos, agent/Tool adapters | + +Legacy scenes with no modifier stack retain their sampling, paused fast path and serialized +representation. Existing linear/step/imported cubic interpolation remains equivalent while +M2 adds native curves; original cubic quaternion derivatives are not scalar Bezier handles. +Native 2D remains usable without the optional Spine dependency. + +## 8. Acceptance and documentation delivery + +For each solver in each dimension, use real production evaluation to verify reachable and +unreachable targets (including the inner gap), root-coincident cases, a straight chain with +an interior reachable collinear target, preserved lengths, bend direction, +rotated parent frames, supported uniform scale, invalid-chain rollback and influence 0/0.5/1. +Check deterministic iteration limits and no drift over repeated paused/seek/loop evaluations. + +Integration tests exercise two independent model instances, crossfade and existing graph +mixing, moving targets under animation LOD, target deletion/rebinding and overlapping stack +order. Compare final node globals, skin palettes and actual visible deformation. Native 2D +acceptance compares final bone poses, weighted vertices, bounds and rendered frames; an +overlay of bones without mesh deformation is insufficient. + +Studio tests exercise chain selection, gizmo drag, Auto Key, one-step Undo/Redo, save/reopen +and unsaved-draft recovery. Equivalent UI, agent and real developer Tool commands must +produce equal saved DTOs and samples. Include both successful and stale-revision transactions. + +Ship a discoverable DocC article at +`Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationIK.md`, linked from DocC Topics +and the existing AnimationAuthoring article. Extend `Editor/Documentation/AnimationAuthoring.md`, +`Documentation/AnimationGraphs.md`, the owning asset/agent references and offline catalog. +Extend the real AnimationHelper Tool with target/influence controls and document its fixture. +Examples cover a 3D arm/leg target and a native 2D chain/weighted tail, including current limits. + +Record genuine Red/Green/Refactor, owning/downstream tests, resource round-trips and dense +chain/editor benchmarks. Build DocC and validate offline guides/examples. Run native Studio +and Play acceptance on Windows and macOS; validate affected Metal/WebGPU rendering and +platform builds using repository checks. Missing tools/hardware remain blockers. Existing +M1 validation blockers are retained separately; this proposal does not close that milestone. + +After approval, update the staged implementation plan and exact runner ownership, then +implement curves, the shared pose boundary, the solvers and native spline/deformation +dependencies in observable vertical slices. Obtain independent review before closing each +delivery. This document itself is design evidence, not runtime acceptance. diff --git a/Documentation/Plans/2026-11-04-AnimationAuthoring.md b/Documentation/Plans/2026-11-04-AnimationAuthoring.md new file mode 100644 index 000000000..a74f6c504 --- /dev/null +++ b/Documentation/Plans/2026-11-04-AnimationAuthoring.md @@ -0,0 +1,402 @@ +# Animation Authoring Implementation Plan + +Date: 2026-11-04. + +Design: [Accepted animation authoring design](../Designs/animation-authoring.md). +Approval: explicit human approval in this Codex conversation on 2026-11-04. +Requested first delivery: skeletal animation editing and the shared API for agents and developer Tools. + +Status: **M1 implementation and validation in progress.** Runtime libraries, the bone keyframe editor, +agent commands and developer Tool API 2 are implemented. Final editor regressions, native acceptance +and independent review remain in progress; required macOS evidence and SwiftLint are unavailable on +the current Windows host. M1 is not closed. Native temporal-curve work in M2 and the +3D modifier/IK foundation are in progress; other remaining stages are not delivered. The local approval record +attests to the conversation message, not identity. + +## 1. Delivery Strategy + +### Approved visual-workspace correction, 2026-11-05 + +The human approved design section 11 on 2026-11-05. Implement these behavior slices through the +existing shared document/preview owners, with focused production-path Red/Green/Refactor evidence: + +1. Replace the existing Graph list with the shared node canvas: cards/ports/wires, selection, + connection validation, grouped gestures/history and contextual property editing. +2. Add the resizable Model hierarchy and contextual animation Inspector. Share stable node/track/key + selection with the viewport and timeline; preserve workspace/preview lifetime across panel changes. +3. Replace the selected-track strip with the common skeletal timeline: hierarchy/channel rows, + ruler/playhead/transport, clip/duration/loop controls, grouped key insertion/selection/drags and + atomic error/Undo handling through the shared service. Include imported read-only timelines. +4. Complete preview settings, project workspace persistence, keyboard/compact layouts and dense-data + interaction checks. Keep semantic scene graph persistence distinct from asset preview/layout state. +5. Update Studio/Graph/DocC guides, navigation, real Tool example and EN/RU catalogs; run owning + regressions, staged native acceptance and independent review of the final state. + +Initial additional owned paths: `EditorAnimationGraphModel+Canvas.swift`, +`EditorAnimationTimelineModel.swift`, `EditorAnimationTimeline.swift`, `EditorAnimationModelPanel.swift` +and `EditorAnimationInspector.swift` under the existing Editor animation/UI owners, plus +`EditorContextualInspector.swift` and the nearest animation tests. Record exact paths before editing. +This brings basic visual/grouped-key editing into M1. Advanced M2 curves/playback and M3 orchestration +remain separate. Existing unavailable required checks still block full acceptance. + +Deliver one usable skeletal authoring workflow first, through the same production service in Animator, +agent tools and a real project-local `.ada` Tool. Internal foundation work is not a standalone completed +first milestone. Ship documentation, examples, validation and independent review with each milestone. + +| Milestone | User-visible result | Prerequisites | +| --- | --- | --- | +| M1. Skeletal authoring and shared API | Extract/create a clip, key a bone, edit through all three clients, save/reopen and use it in the existing graph and Play | Approved design; environment/tooling preparation | +| M2. Curves, properties and playback | Manual tangents, easing families, richer tracks, loop regions and complete repeat/stop behavior | M1 | +| M3. Graph orchestration | Parameters, transitions, blend spaces and live graph controls | M1 clip identity/bindings; M2 curves and clock/event contract | +| M4. Procedural pose foundation | Ordered modifiers work after mixing and LOD interpolation, with consistent final bones and skin palettes | M1 ownership/finalization constraints; regressions against M2/M3 | +| M5. Spatial paths in 2D/3D | Author curves and animate movement along them by distance/progress | M2 temporal tracks; M1 shared service | +| M6. Native 2D bones and deformation | Author bone/cutout animation and deform a weighted native 2D mesh | M1/M2 schema and bindings; native mesh/weight/render support | +| M7. Spline-driven 2D bone deformation | An editable spline drives a bone chain and the weighted mesh through the shared API | M4, M5 and M6 | + +Both path following and 2D bone/spline deformation are confirmed requirements. Completing M5 does +not complete M6/M7. Actual IK solvers remain a subsequent scoped feature; M4/M7 must leave a usable +modifier boundary for them. Retargeting, automatic rig inference, direct FBX import, root-motion +extraction and a full weight-painting product remain separate follow-ups under the accepted design. + +## 2. Preparation Before M1 Code + +1. Recheck the worktree and the accepted design against current source. Preserve unrelated edits. + Create an implementation task with exact file ownership, the approved design reference and this + plan. Record the real approval source; do not fabricate another approval or reclassify the work. +2. Run `python ada_tool.py doctor --profile studio --json`. Prepare missing prerequisites using the + unified CLI and existing Windows/macOS staging workflows. Provision Python 3.11+ with pinned + `script/contribution-requirements.txt` tools before full validation. The planning host's default + Python is 3.9.13; content-only runner support does not establish analyzer readiness. +3. Use task-owned engine/editor SwiftPM scratch directories and reuse them across checks. Preserve + the shared `.build`. Follow the Windows Gravity-package override in the platform guide. Verify + package dependencies before changing them, and keep Swift language mode 6.2. +4. Establish a real small skinned GLB fixture containing named bones, LINEAR/STEP/CUBICSPLINE TRS + channels and morph channels, plus duplicate-name/reimport variants. Reuse existing fixtures when + suitable; add fixture files to task scope explicitly. Record source checksums and sample tolerances. +5. Run the nearest existing suites before edits and retain results. Fix prerequisites rather than + using a build failure as Red evidence. Start each behavior increment with a meaningful Swift + Testing assertion, then production code, Green and Refactor through the contribution runner. + +Use [the workflow commands](../../.agents/knowledge/workflow.md), +[platform guide](../../Editor/Documentation/AgentGuides/platforms.md) and +[validation reference](../../.agents/skills/adaengine-development/references/validation.md). + +## 3. M1: Skeletal Animation Editing and Shared API + +### First completed workflow + +Open a skinned GLB, extract `Walk` into `Assets/Animations/Hero.animlib`, adjust a hand rotation key, +create `Wave` from rest or a captured pose, combine it with the imported walk using the existing +blend/additive graph and bone mask, and save/reopen the project. Repeat equivalent edits from an +agent and a developer Tool, then verify distinct instances in rendered Play. + +### Work packages, in implementation order + +**1.1. Define portable authoring data and contracts.** Introduce version-1 `.animlib` JSON resources +with library/clip/track/key IDs, display names, explicit duration, repeat metadata, typed local TRS +values, imported interpolation/tangents and provenance. Keep common DTOs/validators in `AdaAnimation`; +add the resource codec/dependency integration in `AdaAssets`. If that wrapper needs animation types, +add only the `AdaAssets -> AdaAnimation` dependency after confirming there is no inverse edge. +Keep editor objects and resource handles out of the portable schema. + +Validate finite values, quaternion magnitude, key order/uniqueness, duration, supported versions and +target types. Return structured diagnostics with operation and target IDs. Define duplicate-time +replace-versus-reject explicitly. Add round-trip/rejection tests before editing clients consume data. + +**1.2. Extract source clips and compile stable bindings.** Convert the native imported rig and channels +into editable data without rewriting the GLB. Preserve cubic derivatives, quaternion sampling and +morph channels exactly before edits. Store source asset/clip and rig fingerprints plus imported-node +mapping. Compile authored bone identities into hierarchy indices; palette offsets are not identities. +Diagnose changed/missing/ambiguous nodes and keep unresolved authoring data available for explicit remap. +Do not silently rebase authored keys after reimport. + +Support new clips from rest/captured pose and track/key insert, update, move and delete. M1 exposes +step/linear authoring and preserves/editably round-trips imported cubic data; the full manual curve +editor arrives in M2. Cubic edits must retain valid derivatives and equivalent production sampling. +M1 keeps existing repeat/one-shot, speed, seek and crossfade behavior available. + +**1.3. Integrate resource playback, current graphs and export.** Resolve imported and authored clips +through qualified stable references. Extend legacy graph/scene encoding additively, with optional +library/clip references and library dependencies on the model owner; retain old name-based clip fields, +embedded graphs and six-channel scene timelines. Validate before installing a compiled draft. An +invalid change keeps the last valid preview/runtime graph. + +Load `.animlib` through the real asset pipeline, include referenced libraries in project export, and +exercise interpreted/AOT scene-loading paths where supported. Preserve current graph-time-derived +clip phases, masks, additive order and marker behavior. Imported source bytes and unrelated channels +must survive extraction and edits. Consider the later modifier finalization boundary now; keep pose +writes centralized instead of introducing a second bone writer. + +**1.4. Add one editor document service.** Implement revisioned snapshots and typed command batches, +validation, compilation and local/global pose sampling against the production evaluator. Add a proper +animation-resource document with normal dirty state, Save/reopen and Undo/Redo. A bulk change or drag +is one history transaction. Validate the complete batch before commit; return operation-specific +errors without partially applying it. + +Use explicit resource revisions, project-path confinement and dirty-open-file checks. Keep the scene +binding step separate from asset save unless coordinated cross-document history is implemented and +tested. Sampling and previews are transient, do not dispatch gameplay callbacks, and restore the edit +pose on close/cancel. Invalid drafts preserve the last valid preview. + +**1.5. Expose the service to agents and API discovery.** Register proposed `editor.animation.inspect`, +`.apply`, `.sample`, `.validate` and `.preview` tools using the existing host/MCP registration paths. +Include extract/create/duplicate clips and track/key/loop/binding operations in the typed command +catalog. Return revisions, IDs, diagnostic codes and discoverable limits; paginate large data. +The final advertised schema must match the implementation, including save and binding boundaries. + +Update both agent-facing tool descriptions and AdaScript API tooling where applicable. Do not expose +unimplemented M2-M7 commands as available, or use procedural model generation as an editing fallback. + +**1.6. Expose the same contract to developer Tools, including Windows.** Extend the schema/parser, +scoped VM bridge and host for an API version supporting animation resources. Preserve API 1 behavior. +Expose a proposed `editor.animations` facade with detached data and the same service diagnostics. +Add explicit resource-read/write and preview capabilities/grants; document-write permission alone +must not acquire asset write access. Test changed permissions and revocation. + +Extend platform declarations and the current macOS-only activation path for Windows. Exercise the +real shared panel, project settings and permission UI there. Keep synchronous bounded callbacks; +submit expensive work as host-owned jobs with pollable results and generation-scoped cancellation. +No async callback redesign or relaxed VM budgets is required for this delivery. Closing/reloading or +revoking a Tool invalidates queued edits, jobs and preview ownership. + +**1.7. Connect Animator to the service.** Add library/clip selection, **Extract Editable Copy**, new +clip creation, bone/channel selection, local TRS key insertion and a usable timeline with key value/time +editing and scrubbing. Reuse the skeleton overlay and real model preview. Make source/read-only state, +binding errors, dirty state and explicit capture/auto-key visible. Keep the existing graph editor +usable with both source and authored clips. Ship English/Russian strings together. + +User-directed layout correction (2026-11-04): place all clip, bone/keyframe and Graph controls in +the bottom Animator. Route active GLB/glTF and `.animlib` assets before scene selection, including +projects with no scene open. Keep one document-owned preview/camera shared with the large center +viewport; mode switches and panel mounting do not own its loading/cancellation. Verify the empty-scene +asset target, shared document/history, resource switching and the real native bottom-panel interaction. + +**1.8. Ship examples, documentation and acceptance evidence.** Add a complete Animation Helper +`.ada`/`.ui` Tool example and a compilable Swift usage example. Both edit a known compatible bone, +sample results and demonstrate save/bind behavior. Update the bundled animation skill's stale morph +restriction and document actual capabilities/platforms. Run the documentation delivery in section 11. + +### M1 exit criteria + +- Animator, agent and a real Tool callback apply equivalent edits through one service and produce + equivalent serialized data and sampled poses; all three support create/extract/edit/save workflows. +- An untouched extracted clip matches production import sampling at keys and between keys, including + cubic rotations and morph weights. The source GLB checksum is unchanged after editing and saving. +- Resource and scene save/reopen, Undo/Redo, compatible duplication and export retain the authored + animation. Old scene/keyframe/graph fixtures continue to load and sample compatibly. +- Stale revisions, invalid keys, unknown versions, dirty-file conflicts, incompatible rigs, duplicate + bone names, revoked permissions and retired generations fail explicitly without partial writes. +- A full staged Windows Studio and macOS Studio exercise the Tool panel and visible skeletal preview/ + Play path. Completed frames at chosen times show the changed hand and independent model instances. +- User guides, discovery metadata and executable examples match the shipped API. Full applicable + checks and independent implementation review pass; unavailable required evidence blocks M1 completion. + +## 4. M2: Curves, Track Types and Playback + +Implement in small behavior slices: + +1. Separate temporal easing from interpolation in the portable schema/compiler. Add named polynomial, + sine, exponential, circular, back, elastic and bounce families with in/out/in-out and parameters. + Preserve legacy linear/hold/cubicInOut and imported cubic behavior during migration. +2. Implement Hermite derivatives and scalar temporal Bezier handles, monotone time inversion and + free/broken/aligned/mirrored/auto/linear/flat editing modes. Preserve handles in save/Undo/API edits. +3. Add the curve editor and bulk-key operations: multi-select, copy/paste, retime/trim, snapping and + mute/solo. Extend agent/Tool operations and discovery with the same typed data as they become usable. +4. Extend registered bindings to entity rotation and scalar/vector/color/morph properties. Diagnose + competing writers and incompatible mixing domains. Keep 3D rotation quaternion-safe; Euler editing + specifies units/order, and 2D angle bindings distinguish shortest-path and continuous turns. +5. Add loop regions, ping-pong, finite repetition, explicit stop policies and consistent seek/reverse + behavior. Preserve legacy clock behavior through adapters; keep markers bounded and scrub silent. + +**Exit evidence:** round-trip every mode, compare evaluator output at boundaries and representative +times, test overshoot/invalid handles/quaternions, zero/one-key clips, reverse/multi-loop/ping-pong marker +crossings and explicit trim/retime rollback. Dragging a tangent changes rendered motion and undoes in +one step. Equivalent UI/agent/Tool edits produce equal results. Update the guide with runnable curve +and loop examples; rerun M1 compatibility and parity checks affected by the schema/playback changes. + +## 5. M3: Animation Graph Orchestration + +1. Add per-instance typed parameters, one-shot triggers and defaults. Retain legacy graph-time-derived + phases; define explicit state-activation clock and opt-in phase-sync behavior for new graph nodes. +2. Add state-machine states/transitions, conditions, exit times, priorities, crossfade curves and + interruption rules. Specify deterministic tie-breaking, trigger consumption and transition budgets. + State transition cycles are allowed; cyclic pose dependencies still fail validation. +3. Add BlendSpace1D/2D with defined sample geometry, duplicate/degenerate cases and out-of-range policy. + Add one-shot/time-control nodes without changing existing mask/additive/rest-fill behavior. +4. Extend shared graph commands, agent/Tool discovery and the editor graph canvas with parameter, + active-state and live-weight inspection; retain a usable list-based editing path. + +**Exit evidence:** locomotion driven by speed/direction plus an upper-body one-shot; deterministic +transition/interrupt/trigger tests; independent instances, silent seek and bounded marker delivery; +graph save/reopen and bad-draft rollback from each client. Ship a documented runnable graph example. + +## 6. M4: Procedural Pose Foundation for IK + +1. Introduce the finalization boundary anticipated in M1: sampled/mixed base pose, visual LOD + interpolation, ordered modifiers, final hierarchy rebuild, then node/palette publication. +2. Separate immutable rest, evaluated base and modified buffers. Define stable modifier order, + enabled/influence/mask behavior, local/model/world space conversions and generation invalidation. + Apply influence once; never reuse the last corrected pose as the next base pose. +3. Resolve world targets from a current owner-transform snapshot; handle singular transforms and + paused clips. Stateful modifiers remain separately scoped with explicit cadence/reset semantics. +4. Implement a small stateless custom offset/look-at modifier to prove the API and expose its + configuration through the shared authoring clients. Preserve animation clocks/events under LOD. + +**Exit evidence:** a moving target affects a paused or LOD-interpolated character without accumulation, +double application or mismatched globals/palettes. Exercise seek, disable/reset, hierarchy changes and +duplicated instances. Compare rendered deformation and numerical poses across the finalization path. +Document modifier ordering/spaces/limits. This milestone does not claim a shipped two-bone/CCD/FABRIK solver. + +## 7. M5: Spatial Splines and Path Following in 2D/3D + +1. Add reusable cubic Bezier curve resources with stable point IDs, handles and open/closed state. + Put reusable math in `Math`, resource support in `AdaAssets`, and scene followers in `AdaScene`. +2. Implement position/tangent sampling, approximate arc length and revisioned bounded-error baked + caches. Support distance/progress tracks, reverse/loop/ping-pong and optional tangent orientation. +3. Define stable 3D frames/up-vector policy, closed seams and degenerate/zero-length behavior. + Compile/cache edits outside steady-state frame evaluation. +4. Add viewport curve editing and equivalent inspect/edit/sample commands for agents and Tools. + +**Exit evidence:** 2D and 3D scenes move objects along the same edited path at the intended distance +rate, with stated tolerance; verify seams, orientation, nesting, reverse playback and save/reopen. +Test cache invalidation and shared API parity. Ship path examples and a guide update. Spatial-path +completion is recorded separately from the required native 2D deformation milestones. + +## 8. M6: Native 2D Bones and Weighted Deformation + +1. Add explicit native 2D hierarchy/rest-pose resources, local translation/angle/scale tracks and + scene bindings using the common library and graph conventions. Preserve optional Spine playback. +2. Provide minimal authoring of mesh vertices, triangles, UVs and normalized bone weights through + explicit editor/shared-service operations. Validate hierarchy, indices, influences and rest state. + This enables real deformation without committing to a full weight-painting product. +3. Implement native 2D mesh deformation/render integration and bounds, preserving 2D material/layer + semantics. Verify the owning renderer paths before selecting CPU/GPU details; a material departure + from the accepted ownership/scope returns to the human rather than being silently implemented. +4. Add bone/cutout/mesh editing workflows and the same creation/binding/key/weight operations to + agent and Tool clients. Keep proprietary/exported Spine source editing outside the native API. + +**Exit evidence:** animate a real weighted native 2D mesh, verify rest/posed vertices and rendered +frames, test bad weights/topology and independent instances, and round-trip rig/mesh/library bindings. +Exercise the three clients and ship a native 2D example plus documentation of supported materials, +bounds and weight limits. Relevant rendering/backend validation is required. + +## 9. M7: Spline-Driven Native 2D Bone Deformation + +1. Add a procedural bone-chain modifier consuming M5 curves and M6 rigs. Specify chain order, + bone-length preservation, orientation, explicit stretch policy and local-space conversions. +2. Make curve/progress/influence animatable through ordinary tracks. Author the modifier and its + bindings from Animator, agent commands and a project Tool using M4's finalization path. +3. Handle short/closed/degenerate curves, invalid chains and curve edits without cumulative drift; + preserve last valid preview and revisioned resources. + +**Exit evidence:** edit a curve and visibly bend a weighted 2D tail/rope-like mesh along it; test key +animation, loops, reverse, influence, save/reopen, Undo/Redo, invalid-edit rollback and all-client parity. +Compare chain poses with final deformed vertices and GPU frames. Publish the complete spline-deformation +example and guide. This completes the confirmed second interpretation of 2D spline support. + +## 10. Implementation File Map and Ownership + +Current planning edits are limited to the accepted design's status/approval metadata and this plan. +Future documentation paths are declared to the runner for the plan gate; they are not created now. +The following is the initial implementation inventory. Add exact paths to each implementation task +before editing; inspect current owners first and expand scope when additional files are necessary. +New filenames listed here are planned, not existing APIs or completed files. + +| Area | Existing integration points | Initial new paths / later owner | +| --- | --- | --- | +| M1 data/compiler | `Sources/AdaAnimation/Skeletal/SkeletalAnimation.swift`, `SkeletalAnimationPlayer.swift`, `AnimationGraph.swift`, `CompiledAnimationGraph.swift` in that directory | `Sources/AdaAnimation/Authoring/AnimationLibrary.swift`, `AnimationAuthoringCommands.swift`, `AnimationLibraryCompiler.swift`, `AnimationBinding.swift` | +| M1 resources | `Package.swift`, `Sources/AdaAssets/GLTF/NativeGLTFLoader+Animations.swift` | `Sources/AdaAssets/AnimationLibraryResource.swift` | +| M1 scene/export | `Sources/AdaScene/3D/ModelAsset3D+Animation.swift`, `Model3DSource.swift`, `SkeletalAnimation3D.swift` in that directory; `Editor/Sources/AdaEditor/EditorSceneFileLoader.swift`, `Editor/Sources/AdaEditor/Tooling/EditorAdaScriptNativeExporter.swift` | Additive library/binding resolution; scope other export dependency collectors after tracing callers | +| M1 service/history | `Editor/Sources/AdaEditor/UI/Editor/EditorDocumentHistory.swift`, `EditorWorkbenchViewModel+Documents.swift` in that directory | `Editor/Sources/AdaEditor/Animation/EditorAnimationAuthoringService.swift`, `EditorAnimationLibraryDocument.swift`, `EditorAnimationPreviewSession.swift` | +| M1 agent | `Editor/Sources/AdaEditor/Agent/EditorAgentAssetTools.swift`, `EditorAgentMCPTools.swift`, `EditorAgentAuthoringMCPTools.swift` in that directory | `Editor/Sources/AdaEditor/Agent/EditorAgentAnimationTools.swift` | +| M1 Tools | `Sources/AdaScripting/AdaScriptToolRuntime.swift`; `Sources/AdaScriptCompilerCore/AdaScriptSchemaModels.swift`, `AdaScriptToolSchemaParser.swift`, `AdaScriptAPICatalog.swift` in that directory; `Editor/Sources/AdaEditor/StudioTools/EditorStudioToolHost.swift`, `EditorViewModel+StudioTools.swift` in that directory | `Sources/AdaScripting/AdaScriptAnimationToolBridge.swift`; scope panel/settings/permission UI and tests explicitly when extending them | +| M1 Animator | `Editor/Sources/AdaEditor/EditorKeyframeAnimation.swift`, `EditorAnimationGraphModel.swift` in that directory; `Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift`, `EditorSkeletalAnimatorPanel.swift`, `EditorSkeleton3DOverlay.swift`, `EditorModelAssetPreview.swift` in that directory | Bone/library timeline UI files under the owning shared editor directories | +| Localization | `Editor/Sources/AdaEditor/Assets/Localization/en.json`, `ru.json` in that directory | English/Russian strings in the same behavior change | +| M2-M7 | `Sources/AdaAnimation/KeyframePrimitives.swift`, `KeyframeSampler.swift`; `Sources/AdaScene/3D/AnimationLOD3D.swift`; owning `Math`, `AdaAssets`, `AdaScene`, `AdaRender`/`AdaSprite` modules | Declare exact curve, graph, modifier, native-2D and renderer paths at each milestone start | + +Initial new test files: `Tests/AdaAnimationTests/AnimationLibraryTests.swift`, +`Tests/AdaAssetsTests/AnimationLibraryResourceTests.swift`, +`Tests/AdaSceneTests/AuthoredSkeletalAnimationTests.swift`, +`Tests/AdaScriptingTests/AdaScriptAnimationToolTests.swift`, +`Editor/Tests/AdaEditorTests/EditorAnimationAuthoringTests.swift`, and +`Editor/Tests/AdaEditorTests/EditorAgentAnimationTests.swift`. +Extend `EditorStudioToolsTests.swift`, existing skeletal/import/graph/keyframe tests and applicable +compiler schema/catalog tests rather than replacing their coverage. + +## 11. Documentation, Validation and Review Gates + +### User documentation in every milestone + +- Author `Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationAuthoring.md`, linked in + `Sources/AdaEngine/AdaEngine.docc/AdaEngine.md`, with a production Swift example and current limits. +- Author `Editor/Documentation/AnimationAuthoring.md`, linked from `Documentation/AnimationGraphs.md` + and `Editor/Documentation/StudioTools.md`. Expand it as curves, graphs, paths, 2D and modifiers ship. +- Add `Editor/Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ada` and + `AnimationHelper.ui` in that directory. Execute the example through the real Tool host on each + supported desktop platform; document the fixture/model it expects. +- Update `Editor/Documentation/Agent3DWorkflow.md`, bundled + `Editor/Sources/AdaEditor/Assets/AgentSkills/ada-3d-animation/SKILL.md` and owning subsystem references. +- Add the guide to `Editor/scripts/update-offline-documentation.py` inputs, regenerate + `Editor/Sources/AdaEditor/Assets/Documentation/catalog.json`, and run the generator with `--check`. + Build DocC using the root AGENTS command and compile/run usage examples separately. + +Register article/navigation paths with the runner before code. A design/plan, generated docs or +placeholder guide is not a substitute for the shipped user article. Stage-specific examples must use +actually implemented APIs and label platform limitations explicitly. + +### Validation commands and evidence + +Use focused symbol/suite filters first; confirm that each selects and executes relevant tests. +The following are commands for implementation, not test results from this planning change: + +```text +swift test --scratch-path .build-tools/animation-authoring/engine --filter AnimationLibraryTests +swift test --scratch-path .build-tools/animation-authoring/engine --filter GLTFSkeletalImportTests +swift test --scratch-path .build-tools/animation-authoring/engine --filter AnimationGraphTests +swift test --package-path Editor --scratch-path .build-tools/animation-authoring/editor --filter EditorAnimationAuthoringTests +swift test --package-path Editor --scratch-path .build-tools/animation-authoring/editor --filter EditorAgentAnimationTests +swift test --package-path Editor --scratch-path .build-tools/animation-authoring/editor --filter EditorStudioToolsTests +python Editor/scripts/update-offline-documentation.py --check +``` + +Resolve scratch arguments to one task-owned absolute directory per package before invoking commands +on each host. Windows runs additionally require the documented Gravity package override. After focused +Green/Refactor, run owning/downstream targets and full engine/editor checks selected by the contribution +runner. Rendering changes require actual supported-backend golden-frame checks, including +`script/validate_webgpu.sh` with its pinned tools where applicable. GPU suites stay serialized under +`Render3DGPUTests`; disabled gates or empty suites are not passing evidence. + +Build/stage Studio with `python ada_tool.py build studio` and the platform's existing distribution +workflow; visible acceptance must use a complete distribution. Verify Windows/macOS independently. +Use a real temporary project for edit/Undo/save/reimport/export/Tool tests and capture completed +animation frames at controlled times. The logic-only offscreen scenario does not prove skeletal Play. + +Record realistic CPU/allocation/memory/edit-latency baselines before setting performance budgets. +Exercise dense imported tracks and repeated edits; prove no binding compilation or curve-cache +rebuild in unchanged steady-state evaluation. Follow the repository benchmark path when asserting +3D rendering performance; visual impressions are not performance measurements. + +For every implementation milestone: + +1. Capture exact ownership, approved design and article declarations in the runner; retain initial + unrelated work. Run/verify the plan gate before code. +2. Record meaningful Red -> Green -> Refactor cycles and exact commands/logs; do not weaken checks, + suppress findings or replace behavioral failures with missing-tool failures. +3. Run the complete applicable review profile, runtime/platform checks, example/DocC/offline checks + and `git diff --check`. Missing required environments/results remain explicit blockers. +4. Obtain a separate-context `adaengine-review`, including shipped guides, navigation and examples. + Fix findings and repeat affected checks/review; substantive design deviations return to the human. +5. Record the final review and `verify --stage done`. Report implemented behavior, successful evidence + and remaining limitations. Pending CI does not count as passed or establish branch protection. + +### Implementation status + +M1 is in progress on `codex/animation-authoring`: portable libraries/commands/compiler, +resource loading, scene bindings, shared Studio drafts/history, skeletal-key UI, agent actions +and API 2 Tool jobs have been implemented. Focused core and real-VM tests have recorded genuine +Red/Green/Refactor cycles. Full integration, Editor transaction/preview/Play and required platform +evidence remain under validation; this status does not close M1. Native macOS acceptance is not +available from the current Windows host. M2 now has easing families, temporal Bézier +validation/sampling, schema-2 persistence, and Inspector fields in implementation. +The separately accepted IK plan records the implemented 3D modifier slice. These +changes do not close M2, M4 or the remaining native 2D/spline deliveries. diff --git a/Documentation/Plans/2026-11-06-AnimationIK.md b/Documentation/Plans/2026-11-06-AnimationIK.md new file mode 100644 index 000000000..f6d038ff4 --- /dev/null +++ b/Documentation/Plans/2026-11-06-AnimationIK.md @@ -0,0 +1,102 @@ +# Native Animation IK Implementation + +Date: 2026-11-06. Status: implementation in progress; no milestone is closed by this plan. +Accepted design: [Native Animation IK](../Designs/animation-ik.md), explicitly approved in +the current conversation's response annotation. Preserve all existing animation-authoring +and control-style work. Existing validation blockers remain open rather than becoming passes. + +## Delivery sequence + +1. **Pose boundary and numerical solvers.** Add validated, Sendable/Codable configuration + and compiled instance-local modifier buffers in `AdaAnimation`. Separate evaluated base, + visual base and final corrected transforms in the existing player. Implement stateless + two-bone and bounded FABRIK, deterministic degenerate/collinear seeding, measured reach + error, influence and ordered composition. Keep imported quaternion interpolation unchanged. + Cover 3D and planar math; planar math alone does not establish native 2D authoring/rendering. +2. **Runtime/resource integration.** Add the versioned `.animmod` resource and backward- + compatible `Model3DSource` reference/scene overrides. Resolve every model owner, ordinary + marker and pole from one current local-transform hierarchy snapshot before any stack. + Declare ECS read/write access and ordering, including voxel transform writers. Reject + targets under any animated rig and duplicate scene identities. Preserve live valid models + on invalid replacement; keep strong resource handles and remap/re-resolve scene bindings + when instancing/navigating. Finalize once after LOD and publish identical node/skin poses. +3. **Shared authoring service and clients.** Implement atomic modifier edits, explicit saving, + bounded history, reload/conflict recovery, sample diagnostics and revision-checked binding. + Extend the existing animation request/agent/real Tool VM bridge; preserve permissions, + callback rollback, queue limits and capability discovery. Do not advertise incomplete actions. +4. **Native Studio workflow.** Add the Modifiers section in the resizable Model panel, + contextual Inspector fields and viewport target/pole gizmos, sharing the existing player. + Preserve one-step drag Undo and stable bone IDs. Apply the accepted + [control rules](../../Editor/Documentation/Design/Controls.md), EN/RU localization and + unkeyed-pose recovery. Inspector remains the property owner; Graph remains the base-pose canvas. +5. **Native 2D dependencies and IK adapter.** Implement the accepted M5/M6/M7 spatial-path, + native rig/weighted mesh and spline-chain deliveries as distinct observable slices. Use + the existing native `Mesh2D` render path, without depending on Spine. Adapt the shared + solvers while retaining continuous-turn base angles plus shortest corrective deltas. + Test actual weighted deformation, bounds, scene/resource identity and independent instances. +6. **Animation bindings and complete authoring acceptance.** Connect influence and model-space + target/pole coordinates to M2's typed animation-property work. Deliver advanced temporal + curves, path following and spline deformation through their previously approved plan, + without treating a numerical solver or path follower as the whole requested workflow. + +An initial safe default evaluates enabled stateless modifiers on animation-system updates, +including paused clips. LOD may skip base sampling but cannot freeze mutable targets. No +physics/stateful cadence, implicit stretching, general joint limits or full-body IK is introduced. + +## Initial ownership and later scope expansion + +The runner initially owns the accepted design/approval metadata, this plan, +`Sources/AdaAnimation/Modifiers/AnimationPoseModifier.swift`, `CompiledPoseModifiers.swift`, +`IKMath.swift`, the existing `SkeletalAnimationPlayer.swift`, and +`Tests/AdaAnimationTests/SkeletalIKTests.swift`. Add exact scene, asset, script, Editor and +renderer files before editing each integration slice. Do not sweep unrelated M1 changes into +the new task or alter validation policy to conceal missing platforms/tools. + +## Documentation delivered with implementation + +Author `Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationIK.md` with executable +Swift examples, spaces/order, defaults, limitations and diagnostics. Link it from DocC Topics +and AnimationAuthoring. Extend `Editor/Documentation/AnimationAuthoring.md`, AnimationGraphs, +the owning asset/agent references, the actual AnimationHelper Tool and offline catalog as their +clients become available. Clearly distinguish mathematical planar support from native 2D mesh +authoring and visible deformation. Do not document unimplemented API as available. + +## Validation and completion + +### Current 3D slice (2026-11-06) + +The 3D base/final pose boundary, two-bone and FABRIK solvers, `.animmod` resource, +scene marker binding, shared agent/Tool requests, Inspector/Model panel and viewport +gizmos are implemented. Focused production-path tests and Windows Studio/Play captures +exercise this slice. Independent interim reviews found numerical near-opposite rotation, +Inspector binding restoration, active-model Undo routing and attached-document lifetime +issues; those have been corrected and covered by regressions. This is progress evidence, +not milestone closure or full accepted-design validation. + +Native temporal easing, monotone Bézier sampling, schema-2 persistence and numeric +Inspector handles are implemented as an M2 slice. Native 2D rigs/weighted deformation, +splines, typed animation of modifier properties, Auto Key for those properties, +additional tangent modes/canvas handle dragging and complete platform/render/benchmark +acceptance remain outstanding. Windows DocC conversion currently fails with an invalid +filename; the article's executable Swift example has been compiled/run independently. +Retain these failures and missing platform checks in the contribution report. + +- Use Swift Testing through real production evaluators. Each behavior slice records genuine + Red/Green/Refactor. Minimal new API/data scaffolding may be needed to compile a new test; + do not count a missing symbol or compilation failure as behavioral Red. +- For fast core iteration use a task-owned dependent Swift package that compiles the actual + repository `AdaAnimation`/`Math` products and byte-identical owning tests. This is focused + module evidence, not a substitute for full root/Editor/platform validation. +- Exercise reachable/outer-unreachable/inner-gap/zero-direction targets, arbitrary exported + axes, pole degeneracy, preserved lengths/scales, rotated parents, uniform versus unsupported + transforms, influence 0/0.5/1, stack ordering, iteration budgets and deterministic repeated solves. +- Runtime tests compare final nodes and palettes for pause, LOD, seek, crossfade, graph mixing, + duplication and current moving/deleted/rebound targets. Test target restrictions across rigs, + malformed resources, path confinement and atomic failed replacement. +- Run real Studio and Play/Tool acceptance from complete staged distributions on supported + desktop hosts. Capture actual model deformation and native 2D frames; run affected Metal/ + WebGPU golden paths. Numerical samples do not prove rendered IK or a native 2D workflow. +- Build DocC, compile/run examples, validate offline guides/localization, run the full applicable + contribution profile and independent `adaengine-review`. Fix findings and repeat affected + checks/review. Missing hardware/tools and failed/timed-out checks remain explicit blockers. + Do not claim M1, IK or the full M2–M7 request complete until their exit evidence exists. diff --git a/Editor/AGENTS.md b/Editor/AGENTS.md index 31dfe4866..363e17955 100644 --- a/Editor/AGENTS.md +++ b/Editor/AGENTS.md @@ -16,6 +16,8 @@ before editing. Do not load the whole capability inventory by default. [visual design rules](../AGENTS.md#ada-studio-visual-design-rules) and use [adaengine-studio-design](../.agents/skills/adaengine-studio-design/SKILL.md): present a visual before implementation and inspect the rendered result before completion. +- Native controls follow [the control design rules](Documentation/Design/Controls.md): + visible button surfaces/states, themed fields, inline colored axis badges and overflow indicators. - Every UI change includes English and Russian text in the same change. Native UI uses `L10n` and the shared language preference; read the [localization rules](Documentation/AgentGuides/localization.md) before changing it. diff --git a/Editor/Documentation/Agent3DWorkflow.md b/Editor/Documentation/Agent3DWorkflow.md index b93bddf77..16cff4000 100644 --- a/Editor/Documentation/Agent3DWorkflow.md +++ b/Editor/Documentation/Agent3DWorkflow.md @@ -8,7 +8,7 @@ The mobile agent and desktop editor MCP share native model tools and offline kno Validation checks dependency containment and byte budgets, native decoding, image decoding, scene hierarchy, primitive geometry and the current import profile. `editor.asset.validate` also routes GLB/glTF to this validation. This is not a complete Khronos conformance check, GPU rendering proof or animation-playback proof. It does not author or validate gameplay collision shapes. -This profile supports glTF 2.0 primitive topologies, UV0/UV1, one set of four skin influences and at most 128 joints per skin. Required Draco/Meshopt/KTX2 extensions are unsupported. Node TRS clips can use LINEAR, STEP and CUBICSPLINE interpolation; morph-weight animation is unsupported. Models and their local dependencies have a combined 128 MiB budget. JSON and decoded accessor inventories have additional bounds. +This profile supports glTF 2.0 primitive topologies, UV0/UV1, one set of four skin influences and at most 128 joints per skin. Required Draco/Meshopt/KTX2 extensions are unsupported. Node TRS and morph-weight clips can use LINEAR, STEP and CUBICSPLINE interpolation. Models and their local dependencies have a combined 128 MiB budget. JSON and decoded accessor inventories have additional bounds. ## Import and place @@ -32,4 +32,4 @@ Read exact Camera, Mesh3DComponent, light, Environment3D and physics payloads fr Desktop uses `editor.build.start`, `editor.play.start`, `editor.task.status`, `editor.output.read`, `editor.play.capture` / `render.capture_screenshot` (the running game's camera frame; the editor window is returned only when no game runs, and is not evidence) and `editor.play.run_scenario` for scripted input, frames and state. Mobile uses `editor.build`, `editor.play.start`, `editor.play.capture` and `editor.image.read`. Select the tools actually advertised by the current host. Desktop asset tools exclude mobile vision analysis and direct on-disk scene assignment; desktop scene edits go through the revision/undo workflow. Save dirty asset documents before tool writes. -After validation and build, inspect a completed frame from visible Play. A 3D scene needs a camera, a light and an `Environment3D` (or a camera `backgroundColor`/clear flags) so the frame is not a flat clear color; `editor.play.run_scenario` can capture before/after frames around input. Exercise selected clips and compare frames at different times for animation. Isolated simulation verifies logic and keyboard/physics behavior; it does not install Model3D or prove rendering. Blender execution, rig editing and skeletal clip authoring are not exposed by these Studio tools. To create a new low-poly model use editor.model.generate (an AdaScript program that writes a GLB) or editor.voxel.generate (a .vox). +After validation and build, inspect a completed frame from visible Play. A 3D scene needs a camera, a light and an `Environment3D` (or a camera `backgroundColor`/clear flags) so the frame is not a flat clear color; `editor.play.run_scenario` can capture before/after frames around input. Exercise selected clips and compare frames at different times for animation. Isolated simulation verifies logic and keyboard/physics behavior; it does not install Model3D or prove rendering. Native desktop agents can create/extract and edit skeletal/morph clips using the revision-checked `editor.animation.*` API; read [Animation authoring](AnimationAuthoring.md) for explicit Save, scene binding, shared Tool API 2, budgets and verification. Query `animationAuthoring` in desktop project context before use; mobile hosts do not advertise these desktop authoring commands. Blender execution, visual rig-structure editing and automatic rig inference remain unavailable. To create a new low-poly model use editor.model.generate (an AdaScript program that writes a GLB) or editor.voxel.generate (a .vox). diff --git a/Editor/Documentation/AgentGuides/agent-tools.md b/Editor/Documentation/AgentGuides/agent-tools.md index 2dae68318..32c0754b6 100644 --- a/Editor/Documentation/AgentGuides/agent-tools.md +++ b/Editor/Documentation/AgentGuides/agent-tools.md @@ -97,3 +97,15 @@ Entry points: [GitReviewService.swift](../../Sources/AdaEditor/Tooling/GitReview Coverage: [GitReviewTests.swift](../../Tests/AdaEditorTests/GitReviewTests.swift), [EditorAgentCatalogTests.swift](../../Tests/AdaEditorTests/EditorAgentCatalogTests.swift), [EditorMobileAgentToolTests.swift](../../Tests/AdaEditorTests/EditorMobileAgentToolTests.swift). + + +### 3D IK authoring + +`.animmod` resources use the shared revisioned modifier draft and ordered two-bone/FABRIK +runtime. Model panel selection, Inspector settings and viewport handles use the same edit +commands as `editor.animation.modifier*` and API 2 Tool jobs. Save is explicit; saved resources +bind to compatible `Model3DSource` instances with separate current scene revisions and stable +marker IDs. Marker snapshots use current local transforms, including while paused/under LOD. +The actual `IKHelper` Tool example is in `Documentation/Examples/StudioTools/IKHelper`. +See [Animation authoring](../../Documentation/AnimationAuthoring.md#ik-modifiers). +Native 2D weighted/spline deformation and typed IK timeline bindings remain separate stages. diff --git a/Editor/Documentation/AgentGuides/assets.md b/Editor/Documentation/AgentGuides/assets.md index 5a832343a..94aa0d7de 100644 --- a/Editor/Documentation/AgentGuides/assets.md +++ b/Editor/Documentation/AgentGuides/assets.md @@ -119,3 +119,15 @@ Coverage: [EditorTextureAtlasTests.swift](../../Tests/AdaEditorTests/EditorTextu import, preview, placement and clip playback exist; visual imported-material editing, skeleton editing and skeletal-clip authoring remain incomplete. The shared-workbench model preview has not established iPhone UI parity. + + +### 3D IK authoring + +`.animmod` resources use the shared revisioned modifier draft and ordered two-bone/FABRIK +runtime. Model panel selection, Inspector settings and viewport handles use the same edit +commands as `editor.animation.modifier*` and API 2 Tool jobs. Save is explicit; saved resources +bind to compatible `Model3DSource` instances with separate current scene revisions and stable +marker IDs. Marker snapshots use current local transforms, including while paused/under LOD. +The actual `IKHelper` Tool example is in `Documentation/Examples/StudioTools/IKHelper`. +See [Animation authoring](../../Documentation/AnimationAuthoring.md#ik-modifiers). +Native 2D weighted/spline deformation and typed IK timeline bindings remain separate stages. diff --git a/Editor/Documentation/AnimationAuthoring.md b/Editor/Documentation/AnimationAuthoring.md new file mode 100644 index 000000000..d73152138 --- /dev/null +++ b/Editor/Documentation/AnimationAuthoring.md @@ -0,0 +1,323 @@ +# Editing bone animation + +## Recognizing controls + +Actions in Inspector, Animator and Graph have a visible button surface and border. +The primary action and active modes/toggles use the blue accent; disabled actions +remain outlined with reduced emphasis. Editable text and number fields use the +same themed borders as the scene Inspector, including the scene Graph's time field. +Pose components have inline X/Y/Z/W badges (red, green, blue, purple) so translation, +scale and quaternion values are easy to scan. These colors supplement the axis letters. + +An overflowing Inspector or Model hierarchy shows a scroll indicator. Resize the +panel or scroll to reach the remaining controls. The detailed contributor rules are +in [Studio control design](Design/Controls.md). + +## Open an animation workspace + +Open a GLB/glTF asset. The **Model** panel beside the viewport lists its complete +node hierarchy. Drag its divider to change the width; the **Model** button hides +or reopens it. Search retains matching nodes and their parents. Click a node to +select it; Ctrl/Cmd-click adds to the selection, Shift-click selects a range, and +**Select Subtree** selects its descendants. The project file tree remains separate. + +The right **Inspector** follows the selected node, track, key or graph node. +Choose an imported clip in the bottom **Animator**, then use **Extract Editable Copy** +in Inspector. **New Animation** starts from the rig's rest pose. Set a +project-relative destination such as `Assets/Animations/Hero.animlib`. +The original GLB/glTF stays unchanged. Opening an `.animlib` asset uses the same bottom Animator. The large center viewport +shares one preview/player/camera with Model, Inspector and the bottom Animator controls. +Opening a GLB/glTF or `.animlib` reveals Animator automatically. An open scene is not required to edit a model asset. Switching modes or hiding Animator +does not reload the model. Save the library before using **Add to Scene**; its saved +library references accompany the selected authored clip or graph. + +## Timeline and keys + +All bone rows share one seconds ruler and playhead. Expand a bone row to see +translation, rotation and scale channels; imported morph-weight tracks also appear. +Collapsed rows summarize keyed times. Click or drag the ruler to pause and preview +the exact time, or enter **Seconds** and click **Seek**. Transport offers play/pause, +stop-to-start, previous/next key and frame stepping. **Fit** shows the clip's time +range. Scroll vertically through rows; Shift-scroll moves along time and +Ctrl/Cmd-scroll zooms around the pointer. + +The filter row offers name search, selected-only bones, animated-only tracks and +channel type. These controls remain available without opening Inspector. + +Select bones or channel rows before **Insert Key**. Insertion updates a key already +at that track/time. Select keys individually or with a rectangle, then drag them +horizontally as a group; **Snap** toggles frame snapping. Dragging preserves relative +times. Release commits one Undo operation, and Escape cancels the draft. A collision, +an out-of-range time, or a concurrent resource edit rejects the entire move. A move +replaces complete affected tracks through the same bounded API described below; groups exceeding +the batch budget must be reduced. + +Selecting a bone row inserts translation, rotation and scale. Selecting a channel +row inserts only that channel, including a channel without an existing track. +Morph rows sample the current morph weights. Shift-click keys selects a time range +on the anchor and clicked tracks. The unkeyed-pose **Insert Key** decision commits +all staged TRS channels; a channel-specific insertion keeps other staged channels +pending. Closing an asset also waits for its unkeyed pose to be keyed or discarded. + +Inspector edits a selected key's exact time/value and current interpolation's raw +tangents. Update moves its stable ID; Delete removes selected keys. Occupied times +are never overwritten implicitly by moves. **Duration** must include every key; +extending it holds the last pose, while a reduction past keys is rejected. **Loop** +controls repeat. Rotations accept +normalized XYZW quaternions or Euler degrees in the displayed Z, X, Y application +order. Imported cubic tracks preserve editable raw incoming/outgoing derivatives. + +Select a key to edit **Outgoing segment easing** in Inspector. Choose from linear, +quadratic, cubic, quartic, quintic, sine, exponential, circular, back, elastic and +bounce, with `in`, `out`, `inOut` or `outIn` direction. Back exposes **Overshoot**; +elastic exposes **Amplitude** and **Period**. **Apply Curve** commits the fields as +one Undo operation. Text drafts do not change the current animation before Apply. +Easing has no effect on step interpolation or after the final key. + +Choose **bezier** interpolation on a translation, scale or morph-weight track to +edit its handles. Select a component, then edit incoming/outgoing **Time offset** +and **Value offset** relative to the selected key. Boundary keys expose only the +handle that belongs to an existing segment. Changes must leave time monotone; +invalid edits preserve the previous curve. The handles use seconds/value units, +while imported cubic tangents are derivatives per second. Quaternion tracks offer +easing with their existing interpolation, without scalar Bézier handles. + +**Curves** samples the same evaluator used by scene playback. Recapturing an +existing key preserves its curve metadata. Native curves save in `.animlib` schema +2; old editors must reject that version. Unchanged legacy libraries remain schema +1. Save/reopen and the shared API preserve easing and all component handles. +Additional tangent modes, dragging handles on the canvas, graph orchestration, +paths and native 2D deformation remain later work. + +**Preview Pose** changes the selected bones temporarily. Editing one component +preserves each bone's other components; switching rotation representation keeps +the current orientation. **Insert Key** commits the pose in one resource operation; +**Auto Key** inserts immediately. An unkeyed pose must be keyed or discarded before +changing time, clip, library or graph preview. **Cancel** keeps the pose and cancels +the requested switch. Concurrent resource changes require discarding the stale pose. +Use the viewport's **Preview Settings** button for projection, background, lighting, +grid and axes. **Save Preview Workspace** retains panel/graph/selection and preview +state under the project's `.ada/workspace`. + +## Node Graph + +**Animation Graph** opens a canvas of clip, blend and additive cards connected by +pose wires. The card marked **Output** is the root. Drag a pose output to an input +or **+ Input** to connect it. Drag a connected input to reconnect its wire; its +weight and mask travel with the connection. Cycles are rejected. The Inspector +edits clip settings, connection weights and bone/subtree masks. In additive nodes, +the first input is the base pose. + +Drag cards to arrange them, use middle-drag or **Pan** to move the canvas, scroll +to zoom, and **Fit** to reveal the graph. Select a card or wire and press Delete; +Undo/Redo restores complete operations. Invalid/incomplete graph drafts show +diagnostics while the viewport retains its last valid graph. Scene graph data +saves through the model's scene component; asset preview/layout state and library +key data have separate destinations. **Save** on a library saves its clips. + +Edits are shared drafts with Undo/Redo. **Save** writes explicitly. Autosave, tab +switches and attempting to close a dirty library do not save it. **Save and Bind** +saves the resource, then attaches it through the scene's own Undo history. Scene +Undo does not undo an already saved resource. The existing Skeletal Graph selects +bound saved authored clips by their qualified runtime names. + +Bindings preserve hierarchy/rest pose, non-joint nodes and morph widths. Changed +rigs require explicit remapping; name/index guesses fail. Multiple libraries need +distinct library IDs. Scene references stay inside Assets, including relative +references and symlinks. Verify viewport animation and game-camera Play separately. + +If a file changes externally, Save reports a conflict. Use **Reload from Disk** on +a clean document, **Discard Local Edits and Reload** to explicitly replace the draft, +or **Discard Draft** to close it without writing/deleting its file. New/discarded +drafts can be reopened or recreated. +These recovery actions are in the right Inspector's library controls. Resolve an +unkeyed preview pose before reloading or discarding the library. + +## Agent request contract + +Every `editor.animation.` tool takes `requestJSON`: a JSON string containing +an `AnimationAuthoringRequest`. Its `action` must match the tool name. Actions are +`inspect`, `create`, `extract`, `apply`, `capture`, `sample`, `validate`, `save`, +`undo`, `redo`, `bind`, `preview`, `reload`, `discard`. +Graph actions are `graphInspect`, `graphApply`, `graphUndo`, `graphRedo`, +`graphPreview`, `graphBind`. + +Inspect first: + +```json +{"action":"inspect","path":"Assets/Animations/Hero.animlib","limit":32} +``` + +The response contains `revision`, `libraryID`, clips and a page of node bindings. +Add `clipID` for tracks and `trackID` for keys. `offset`/`limit` select pages (maximum +256 items). Clips and nodes/tracks use the same offset; target a clip/track when +paging keys. Read stable IDs rather than deriving them from display names. + +Extract an imported clip into an unsaved copy: + +```json +{"action":"extract","path":"Assets/Animations/Hero.animlib","modelPath":"Assets/Models/Hero.glb","sourceClip":"Walk","name":"Walk edited"} +``` + +Existing destinations require `expectedRevision`. `create` takes the same paths +and makes a rest-pose clip. `capture` samples a library `clipID` at `time`, creating +a new pose clip with optional `name`/`duration`. Their responses give `createdClipID`. + +Update an inspected rotation key atomically: + +```json +{"action":"apply","path":"Assets/Animations/Hero.animlib","expectedRevision":"","operations":[{"id":"raise-arm","kind":"key.put","clipID":"","trackID":"","replaceExisting":true,"key":{"id":"","time":0.5,"value":[0,0,0,1]}}]} +``` + +A new key uses a fresh ID and omits `replaceExisting`. `track.create` supplies +`track` with `id`, `nodeID`, `channel` (`translation`, `rotation`, `scale`, `weights`), +`interpolation` (`linear`, `step`, `cubicSpline`) and `keys`. Cubic keys need sized +`incoming`/`outgoing` derivatives per second. Every operation leaves a valid document; +one failure rolls back the batch and identifies its operation ID. + +`clip.configure` accepts `name`, `duration`, `repeats`; `clip.duplicate` needs `newID` +and `name`; `track.configure` accepts `interpolation`. Remove operations identify +`clipID`, `trackID`, `keyID` as applicable. `clip.create` supplies a full `clip`. + +Save with `{"action":"save","path":"…","expectedRevision":"…"}`. Bind additionally +supplies open `scenePath`, `entityID`, `sceneRevision`, `clipID`. It checks all saved +libraries, imported clips and the current graph before one scene transaction. +`sample` returns paginated TRS/morphs plus `hasTRS`, `localMatrixColumnMajor` and +`globalMatrixColumnMajor` (16 column-major floats in model/rig space, before the scene instance transform). Matrix-authored nodes retain +their matrices; their fallback TRS is not a decomposition. `validate` optionally takes `modelPath` +to check the rig. `preview` opens the library viewport at `clipID`/`time`; wait for +load and capture the editor for visual proof. CPU samples/preview requests do not +prove rendered output. `reload` needs a revision; dirty reload and `discard` also +need explicit `discardChanges:true`. Mutation acknowledgements contain the new +revision/dirty state; inspect separately for detailed data. + +For graph actions, `path` addresses an **open** GLB/glTF or `.animlib` asset. +Open its viewport and call `graphInspect`; wait until `ready:true`. Its `revision` +is the graph revision, separate from the optional `resourceRevision`. Inspect returns +`root` and paginated `nodes` in the runtime `AnimationGraph` schema. `graphApply` +takes `graph:{root,nodes}` and the graph's `expectedRevision`; it atomically replaces +the same draft used by the canvas. Incomplete drafts retain diagnostics. Active +canvas gestures reject API writes. Card movements do not change the semantic graph +revision, while graph changes and graph Undo/Redo do. + +`graphPreview` needs a valid graph, its revision and optional finite `time`. +`graphBind` additionally takes `scenePath`, `entityID` and `sceneRevision`; it validates +the destination model and its saved library bindings before one scene-history edit. +The graph draft/layout remains editor workspace data until explicitly applied to a +scene. Library Save does not publish a preview graph into a GLB or `.animlib`. +Graph actions use the same 64 KiB request limit; `graphApply` allows up to 4,096 +cards and 16,384 total inputs/markers within that limit. +The runtime currently validates at most 256 cards and 64 inputs per card. Larger +drafts remain invalid. Studio additionally bounds compilation to one million +pose/mask cells, one million morph cells and four million ancestor checks. Reduce +cards or mask entries when a preview exceeds these budgets. Scene binding requires +the same complete rig; matching clip names alone do not establish compatibility. + +## Project Tools + +[Studio Tools](StudioTools.md) API 2 exposes the identical contract through +`editor.animations.submit(requestMap)`. Successful callbacks publish detached jobs; +do not mix them with entity-creation batches. A later button/action calls `poll(jobID)` +and receives a detached Map with `status` (`pending`, `completed`, `failed`) and +`result` or `error`. Do not busy-wait inside a callback. A successful terminal poll +consumes the result. Disabling/replacing a Tool or closing its project cancels its +generation's pending jobs. + +Declare `editor.animations.read`; edits/save/reload/discard also require +`editor.animations.write`; preview requires `editor.animations.preview`; bind needs +`editor.documents.write`. The example also reads the active scene through `editor.documents.read`. API 1/document grants confer no animation access. Declare +`platforms:["macos","windows"]` for native desktop hosts. +The same grants apply respectively to graph edits/Undo/Redo, graph preview and graph binding. + +Limits: 64 KiB requests, 1 MiB responses, 8 submissions per callback, 16 retained +jobs, 256 operations/batch, 256 items/page, 32 MiB library/model import. A library +allows 16,384 nodes, 256 clips, 4,096 tracks/clip, 100,000 keys and 1,024 morph targets +per node. IDs have 256-byte UTF-8 limits, names 1,024, source paths 4,096 and +fingerprints 256. Large key pages may need smaller `limit`. These in-process +capabilities/budgets are not an OS sandbox. Swift users share `AnimationLibrary`, +`AnimationEdit` and the native compiled tracks. + +## Run the complete Tool example + +Copy `Examples/StudioTools/AnimationHelper` into the project's `Tools` directory. +For the repository's GardenRobot fixture, copy `Tests/AdaEngineTests/Fixtures/SkeletalGardenAssets/GardenRobot.glb` +to `Assets/Robot.glb`. Open a scene containing a Model 3D entity with that source; +set the Tool's entity ID to its stable scene ID (`robot` in the acceptance fixture). + +Extract `Walk`, then Poll. Inspect Rotation Tracks, Poll, Inspect First Key, Poll. +The fixture's first rotation track targets `Foot.L`. Set Bone Local Z Rotation to +90 Degrees, Poll, and Sample, Poll. The Tool reads the selected node's quaternion +from the nested pose response. Sampling reads at most eight nodes per job; keep +clicking Poll while another page is queued for a later bone. Small response pages +keep the synchronous Tool callback within its execution budget. Save, Poll, then Bind, Poll. These are separate +explicit operations; inspect the library viewport and Play to assess the pose. +The sample replaces a linear key's absolute local rotation; it is not a retargeter +or a cubic-tangent editor. For other rigs, inspect and choose compatible IDs through +the same API. The supplied test executes this actual `.ada`/`.ui` example through +the real Tool host and compares its key edit with native UI and agent commands. + +For its graph example, open `Assets/Robot.glb` and wait for the model. Choose +**Inspect Open Model Graph**, Poll, then **Replace Preview Graph with Half-Weight +Clip**, Poll. The source clip is blended with the rest pose. **Preview Graph**, +Poll opens that same draft in the canvas. With a compatible model entity in an +open scene, **Apply Graph to Scene Entity**, Poll writes one undoable scene edit; +Save the scene to retain it. The Tool keeps graph and library revisions separately. + +Tool request conversion additionally limits expanded values (including Map keys) +to 8,192, cumulative UTF-8 string bytes to 65,536 and nesting to 64. Shared subgraphs +consume the budget on every expansion. Import preflight caps each top-level +inventory at 16,384, primitives/samplers/channels at 4,096 per owner and total decoded +accessor work at 32 MiB of Double elements, charging repeated references. These +checks happen before accessor allocation. Encoded files and local/data resources +have a separate 32 MiB limit. + +The library viewport uses its own restricted native loader all the way through GPU +model construction. It does not preflight then reopen the source with the ordinary +loader. The scene Animator reads initial clip names through the CPU authoring service. + + +## IK modifiers + +Open a GLB/glTF or `.animmod` asset and expand **Modifiers** in the resizable Model panel. +Select a contiguous bone chain (three joints for two-bone IK), then add **Two-bone IK** or +**FABRIK**. The existing Inspector edits the root/end chain, model-space target and pole, +influence, and FABRIK iterations/tolerance. Apply validates the complete edit before replacing +it. Blue target and purple pole handles preview a drag; release creates one Undo step, and +Escape cancels. The existing Animation Graph remains the base-pose canvas. + +Create/Open an `.animmod` under project Assets. Save is explicit; Undo/Redo share the same +revisioned draft across UI, agents and developer Tools. Dirty IK resources are excluded from +automatic Save All, just like animation libraries. Save, then **Bind IK to Scene** with an +open scene model. Marker IDs are authored scene IDs, not node names or runtime entity IDs; +marker offsets are local to the marker. The Inspector restores existing scene bindings and +their offsets when selecting a model/constraint. With no marker ID, **Override Target Point** +and **Override Pole Point** edit instance-specific model-space points; disabling an override +uses the resource point. Reload scene bindings if the scene revision changes before applying. +The asset preview uses resource model-space points. Scene +bindings use a snapshot of current local transforms and reject targets within any animated +rig. Paused clips and LOD continue to respond to moving markers. Missing markers skip IK. + +`modifierInspect`, `modifierCreate`, `modifierApply`, `modifierSample`, `modifierSave`, +`modifierUndo`, `modifierRedo`, `modifierPreview`, `modifierBind`, `modifierReload` and +`modifierDiscard` use `AnimationAuthoringRequest`. Edits require `expectedRevision`; binding +also requires `scenePath`, `entityID`, `sceneRevision` and a saved compatible resource. +`modifierOperations` contains add/remove/move/update commands. `modifierBindings` contains +instance-local target/pole overrides. The same read/write/preview/document permissions and +bounded asynchronous job transport apply to real API 2 Tools and the editor agent. + +IK limits: 1 MiB per `.animmod`, 32 modifiers, 64 segments per chain, 1–64 iterations, and +32,768 segment-iterations per stack. There is no stretching, general joint-limit system or +end-effector orientation lock. Read `solverError` for the full-strength solve and `reachError` +for the actual influenced endpoint. Iteration exhaustion is an approximate result. Numerical +samples and successful binding do not prove pixels; inspect the shared viewport and Play. + +Native 2D weighted deformation, spline-chain editing and typed timeline animation of IK +parameters are subsequent implementation stages; this 3D workflow does not establish them. + + +Copy `Examples/StudioTools/IKHelper` into project `Tools` to try the actual API 2 Tool. +Set the model and `.animmod` paths. Create, Poll, then Inspect, Poll. Copy a valid contiguous +three-bone set of stable IDs from the native Inspector or agent binding response into Root, +Middle and End. Add Two-Bone IK, Poll, then Sample, Poll. Save, Poll before binding. Set +model and marker entity IDs in an open scene, then Bind, Poll. Open the IK asset before +Preview. A failed/stale job requires a fresh Inspect; each terminal Poll consumes its result. diff --git a/Editor/Documentation/Design/Controls.md b/Editor/Documentation/Design/Controls.md new file mode 100644 index 000000000..3a9009851 --- /dev/null +++ b/Editor/Documentation/Design/Controls.md @@ -0,0 +1,66 @@ +# Ada Studio Control Design + +Status: required visual rules, specified by the human's annotated screenshots on 2026-11-06. +Applies to native Studio controls, including animation assets, Inspector, Animator and Graph. +The existing scene Transform Inspector is the reference for vector fields. These rules +correct the agreed workspace presentation; they do not alter animation semantics or approve IK. + +## Buttons and actions + +- An action must look interactive before hover: use a contrasting themed surface, a subtle + border and rounded corners. Bare text is not an acceptable Save, Reload, Extract, Insert + Key, Preview Pose, Apply, graph Add/Remove, Undo/Redo, Fit or Pan button. +- Use the editor's blue accent for the primary action and for active toggles/modes. Active + tabs and Auto Key/Loop/Pan states need a filled or bordered accent treatment in addition + to their label. Do not communicate state through text alone. +- Secondary actions retain a neutral button surface. Hover, pressed and keyboard focus + must be visible; disabled controls keep their shape and use reduced emphasis. +- Icon-only viewport and playback buttons follow the same surface/state rules. Preserve + their existing identifiers, readable hover help and keyboard/input behavior. +- A hierarchy row may use the existing full-row hover/selection treatment instead of + separate button boxes. Disclosure arrows stay aligned with their row. This exception + does not permit bare toolbar or Inspector action text. +- Use theme tokens for surfaces, borders, foregrounds and accents. Do not rely on a + platform-default white input background or a transparent default button style. + +## Text and number fields + +- Text, search and scalar number fields use a themed surface, thin visible border, + rounded corners and horizontal padding. Keep a consistent compact height of 26–32 + logical points appropriate to the surrounding toolbar or Inspector. +- Label fields clearly. A static value/path must be distinguishable from editable input. + Long paths, intermediate numeric drafts, negative values and scientific notation remain + supported. Styling must not parse or commit edits earlier than the owning control. +- Vector/quaternion rows match the scene Transform Inspector: an inline colored axis + badge at the leading edge of each numeric field, with X red, Y green, Z blue and W purple. + Keep the axis letter visible as well as color. Axis labels above plain boxes are insufficient. +- Give every component the same height, padding and border. Share available width and + preserve readable values when the Inspector is resized; constrain each cell rather + than letting a long value expand the entire row beyond the panel. +- Preserve selection, caret, focus and keyboard editing. A decoration must not intercept + input or make only a small part of the visible control clickable. + +## Scrolling and resizing + +- Any overflowing Inspector, hierarchy or graph-properties list shows a scroll indicator. + Indicators track the content offset and resize with the viewport; no indicator is needed + when the whole content fits. +- Reserve padding so the indicator does not cover field values or action labels. The + scrollbar belongs to the panel viewport and must not scroll away with its content. +- Keep the Model panel and outer panels resizable. Check controls at narrow and wide + widths and with longer Russian labels. Scrolling must reach the final field/action. + +## Implementation and visual acceptance + +Reuse common themed action/field/axis components within a workspace; do not apply one-off +background fixes to individual screenshots. `DefaultButtonStyle` only forwards its label +and therefore is suitable only when the label already supplies the complete row/card +appearance. AdaUI `ScrollView` indicators must be enabled explicitly. + +Check idle, hover/focus, pressed, selected and disabled states in the actual editor. Cover +both asset animation and the scene Animator Graph. Inspect Save/Reload, pose controls, +preview toolbar, timeline controls, graph toolbar and graph properties. Verify that no +white default fields or bare action labels remain. Rendered control and overflow regressions +belong in the owning UI tests; screenshots provide additional native visual evidence. + +User workflow: [Animation authoring](../AnimationAuthoring.md). diff --git a/Editor/Documentation/EditorGuide.md b/Editor/Documentation/EditorGuide.md index e45ec9f53..11a0c7c6f 100644 --- a/Editor/Documentation/EditorGuide.md +++ b/Editor/Documentation/EditorGuide.md @@ -1,5 +1,8 @@ # AdaEditor Guide +Native control appearance follows the [Studio control design rules](Design/Controls.md): +visible action surfaces and states, themed fields, colored vector-axis badges and overflow indicators. + ## Open or create a project Use **Create new project** on the welcome screen, or choose **File > Open Project** to open an existing project. AdaScript projects use `.ada` source files. Projects with native Swift code also use Swift Package Manager. @@ -34,8 +37,21 @@ glTF imports with external buffers or textures are copied into a new model folde with those dependencies. Keep dependencies beneath the source model's directory; remote URLs and references escaping that directory are rejected. Missing files fail the bundle import. Repeated imports create another bundle without replacing -the original. Imported materials are preserved; visual material editing and -skeleton/clip authoring are not part of this workflow yet. +the original. Imported materials are preserved. Imported meshes/rigs remain source +assets; editable animation clips are stored separately as described below. + +## Edit bone animation + +Select a Model 3D entity and open **Animator → Bone Keyframes**. Extract an editable +copy of an imported clip or create a clip from the rig's rest pose. Select bones, +tracks and timeline keys to edit translation, rotation, scale or morph weights. +Preview the model, use Undo/Redo, then explicitly **Save** the `.animlib` resource. +Autosave and tab switches retain animation drafts without writing them. +**Save and Bind** attaches a saved compatible library through the scene's own history; +the Skeletal Graph can then select its qualified clips. See +[Editing bone animation](AnimationAuthoring.md) for key commands, conflict recovery +and the shared agent/Studio Tools API. The offline **Authoring skeletal animation** +guide also covers Swift library construction and runtime graph references. ## MagicaVoxel models diff --git a/Editor/Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ada b/Editor/Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ada new file mode 100644 index 000000000..5f8bd9b06 --- /dev/null +++ b/Editor/Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ada @@ -0,0 +1,160 @@ +@tool( + id: "studio.animation-helper", + name: "Animation Helper", + version: "1.2.0", + api: 2, + platforms: ["macos", "windows"], + permissions: ["editor.animations.read", "editor.animations.write", "editor.animations.preview", "editor.documents.read", "editor.documents.write"] +) +class AnimationHelper { + @export var path = "Assets/Animations/Robot.animlib"; + @export var modelPath = "Assets/Robot.glb"; + @export var sourceClip = "Walk"; + @export var clipID = ""; + @export var trackID = ""; + @export var nodeID = ""; + @export var keyID = ""; + @export var keyTime = 0.0; + @export var sampledRotationZ = 0.0; + @export var entityID = "robot"; + @export var revision = ""; + @export var graphRevision = ""; + @export var job = ""; + @export var status = "Extract or inspect, then Poll. Save is explicit."; + + func activate(editor) { + editor.addPanel(id: "animation-helper", title: "Animation Helper", location: "right", ui: "AnimationHelper.ui"); + } + + func inspect(editor) { + job = editor.animations.submit(["action": "inspect", "path": path, "limit": 16]); + status = "Inspection queued. Click Poll."; + } + + func extract(editor) { + job = editor.animations.submit(["action": "extract", "path": path, "modelPath": modelPath, + "sourceClip": sourceClip, "expectedRevision": revision]); + status = "Extraction queued. Click Poll."; + } + + func inspectTracks(editor) { + job = editor.animations.submit(["action": "inspect", "path": path, "clipID": clipID, "limit": 256]); + status = "Track inspection queued. Click Poll."; + } + + func inspectKeys(editor) { + job = editor.animations.submit(["action": "inspect", "path": path, "clipID": clipID, "trackID": trackID, "limit": 1]); + status = "Key inspection queued. Click Poll."; + } + + func rotateBone(editor) { + job = editor.animations.submit(["action": "apply", "path": path, "expectedRevision": revision, + "operations": [["id": "rotate-bone", "kind": "key.put", "clipID": clipID, "trackID": trackID, + "replaceExisting": true, "key": ["id": keyID, "time": keyTime, + "value": [0.0, 0.0, 0.7071067811865476, 0.7071067811865476]]]]]); + status = "Local Z quarter-turn queued for the inspected rotation key. Click Poll."; + } + + func sample(editor) { + job = editor.animations.submit(["action": "sample", "path": path, "clipID": clipID, "time": keyTime, "offset": 0, "limit": 8]); + status = "Pose sample queued. Click Poll."; + } + + func bind(editor) { + job = editor.animations.submit(["action": "bind", "path": path, "expectedRevision": revision, + "clipID": clipID, "scenePath": editor.scenePath, "sceneRevision": editor.sceneRevision, "entityID": entityID]); + status = "Saved clip binding queued for the open scene. Click Poll."; + } + + func oneShot(editor) { + job = editor.animations.submit(["action": "apply", "path": path, "expectedRevision": revision, + "operations": [["id": "set-one-shot", "kind": "clip.configure", "clipID": clipID, "repeats": false]]]); + status = "Edit queued. Click Poll, then Save when ready."; + } + + func save(editor) { + job = editor.animations.submit(["action": "save", "path": path, "expectedRevision": revision]); + status = "Save queued. Click Poll."; + } + + func preview(editor) { + job = editor.animations.submit(["action": "preview", "path": path, "clipID": clipID, "time": 0.25]); + status = "Preview queued. Click Poll, then inspect the viewport."; + } + + func inspectGraph(editor) { + job = editor.animations.submit(["action": "graphInspect", "path": modelPath, "limit": 16]); + status = "Open the model viewport first. Graph inspection queued; click Poll."; + } + + func halfWeightGraph(editor) { + job = editor.animations.submit(["action": "graphApply", "path": modelPath, "expectedRevision": graphRevision, + "graph": ["root": "Output", "nodes": [ + ["id": "Source", "kind": "clip", "clip": sourceClip, "speed": 1, "repeats": true, "inputs": [], "events": []], + ["id": "Output", "kind": "blend", "clip": "", "speed": 1, "repeats": true, + "inputs": [["node": "Source", "weight": 0.5]], "events": []]]]]); + status = "Replacing the preview graph with the imported clip at half weight. Click Poll."; + } + + func previewGraph(editor) { + job = editor.animations.submit(["action": "graphPreview", "path": modelPath, "expectedRevision": graphRevision, "time": 0.25]); + status = "Graph preview queued. Click Poll, then inspect the viewport."; + } + + func bindGraph(editor) { + job = editor.animations.submit(["action": "graphBind", "path": modelPath, "expectedRevision": graphRevision, + "scenePath": editor.scenePath, "sceneRevision": editor.sceneRevision, "entityID": entityID]); + status = "Graph binding queued for the open scene. Click Poll; Save the scene explicitly."; + } + + func poll(editor) { + if (job == "") { status = "Submit an action first."; return; } + var envelope = editor.animations.poll(job); + status = envelope["status"]; + if (status == "pending") { return; } + job = ""; + if (status == "failed") { status = envelope["error"]; return; } + var result = envelope["result"]; + if (result["revision"] != null) { + if (result["root"] != null) { graphRevision = result["revision"]; } + else { revision = result["revision"]; } + } + if (result["createdClipID"] != null) { clipID = result["createdClipID"]; } + if (result["clips"] != null && result["tracks"] == null && result["clips"].count > 0) { + clipID = result["clips"][0]["id"]; + } + if (result["tracks"] != null) { + var tracks = result["tracks"]; + var i = 0; + while (i < tracks.count) { + if (tracks[i]["channel"] == "rotation") { + trackID = tracks[i]["id"]; nodeID = tracks[i]["nodeID"]; break; + } + i += 1; + } + } + if (result["keys"] != null && result["keys"].count > 0) { + keyID = result["keys"][0]["id"]; keyTime = result["keys"][0]["time"]; + } + if (result["poses"] != null) { + var poses = result["poses"]; + var i = 0; + var found = false; + while (i < poses.count) { + if (poses[i]["nodeID"] == nodeID) { + sampledRotationZ = poses[i]["rotation"][2]; found = true; break; + } + i += 1; + } + var nextOffset = result["offset"] + poses.count; + if (!found && poses.count > 0 && nextOffset < result["total"]) { + job = editor.animations.submit(["action": "sample", "path": path, "clipID": clipID, + "time": keyTime, "offset": nextOffset, "limit": 8]); + status = "Reading the next pose page. Click Poll."; + return; + } + if (!found) { status = "The selected bone was not found in the sampled pose."; return; } + } + status = "Completed. Inspect the draft; Save writes it explicitly."; + } +} diff --git a/Editor/Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ui b/Editor/Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ui new file mode 100644 index 000000000..7d4145a2a --- /dev/null +++ b/Editor/Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ui @@ -0,0 +1,144 @@ +format: ada.ui +schemaVersion: 1 +inputs: + - {name: path, type: string, defaultValue: Assets/Animations/Robot.animlib} + - {name: modelPath, type: string, defaultValue: Assets/Robot.glb} + - {name: sourceClip, type: string, defaultValue: Walk} + - {name: entityID, type: string, defaultValue: robot} + - {name: status, type: string, defaultValue: Ready} +actions: + - {name: inspect} + - {name: extract} + - {name: inspectTracks} + - {name: inspectKeys} + - {name: rotateBone} + - {name: sample} + - {name: bind} + - {name: oneShot} + - {name: save} + - {name: preview} + - {name: inspectGraph} + - {name: halfWeightGraph} + - {name: previewGraph} + - {name: bindGraph} + - {name: poll} +root: + id: animation-helper + type: VStack + arguments: + spacing: {value: 8} + children: + - id: instructions + type: Text + arguments: + text: {value: 'For GardenRobot.glb Walk: Extract, Inspect Tracks, Inspect Key, Rotate Bone, Sample, Save and Bind. Poll after each action. The first rotation track is Foot.L; binding needs an open scene with the model entity ID below.'} + - id: path + type: TextField + arguments: + text: {binding: path} + - id: model-path + type: TextField + arguments: + text: {binding: modelPath} + - id: source-clip + type: TextField + arguments: + text: {binding: sourceClip} + - id: extract + type: Button + arguments: + title: {value: Extract Editable Copy} + actions: + action: extract + - id: inspect + type: Button + arguments: + title: {value: Inspect} + actions: + action: inspect + - id: entity-id + type: TextField + arguments: + text: {binding: entityID} + - id: tracks + type: Button + arguments: + title: {value: Inspect Rotation Tracks} + actions: + action: inspectTracks + - id: keys + type: Button + arguments: + title: {value: Inspect First Key} + actions: + action: inspectKeys + - id: rotate-bone + type: Button + arguments: + title: {value: Set Bone Local Z Rotation to 90 Degrees} + actions: + action: rotateBone + - id: sample + type: Button + arguments: + title: {value: Sample Inspected Bone Pose} + actions: + action: sample + - id: bind + type: Button + arguments: + title: {value: Bind Saved Clip to Scene Entity} + actions: + action: bind + - id: one-shot + type: Button + arguments: + title: {value: Make One-Shot} + actions: + action: oneShot + - id: save + type: Button + arguments: + title: {value: Save Draft} + actions: + action: save + - id: preview + type: Button + arguments: + title: {value: Preview} + actions: + action: preview + - id: poll + type: Button + arguments: + title: {value: Poll} + actions: + action: poll + - id: inspect-graph + type: Button + arguments: + title: {value: Inspect Open Model Graph} + actions: + action: inspectGraph + - id: half-weight-graph + type: Button + arguments: + title: {value: Replace Preview Graph with Half-Weight Clip} + actions: + action: halfWeightGraph + - id: preview-graph + type: Button + arguments: + title: {value: Preview Graph} + actions: + action: previewGraph + - id: bind-graph + type: Button + arguments: + title: {value: Apply Graph to Scene Entity} + actions: + action: bindGraph + - id: status + type: Text + arguments: + text: {binding: status} diff --git a/Editor/Documentation/Examples/StudioTools/IKHelper/IKHelper.ada b/Editor/Documentation/Examples/StudioTools/IKHelper/IKHelper.ada new file mode 100644 index 000000000..07c40a49a --- /dev/null +++ b/Editor/Documentation/Examples/StudioTools/IKHelper/IKHelper.ada @@ -0,0 +1,48 @@ +@tool(id: "studio.ik-helper", name: "IK Helper", version: "1.0.0", api: 2, + platforms: ["macos", "windows"], + permissions: ["editor.animations.read", "editor.animations.write", "editor.animations.preview", "editor.documents.read", "editor.documents.write"]) +class IKHelper { + @export var path = "Assets/Arm.animmod"; + @export var modelPath = "Assets/Robot.glb"; + @export var rootBone = ""; + @export var middleBone = ""; + @export var endBone = ""; + @export var entityID = "robot"; + @export var targetEntity = "target"; + @export var targetX = 1.0; + @export var targetY = 1.0; + @export var targetZ = 0.0; + @export var revision = ""; + @export var job = ""; + @export var status = "Create or Inspect, then Poll. Use stable bone IDs from the Inspector or agent inspection."; + + func activate(editor) { editor.addPanel(id: "ik-helper", title: "IK Helper", location: "right", ui: "IKHelper.ui"); } + func create(editor) { job = editor.animations.submit(["action": "modifierCreate", "path": path, "modelPath": modelPath]); } + func inspect(editor) { job = editor.animations.submit(["action": "modifierInspect", "path": path, "limit": 32]); } + func addTwoBone(editor) { + job = editor.animations.submit(["action": "modifierApply", "path": path, "expectedRevision": revision, + "modifierOperations": [["kind": "add", "modifier": ["id": "arm", "kind": "twoBone", + "joints": [rootBone, middleBone, endBone], "target": ["x": targetX, "y": targetY, "z": targetZ], + "enabled": true, "influence": 1.0, "iterations": 16]]]]); + } + func sample(editor) { job = editor.animations.submit(["action": "modifierSample", "path": path, "limit": 8]); } + func save(editor) { job = editor.animations.submit(["action": "modifierSave", "path": path, "expectedRevision": revision]); } + func undo(editor) { job = editor.animations.submit(["action": "modifierUndo", "path": path, "expectedRevision": revision]); } + func preview(editor) { job = editor.animations.submit(["action": "modifierPreview", "path": path, "expectedRevision": revision, "time": 0.0]); } + func bind(editor) { + job = editor.animations.submit(["action": "modifierBind", "path": path, "expectedRevision": revision, + "scenePath": editor.scenePath, "sceneRevision": editor.sceneRevision, "entityID": entityID, + "modifierBindings": ["arm": ["target": ["entity": targetEntity, "point": ["x": 0.0, "y": 0.0, "z": 0.0]]]]]); + } + func poll(editor) { + if (job == "") { status = "No pending job."; return; } + var envelope = editor.animations.poll(job); + status = envelope["status"]; + if (status == "pending") { return; } + job = ""; + if (status == "failed") { status = envelope["error"]; return; } + var result = envelope["result"]; + if (result["revision"] != null) { revision = result["revision"]; } + status = "Completed. Save is explicit. Samples are not rendered verification."; + } +} diff --git a/Editor/Documentation/Examples/StudioTools/IKHelper/IKHelper.ui b/Editor/Documentation/Examples/StudioTools/IKHelper/IKHelper.ui new file mode 100644 index 000000000..16dbedb4a --- /dev/null +++ b/Editor/Documentation/Examples/StudioTools/IKHelper/IKHelper.ui @@ -0,0 +1,44 @@ +format: ada.ui +schemaVersion: 1 +inputs: + - {name: path, type: string, defaultValue: Assets/Arm.animmod} + - {name: modelPath, type: string, defaultValue: Assets/Robot.glb} + - {name: rootBone, type: string, defaultValue: ''} + - {name: middleBone, type: string, defaultValue: ''} + - {name: endBone, type: string, defaultValue: ''} + - {name: entityID, type: string, defaultValue: robot} + - {name: targetEntity, type: string, defaultValue: target} + - {name: status, type: string, defaultValue: Ready} +actions: + - {name: create} + - {name: inspect} + - {name: addTwoBone} + - {name: sample} + - {name: save} + - {name: undo} + - {name: preview} + - {name: bind} + - {name: poll} +root: + id: ik-helper + type: VStack + arguments: + spacing: {value: 8} + children: + - {id: path, type: TextField, arguments: {text: {binding: path}}} + - {id: model-path, type: TextField, arguments: {text: {binding: modelPath}}} + - {id: root, type: TextField, arguments: {text: {binding: rootBone}}} + - {id: middle, type: TextField, arguments: {text: {binding: middleBone}}} + - {id: end, type: TextField, arguments: {text: {binding: endBone}}} + - {id: entity, type: TextField, arguments: {text: {binding: entityID}}} + - {id: target, type: TextField, arguments: {text: {binding: targetEntity}}} + - {id: create, type: Button, arguments: {title: {value: Create IK Resource}}, actions: {action: create}} + - {id: inspect, type: Button, arguments: {title: {value: Inspect}}, actions: {action: inspect}} + - {id: add, type: Button, arguments: {title: {value: Add Two-Bone IK}}, actions: {action: addTwoBone}} + - {id: sample, type: Button, arguments: {title: {value: Sample}}, actions: {action: sample}} + - {id: save, type: Button, arguments: {title: {value: Save}}, actions: {action: save}} + - {id: undo, type: Button, arguments: {title: {value: Undo}}, actions: {action: undo}} + - {id: preview, type: Button, arguments: {title: {value: Preview}}, actions: {action: preview}} + - {id: bind, type: Button, arguments: {title: {value: Bind IK to Scene}}, actions: {action: bind}} + - {id: poll, type: Button, arguments: {title: {value: Poll}}, actions: {action: poll}} + - {id: status, type: Text, arguments: {text: {binding: status}}} diff --git a/Editor/Documentation/Examples/StudioTools/LevelHelper/LevelHelper.ada b/Editor/Documentation/Examples/StudioTools/LevelHelper/LevelHelper.ada index cd16fe173..9095c7360 100644 --- a/Editor/Documentation/Examples/StudioTools/LevelHelper/LevelHelper.ada +++ b/Editor/Documentation/Examples/StudioTools/LevelHelper/LevelHelper.ada @@ -3,7 +3,7 @@ name: "Level Helper", version: "1.0.0", api: 1, - platforms: ["macos"], + platforms: ["macos", "windows"], permissions: ["editor.documents.read", "editor.documents.write"] ) class LevelHelper { diff --git a/Editor/Documentation/StudioTools.md b/Editor/Documentation/StudioTools.md index d17aade12..4913eeaec 100644 --- a/Editor/Documentation/StudioTools.md +++ b/Editor/Documentation/StudioTools.md @@ -1,7 +1,12 @@ # Studio tools with .ui panels -Studio API 1 supports project-local AdaScript tools with a `.ui` panel in the -macOS workspace's right sidebar. Open **Studio Tools** using the wrench icon, +Studio APIs 1 and 2 support project-local AdaScript tools with a `.ui` panel in the +native macOS/Windows workspace's right sidebar. Declare the desired hosts in `platforms`. +API 2 adds [animation authoring jobs](AnimationAuthoring.md) through the shared +Animator/agent service. API 1 retains its existing document capabilities. +Copy the complete [Animation Helper](Examples/StudioTools/AnimationHelper) into +`Tools/AnimationHelper` to inspect/extract/edit/save a clip with the job API. +Open **Studio Tools** using the wrench icon, review the access dialog and click **Allow and Enable** before the tool code or its `.ui` is activated. The panel contains the tool interface plus reload/settings icons. Enablement, versions and access management live in **Settings → Project → diff --git a/Editor/Sources/AdaEditor/Agent/EditorAgentAnimationTools.swift b/Editor/Sources/AdaEditor/Agent/EditorAgentAnimationTools.swift new file mode 100644 index 000000000..370db96f7 --- /dev/null +++ b/Editor/Sources/AdaEditor/Agent/EditorAgentAnimationTools.swift @@ -0,0 +1,84 @@ +import AdaEngine +import Foundation +import MCP + +/// Native desktop animation authoring uses the same revisioned drafts as Animator and project Tools. +@MainActor +enum EditorAgentAnimationTools { + static var capabilities: [String: Any] { + ["formats": ["animlib", "animmod"], "tools": tools().map(\.name), "toolAPI": 2, + "clients": ["Animator", "editor agent", "project Tools"], "save": "Explicit, revision-checked resource Save; scene binding is a separate transaction.", + "verification": "CPU samples and preview acknowledgements do not prove pixels. Capture the loaded viewport and verify visible Play."] + } + + static var modelCapabilities: [String: Any] { + var result = EditorAgentModelToolService.capabilities + result["unavailable"] = (result["unavailable"] as? [String])?.map { + $0 == "visual imported rig/keyframe editing" ? "visual rig structure editing" : $0 + } ?? [] + return result + } + + static func tools() -> [Tool] { + AnimationAuthoringRequest.Action.allCases.map { action in + Tool(name: "editor.animation.\(action.rawValue)", + description: description(action), + inputSchema: .object(["type": "object", "properties": .object([ + "requestJSON": .object(["type": "string", "description": .string( + "JSON AnimationAuthoringRequest, at most 64 KiB. action must match the tool name; path is project-relative inside Assets. " + + "Inspect for revision and stable IDs before editing. Mutations require expectedRevision; save is explicit.")]) + ]), "required": .array([.string("requestJSON")])]), + annotations: .init(readOnlyHint: action.isReadOnly)) + } + } + + static func execute(name: String, arguments: [String: Value], viewModel: EditorViewModel) async throws -> [String: Any] { + guard let text = arguments["requestJSON"]?.stringValue, text.utf8.count <= 65_536, + let service = viewModel.workbench.animationService else { + throw Failure("Open a native project and provide requestJSON (at most 64 KiB).") + } + let request = try JSONDecoder().decode(AnimationAuthoringRequest.self, from: Data(text.utf8)) + guard name == "editor.animation.\(request.action.rawValue)" else { throw Failure("Request action must match the tool name.") } + return try await service.execute(request, workbench: viewModel.workbench) + } + + private static func description(_ action: AnimationAuthoringRequest.Action) -> String { + switch action { + case .modifierInspect: "Inspect a .animmod draft: revision, full rig binding, ordered paginated modifiers and history state." + case .modifierCreate: "Create an unsaved .animmod draft from modelPath (.glb/.gltf), preserving the source model." + case .modifierApply: "Apply modifierOperations (add/remove/move/update) atomically with expectedRevision. Maximum 32 constraints and 64 segments per chain; stable IDs." + case .modifierSample: "Sample .animmod from rest or imported sourceClip at finite time. Returns paginated poses and solver/reach diagnostics, not rendered proof." + case .modifierSave: "Save .animmod with expectedRevision, rejecting external disk changes." + case .modifierUndo, .modifierRedo: "Move the shared IK history with expectedRevision." + case .modifierReload: "Reload IK from disk with expectedRevision; dirty drafts require discardChanges: true." + case .modifierDiscard: "Discard IK draft with expectedRevision and discardChanges: true, without deleting the file." + case .modifierPreview: "Preview the open IK workspace at finite time with expectedRevision. Capture the viewport for visual verification." + case .modifierBind: "Bind saved compatible IK to scenePath/entityID with expectedRevision and sceneRevision. Optional modifierBindings use stable scene entity IDs and local offsets. One scene Undo transaction." + case .inspect: "Inspect an animation draft: revision, clips, node bindings, tracks and paginated keys. Optional clipID/trackID and offset/limit (1...256)." + case .create: "Create an unsaved rest-pose clip from modelPath into path (.animlib); optional name. Existing libraries require expectedRevision." + case .extract: "Copy sourceClip from modelPath (.glb/.gltf) into a project .animlib draft. Source bytes stay unchanged." + case .apply: + "Atomically apply operations to a draft with expectedRevision. Operations: clip.create/duplicate/remove/configure, " + + "track.create/remove/configure, key.put/remove. IDs are stable; key replacement is explicit." + case .capture: "Capture a sampled clipID/time into a new pose clip; optional name/duration and required expectedRevision." + case .sample: "Sample clipID at finite time without events. Returns paginated local TRS and morph weights; CPU samples do not prove rendered output." + case .validate: "Validate draft schema; optional modelPath also checks exact rig compatibility." + case .save: "Explicitly save a draft with expectedRevision. Rejects external disk changes." + case .undo, .redo: "Move animation draft history with expectedRevision, producing a new revision." + case .reload: "Reload an open library from disk with expectedRevision; dirty drafts require explicit discardChanges: true." + case .discard: "Close and discard an open draft without writing or deleting its file. Requires expectedRevision and discardChanges: true." + case .bind: "Bind a saved compatible library/clipID to an open Model3DSource entity using scenePath/entityID/sceneRevision and expectedRevision. Uses scene Undo history." + case .preview: "Open the library viewport at clipID/time. Acknowledges the preview request; capture the viewport after load for visual proof." + case .graphInspect: "Inspect an open GLB/glTF/animlib workspace graph: graph revision, root, paginated nodes, validity and resource revision. Open the asset first; wait until ready." + case .graphApply: "Replace the open asset preview graph atomically using graph and its expectedRevision. Incomplete drafts retain validation diagnostics; source assets stay unchanged." + case .graphUndo, .graphRedo: "Move the shared canvas graph/layout history using the current graph expectedRevision." + case .graphPreview: "Activate the valid asset graph at finite time; requires graph expectedRevision. Acknowledgement does not verify rendered output." + case .graphBind: "Apply the current graph to a compatible open scene Model3DSource using graph expectedRevision and scenePath/entityID/sceneRevision. Bound libraries must be saved. One scene Undo transaction." + } + } + + private struct Failure: LocalizedError { + let errorDescription: String? + init(_ message: String) { errorDescription = message } + } +} diff --git a/Editor/Sources/AdaEditor/Agent/EditorAgentAuthoringMCPTools.swift b/Editor/Sources/AdaEditor/Agent/EditorAgentAuthoringMCPTools.swift index 28d5dc29f..1e4c17b53 100644 --- a/Editor/Sources/AdaEditor/Agent/EditorAgentAuthoringMCPTools.swift +++ b/Editor/Sources/AdaEditor/Agent/EditorAgentAuthoringMCPTools.swift @@ -5,7 +5,7 @@ import MCP @MainActor enum EditorAgentAuthoringMCPTools { static func tools() -> [Tool] { - EditorAgentPublicationTools.tools() + EditorAgentKnowledgeTools.tools() + EditorAgentAssetTools.tools().filter { + EditorAgentAnimationTools.tools() + EditorAgentPublicationTools.tools() + EditorAgentKnowledgeTools.tools() + EditorAgentAssetTools.tools().filter { // Desktop scene edits must enter the open document's revision/undo path. // Image analysis requires a provider callback installed by the mobile host. !["editor.scene.asset.assign", "editor.image.read"].contains($0.name) @@ -42,14 +42,17 @@ enum EditorAgentAuthoringMCPTools { static func execute(name: String, arguments: [String: Value], projectURL: URL) async throws -> CallTool.Result { let payload: [String: Any] - if name == "editor.community.submit" { + if name.hasPrefix("editor.animation."), let viewModel = EditorAgentMCPTools.shared.activeViewModel { + payload = try await EditorAgentAnimationTools.execute(name: name, arguments: arguments, viewModel: viewModel) + } else if name == "editor.community.submit" { payload = try await EditorAgentPublicationTools.submit(projectURL: projectURL, arguments: arguments) } else if EditorAgentKnowledgeTools.tools().contains(where: { $0.name == name }) { payload = try EditorAgentKnowledge.query(name: name, arguments: arguments) } else { payload = try await EditorAgentAssetToolService(projectURL: projectURL).execute(name: name, arguments: arguments) } - let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + let data = try name.hasPrefix("editor.animation.") ? EditorAnimationAuthoringService.responseData(payload) + : JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) return .init(content: [.text(text: try EditorAgentToolEncoding.string(data), annotations: nil, _meta: nil)], isError: false) } diff --git a/Editor/Sources/AdaEditor/Agent/EditorAgentMCPTools.swift b/Editor/Sources/AdaEditor/Agent/EditorAgentMCPTools.swift index e177d7202..ef7519852 100644 --- a/Editor/Sources/AdaEditor/Agent/EditorAgentMCPTools.swift +++ b/Editor/Sources/AdaEditor/Agent/EditorAgentMCPTools.swift @@ -158,7 +158,8 @@ final class EditorAgentMCPTools { "activeDocument": activeDocument, "workspaceStatus": viewModel.workspaceStatus.title, "playMode": String(describing: viewModel.playModeState), - "model3D": EditorAgentModelToolService.capabilities, + "model3D": EditorAgentAnimationTools.modelCapabilities, + "animationAuthoring": EditorAgentAnimationTools.capabilities, "authoringTools": EditorAgentAuthoringMCPTools.tools().map(\.name) ]) } diff --git a/Editor/Sources/AdaEditor/Agent/EditorAgentSceneToolService.swift b/Editor/Sources/AdaEditor/Agent/EditorAgentSceneToolService.swift index cd29007b4..ec8d99281 100644 --- a/Editor/Sources/AdaEditor/Agent/EditorAgentSceneToolService.swift +++ b/Editor/Sources/AdaEditor/Agent/EditorAgentSceneToolService.swift @@ -397,7 +397,7 @@ final class EditorAgentSceneToolService { private static let sceneExtensions: Set = ["ascn", "scene", "scn"] - static func revision(for content: String) -> String { + nonisolated static func revision(for content: String) -> String { var hash: UInt64 = 14_695_981_039_346_656_037 for byte in content.utf8 { hash ^= UInt64(byte) diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationAssetWorkspace.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationAssetWorkspace.swift new file mode 100644 index 000000000..4e00723f4 --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationAssetWorkspace.swift @@ -0,0 +1,244 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Observation + +/// UI state owned by an open asset, independently of the Animator's visibility and selected mode. +/// Resource contents, revisions and Undo remain in the project authoring service. +@Observable +@MainActor +final class EditorAnimationAssetWorkspace { + enum Mode: String, CaseIterable { case clips, keys, curves, graph } + let document: EditorAssetDocument + let preview = EditorModelAssetPreviewModel() + let camera = EditorModelPreviewCamera() + let graph = EditorAnimationGraphModel() + let timeline = EditorAnimationTimelineModel() + var sourceLibrary: AnimationLibrary? + var mode = Mode.keys + var library: EditorAnimationLibraryDocument? + var modifiers: EditorAnimationModifierDocument? + var modifierPath = "" + var selectedModifierID: String? { didSet { preview.selectedPoseModifierID = selectedModifierID } } + var libraryPath = "" + var clipName = "" + var sourceClip = "" + var seekTime = "0" + var error = "" + private(set) var isWorking = false + private(set) var isChangingLibrary = false + @ObservationIgnored let service: EditorAnimationAuthoringService? + @ObservationIgnored weak var workbench: EditorWorkbenchViewModel? + @ObservationIgnored var revealInspector: (() -> Void)? + @ObservationIgnored private var started = false + @ObservationIgnored private var workTask: Task? + @ObservationIgnored var modifierDrag: EditorAnimationModifierDrag? + + init(document: EditorAssetDocument, service: EditorAnimationAuthoringService?) { + self.document = document + self.service = service + timeline.workspace = self + preview.onModifierInput = { [weak self] event, camera in self?.handleModifierInput(event, camera: camera) ?? false } + preview.onSelectBone = { [weak self] index, additive in + guard let self, timeline.nodes.indices.contains(index) else { return } + timeline.selectNode(timeline.nodes[index].id, additive: additive) + } + graph.onInspect = { [weak self] in self?.timeline.inspectorMode = "graph" } + preview.playsAnimation = false + if let service { + let assets = String(service.assetsURL.path.dropFirst(service.projectURL.path.count + 1)) + libraryPath = assets + "/Animations/" + URL(fileURLWithPath: document.title).deletingPathExtension().lastPathComponent + ".animlib" + modifierPath = assets + "/Animations/" + URL(fileURLWithPath: document.title).deletingPathExtension().lastPathComponent + ".animmod" + } + restoreWorkspaceState() + } + + func start() { + guard !started else { return } + started = true + do { + if document.kind == .animationModifiers { + guard let service else { throw unavailable() } + modifierPath = document.relativePath + try openModifiers() + let url = try service.resolve(modifiers?.stack.binding.sourceAsset ?? "", extensions: ["glb", "gltf"]) + preview.load(path: url.path, resourceRoot: service.assetsURL) + } else if document.kind == .animationLibrary { + guard let service else { throw unavailable() } + libraryPath = document.relativePath + library = try service.open(libraryPath) + let url = try service.resolve(library?.library.binding.sourceAsset ?? "", extensions: ["glb", "gltf"]) + preview.load(path: url.path, resourceRoot: service.assetsURL) + } else if ["glb", "gltf"].contains(document.fileExtension.lowercased()), let service { + let url = try service.resolve(document.relativePath, extensions: ["glb", "gltf"]) + preview.load(path: url.path, resourceRoot: service.assetsURL) + } else { + mode = .clips + preview.load(document) + } + } catch { + preview.isLoading = false + self.error = error.localizedDescription + preview.error = error.localizedDescription + } + } + + func cancel() { + cancelModifierDrag() + do { try saveWorkspaceState() } catch { self.error = L10n.text("Unable to save animation workspace: \(error.localizedDescription)") } + workTask?.cancel() + preview.cancel() + camera.endNavigation() + } + + func configureLoadedAsset() { + guard let asset = preview.asset else { return } + if let rig = asset.animationRig { + let source = modifiers?.stack.binding.sourceAsset + ?? (document.kind == .animationLibrary ? library?.library.binding.sourceAsset ?? document.relativePath : document.relativePath) + var imported = AnimationLibrary(id: "imported-preview", binding: .init(rig: rig, sourceAsset: source)) + do { + for (index, clip) in asset.animationClips.enumerated() { + try imported.extract(clip, rig: rig, id: "source-\(index)") + } + sourceLibrary = imported + } catch { self.error = error.localizedDescription } + } + if !asset.animationClips.contains(where: { $0.name == sourceClip }) { sourceClip = asset.animationClips.first?.name ?? "" } + preview.selectedAnimation = sourceClip.isEmpty ? "Rest pose" : sourceClip + graph.configure(asset: asset, graph: graph.graph.nodes.isEmpty ? nil : graph.graph) + timeline.synchronizeBoneSelection() + applyRequestedPreview() + refreshModifiers() + } + + func openLibrary() throws { + guard !isChangingLibrary else { throw unavailable() } + try requireNoUnkeyedPose() + guard let service else { throw unavailable() } + let candidate = try service.open(libraryPath) + if let rig = preview.asset?.animationRig { _ = try candidate.library.compiledClips(for: rig) } + library = candidate + mode = .keys + refreshLibrary() + } + + func extract(fromRest: Bool) async throws { + guard !isChangingLibrary else { throw unavailable() } + try requireNoUnkeyedPose() + isChangingLibrary = true + defer { isChangingLibrary = false } + guard let service else { throw unavailable() } + let modelPath = sourceLibrary?.binding.sourceAsset ?? (document.kind == .animationLibrary ? library?.library.binding.sourceAsset : document.relativePath) + guard let modelPath else { throw unavailable() } + let result = try await service.extract(path: libraryPath, modelPath: modelPath, + clipName: fromRest ? nil : sourceClip, name: clipName.isEmpty ? nil : clipName, expectedRevision: library?.revision) + try Task.checkCancellation() + library = result + libraryPath = result.relativePath + result.selectedClipID = result.library.clips.last?.id + mode = .keys + refreshLibrary() + } + + /// Recompile the shared draft without replacing the viewport, camera or playback clock. + func refreshLibrary() { + guard !timeline.hasUnkeyedPose else { return } + if library?.isDiscarded == true { library = nil; mode = .clips } + guard let asset = preview.asset else { return } + let clock = preview.playbackTime + do { + let libraries = library.map { [$0.library] } ?? [] + try preview.installAnimationLibraries(libraries) + let clips = try libraries.flatMap { value in + guard let rig = asset.animationRig else { throw unavailable() } + return try value.compiledClips(for: rig) + } + var labels: [String: String] = [:] + if let library { + for clip in library.library.clips { labels[library.library.runtimeName(for: clip.id)] = clip.name } + } + let prior = graph.graph.nodes.isEmpty ? nil : graph.graph + let selected = graph.selectedNodeID + graph.configure(asset: asset, graph: prior, authoredClips: clips, clipLabels: labels) + if graph.graph.nodes.contains(where: { $0.id == selected }) { graph.selectedNodeID = selected } + applyModePreview() + preview.seekGraph(to: clock) + error = "" + } catch { self.error = error.localizedDescription } + } + + func applyModePreview() { + guard !timeline.hasUnkeyedPose else { return } + let clock = preview.playbackTime + if mode == .graph { + if let graph = graph.validGraph { preview.animationGraph = graph } + } else { + preview.animationGraph = nil + if (mode == .keys || mode == .curves), let library, + let clip = library.library.clips.first(where: { $0.id == library.selectedClipID }) ?? library.library.clips.first { + preview.animationRepeats = clip.repeats + preview.selectedAnimation = library.library.runtimeName(for: clip.id) + } else { + preview.animationRepeats = true + preview.selectedAnimation = sourceClip.isEmpty ? "Rest pose" : sourceClip + } + } + preview.seekGraph(to: clock) + } + + func applyRequestedPreview() { + guard !timeline.hasUnkeyedPose else { return } + refreshLibrary() + if let library, library.previewSerial > 0 { + preview.playsAnimation = false + preview.seekGraph(to: library.previewTime) + } + } + + func run(_ operation: @escaping @MainActor () async throws -> Void) { + guard !isWorking else { return } + isWorking = true + workTask = Task { @MainActor in + defer { isWorking = false } + do { try await operation(); error = "" } + catch is CancellationError { return } + catch { self.error = error.localizedDescription } + } + } + + private func unavailable() -> AnimationAuthoringError { + .init(.missingTarget, itemID: document.id, message: L10n.tr("Open a project to edit animations")) + } + + func requireNoUnkeyedPose() throws { + guard !timeline.hasUnkeyedPose else { + throw AnimationAuthoringError(.conflict, itemID: document.id, message: L10n.tr("Insert or discard the unkeyed pose first.")) + } + } +} + +extension EditorWorkbenchViewModel { + var animationAssetDocument: EditorAssetDocument? { + guard case let .asset(document)? = activeDocument, + document.kind == .model3D || document.kind == .animationLibrary || document.kind == .animationModifiers else { return nil } + return document + } + + func animationWorkspace(for document: EditorAssetDocument) -> EditorAnimationAssetWorkspace { + if let existing = animationWorkspaces[document.id] { return existing } + let workspace = EditorAnimationAssetWorkspace(document: document, service: animationService) + workspace.workbench = self + workspace.revealInspector = { [weak self] in self?.onRevealAnimationInspector?() } + animationWorkspaces[document.id] = workspace + return workspace + } +} + +extension EditorViewModel { + func revealAssetAnimatorIfNeeded() { + guard let asset = workbench.animationAssetDocument, + asset.kind == .animationLibrary || asset.kind == .animationModifiers || ["glb", "gltf"].contains(asset.fileExtension.lowercased()) else { return } + toolStrip.selectLeftBottomTool(.init(identifier: "animator", title: "Animator", icon: "\u{E71C}")) + showBottomPanel = true + } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationAuthoringService.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationAuthoringService.swift new file mode 100644 index 000000000..8e177c3f9 --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationAuthoringService.swift @@ -0,0 +1,242 @@ +import AdaEngine +import Foundation +import Observation + +/// One project-owned authoring service. No VM or agent receives editor model references. +@Observable +@MainActor +final class EditorAnimationAuthoringService { + let projectURL: URL + let assetsURL: URL + private(set) var documents: [String: EditorAnimationLibraryDocument] = [:] + var modifierDocuments: [String: EditorAnimationModifierDocument] = [:] + @ObservationIgnored var onDocumentOpened: ((EditorAnimationLibraryDocument) -> Void)? + @ObservationIgnored var onModifierDocumentOpened: ((EditorAnimationModifierDocument) -> Void)? + @ObservationIgnored var onModifierDocumentDiscarded: ((EditorAnimationModifierDocument) -> Void)? + @ObservationIgnored var onDocumentDiscarded: ((String) -> Void)? + @ObservationIgnored private var generation = UUID() + nonisolated static let maximumBytes = 32 * 1024 * 1024 + + init(projectURL: URL, assetsURL: URL) { + self.projectURL = projectURL.resolvingSymlinksInPath().standardizedFileURL + self.assetsURL = assetsURL.resolvingSymlinksInPath().standardizedFileURL + } + + /// Resolves a project-relative path, including new destinations, under the configured Assets root. + func resolve(_ path: String, extensions: Set) throws -> URL { + guard !path.isEmpty, !path.contains(":"), !path.contains("\\"), !path.hasPrefix("/"), + !path.contains("#"), !path.contains("?"), !path.contains("\0"), + projectURL.resolvingSymlinksInPath().standardizedFileURL.path == projectURL.path, + assetsURL.resolvingSymlinksInPath().standardizedFileURL.path == assetsURL.path else { throw invalidPath(path) } + let target = Self.canonicalDestination(projectURL.appendingPathComponent(path)) + guard assetsURL.path.hasPrefix(projectURL.path + "/"), target.path.hasPrefix(assetsURL.path + "/"), + extensions.contains(target.pathExtension.lowercased()) else { throw invalidPath(path) } + return target + } + + /// Foundation on Windows leaves a missing leaf unresolved, even when its parent is a symlink. + /// Resolve an existing ancestor first so new destinations receive the same confinement checks. + nonisolated static func canonicalDestination(_ url: URL) -> URL { + var ancestor = url.standardizedFileURL + var suffix: [String] = [] + while !FileManager.default.fileExists(atPath: ancestor.path) { + let parent = ancestor.deletingLastPathComponent() + guard parent != ancestor else { break } + suffix.append(ancestor.lastPathComponent) + ancestor = parent + } + var result = ancestor.resolvingSymlinksInPath().standardizedFileURL + for component in suffix.reversed() { result.appendPathComponent(component) } + return result.standardizedFileURL + } + + func open(_ path: String) throws -> EditorAnimationLibraryDocument { + let url = try resolve(path, extensions: ["animlib"]) + if let document = documents[url.path] { return document } + let data = try Self.readBounded(url, resourceRoot: assetsURL) + let document = try EditorAnimationLibraryDocument(url: url, relativePath: relative(url), library: AnimationLibrary.decode(data), savedBytes: data, resourceRoot: assetsURL) + documents[url.path] = document + onDocumentOpened?(document) + return document + } + + func create(_ path: String, library: AnimationLibrary) throws -> EditorAnimationLibraryDocument { + let url = try resolve(path, extensions: ["animlib"]) + guard documents[url.path] == nil, !FileManager.default.fileExists(atPath: url.path) else { + throw AnimationAuthoringError(.conflict, itemID: path, message: "Animation resource already exists; inspect and edit it instead.") + } + let document = try EditorAnimationLibraryDocument(url: url, relativePath: relative(url), library: library, savedBytes: nil, resourceRoot: assetsURL) + documents[url.path] = document + onDocumentOpened?(document) + return document + } + + /// CPU import runs outside the UI actor. Publishing rechecks the resource revision after suspension. + func extract(path: String, modelPath: String, clipName: String?, name: String?, expectedRevision: String? = nil) async throws -> EditorAnimationLibraryDocument { + let token = generation + let destination = try resolve(path, extensions: ["animlib"]) + let modelURL = try resolve(modelPath, extensions: ["glb", "gltf"]) + let existing: EditorAnimationLibraryDocument? + if FileManager.default.fileExists(atPath: destination.path) || documents[destination.path] != nil { + existing = try open(path) + try existing?.checkRevision(expectedRevision ?? "") + } else { existing = nil } + let imported = try await Self.importLibrary(modelURL: modelURL, assetsURL: assetsURL, source: relative(modelURL), clipName: clipName, name: name) + try Task.checkCancellation() + guard generation == token, try resolve(path, extensions: ["animlib"]) == destination, + try resolve(modelPath, extensions: ["glb", "gltf"]) == modelURL else { + throw AnimationAuthoringError(.conflict, itemID: path, message: "Project resources changed during import; retry.") + } + if let existing { + try existing.checkRevision(expectedRevision ?? "") + guard existing.library.binding == imported.binding else { + throw AnimationAuthoringError(.incompatibleRig, itemID: path, message: "Source rig differs from this library; remap explicitly.") + } + var draft = existing.library + draft.clips.append(contentsOf: imported.clips) + try existing.commit(draft, expectedRevision: expectedRevision ?? "") + return existing + } + return try create(path, library: imported) + } + + func apply(path: String, expectedRevision: String, operations: [AnimationEdit]) throws -> EditorAnimationLibraryDocument { + let document = try open(path) + try document.apply(operations, expectedRevision: expectedRevision) + return document + } + + func close(_ path: String) throws { + let url = try resolve(path, extensions: ["animlib"]) + guard documents[url.path]?.isDirty != true else { + throw AnimationAuthoringError(.conflict, itemID: path, message: "Save or explicitly discard the animation draft before closing it.") + } + generation = UUID() + documents.removeValue(forKey: url.path)?.discard() + } + + /// Explicit discard/project deletion invalidates suspended imports and removes stale cached drafts. + func discardDocuments(atOrBelow path: String) { + generation = UUID() + for (key, document) in documents where document.relativePath == path || document.relativePath.hasPrefix(path + "/") { + document.discard() + documents.removeValue(forKey: key) + } + for (key, document) in modifierDocuments where document.relativePath == path || document.relativePath.hasPrefix(path + "/") { + document.discard() + modifierDocuments.removeValue(forKey: key) + onModifierDocumentDiscarded?(document) + } + } + + func invalidatePendingWork() { generation = UUID() } + + func requestGeneration() -> UUID { generation } + + func checkGeneration(_ expected: UUID) throws { + guard generation == expected else { + throw AnimationAuthoringError(.conflict, itemID: "project", message: "The project authoring session changed; inspect and retry.") + } + } + + func discard(_ document: EditorAnimationLibraryDocument, expectedRevision: String, discardChanges: Bool) throws { + guard documents[document.url.path] === document, document.revision == expectedRevision, discardChanges else { + throw AnimationAuthoringError(.conflict, itemID: document.relativePath, message: "Discard needs the current revision and explicit discardChanges: true.") + } + discardDocuments(atOrBelow: document.relativePath) + onDocumentDiscarded?(document.relativePath) + } + + func reload(_ document: EditorAnimationLibraryDocument, expectedRevision: String, discardChanges: Bool = false) throws { + let url = try resolve(document.relativePath, extensions: ["animlib"]) + guard documents[url.path] === document else { throw invalidPath(document.relativePath) } + try document.reload(Self.readBounded(url, resourceRoot: assetsURL), expectedRevision: expectedRevision, discardChanges: discardChanges) + } + + func savedDocuments(references: [String], sceneURL: URL?) throws -> [EditorAnimationLibraryDocument] { + var seen: Set = [] + return try references.map { reference in + guard let url = EditorSceneFileLoader.resolveTileMapReference(reference, relativeTo: sceneURL, resourceRootURL: assetsURL, fileExtension: "animlib"), + seen.insert(url).inserted else { + throw AnimationAuthoringError(.conflict, itemID: reference, message: "Use distinct animation resources inside project assets.") + } + let document = try open(relative(url)) + try document.requireSavedSnapshot() + return document + } + } + + struct ModelAnimations: Sendable { let rig: SkeletalRig; let clips: [SkeletalAnimationClip] } + + func animationsForModel(_ path: String) async throws -> ModelAnimations { + let token = generation + let url = try resolve(path, extensions: ["glb", "gltf"]) + let result = try await Self.readAnimations(modelURL: url, assetsURL: assetsURL) + try Task.checkCancellation() + guard generation == token, try resolve(path, extensions: ["glb", "gltf"]) == url else { + throw AnimationAuthoringError(.conflict, itemID: path, message: "Model changed while loading animation bindings.") + } + return result + } + + func rigForModel(_ path: String) async throws -> SkeletalRig { + try await animationsForModel(path).rig + } + + @concurrent + private static func readAnimations(modelURL: URL, assetsURL: URL) async throws -> ModelAnimations { + try Task.checkCancellation() + let imported = try NativeGLTFLoader(resourceRoot: assetsURL, maximumTotalBytes: maximumBytes) + .load(data: readBounded(modelURL, resourceRoot: assetsURL), baseURL: modelURL.deletingLastPathComponent()) + try Task.checkCancellation() + return try .init(rig: GLTFAnimationAdapter.makeRig(nodes: imported.nodes, meshes: imported.meshes), + clips: GLTFAnimationAdapter.makeClips(imported.animations, nodes: imported.nodes, meshes: imported.meshes)) + } + + @concurrent + private static func importLibrary(modelURL: URL, assetsURL: URL, source: String, clipName: String?, name: String?) async throws -> AnimationLibrary { + try Task.checkCancellation() + let data = try readBounded(modelURL, resourceRoot: assetsURL) + let imported = try NativeGLTFLoader(resourceRoot: assetsURL, maximumTotalBytes: maximumBytes) + .load(data: data, baseURL: modelURL.deletingLastPathComponent()) + let rig = try GLTFAnimationAdapter.makeRig(nodes: imported.nodes, meshes: imported.meshes) + var library = AnimationLibrary(binding: .init(rig: rig, sourceAsset: source)) + if let clipName { + let clips = try GLTFAnimationAdapter.makeClips(imported.animations, nodes: imported.nodes, meshes: imported.meshes) + let matches = clips.filter { $0.name == clipName } + guard matches.count == 1, let clip = matches.first else { + throw AnimationAuthoringError(.missingClip, itemID: clipName, message: "Choose one exact, unambiguous imported clip name.") + } + try library.extract(clip, rig: rig, name: name, sourceFingerprint: EditorAgentSceneToolService.revision(for: data.base64EncodedString())) + } else { + library.clips = [try library.poseClip(name: name ?? "Animation", duration: 1)] + } + try Task.checkCancellation() + return library + } + + private nonisolated static func readBounded(_ url: URL, resourceRoot: URL) throws -> Data { + guard url.resolvingSymlinksInPath().standardizedFileURL.path == url.path, + resourceRoot.resolvingSymlinksInPath().standardizedFileURL.path == resourceRoot.path, + url.path.hasPrefix(resourceRoot.path + "/") else { + throw AnimationAuthoringError(.conflict, itemID: url.lastPathComponent, message: "Resource location changed outside project assets.") + } + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size <= maximumBytes else { + throw AnimationAuthoringError(.limitExceeded, itemID: url.lastPathComponent, message: "Animation/model documents must be at most 32 MiB.") + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: maximumBytes + 1) ?? Data() + guard data.count <= maximumBytes else { + throw AnimationAuthoringError(.limitExceeded, itemID: url.lastPathComponent, message: "Document grew beyond 32 MiB while reading it.") + } + return data + } + + private func relative(_ url: URL) -> String { String(url.path.dropFirst(projectURL.path.count + 1)) } + + private func invalidPath(_ path: String) -> AnimationAuthoringError { + .init(.missingTarget, itemID: path, message: "Use a project-relative resource path inside Assets with a supported extension.") + } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationGraphRequests.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationGraphRequests.swift new file mode 100644 index 000000000..e33cdf37e --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationGraphRequests.swift @@ -0,0 +1,102 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +extension EditorAnimationAuthoringService { + /// Canvas, agent and Tool clients share this workspace graph and its semantic Undo history. + func executeGraph(_ request: AnimationAuthoringRequest, workbench: EditorWorkbenchViewModel) async throws -> [String: Any] { + let url = try resolve(request.path, extensions: ["glb", "gltf", "animlib"]) + guard let document = workbench.openDocuments.compactMap({ value -> EditorAssetDocument? in + if case let .asset(asset) = value, asset.relativePath == request.path { return asset } + return nil + }).first, document.absolutePath.map({ Self.canonicalDestination(URL(fileURLWithPath: $0)) }) == url else { + throw graphFailure("Open the model or animation library asset before inspecting its graph.") + } + let workspace = workbench.animationWorkspace(for: document) + let model = workspace.graph + if let expected = request.expectedRevision { try model.checkRevision(expected) } + if request.action != .graphInspect { + try workspace.requireNoUnkeyedPose() + try model.checkRevision(request.expectedRevision ?? "") + guard model.rig != nil else { throw graphFailure("Wait for the model viewport to load its animation rig.") } + } + switch request.action { + case .graphApply: + guard let graph = request.graph else { throw graphFailure("graph is required.") } + try model.applyGraph(graph, expectedRevision: request.expectedRevision ?? "") + case .graphUndo: model.undoCanvas() + case .graphRedo: model.redoCanvas() + case .graphPreview: + guard model.validGraph != nil, (request.time ?? 0).isFinite else { throw graphFailure("A valid graph and finite time are required.") } + workspace.mode = .graph + workspace.timeline.inspectorMode = "graph" + workspace.preview.playsAnimation = false + workspace.applyModePreview() + workspace.preview.seekGraph(to: request.time ?? 0) + workbench.selectDocument(id: document.id) + case .graphBind: + try await bindWorkspaceGraph(workspace, request: request, workbench: workbench) + case .graphInspect: break + default: throw graphFailure("Expected a graph action.") + } + var result: [String: Any] = ["path": request.path, "revision": model.revision, "ready": model.rig != nil, + "valid": model.validGraph != nil, "root": model.graph.root, "total": model.graph.nodes.count, + "canUndo": !model.undoStack.isEmpty, "canRedo": !model.redoStack.isEmpty, "renderVerified": false] + if let error = model.validationMessage { result["diagnostic"] = error } + if let library = workspace.library { result["resourceRevision"] = library.revision } + if request.action == .graphInspect { + let offset = request.offset ?? 0, limit = request.limit ?? 128 + let nodes = Array(model.graph.nodes.dropFirst(offset).prefix(limit)) + result["offset"] = offset + result["nodes"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(nodes)) + } + if request.action == .graphPreview { result["previewRequested"] = true } + if request.action == .graphBind { result["bound"] = true } + return result + } + + private func bindWorkspaceGraph(_ workspace: EditorAnimationAssetWorkspace, request: AnimationAuthoringRequest, + workbench: EditorWorkbenchViewModel) async throws { + let generation = requestGeneration() + guard let graph = workspace.graph.validGraph, let path = request.scenePath, let entityID = request.entityID, + let scene = workbench.openDocuments.compactMap({ value -> EditorSceneDocument? in + if case let .scene(scene) = value, scene.relativePath == path { return scene } + return nil + }).first, !scene.isReadOnly, + EditorAgentSceneToolService.revision(for: scene.content) == request.sceneRevision, + let payload = scene.sceneModel?.entities.first(where: { $0.id == entityID })?.components[EditorBuiltInComponentType.model3DSource], + var candidate = try EditorComponentRegistry.decode(typeName: EditorBuiltInComponentType.model3DSource, payload: payload) as? Model3DSource else { + throw graphFailure("Current scenePath, entityID and sceneRevision for a Model3DSource are required.") + } + let source = candidate + let sceneURL = scene.absolutePath.map { URL(fileURLWithPath: $0) } + let modelURL = try EditorModelResource.resolve(candidate.source, sourceURL: sceneURL, resourceRootURL: assetsURL) + let documents = try savedDocuments(references: candidate.animationLibraries, sceneURL: sceneURL) + let revisions = documents.map(\.revision) + let imported = try await animationsForModel(String(modelURL.path.dropFirst(projectURL.path.count + 1))) + try Task.checkCancellation() + try checkGeneration(generation) + try workspace.requireNoUnkeyedPose() + try workspace.graph.checkRevision(request.expectedRevision ?? "") + guard let authoredRig = workspace.graph.rig, + AnimationRigBinding(rig: authoredRig, sourceAsset: "").nodes == AnimationRigBinding(rig: imported.rig, sourceAsset: "").nodes else { + throw AnimationAuthoringError(.incompatibleRig, itemID: entityID, message: "Destination rig differs from the graph workspace; remap masks explicitly.") + } + guard workbench.animationService === self, workbench.animationWorkspaces[workspace.document.id] === workspace, + workbench.openDocuments.contains(where: { $0.id == workspace.document.id }), + let current = workbench.sceneDocument(id: scene.id), !current.isReadOnly, + EditorAgentSceneToolService.revision(for: current.content) == request.sceneRevision else { + throw graphFailure("Scene or workspace changed during binding; inspect and retry.") + } + for (document, revision) in zip(documents, revisions) { + try document.checkRevision(revision) + try document.requireSavedSnapshot() + } + candidate.animationGraph = graph + _ = try candidate.resolvedAnimationClips(importedClips: imported.clips, rig: imported.rig, libraries: documents.map(\.library)) + guard try workbench.setAnimationGraph(graph, target: .init(documentID: scene.id, entityID: entityID, source: source, sceneURL: sceneURL)) else { + throw graphFailure("Scene target is unavailable.") + } + } + + private func graphFailure(_ message: String) -> AnimationAuthoringError { .init(.conflict, itemID: "graph", message: message) } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationLibraryDocument.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationLibraryDocument.swift new file mode 100644 index 000000000..0c57c291b --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationLibraryDocument.swift @@ -0,0 +1,156 @@ +import AdaEngine +import Foundation +import Observation + +/// Shared resource state for Animator, agent requests and developer Tool jobs. +/// Edits change a draft; Save is explicit and checks the original bytes on disk. +@Observable +@MainActor +final class EditorAnimationLibraryDocument { + let url: URL + let relativePath: String + private(set) var library: AnimationLibrary + private(set) var revision = UUID().uuidString + private(set) var isDirty: Bool + private(set) var error: String? + private(set) var isDiscarded = false + var selectedClipID: String? + var previewTime: Double = 0 + var previewSerial: UInt64 = 0 + @ObservationIgnored var onChange: (() -> Void)? + @ObservationIgnored private var savedBytes: Data? + @ObservationIgnored private var savedLibrary: AnimationLibrary? + @ObservationIgnored private let resourceRoot: URL? + @ObservationIgnored private var undoStack: [AnimationLibrary] = [] + @ObservationIgnored private var redoStack: [AnimationLibrary] = [] + + var canUndo: Bool { !undoStack.isEmpty } + var canRedo: Bool { !redoStack.isEmpty } + var isReadOnly: Bool { FileManager.default.fileExists(atPath: url.path) && !FileManager.default.isWritableFile(atPath: url.path) } + + init(url: URL, relativePath: String, library: AnimationLibrary, savedBytes: Data?, resourceRoot: URL? = nil) throws { + try library.validate() + self.url = url + self.relativePath = relativePath + self.library = library + self.resourceRoot = resourceRoot + self.savedBytes = savedBytes + self.savedLibrary = savedBytes == nil ? nil : library + self.isDirty = savedBytes == nil + } + + func checkRevision(_ expected: String) throws { + guard !isDiscarded else { throw diskConflict() } + try checkLocation() + guard expected == revision else { + throw AnimationAuthoringError(.conflict, itemID: relativePath, message: "Animation resource changed; inspect its latest revision and retry.") + } + } + + func checkEditableRevision(_ expected: String) throws { + try checkRevision(expected) + guard !isReadOnly else { + throw AnimationAuthoringError(.conflict, itemID: relativePath, message: "Animation resource is read-only.") + } + } + + func apply(_ operations: [AnimationEdit], expectedRevision: String) throws { + try checkEditableRevision(expectedRevision) + var draft = library + try draft.apply(operations) + try commit(draft, expectedRevision: expectedRevision) + } + + func commit(_ draft: AnimationLibrary, expectedRevision: String) throws { + try checkEditableRevision(expectedRevision) + try draft.validate() + guard draft != library else { return } + undoStack.append(library) + if undoStack.count > 100 { undoStack.removeFirst() } + redoStack.removeAll() + adopt(draft) + } + + func undo(expectedRevision: String) throws { + try checkEditableRevision(expectedRevision) + guard let previous = undoStack.popLast() else { return } + redoStack.append(library) + adopt(previous) + } + + func redo(expectedRevision: String) throws { + try checkEditableRevision(expectedRevision) + guard let next = redoStack.popLast() else { return } + undoStack.append(library) + adopt(next) + } + + func save(expectedRevision: String) throws { + try checkEditableRevision(expectedRevision) + if let savedBytes { + guard try matchesDisk(savedBytes) else { throw diskConflict() } + } else if FileManager.default.fileExists(atPath: url.path) { throw diskConflict() } + let data = try library.encodedData() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try checkLocation() + try data.write(to: url, options: .atomic) + savedBytes = data + savedLibrary = library + isDirty = false + error = nil + onChange?() + } + + func requireSavedSnapshot() throws { + try checkLocation() + guard !isDirty, let savedBytes, try matchesDisk(savedBytes) else { throw diskConflict() } + } + + private func checkLocation() throws { + guard let resourceRoot else { return } + guard resourceRoot.resolvingSymlinksInPath().standardizedFileURL.path == resourceRoot.path, + EditorAnimationAuthoringService.canonicalDestination(url).path == url.path, url.path.hasPrefix(resourceRoot.path + "/") else { + throw AnimationAuthoringError(.conflict, itemID: relativePath, message: "Animation resource location changed; discard the draft and reopen a project-owned path.") + } + } + + private func matchesDisk(_ saved: Data) throws -> Bool { + guard try url.resourceValues(forKeys: [.fileSizeKey]).fileSize == saved.count else { return false } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + return try handle.read(upToCount: saved.count + 1) == saved + } + + func reload(_ data: Data, expectedRevision: String, discardChanges: Bool) throws { + try checkRevision(expectedRevision) + guard !isDirty || discardChanges else { + throw AnimationAuthoringError(.conflict, itemID: relativePath, message: "Reload would discard local edits. Request discardChanges explicitly.") + } + let fresh = try AnimationLibrary.decode(data) + guard isDirty || data != savedBytes else { return } + savedBytes = data; savedLibrary = fresh + undoStack = []; redoStack = [] + if !fresh.clips.contains(where: { $0.id == selectedClipID }) { selectedClipID = fresh.clips.first?.id } + adopt(fresh) + } + + func discard() { isDiscarded = true; onChange = nil } + + @discardableResult + func save() -> Bool { + do { try save(expectedRevision: revision); return true } + catch { self.error = error.localizedDescription; onChange?(); return false } + } + + private func adopt(_ value: AnimationLibrary) { + library = value + revision = UUID().uuidString + isDirty = value != savedLibrary + error = nil + onChange?() + } + + private func diskConflict() -> AnimationAuthoringError { + .init(.conflict, itemID: relativePath, message: "Animation file changed on disk. Reload before saving over external changes.") + } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierDocument.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierDocument.swift new file mode 100644 index 000000000..3a69d1b2f --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierDocument.swift @@ -0,0 +1,121 @@ +import AdaEngine +import Foundation +import Observation + +/// A shared, revisioned IK draft. UI, agent and developer Tool edits use this same history. +@Observable +@MainActor +final class EditorAnimationModifierDocument { + let url: URL + let relativePath: String + private(set) var stack: AnimationModifierStack + private(set) var revision = UUID().uuidString + private(set) var isDirty: Bool + private(set) var isDiscarded = false + @ObservationIgnored var onChange: (() -> Void)? + @ObservationIgnored private let resourceRoot: URL + @ObservationIgnored private var savedBytes: Data? + @ObservationIgnored private var savedStack: AnimationModifierStack? + @ObservationIgnored private var undoStack: [AnimationModifierStack] = [] + @ObservationIgnored private var redoStack: [AnimationModifierStack] = [] + var canUndo: Bool { !undoStack.isEmpty } + var canRedo: Bool { !redoStack.isEmpty } + var isReadOnly: Bool { FileManager.default.fileExists(atPath: url.path) && !FileManager.default.isWritableFile(atPath: url.path) } + + init(url: URL, relativePath: String, stack: AnimationModifierStack, savedBytes: Data?, resourceRoot: URL) throws { + try stack.validate() + self.url = url + self.relativePath = relativePath + self.stack = stack + self.savedBytes = savedBytes + self.savedStack = savedBytes == nil ? nil : stack + self.isDirty = savedBytes == nil + self.resourceRoot = resourceRoot + } + + func checkRevision(_ expected: String, editable: Bool = false) throws { + guard !isDiscarded, expected == revision else { throw conflict("IK draft changed; inspect its latest revision and retry.") } + try checkLocation() + guard !editable || !isReadOnly else { throw conflict("IK resource is read-only.") } + } + + func commit(_ candidate: AnimationModifierStack, expectedRevision: String) throws { + try checkRevision(expectedRevision, editable: true) + try candidate.validate() + guard candidate.id == stack.id, candidate.binding == stack.binding else { throw conflict("Remap the IK rig explicitly instead of replacing its binding.") } + guard candidate != stack else { return } + undoStack.append(stack) + if undoStack.count > 100 { undoStack.removeFirst() } + redoStack.removeAll(keepingCapacity: true) + adopt(candidate) + } + + func undo(expectedRevision: String) throws { + try checkRevision(expectedRevision, editable: true) + guard let previous = undoStack.popLast() else { return } + redoStack.append(stack) + adopt(previous) + } + + func redo(expectedRevision: String) throws { + try checkRevision(expectedRevision, editable: true) + guard let next = redoStack.popLast() else { return } + undoStack.append(stack) + adopt(next) + } + + func save(expectedRevision: String) throws { + try checkRevision(expectedRevision, editable: true) + if let savedBytes { guard try matchesDisk(savedBytes) else { throw conflict("IK file changed on disk; reload before saving.") } } + else if FileManager.default.fileExists(atPath: url.path) { throw conflict("IK destination already exists.") } + let data = try stack.encodedData() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try checkLocation() + try data.write(to: url, options: .atomic) + savedBytes = data + savedStack = stack + isDirty = false + onChange?() + } + + func requireSavedSnapshot() throws { + try checkLocation() + guard !isDirty, let savedBytes, try matchesDisk(savedBytes) else { throw conflict("Save the current IK resource before binding it to a scene.") } + } + + func reload(_ data: Data, expectedRevision: String, discardChanges: Bool) throws { + try checkRevision(expectedRevision) + guard !isDirty || discardChanges else { throw conflict("Reload would discard local IK edits; request discardChanges explicitly.") } + let candidate = try AnimationModifierStack.decode(data) + guard isDirty || data != savedBytes else { return } + savedStack = candidate + savedBytes = data + undoStack.removeAll(); redoStack.removeAll() + adopt(candidate) + } + + func discard() { isDiscarded = true; onChange = nil } + + private func adopt(_ candidate: AnimationModifierStack) { + stack = candidate + revision = UUID().uuidString + isDirty = stack != savedStack + onChange?() + } + + private func checkLocation() throws { + guard resourceRoot.resolvingSymlinksInPath().standardizedFileURL == resourceRoot, + EditorAnimationAuthoringService.canonicalDestination(url) == url, url.path.hasPrefix(resourceRoot.path + "/") else { + throw conflict("IK resource location changed; reopen a project-owned path.") + } + } + + private func matchesDisk(_ bytes: Data) throws -> Bool { + guard try url.resourceValues(forKeys: [.fileSizeKey]).fileSize == bytes.count else { return false } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + return try handle.read(upToCount: bytes.count + 1) == bytes + } + + private func conflict(_ message: String) -> AnimationAuthoringError { .init(.conflict, itemID: relativePath, message: message) } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierGizmos.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierGizmos.swift new file mode 100644 index 000000000..365b57c7e --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierGizmos.swift @@ -0,0 +1,77 @@ +import AdaEngine +import Math + +struct EditorAnimationModifierDrag { + let document: EditorAnimationModifierDocument + let revision: String + let id: String + let pole: Bool + let planePoint: Vector3 + var candidate: AnimationPoseModifier +} + +extension EditorAnimationAssetWorkspace { + /// A handle drag previews freely, then commits one revision/history entry on release. + func handleModifierInput(_ input: any InputEvent, camera: EditorModelPreviewCamera) -> Bool { + if let key = input as? KeyEvent, key.keyCode == .escape, modifierDrag != nil { cancelModifierDrag(); return true } + guard let mouse = input as? MouseEvent, mouse.button == .left else { return false } + if mouse.phase == .began { + guard timeline.inspectorMode == "modifiers", !timeline.hasUnkeyedPose, + let document = modifiers, let modifier = selectedModifier else { return false } + let matrix = preview.normalizationTransform.matrix + let targets: [(Bool, Vector3)] = [(false, modifier.target)] + (modifier.pole.map { [(true, $0)] } ?? []) + for (isPole, point) in targets { + let world = (matrix * Vector4(point, 1)).xyz + if let screen = camera.project(world), (screen - Vector2(mouse.mousePosition.x, mouse.mousePosition.y)).squaredLength <= 144 { + modifierDrag = .init(document: document, revision: document.revision, id: modifier.id, pole: isPole, planePoint: world, candidate: modifier) + return true + } + } + return false + } + guard var drag = modifierDrag else { return false } + if mouse.phase == .cancelled { cancelModifierDrag(); return true } + defer { if mouse.phase == .ended { modifierDrag = nil; refreshModifiers() } } + do { + guard modifiers === drag.document, selectedModifierID == drag.id else { cancelModifierDrag(); return true } + try drag.document.checkRevision(drag.revision, editable: true) + if let world = camera.unproject(mouse.mousePosition, atDepthOf: drag.planePoint) { + let point = (preview.normalizationTransform.matrix.inverse * Vector4(world, 1)).xyz + if drag.pole { drag.candidate.pole = point } else { drag.candidate.target = point } + var stack = drag.document.stack + try stack.apply([.init(kind: .update, modifierID: drag.id, modifier: drag.candidate)]) + try preview.installPoseModifiers(stack) + modifierDrag = drag + if mouse.phase == .ended { + modifierDrag = nil + try drag.document.commit(stack, expectedRevision: drag.revision) + refreshModifiers() + } + } + } catch { + modifierDrag = nil + refreshModifiers() + self.error = error.localizedDescription + } + return true + } + + func cancelModifierDrag() { modifierDrag = nil; refreshModifiers() } +} + +extension EditorModelAssetPreviewModel { + /// Alternating anchor/handle pairs, already converted to the normalized viewport world. + func modifierHandleSegments() -> [Vector3] { + guard let player = capturedAnimationPlayer(), let modifier = player.poseModifiers.first(where: { $0.id == selectedPoseModifierID }), + let first = modifier.joints.first, let last = modifier.joints.last, + let end = modifierNodeIndex(last), let root = modifierNodeIndex(first), + player.globalTransforms.indices.contains(end), player.globalTransforms.indices.contains(root) else { return [] } + let matrix = normalizationTransform.matrix + var points = [(matrix * Vector4(player.globalTransforms[end].origin, 1)).xyz, (matrix * Vector4(modifier.target, 1)).xyz] + if let pole = modifier.pole { + points.append((matrix * Vector4(player.globalTransforms[root].origin, 1)).xyz) + points.append((matrix * Vector4(pole, 1)).xyz) + } + return points + } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierRequests.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierRequests.swift new file mode 100644 index 000000000..162ad14b2 --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierRequests.swift @@ -0,0 +1,190 @@ +import AdaEngine +import Foundation + +extension EditorAnimationAuthoringService { + func openModifiers(_ path: String) throws -> EditorAnimationModifierDocument { + let url = try resolve(path, extensions: ["animmod"]) + if let document = modifierDocuments[url.path], !document.isDiscarded { return document } + let data = try readModifierBytes(url) + let document = try EditorAnimationModifierDocument(url: url, relativePath: path, stack: AnimationModifierStack.decode(data), + savedBytes: data, resourceRoot: assetsURL) + modifierDocuments[url.path] = document + onModifierDocumentOpened?(document) + return document + } + + func createModifiers(_ path: String, binding: AnimationRigBinding) throws -> EditorAnimationModifierDocument { + let url = try resolve(path, extensions: ["animmod"]) + guard modifierDocuments[url.path] == nil, !FileManager.default.fileExists(atPath: url.path) else { throw modifierFailure("IK resource already exists; inspect it before editing.") } + let document = try EditorAnimationModifierDocument(url: url, relativePath: path, stack: .init(binding: binding), savedBytes: nil, resourceRoot: assetsURL) + modifierDocuments[url.path] = document + onModifierDocumentOpened?(document) + return document + } + + func closeModifiers(_ path: String, discardChanges: Bool = false) throws { + let url = try resolve(path, extensions: ["animmod"]) + guard let document = modifierDocuments[url.path] else { return } + guard !document.isDirty || discardChanges else { throw modifierFailure("Save or explicitly discard the IK draft before closing it.") } + document.discard() + modifierDocuments[url.path] = nil + onModifierDocumentDiscarded?(document) + } + + /// Every adapter calls this dispatcher; previews use the same production player as scene playback. + func executeModifiers(_ request: AnimationAuthoringRequest, workbench: EditorWorkbenchViewModel) async throws -> [String: Any] { + let generation = requestGeneration() + if request.action == .modifierCreate { + guard let modelPath = request.modelPath else { throw modifierFailure("modelPath is required to create IK resources.") } + let rig = try await rigForModel(modelPath) + try Task.checkCancellation() + try checkGeneration(generation) + return modifierAcknowledgement(try createModifiers(request.path, binding: .init(rig: rig, sourceAsset: modelPath))) + } + let document = try openModifiers(request.path) + if let expected = request.expectedRevision { try document.checkRevision(expected) } + if !request.action.isReadOnly { try document.checkRevision(request.expectedRevision ?? "") } + switch request.action { + case .modifierApply: + guard let operations = request.modifierOperations else { throw modifierFailure("modifierOperations are required.") } + var candidate = document.stack + try candidate.apply(operations) + try document.commit(candidate, expectedRevision: request.expectedRevision ?? "") + case .modifierSave: try document.save(expectedRevision: request.expectedRevision ?? "") + case .modifierUndo: try document.undo(expectedRevision: request.expectedRevision ?? "") + case .modifierRedo: try document.redo(expectedRevision: request.expectedRevision ?? "") + case .modifierReload: + try document.reload(readModifierBytes(document.url), expectedRevision: request.expectedRevision ?? "", discardChanges: request.discardChanges == true) + case .modifierDiscard: + guard request.discardChanges == true else { throw modifierFailure("Discard requires discardChanges: true.") } + try closeModifiers(request.path, discardChanges: true) + onDocumentDiscarded?(request.path) + return ["path": request.path, "discarded": true] + case .modifierSample: + let revision = document.revision + var player: SkeletalAnimationPlayer + if let clip = request.sourceClip { + let imported = try await animationsForModel(document.stack.binding.sourceAsset) + try checkGeneration(generation) + try document.checkRevision(revision) + guard document.stack.binding.isCompatible(with: imported.rig) else { throw AnimationPoseModifierError.incompatibleRig } + player = try SkeletalAnimationPlayer(rig: imported.rig, clips: imported.clips) + try player.play(clip, transitionDuration: 0) + } else { player = try SkeletalAnimationPlayer(rig: document.stack.binding.makeRig(), clips: []) } + guard (request.time ?? 0).isFinite else { throw modifierFailure("Sample time must be finite.") } + try player.setPoseModifiers(document.stack.modifiers, binding: document.stack.binding) + player.seek(to: request.time ?? 0) + let offset = request.offset ?? 0, limit = request.limit ?? 128 + let poses = player.poses.enumerated().dropFirst(offset).prefix(limit).map { index, pose -> [String: Any] in + let global = player.globalTransforms[index] + return ["nodeID": document.stack.binding.nodes[index].id, + "translation": [pose.translation.x, pose.translation.y, pose.translation.z], + "rotation": [pose.rotation.x, pose.rotation.y, pose.rotation.z, pose.rotation.w], + "scale": [pose.scale.x, pose.scale.y, pose.scale.z], + "globalMatrixColumnMajor": (0..<4).flatMap { column in (0..<4).map { global[column, $0] } }] + } + var result = modifierAcknowledgement(document) + result["poses"] = poses; result["offset"] = offset; result["total"] = player.poses.count + result["diagnostics"] = modifierDiagnostics(player) + result["rendered"] = false + return result + case .modifierPreview: + guard let workspace = workbench.animationWorkspaces.values.first(where: { $0.modifiers === document }) else { + throw modifierFailure("Open the IK resource or attach it in the Model panel before previewing.") + } + try workspace.requireNoUnkeyedPose() + guard (request.time ?? 0).isFinite else { throw modifierFailure("Preview time must be finite.") } + workspace.preview.playsAnimation = false + workspace.timeline.inspectorMode = "modifiers" + try workspace.preview.installPoseModifiers(document.stack) + workspace.preview.seekGraph(to: request.time ?? 0) + workbench.selectDocument(id: workspace.document.id) + var result = modifierAcknowledgement(document) + result["previewRequested"] = true; result["renderVerified"] = false + return result + case .modifierBind: try await bindModifiers(document, request: request, workbench: workbench) + case .modifierInspect: break + default: throw modifierFailure("Expected a modifier action.") + } + var result = modifierAcknowledgement(document) + if request.action == .modifierInspect { + result["binding"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(document.stack.binding)) + result["modifiers"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(Array(document.stack.modifiers.dropFirst(request.offset ?? 0).prefix(request.limit ?? 128)))) + result["total"] = document.stack.modifiers.count + } + if request.action == .modifierBind { result["bound"] = true } + return result + } + + func modifierDiagnostics(_ player: SkeletalAnimationPlayer) -> [[String: Any]] { + player.modifierDiagnostics.map { diagnostic in + var value: [String: Any] = ["id": diagnostic.id, "status": diagnostic.status.rawValue, "iterations": diagnostic.iterations, + "converged": diagnostic.status == .solved] + if let error = diagnostic.solverError, error.isFinite { value["solverError"] = error } + if let error = diagnostic.reachError, error.isFinite { value["reachError"] = error } + return value + } + } + + private func bindModifiers(_ document: EditorAnimationModifierDocument, request: AnimationAuthoringRequest, workbench: EditorWorkbenchViewModel) async throws { + let generation = requestGeneration() + try document.requireSavedSnapshot() + guard let scenePath = request.scenePath, let entityID = request.entityID, + let scene = workbench.openDocuments.compactMap({ value -> EditorSceneDocument? in + if case let .scene(scene) = value, scene.relativePath == scenePath { return scene }; return nil + }).first, + !scene.isReadOnly, EditorAgentSceneToolService.revision(for: scene.content) == request.sceneRevision, + let payload = scene.sceneModel?.entities.first(where: { $0.id == entityID })?.components[EditorBuiltInComponentType.model3DSource], + var source = try EditorComponentRegistry.decode(typeName: EditorBuiltInComponentType.model3DSource, payload: payload) as? Model3DSource else { + throw modifierFailure("Current scenePath, entityID and sceneRevision for an editable Model3DSource are required.") + } + let sceneURL = scene.absolutePath.map { URL(fileURLWithPath: $0) } + let modelURL = try EditorModelResource.resolve(source.source, sourceURL: sceneURL, resourceRootURL: assetsURL) + let imported = try await animationsForModel(String(modelURL.path.dropFirst(projectURL.path.count + 1))) + try Task.checkCancellation() + try checkGeneration(generation) + try document.checkRevision(request.expectedRevision ?? "") + try document.requireSavedSnapshot() + guard workbench.animationService === self, modifierDocuments[document.url.path] === document, + let current = workbench.sceneDocument(id: scene.id), !current.isReadOnly, + EditorAgentSceneToolService.revision(for: current.content) == request.sceneRevision else { throw modifierFailure("Scene or IK resource changed during binding.") } + let bindings = request.modifierBindings ?? source.modifierBindings + for binding in bindings.values { + for target in [binding.target, binding.pole].compactMap({ $0 }) { + if let id = target.entity, current.sceneModel?.entities.contains(where: { $0.id == id }) != true { + throw modifierFailure("Scene target does not exist: \(id)") + } + } + } + source.animationModifiers = "@res://" + String(document.url.path.dropFirst(assetsURL.path.count + 1)) + source.modifierBindings = bindings + try source.validateModifiers(document.stack, rig: imported.rig) + var candidate = payload + candidate["animationModifiers"] = .string(source.animationModifiers ?? "") + candidate["modifierBindings"] = try JSONDecoder().decode(EditorSceneValue.self, from: JSONEncoder().encode(bindings)) + workbench.updateSceneModelDocument(id: current.id, status: "Animation modifiers bound") { model in + if let index = model.entities.firstIndex(where: { $0.id == entityID }) { + model.entities[index].components[EditorBuiltInComponentType.model3DSource] = candidate + } + } + } + + private func readModifierBytes(_ url: URL) throws -> Data { + guard try resolve(String(url.path.dropFirst(projectURL.path.count + 1)), extensions: ["animmod"]) == url, + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size <= AnimationModifierStack.maximumEncodedBytes else { + throw AnimationPoseModifierError.limitExceeded + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: AnimationModifierStack.maximumEncodedBytes + 1) ?? Data() + guard data.count <= AnimationModifierStack.maximumEncodedBytes else { throw AnimationPoseModifierError.limitExceeded } + return data + } + + private func modifierAcknowledgement(_ document: EditorAnimationModifierDocument) -> [String: Any] { + ["path": document.relativePath, "revision": document.revision, "dirty": document.isDirty, + "canUndo": document.canUndo, "canRedo": document.canRedo, "saved": !document.isDirty] + } + + private func modifierFailure(_ message: String) -> AnimationAuthoringError { .init(.conflict, itemID: "modifiers", message: message) } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierWorkspace.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierWorkspace.swift new file mode 100644 index 000000000..8552fd821 --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationModifierWorkspace.swift @@ -0,0 +1,67 @@ +import AdaEngine +import Foundation + +extension EditorAnimationAssetWorkspace { + func openModifiers() throws { + guard let service else { throw failure() } + try attachModifiers(service.openModifiers(modifierPath)) + } + + func createModifiers() throws { + try requireNoUnkeyedPose() + guard let service, let binding = sourceLibrary?.binding else { throw failure() } + try attachModifiers(service.createModifiers(modifierPath, binding: binding)) + } + + private func attachModifiers(_ document: EditorAnimationModifierDocument) throws { + if let rig = preview.asset?.animationRig, !document.stack.binding.isCompatible(with: rig) { throw AnimationPoseModifierError.incompatibleRig } + modifiers = document + selectedModifierID = document.stack.modifiers.first?.id + timeline.inspectorMode = "modifiers" + refreshModifiers() + } + + func refreshModifiers() { + do { + if modifiers?.isDiscarded == true { modifiers = nil } + if preview.asset != nil { try preview.installPoseModifiers(modifiers?.stack) } + } catch { self.error = error.localizedDescription } + } + + func editModifiers(_ operations: [AnimationModifierEdit]) throws { + try requireNoUnkeyedPose() + guard let document = modifiers else { throw failure() } + var draft = document.stack + try draft.apply(operations) + try document.commit(draft, expectedRevision: document.revision) + refreshModifiers() + } + + var selectedModifier: AnimationPoseModifier? { modifiers?.stack.modifiers.first { $0.id == selectedModifierID } } + + /// Uses the hierarchy's authored order, requiring a contiguous selected chain. + func addModifier(_ kind: AnimationPoseModifier.Kind) throws { + guard let binding = modifiers?.stack.binding ?? sourceLibrary?.binding else { throw failure() } + let ids = timeline.selectedNodes + let nodes = binding.nodes + var chain: [String] = [] + guard let root = nodes.first(where: { ids.contains($0.id) && ($0.parentID.map { !ids.contains($0) } ?? true) }) else { throw failure() } + chain.append(root.id) + while let child = nodes.first(where: { ids.contains($0.id) && $0.parentID == chain.last }), !chain.contains(child.id) { chain.append(child.id) } + guard chain.count == ids.count, kind != .twoBone || chain.count == 3, chain.count >= 2 else { throw failure() } + let end = chain.last.flatMap { id in nodes.firstIndex(where: { $0.id == id }) } + let point = end.flatMap { preview.capturedAnimationPlayer()?.globalTransforms[$0].origin } ?? .zero + let modifier = AnimationPoseModifier(kind: kind, joints: chain, target: point) + var candidate = modifiers?.stack ?? AnimationModifierStack(binding: binding) + try candidate.apply([.init(kind: .add, modifier: modifier)]) + if modifiers == nil { try createModifiers() } + try editModifiers([.init(kind: .add, modifier: modifier)]) + selectedModifierID = modifier.id + timeline.inspectorMode = "modifiers" + revealInspector?() + } + + private func failure() -> AnimationAuthoringError { + .init(.invalidTrack, itemID: "modifiers", message: L10n.tr("Select a contiguous bone chain before adding IK.")) + } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationPreviewSelection.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationPreviewSelection.swift new file mode 100644 index 000000000..add7fa956 --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationPreviewSelection.swift @@ -0,0 +1,37 @@ +@_spi(AdaEngine) import AdaEngine +import Math + +extension EditorModelAssetPreviewModel { + func handleBoneSelection(_ input: any InputEvent, camera: EditorModelPreviewCamera) { + guard let event = input as? MouseEvent, event.button == .left else { return } + switch event.phase { + case .began: bonePress = event.mousePosition; boneMoved = false + case .changed: + if let origin = bonePress, (event.mousePosition - origin).squaredLength > 16 { boneMoved = true } + case .cancelled: bonePress = nil + case .ended: + defer { bonePress = nil } + guard bonePress != nil, !boneMoved, showsBones, + let asset, let player = capturedAnimationPlayer() else { return } + var closest: (Int, Float)? + let joints = Set(asset.skins.flatMap(\.joints)) + for joint in joints where player.globalTransforms.indices.contains(joint) { + let position = (normalizationTransform.matrix * player.globalTransforms[joint] * Vector4(0, 0, 0, 1)).xyz + guard let screen = camera.project(position) else { continue } + let distance = (screen - Vector2(event.mousePosition.x, event.mousePosition.y)).squaredLength + if distance < 100, distance < (closest?.1 ?? Float.greatestFiniteMagnitude) { closest = (joint, distance) } + } + if let closest { + onSelectBone?(closest.0, event.modifierKeys.contains(.main) || event.modifierKeys.contains(.control) || event.modifierKeys.contains(.shift)) + } + } + } + + func selectedWorldPoints() -> [Vector3] { + guard let player = capturedAnimationPlayer() else { return [] } + return selectedNodeIndices.sorted().compactMap { index in + guard player.globalTransforms.indices.contains(index) else { return nil } + return (normalizationTransform.matrix * player.globalTransforms[index] * Vector4(0, 0, 0, 1)).xyz + } + } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationRequests.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationRequests.swift new file mode 100644 index 000000000..4bec1645d --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationRequests.swift @@ -0,0 +1,182 @@ +import AdaEngine +import Foundation + +extension EditorAnimationAuthoringService { + /// Common transport adapter for agent calls and generation-scoped Tool jobs. + func execute(_ request: AnimationAuthoringRequest, workbench: EditorWorkbenchViewModel) async throws -> [String: Any] { + let generation = requestGeneration() + let offset = request.offset ?? 0 + let limit = request.limit ?? 128 + guard offset >= 0, (1...256).contains(limit) else { throw invalidRequest("offset >= 0 and limit in 1...256 are required.") } + try Task.checkCancellation() + if request.action.isGraphAction { return try await executeGraph(request, workbench: workbench) } + if request.action.isModifierAction { return try await executeModifiers(request, workbench: workbench) } + if request.action == .discard { + guard let document = documents.values.first(where: { $0.relativePath == request.path }) else { throw invalidRequest("Open draft was not found.") } + try discard(document, expectedRevision: request.expectedRevision ?? "", discardChanges: request.discardChanges == true) + return ["path": request.path, "discarded": true] + } + if request.action == .create || request.action == .extract { + guard let modelPath = request.modelPath, request.action != .extract || request.sourceClip != nil else { + throw invalidRequest("A modelPath and, for extraction, sourceClip are required.") + } + let document = try await extract(path: request.path, modelPath: modelPath, + clipName: request.action == .create ? nil : request.sourceClip, name: request.name, expectedRevision: request.expectedRevision) + var result = acknowledgement(document) + result["createdClipID"] = document.library.clips.last?.id ?? "" + return result + } + let document = try open(request.path) + if let expected = request.expectedRevision { try document.checkRevision(expected) } + switch request.action { + case .modifierInspect, .modifierCreate, .modifierApply, .modifierSample, .modifierSave, .modifierUndo, .modifierRedo, + .modifierPreview, .modifierBind, .modifierReload, .modifierDiscard: break + case .graphInspect, .graphApply, .graphPreview, .graphUndo, .graphRedo, .graphBind: break + case .create, .extract: break + case .inspect: break + case .apply: + guard let operations = request.operations else { throw invalidRequest("operations are required.") } + try document.checkEditableRevision(request.expectedRevision ?? "") + let draft = try await Self.editedLibrary(document.library, operations: operations) + try Task.checkCancellation() + try checkGeneration(generation) + try document.commit(draft, expectedRevision: request.expectedRevision ?? "") + case .capture: + guard let source = request.clipID else { throw invalidRequest("clipID is required.") } + try document.checkRevision(request.expectedRevision ?? "") + let revision = document.revision + let player = try await Self.sampledPlayer(document.library, clipID: source, time: request.time ?? 0) + try Task.checkCancellation() + try checkGeneration(generation) + try document.checkRevision(revision) + let clip = try document.library.poseClip(name: request.name ?? "Captured pose", duration: request.duration ?? 1, from: player) + try document.apply([.init(kind: .createClip, clip: clip)], expectedRevision: request.expectedRevision ?? "") + var result = acknowledgement(document) + result["createdClipID"] = clip.id + return result + case .sample: + guard let clipID = request.clipID else { throw invalidRequest("clipID is required.") } + let revision = document.revision + let player = try await Self.sampledPlayer(document.library, clipID: clipID, time: request.time ?? 0) + try Task.checkCancellation() + try checkGeneration(generation) + try document.checkRevision(revision) + let end = min(player.poses.count, offset + min(limit, Int.max - offset)) + let indices = offset < end ? Array(offset.. [String: Any] in + let pose = player.poses[index] + return ["nodeID": document.library.binding.nodes[index].id, + "hasTRS": document.library.binding.nodes[index].restTranslation != nil, + "localMatrixColumnMajor": matrixValues(player.localTransforms[index]), + "globalMatrixColumnMajor": matrixValues(player.globalTransforms[index]), + "translation": [pose.translation.x, pose.translation.y, pose.translation.z], + "rotation": [pose.rotation.x, pose.rotation.y, pose.rotation.z, pose.rotation.w], + "scale": [pose.scale.x, pose.scale.y, pose.scale.z], "morphWeights": player.morphWeights[index]] + }] + case .validate: + try document.library.validate() + if let modelPath = request.modelPath { + let revision = document.revision + let rig = try await rigForModel(modelPath) + try Task.checkCancellation() + try document.checkRevision(revision) + _ = try document.library.compiledClips(for: rig) + } + return ["path": document.relativePath, "revision": document.revision, "schemaValid": true, + "rigCompatibilityChecked": request.modelPath != nil, "rendered": false] + case .save: try document.save(expectedRevision: request.expectedRevision ?? "") + case .undo: try document.undo(expectedRevision: request.expectedRevision ?? "") + case .redo: try document.redo(expectedRevision: request.expectedRevision ?? "") + case .reload: try reload(document, expectedRevision: request.expectedRevision ?? "", discardChanges: request.discardChanges == true) + case .discard: break + case .bind: + guard let scenePath = request.scenePath, let entityID = request.entityID, let sceneRevision = request.sceneRevision, + let clipID = request.clipID, let scene = workbench.openDocuments.compactMap({ value -> EditorSceneDocument? in + if case let .scene(scene) = value, scene.relativePath == scenePath { return scene } + return nil + }).first else { throw invalidRequest("Open scenePath, entityID, sceneRevision and clipID are required.") } + try await workbench.bindAnimationLibrary(document, sceneID: scene.id, entityID: entityID, + expectedSceneRevision: sceneRevision, expectedResourceRevision: request.expectedRevision ?? "", clipID: clipID) + case .preview: + for workspace in workbench.animationWorkspaces.values where workspace.library === document { + try workspace.requireNoUnkeyedPose() + } + guard let clipID = request.clipID, document.library.clips.contains(where: { $0.id == clipID }), + (request.time ?? 0).isFinite else { throw invalidRequest("A valid clipID and finite preview time are required.") } + document.selectedClipID = clipID + document.previewTime = request.time ?? 0 + document.previewSerial &+= 1 + workbench.selectDocument(id: "asset:\(document.relativePath)") + return ["path": document.relativePath, "revision": document.revision, "previewRequested": true, "renderVerified": false, + "verification": "Wait for the library viewport to load, then capture the editor; use visible Play for game-camera proof."] + } + if request.action.writesResource || request.action == .bind { return acknowledgement(document) } + return try snapshot(document, clipID: request.clipID, trackID: request.trackID, offset: offset, limit: limit) + } + + private func matrixValues(_ matrix: Transform3D) -> [Float] { + (0..<4).flatMap { column in (0..<4).map { row in matrix[column, row] } } + } + + @concurrent + private static func editedLibrary(_ library: AnimationLibrary, operations: [AnimationEdit]) async throws -> AnimationLibrary { + try Task.checkCancellation() + var draft = library + try draft.apply(operations) + try Task.checkCancellation() + return draft + } + + @concurrent + private static func sampledPlayer(_ library: AnimationLibrary, clipID: String, time: Double) async throws -> SkeletalAnimationPlayer { + try Task.checkCancellation() + return try library.sample(clipID: clipID, at: time) + } + + /// Mutation acknowledgements are independent of page/selection fields, so a committed edit cannot + /// be reported as failed because a requested inspection page exceeds the transport budget. + private func acknowledgement(_ document: EditorAnimationLibraryDocument) -> [String: Any] { + ["path": document.relativePath, "revision": document.revision, "dirty": document.isDirty, + "libraryID": document.library.id, "clipCount": document.library.clips.count] + } + + static func responseData(_ payload: [String: Any]) throws -> Data { + let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + guard data.count <= 1_048_576 else { + throw AnimationAuthoringError(.limitExceeded, itemID: "response", message: "Response exceeds 1 MiB; request a smaller page.") + } + return data + } + + private func snapshot(_ document: EditorAnimationLibraryDocument, clipID: String?, trackID: String?, offset: Int, limit: Int) throws -> [String: Any] { + let library = document.library + var payload: [String: Any] = ["path": document.relativePath, "revision": document.revision, "dirty": document.isDirty, + "libraryID": library.id, "sourceAsset": library.binding.sourceAsset, "nodeCount": library.binding.nodes.count, + "limits": ["nodes": 16_384, "clips": 256, "tracksPerClip": 4_096, "keys": 100_000, "operationsPerBatch": 256, "pageSize": 256], + "clipCount": library.clips.count, + "clips": library.clips.dropFirst(offset).prefix(limit).map { ["id": $0.id, "name": $0.name, "duration": $0.duration, "repeats": $0.repeats, + "runtimeName": library.runtimeName(for: $0.id), "trackCount": $0.tracks.count] as [String: Any] }] + if let clipID { + guard let clip = library.clips.first(where: { $0.id == clipID }) else { throw invalidRequest("clipID was not found.") } + payload["tracks"] = clip.tracks.dropFirst(offset).prefix(limit).map { track in + ["id": track.id, "nodeID": track.nodeID, "channel": track.channel.rawValue, + "interpolation": track.interpolation.rawValue, "keyCount": track.keys.count] as [String: Any] + } + if let trackID { + guard let track = clip.tracks.first(where: { $0.id == trackID }) else { throw invalidRequest("trackID was not found.") } + payload["keys"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(Array(track.keys.dropFirst(offset).prefix(limit)))) + payload["keyCount"] = track.keys.count + } + } else { + payload["nodes"] = library.binding.nodes.dropFirst(offset).prefix(limit).map { + ["id": $0.id, "name": $0.name ?? "", "parentID": $0.parentID ?? "", "hasTRS": $0.restTranslation != nil, + "morphTargetCount": $0.morphRestWeights?.count ?? 0] as [String: Any] + } + } + return payload + } + + private func invalidRequest(_ message: String) -> AnimationAuthoringError { .init(.invalidTrack, itemID: "request", message: message) } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationTimelineModel+Pose.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationTimelineModel+Pose.swift new file mode 100644 index 000000000..d2c5dbc3f --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationTimelineModel+Pose.swift @@ -0,0 +1,85 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +extension EditorAnimationTimelineModel { + func sampledValues(_ channel: AnimationLibraryTrack.Channel, nodeID: String? = nil) -> [Float] { + guard let library, let clip, let id = nodeID ?? selectedNode?.id, + let index = library.binding.nodes.firstIndex(where: { $0.id == id }) else { return [] } + if let draft = poseValues[id + ":" + channel.rawValue] { return draft } + guard let sampled = workspace?.preview.capturedAnimationPlayer() ?? (try? library.sample(clipID: clip.id, at: time)), + sampled.poses.indices.contains(index) else { return [] } + let pose = sampled.poses[index] + switch channel { + case .translation: return [pose.translation.x, pose.translation.y, pose.translation.z] + case .rotation: return [pose.rotation.x, pose.rotation.y, pose.rotation.z, pose.rotation.w] + case .scale: return [pose.scale.x, pose.scale.y, pose.scale.z] + case .weights: return [] + } + } + + /// Inverse of Math.Quat.euler's existing Y, X, Z argument convention. + static func eulerDegrees(_ values: [Float]) -> [Float] { + guard values.count == 4 else { return [] } + let length: Double = values.reduce(0.0) { $0 + Double($1) * Double($1) }.squareRoot() + guard length > 0, length.isFinite else { return [] } + let x = Double(values[0]) / length, y = Double(values[1]) / length + let z = Double(values[2]) / length, w = Double(values[3]) / length + let r00 = 1 - 2 * (y * y + z * z), r01 = 2 * (x * y - w * z) + let r02 = 2 * (x * z + w * y), r12 = 2 * (y * z - w * x), r22 = 1 - 2 * (x * x + y * y) + let cosine = (r00 * r00 + r01 * r01).squareRoot() + let b = Foundation.atan2(r02, cosine) + let a = cosine > 0.000001 ? Foundation.atan2(-r12, r22) : 2 * Foundation.atan2(x, w) + let c = cosine > 0.000001 ? Foundation.atan2(-r01, r00) : 0 + return [b, a, c].map { Float($0 * 180 / .pi) } + } + + func requirePoseTarget() throws { + guard let poseIdentity else { return } + guard poseIdentity.library == library?.id, poseIdentity.clip == clip?.id, + abs(poseIdentity.time - time) < 0.0000001, + poseIdentity.revision == workspace?.library?.revision else { + throw AnimationAuthoringError(.conflict, itemID: "pose", message: L10n.tr("Insert or discard the unkeyed pose first.")) + } + } + + func setPose(_ channel: AnimationLibraryTrack.Channel, values: [Float], components: Set? = nil, euler: Bool = false) throws { + guard isEditable, !selectedNodes.isEmpty, let workspace, let library, let clip else { return } + guard !workspace.isWorking, !workspace.isChangingLibrary else { + throw AnimationAuthoringError(.conflict, itemID: "pose", message: L10n.tr("Wait for the current animation operation to finish.")) + } + try requirePoseTarget() + let sampled = try library.sample(clipID: clip.id, at: time) + var pose = try library.poseClip(name: "__EditorPose_" + UUID().uuidString, duration: max(0.001, duration), from: sampled) + var proposed = poseValues + for id in selectedNodes { + var result = sampledValues(channel, nodeID: id) + if euler { result = Self.eulerDegrees(result) } + guard result.count == values.count else { continue } + for index in result.indices where components == nil || components?.contains(index) == true { result[index] = values[index] } + if euler { + let rotation = Quat.euler(Vector3(result[0], result[1], result[2]) * (Float.pi / 180)).normalized + result = [rotation.x, rotation.y, rotation.z, rotation.w] + } + proposed[id + ":" + channel.rawValue] = result + } + for index in pose.tracks.indices { + let track = pose.tracks[index] + guard let value = proposed[track.nodeID + ":" + track.channel.rawValue], !track.keys.isEmpty else { continue } + pose.tracks[index].keys[0].value = value + } + var draft = library + draft.clips.append(pose) + try draft.validate() + let clock = time + try workspace.preview.installAnimationLibraries([draft]) + workspace.preview.playsAnimation = false + workspace.preview.animationGraph = nil + workspace.preview.selectedAnimation = draft.runtimeName(for: pose.id) + workspace.preview.seekGraph(to: clock) + poseValues = proposed + if poseRevision == nil { poseRevision = workspace.library?.revision } + if poseIdentity == nil { poseIdentity = .init(library: library.id, clip: clip.id, time: clock, revision: workspace.library?.revision ?? "") } + if autoKey { try insertKeys(channels: [channel]) } + } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationTimelineModel.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationTimelineModel.swift new file mode 100644 index 000000000..680744cf4 --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationTimelineModel.swift @@ -0,0 +1,394 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Observation + +/// Selection and gesture drafts belong to the open asset; every committed edit uses its shared service. +@Observable @MainActor +final class EditorAnimationTimelineModel { + struct Key: Hashable { var track: String; var key: String } + struct Row: Identifiable { + var id: String + var nodeID: String + var label: String + var depth: Int + var channel: AnimationLibraryTrack.Channel? + var track: AnimationLibraryTrack? + } + @ObservationIgnored weak var workspace: EditorAnimationAssetWorkspace? + var selectedNodes: Set = [] { didSet { synchronizeBoneSelection() } } + var selectedKeys: Set = [] + var selectedTrackID: String? + var selectedChannel: AnimationLibraryTrack.Channel? + var expandedChannels: Set = [] + var collapsedNodes: Set = [] + var search = "" + var selectedOnly = false + var animatedOnly = false + var channelFilter = "all" + var showsModelPanel = true + var modelPanelWidth: Float = 220 + var inspectorMode = "selection" + private var requestedVerticalOffset: Float = 0 + var verticalOffset: Float { + get { min(requestedVerticalOffset, max(0, Float(rows.count) * EditorAnimationTimeline.rowHeight - viewportSize.height + EditorAnimationTimeline.rulerHeight)) } + set { requestedVerticalOffset = newValue.isFinite ? max(0, newValue) : 0 } + } + var timeOffset: Double = 0 + var pixelsPerSecond: Float = 160 + var viewportSize = Size.zero + var snap: Double = 1 / 30 + var autoKey = false + var speed: Double = 1 + var timeText = "0" + var durationText = "1" + var error = "" + var dragDelta: Double = 0 + var marquee: Rect? + var pointerEpoch: UInt64 = 0 + @ObservationIgnored var onCancelPointer: (() -> Void)? + var poseValues: [String: [Float]] = [:] + var poseRevision: String? + struct PoseIdentity { let library: String; let clip: String; let time: Double; let revision: String } + var poseIdentity: PoseIdentity? + var queuedTime: Double? + var queuedClip: String? + var queuedMode: EditorAnimationAssetWorkspace.Mode? + @ObservationIgnored private var selectionAnchor: String? + @ObservationIgnored private var keyAnchor: Key? + @ObservationIgnored private var dragRevision: String? + @ObservationIgnored private var dragClip: AnimationLibraryClip? + @ObservationIgnored private var dragLibraryID: String? + @ObservationIgnored private var dragMode: EditorAnimationAssetWorkspace.Mode? + @ObservationIgnored private var dragKeys: [(Key, AnimationLibraryKey)] = [] + + var isEditable: Bool { workspace?.library?.isDiscarded == false && workspace?.mode != .clips } + var library: AnimationLibrary? { isEditable ? workspace?.library?.library : workspace?.sourceLibrary } + var clip: AnimationLibraryClip? { + if isEditable { return library?.clips.first { $0.id == workspace?.library?.selectedClipID } ?? library?.clips.first } + return library?.clips.first { $0.name == workspace?.sourceClip } ?? library?.clips.first + } + var duration: Double { clip?.duration ?? 0 } + var time: Double { + let clock = workspace?.preview.playbackTime ?? 0 + guard duration > 0, clock > duration else { return max(0, clock) } + return clip?.repeats == true ? clock.truncatingRemainder(dividingBy: duration) : duration + } + var nodes: [AnimationRigBinding.Node] { library?.binding.nodes ?? workspace?.sourceLibrary?.binding.nodes ?? [] } + var selectedNode: AnimationRigBinding.Node? { nodes.first { selectedNodes.contains($0.id) } } + var selectedTrack: AnimationLibraryTrack? { + let tracks = clip?.tracks ?? [] + if let track = tracks.first(where: { $0.id == selectedTrackID }), selectedNodes.isEmpty || selectedNodes.contains(track.nodeID) { return track } + if !selectedNodes.isEmpty { + return tracks.first { selectedNodes.contains($0.nodeID) && $0.channel == (selectedChannel ?? .rotation) } + ?? (selectedChannel == nil ? tracks.first { selectedNodes.contains($0.nodeID) } : nil) + } + return tracks.first + } + var hasUnkeyedPose: Bool { !poseValues.isEmpty } + + var hierarchyRows: [Row] { + let values = nodes + let children = Dictionary(grouping: values, by: { $0.parentID ?? "" }) + var allowed: Set = [] + if !search.isEmpty { + let parents = values.reduce(into: [String: String]()) { $0[$1.id] = $1.parentID } + for node in values where (node.name ?? node.id).localizedCaseInsensitiveContains(search) { + var current: String? = node.id + while let id = current, allowed.insert(id).inserted { current = parents[id] } + } + } + var stack = (children[""] ?? []).reversed().map { ($0, 0) } + var result: [Row] = [] + var visited: Set = [] + while let (node, depth) = stack.popLast() { + guard visited.insert(node.id).inserted else { continue } + if search.isEmpty || allowed.contains(node.id) { + result.append(.init(id: node.id, nodeID: node.id, label: node.name ?? node.id, depth: depth)) + if !collapsedNodes.contains(node.id) || !search.isEmpty { + for child in (children[node.id] ?? []).reversed() { stack.append((child, depth + 1)) } + } + } + } + return result + } + + var rows: [Row] { + let tracks = Dictionary(grouping: clip?.tracks ?? [], by: \.nodeID) + return hierarchyRows.flatMap { row -> [Row] in + if selectedOnly && !selectedNodes.contains(row.nodeID) { return [] } + if animatedOnly && tracks[row.nodeID, default: []].isEmpty { return [] } + guard expandedChannels.contains(row.nodeID) || channelFilter != "all" else { return [row] } + let channels: [AnimationLibraryTrack.Channel] = [.translation, .rotation, .scale] + + (tracks[row.nodeID, default: []].contains { $0.channel == .weights } ? [.weights] : []) + return [row] + channels.filter { channelFilter == "all" || $0.rawValue == channelFilter }.map { channel in + .init(id: row.nodeID + ":" + channel.rawValue, nodeID: row.nodeID, + label: L10n.tr(channel.rawValue), depth: row.depth + 1, channel: channel, + track: tracks[row.nodeID]?.first { $0.channel == channel }) + } + } + } + + func selectNode(_ id: String, additive: Bool = false, range: Bool = false) { + let order = hierarchyRows.map(\.nodeID) + if range, let anchor = selectionAnchor, let first = order.firstIndex(of: anchor), let last = order.firstIndex(of: id) { + selectedNodes.formUnion(order[min(first, last)...max(first, last)]) + } else if additive { + if !selectedNodes.insert(id).inserted { selectedNodes.remove(id) } + } else { selectedNodes = [id] } + selectionAnchor = id + selectedTrackID = nil + selectedChannel = nil + keyAnchor = nil + selectedKeys = [] + inspectorMode = "selection" + workspace?.preview.showsBones = true + } + + func selectSubtree(_ id: String) { + selectNode(id) + var changed = true + while changed { + changed = false + for node in nodes where node.parentID.map(selectedNodes.contains) == true { + if selectedNodes.insert(node.id).inserted { changed = true } + } + } + } + + func seek(_ value: Double, discardPose: Bool = false) { + guard value.isFinite else { return } + if hasUnkeyedPose && !discardPose { queuedTime = value; return } + poseValues = [:] + poseRevision = nil + poseIdentity = nil + let clamped = max(0, min(duration, value)) + workspace?.preview.playsAnimation = false + workspace?.applyModePreview() + workspace?.preview.seekGraph(to: clamped) + timeText = String(format: "%.4f", clamped) + } + + func step(_ direction: Double, byKey: Bool) { + let times = clip?.tracks.flatMap { $0.keys.map(\.time) }.sorted() ?? [] + let next = direction > 0 ? times.first { $0 > time + 0.000001 } : times.last { $0 < time - 0.000001 } + seek(byKey ? next ?? (direction > 0 ? duration : 0) : time + direction * (snap > 0 ? snap : 1 / 30)) + } + + func selectClip(_ id: String) { + if hasUnkeyedPose { queuedClip = id; return } + let clock = time + if let clip = workspace?.library?.library.clips.first(where: { $0.id == id }) { + workspace?.mode = .keys + workspace?.library?.selectedClipID = clip.id + } else if let clip = workspace?.sourceLibrary?.clips.first(where: { $0.id == id }) { + workspace?.mode = .clips + workspace?.sourceClip = clip.name + } + selectedKeys = [] + selectedTrackID = nil + selectedChannel = nil + keyAnchor = nil + seek(min(clock, duration)) + durationText = String(format: "%.5g", duration) + } + + func resolveUnkeyedPose(key: Bool, cancel: Bool = false) { + if cancel { queuedTime = nil; queuedClip = nil; queuedMode = nil; return } + if key { perform { try insertKeys(channels: [.translation, .rotation, .scale]) }; if !error.isEmpty { return } } + poseValues = [:] + poseRevision = nil + poseIdentity = nil + workspace?.refreshLibrary() + let nextTime = queuedTime + let nextClip = queuedClip + let nextMode = queuedMode + queuedTime = nil; queuedClip = nil; queuedMode = nil + if let nextClip { selectClip(nextClip) } + if let nextTime { seek(nextTime) } + if let nextMode { workspace?.mode = nextMode; inspectorMode = nextMode == .graph ? "graph" : "selection" } + } + + func keys(in row: Row, timeRange: ClosedRange? = nil) -> [(Key, AnimationLibraryKey)] { + (row.track.map { [$0] } ?? (clip?.tracks.filter { $0.nodeID == row.nodeID } ?? [])).flatMap { track in + guard let timeRange else { return track.keys.map { (Key(track: track.id, key: $0.id), $0) } } + func lowerBound(_ time: Double) -> Int { + var low = 0, high = track.keys.count + while low < high { + let middle = (low + high) / 2 + if track.keys[middle].time < time { low = middle + 1 } else { high = middle } + } + return low + } + func visible(_ range: ClosedRange) -> [(Key, AnimationLibraryKey)] { + let start = lowerBound(range.lowerBound) + var end = lowerBound(range.upperBound) + if end < track.keys.count, track.keys[end].time == range.upperBound { end += 1 } + return track.keys[start.. = [] + while let value = current, visited.insert(value).inserted { collapsedNodes.remove(value); current = parents[value] } + } + + func beginKeyDrag() { + dragRevision = workspace?.library?.revision + dragClip = clip + dragLibraryID = library?.id + dragMode = workspace?.mode + dragKeys = (clip?.tracks ?? []).flatMap { track in + track.keys.compactMap { key in + let ref = Key(track: track.id, key: key.id) + return selectedKeys.contains(ref) ? (ref, key) : nil + } + } + dragDelta = 0 + } + + func endKeyDrag(cancel: Bool = false) { + defer { dragKeys = []; dragDelta = 0; dragRevision = nil; dragClip = nil; dragLibraryID = nil; dragMode = nil } + guard !cancel, dragDelta != 0, let original = dragClip else { return } + guard clip?.id == original.id, library?.id == dragLibraryID, workspace?.mode == dragMode else { + error = L10n.tr("Animation selection changed during the drag.") + return + } + perform { + let refs = Set(dragKeys.map(\.0)) + let trackIDs = Set(refs.map(\.track)) + var edits: [AnimationEdit] = [] + for track in original.tracks where trackIDs.contains(track.id) { + var replacement = track + for index in replacement.keys.indices where refs.contains(.init(track: track.id, key: replacement.keys[index].id)) { + replacement.keys[index].time += dragDelta + } + replacement.keys.sort { $0.time < $1.time } + // Every intermediate operation remains valid, including two-key cubic morph tracks. + edits.append(.init(kind: .removeTrack, clipID: original.id, trackID: track.id)) + edits.append(.init(kind: .createTrack, clipID: original.id, track: replacement)) + } + try apply(edits, revision: dragRevision) + } + } + + func cancelPointer() { + pointerEpoch &+= 1 + onCancelPointer?() + endKeyDrag(cancel: true) + marquee = nil + } + + func apply(_ edits: [AnimationEdit], revision: String? = nil) throws { + guard isEditable, let workspace, let document = workspace.library, let service = workspace.service else { + throw AnimationAuthoringError(.conflict, itemID: "timeline", message: L10n.tr("Extract an editable copy before changing keys.")) + } + _ = try service.apply(path: document.relativePath, expectedRevision: revision ?? document.revision, operations: edits) + workspace.refreshLibrary() + } + + func configureDuration(_ value: Double) throws { + guard let clip else { return } + try apply([.init(kind: .configureClip, clipID: clip.id, duration: value)]) + } + + func deleteKeys() throws { + guard let clip else { return } + try apply(selectedKeys.map { .init(kind: .removeKey, clipID: clip.id, trackID: $0.track, keyID: $0.key) }) + selectedKeys = [] + } + + func perform(_ action: () throws -> Void) { + do { try action(); error = "" } catch { self.error = error.localizedDescription } + } + + func insertKeys(channels requestedChannels: [AnimationLibraryTrack.Channel]? = nil) throws { + guard let library, let clip else { return } + let channels = requestedChannels ?? selectedChannel.map { [$0] } ?? [.translation, .rotation, .scale] + try requirePoseTarget() + let sampled = try library.sample(clipID: clip.id, at: time) + var edits: [AnimationEdit] = [] + for (index, node) in library.binding.nodes.enumerated() where selectedNodes.contains(node.id) + || channels.contains(where: { poseValues[node.id + ":" + $0.rawValue] != nil }) { + guard sampled.poses.indices.contains(index) else { continue } + let pose = sampled.poses[index] + for channel in channels { + guard channel == .weights || node.restTranslation != nil else { continue } + let value: [Float] + switch channel { + case .translation: value = [pose.translation.x, pose.translation.y, pose.translation.z] + case .rotation: value = [pose.rotation.x, pose.rotation.y, pose.rotation.z, pose.rotation.w] + case .scale: value = [pose.scale.x, pose.scale.y, pose.scale.z] + case .weights: value = sampled.morphWeights[index] + } + let values = poseValues[node.id + ":" + channel.rawValue] ?? value + guard !values.isEmpty else { continue } + var track = clip.tracks.first { $0.nodeID == node.id && $0.channel == channel } + if track == nil { + track = .init(nodeID: node.id, channel: channel) + edits.append(.init(kind: .createTrack, clipID: clip.id, track: track)) + } + guard let track else { continue } + let existing = track.keys.first { abs($0.time - time) < 0.0000001 } + let zero = Array(repeating: Float(0), count: values.count) + let key = AnimationLibraryKey(id: existing?.id ?? UUID().uuidString, time: existing?.time ?? time, value: values, + incoming: track.interpolation == .cubicSpline ? existing?.incoming ?? zero : nil, + outgoing: track.interpolation == .cubicSpline ? existing?.outgoing ?? zero : nil, + curve: existing?.curve) + edits.append(.init(kind: .putKey, clipID: clip.id, trackID: track.id, key: key, replaceExisting: existing != nil)) + } + } + guard !edits.isEmpty else { return } + try apply(edits, revision: poseRevision) + poseValues = poseValues.filter { entry in !channels.contains { entry.key.hasSuffix(":" + $0.rawValue) } } + if poseValues.isEmpty { + poseRevision = nil + poseIdentity = nil + workspace?.refreshLibrary() + } else if let identity = poseIdentity, let revision = workspace?.library?.revision { + poseRevision = revision + poseIdentity = .init(library: identity.library, clip: identity.clip, time: identity.time, revision: revision) + } + } + + func requestMode(_ mode: EditorAnimationAssetWorkspace.Mode) { + if hasUnkeyedPose { queuedMode = mode; return } + workspace?.mode = mode + inspectorMode = mode == .graph ? "graph" : "selection" + } + + func synchronizeBoneSelection() { + workspace?.preview.selectedNodeIndices = Set(nodes.enumerated().filter { selectedNodes.contains($0.element.id) }.map(\.offset)) + } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorAnimationWorkspaceState.swift b/Editor/Sources/AdaEditor/Animation/EditorAnimationWorkspaceState.swift new file mode 100644 index 000000000..7c1c989cf --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorAnimationWorkspaceState.swift @@ -0,0 +1,146 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// Editor-only state. Libraries and source models are never saved through this file. +struct EditorAnimationWorkspaceState: Codable { + var version = 1 + var assetPath: String + var libraryPath: String? + var clipID: String? + var sourceClip: String + var mode: String + var panelWidth: Float + var showsPanel: Bool + var selectedNodes: Set + var collapsedNodes: Set + var expandedChannels: Set + var graph: AnimationGraph + var positions: [String: [Float]] + var pan: [Float] + var zoom: Float + var preview: Preview? + + struct Preview: Codable { + var orthographic: Bool + var background: String + var lighting: Float + var grid: Bool + var axes: Bool + } + + @MainActor init(_ workspace: EditorAnimationAssetWorkspace) { + assetPath = workspace.document.relativePath + libraryPath = workspace.library?.relativePath ?? workspace.libraryPath + clipID = workspace.library?.selectedClipID + sourceClip = workspace.sourceClip + mode = workspace.mode.rawValue + panelWidth = workspace.timeline.modelPanelWidth + showsPanel = workspace.timeline.showsModelPanel + selectedNodes = workspace.timeline.selectedNodes + collapsedNodes = workspace.timeline.collapsedNodes + expandedChannels = workspace.timeline.expandedChannels + graph = workspace.graph.graph + let ids = Set(graph.nodes.map(\.id)) + positions = workspace.graph.positions.filter { ids.contains($0.key) }.mapValues { [$0.x, $0.y] } + pan = [workspace.graph.canvas.pan.x, workspace.graph.canvas.pan.y] + zoom = workspace.graph.canvas.zoom + preview = .init(orthographic: workspace.camera.isOrthographic, background: workspace.camera.backgroundStyle, + lighting: workspace.camera.lightingIntensity, grid: workspace.camera.showsGrid, axes: workspace.camera.showsAxes) + } + + @MainActor private static func url(project: URL, asset: String) throws -> URL { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in asset.utf8 { hash = (hash ^ UInt64(byte)) &* 1_099_511_628_211 } + let url = project.appendingPathComponent(".ada/workspace/animation/" + String(hash, radix: 16) + ".json").standardizedFileURL + guard EditorAnimationAuthoringService.canonicalDestination(url).path == url.path, + url.path.hasPrefix(project.path + "/") else { throw invalid() } + return url + } + + @MainActor static func load(project: URL, asset: String) throws -> Self? { + let url = try url(project: project, asset: asset) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + guard (try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max) <= 2 * 1024 * 1024 else { throw invalid() } + let file = try FileHandle(forReadingFrom: url) + defer { try? file.close() } + let bytes = try file.read(upToCount: 2 * 1024 * 1024 + 1) ?? Data() + guard bytes.count <= 2 * 1024 * 1024 else { throw invalid() } + let state = try JSONDecoder().decode(Self.self, from: bytes) + try state.validate(asset: asset) + return state + } + + @MainActor func save(project: URL) throws { + try validate(asset: assetPath) + let target = try Self.url(project: project, asset: assetPath) + // Full identity comparison prevents a cache-name collision from replacing another asset's state. + if let previous = try Self.load(project: project, asset: assetPath), previous.assetPath != assetPath { throw Self.invalid() } + let data = try JSONEncoder().encode(self) + guard data.count <= 2 * 1024 * 1024 else { throw Self.invalid() } + try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) + _ = try Self.url(project: project, asset: assetPath) + try data.write(to: target, options: .atomic) + } + + @MainActor private func validate(asset: String) throws { + let ids = graph.nodes.map(\.id) + guard version == 1, assetPath == asset, asset.utf8.count <= 4096, + panelWidth.isFinite, (100...4000).contains(panelWidth), zoom.isFinite, (0.25...2.5).contains(zoom), + pan.count == 2, pan.allSatisfy({ $0.isFinite && abs($0) < 10_000_000 }), + graph.nodes.count <= 1024, Set(ids).count == ids.count, ids.allSatisfy({ !$0.isEmpty && $0.utf8.count <= 256 }), + graph.nodes.reduce(0, { $0 + $1.inputs.count }) <= 4096, + positions.count <= 1024, positions.values.allSatisfy({ $0.count == 2 && $0.allSatisfy { $0.isFinite && abs($0) < 10_000_000 } }), + selectedNodes.count <= 16_384, collapsedNodes.count <= 16_384, expandedChannels.count <= 16_384 else { throw Self.invalid() } + if let preview { + guard preview.lighting.isFinite, (0...8).contains(preview.lighting), ["dark", "gray", "light"].contains(preview.background) else { throw Self.invalid() } + } + } + + @MainActor private static func invalid() -> AnimationAuthoringError { + .init(.conflict, itemID: "workspace", message: L10n.tr("Animation workspace state is invalid or outside the project.")) + } +} + +extension EditorAnimationAssetWorkspace { + func restoreWorkspaceState() { + guard let service else { return } + do { + guard let saved = try EditorAnimationWorkspaceState.load(project: service.projectURL, asset: document.relativePath) else { return } + timeline.modelPanelWidth = saved.panelWidth + timeline.showsModelPanel = saved.showsPanel + timeline.selectedNodes = saved.selectedNodes + timeline.collapsedNodes = saved.collapsedNodes + timeline.expandedChannels = saved.expandedChannels + mode = Mode(rawValue: saved.mode) ?? .keys + sourceClip = saved.sourceClip + graph.restoring = true + graph.graph = saved.graph + graph.restoring = false + graph.undoStack = []; graph.redoStack = [] + graph.selectedNodeID = graph.graph.root + graph.positions = saved.positions.mapValues { Vector2($0[0], $0[1]) } + graph.canvas.zoom(by: saved.zoom / graph.canvas.zoom, around: .zero) + graph.canvas.setPan(Vector2(saved.pan[0], saved.pan[1])) + if let preview = saved.preview { + camera.isOrthographic = preview.orthographic + camera.backgroundStyle = preview.background + camera.lightingIntensity = preview.lighting + camera.showsGrid = preview.grid + camera.showsAxes = preview.axes + } + if let path = saved.libraryPath { + libraryPath = path + let url = try service.resolve(path, extensions: ["animlib"]) + if FileManager.default.fileExists(atPath: url.path) { + library = try service.open(path) + library?.selectedClipID = saved.clipID + } + } + } catch { self.error = L10n.text("Unable to restore animation workspace: \(error.localizedDescription)") } + } + + func saveWorkspaceState() throws { + guard let service else { return } + try EditorAnimationWorkspaceState(self).save(project: service.projectURL) + } +} diff --git a/Editor/Sources/AdaEditor/Animation/EditorIKSceneBindingDraft.swift b/Editor/Sources/AdaEditor/Animation/EditorIKSceneBindingDraft.swift new file mode 100644 index 000000000..508dd5456 --- /dev/null +++ b/Editor/Sources/AdaEditor/Animation/EditorIKSceneBindingDraft.swift @@ -0,0 +1,44 @@ +import AdaEngine +import Math +import Observation + +/// Editable Inspector state for one scene/model/modifier selection, independent of resource settings. +@Observable +@MainActor +final class EditorIKSceneBindingDraft { + var targetEntity = "" + var poleEntity = "" + var targetOffset: [String] = ["0", "0", "0"] + var poleOffset: [String] = ["0", "0", "0"] + var targetUsesModelPoint = false + var poleUsesModelPoint = false + private(set) var sceneRevision = "" + + func load(_ binding: AnimationIKBinding3D?, sceneRevision: String) { + self.sceneRevision = sceneRevision + targetEntity = binding?.target?.entity ?? "" + poleEntity = binding?.pole?.entity ?? "" + targetUsesModelPoint = binding?.target != nil && binding?.target?.entity == nil + poleUsesModelPoint = binding?.pole != nil && binding?.pole?.entity == nil + targetOffset = Self.fields(binding?.target?.point ?? .zero) + poleOffset = Self.fields(binding?.pole?.point ?? .zero) + } + + func binding() throws -> AnimationIKBinding3D { + let result = try AnimationIKBinding3D( + target: point(entity: targetEntity, fields: targetOffset, usesModelPoint: targetUsesModelPoint), + pole: point(entity: poleEntity, fields: poleOffset, usesModelPoint: poleUsesModelPoint) + ) + try result.validate() + return result + } + + private func point(entity: String, fields: [String], usesModelPoint: Bool) throws -> AnimationIKTarget3D? { + if entity.isEmpty, !usesModelPoint { return nil } + let values = fields.compactMap(Float.init) + guard values.count == 3, values.allSatisfy(\.isFinite) else { throw AnimationPoseModifierError.invalidModifier("scene point") } + return .init(entity: entity.isEmpty ? nil : entity, point: Vector3(values[0], values[1], values[2])) + } + + private static func fields(_ point: Vector3) -> [String] { [point.x, point.y, point.z].map { String($0) } } +} diff --git a/Editor/Sources/AdaEditor/Assets/AgentSkills/ada-3d-animation/SKILL.md b/Editor/Sources/AdaEditor/Assets/AgentSkills/ada-3d-animation/SKILL.md index c29fd0ca7..1cd1a7834 100644 --- a/Editor/Sources/AdaEditor/Assets/AgentSkills/ada-3d-animation/SKILL.md +++ b/Editor/Sources/AdaEditor/Assets/AgentSkills/ada-3d-animation/SKILL.md @@ -1,18 +1,28 @@ --- name: ada-3d-animation -description: Inspect imported skeletal clips and skins, configure Model3DSource playback and diagnose animation compatibility in Ada Studio. -allowed-tools: editor.model.inspect, editor.model.validate, editor.components.describe, editor.scene.get, editor.scene.apply, editor.play.start, editor.docs.read +description: Inspect and edit bone animation, configure animation libraries, graphs and 3D IK, and verify skeletal playback in native Ada Studio. +allowed-tools: editor.model.inspect, editor.model.validate, editor.components.describe, editor.scene.get, editor.scene.apply, editor.play.start, editor.docs.read, editor.animation.inspect, editor.animation.create, editor.animation.extract, editor.animation.apply, editor.animation.capture, editor.animation.sample, editor.animation.validate, editor.animation.save, editor.animation.undo, editor.animation.redo, editor.animation.bind, editor.animation.preview, editor.animation.reload, editor.animation.discard, editor.animation.graphInspect, editor.animation.graphApply, editor.animation.graphUndo, editor.animation.graphRedo, editor.animation.graphPreview, editor.animation.graphBind, editor.animation.modifierInspect, editor.animation.modifierCreate, editor.animation.modifierApply, editor.animation.modifierSample, editor.animation.modifierSave, editor.animation.modifierUndo, editor.animation.modifierRedo, editor.animation.modifierPreview, editor.animation.modifierBind, editor.animation.modifierReload, editor.animation.modifierDiscard --- # Ada Studio Imported Animation +The bottom Animator has a common skeletal timeline and a node canvas. Model hierarchy and Inspector share its selection. For graph data, use graphInspect on an open loaded GLB/glTF or animlib workspace, then graphApply with its graph revision and a complete detached graph. Graph Undo/Redo shares canvas history. graphPreview requires a valid draft; graphBind applies it to a compatible scene entity with the current scene revision. Keep graph and library revisions separate. Incomplete graphs show diagnostics; malformed IDs and excessive compilation work are rejected before publication. See the AnimationAuthoring guide for budgets and the complete Tool example. + 1. Inspect the GLB/glTF for skins, joint counts, exact case-sensitive clip names, durations and interpolation modes. Unnamed clips use `Animation `. Use the returned name without inventing Idle/Walk/Run names. 2. Read the current `Model3DSource` component descriptor. For simple playback set `animation`, `autoplay: true` and `repeats` on the authored entity, retaining its source and other fields. Each placed model has an independent runtime player. If the scene payload already contains `animationGraph`, it takes precedence over the single `animation` clip. Preserve the authored graph; do not silently remove it to force clip playback. Pose-graph editing and skeletal keyframe/rig authoring are different capabilities. -3. Validate before Play. The native importer supports node TRS translation/rotation/scale channels with LINEAR, STEP and CUBICSPLINE interpolation; this profile rejects morph-weight animation and extra joint influence sets. Each skin supports at most 128 joints. +3. Validate before Play. The native importer supports node TRS translation/rotation/scale and morph-weight channels with LINEAR, STEP and CUBICSPLINE interpolation. Extra joint influence sets remain outside this profile. Each skin supports at most 128 joints. 4. Run visible Play, inspect runtime errors, and compare completed captures at different times. A clip existing in the file is not proof it plays. The isolated logic simulation does not install the Model3D plugin and cannot verify skeletal playback. For a frozen pose, check the selected name, autoplay, Play state and missing model/clip errors. For deformation, investigate joint order, inverse bind matrices, rest transforms and vertex weights. Imported asset bounds reported by inspection are rest-pose bounds, not animated bounds. Do not decimate a character as a static prop or discard its skeleton to solve a rendering issue. -For new procedural assets, load `ada-procedural-models`: `editor.model.generate` accepts explicitly placed joints, rigid/two-joint weights and linear joint translation/rotation clips from interpreted AdaScript. Geometry and rig rules are authored together in that recipe. Visual imported-skeleton/keyframe editing, automatic rig inference and retargeting remain separate gaps; importing a model does not expose it as editable generator geometry. +For editable clips, read the offline `AnimationAuthoring` guide. Native desktop hosts provide `editor.animation.` with a bounded `requestJSON` containing `AnimationAuthoringRequest`. Inspect a library for revisions/IDs, or extract a case-sensitive source clip from a project GLB/glTF into `Assets/...animlib`. Create starts from rest; capture samples an existing clip. Apply atomic `AnimationEdit` batches with `expectedRevision`; replace keys explicitly. The UI, agent and API 2 project Tools share drafts and Undo history. Save is explicit. Bind requires a saved compatible library, open scene revision and Model3DSource entity; it preserves an existing graph. Use qualified runtime names from inspection in graph clip nodes. Reload/Discard recover conflicts only with explicit discard consent. Source model bytes remain unchanged. + +For 3D IK, discover `editor.animation.modifierInspect/Create/Apply/Sample/Save/Undo/Redo/Preview/Bind/Reload/Discard`. These use the same bounded request envelope and shared draft/history as the Modifiers panel. A saved `.animmod` contains a complete rig binding and an ordered two-bone/FABRIK stack. Use exact bone IDs and directly parented chains; two-bone needs three joints. Targets/poles in the resource are model-space points. Scene binding uses saved resources, both resource and scene revisions, and optional stable marker entity IDs plus local offsets. Markers must be outside every animated rig subtree. Rigid positive-uniform transforms are supported; unsupported transforms or missing markers skip the affected modifier with diagnostics. Read AnimationIK and AnimationAuthoring before editing. The real API 2 IKHelper Tool example demonstrates the same service and explicit saving. + +Native temporal curves use key `curve` metadata through the same revisioned `key.put` operation. `easing` contains `family` (linear/quadratic/cubic/quartic/quintic/sine/exponential/circular/back/elastic/bounce), `direction` (in/out/inOut/outIn) and optional bounded overshoot/amplitude/period. Translation, scale and morph tracks also support `bezier` interpolation with per-component `incoming`/`outgoing` arrays of `{timeOffset, valueOffset}` in seconds/value units. These are distinct from raw cubic key derivatives. Rotation tracks reject scalar Bézier handles. Native curve edits promote `.animlib` to schema 2; inspect key pages and preserve their metadata when updating keys. Each library permits 500,000 compiled native scalar segments; large operations remain subject to request/key/byte limits. + +CPU sample/validation responses do not prove rendering. Request the library or modifier preview, wait for load, capture it, then verify visible Play. Native rig creation, automatic skin inference, retargeting, additional tangent modes/canvas handle dragging, state machines, native 2D spline deformation and native 2D IK remain later/separate capabilities. Discover host tool availability before using animation actions on a mobile/cloud host. + +For new procedural assets, load `ada-procedural-models`: `editor.model.generate` accepts explicitly placed joints, rigid/two-joint weights and linear joint translation/rotation clips from interpreted AdaScript. Geometry and rig rules are authored together in that recipe; importing a model does not expose it as editable generator geometry. diff --git a/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json b/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json index 53c4261bb..b0fef0dab 100644 --- a/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json +++ b/Editor/Sources/AdaEditor/Assets/Documentation/catalog.json @@ -5,6 +5,12 @@ "section": "Editor", "source": "Editor/Documentation/EditorGuide.md", "blocks": [ + { + "kind": "text", + "text": "Native control appearance follows the [Studio control design rules](Design/Controls.md):\nvisible action surfaces and states, themed fields, colored vector-axis badges and overflow indicators.", + "level": 0, + "links": [] + }, { "kind": "heading", "text": "Open or create a project", @@ -61,7 +67,19 @@ }, { "kind": "text", - "text": "glTF imports with external buffers or textures are copied into a new model folder\nwith those dependencies. Keep dependencies beneath the source model's directory;\nremote URLs and references escaping that directory are rejected. Missing files\nfail the bundle import. Repeated imports create another bundle without replacing\nthe original. Imported materials are preserved; visual material editing and\nskeleton/clip authoring are not part of this workflow yet.", + "text": "glTF imports with external buffers or textures are copied into a new model folder\nwith those dependencies. Keep dependencies beneath the source model's directory;\nremote URLs and references escaping that directory are rejected. Missing files\nfail the bundle import. Repeated imports create another bundle without replacing\nthe original. Imported materials are preserved. Imported meshes/rigs remain source\nassets; editable animation clips are stored separately as described below.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Edit bone animation", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Select a Model 3D entity and open **Animator → Bone Keyframes**. Extract an editable\ncopy of an imported clip or create a clip from the rig's rest pose. Select bones,\ntracks and timeline keys to edit translation, rotation, scale or morph weights.\nPreview the model, use Undo/Redo, then explicitly **Save** the `.animlib` resource.\nAutosave and tab switches retain animation drafts without writing them.\n**Save and Bind** attaches a saved compatible library through the scene's own history;\nthe Skeletal Graph can then select its qualified clips. See\n[Editing bone animation](AnimationAuthoring.md) for key commands, conflict recovery\nand the shared agent/Studio Tools API. The offline **Authoring skeletal animation**\nguide also covers Swift library construction and runtime graph references.", "level": 0, "links": [] }, @@ -280,7 +298,7 @@ }, { "kind": "text", - "text": "• Building Ada\n• Contributing to Ada\n• AdaUI View Identity\n• Loading images and adding formats\n• Movie Maker\n• visionOS Windowed Mode", + "text": "• Building Ada\n• Contributing to Ada\n• AdaUI View Identity\n• Loading images and adding formats\n• Authoring skeletal animation\n• Animation IK and pose modifiers\n• Movie Maker\n• visionOS Windowed Mode", "level": 0, "links": [ { @@ -299,6 +317,14 @@ "title": "Loading images and adding formats", "destination": "ImageDecoders" }, + { + "title": "Authoring skeletal animation", + "destination": "AnimationAuthoring" + }, + { + "title": "Animation IK and pose modifiers", + "destination": "AnimationIK" + }, { "title": "Movie Maker", "destination": "MovieMaker" @@ -2255,7 +2281,7 @@ }, { "kind": "text", - "text": "This profile supports glTF 2.0 primitive topologies, UV0/UV1, one set of four skin influences and at most 128 joints per skin. Required Draco/Meshopt/KTX2 extensions are unsupported. Node TRS clips can use LINEAR, STEP and CUBICSPLINE interpolation; morph-weight animation is unsupported. Models and their local dependencies have a combined 128 MiB budget. JSON and decoded accessor inventories have additional bounds.", + "text": "This profile supports glTF 2.0 primitive topologies, UV0/UV1, one set of four skin influences and at most 128 joints per skin. Required Draco/Meshopt/KTX2 extensions are unsupported. Node TRS and morph-weight clips can use LINEAR, STEP and CUBICSPLINE interpolation. Models and their local dependencies have a combined 128 MiB budget. JSON and decoded accessor inventories have additional bounds.", "level": 0, "links": [] }, @@ -2327,7 +2353,7 @@ }, { "kind": "text", - "text": "After validation and build, inspect a completed frame from visible Play. A 3D scene needs a camera, a light and an `Environment3D` (or a camera `backgroundColor`/clear flags) so the frame is not a flat clear color; `editor.play.run_scenario` can capture before/after frames around input. Exercise selected clips and compare frames at different times for animation. Isolated simulation verifies logic and keyboard/physics behavior; it does not install Model3D or prove rendering. Blender execution, rig editing and skeletal clip authoring are not exposed by these Studio tools. To create a new low-poly model use editor.model.generate (an AdaScript program that writes a GLB) or editor.voxel.generate (a .vox).", + "text": "After validation and build, inspect a completed frame from visible Play. A 3D scene needs a camera, a light and an `Environment3D` (or a camera `backgroundColor`/clear flags) so the frame is not a flat clear color; `editor.play.run_scenario` can capture before/after frames around input. Exercise selected clips and compare frames at different times for animation. Isolated simulation verifies logic and keyboard/physics behavior; it does not install Model3D or prove rendering. Native desktop agents can create/extract and edit skeletal/morph clips using the revision-checked `editor.animation.*` API; read [Animation authoring](AnimationAuthoring.md) for explicit Save, scene binding, shared Tool API 2, budgets and verification. Query `animationAuthoring` in desktop project context before use; mobile hosts do not advertise these desktop authoring commands. Blender execution, visual rig-structure editing and automatic rig inference remain unavailable. To create a new low-poly model use editor.model.generate (an AdaScript program that writes a GLB) or editor.voxel.generate (a .vox).", "level": 0, "links": [] } @@ -2908,6 +2934,314 @@ } ] }, + { + "id": "AnimationAuthoring", + "title": "Authoring skeletal animation", + "section": "AdaEngine", + "source": "Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationAuthoring.md", + "blocks": [ + { + "kind": "text", + "text": "Copy an imported animation or capture a rest/current pose into a project-owned\n`.animlib` resource. Source GLB/glTF bytes remain unchanged. Swift code, Studio's\nAnimator, the editor agent and project Tools share the same data and edit commands.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Opening a GLB/glTF or `.animlib` in Studio reveals the bottom **Animator**. A\nresizable **Model** panel shows the imported node hierarchy, and the right Inspector\nfollows bone, track, key and graph selection. The timeline places all bones on a\nshared seconds ruler with a draggable playhead, clip/duration/loop controls, key\ninsertion and grouped editing. Graph uses connected pose cards on a canvas.\nThese surfaces share the large center viewport's player and camera. Asset editing\ndoes not require an open scene. See the Studio animation-authoring guide for the\ncomplete pointer and keyboard workflow.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Create a library", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "A binding snapshots the complete hierarchy: stable node IDs, parents, names, rest\nmatrices/TRS and morph target widths. Tracks target hierarchy nodes, including\nnon-joint nodes, rather than offsets in a skin palette. Changed rigs fail binding\nexplicitly; guessing by duplicate bone names or reordered indices is not remapping.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaAnimation\nimport Foundation\n\nlet rig = try SkeletalRig(nodes: [\n .init(parentIndex: nil, restPose: .init(), restMatrix: .identity, name: \"Root\")\n])\nvar library = AnimationLibrary(id: \"hero\", binding: .init(rig: rig, sourceAsset: \"Assets/Hero.glb\"))\nlet clip = try library.poseClip(id: \"wave\", name: \"Wave\", duration: 1)\ntry library.apply([.init(id: \"create-wave\", kind: .createClip, clip: clip)])\n// This known one-bone rig has one rotation track in the captured pose clip.\nif let rotation = clip.tracks.first(where: { $0.channel == .rotation }), var key = rotation.keys.first {\n key.value = [0, 0, 0.70710677, 0.70710677]\n try library.apply([.init(id: \"rotate-root\", kind: .putKey, clipID: clip.id,\n trackID: rotation.id, key: key, replaceExisting: true)])\n}\nlet reopened = try AnimationLibrary.decode(library.encodedData())\nlet player = try reopened.sample(clipID: \"wave\", at: 0.5)", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "Use `library.extract(clip, rig: rig)` to copy a runtime clip from the same source rig.\nTranslation, normalized XYZW quaternion rotation, scale and morph-weight tracks\nretain their linear/step/cubic-spline values and derivatives. Explicit clip duration\npreserves a hold after the last key. `poseClip(name:duration:from:)` can capture a\ncompatible player's local pose/morphs. Matrix-authored static nodes retain matrices.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Edit keys and play clips", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`AnimationEdit` supports `clip.create/duplicate/remove/configure`,\n`track.create/remove/configure` and `key.put/remove`. IDs are scoped to the library,\nclip and track respectively. Duplicated clips own independent value data.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Batches are atomic. Each operation leaves a valid document; an invalid operation\nreports its ID and preserves the original library. Updating/moving an existing key\nID requires `replaceExisting:true`. Another key at the same time remains a conflict\nand must be removed explicitly. Keys use finite seconds in strictly increasing\norder within duration, typed finite values, and normalized quaternion rotations.\nCubic incoming/outgoing tangents are component derivatives per second.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Native temporal curves", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Key `curve` metadata adds easing to the outgoing segment. Families are linear,\nquadratic, cubic, quartic, quintic, sine, exponential, circular, back, elastic and\nbounce; directions are `in`, `out`, `inOut` and `outIn`. Back exposes overshoot;\nelastic exposes amplitude and period. Overshoot is retained rather than clamped.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaAnimation\n\nlet pose = SkeletalJointPose()\nlet rig = try SkeletalRig(nodes: [.init(parentIndex: nil, restPose: pose, restMatrix: pose.matrix)])\nvar library = AnimationLibrary(binding: .init(rig: rig, sourceAsset: \"Assets/Hero.glb\"))\nlet track = AnimationLibraryTrack(id: \"move\", nodeID: \"node-0\", channel: .translation, keys: [\n .init(id: \"start\", time: 0, value: [0, 0, 0]),\n .init(id: \"end\", time: 1, value: [1, 0, 0])\n])\ntry library.apply([.init(kind: .createClip, clip: .init(id: \"move\", name: \"Move\", duration: 1, tracks: [track]))])\nvar key = track.keys[0]\nkey.curve = .init(easing: .init(family: .quadratic, direction: .in))\ntry library.apply([.init(kind: .putKey, clipID: \"move\", trackID: \"move\", key: key, replaceExisting: true)])\nlet player = try library.sample(clipID: \"move\", at: 0.5) // translation.x is 0.25\nlet reopened = try AnimationLibrary.decode(library.encodedData())", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "For translation, scale and morph weights, `bezier` interpolation accepts per-component\n`curve.incoming` and `curve.outgoing` arrays of `AnimationBezierHandle`. Each handle\nstores a time offset in seconds and a value offset relative to its key. Missing\nhandles default to one third of the straight segment. They are distinct from\nHermite's `incoming`/`outgoing` derivatives on the key itself. Sampling first inverts\nmonotone Bézier time, applies easing to that parameter and then evaluates the value.\nReversing temporal curves, invalid dimensions, non-finite values and unsafe Float\nranges fail before replacing the document. Flat and crossing temporal controls are\nsupported when their full time polynomial remains monotone.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Quaternion linear tracks use shortest-path slerp with eased progress. Imported cubic\nquaternion tracks retain their normalized component interpolation; easing remaps\ntheir segment parameter. Arbitrary scalar Bézier handles are rejected on rotations.\nUnedited linear/step/imported cubic tracks retain their existing evaluator.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Edits and extraction promote native curve data to `.animlib` schema **2**. Legacy\nlibraries without that data remain schema 1; old readers must reject schema 2 rather\nthan silently remove its curves. A library permits at most 500,000 compiled native\nscalar segments in addition to its existing key/byte limits. A sparse easing edit\nonly compiles the changed segments; Bézier tracks compile every scalar segment.\nThe same key payload goes through `key.put` from native Inspector, agents and Tools,\nincluding resource revisions, atomic rejection, history and explicit Save.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Load `AnimationLibraryResource` through AssetsManager or decode its JSON, then\nprovide libraries to `Model3DSource.instantiate(_:libraries:in:under:)`. The source's\n`animationLibraries` references and loaded libraries must match in count/order.\nDistinct library IDs, compatible bindings, imported/authored name collisions and\nthe complete graph validate before runtime entities are allocated.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Use `library.runtimeName(for: clipID)` for a single animation or an existing\n`AnimationGraph` clip reference. Display names may repeat; qualified names encode\nlibrary/clip identity. Native compiled tracks retain the existing mixer, crossfade,\nrepeat/one-shot clock and skin-palette behavior. Each player owns mutable poses.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Studio and automation", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Select a Model 3D entity and open **Animator → Bone Keyframes**. Extract a copy or\ncreate a rest-pose clip, select bone/node tracks and edit keys. The model viewport\nshows the animated mesh and bones. **Save and Bind** explicitly saves the resource\nand performs a separate undoable scene transaction. The graph picker includes\nbound saved clips. Inspector edits easing and per-component Bézier offsets; Curves\nplots the production evaluator. Additional tangent modes, canvas handle dragging,\nstate machines/blend spaces, paths and native 2D spline deformation remain later work.\nProcedural 3D IK is described below.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "The editor agent calls `editor.animation.` with a `requestJSON` string\ncontaining `AnimationAuthoringRequest`. Studio API 2 project Tools use\n`editor.animations.submit(requestMap)` and `poll(jobID)`. All clients share the same\nproject drafts/history. Inspect first for stable IDs and `revision`; writes require\n`expectedRevision`. Binding also needs an open scene revision and saved resource.\nDrafts require explicit Save. Reload/Discard recover external-file conflicts.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Requests support 64 KiB, responses 1 MiB, pages 256 items and batches 256 operations.\nLibraries/model imports support 32 MiB; a library allows 16,384 nodes, 256 clips,\n4,096 tracks per clip, 100,000 keys and 1,024 morph targets per node. IDs are limited\nto 256 UTF-8 bytes, names to 1,024, source paths to 4,096 and fingerprints to 256.\nLarge morph-key pages may need smaller limits. CPU samples and preview\nacknowledgements do not establish pixels: verify the viewport and visible Play.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Save and bind from Swift", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Given a model compatible with the library's complete rig and an existing owner in\nits world, explicitly save the resource and instantiate an authored clip:", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaEngine\nimport Foundation\n\nfunc saveAndBind(_ library: AnimationLibrary, resourceURL: URL, resourceReference: String,\n model: ModelAsset3D, world: World, owner: Entity, clipID: String) throws {\n try FileManager.default.createDirectory(at: resourceURL.deletingLastPathComponent(), withIntermediateDirectories: true)\n try library.encodedData().write(to: resourceURL, options: .atomic)\n let reopened = try AnimationLibrary.decode(Data(contentsOf: resourceURL))\n let source = Model3DSource(source: reopened.binding.sourceAsset,\n animation: reopened.runtimeName(for: clipID), autoplay: true,\n animationLibraries: [resourceReference])\n _ = try source.instantiate(model, libraries: [reopened], in: world, under: owner)\n}", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "Use a project-owned resource URL and its portable `@res://Animations/Hero.animlib`\nreference. For an imported model, derive the binding from\n`GLTFAnimationAdapter.makeRig(nodes:meshes:)` using that model's native import data,\nthen extract its compatible clip. The synthetic Root rig above is only for a model\nwith that same rig; incompatible models fail explicitly. Studio Tools can follow\nthe complete bone-key, sample, Save and Bind workflow in the AnimationHelper example\nlinked from the Studio guide. Native Tools bound expanded request conversion to\n8,192 values and 64 KiB of UTF-8 strings, and restricted imports preflight repeated\ndecoded accessor work before allocation.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Native hosts can construct a preview with `ModelAsset3D(gltfAt:loader:)`, supplying\n`NativeGLTFLoader(resourceRoot:maximumTotalBytes:)` for the full model import. Studio's\nlibrary viewport uses this scoped loader; it does not change the global resolver.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Studio's shared request envelope also carries `graphInspect`, `graphApply`,\n`graphUndo`, `graphRedo`, `graphPreview` and `graphBind`. These address an open\nmodel/library workspace and use its graph revision rather than the clip-library\nrevision. `graphApply` replaces a detached `AnimationGraph` in the same canvas\nhistory. Binding validates the complete destination rig and saved libraries,\nthen commits one scene-history edit. See the Studio guide and AnimationHelper\nexample for request maps, separate permissions, budgets and explicit scene Save.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Procedural IK after animation", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "See Animation IK and pose modifiers for ordered two-bone/FABRIK constraints, model-space targets,\ncurrent scene marker bindings, `.animmod` resources and the shared Tool/agent API.", + "level": 0, + "links": [ + { + "title": "Animation IK and pose modifiers", + "destination": "AnimationIK" + } + ] + } + ] + }, + { + "id": "AnimationIK", + "title": "Animation IK and pose modifiers", + "section": "AdaEngine", + "source": "Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationIK.md", + "blocks": [ + { + "kind": "text", + "text": "Constrain a skeletal chain after its clip, Animation Graph and visual LOD pose have been mixed.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "IK belongs to each playback instance. Editing a target does not rewrite the imported GLB or\nbake corrections into an animation library. An ordered stack supports two-bone IK and FABRIK.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Solve an editable chain", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "This complete example constructs two unit segments and moves their endpoint. For an imported\nmodel, use its full rig binding; skin-palette indices are not stable bone IDs.", + "level": 0, + "links": [] + }, + { + "kind": "code", + "text": "import AdaAnimation\nimport Math\n\nlet poses = [SkeletalJointPose(), SkeletalJointPose(translation: [1, 0, 0]),\n SkeletalJointPose(translation: [1, 0, 0])]\nlet rig = try SkeletalRig(nodes: poses.indices.map { index in\n .init(parentIndex: index == 0 ? nil : index - 1,\n restPose: poses[index], restMatrix: poses[index].matrix)\n})\nlet binding = AnimationRigBinding(rig: rig, sourceAsset: \"Assets/Robot.glb\")\nvar player = try SkeletalAnimationPlayer(rig: rig, clips: [])\nlet arm = AnimationPoseModifier(id: \"arm\", kind: .twoBone,\n joints: [\"node-0\", \"node-1\", \"node-2\"], target: [1, 1, 0], pole: [0, 0, 1])\ntry player.setPoseModifiers([arm], binding: binding)\nassert((player.globalTransforms[2].origin - Vector3(1, 1, 0)).length < 0.0002)\n\n// Transient scene input does not change the saved stack or advance the clip clock.\nplayer.finalizePoseModifiers(inputs: [\"arm\": .init(target: [0, 2, 0])])\nassert(player.basePoses == poses)", + "language": "swift", + "links": [] + }, + { + "kind": "text", + "text": "`setPoseModifiers` validates before replacing the stack. Chains are contiguous and have\neditable TRS and nonzero segments. Two-bone IK requires exactly three joints. FABRIK accepts\n1–64 segments, 1–64 iterations (default 16) and a positive tolerance. Its automatic tolerance\nis `max(1e-5, length * 1e-4)`. Influence is in `0...1`; coordinates must be finite. A stack has\nat most 32 modifiers and 32,768 compiled segment-iterations. Neither solver stretches bones.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Separate base and final poses", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`basePoses` contains animation/graph/LOD output. `poses`, local/global transforms, scene\nbones and skin palettes contain the final constrained result. Repeated finalization starts\nfrom the same base pose, without accumulating corrections. Crossfade sources and LOD\nendpoints also contain base poses. Moving targets and paused clips still finalize active IK;\ntarget movement does not emit animation markers or advance playback clocks.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Modifiers execute in saved order, each observing the previous influenced result. Influence\nblends local rotations once. Local translation, scale and the end joint's authored local\nrotation remain intact; its world orientation is not independently locked. Rigid transforms\nand positive uniform scale are supported. An unsupported participating hierarchy skips that\nmodifier; unrelated nonuniform branches do not disable valid chains.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Read `modifierDiagnostics` for each stable modifier ID. `solverError` measures the\nfull-strength solution; `reachError` measures the published endpoint after influence.\nUnreachable targets and iteration exhaustion can produce valid approximate results. Missing\ntargets skip their modifier instead of holding an old solved frame. CPU samples do not prove\nrendered output.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Resources and scene bindings", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "`AnimationModifierStack` stores a versioned full-rig snapshot and an ordered list. Bounded\nJSON uses `.animmod`; `AnimationModifierResource` supplies normal asset codecs. Unknown\nversions and changed rigs are rejected. `Model3DSource.animationModifiers` loads the stack\nbefore replacing a live model. Scene overrides remain in `Model3DSource.modifierBindings`.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "An `AnimationIKTarget3D` is a model-space point or a stable scene entity ID with a local\noffset. Studio assigns `AnimationSceneIdentity3D` to authored entities. Programmatic scenes\nassign a shared namespace to the model owner and its ordinary target markers. Namespaces\nisolate loaded scene instances; runtime Entity.ID values are never persisted as targets.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Owners and marker offsets resolve from one current local-transform snapshot before applying\nany stack. A marker beneath any animated rig is rejected, including another instance.", + "level": 0, + "links": [] + }, + { + "kind": "heading", + "text": "Studio, agents and developer Tools", + "level": 2, + "links": [] + }, + { + "kind": "text", + "text": "Open a GLB/glTF or `.animmod`. **Modifiers** is in the resizable Model panel. Select a\ncontiguous chain, then add **Two-bone IK** or **FABRIK**. The Inspector owns chain, target,\npole, influence and solver settings. Blue target and purple pole handles preview a drag;\nrelease creates one Undo entry and Escape cancels. Save explicitly before binding to a scene.", + "level": 0, + "links": [] + }, + { + "kind": "text", + "text": "Agents call `editor.animation.modifierInspect/Create/Apply/Sample/Save/Undo/Redo/Preview/Bind/Reload/Discard`\n(each suffix is a separate action, for example `editor.animation.modifierApply`). API 2 Tools\nsubmit the same `AnimationAuthoringRequest` through `editor.animations.submit` and poll\nasynchronously. Edits use the inspected `expectedRevision`; binding also requires a current\n`sceneRevision` and a saved compatible stack. Standard animation read/write, preview and\ndocument-write permissions apply. See Authoring skeletal animation for job limits and transport.", + "level": 0, + "links": [ + { + "title": "Authoring skeletal animation", + "destination": "AnimationAuthoring" + } + ] + }, + { + "kind": "text", + "text": "This article describes 3D skeletal IK. Native 2D weighted deformation, continuous-turn angles,\nspline-chain authoring and typed timeline bindings for modifier parameters remain separate\nimplementation stages. A 3D numerical solve does not establish those workflows.", + "level": 0, + "links": [] + } + ] + }, { "id": "ImageDecoders", "title": "Loading images and adding formats", diff --git a/Editor/Sources/AdaEditor/Assets/Localization/en.json b/Editor/Sources/AdaEditor/Assets/Localization/en.json index 3022981d6..4edeb6b1a 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/en.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/en.json @@ -1,4 +1,60 @@ { + "All bones": "All bones", + "All tracks": "All tracks", + "All channels": "All channels", + "Animated only": "Animated only", + "Selected only": "Selected only", + "Filter Tracks": "Filter Tracks", + "Source clip": "Source clip", + "Source asset": "Source asset", + "Bone": "Bone", + "Node": "Node", + "Wait for the current animation operation to finish.": "Wait for the current animation operation to finish.", + "Graph changed or a canvas gesture is active; inspect and retry.": "Graph changed or a canvas gesture is active; inspect and retry.", + "Graph requests must fit in 64 KiB.": "Graph requests must fit in 64 KiB.", + "Graph node IDs must be unique, nonempty and at most 256 UTF-8 bytes.": "Graph node IDs must be unique, nonempty and at most 256 UTF-8 bytes.", + "Graph preview exceeds the pose or mask compilation budget; reduce cards or mask entries.": "Graph preview exceeds the pose or mask compilation budget; reduce cards or mask entries.", + "Unable to save animation workspace: {0}": "Unable to save animation workspace: {0}", + "Unable to restore animation workspace: {0}": "Unable to restore animation workspace: {0}", + "Animation workspace state is invalid or outside the project.": "Animation workspace state is invalid or outside the project.", + "Select a track": "Select a track", + "Asset preview draft": "Asset preview draft", + "This node keeps its imported matrix transform.": "This node keeps its imported matrix transform.", + "Mixed values": "Mixed values", + "Perspective": "Perspective", + "Orthographic": "Orthographic", + "Gray": "Gray", + "Lighting intensity": "Lighting intensity", + "Local pose": "Local pose", + "Enter finite numeric values for every component.": "Enter finite numeric values for every component.", + "Enter a finite time in seconds.": "Enter a finite time in seconds.", + "Discard": "Discard", + "Frame Selection": "Frame Selection", + "Preview Pose": "Preview Pose", + "Save Preview Workspace": "Save Preview Workspace", + "Snap": "Snap", + "Show grid": "Show grid", + "Hide grid": "Hide grid", + "Show axes": "Show axes", + "Hide axes": "Hide axes", + "Animation selection changed during the drag.": "Animation selection changed during the drag.", + "Editable animation": "Editable animation", + "Imported animation (read-only)": "Imported animation (read-only)", + "Auto Key: On": "Auto Key: On", + "Auto Key: Off": "Auto Key: Off", + "Preview Settings": "Preview Settings", + "Previous Key": "Previous Key", + "Next Key": "Next Key", + "Previous Frame": "Previous Frame", + "Next Frame": "Next Frame", + "Extract an editable copy before changing keys.": "Extract an editable copy before changing keys.", + "Pose": "Pose", + "Selected bones": "Selected bones", + "Filter Nodes": "Filter Nodes", + "Select Subtree": "Select Subtree", + "Unkeyed pose": "Unkeyed pose", + "Insert or discard the unkeyed pose first.": "Insert or discard the unkeyed pose first.", + "Animation graph connections cannot form a cycle.": "Animation graph connections cannot form a cycle.", "Workspaces": "Workspaces", "Open a file to see its editor or preview here.": "Open a file to see its editor or preview here.", "Back to Chat": "Back to Chat", @@ -2717,5 +2773,113 @@ "Loading resources: {0} of {1}": "Loading resources: {0} of {1}", "Creating scene objects…": "Creating scene objects…", "Reading scene data…": "Reading scene data…", - "Drag: orbit · Middle drag: pan · Scroll: zoom\nRight drag: look · WASD: move · Q/E: down/up · Shift: faster": "Drag: orbit · Middle drag: pan · Scroll: zoom\nRight drag: look · WASD: move · Q/E: down/up · Shift: faster" + "Drag: orbit · Middle drag: pan · Scroll: zoom\nRight drag: look · WASD: move · Q/E: down/up · Shift: faster": "Drag: orbit · Middle drag: pan · Scroll: zoom\nRight drag: look · WASD: move · Q/E: down/up · Shift: faster", + "Bone Keyframes": "Bone Keyframes", + "Open a project to edit animations": "Open a project to edit animations", + "Animation edit failed: {0}": "Animation edit failed: {0}", + "Unsaved animation": "Unsaved animation", + "Bones": "Bones", + "Create Clip": "Create Clip", + "Capture Pose": "Capture Pose", + "Set Duration": "Set Duration", + "Loop: On": "Loop: On", + "Loop: Off": "Loop: Off", + "Add Bone Track": "Add Bone Track", + "Euler degrees (Z, X, Y)": "Euler degrees (Z, X, Y)", + "Quaternion XYZW": "Quaternion XYZW", + "Key values, separated by commas": "Key values, separated by commas", + "Incoming derivative": "Incoming derivative", + "Outgoing derivative": "Outgoing derivative", + "Insert Key": "Insert Key", + "Update Key": "Update Key", + "Animation library path": "Animation library path", + "Imported clip": "Imported clip", + "Extract Editable Copy": "Extract Editable Copy", + "New Animation": "New Animation", + "Open Library": "Open Library", + "Save and Bind": "Save and Bind", + "Choose an imported clip to copy, or create an animation from the rig's rest pose.": "Choose an imported clip to copy, or create an animation from the rig's rest pose.", + "Draft discarded. Open the library again.": "Draft discarded. Open the library again.", + "Reload from Disk": "Reload from Disk", + "Discard Local Edits and Reload": "Discard Local Edits and Reload", + "Discard Draft": "Discard Draft", + "Read animation resources": "Read animation resources", + "Edit animation resources": "Edit animation resources", + "Control animation previews": "Control animation previews", + "Inspect project animation drafts, bindings, keys and sampled poses.": "Inspect project animation drafts, bindings, keys and sampled poses.", + "Create, edit and explicitly save animation libraries through their Undo history.": "Create, edit and explicitly save animation libraries through their Undo history.", + "Open animation library previews and seek their viewport.": "Open animation library previews and seek their viewport.", + "step": "step", + "cubicSpline": "cubicSpline", + "rotation": "rotation", + "translation": "translation", + "scale": "scale", + "weights": "weights", + "Save the animation draft explicitly or discard it before closing.": "Save the animation draft explicitly or discard it before closing.", + "Save the animation draft explicitly before continuing.": "Save the animation draft explicitly before continuing.", + "Rest pose": "Rest pose", + "+ Two-bone IK": "+ Two-bone IK", + "+ FABRIK": "+ FABRIK", + "Two-bone IK": "Two-bone IK", + "FABRIK": "FABRIK", + "Modifier resource path": "Modifier resource path", + "Open IK Resource": "Open IK Resource", + "Create IK Resource": "Create IK Resource", + "IK Enabled": "IK Enabled", + "IK Disabled": "IK Disabled", + "Root bone": "Root bone", + "End bone": "End bone", + "IK Target (model space)": "IK Target (model space)", + "Pole": "Pole", + "Influence": "Influence", + "Iterations": "Iterations", + "Tolerance (empty = automatic)": "Tolerance (empty = automatic)", + "Apply IK": "Apply IK", + "Move Up": "Move Up", + "Move Down": "Move Down", + "Scene target bindings": "Scene target bindings", + "Choose scene model": "Choose scene model", + "Target entity ID (empty = model point)": "Target entity ID (empty = model point)", + "Pole entity ID (empty = model point)": "Pole entity ID (empty = model point)", + "Marker offsets use local space. Asset preview uses model-space points.": "Marker offsets use local space. Asset preview uses model-space points.", + "Bind IK to Scene": "Bind IK to Scene", + "IK status": "IK status", + "Reach error": "Reach error", + "Select a contiguous bone chain before adding IK.": "Select a contiguous bone chain before adding IK.", + "solved": "solved", + "unreachable": "unreachable", + "iterationLimit": "iterationLimit", + "invalidTarget": "invalidTarget", + "unsupportedTransform": "unsupportedTransform", + "invalidPose": "invalidPose", + "Reload Scene Bindings": "Reload Scene Bindings", + "Scene changed. Reload bindings before applying.": "Scene changed. Reload bindings before applying.", + "Override Target Point": "Override Target Point", + "Override Pole Point": "Override Pole Point", + "Outgoing segment easing": "Outgoing segment easing", + "Overshoot": "Overshoot", + "Amplitude": "Amplitude", + "Period": "Period", + "Bezier handles": "Bezier handles", + "Handle offsets use seconds and value units.": "Handle offsets use seconds and value units.", + "Incoming handle": "Incoming handle", + "Outgoing handle": "Outgoing handle", + "Time offset": "Time offset", + "Value offset": "Value offset", + "Apply Curve": "Apply Curve", + "bezier": "bezier", + "quadratic": "quadratic", + "cubic": "cubic", + "quartic": "quartic", + "quintic": "quintic", + "sine": "sine", + "exponential": "exponential", + "circular": "circular", + "back": "back", + "elastic": "elastic", + "bounce": "bounce", + "in": "in", + "out": "out", + "inOut": "inOut", + "outIn": "outIn" } diff --git a/Editor/Sources/AdaEditor/Assets/Localization/ru.json b/Editor/Sources/AdaEditor/Assets/Localization/ru.json index db331214d..5774c70ba 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/ru.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/ru.json @@ -1,4 +1,60 @@ { + "All bones": "Все кости", + "All tracks": "Все дорожки", + "All channels": "Все каналы", + "Animated only": "С анимацией", + "Selected only": "Выбранные", + "Filter Tracks": "Поиск дорожек", + "Source clip": "Исходный клип", + "Source asset": "Исходная модель", + "Bone": "Кость", + "Node": "Узел", + "Wait for the current animation operation to finish.": "Дождитесь завершения текущего действия с анимацией.", + "Graph changed or a canvas gesture is active; inspect and retry.": "Граф изменён или сейчас перемещается. Обновите данные и повторите действие.", + "Graph requests must fit in 64 KiB.": "Запрос к графу должен быть не больше 64 КиБ.", + "Graph node IDs must be unique, nonempty and at most 256 UTF-8 bytes.": "ID узлов графа должны быть уникальными, непустыми и не длиннее 256 байт UTF-8.", + "Graph preview exceeds the pose or mask compilation budget; reduce cards or mask entries.": "Граф слишком сложный для предпросмотра. Уменьшите число узлов или записей в масках.", + "Unable to save animation workspace: {0}": "Не удалось сохранить рабочее место анимации: {0}", + "Unable to restore animation workspace: {0}": "Не удалось восстановить рабочее место анимации: {0}", + "Animation workspace state is invalid or outside the project.": "Настройки рабочего места анимации повреждены или находятся вне проекта.", + "Select a track": "Выбери дорожку", + "Asset preview draft": "Черновик просмотра модели", + "This node keeps its imported matrix transform.": "Эта нода сохраняет матрицу из исходной модели.", + "Mixed values": "Разные значения", + "Perspective": "Перспектива", + "Orthographic": "Ортографический вид", + "Gray": "Серый", + "Lighting intensity": "Яркость освещения", + "Local pose": "Локальная поза", + "Enter finite numeric values for every component.": "Введи конечное число для каждого компонента.", + "Enter a finite time in seconds.": "Введи конечное время в секундах.", + "Discard": "Отменить изменения", + "Frame Selection": "Показать выбранное", + "Preview Pose": "Посмотреть позу", + "Save Preview Workspace": "Сохранить рабочее место просмотра", + "Snap": "Привязка", + "Show grid": "Показать сетку", + "Hide grid": "Скрыть сетку", + "Show axes": "Показать оси", + "Hide axes": "Скрыть оси", + "Animation selection changed during the drag.": "Выбор анимации изменился во время перетаскивания.", + "Editable animation": "Редактируемая анимация", + "Imported animation (read-only)": "Импортированная анимация (только просмотр)", + "Auto Key: On": "Автоключи: вкл.", + "Auto Key: Off": "Автоключи: выкл.", + "Preview Settings": "Настройки просмотра", + "Previous Key": "Предыдущий ключ", + "Next Key": "Следующий ключ", + "Previous Frame": "Предыдущий кадр", + "Next Frame": "Следующий кадр", + "Extract an editable copy before changing keys.": "Создай редактируемую копию перед изменением ключей.", + "Pose": "Поза", + "Selected bones": "Выбранные кости", + "Filter Nodes": "Поиск нод", + "Select Subtree": "Выбрать ветку", + "Unkeyed pose": "Поза без ключей", + "Insert or discard the unkeyed pose first.": "Сначала сохрани позу в ключи или отмени её.", + "Animation graph connections cannot form a cycle.": "Связи графа анимации не могут образовывать цикл.", "Workspaces": "Рабочие области", "Open a file to see its editor or preview here.": "Откройте файл, и здесь появится его редактор или превью.", "Back to Chat": "Вернуться к чату", @@ -2717,5 +2773,113 @@ "Loading resources: {0} of {1}": "Загружаем ресурсы: {0} из {1}", "Creating scene objects…": "Создаём объекты сцены…", "Reading scene data…": "Читаем данные сцены…", - "Drag: orbit · Middle drag: pan · Scroll: zoom\nRight drag: look · WASD: move · Q/E: down/up · Shift: faster": "Перетаскивай: вращение · Средняя кнопка: сдвиг · Колесо: масштаб\nПравая кнопка: осмотр · WASD: полёт · Q/E: вниз/вверх · Shift: быстрее" + "Drag: orbit · Middle drag: pan · Scroll: zoom\nRight drag: look · WASD: move · Q/E: down/up · Shift: faster": "Перетаскивай: вращение · Средняя кнопка: сдвиг · Колесо: масштаб\nПравая кнопка: осмотр · WASD: полёт · Q/E: вниз/вверх · Shift: быстрее", + "Bone Keyframes": "Ключи костей", + "Open a project to edit animations": "Откройте проект для редактирования анимаций", + "Animation edit failed: {0}": "Не удалось изменить анимацию: {0}", + "Unsaved animation": "Несохранённая анимация", + "Bones": "Кости", + "Create Clip": "Создать клип", + "Capture Pose": "Захватить позу", + "Set Duration": "Задать длительность", + "Loop: On": "Цикл: вкл.", + "Loop: Off": "Цикл: выкл.", + "Add Bone Track": "Добавить трек кости", + "Euler degrees (Z, X, Y)": "Углы Эйлера в градусах (Z, X, Y)", + "Quaternion XYZW": "Кватернион XYZW", + "Key values, separated by commas": "Значения ключа через запятую", + "Incoming derivative": "Входящая производная", + "Outgoing derivative": "Исходящая производная", + "Insert Key": "Вставить ключ", + "Update Key": "Изменить ключ", + "Animation library path": "Путь библиотеки анимаций", + "Imported clip": "Импортированный клип", + "Extract Editable Copy": "Извлечь редактируемую копию", + "New Animation": "Новая анимация", + "Open Library": "Открыть библиотеку", + "Save and Bind": "Сохранить и привязать", + "Choose an imported clip to copy, or create an animation from the rig's rest pose.": "Выберите импортированный клип для копирования или создайте анимацию из исходной позы скелета.", + "Draft discarded. Open the library again.": "Черновик отброшен. Откройте библиотеку снова.", + "Reload from Disk": "Перезагрузить с диска", + "Discard Local Edits and Reload": "Отбросить правки и перезагрузить", + "Discard Draft": "Отбросить черновик", + "Read animation resources": "Чтение ресурсов анимации", + "Edit animation resources": "Редактирование ресурсов анимации", + "Control animation previews": "Управление предпросмотром анимации", + "Inspect project animation drafts, bindings, keys and sampled poses.": "Чтение черновиков анимации, привязок, ключей и вычисленных поз проекта.", + "Create, edit and explicitly save animation libraries through their Undo history.": "Создание, редактирование и явное сохранение библиотек анимаций с историей отмены.", + "Open animation library previews and seek their viewport.": "Открытие предпросмотра библиотек анимаций и переход по времени.", + "step": "Ступенчатая", + "cubicSpline": "Кубический сплайн", + "rotation": "Вращение", + "translation": "Перемещение", + "scale": "Масштаб", + "weights": "Веса", + "Save the animation draft explicitly or discard it before closing.": "Явно сохраните черновик анимации или отбросьте его перед закрытием.", + "Save the animation draft explicitly before continuing.": "Явно сохраните черновик анимации перед продолжением.", + "Rest pose": "Исходная поза", + "+ Two-bone IK": "+ Двухкостный IK", + "+ FABRIK": "+ FABRIK", + "Two-bone IK": "Двухкостный IK", + "FABRIK": "FABRIK", + "Modifier resource path": "Путь к ресурсу модификаторов", + "Open IK Resource": "Открыть ресурс IK", + "Create IK Resource": "Создать ресурс IK", + "IK Enabled": "IK включён", + "IK Disabled": "IK выключен", + "Root bone": "Начальная кость", + "End bone": "Конечная кость", + "IK Target (model space)": "Цель IK (пространство модели)", + "Pole": "Направляющая", + "Influence": "Влияние", + "Iterations": "Итерации", + "Tolerance (empty = automatic)": "Допуск (пусто — автоматически)", + "Apply IK": "Применить IK", + "Move Up": "Выше", + "Move Down": "Ниже", + "Scene target bindings": "Привязки целей к сцене", + "Choose scene model": "Выберите модель сцены", + "Target entity ID (empty = model point)": "ID цели (пусто — точка модели)", + "Pole entity ID (empty = model point)": "ID направляющей (пусто — точка модели)", + "Marker offsets use local space. Asset preview uses model-space points.": "Смещения маркеров заданы локально. В просмотрщике используются точки в пространстве модели.", + "Bind IK to Scene": "Привязать IK к сцене", + "IK status": "Состояние IK", + "Reach error": "Расстояние до цели", + "Select a contiguous bone chain before adding IK.": "Перед добавлением IK выберите непрерывную цепочку костей.", + "solved": "Цель достигнута", + "unreachable": "Цель недостижима", + "iterationLimit": "Лимит итераций", + "invalidTarget": "Недоступная цель", + "unsupportedTransform": "Неподдерживаемая трансформация", + "invalidPose": "Некорректная поза", + "Reload Scene Bindings": "Перечитать привязки сцены", + "Scene changed. Reload bindings before applying.": "Сцена изменилась. Перечитайте привязки перед применением.", + "Override Target Point": "Переопределить точку цели", + "Override Pole Point": "Переопределить точку полюса", + "Outgoing segment easing": "Плавность следующего участка", + "Overshoot": "Выход за границы", + "Amplitude": "Амплитуда", + "Period": "Период", + "Bezier handles": "Ручки Безье", + "Handle offsets use seconds and value units.": "Смещения ручек заданы в секундах и единицах значения.", + "Incoming handle": "Входящая ручка", + "Outgoing handle": "Исходящая ручка", + "Time offset": "Смещение времени", + "Value offset": "Смещение значения", + "Apply Curve": "Применить кривую", + "bezier": "Безье", + "quadratic": "Квадратичная", + "cubic": "Кубическая", + "quartic": "Четвёртой степени", + "quintic": "Пятой степени", + "sine": "Синусоидальная", + "exponential": "Экспоненциальная", + "circular": "Круговая", + "back": "С возвратом", + "elastic": "Упругая", + "bounce": "С отскоком", + "in": "Разгон", + "out": "Замедление", + "inOut": "Разгон и замедление", + "outIn": "Замедление и разгон" } diff --git a/Editor/Sources/AdaEditor/EditorAnimationGraphModel.swift b/Editor/Sources/AdaEditor/EditorAnimationGraphModel.swift index f1841234b..d85bfddff 100644 --- a/Editor/Sources/AdaEditor/EditorAnimationGraphModel.swift +++ b/Editor/Sources/AdaEditor/EditorAnimationGraphModel.swift @@ -20,15 +20,48 @@ enum EditorAnimationGraphPayload { @Observable @MainActor final class EditorAnimationGraphModel { - var graph = AnimationGraph(root: "Output", nodes: []) { didSet { validate() } } + var graph = AnimationGraph(root: "Output", nodes: []) { + didSet { + if oldValue != graph { revision = UUID().uuidString } + if oldValue != graph, !restoring, transaction == nil { + undoStack.append(.init(graph: oldValue, positions: positions)) + if undoStack.count > 100 { undoStack.removeFirst() } + redoStack.removeAll() + } + if oldValue != graph { selectedWire = nil } + validate() + } + } var selectedNodeID = "Output" + private(set) var revision = UUID().uuidString var rig: SkeletalRig? { didSet { validate() } } var clips: [SkeletalAnimationClip] = [] { didSet { validate() } } + var clipLabels: [String: String] = [:] private(set) var validationMessage: String? = "This model has no animation clips." + let canvas = EditorNodeCanvasState() + var canvasViewportSize = Size.zero + var canvasSelection: Set = [] + var selectedWire: EditorNodeCanvasWire? + var positions: [String: Vector2] = [:] + var canvasError: String? + var layoutRevision: UInt64 = 0 + var undoStack: [CanvasSnapshot] = [] + var redoStack: [CanvasSnapshot] = [] + var transaction: CanvasSnapshot? + var restoring = false + @ObservationIgnored var onInspect: (() -> Void)? - func configure(asset: ModelAsset3D, graph: AnimationGraph? = nil) { + struct CanvasSnapshot { + var graph: AnimationGraph + var positions: [String: Vector2] + } + + func configure(asset: ModelAsset3D, graph: AnimationGraph? = nil, authoredClips: [SkeletalAnimationClip] = [], clipLabels: [String: String] = [:]) { + restoring = true + defer { restoring = false } rig = asset.animationRig - clips = asset.animationClips + clips = asset.animationClips + authoredClips + self.clipLabels = clipLabels self.graph = graph ?? Self.defaultGraph(clip: clips.first?.name) selectedNodeID = self.graph.root } @@ -52,6 +85,7 @@ final class EditorAnimationGraphModel { return } do { + try checkGraphBudget(graph) try graph.validate(rig: rig, clips: clips) validationMessage = nil } catch { @@ -62,6 +96,55 @@ final class EditorAnimationGraphModel { var validGraph: AnimationGraph? { validationMessage == nil ? graph : nil } var selectedNode: AnimationGraph.Node? { graph.nodes.first { $0.id == selectedNodeID } } + func checkRevision(_ expected: String) throws { + guard expected == revision, transaction == nil else { + throw AnimationAuthoringError(.conflict, itemID: "graph", message: L10n.tr("Graph changed or a canvas gesture is active; inspect and retry.")) + } + } + + func applyGraph(_ candidate: AnimationGraph, expectedRevision: String) throws { + try checkRevision(expectedRevision) + try checkGraphBudget(candidate) + guard candidate.nodes.count <= 4096, candidate.nodes.reduce(0, { $0 + $1.inputs.count + $1.events.count }) <= 16_384, + try JSONEncoder().encode(candidate).count <= 65_536 else { + throw AnimationAuthoringError(.limitExceeded, itemID: "graph", message: L10n.tr("Graph requests must fit in 64 KiB.")) + } + graph = candidate + if !graph.nodes.contains(where: { $0.id == selectedNodeID }) { selectedNodeID = graph.root } + canvasSelection = canvasSelection.filter { id in graph.nodes.contains { $0.id == id } } + } + + /// Bound editor compilation before the runtime expands masks and allocates per-card pose buffers. + private func checkGraphBudget(_ candidate: AnimationGraph) throws { + let ids = candidate.nodes.map(\.id) + guard Set(ids).count == ids.count, ids.allSatisfy({ !$0.isEmpty && $0.utf8.count <= 256 }) else { + throw AnimationAuthoringError(.invalidTrack, itemID: "graph", message: L10n.tr("Graph node IDs must be unique, nonempty and at most 256 UTF-8 bytes.")) + } + guard let rig else { return } + var depths = Array(repeating: 1, count: rig.nodes.count) + for index in rig.evaluationOrder { + if let parent = rig.nodes[index].parentIndex { depths[index] = depths[parent] + 1 } + } + let ancestry = depths.reduce(0, +) + var work = 0, inputs = 0 + for node in candidate.nodes { + inputs += node.inputs.count + for input in node.inputs { + for entry in input.mask?.joints ?? [] { + work += entry.includesDescendants ? ancestry : rig.nodes.count + guard work <= 4_000_000 else { throw graphBudgetError() } + } + } + } + let poseCells = rig.nodes.count * (candidate.nodes.count + inputs) + let morphCells = rig.nodes.reduce(0, { $0 + ($1.morphRestWeights?.count ?? 0) }) * candidate.nodes.count + guard poseCells <= 1_000_000, morphCells <= 1_000_000 else { throw graphBudgetError() } + } + + private func graphBudgetError() -> AnimationAuthoringError { + .init(.limitExceeded, itemID: "graph", message: L10n.tr("Graph preview exceeds the pose or mask compilation budget; reduce cards or mask entries.")) + } + func editNode(_ id: String, _ edit: (inout AnimationGraph.Node) -> Void) { guard let index = graph.nodes.firstIndex(where: { $0.id == id }) else { return @@ -77,6 +160,8 @@ final class EditorAnimationGraphModel { let input = graph.nodes.first { $0.kind == .clip }.map { [AnimationGraph.Input(node: $0.id)] } ?? [] graph.nodes.append(.init(id: id, kind: kind, clip: clips.first?.name ?? "", inputs: kind == .clip ? [] : input)) selectedNodeID = id + canvasSelection = [id] + onInspect?() } func removeSelectedNode() { @@ -84,8 +169,10 @@ final class EditorAnimationGraphModel { return } let id = selectedNodeID - graph.nodes.removeAll { $0.id == id } - for index in graph.nodes.indices { graph.nodes[index].inputs.removeAll { $0.node == id } } + var candidate = graph + candidate.nodes.removeAll { $0.id == id } + for index in candidate.nodes.indices { candidate.nodes[index].inputs.removeAll { $0.node == id } } + graph = candidate selectedNodeID = graph.root } @@ -106,6 +193,11 @@ struct EditorSkeletalAnimationTarget: Equatable { extension EditorViewModel { var skeletalAnimationTarget: EditorSkeletalAnimationTarget? { + if let asset = workbench.animationAssetDocument, asset.kind == .model3D, + ["glb", "gltf"].contains(asset.fileExtension.lowercased()), let reference = asset.assetReference { + return .init(documentID: asset.id, entityID: "", source: Model3DSource(source: reference), + sceneURL: asset.absolutePath.map { URL(fileURLWithPath: $0) }) + } guard let document = workbench.activeSceneDocument, let entity = document.sceneModel?.selectedEntity(), let payload = entity.components[EditorBuiltInComponentType.model3DSource], @@ -118,10 +210,21 @@ extension EditorViewModel { extension EditorWorkbenchViewModel { /// One scene-history transaction, shared by Save/reopen, duplicate and runtime Play. @discardableResult - func setAnimationGraph(_ graph: AnimationGraph?, target: EditorSkeletalAnimationTarget, autoplay: Bool? = nil) throws -> Bool { + func setAnimationGraph(_ graph: AnimationGraph?, target: EditorSkeletalAnimationTarget, autoplay: Bool? = nil, asset: ModelAsset3D? = nil) throws -> Bool { guard let document = sceneDocument(id: target.documentID), !document.isReadOnly, document.sceneModel?.entities.contains(where: { $0.id == target.entityID }) == true else { return false } + if let asset, let service = animationService { + guard let payload = document.sceneModel?.entities.first(where: { $0.id == target.entityID })?.components[EditorBuiltInComponentType.model3DSource], + let current = try EditorComponentRegistry.decode(typeName: EditorBuiltInComponentType.model3DSource, payload: payload) as? Model3DSource, + current == target.source else { + throw AnimationAuthoringError(.conflict, itemID: target.entityID, message: "Model binding changed; refresh the graph before applying it.") + } + var candidate = current + candidate.animationGraph = graph + let libraries = try service.savedDocuments(references: current.animationLibraries, sceneURL: target.sceneURL).map(\.library) + _ = try candidate.resolvedAnimationClips(importedClips: asset.animationClips, rig: asset.animationRig, libraries: libraries) + } let encoded = try graph.map(EditorAnimationGraphPayload.encode) updateSceneModelDocument(id: target.documentID, status: graph == nil ? "Animation graph removed" : "Animation graph applied") { scene in guard let index = scene.entities.firstIndex(where: { $0.id == target.entityID }), diff --git a/Editor/Sources/AdaEditor/EditorModel3DComponentDescriptor.swift b/Editor/Sources/AdaEditor/EditorModel3DComponentDescriptor.swift index 7c395a384..50036029e 100644 --- a/Editor/Sources/AdaEditor/EditorModel3DComponentDescriptor.swift +++ b/Editor/Sources/AdaEditor/EditorModel3DComponentDescriptor.swift @@ -30,8 +30,13 @@ extension EditorComponentRegistry { return try JSONDecoder().decode(Model3DSourceLOD.self, from: Data(value.jsonString.utf8)) }, voxel: voxelSettings(in: payload), - morphWeights: EditorMorphWeightsPayload.decode(payload["morphWeights"]) + morphWeights: EditorMorphWeightsPayload.decode(payload["morphWeights"]), + animationLibraries: try payload["animationLibraries"].map { try JSONDecoder().decode([String].self, from: JSONEncoder().encode($0)) } ?? [] ) + if let path = payload["animationModifiers"]?.stringValue, !path.isEmpty { source.animationModifiers = path } + if let bindings = payload["modifierBindings"], bindings != .null { + source.modifierBindings = try JSONDecoder().decode([String: AnimationIKBinding3D].self, from: JSONEncoder().encode(bindings)) + } if let overrides = payload["graphMaterials"], overrides != .null { source.graphMaterials = try JSONDecoder().decode( [String: ShaderGraphMaterialSource].self, from: JSONEncoder().encode(overrides) diff --git a/Editor/Sources/AdaEditor/EditorSceneAssetCache.swift b/Editor/Sources/AdaEditor/EditorSceneAssetCache.swift index 36028e47e..d10aa9393 100644 --- a/Editor/Sources/AdaEditor/EditorSceneAssetCache.swift +++ b/Editor/Sources/AdaEditor/EditorSceneAssetCache.swift @@ -7,21 +7,29 @@ import Foundation struct EditorSceneAssetCache: Resource { private(set) var models: [String: AssetHandle] = [:] private(set) var skeletons: [String: AssetHandle] = [:] + private(set) var libraries: [String: AssetHandle] = [:] + private(set) var modifiers: [String: AssetHandle] = [:] private var failures: [String: String] = [:] private var revisions: [String: FileRevision] = [:] private var usedModels: Set = [] private var usedSkeletons: Set = [] + private var usedLibraries: Set = [] + private var usedModifiers: Set = [] mutating func beginLoad() { usedModels.removeAll(keepingCapacity: true) usedSkeletons.removeAll(keepingCapacity: true) + usedLibraries.removeAll(keepingCapacity: true) + usedModifiers.removeAll(keepingCapacity: true) } mutating func finishLoad() { models = models.filter { usedModels.contains($0.key) } skeletons = skeletons.filter { usedSkeletons.contains($0.key) } + libraries = libraries.filter { usedLibraries.contains($0.key) } + modifiers = modifiers.filter { usedModifiers.contains($0.key) } failures.removeAll() - revisions = revisions.filter { usedModels.contains($0.key) || usedSkeletons.contains($0.key) } + revisions = revisions.filter { usedModels.contains($0.key) || usedSkeletons.contains($0.key) || usedLibraries.contains($0.key) || usedModifiers.contains($0.key) } } mutating func model(at path: String) throws -> ModelAsset3D { @@ -60,6 +68,24 @@ struct EditorSceneAssetCache: Resource { #endif } + mutating func library(at path: String) throws -> AnimationLibrary { + usedLibraries.insert(path) + if let failure = failures[path] { throw AssetError.message(failure) } + let revision = FileRevision(path: path) + if revisions[path] == revision, let library = libraries[path]?.asset?.library { return library } + #if WASM + throw AssetError.message("Animation library has not loaded.") + #else + let handle = try libraries[path] == nil + ? AssetsManager.loadSync(AnimationLibraryResource.self, at: path) + : AssetHandle(AssetsManager.loadFreshSync(AnimationLibraryResource.self, at: path)) + guard let library = handle.asset?.library else { throw AssetError.message("Animation library has not loaded: \(path)") } + libraries[path] = handle + revisions[path] = revision + return library + #endif + } + /// Decode/import off the UI actor, sequentially to bound temporary import memory. /// Invalid assets remain per-entity warnings instead of preventing the scene from opening. @concurrent @@ -73,7 +99,7 @@ struct EditorSceneAssetCache: Resource { var paths = Paths() try collect(model: model, sourceURL: sourceURL, resourceRootURL: resourceRootURL, ancestry: [], paths: &paths) var result = Self() - let total = paths.models.count + paths.skeletons.count + let total = paths.models.count + paths.skeletons.count + paths.libraries.count + paths.modifiers.count var completed = 0 await onProgress(EditorSceneLoadingProgress(phase: .resources, total: total)) for path in paths.models.sorted() { @@ -116,6 +142,38 @@ struct EditorSceneAssetCache: Resource { completed += 1 await onProgress(EditorSceneLoadingProgress(phase: .resources, completed: completed, total: total, resourceName: name)) } + for path in paths.libraries.sorted() { + try Task.checkCancellation() + do { + let revision = FileRevision(path: path) + if previous.revisions[path] == revision, let handle = previous.libraries[path], handle.asset != nil { + result.libraries[path] = handle + } else if previous.libraries[path] != nil { + result.libraries[path] = AssetHandle(try await AssetsManager.loadFresh(AnimationLibraryResource.self, at: path)) + } else { + result.libraries[path] = try await AssetsManager.load(AnimationLibraryResource.self, at: path) + } + result.revisions[path] = revision + } catch { result.failures[path] = error.localizedDescription } + completed += 1 + await onProgress(EditorSceneLoadingProgress(phase: .resources, completed: completed, total: total, + resourceName: URL(fileURLWithPath: path).lastPathComponent)) + } + for path in paths.modifiers.sorted() { + try Task.checkCancellation() + do { + let revision = FileRevision(path: path) + if previous.revisions[path] == revision, let handle = previous.modifiers[path], handle.asset != nil { + result.modifiers[path] = handle + } else if previous.modifiers[path] != nil { + result.modifiers[path] = AssetHandle(try await AssetsManager.loadFresh(AnimationModifierResource.self, at: path)) + } else { result.modifiers[path] = try await AssetsManager.load(AnimationModifierResource.self, at: path) } + result.revisions[path] = revision + } catch { result.failures[path] = error.localizedDescription } + completed += 1 + await onProgress(EditorSceneLoadingProgress(phase: .resources, completed: completed, total: total, + resourceName: URL(fileURLWithPath: path).lastPathComponent)) + } try Task.checkCancellation() return result } @@ -123,6 +181,8 @@ struct EditorSceneAssetCache: Resource { private struct Paths { var models: Set = [] var skeletons: Set = [] + var libraries: Set = [] + var modifiers: Set = [] } private struct FileRevision: Equatable, Sendable { @@ -144,6 +204,24 @@ struct EditorSceneAssetCache: Resource { var ancestry = ancestry if let sourceURL { ancestry.insert(sourceURL.resolvingSymlinksInPath().standardizedFileURL) } for entity in model.entities { + if let path = entity.components[EditorBuiltInComponentType.model3DSource]?["animationModifiers"]?.stringValue, !path.isEmpty { + guard let url = EditorSceneFileLoader.resolveTileMapReference(path, relativeTo: sourceURL, resourceRootURL: resourceRootURL, + fileExtension: "animmod") else { + throw AssetError.message("Animation modifiers must be an .animmod resource inside project assets.") + } + paths.modifiers.insert(url.path) + } + if let payload = entity.components[EditorBuiltInComponentType.model3DSource], let references = payload["animationLibraries"], references != .null { + guard case let .array(values) = references else { throw AssetError.message("Animation libraries must be a list of resources.") } + for value in values { + guard case let .string(path) = value, + let url = EditorSceneFileLoader.resolveTileMapReference(path, relativeTo: sourceURL, resourceRootURL: resourceRootURL, + fileExtension: "animlib") else { + throw AssetError.message("Animation library must be an .animlib resource inside project assets.") + } + paths.libraries.insert(url.path) + } + } if let payload = entity.components[EditorBuiltInComponentType.model3DSource], let source = payload["source"]?.stringValue, !source.isEmpty, let url = try? EditorModelResource.resolve(source, sourceURL: sourceURL, resourceRootURL: resourceRootURL) { @@ -170,3 +248,23 @@ struct EditorSceneAssetCache: Resource { } } } + +extension EditorSceneAssetCache { + mutating func modifierStack(at path: String) throws -> AnimationModifierStack { + usedModifiers.insert(path) + if let failure = failures[path] { throw AssetError.message(failure) } + let revision = FileRevision(path: path) + if revisions[path] == revision, let stack = modifiers[path]?.asset?.stack { return stack } + #if WASM + throw AssetError.message("Animation modifiers have not loaded.") + #else + let handle = try modifiers[path] == nil + ? AssetsManager.loadSync(AnimationModifierResource.self, at: path) + : AssetHandle(AssetsManager.loadFreshSync(AnimationModifierResource.self, at: path)) + guard let stack = handle.asset?.stack else { throw AssetError.message("Animation modifiers have not loaded: \(path)") } + modifiers[path] = handle + revisions[path] = revision + return stack + #endif + } +} diff --git a/Editor/Sources/AdaEditor/EditorSceneFileLoader.swift b/Editor/Sources/AdaEditor/EditorSceneFileLoader.swift index 48e225e58..3d51f9798 100644 --- a/Editor/Sources/AdaEditor/EditorSceneFileLoader.swift +++ b/Editor/Sources/AdaEditor/EditorSceneFileLoader.swift @@ -136,6 +136,22 @@ enum EditorSceneFileLoader { } if typeName == EditorBuiltInComponentType.model3DSource, let reference = payload["source"]?.stringValue, !reference.isEmpty { resolvedPayload["source"] = .string(try EditorModelResource.resolve(reference, sourceURL: sourceURL, resourceRootURL: resourceRootURL).path) + if let path = payload["animationModifiers"]?.stringValue, !path.isEmpty { + guard let url = resolveTileMapReference(path, relativeTo: sourceURL, resourceRootURL: resourceRootURL, fileExtension: "animmod") else { + throw AssetError.message("Animation modifiers must be an .animmod resource inside project assets.") + } + resolvedPayload["animationModifiers"] = .string(url.path) + } + if let libraries = payload["animationLibraries"], libraries != .null { + guard case let .array(values) = libraries else { throw AssetError.message("Animation libraries must be a list of .animlib references.") } + resolvedPayload["animationLibraries"] = .array(try values.map { value in + guard case let .string(path) = value, + let url = resolveTileMapReference(path, relativeTo: sourceURL, resourceRootURL: resourceRootURL, fileExtension: "animlib") else { + throw AssetError.message("Animation library must be an .animlib resource inside project assets.") + } + return .string(url.path) + }) + } if case var .object(lod) = payload["lod"], case let .array(sources) = lod["sources"] { lod["sources"] = .array(try sources.map { .string(try EditorModelResource.resolve($0.stringValue, sourceURL: sourceURL, resourceRootURL: resourceRootURL).path) @@ -259,6 +275,7 @@ enum EditorSceneFileLoader { let parentEntity: Entity? let idPrefix: String let ancestry: Set + let identityNamespace = UUID().uuidString var assets: EditorSceneAssetCache var warnings: [String] = [] var entityCount: Int @@ -294,6 +311,7 @@ enum EditorSceneFileLoader { let entity = world.spawn(sceneEntity.name) entity.isActive = sceneEntity.enabled let runtimeEditorID = "\(idPrefix)\(sceneEntity.id)" + entity.components[AnimationSceneIdentity3D.self] = .init(namespace: identityNamespace, id: sceneEntity.id) entitiesByEditorID[sceneEntity.id] = entity runtimeEntityIDsByEditorID[runtimeEditorID] = entity.id editorIDsByRuntimeEntityID[entity.id] = runtimeEditorID @@ -366,7 +384,9 @@ enum EditorSceneFileLoader { let alternatives = try (reference.lod?.sources ?? []).map { source in try assets.model(at: source) } - let root = try reference.instantiate(asset, alternatives: alternatives, in: world, under: entity) + let libraries = try reference.animationLibraries.map { try assets.library(at: $0) } + let modifiers = try reference.animationModifiers.map { try assets.modifierStack(at: $0) } + let root = try reference.instantiate(asset, alternatives: alternatives, libraries: libraries, modifiers: modifiers, in: world, under: entity) func registerNodes(_ node: Entity) { entityCount += 1 editorIDsByRuntimeEntityID[node.id] = runtimeEditorID @@ -527,7 +547,7 @@ enum EditorSceneFileLoader { return result } - private static func resolveTileMapReference( + static func resolveTileMapReference( _ reference: String, relativeTo sourceURL: URL?, resourceRootURL: URL?, @@ -544,7 +564,7 @@ enum EditorSceneFileLoader { } let resolved = url.resolvingSymlinksInPath().standardizedFileURL guard resolved.pathExtension == fileExtension else { return nil } - if reference.hasPrefix("@res://") || fileExtension == "particles", let root { + if reference.hasPrefix("@res://") || fileExtension == "particles" || fileExtension == "animlib" || fileExtension == "animmod", let root { let rootPath = root.resolvingSymlinksInPath().standardizedFileURL.path guard resolved.path.hasPrefix(rootPath + "/") else { return nil } } diff --git a/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolHost.swift b/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolHost.swift index 21f7707ca..e06bafe6d 100644 --- a/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolHost.swift +++ b/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolHost.swift @@ -6,6 +6,7 @@ import Observation @MainActor @Observable final class EditorStudioToolHost { + typealias AnimationExecutor = @MainActor (AnimationAuthoringRequest, EditorWorkbenchViewModel) async throws -> [String: Any] private(set) var definitions: [EditorStudioToolDefinition] = [] private(set) var panels: [EditorStudioToolPanel] = [] var selectedPanelID: String? @@ -20,8 +21,17 @@ final class EditorStudioToolHost { @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private var reloadTask: Task? @ObservationIgnored private var actionTask: Task? + @ObservationIgnored private var animationTasks: [String: Task] = [:] + @ObservationIgnored private var animationQueues: [String: [AdaScriptAnimationToolJob]] = [:] + @ObservationIgnored private let animationExecutor: AnimationExecutor - init(defaults: UserDefaults = .standard) { self.defaults = defaults } + init(defaults: UserDefaults = .standard, animationExecutor: @escaping AnimationExecutor = { request, workbench in + guard let service = workbench.animationService else { throw EditorStudioToolDiscovery.Failure("Open a project before running animation jobs.") } + return try await service.execute(request, workbench: workbench) + }) { + self.defaults = defaults + self.animationExecutor = animationExecutor + } var selectedPanel: EditorStudioToolPanel? { panels.first { $0.id == selectedPanelID } ?? panels.first } private var preferenceKey: String { "studio.tools." + (projectURL?.path ?? "") } @@ -84,6 +94,7 @@ final class EditorStudioToolHost { } private func install(_ definition: EditorStudioToolDefinition, projectURL: URL, overrides: [String: String]) throws { + if let reason = unavailabilityReason(definition) { throw EditorStudioToolDiscovery.Failure(reason) } let id = definition.schema.id let keepsRuntime = signatures[id] == definition.signature && runtimes[id]?.isFailed == false let uiSignature = try resourceSignature(definition, projectURL: projectURL, overrides: overrides) + "|" + String(describing: editor?.workbench.uiCatalog.generation) @@ -113,7 +124,11 @@ final class EditorStudioToolHost { for panel in mounted { panel.bindActions { [weak self] panel, action in self?.enqueue(panel: panel, action: action) } } for panel in panels where panel.toolID == id { panel.isRetired = true } panels.removeAll { $0.toolID == id } - if !keepsRuntime { runtimes[id]?.deactivate() } + if !keepsRuntime { + animationTasks.removeValue(forKey: id)?.cancel() + animationQueues.removeValue(forKey: id) + runtimes[id]?.deactivate() + } runtimes[id] = runtime signatures[id] = definition.signature uiSignatures[id] = uiSignature @@ -126,6 +141,8 @@ final class EditorStudioToolHost { } private func retire(_ id: String) { + animationTasks.removeValue(forKey: id)?.cancel() + animationQueues.removeValue(forKey: id) for panel in panels where panel.toolID == id { panel.isRetired = true } runtimes.removeValue(forKey: id)?.deactivate() panels.removeAll { $0.toolID == id } @@ -233,6 +250,7 @@ final class EditorStudioToolHost { updated.statusMessage = "Applied \(panel.title)" editor.workbench.replaceSceneDocument(updated) } + enqueueAnimationJobs(toolID: panel.toolID, runtime: panel.runtime) for item in panels where item.toolID == panel.toolID { for (key, value) in panel.runtime.values { item.session.context.set(key, to: EditorStudioToolPanel.uiValue(value)) } } @@ -242,6 +260,44 @@ final class EditorStudioToolHost { editor?.appendOutput("Studio tool \(panel.toolID): \(error.localizedDescription)") } } + + private func enqueueAnimationJobs(toolID: String, runtime: AdaScriptToolRuntime) { + let jobs = runtime.takeAnimationJobs() + guard !jobs.isEmpty else { return } + animationQueues[toolID, default: []].append(contentsOf: jobs) + guard animationTasks[toolID] == nil else { return } + animationTasks[toolID] = Task { [weak self, weak runtime] in + guard let self, let runtime else { return } + defer { + if self.runtimes[toolID] === runtime { + self.animationTasks.removeValue(forKey: toolID) + self.animationQueues.removeValue(forKey: toolID) + } + } + while let job = self.animationQueues[toolID]?.first { + self.animationQueues[toolID]?.removeFirst() + guard !Task.isCancelled, !runtime.isFailed, + self.runtimes[toolID] === runtime, let editor = self.editor, + editor.workbench.animationService != nil else { return } + let envelope: [String: Any] + do { + let payload = try await self.animationExecutor(job.request, editor.workbench) + try Task.checkCancellation() + guard self.runtimes[toolID] === runtime else { return } + envelope = ["status": "completed", "result": payload] + } catch is CancellationError { return } + catch { envelope = ["status": "failed", "error": String(error.localizedDescription.prefix(4_096))] } + guard !Task.isCancelled, self.runtimes[toolID] === runtime else { return } + do { + let data = try EditorAnimationAuthoringService.responseData(envelope) + try runtime.completeAnimationJob(job.id, responseJSON: String(decoding: data, as: UTF8.self)) + } catch { + let data = try? JSONSerialization.data(withJSONObject: ["status": "failed", "error": error.localizedDescription]) + if let data { try? runtime.completeAnimationJob(job.id, responseJSON: String(decoding: data, as: UTF8.self)) } + } + } + } + } } // Consent and its persisted identity are separate from runtime generation ownership. @@ -311,10 +367,16 @@ extension EditorStudioToolHost { } func unavailabilityReason(_ definition: EditorStudioToolDefinition) -> String? { - guard definition.schema.apiVersion == 1, definition.schema.platforms.contains(.macOS) else { + #if os(Windows) + let platform = AdaScriptToolPlatform.windows + #else + let platform = AdaScriptToolPlatform.macOS + #endif + guard [1, 2].contains(definition.schema.apiVersion), definition.schema.platforms.contains(platform) else { return "This tool requires an unsupported Studio API or platform." } - let unsupported = definition.schema.permissions.filter { ![.documentRead, .documentWrite].contains($0) } + let supported = AdaScriptToolRuntime.supportedPermissions(apiVersion: definition.schema.apiVersion) + let unsupported = definition.schema.permissions.filter { !supported.contains($0) } return unsupported.isEmpty ? nil : "This version of Studio does not support the requested \(unsupported.map(\.rawValue).joined(separator: ", ")) access." } diff --git a/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolPermissionDialog.swift b/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolPermissionDialog.swift index ca620fe91..5944544c3 100644 --- a/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolPermissionDialog.swift +++ b/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolPermissionDialog.swift @@ -15,6 +15,9 @@ struct EditorStudioToolPermissionRequest: Identifiable, Hashable { extension AdaScriptToolPermission { var studioAccessTitle: String { switch self { + case .animationRead: "Read animation resources" + case .animationWrite: "Edit animation resources" + case .animationPreview: "Control animation previews" case .documentRead: "Read open scenes" case .documentWrite: "Change open scenes" case .workspaceRead: "Read project files" @@ -28,6 +31,9 @@ extension AdaScriptToolPermission { var studioAccessDetail: String { switch self { + case .animationRead: "Inspect project animation drafts, bindings, keys and sampled poses." + case .animationWrite: "Create, edit and explicitly save animation libraries through their Undo history." + case .animationPreview: "Open animation library previews and seek their viewport." case .documentRead: "Read the active scene's path, revision and entity count." case .documentWrite: "Create entities in the active scene through its Undo history." default: "This capability is not available to Studio tools in this version." @@ -56,8 +62,8 @@ struct EditorStudioToolPermissionDialog: View { } ForEach(request.definition.schema.permissions, id: \.rawValue) { permission in VStack(alignment: .leading, spacing: 4) { - Text(permission.studioAccessTitle).font(.system(size: 13, weight: .semibold)) - Text(permission.studioAccessDetail).font(.system(size: 12)).foregroundColor(theme.editorColors.muted) + Text(L10n.tr(permission.studioAccessTitle)).font(.system(size: 13, weight: .semibold)) + Text(L10n.tr(permission.studioAccessDetail)).font(.system(size: 12)).foregroundColor(theme.editorColors.muted) } } VStack(alignment: .leading, spacing: 4) { diff --git a/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolsSettings.swift b/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolsSettings.swift index f85407164..fb13019c2 100644 --- a/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolsSettings.swift +++ b/Editor/Sources/AdaEditor/StudioTools/EditorStudioToolsSettings.swift @@ -35,7 +35,7 @@ struct EditorStudioToolsSettings: View { Text(host.isAuthorized(definition) ? L10n.text("Access allowed") : L10n.text("Access not allowed")) .font(.system(size: 12)) ForEach(definition.schema.permissions, id: \.rawValue) { permission in - Text(permission.studioAccessTitle).font(.system(size: 12)).foregroundColor(theme.editorColors.muted) + Text(L10n.tr(permission.studioAccessTitle)).font(.system(size: 12)).foregroundColor(theme.editorColors.muted) } if let reason = host.unavailabilityReason(definition) { Text(reason).font(.system(size: 12)).foregroundColor(theme.editorColors.muted) diff --git a/Editor/Sources/AdaEditor/StudioTools/EditorViewModel+StudioTools.swift b/Editor/Sources/AdaEditor/StudioTools/EditorViewModel+StudioTools.swift index 9291899a5..676bb795c 100644 --- a/Editor/Sources/AdaEditor/StudioTools/EditorViewModel+StudioTools.swift +++ b/Editor/Sources/AdaEditor/StudioTools/EditorViewModel+StudioTools.swift @@ -2,7 +2,7 @@ import Foundation extension EditorViewModel { func startStudioTools() { - #if os(macOS) + #if os(macOS) || os(Windows) guard let projectURL else { return } diff --git a/Editor/Sources/AdaEditor/Tooling/EditorRuntimeScenePreparation.swift b/Editor/Sources/AdaEditor/Tooling/EditorRuntimeScenePreparation.swift index 501e82fc8..55522cd5b 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorRuntimeScenePreparation.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorRuntimeScenePreparation.swift @@ -8,6 +8,8 @@ struct EditorRuntimeScenePreparation: Sendable { let model: EditorSceneModel private let models: [AssetHandle] private let skeletons: [AssetHandle] + private let libraries: [AssetHandle] + private let modifiers: [AssetHandle] @concurrent static func prepare( @@ -20,7 +22,9 @@ struct EditorRuntimeScenePreparation: Sendable { let model = try collect(at: url, assetsDirectory: assetsDirectory, ancestry: [], paths: &paths) var models: [AssetHandle] = [] var skeletons: [AssetHandle] = [] - let total = max(1, paths.models.count + paths.skeletons.count) + var libraries: [AssetHandle] = [] + var modifiers: [AssetHandle] = [] + let total = max(1, paths.models.count + paths.skeletons.count + paths.libraries.count + paths.modifiers.count) var completed = 0 reportProgress(.init(fraction: 0.05, stage: "assets")) // Keep imports bounded rather than decoding every GLB/texture at once. @@ -41,14 +45,32 @@ struct EditorRuntimeScenePreparation: Sendable { completed += 1 reportProgress(.init(fraction: 0.05 + 0.9 * Double(completed) / Double(total), stage: "assets")) } + for path in paths.libraries.sorted() { + try Task.checkCancellation() + let handle = try await AssetsManager.load(AnimationLibraryResource.self, at: path) + guard handle.asset != nil else { throw AssetError.message("Animation library has not loaded: \(path)") } + libraries.append(handle) + completed += 1 + reportProgress(.init(fraction: 0.05 + 0.9 * Double(completed) / Double(total), stage: "assets")) + } + for path in paths.modifiers.sorted() { + try Task.checkCancellation() + let handle = try await AssetsManager.load(AnimationModifierResource.self, at: path) + guard handle.asset != nil else { throw AssetError.message("Animation modifiers have not loaded: \(path)") } + modifiers.append(handle) + completed += 1 + reportProgress(.init(fraction: 0.05 + 0.9 * Double(completed) / Double(total), stage: "assets")) + } try Task.checkCancellation() reportProgress(.init(fraction: 0.95, stage: "installing")) - return Self(model: model, models: models, skeletons: skeletons) + return Self(model: model, models: models, skeletons: skeletons, libraries: libraries, modifiers: modifiers) } private struct Paths { var models: Set = [] var skeletons: Set = [] + var libraries: Set = [] + var modifiers: Set = [] } private static func collect(at url: URL, assetsDirectory: URL, ancestry: Set, paths: inout Paths) throws -> EditorSceneModel { @@ -66,6 +88,22 @@ struct EditorRuntimeScenePreparation: Sendable { voxel = try JSONDecoder().decode(VoxelImportSettings.self, from: JSONEncoder().encode(value)) } else { voxel = nil } paths.models.insert(Model3DSource(source: resolved, voxel: voxel).assetPath) + if let path = payload["animationModifiers"]?.stringValue, !path.isEmpty { + guard let modifierURL = EditorSceneFileLoader.resolveTileMapReference(path, relativeTo: url, resourceRootURL: assetsDirectory, + fileExtension: "animmod") else { + throw AssetError.message("Animation modifiers must be inside project assets.") + } + paths.modifiers.insert(modifierURL.path) + } + if case let .array(references) = payload["animationLibraries"] { + for reference in references { + guard case let .string(path) = reference, + let url = EditorSceneFileLoader.resolveTileMapReference(path, relativeTo: url, resourceRootURL: assetsDirectory, fileExtension: "animlib") else { + throw AssetError.message("Animation library must be inside project assets.") + } + paths.libraries.insert(url.path) + } + } if case let .object(lod) = payload["lod"], case let .array(sources) = lod["sources"] { for source in sources { paths.models.insert(try EditorModelResource.resolve(source.stringValue, sourceURL: url, resourceRootURL: assetsDirectory).path) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationAssetPanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationAssetPanel.swift new file mode 100644 index 000000000..92094bacc --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationAssetPanel.swift @@ -0,0 +1,130 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// Controls live in the bottom Animator; the center renders this workspace's only preview viewport. +struct EditorAnimationAssetPanel: View { + let workspace: EditorAnimationAssetWorkspace + let service: EditorAnimationAuthoringService? + @Environment(\.theme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + toolbar + if workspace.mode == .graph { + EditorAnimationGraphEditor(model: workspace.graph) + } else if workspace.sourceLibrary != nil || workspace.library != nil { + EditorAnimationTimeline(workspace: workspace) + } else { + Text(L10n.text("Choose an imported clip to copy, or create an animation from the rig's rest pose.")) + .font(.system(size: 12)).padding(8) + Spacer() + } + if !workspace.error.isEmpty { + Text(L10n.text("Animation edit failed: \(workspace.error)")).font(.system(size: 11)).foregroundColor(.red) + } + } + .padding(6) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) + .foregroundColor(theme.editorColors.text) + .textFieldStyle(PlainTextFieldStyle()) + .onAppear { workspace.start() } + .onChange(of: workspace.mode) { _, _ in workspace.applyModePreview() } + .onChange(of: workspace.graph.graph) { _, _ in + if workspace.mode == .graph { workspace.applyModePreview() } + } + .accessibilityIdentifier("AdaEditor.Animator.Asset") + } + + private var clipBrowser: some View { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 6) { + ForEach(workspace.preview.asset?.animationClips ?? [], id: \.name) { clip in + action(clip.name, id: "Imported.\(clip.name)", localized: false, selected: workspace.preview.selectedAnimation == clip.name) { + workspace.sourceClip = clip.name + workspace.preview.animationGraph = nil + workspace.preview.animationRepeats = true + workspace.preview.selectedAnimation = clip.name + } + } + if let document = workspace.library { + Text(document.relativePath).font(.system(size: 11)) + ForEach(document.library.clips, id: \.id) { clip in + action(clip.name, id: "Clip.\(clip.id)", localized: false, + selected: workspace.preview.selectedAnimation == document.library.runtimeName(for: clip.id)) { + document.selectedClipID = clip.id + workspace.mode = .keys + workspace.applyModePreview() + } + } + } + if let timeline = workspace.preview.voxelTimelineDescription { Text(timeline).font(.system(size: 11)) } + } + }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var toolbar: some View { + HStack(spacing: 8) { + Text(L10n.text("ANIMATOR")).font(.system(size: 11, weight: .semibold)) + mode(.clips, title: "Clips") + mode(.keys, title: "Timeline") + mode(.curves, title: "Curves") + mode(.graph, title: "Animation Graph") + action(workspace.preview.showsBones ? "Hide bones" : "Show bones", id: "Bones", selected: workspace.preview.showsBones) { workspace.preview.showsBones.toggle() } + Spacer() + } + } + + private var sourceControls: some View { + HStack(spacing: 6) { + EditorEnumField(cases: workspace.preview.animationNames, + selection: Binding(get: { workspace.sourceClip.isEmpty ? "Rest pose" : workspace.sourceClip }, set: { + workspace.sourceClip = $0 == "Rest pose" ? "" : $0 + if workspace.mode == .clips || workspace.library == nil { workspace.preview.selectedAnimation = $0 } + }), accessibilityID: "AdaEditor.Animator.Asset.ImportedClip", labels: ["Rest pose": L10n.tr("Rest pose")], localizesValues: false).frame(width: 160) + field("Animation library path", text: Binding(get: { workspace.libraryPath }, set: { workspace.libraryPath = $0 })) + .frame(minWidth: 140, maxWidth: 320).frame(height: 28) + field("Clip name", text: Binding(get: { workspace.clipName }, set: { workspace.clipName = $0 })).frame(width: 110, height: 28) + action("Extract Editable Copy", id: "Extract") { workspace.run { try await workspace.extract(fromRest: false) } } + .disabled(workspace.isWorking || workspace.sourceClip.isEmpty || service == nil) + action("New Animation", id: "New") { workspace.run { try await workspace.extract(fromRest: true) } } + .disabled(workspace.isWorking || service == nil) + action("Open Library", id: "Open") { workspace.run { try workspace.openLibrary() } }.disabled(workspace.isWorking || service == nil) + Spacer() + } + } + + private func field(_ title: String, text: Binding) -> some View { + TextField(L10n.tr(title), text: text).textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + } + + private func mode(_ mode: EditorAnimationAssetWorkspace.Mode, title: String) -> some View { + action(title, id: "Mode.\(mode.rawValue)", selected: workspace.mode == mode) { workspace.timeline.requestMode(mode) } + } + + private func action(_ title: String, id: String, localized: Bool = true, selected: Bool = false, _ action: @escaping () -> Void) -> some View { + Button(action: action) { Text(localized ? L10n.tr(title) : title).font(.system(size: 11)) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, selected: selected)) + .accessibilityIdentifier("AdaEditor.Animator.Asset.\(id)") + } +} + +/// Only the center workbench instantiates a viewport. Control views never load or cancel this player. +struct EditorAnimationAssetViewport: View { + let workspace: EditorAnimationAssetWorkspace + @Environment(\.theme) private var theme + + var body: some View { + ZStack { + if workspace.preview.isLoading { Text(L10n.text("Loading animation model…")).foregroundColor(theme.editorColors.muted) } + else if let error = workspace.preview.error { Text(error).foregroundColor(.red).padding(16) } + else if workspace.preview.asset != nil { EditorAnimationModelWorkspace(workspace: workspace) } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { workspace.start() } + .onChange(of: workspace.preview.asset.map(ObjectIdentifier.init)) { _, _ in workspace.configureLoadedAsset() } + .onChange(of: workspace.library?.revision) { _, _ in workspace.refreshLibrary() } + .onChange(of: workspace.library?.selectedClipID) { _, _ in workspace.refreshLibrary() } + .onChange(of: workspace.library?.previewSerial) { _, _ in workspace.applyRequestedPreview() } + .accessibilityIdentifier("AdaEditor.AnimationAsset.Viewport") + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationControlStyle.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationControlStyle.swift new file mode 100644 index 000000000..4edfba3a9 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationControlStyle.swift @@ -0,0 +1,79 @@ +@_spi(AdaEngine) import AdaEngine + +/// Shared native action appearance for the animation workspace and scene Animator. +struct EditorAnimationButtonStyle: ButtonStyle { + let colors: EditorThemeColors + var prominent = false + var selected = false + var horizontalPadding: Float = 8 + + func makeBody(configuration: Configuration) -> some View { + let enabled = configuration.state.isEnabled + let focused = enabled && configuration.state.contains(.focused) + let hovered = enabled && configuration.isHighlighted + let pressed = enabled && configuration.isPressed + let accented = prominent || selected + return configuration.label + .foregroundColor(accented ? colors.blue : colors.text) + .padding(.horizontal, horizontalPadding) + .frame(minHeight: 28) + .background(RoundedRectangleShape(cornerRadius: 5).fill( + accented ? colors.blue.opacity(pressed ? 0.32 : hovered ? 0.24 : 0.16) + : pressed ? colors.border : hovered ? colors.border.opacity(0.65) : colors.surface + )) + .overlay { + RoundedRectangleShape(cornerRadius: 5) + .stroke(focused || accented ? colors.blue : colors.border.opacity(0.92), lineWidth: focused ? 2 : 1) + } + .opacity(enabled ? 1 : 0.38) + } +} + +/// Suppresses the primitive platform background, then supplies the Inspector's themed field. +struct EditorAnimationFieldStyle: TextFieldStyle { + let colors: EditorThemeColors + var height: Float = 28 + + func _body(configuration: TextField) -> some View { + PlainTextFieldStyle()._body(configuration: configuration) + .font(.system(size: 11)) + .foregroundColor(colors.text) + .padding(.horizontal, 7) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: height, maxHeight: height) + .background(RoundedRectangleShape(cornerRadius: 5).fill(colors.surface)) + .overlay { RoundedRectangleShape(cornerRadius: 5).stroke(colors.border.opacity(0.92), lineWidth: 1) } + } +} + +/// Uses the established scene Transform Inspector's inline axis colors and geometry. +struct EditorAnimationAxisField: View { + let axis: String + let text: Binding + let identifier: String + @Environment(\.theme) private var theme + + var body: some View { + HStack(spacing: 0) { + Text(axis).font(.system(size: 10, weight: .bold)).foregroundColor(.white) + .frame(width: 24, height: 32) + .background(RoundedRectangleShape(cornerRadius: 5).fill(axisColor)) + TextField("", text: text).textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 11, weight: .bold)).foregroundColor(theme.editorColors.text) + .padding(.horizontal, 7).frame(minWidth: 0, maxWidth: .infinity) + .accessibilityIdentifier(identifier) + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 32, maxHeight: 32) + .background(RoundedRectangleShape(cornerRadius: 5).fill(theme.editorColors.surface)) + .overlay { RoundedRectangleShape(cornerRadius: 5).stroke(theme.editorColors.border.opacity(0.92), lineWidth: 1) } + } + + private var axisColor: Color { + switch axis { + case "X": Color(red: 0.78, green: 0.24, blue: 0.28) + case "Y": Color(red: 0.24, green: 0.60, blue: 0.31) + case "Z": Color(red: 0.22, green: 0.43, blue: 0.82) + case "W": Color(red: 0.57, green: 0.32, blue: 0.76) + default: theme.editorColors.muted + } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationCurveInspector.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationCurveInspector.swift new file mode 100644 index 000000000..7d69eeb2b --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationCurveInspector.swift @@ -0,0 +1,126 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +/// An explicit key transaction: text drafts never mutate playback until Apply succeeds. +struct EditorAnimationCurveInspector: View { + let model: EditorAnimationTimelineModel + let track: AnimationLibraryTrack + let key: AnimationLibraryKey + @State private var family = "linear" + @State private var direction = "inOut" + @State private var overshoot = "1.70158" + @State private var amplitude = "1" + @State private var period = "0.3" + @State private var component = 0 + @State private var handleDrafts: [String: String] = [:] + @State private var revision = "" + @Environment(\.theme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.tr("Outgoing segment easing")).font(.system(size: 11, weight: .semibold)) + EditorEnumField(cases: AnimationEasing.Family.allCases.map(\.rawValue), selection: $family, + accessibilityID: "AdaEditor.Animation.Key.Easing.Family") + EditorEnumField(cases: AnimationEasing.Direction.allCases.map(\.rawValue), selection: $direction, + accessibilityID: "AdaEditor.Animation.Key.Easing.Direction") + if family == "back" { field("Overshoot", text: $overshoot, id: "Easing.Overshoot") } + if family == "elastic" { + field("Amplitude", text: $amplitude, id: "Easing.Amplitude") + field("Period", text: $period, id: "Easing.Period") + } + if track.interpolation == .bezier, track.channel != .rotation { + Text(L10n.tr("Bezier handles")).font(.system(size: 11, weight: .semibold)) + EditorEnumField(cases: key.value.indices.map(String.init), selection: Binding(get: { String(component) }, set: { component = Int($0) ?? 0 }), + accessibilityID: "AdaEditor.Animation.Key.Bezier.Component", labels: componentLabels, localizesValues: false) + Text(L10n.tr("Handle offsets use seconds and value units.")).font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + if neighbor(incoming: true) != nil { handleFields(incoming: true) } + if neighbor(incoming: false) != nil { handleFields(incoming: false) } + } + Button { model.perform { try apply() } } label: { + Text(L10n.tr("Apply Curve")).font(.system(size: 11)).frame(maxWidth: .infinity, alignment: .leading) + }.buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, prominent: true)) + .accessibilityIdentifier("AdaEditor.Animation.Key.Curve.Apply") + }.disabled(!model.isEditable || track.interpolation == .step) + .onAppear { synchronize() } + .onChange(of: key.id) { _, _ in synchronize() } + .onChange(of: track.id) { _, _ in synchronize() } + .onChange(of: model.workspace?.library?.revision) { _, _ in synchronize() } + } + + private var componentLabels: [String: String] { + key.value.indices.reduce(into: [:]) { values, index in + values[String(index)] = track.channel == .weights ? String(index) : ["X", "Y", "Z", "W"][index] + } + } + + private func neighbor(incoming: Bool) -> AnimationLibraryKey? { + guard let index = track.keys.firstIndex(where: { $0.id == key.id }) else { return nil } + let next = index + (incoming ? -1 : 1) + return track.keys.indices.contains(next) ? track.keys[next] : nil + } + + private func handle(incoming: Bool, component: Int) -> AnimationBezierHandle { + let values = incoming ? key.curve?.incoming : key.curve?.outgoing + if let values, values.indices.contains(component) { return values[component] } + guard let other = neighbor(incoming: incoming), key.value.indices.contains(component), other.value.indices.contains(component) else { + return .init(timeOffset: 0, valueOffset: 0) + } + return .init(timeOffset: (other.time - key.time) / 3, valueOffset: (Double(other.value[component]) - Double(key.value[component])) / 3) + } + + private func handleFields(incoming: Bool) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr(incoming ? "Incoming handle" : "Outgoing handle")).font(.system(size: 10, weight: .semibold)) + field("Time offset", text: handleBinding(incoming: incoming, time: true), id: "Bezier.\(incoming ? "Incoming" : "Outgoing").Time") + field("Value offset", text: handleBinding(incoming: incoming, time: false), id: "Bezier.\(incoming ? "Incoming" : "Outgoing").Value") + } + } + + private func handleBinding(incoming: Bool, time: Bool) -> Binding { + let id = "\(component):\(incoming):\(time)" + return Binding(get: { + let value = handle(incoming: incoming, component: component) + return handleDrafts[id] ?? String(time ? value.timeOffset : value.valueOffset) + }, set: { handleDrafts[id] = $0 }) + } + + private func apply() throws { + guard let clip = model.clip, let family = AnimationEasing.Family(rawValue: family), let direction = AnimationEasing.Direction(rawValue: direction), + let overshoot = Double(overshoot), let amplitude = Double(amplitude), let period = Double(period) else { throw invalidInput() } + var edited = key, curve = key.curve ?? .init() + curve.easing = .init(family: family, direction: direction, overshoot: overshoot, amplitude: amplitude, period: period) + if track.interpolation == .bezier { + for incoming in [true, false] where neighbor(incoming: incoming) != nil { + let handles = try key.value.indices.map { index -> AnimationBezierHandle in + let original = handle(incoming: incoming, component: index) + guard let time = Double(handleDrafts["\(index):\(incoming):true"] ?? String(original.timeOffset)), + let value = Double(handleDrafts["\(index):\(incoming):false"] ?? String(original.valueOffset)), time.isFinite, value.isFinite else { throw invalidInput() } + return .init(timeOffset: time, valueOffset: value) + } + if incoming { curve.incoming = handles } else { curve.outgoing = handles } + } + } + edited.curve = curve + try model.apply([.init(kind: .putKey, clipID: clip.id, trackID: track.id, key: edited, replaceExisting: true)], revision: revision) + } + + private func synchronize() { + let easing = key.curve?.easing ?? .init() + family = easing.family.rawValue; direction = easing.direction.rawValue + overshoot = String(easing.overshoot); amplitude = String(easing.amplitude); period = String(easing.period) + component = min(component, max(0, key.value.count - 1)); handleDrafts = [:] + revision = model.workspace?.library?.revision ?? "" + } + + private func invalidInput() -> AnimationAuthoringError { + .init(.invalidCurve, itemID: key.id, message: L10n.tr("Enter finite numeric values for every component.")) + } + + private func field(_ title: String, text: Binding, id: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.tr(title)).font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + TextField(L10n.tr(title), text: text).textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + .accessibilityIdentifier("AdaEditor.Animation.Key." + id) + } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationCurves.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationCurves.swift new file mode 100644 index 000000000..cb76350a3 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationCurves.swift @@ -0,0 +1,147 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +/// Curves use production pose sampling; advanced tangent-handle modes remain the next milestone. +struct EditorAnimationCurves: View { + let model: EditorAnimationTimelineModel + @State private var curves: [[Vector2]] = [] + @State private var component = 0 + @State private var task: Task? + @State private var error = "" + @Environment(\.theme) private var theme + + var body: some View { + VStack(spacing: 4) { + HStack(spacing: 6) { + Text(model.selectedTrack.map { L10n.tr($0.channel.rawValue) } ?? L10n.tr("Select a track")) + .font(.system(size: 11, weight: .semibold)) + ForEach(0.. 4 { + EditorEnumField(cases: curves.indices.map(String.init), selection: Binding(get: { String(component) }, set: { component = Int($0) ?? 0 }), + accessibilityID: "AdaEditor.AnimationCurves.Component", localizesValues: false).frame(width: 75) + } + Spacer(minLength: 0) + }.padding(4).frame(height: 30) + GeometryReader { geometry in + let points = curves.indices.contains(component) ? curves[component] : [] + let lower = points.map(\.y).min() ?? 0 + let upper = points.map(\.y).max() ?? 1 + let spread = max(0.1, upper - lower) + let minimum = lower - spread * 0.15 + let maximum = upper + spread * 0.15 + ZStack(anchor: .topLeading) { + RectangleShape().fill(theme.editorColors.background) + Canvas { context, size in + for tick in 0...8 { + let x = 42 + Float(tick) * max(1, size.width - 56) / 8 + context.drawRect(Rect(x: x, y: 0, width: 1, height: size.height), color: theme.editorColors.border.opacity(0.3)) + } + for tick in 0...4 { + context.drawRect(Rect(x: 42, y: Float(tick) * size.height / 4, width: max(0, size.width - 42), height: 1), color: theme.editorColors.border.opacity(0.3)) + } + var previous: Point? + for point in points { + let current = project(point, size: size, minimum: minimum, maximum: maximum) + if let previous { context.drawLine(start: [previous.x, -previous.y], end: [current.x, -current.y], lineWidth: 2, color: curveColor) } + previous = current + } + for key in model.selectedTrack?.keys ?? [] where key.value.indices.contains(component) { + let point = project([Float(key.time), key.value[component]], size: size, minimum: minimum, maximum: maximum) + context.drawRect(Rect(x: point.x - 4, y: point.y - 4, width: 8, height: 8), + color: model.selectedKeys.contains(.init(track: model.selectedTrack?.id ?? "", key: key.id)) ? .yellow : curveColor) + } + } + Text(String(format: "%.3g", maximum)).font(.system(size: 10)).foregroundColor(theme.editorColors.muted).padding(3) + EditorAnimationCurveInput { point in + guard let track = model.selectedTrack else { return } + if let key = track.keys.first(where: { key in + guard key.value.indices.contains(component) else { return false } + return (project([Float(key.time), key.value[component]], size: geometry.size, minimum: minimum, maximum: maximum) - point).squaredLength < 81 + }) { + model.selectKey(.init(track: track.id, key: key.id)) + model.seek(key.time) + } else { model.seek(Double(max(0, point.x - 42) / max(1, geometry.size.width - 56)) * model.duration) } + }.frame(width: geometry.size.width, height: geometry.size.height) + TimelineView(.animation(minimumInterval: 1 / 30)) { _ in + RectangleShape().fill(theme.editorColors.blue).frame(width: 1, height: geometry.size.height) + .offset(x: 42 + Float(model.time / max(0.01, model.duration)) * max(1, geometry.size.width - 56)).allowsHitTesting(false) + } + }.frame(width: geometry.size.width, height: geometry.size.height).mask(RectangleShape()) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + if !error.isEmpty { Text(error).font(.system(size: 11)).foregroundColor(.red) } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("AdaEditor.AnimationCurves.Editor") + .onAppear { rebuild() } + .onChange(of: model.workspace?.library?.revision) { _, _ in rebuild() } + .onChange(of: model.clip?.id) { _, _ in rebuild() } + .onChange(of: model.selectedTrack?.id) { _, _ in rebuild() } + .onDisappear { task?.cancel() } + } + + private var curveColor: Color { component == 0 ? Color(1, 0.4, 0.4, 1) : component == 1 ? Color(0.3, 0.9, 0.5, 1) : theme.editorColors.blue } + + private func project(_ point: Vector2, size: Size, minimum: Float, maximum: Float) -> Point { + Point(42 + point.x / Float(max(0.01, model.duration)) * max(1, size.width - 56), + size.height - (point.y - minimum) / max(0.01, maximum - minimum) * size.height) + } + + private func rebuild() { + task?.cancel() + guard let library = model.library, let clip = model.clip, let track = model.selectedTrack else { curves = []; return } + task = Task { @MainActor in + do { + let result = try await Self.sample(library: library, clipID: clip.id, track: track, duration: clip.duration) + try Task.checkCancellation() + curves = result + component = min(component, max(0, result.count - 1)) + error = "" + } catch is CancellationError { return } + catch { self.error = error.localizedDescription } + } + } + + @concurrent private static func sample(library: AnimationLibrary, clipID: String, track: AnimationLibraryTrack, duration: Double) async throws -> [[Vector2]] { + guard let node = library.binding.nodes.firstIndex(where: { $0.id == track.nodeID }) else { return [] } + var player = try library.sample(clipID: clipID, at: 0) + let width = track.channel == .weights ? library.binding.nodes[node].morphRestWeights?.count ?? 0 : track.channel == .rotation ? 4 : 3 + var result = Array(repeating: [Vector2](), count: width) + for index in 0...128 { + if index % 8 == 0 { try Task.checkCancellation() } + let time = duration * Double(index) / 128 + player.seek(to: time) + let pose = player.poses[node] + let values: [Float] + switch track.channel { + case .translation: values = [pose.translation.x, pose.translation.y, pose.translation.z] + case .rotation: values = [pose.rotation.x, pose.rotation.y, pose.rotation.z, pose.rotation.w] + case .scale: values = [pose.scale.x, pose.scale.y, pose.scale.z] + case .weights: values = player.morphWeights[node] + } + for component in 0.. Void + func makeUIView(in _: Context) -> EditorAnimationCurveInputView { + let view = EditorAnimationCurveInputView(frame: .zero) + view.backgroundColor = .clear + return view + } + func updateUIView(_ view: EditorAnimationCurveInputView, in _: Context) { view.select = select } +} + +private final class EditorAnimationCurveInputView: UIView { + var select: ((Point) -> Void)? + override func onMouseEvent(_ event: MouseEvent) { + if event.button == .left, event.phase == .began { select?(event.mousePosition) } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationGraphEditor.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationGraphEditor.swift index 924fe0923..3b3864200 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationGraphEditor.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationGraphEditor.swift @@ -3,16 +3,85 @@ import Foundation /// Node and connection authoring shared by the model preview and scene Animator. struct EditorAnimationGraphEditor: View { + enum Presentation { case canvas, inspector, properties } let model: EditorAnimationGraphModel + var presentation: Presentation = .canvas + @State private var interaction: EditorNodeCanvasInteraction? @Environment(\.theme) private var theme var body: some View { + Group { + if presentation == .canvas { canvasContent } + else if presentation == .inspector { inspectorContent } + else { propertiesContent } + } + .foregroundColor(theme.editorColors.text) + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + } + + private var inspectorContent: some View { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.tr("Animation Graph")).font(.system(size: 12, weight: .semibold)) + EditorEnumField(cases: model.graph.nodes.map(\.id), selection: Binding( + get: { model.selectedNodeID }, set: { model.selectedNodeID = $0; model.canvasSelection = [$0] }), + accessibilityID: "AdaEditor.AnimationGraph.SelectedNode", localizesValues: false) + if let node = model.selectedNode { nodeEditor(node).id(node.id) } + button("Remove node", id: "RemoveNode") { model.removeSelectedNode() }.disabled(model.selectedNodeID == model.graph.root) + Text(L10n.tr("Output")).font(.system(size: 11)) + EditorEnumField(cases: model.graph.nodes.map(\.id), selection: Binding( + get: { model.graph.root }, set: { model.graph.root = $0 }), + accessibilityID: "AdaEditor.AnimationGraph.Output", localizesValues: false) + if let message = model.validationMessage { Text(message).font(.system(size: 11)).foregroundColor(.red) } + }.padding(10).frame(maxWidth: .infinity, alignment: .topLeading) + }.accessibilityIdentifier("AdaEditor.AnimationGraph.Inspector") + } + + private var canvasContent: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + ForEach(AnimationGraph.Node.Kind.allCases, id: \.rawValue) { kind in + button("+ \(kind.rawValue)", id: "Add.\(kind.rawValue)") { model.addNode(kind) } + } + button("Undo", id: "Undo") { model.undoCanvas() }.disabled(model.undoStack.isEmpty || model.transaction != nil) + button("Redo", id: "Redo") { model.redoCanvas() }.disabled(model.redoStack.isEmpty || model.transaction != nil) + button("Fit", id: "Fit") { model.canvas.fit(model.canvasItems, in: model.canvasViewportSize) } + button("Pan", id: "Pan", selected: model.canvas.panMode) { model.canvas.panMode.toggle() } + Spacer(minLength: 0) + }.frame(height: 32).padding(.horizontal, 6) + EditorNodeCanvas(items: model.canvasItems, wires: model.canvasWires, + selection: model.canvasSelection.isEmpty ? [model.selectedNodeID] : model.canvasSelection, + state: model.canvas, revision: model.layoutRevision, interaction: interaction, + onKeyEvent: { event in + guard event.status == .down else { return } + if event.modifiers.contains(.command) || event.modifiers.contains(.control) { + if event.keyCode == .z { + if event.modifiers.contains(.shift) { model.redoCanvas() } else { model.undoCanvas() } + } + if event.keyCode == .y { model.redoCanvas() } + return + } + if event.keyCode == .escape { interaction?.abort() } + if event.keyCode == .delete || event.keyCode == .backspace { model.deleteCanvasSelection() } + }, viewportSizeChanged: { model.canvasViewportSize = $0 }, selectedWire: model.selectedWire) + if let message = model.canvasError ?? model.validationMessage { + Text(message).font(.system(size: 11)).foregroundColor(.red).lineLimit(2).padding(4) + } + } + .onAppear { interaction = model.canvasInteraction() } + .onDisappear { interaction?.abort() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityIdentifier("AdaEditor.AnimationGraph.Editor") + } + + private var propertiesContent: some View { GeometryReader { geometry in content(height: max(80, geometry.size.height - 76)) } .padding(8) .foregroundColor(theme.editorColors.text) - .accessibilityIdentifier("AdaEditor.AnimationGraph.Editor") + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + .accessibilityIdentifier("AdaEditor.AnimationGraph.Inspector") } private func content(height: Float) -> some View { @@ -20,7 +89,7 @@ struct EditorAnimationGraphEditor: View { HStack(spacing: 6) { Text(L10n.text("Output")).font(.system(size: 11)) EditorEnumField( - cases: model.graph.nodes.map(\.id), + cases: model.graph.nodes.map(\.id), selection: Binding(get: { model.graph.root }, set: { model.graph.root = $0 }), accessibilityID: "AdaEditor.AnimationGraph.Output" ) @@ -30,14 +99,14 @@ struct EditorAnimationGraphEditor: View { } } HStack(alignment: .top, spacing: 12) { - ScrollView { + ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 5) { ForEach(model.graph.nodes, id: \.id) { node in Button(action: { model.selectedNodeID = node.id }) { VStack(alignment: .leading, spacing: 3) { Text(node.id + (node.id == model.graph.root ? " → Output" : "")) .font(.system(size: 11, weight: .semibold)) - Text(node.kind == .clip ? node.clip : node.inputs.map(\.node).joined(separator: " + ")) + Text(node.kind == .clip ? model.clipLabels[node.clip] ?? node.clip : node.inputs.map(\.node).joined(separator: " + ")) .font(.system(size: 10)).foregroundColor(theme.editorColors.muted) } .padding(7) @@ -56,7 +125,7 @@ struct EditorAnimationGraphEditor: View { } } .frame(width: 150, height: height) - ScrollView { + ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 8) { if let node = model.selectedNode { nodeEditor(node).id(node.id) @@ -108,11 +177,11 @@ struct EditorAnimationGraphEditor: View { set: { value in model.editNode(node.id) { $0.clip = value } } - ), accessibilityID: "AdaEditor.AnimationGraph.Clip" + ), accessibilityID: "AdaEditor.AnimationGraph.Clip", labels: model.clipLabels, localizesValues: false ) HStack { number("Speed", value: node.speed, id: "Speed") { value in model.editNode(node.id) { $0.speed = value } } - button(node.repeats ? "Loop: On" : "Loop: Off", id: "Loop") { model.editNode(node.id) { $0.repeats.toggle() } } + button(node.repeats ? "Loop: On" : "Loop: Off", id: "Loop", selected: node.repeats) { model.editNode(node.id) { $0.repeats.toggle() } } } eventsEditor(node) } else { @@ -233,9 +302,9 @@ struct EditorAnimationGraphEditor: View { EditorAnimationGraphNumberField(title: title, value: value, identifier: id, set: set) } - private func button(_ title: String, id: String, action: @escaping () -> Void) -> some View { - Button(action: action) { Text(L10n.tr(title)).font(.system(size: 11)).padding(4) } - .buttonStyle(DefaultButtonStyle()) + private func button(_ title: String, id: String, selected: Bool = false, action: @escaping () -> Void) -> some View { + Button(action: action) { Text(L10n.tr(title)).font(.system(size: 11)) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, selected: selected)) .accessibilityIdentifier("AdaEditor.AnimationGraph.\(id)") } } @@ -253,8 +322,7 @@ private struct EditorAnimationGraphNumberField: View { HStack(spacing: 4) { Text(L10n.tr(title)).font(.system(size: 10)).foregroundColor(theme.editorColors.muted) TextField(L10n.tr(title), text: $draft) - .font(.system(size: 11)).frame(width: 54, height: 28) - .background(RoundedRectangleShape(cornerRadius: 4).fill(theme.editorColors.background)) + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)).frame(width: 64, height: 28) .accessibilityIdentifier("AdaEditor.AnimationGraph.\(identifier)") } .onAppear { draft = String(format: "%.6g", value) } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationGraphModel+Canvas.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationGraphModel+Canvas.swift new file mode 100644 index 000000000..fed19f4b3 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationGraphModel+Canvas.swift @@ -0,0 +1,163 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +extension EditorAnimationGraphModel { + var canvasItems: [EditorNodeCanvasItem] { + graph.nodes.enumerated().map { index, node in + let inputs: [EditorNodeCanvasPort] = node.kind == .clip ? [] : node.inputs.indices.map { + .init(id: String($0), title: L10n.tr(node.kind == .additive && $0 == 0 ? "Base" : "Input") + " \($0 + 1)", type: "pose") + } + [.init(id: "new", title: L10n.tr("+ Input"), type: "pose")] + return .init(id: node.id, title: node.id == graph.root ? node.id + " → " + L10n.tr("Output") : node.id, + position: positions[node.id] ?? Vector2(Float(index % 4) * 280, Float(index / 4) * 240), inputs: inputs, + outputs: [.init(id: "pose", title: L10n.tr("Pose"), type: "pose")], + subtitle: node.kind == .clip ? clipLabels[node.clip] ?? node.clip : L10n.tr(node.kind.rawValue), + hasError: validationMessage != nil && node.id == selectedNodeID) + } + } + + var canvasWires: [EditorNodeCanvasWire] { + graph.nodes.flatMap { node in + node.inputs.enumerated().map { index, input in + .init(source: .init(node: input.node, port: "pose", isOutput: true), + target: .init(node: node.id, port: String(index), isOutput: false), type: "pose") + } + } + } + + func canvasInteraction() -> EditorNodeCanvasInteraction { + let pointer = EditorNodeCanvasInteraction() + pointer.select = { [weak self] ids in + guard let self else { return } + canvasSelection = ids + selectedWire = nil + if let first = ids.sorted().first { selectedNodeID = first } + onInspect?() + } + pointer.selectWire = { [weak self] wire in + self?.selectedWire = wire + self?.selectedNodeID = wire.target.node + self?.onInspect?() + } + pointer.begin = { [weak self] in self?.beginCanvasTransaction() } + pointer.commit = { [weak self] in self?.endCanvasTransaction() } + pointer.cancel = { [weak self] in self?.cancelCanvasTransaction() } + pointer.move = { [weak self] values in + guard let self else { return } + for (id, point) in values { positions[id] = point } + layoutRevision &+= 1 + } + pointer.connect = { [weak self, weak pointer] source, target in + self?.connectCanvas(source, to: target, replacing: pointer?.replacedInput) + } + return pointer + } + + func connectCanvas(_ source: EditorNodeCanvasEndpoint, to target: EditorNodeCanvasEndpoint, + replacing: EditorNodeCanvasEndpoint? = nil) { + if replacing == target { return } + guard source.isOutput, !target.isOutput, source.port == "pose", source.node != target.node, + graph.nodes.contains(where: { $0.id == source.node }), + let index = graph.nodes.firstIndex(where: { $0.id == target.node && $0.kind != .clip }) else { return } + var candidate = graph + var targetPort = Int(target.port) + var movedInput = AnimationGraph.Input(node: source.node) + if let replacing, replacing != target, + let oldNode = candidate.nodes.firstIndex(where: { $0.id == replacing.node }), + let oldPort = Int(replacing.port), candidate.nodes[oldNode].inputs.indices.contains(oldPort) { + movedInput = candidate.nodes[oldNode].inputs.remove(at: oldPort) + movedInput.node = source.node + if oldNode == index, let port = targetPort, oldPort < port { targetPort = port - 1 } + } + // Replacing an input on the same card is index-sensitive; resolve against the final draft. + if target.port == "new" { candidate.nodes[index].inputs.append(movedInput) } + else if let port = targetPort, candidate.nodes[index].inputs.indices.contains(port) { + if replacing != nil { candidate.nodes[index].inputs[port] = movedInput } + else { candidate.nodes[index].inputs[port].node = source.node } + } else { return } + var visiting: Set = [] + var visited: Set = [] + let dependencies = candidate.nodes.reduce(into: [String: [String]]()) { $0[$1.id] = $1.inputs.map(\.node) } + func acyclic(_ id: String) -> Bool { + if visiting.contains(id) { return false } + if visited.contains(id) { return true } + visiting.insert(id) + for child in dependencies[id] ?? [] where !acyclic(child) { return false } + visiting.remove(id) + visited.insert(id) + return true + } + guard candidate.nodes.allSatisfy({ acyclic($0.id) }) else { + canvasError = L10n.tr("Animation graph connections cannot form a cycle.") + return + } + graph = candidate + canvasError = nil + } + + func beginCanvasTransaction() { + guard transaction == nil else { return } + transaction = .init(graph: graph, positions: positions) + } + + func endCanvasTransaction() { + guard let before = transaction else { return } + transaction = nil + func effective(_ graph: AnimationGraph, _ positions: [String: Vector2]) -> [String: Vector2] { + graph.nodes.enumerated().reduce(into: [:]) { result, entry in + result[entry.element.id] = positions[entry.element.id] ?? Vector2(Float(entry.offset % 4) * 280, Float(entry.offset / 4) * 240) + } + } + if before.graph == graph, effective(before.graph, before.positions) == effective(graph, positions) { + positions = before.positions + return + } + if before.graph != graph || before.positions != positions { + undoStack.append(before) + redoStack.removeAll() + } + } + + func cancelCanvasTransaction() { + guard let before = transaction else { return } + transaction = nil + restoreCanvas(before) + } + + func undoCanvas() { + guard transaction == nil, let before = undoStack.popLast() else { return } + redoStack.append(.init(graph: graph, positions: positions)) + restoreCanvas(before) + } + + func redoCanvas() { + guard transaction == nil, let after = redoStack.popLast() else { return } + undoStack.append(.init(graph: graph, positions: positions)) + restoreCanvas(after) + } + + private func restoreCanvas(_ snapshot: CanvasSnapshot) { + restoring = true + graph = snapshot.graph + positions = snapshot.positions + restoring = false + selectedWire = nil + canvasSelection = canvasSelection.filter { id in graph.nodes.contains { $0.id == id } } + if !graph.nodes.contains(where: { $0.id == selectedNodeID }) { selectedNodeID = graph.root } + layoutRevision &+= 1 + } + + func deleteCanvasSelection() { + if let wire = selectedWire, canvasWires.contains(wire), let port = Int(wire.target.port) { + editNode(wire.target.node) { node in + if node.inputs.indices.contains(port) { node.inputs.remove(at: port) } + } + selectedWire = nil + } else { + beginCanvasTransaction() + let ids = canvasSelection.isEmpty ? [selectedNodeID] : canvasSelection.sorted() + for id in ids { selectedNodeID = id; removeSelectedNode() } + endCanvasTransaction() + canvasSelection = [] + } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationInspector.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationInspector.swift new file mode 100644 index 000000000..b6c32448b --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationInspector.swift @@ -0,0 +1,332 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +struct EditorAnimationInspector: View { + let workspace: EditorAnimationAssetWorkspace + private var model: EditorAnimationTimelineModel { workspace.timeline } + @State private var keyTime = "0" + @State private var keyValue = "" + @State private var incoming = "" + @State private var outgoing = "" + @State private var keyRevision = "" + @State private var poseDrafts: [String: [String]] = [:] + @State private var editedComponents: [String: Set] = [:] + @State private var eulerDegrees = false + @Environment(\.theme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + adaEditorInspectorTitle(theme: theme) + if model.inspectorMode == "modifiers" || workspace.document.kind == .animationModifiers && model.inspectorMode != "preview" && model.inspectorMode != "graph" { + ScrollView(.vertical, showsIndicators: true) { EditorAnimationModifierInspector(workspace: workspace).padding(12) } + } else if workspace.mode == .graph && model.inspectorMode == "graph" { + Text(L10n.tr("Asset preview draft")).font(.system(size: 11)).padding(8) + action("Save Preview Workspace") { model.perform { try workspace.saveWorkspaceState() } } + EditorAnimationGraphEditor(model: workspace.graph, presentation: .inspector) + } else if model.inspectorMode == "preview" { + ScrollView(.vertical, showsIndicators: true) { previewSettings.padding(12) } + } else { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 10) { + Text(workspace.document.title).font(.system(size: 14, weight: .semibold)) + libraryControls + if let node = model.selectedNode { + Text(node.name ?? node.id).font(.system(size: 12, weight: .semibold)) + Text(node.id).font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + Text(nodePath(node.id)).font(.system(size: 10)).lineLimit(4).foregroundColor(theme.editorColors.muted) + Text(L10n.tr(nodeType(node.id))).font(.system(size: 10)) + Text(L10n.tr("Selected bones") + ": \(model.selectedNodes.count)").font(.system(size: 11)) + action("Select Subtree") { model.selectSubtree(node.id) } + if node.restTranslation != nil { + restPose(node) + poseControls + } else { + Text(L10n.tr("This node keeps its imported matrix transform.")).font(.system(size: 11)) + } + } + if let track = model.selectedTrack { + Text(L10n.tr(track.channel.rawValue)).font(.system(size: 11, weight: .semibold)) + EditorEnumField(cases: AnimationLibraryTrack.Interpolation.allCases.filter { track.channel != .rotation || $0 != .bezier }.map(\.rawValue), + selection: Binding(get: { track.interpolation.rawValue }, set: { value in + model.perform { + if let clip = model.clip, let interpolation = AnimationLibraryTrack.Interpolation(rawValue: value) { + try model.apply([.init(kind: .configureTrack, clipID: clip.id, trackID: track.id, interpolation: interpolation)]) + } + } + }), accessibilityID: "AdaEditor.Animation.Track.Interpolation") + EditorEnumField(cases: model.clip?.tracks.filter { model.selectedNodes.contains($0.nodeID) }.map(\.id) ?? [], + selection: Binding(get: { track.id }, set: { id in + model.selectedTrackID = id; model.selectedKeys = [] + model.selectedChannel = model.clip?.tracks.first { $0.id == id }?.channel + }), + accessibilityID: "AdaEditor.Animation.Track", labels: trackLabels, localizesValues: false) + if model.selectedKeys.count > 1 { + Text(L10n.tr("Mixed values")).font(.system(size: 11)) + } else if let ref = model.selectedKeys.first, ref.track == track.id, let key = track.keys.first(where: { $0.id == ref.key }) { + keyFields(track, key: key) + } + } + if !model.error.isEmpty { Text(model.error).font(.system(size: 11)).foregroundColor(.red) } + }.padding(12).frame(maxWidth: .infinity, alignment: .topLeading) + } + } + }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .foregroundColor(theme.editorColors.text).accessibilityIdentifier("AdaEditor.Animation.Inspector") + .onChange(of: model.selectedKeys) { _, _ in synchronizeKey() } + .onChange(of: model.selectedTrackID) { _, _ in synchronizeKey() } + .onChange(of: workspace.library?.revision) { _, _ in synchronizeKey(); resetPoseDrafts() } + .onChange(of: model.selectedNodes) { _, _ in resetPoseDrafts() } + .onChange(of: model.timeText) { _, _ in resetPoseDrafts() } + .onChange(of: model.clip?.id) { _, _ in resetPoseDrafts() } + .onChange(of: workspace.library?.previewSerial) { _, _ in resetPoseDrafts() } + } + + private var trackLabels: [String: String] { + (model.clip?.tracks ?? []).reduce(into: [:]) { $0[$1.id] = L10n.tr($1.channel.rawValue) } + } + + private func nodePath(_ id: String) -> String { + let nodes = model.nodes.reduce(into: [String: AnimationRigBinding.Node]()) { $0[$1.id] = $1 } + var current: String? = id, names: [String] = [], visited: Set = [] + while let id = current, let node = nodes[id], visited.insert(id).inserted { + names.append(node.name ?? node.id); current = node.parentID + } + return names.reversed().joined(separator: "/") + } + + private func nodeType(_ id: String) -> String { + guard let index = model.nodes.firstIndex(where: { $0.id == id }), let asset = workspace.preview.asset else { return "Node" } + if asset.skins.contains(where: { $0.joints.contains(index) }) { return "Bone" } + return asset.nodes.indices.contains(index) && asset.nodes[index].meshIndex != nil ? "Mesh" : "Node" + } + + private func restPose(_ node: AnimationRigBinding.Node) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.tr("Rest pose")).font(.system(size: 11, weight: .semibold)) + if let value = node.restTranslation { restValues("translation", [value.x, value.y, value.z]) } + if let value = node.restRotation { restValues("rotation", [value.x, value.y, value.z, value.w]) } + if let value = node.restScale { restValues("scale", [value.x, value.y, value.z]) } + }.accessibilityIdentifier("AdaEditor.Animation.Pose.Rest") + } + + private func restValues(_ title: String, _ values: [Float]) -> some View { + HStack { + Text(L10n.tr(title)).font(.system(size: 10)) + Text(values.map { String(format: "%.4g", $0) }.joined(separator: ", ")).font(.system(size: 10)) + }.foregroundColor(theme.editorColors.muted) + } + + private var previewSettings: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.tr("Preview Settings")).font(.system(size: 13, weight: .semibold)) + Text(workspace.document.title).font(.system(size: 11)) + EditorEnumField(cases: ["perspective", "orthographic"], selection: Binding( + get: { workspace.camera.isOrthographic ? "orthographic" : "perspective" }, + set: { workspace.camera.isOrthographic = $0 == "orthographic" }), accessibilityID: "AdaEditor.Animation.Projection", + labels: ["perspective": L10n.tr("Perspective"), "orthographic": L10n.tr("Orthographic")], localizesValues: false) + Text(L10n.tr("Background")).font(.system(size: 11)) + EditorEnumField(cases: ["dark", "gray", "light"], selection: Binding( + get: { workspace.camera.backgroundStyle }, set: { workspace.camera.backgroundStyle = $0 }), accessibilityID: "AdaEditor.Animation.Background", + labels: ["dark": L10n.tr("Dark"), "gray": L10n.tr("Gray"), "light": L10n.tr("Light")], localizesValues: false) + Text(L10n.tr("Lighting intensity")).font(.system(size: 11)) + Slider(value: Binding(get: { Double(workspace.camera.lightingIntensity) }, set: { + if $0.isFinite { workspace.camera.lightingIntensity = Float(max(0, min(8, $0))) } + }), in: 0...8).frame(maxWidth: .infinity).accessibilityIdentifier("AdaEditor.Animation.Lighting") + Text(String(format: "%.2f", workspace.camera.lightingIntensity)).font(.system(size: 11)) + action(workspace.camera.showsGrid ? "Hide grid" : "Show grid", selected: workspace.camera.showsGrid) { workspace.camera.showsGrid.toggle() } + action(workspace.camera.showsAxes ? "Hide axes" : "Show axes", selected: workspace.camera.showsAxes) { workspace.camera.showsAxes.toggle() } + action(workspace.preview.showsBones ? "Hide bones" : "Show bones", selected: workspace.preview.showsBones) { workspace.preview.showsBones.toggle() } + action("Frame model") { workspace.camera.reset() } + action("Frame Selection") { workspace.camera.frameSelection(workspace.preview.selectedWorldPoints()) } + .disabled(model.selectedNodes.isEmpty) + action("Save Preview Workspace") { model.perform { try workspace.saveWorkspaceState() } } + }.frame(maxWidth: .infinity, alignment: .topLeading).accessibilityIdentifier("AdaEditor.Animation.PreviewSettings") + } + + private var poseControls: some View { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.tr("Local pose")).font(.system(size: 11, weight: .semibold)) + action(eulerDegrees ? "Euler degrees (Z, X, Y)" : "Quaternion XYZW") { + model.perform { + let width = eulerDegrees ? 3 : 4 + let values = (0.. Binding { + func values() -> [String] { + if let draft = poseDrafts[channel.rawValue] { return draft } + let values = model.sampledValues(channel) + return (channel == .rotation && eulerDegrees ? EditorAnimationTimelineModel.eulerDegrees(values) : values).map { String(format: "%.7g", $0) } + } + return Binding(get: { let data = values(); return data.indices.contains(index) ? data[index] : "0" }, set: { text in + workspace.preview.playsAnimation = false + var data = values() + while data.count <= index { data.append("0") } + data[index] = text + poseDrafts[channel.rawValue] = data + editedComponents[channel.rawValue, default: []].insert(index) + }) + } + + private func resetPoseDrafts() { poseDrafts = [:]; editedComponents = [:] } + + private func isMixed(_ channel: AnimationLibraryTrack.Channel, index: Int) -> Bool { + guard editedComponents[channel.rawValue]?.contains(index) != true else { return false } + let values = model.selectedNodes.compactMap { id -> Float? in + let sampled = model.sampledValues(channel, nodeID: id) + let components = channel == .rotation && eulerDegrees ? EditorAnimationTimelineModel.eulerDegrees(sampled) : sampled + return components.indices.contains(index) ? components[index] : nil + } + guard let first = values.first else { return false } + return values.contains { abs($0 - first) > 0.000001 } + } + + private var libraryControls: some View { + VStack(alignment: .leading, spacing: 6) { + if let document = workspace.library { + Text(document.relativePath).font(.system(size: 10)).lineLimit(3) + if document.isDirty { Text(L10n.tr("Unsaved animation")).font(.system(size: 11)).foregroundColor(theme.editorColors.blue) } + action("Save", prominent: true) { model.perform { try document.save(expectedRevision: document.revision) } } + action("Reload from Disk") { recover(document, discard: false) } + .disabled(model.hasUnkeyedPose || workspace.isWorking).accessibilityIdentifier("AdaEditor.Animation.Library.Reload") + action("Discard Local Edits and Reload") { recover(document, discard: true) } + .disabled(model.hasUnkeyedPose || workspace.isWorking).accessibilityIdentifier("AdaEditor.Animation.Library.DiscardReload") + action("Discard Draft") { + model.perform { + try workspace.requireNoUnkeyedPose() + try workspace.service?.discard(document, expectedRevision: document.revision, discardChanges: true) + workspace.library = nil + workspace.mode = .clips + model.selectedKeys = []; model.selectedTrackID = nil; model.selectedChannel = nil + workspace.refreshLibrary() + } + }.disabled(model.hasUnkeyedPose || workspace.isWorking).accessibilityIdentifier("AdaEditor.Animation.Library.Discard") + } + Text(L10n.tr(model.isEditable ? "Editable animation" : "Imported animation (read-only)")).font(.system(size: 11)) + Text(L10n.tr("Source asset")).font(.system(size: 10)) + Text(model.library?.binding.sourceAsset ?? workspace.document.relativePath).font(.system(size: 10)).lineLimit(3) + if let clip = model.clip { + Text(L10n.tr("Source clip")).font(.system(size: 10)) + Text(clip.sourceClip ?? clip.name).font(.system(size: 10)) + } + field("Animation library path", text: Binding(get: { workspace.libraryPath }, set: { workspace.libraryPath = $0 })) + field("Clip name", text: Binding(get: { workspace.clipName }, set: { workspace.clipName = $0 })) + action("Extract Editable Copy") { workspace.run { try await workspace.extract(fromRest: false) } } + .disabled(workspace.sourceClip.isEmpty || workspace.isWorking || model.hasUnkeyedPose) + action("New Animation") { workspace.run { try await workspace.extract(fromRest: true) } }.disabled(workspace.isWorking || model.hasUnkeyedPose) + action("Open Library") { workspace.run { try workspace.openLibrary() } }.disabled(model.hasUnkeyedPose) + action("Insert Key", prominent: true) { model.perform { try model.insertKeys() } }.disabled(!model.isEditable || model.selectedNodes.isEmpty) + action(model.autoKey ? "Auto Key: On" : "Auto Key: Off", selected: model.autoKey) { + if model.hasUnkeyedPose { model.error = L10n.tr("Insert or discard the unkeyed pose first.") } + else { model.autoKey.toggle() } + } + } + } + + private func recover(_ document: EditorAnimationLibraryDocument, discard: Bool) { + model.perform { + try workspace.requireNoUnkeyedPose() + try workspace.service?.reload(document, expectedRevision: document.revision, discardChanges: discard) + model.selectedKeys = []; model.selectedTrackID = nil; model.selectedChannel = nil + workspace.refreshLibrary() + } + } + + private func keyFields(_ track: AnimationLibraryTrack, key: AnimationLibraryKey) -> some View { + VStack(alignment: .leading, spacing: 6) { + field("Seconds", text: $keyTime).accessibilityIdentifier("AdaEditor.Animation.Key.Time") + field("Key values, separated by commas", text: $keyValue).accessibilityIdentifier("AdaEditor.Animation.Key.Value") + if track.interpolation == .cubicSpline { + field("Incoming derivative", text: $incoming) + field("Outgoing derivative", text: $outgoing) + } + action("Update Key") { + model.perform { + guard let clip = model.clip, let time = Double(keyTime), let values = numbers(keyValue) else { throw invalidNumbers() } + var edited = key + edited.time = time; edited.value = values + if track.interpolation == .cubicSpline { edited.incoming = numbers(incoming); edited.outgoing = numbers(outgoing) } + try model.apply([.init(kind: .putKey, clipID: clip.id, trackID: track.id, key: edited, replaceExisting: true)], revision: keyRevision) + } + }.disabled(!model.isEditable).accessibilityIdentifier("AdaEditor.Animation.Key.Update") + action("Delete Key") { model.perform { try model.deleteKeys() } }.disabled(!model.isEditable) + EditorAnimationCurveInspector(model: model, track: track, key: key) + }.onAppear { synchronizeKey() } + } + + private func synchronizeKey() { + guard let reference = model.selectedKeys.first, let track = model.clip?.tracks.first(where: { $0.id == reference.track }), + let key = track.keys.first(where: { $0.id == reference.key }) else { return } + keyTime = String(key.time) + keyRevision = workspace.library?.revision ?? "" + keyValue = key.value.map(String.init(describing:)).joined(separator: ", ") + incoming = key.incoming?.map(String.init(describing:)).joined(separator: ", ") ?? "" + outgoing = key.outgoing?.map(String.init(describing:)).joined(separator: ", ") ?? "" + } + + private func numbers(_ text: String) -> [Float]? { + let parts = text.split(separator: ",", omittingEmptySubsequences: false) + let values = parts.compactMap { Float($0.trimmingCharacters(in: .whitespacesAndNewlines)) } + return values.count == parts.count && values.allSatisfy(\.isFinite) ? values : nil + } + + private func invalidNumbers() -> AnimationAuthoringError { + .init(.invalidKey, itemID: "input", message: L10n.tr("Enter finite numeric values for every component.")) + } + + private func field(_ title: String, text: Binding) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.tr(title)).font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + TextField(L10n.tr(title), text: text).textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + .frame(maxWidth: .infinity) + } + } + private func action(_ title: String, prominent: Bool = false, selected: Bool = false, _ perform: @escaping () -> Void) -> some View { + Button(action: perform) { Text(L10n.tr(title)).font(.system(size: 11)).lineLimit(2).frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, prominent: prominent, selected: selected)) + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationLibraryAssetEditor.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationLibraryAssetEditor.swift new file mode 100644 index 000000000..ad20e7a5b --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationLibraryAssetEditor.swift @@ -0,0 +1,329 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Math + +struct EditorAnimationLibraryAssetEditor: View { + let document: EditorAssetDocument + let workbench: EditorWorkbenchViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(document.title).font(.system(size: 14, weight: .bold)) + EditorAnimationAssetViewport(workspace: workbench.animationWorkspace(for: document)) + }.padding(16).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +/// The same document and command path is used by the scene Animator and an opened library asset. +struct EditorAnimationLibraryEditor: View { + let document: EditorAnimationLibraryDocument + let service: EditorAnimationAuthoringService + @State private var preview: EditorModelAssetPreviewModel + let showsPreview: Bool + @State private var camera = EditorModelPreviewCamera() + @State private var nodeID = "" + @State private var trackID = "" + @State private var keyID = "" + @State private var keyTime = "0" + @State private var keyValue = "0, 0, 0" + @State private var incoming = "" + @State private var outgoing = "" + @State private var duration = "1" + @State private var newName = "" + @State private var channel = AnimationLibraryTrack.Channel.rotation + @State private var eulerDegrees = false + @State private var error = "" + @Environment(\.theme) private var theme + + init(document: EditorAnimationLibraryDocument, service: EditorAnimationAuthoringService, + preview: EditorModelAssetPreviewModel? = nil, showsPreview: Bool = true) { + self.document = document + self.service = service + self._preview = State(wrappedValue: preview ?? EditorModelAssetPreviewModel()) + self.showsPreview = showsPreview + } + + private var clip: AnimationLibraryClip? { document.library.clips.first { $0.id == document.selectedClipID } ?? document.library.clips.first } + private var node: AnimationRigBinding.Node? { document.library.binding.nodes.first { $0.id == nodeID } ?? document.library.binding.nodes.first } + private var track: AnimationLibraryTrack? { + clip?.tracks.first { $0.id == trackID } ?? clip?.tracks.first { $0.nodeID == node?.id } + } + + var body: some View { + if document.isDiscarded { Text(L10n.text("Draft discarded. Open the library again.")) } + else { + GeometryReader { geometry in + ScrollView(.horizontal, showsIndicators: true) { + editorContent.frame(width: max(760, geometry.size.width), height: geometry.size.height) + } + } + } + } + + private var editorContent: some View { + VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(document.relativePath).font(.system(size: 11)) + if document.isDirty { Text(L10n.text("Unsaved animation")).font(.system(size: 11)) } + Spacer() + action("Save") { try document.save(expectedRevision: document.revision) } + action("Undo") { try document.undo(expectedRevision: document.revision) }.disabled(!document.canUndo) + action("Redo") { try document.redo(expectedRevision: document.revision) }.disabled(!document.canRedo) + } + HStack { + action("Reload from Disk") { try service.reload(document, expectedRevision: document.revision) } + action("Discard Local Edits and Reload") { try service.reload(document, expectedRevision: document.revision, discardChanges: true) } + action("Discard Draft") { try service.discard(document, expectedRevision: document.revision, discardChanges: true) } + } + } + HStack(alignment: .top, spacing: 8) { + if showsPreview { + VStack { + if preview.asset != nil { EditorModelPreviewViewport(model: preview, camera: camera).frame(minHeight: 150, maxHeight: .infinity) } + else { Text(preview.error ?? L10n.tr("Loading animation model…")) } + HStack { + action(preview.playsAnimation ? "Pause" : "Play", selected: preview.playsAnimation) { preview.playsAnimation.toggle() } + action("Seek") { + guard let time = Double(keyTime), time.isFinite else { throw inputError() } + preview.playsAnimation = false + preview.seekGraph(to: time) + } + } + }.frame(width: 210) + } + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.text("Clips")).font(.system(size: 11, weight: .semibold)) + ForEach(document.library.clips, id: \.id) { value in + Button { selectClip(value) } label: { + Text(value.name).font(.system(size: 11)).foregroundColor(value.id == clip?.id ? theme.editorColors.blue : theme.editorColors.text) + } + .accessibilityIdentifier("AdaEditor.AnimationLibrary.Clip.\(value.id)") + } + field(L10n.text("Clip name"), text: $newName).frame(height: 28) + action("Create Clip") { try addPose(capture: false) } + action("Capture Pose") { try addPose(capture: true) }.disabled(preview.capturedAnimationPlayer() == nil) + Text(L10n.text("Bones")).font(.system(size: 11, weight: .semibold)) + ForEach(document.library.binding.nodes, id: \.id) { value in + Button { + nodeID = value.id; trackID = ""; keyID = "" + synchronizeSelection(seek: true) + } label: { + Text("\(value.name ?? value.id) [\(value.id)]").font(.system(size: 10)) + .foregroundColor(value.id == node?.id ? theme.editorColors.blue : theme.editorColors.text) + } + .accessibilityIdentifier("AdaEditor.AnimationLibrary.Node.\(value.id)") + } + } + }.frame(width: 150).frame(maxHeight: .infinity, alignment: .topLeading) + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 5) { + if let clip { + Text(clip.name).font(.system(size: 12, weight: .semibold)) + HStack { + field(L10n.text("Duration"), text: $duration).frame(width: 65, height: 28) + action("Set Duration") { + guard let value = Double(duration) else { throw inputError() } + try edit(.init(kind: .configureClip, clipID: clip.id, duration: value)) + } + action(clip.repeats ? "Loop: On" : "Loop: Off", selected: clip.repeats) { + try edit(.init(kind: .configureClip, clipID: clip.id, repeats: !clip.repeats)) + } + } + if let node { + Text("\(node.name ?? node.id) [\(node.id)]").font(.system(size: 11, weight: .semibold)) + HStack { + action(channel.rawValue) { + let choices = AnimationLibraryTrack.Channel.allCases + channel = choices[((choices.firstIndex(of: channel) ?? 0) + 1) % choices.count] + } + action("Add Bone Track") { + let value = AnimationLibraryTrack(nodeID: node.id, channel: channel) + try edit(.init(kind: .createTrack, clipID: clip.id, track: value)) + selectTrack(value) + } + } + ForEach(clip.tracks.filter { $0.nodeID == node.id }, id: \.id) { value in + Button { selectTrack(value) } label: { + Text(L10n.tr(value.channel.rawValue)).font(.system(size: 11)) + .foregroundColor(value.id == track?.id ? theme.editorColors.blue : theme.editorColors.text) + } + .accessibilityIdentifier("AdaEditor.AnimationLibrary.Track.\(value.id)") + } + } + if let track { + timeline(track, duration: clip.duration).frame(height: 30) + HStack { + field(L10n.text("Seconds"), text: $keyTime).frame(width: 70, height: 28) + .accessibilityIdentifier("AdaEditor.AnimationLibrary.KeyTime") + if track.channel == .rotation { + action(eulerDegrees ? "Euler degrees (Z, X, Y)" : "Quaternion XYZW") { + eulerDegrees.toggle(); keyValue = eulerDegrees ? "0, 0, 0" : "0, 0, 0, 1" + } + } + } + field(L10n.text("Key values, separated by commas"), text: $keyValue).frame(height: 28) + .accessibilityIdentifier("AdaEditor.AnimationLibrary.KeyValue") + if track.interpolation == .cubicSpline { + field(L10n.text("Incoming derivative"), text: $incoming).frame(height: 28) + field(L10n.text("Outgoing derivative"), text: $outgoing).frame(height: 28) + } + HStack { + action("Insert Key") { try putKey(replace: false) } + action("Update Key") { try putKey(replace: true) }.disabled(keyID.isEmpty) + action("Delete Key") { try edit(.init(kind: .removeKey, clipID: clip.id, trackID: track.id, keyID: keyID)); keyID = "" } + .disabled(keyID.isEmpty) + action(track.interpolation.rawValue) { + let choices: [AnimationLibraryTrack.Interpolation] = [.linear, .step] + let value = track.interpolation == .linear ? choices[1] : choices[0] + try edit(.init(kind: .configureTrack, clipID: clip.id, trackID: track.id, interpolation: value)) + } + } + ForEach(track.keys, id: \.id) { key in + Button { selectKey(key) } label: { Text("\(key.time) · \(key.value.map(String.init(describing:)).joined(separator: ", "))").font(.system(size: 10)) } + } + } + } + if !error.isEmpty { Text(error).font(.system(size: 11)).foregroundColor(.red) } + } + }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + }.padding(8).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { + if showsPreview { + preview.showsBones = true + preview.playsAnimation = false + do { preview.load(path: try service.resolve(document.library.binding.sourceAsset, extensions: ["glb", "gltf"]).path, resourceRoot: service.assetsURL) } + catch { self.error = L10n.text("Animation edit failed: \(error.localizedDescription)") } + } + if let clip { selectClip(clip) } + } + .onDisappear { if showsPreview { preview.cancel() } } + .onChange(of: preview.asset.map(ObjectIdentifier.init)) { _, _ in + refreshPreview() + if document.previewSerial > 0 { preview.playsAnimation = false; preview.seekGraph(to: document.previewTime) } + } + .onChange(of: document.revision) { _, _ in refreshPreview(); synchronizeSelection() } + .onChange(of: document.previewSerial) { _, _ in refreshPreview(); preview.playsAnimation = false; preview.seekGraph(to: document.previewTime) } + .onChange(of: document.selectedClipID) { _, _ in + refreshPreview() + synchronizeSelection() + } + .accessibilityIdentifier("AdaEditor.AnimationLibrary.Editor") + } + + private func field(_ placeholder: String, text: Binding) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors, height: 28)) + } + + private func action(_ title: String, selected: Bool = false, _ perform: @escaping () throws -> Void) -> some View { + Button { + do { try perform(); error = "" } catch { self.error = L10n.text("Animation edit failed: \(error.localizedDescription)") } + } label: { Text(L10n.tr(title)).font(.system(size: 11)) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, selected: selected)) + .accessibilityIdentifier("AdaEditor.AnimationLibrary.Action.\(title)") + } + + private func selectClip(_ value: AnimationLibraryClip) { + document.selectedClipID = value.id; trackID = ""; keyID = ""; duration = String(format: "%.5g", value.duration) + if nodeID.isEmpty { nodeID = value.tracks.first?.nodeID ?? document.library.binding.nodes.first?.id ?? "" } + refreshPreview() + synchronizeSelection() + } + + /// Refresh field drafts after edits from any client, without overriding an explicit API preview seek. + private func synchronizeSelection(seek: Bool = false) { + guard let clip else { trackID = ""; keyID = ""; return } + duration = String(format: "%.5g", clip.duration) + if let selected = clip.tracks.first(where: { $0.id == trackID && $0.nodeID == node?.id }) { + if let key = selected.keys.first(where: { $0.id == keyID }) { selectKey(key, seek: seek) } + else { selectTrack(selected, seek: seek) } + } else if let first = clip.tracks.first(where: { $0.nodeID == node?.id && $0.channel == .rotation }) + ?? clip.tracks.first(where: { $0.nodeID == node?.id }) { + selectTrack(first, seek: seek) + } else { trackID = ""; keyID = "" } + } + + private func selectKey(_ value: AnimationLibraryKey, seek: Bool = true) { + keyID = value.id; keyTime = String(value.time); eulerDegrees = false + keyValue = value.value.map(String.init(describing:)).joined(separator: ", ") + incoming = value.incoming?.map(String.init(describing:)).joined(separator: ", ") ?? "" + outgoing = value.outgoing?.map(String.init(describing:)).joined(separator: ", ") ?? "" + if seek { preview.playsAnimation = false; preview.seekGraph(to: value.time) } + } + + private func selectTrack(_ value: AnimationLibraryTrack, seek: Bool = true) { + trackID = value.id; keyID = ""; eulerDegrees = false + if let first = value.keys.first { selectKey(first, seek: seek); return } + keyTime = "0"; incoming = ""; outgoing = "" + switch value.channel { + case .translation: keyValue = "0, 0, 0" + case .rotation: keyValue = "0, 0, 0, 1" + case .scale: keyValue = "1, 1, 1" + case .weights: keyValue = (node?.morphRestWeights ?? []).map(String.init(describing:)).joined(separator: ", ") + } + } + + private func edit(_ operation: AnimationEdit) throws { + _ = try service.apply(path: document.relativePath, expectedRevision: document.revision, operations: [operation]) + } + + private func putKey(replace: Bool) throws { + guard let clip, let track, let time = Double(keyTime), !replace || !keyID.isEmpty else { throw inputError() } + var values = try numbers(keyValue) + if track.channel == .rotation, eulerDegrees { + guard values.count == 3 else { throw inputError() } + let rotation = Quat.euler(Vector3(values[0], values[1], values[2]) * (Float.pi / 180)).normalized + values = [rotation.x, rotation.y, rotation.z, rotation.w] + } + let cubic = track.interpolation == .cubicSpline + let zero = Array(repeating: Float(0), count: values.count) + let key = AnimationLibraryKey(id: replace ? keyID : UUID().uuidString, time: time, value: values, + incoming: cubic ? (incoming.isEmpty ? zero : try numbers(incoming)) : nil, + outgoing: cubic ? (outgoing.isEmpty ? zero : try numbers(outgoing)) : nil) + try edit(.init(kind: .putKey, clipID: clip.id, trackID: track.id, key: key, replaceExisting: replace)) + selectKey(key) + } + + private func addPose(capture: Bool) throws { + let name = newName.isEmpty ? "Animation \(document.library.clips.count + 1)" : newName + let value = try document.library.poseClip(name: name, duration: 1, from: capture ? preview.capturedAnimationPlayer() : nil) + try edit(.init(kind: .createClip, clip: value)) + selectClip(value) + } + + private func numbers(_ value: String) throws -> [Float] { + let parts = value.split(separator: ",", omittingEmptySubsequences: false) + let numbers = parts.compactMap { Float($0.trimmingCharacters(in: .whitespacesAndNewlines)) } + guard numbers.count == parts.count, numbers.allSatisfy(\.isFinite) else { throw inputError() } + return numbers + } + + private func inputError() -> AnimationAuthoringError { + .init(.invalidKey, itemID: keyID, message: "Enter finite numeric values with the channel's expected number of components.") + } + + private func refreshPreview() { + guard preview.asset != nil, let clip else { return } + do { + try preview.installAnimationLibraries([document.library]) + preview.animationGraph = nil + preview.animationRepeats = clip.repeats + preview.selectedAnimation = document.library.runtimeName(for: clip.id) + } catch { self.error = L10n.text("Animation edit failed: \(error.localizedDescription)") } + } + + private func timeline(_ track: AnimationLibraryTrack, duration: Double) -> some View { + GeometryReader { geometry in + ZStack(anchor: .topLeading) { + RectangleShape().fill(theme.editorColors.muted.opacity(0.4)).frame(height: 1).offset(y: 12) + ForEach(track.keys, id: \.id) { key in + Button { selectKey(key) } label: { Text("◆").foregroundColor(key.id == keyID ? .yellow : theme.editorColors.blue) } + .offset(x: Float(duration > 0 ? key.time / duration : 0) * max(0, geometry.size.width - 15), y: 3) + } + }.frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) + } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationModelPanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationModelPanel.swift new file mode 100644 index 000000000..d0f0dd847 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationModelPanel.swift @@ -0,0 +1,150 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +struct EditorAnimationModelWorkspace: View { + let workspace: EditorAnimationAssetWorkspace + @State private var startWidth: Float? + @State private var hoverHelp: String? + @Environment(\.theme) private var theme + + var body: some View { + GeometryReader { geometry in + let width = workspace.timeline.showsModelPanel ? min(workspace.timeline.modelPanelWidth, max(100, geometry.size.width * 0.45)) : 0 + HStack(spacing: 0) { + EditorAnimationModelPanel(workspace: workspace).frame(width: width) + .mask(RectangleShape()).allowsHitTesting(width > 0) + RectangleShape().fill(theme.editorColors.border).frame(width: width > 0 ? 6 : 0) + .gesture(DragGesture(minimumDistance: 0).onChanged { value in + if startWidth == nil { startWidth = width } + workspace.timeline.modelPanelWidth = max(100, min(max(100, geometry.size.width * 0.45), (startWidth ?? width) + value.translation.width)) + }.onEnded { _ in startWidth = nil }) + .accessibilityIdentifier("AdaEditor.Model.Hierarchy.Divider") + VStack(spacing: 0) { + HStack(spacing: 5) { + control("Model", glyph: "☰", selected: workspace.timeline.showsModelPanel) { workspace.timeline.showsModelPanel.toggle() } + control("Frame model", glyph: "⌖") { workspace.camera.reset() } + control("Frame Selection", glyph: "◎") { workspace.camera.frameSelection(workspace.preview.selectedWorldPoints()) } + control(workspace.camera.isOrthographic ? "Orthographic" : "Perspective", glyph: workspace.camera.isOrthographic ? "O" : "P", + selected: workspace.camera.isOrthographic) { workspace.camera.isOrthographic.toggle() } + control(workspace.preview.showsBones ? "Hide bones" : "Show bones", glyph: "◆", selected: workspace.preview.showsBones) { workspace.preview.showsBones.toggle() } + control("Preview Settings", glyph: "⚙") { workspace.timeline.inspectorMode = "preview"; workspace.revealInspector?() } + Spacer(minLength: 0) + }.padding(4).frame(height: 30) + EditorModelPreviewViewport(model: workspace.preview, camera: workspace.camera) + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + }.frame(width: geometry.size.width, height: geometry.size.height) + .overlay(anchor: .topLeading) { + if let hoverHelp { + Text(hoverHelp).font(.system(size: 11)).padding(6).background(theme.editorColors.surfaceElevated) + .offset(x: width + 6, y: 32).allowsHitTesting(false) + } + } + } + } + + private func control(_ title: String, glyph: String, selected: Bool = false, action: @escaping () -> Void) -> some View { + Button(action: action) { Text(glyph).font(.system(size: 13)).frame(width: 26, height: 26) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, selected: selected, horizontalPadding: 0)) + .onHover { hoverHelp = $0 ? L10n.tr(title) : nil } + .accessibilityIdentifier("AdaEditor.Animation.View.\(title)") + } +} + +struct EditorAnimationModelPanel: View { + let workspace: EditorAnimationAssetWorkspace + private var model: EditorAnimationTimelineModel { workspace.timeline } + @Environment(\.theme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.tr("Model")).font(.system(size: 12, weight: .semibold)).padding(6) + TextField(L10n.tr("Filter Nodes"), text: Binding(get: { model.search }, set: { model.search = $0 })) + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors, height: 30)) + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 0) { + ForEach(model.hierarchyRows) { row in + HStack(spacing: 4) { + Button { + if !model.collapsedNodes.insert(row.nodeID).inserted { model.collapsedNodes.remove(row.nodeID) } + } label: { Text(model.collapsedNodes.contains(row.nodeID) ? "▸" : "▾").font(.system(size: 11)).frame(width: 16, height: 24) } + .buttonStyle(DefaultButtonStyle()) + Button { model.selectNode(row.nodeID) } label: { + Text(symbol(row.nodeID) + " " + row.label).font(.system(size: 11)).lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + }.buttonStyle(DefaultButtonStyle()) + .overlay { EditorAnimationHierarchyRowInput(model: model, nodeID: row.nodeID) } + .accessibilityIdentifier("AdaEditor.Model.Node.\(row.nodeID)") + }.padding(.leading, Float(min(8, row.depth)) * 10).frame(height: 24) + .foregroundColor(model.selectedNodes.contains(row.nodeID) ? theme.editorColors.blue : theme.editorColors.text) + .background(model.selectedNodes.contains(row.nodeID) ? theme.editorColors.blue.opacity(0.14) : Color.clear) + .contextMenu { Button(L10n.tr("Select Subtree")) { model.selectSubtree(row.nodeID) } } + } + }.frame(maxWidth: .infinity, alignment: .topLeading) + } + modifierList + }.padding(4).background(theme.editorColors.surface) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityIdentifier("AdaEditor.Model.Hierarchy") + } + + private func symbol(_ id: String) -> String { + guard let index = model.nodes.firstIndex(where: { $0.id == id }), let asset = workspace.preview.asset else { return "◇" } + if asset.skins.contains(where: { $0.joints.contains(index) }) { return "◆" } + return asset.nodes.indices.contains(index) && asset.nodes[index].meshIndex != nil ? "▣" : "◇" + } + + private var modifierList: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + model.inspectorMode = "modifiers"; workspace.revealInspector?() + } label: { Text(L10n.tr("Modifiers")).font(.system(size: 12, weight: .semibold)) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, selected: model.inspectorMode == "modifiers")) + HStack(spacing: 4) { + modifierAction("+ Two-bone IK") { try workspace.addModifier(.twoBone) } + modifierAction("+ FABRIK") { try workspace.addModifier(.fabrik) } + } + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 3) { + ForEach(workspace.modifiers?.stack.modifiers ?? [], id: \.id) { modifier in + Button { + workspace.selectedModifierID = modifier.id + model.inspectorMode = "modifiers" + workspace.revealInspector?() + } label: { + Text((modifier.enabled ? "● " : "○ ") + (modifier.name ?? L10n.tr(modifier.kind == .twoBone ? "Two-bone IK" : "FABRIK"))) + .font(.system(size: 11)).lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + }.buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, selected: workspace.selectedModifierID == modifier.id)) + .accessibilityIdentifier("AdaEditor.Animation.Modifier.\(modifier.id)") + } + }.padding(.trailing, 8) + }.frame(height: min(90, Float(workspace.modifiers?.stack.modifiers.count ?? 0) * 29)) + }.padding(4).accessibilityIdentifier("AdaEditor.Animation.Modifiers") + } + + private func modifierAction(_ title: String, action: @escaping () throws -> Void) -> some View { + Button { model.perform(action) } label: { Text(L10n.tr(title)).font(.system(size: 11)).lineLimit(2) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors)).disabled(workspace.sourceLibrary == nil) + } +} + +private struct EditorAnimationHierarchyRowInput: UIViewRepresentable { + let model: EditorAnimationTimelineModel + let nodeID: String + func makeUIView(in _: Context) -> EditorAnimationHierarchyRowView { + let view = EditorAnimationHierarchyRowView(frame: .zero) + view.backgroundColor = .clear + return view + } + func updateUIView(_ view: EditorAnimationHierarchyRowView, in _: Context) { view.model = model; view.nodeID = nodeID } +} + +private final class EditorAnimationHierarchyRowView: UIView { + weak var model: EditorAnimationTimelineModel? + var nodeID = "" + override func onMouseEvent(_ event: MouseEvent) { + guard event.button == .left, event.phase == .began else { return } + model?.selectNode(nodeID, additive: event.modifierKeys.contains(.main) || event.modifierKeys.contains(.control), range: event.modifierKeys.contains(.shift)) + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationModifierInspector.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationModifierInspector.swift new file mode 100644 index 000000000..957f3e926 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationModifierInspector.swift @@ -0,0 +1,243 @@ +import AdaEngine +import Foundation +import Math + +struct EditorAnimationModifierInspector: View { + let workspace: EditorAnimationAssetWorkspace + @State private var name = "" + @State private var rootBone = "" + @State private var endBone = "" + @State private var influence = "1" + @State private var iterations = "16" + @State private var tolerance = "" + @State private var target: [String] = ["0", "0", "0"] + @State private var pole: [String] = ["0", "1", "0"] + @State private var usesPole = false + @State private var sceneTarget = "" + @State private var sceneDraft: EditorIKSceneBindingDraft = EditorIKSceneBindingDraft() + @Environment(\.theme) private var theme + private var document: EditorAnimationModifierDocument? { workspace.modifiers } + private var modifier: AnimationPoseModifier? { workspace.selectedModifier } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.tr("Modifiers")).font(.system(size: 14, weight: .semibold)) + resourceControls + if let modifier { + constraintControls(modifier) + sceneControls + diagnostics + } else { Text(L10n.tr("Select a contiguous bone chain before adding IK.")).font(.system(size: 11)) } + if !workspace.error.isEmpty { Text(workspace.error).font(.system(size: 11)).foregroundColor(.red) } + if !workspace.timeline.error.isEmpty { Text(workspace.timeline.error).font(.system(size: 11)).foregroundColor(.red) } + }.frame(maxWidth: .infinity, alignment: .topLeading) + .onAppear { synchronize(); synchronizeSceneBinding() } + .onChange(of: workspace.selectedModifierID) { _, _ in synchronize(); synchronizeSceneBinding() } + .onChange(of: document?.revision) { _, _ in synchronize() } + .onChange(of: sceneTarget) { _, _ in synchronizeSceneBinding() } + .accessibilityIdentifier("AdaEditor.Animation.Modifier.Inspector") + } + + private var resourceControls: some View { + VStack(alignment: .leading, spacing: 6) { + TextField(L10n.tr("Modifier resource path"), text: Binding(get: { workspace.modifierPath }, set: { workspace.modifierPath = $0 })) + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + VStack(alignment: .leading, spacing: 4) { + action("Open IK Resource") { try workspace.openModifiers() } + action("Create IK Resource") { try workspace.createModifiers() }.disabled(document != nil || workspace.sourceLibrary == nil) + } + if let document { + Text(document.relativePath).font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + if document.isDirty { Text(L10n.tr("Unsaved animation")).font(.system(size: 11)).foregroundColor(theme.editorColors.blue) } + HStack(spacing: 4) { + action("Save", primary: true) { try document.save(expectedRevision: document.revision) } + action("Undo") { try document.undo(expectedRevision: document.revision) }.disabled(!document.canUndo) + action("Redo") { try document.redo(expectedRevision: document.revision) }.disabled(!document.canRedo) + } + } + } + } + + private func constraintControls(_ modifier: AnimationPoseModifier) -> some View { + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.tr("Name"), text: $name).textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + action(modifier.enabled ? "IK Enabled" : "IK Disabled", selected: modifier.enabled) { + var value = modifier; value.enabled.toggle() + try workspace.editModifiers([.init(kind: .update, modifierID: value.id, modifier: value)]) + } + bonePicker("Root bone", selection: $rootBone) + bonePicker("End bone", selection: $endBone) + Text(L10n.tr("IK Target (model space)")).font(.system(size: 11, weight: .semibold)) + vectorFields($target, identifier: "Target") + if modifier.kind == .twoBone { + action("Pole", selected: usesPole) { usesPole.toggle() } + if usesPole { vectorFields($pole, identifier: "Pole") } + } + field("Influence", value: $influence) + if modifier.kind == .fabrik { + field("Iterations", value: $iterations) + field("Tolerance (empty = automatic)", value: $tolerance) + } + action("Apply IK", primary: true) { try apply(modifier) } + HStack(spacing: 4) { + action("Move Up") { try move(modifier, delta: -1) } + action("Move Down") { try move(modifier, delta: 1) } + action("Remove") { try workspace.editModifiers([.init(kind: .remove, modifierID: modifier.id)]); workspace.selectedModifierID = nil } + } + } + } + + private func bonePicker(_ title: String, selection: Binding) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(L10n.tr(title)).font(.system(size: 11)) + EditorEnumField(cases: document?.stack.binding.nodes.map(\.id) ?? [], selection: selection, + accessibilityID: "AdaEditor.Animation.Modifier.\(title)", + labels: (document?.stack.binding.nodes ?? []).reduce(into: [:]) { $0[$1.id] = ($1.name ?? $1.id) + " (\($1.id))" }, localizesValues: false) + } + } + + private var sceneCandidates: [(id: String, label: String, scene: EditorSceneDocument, entity: String)] { + (workspace.workbench?.openDocuments ?? []).flatMap { document -> [(String, String, EditorSceneDocument, String)] in + guard case let .scene(scene) = document, !scene.isReadOnly else { return [] } + return (scene.sceneModel?.entities ?? []).filter { $0.components[EditorBuiltInComponentType.model3DSource] != nil }.map { + (scene.id + "|" + $0.id, scene.title + " / " + $0.name, scene, $0.id) + } + } + } + + private var sceneControls: some View { + VStack(alignment: .leading, spacing: 5) { + Text(L10n.tr("Scene target bindings")).font(.system(size: 12, weight: .semibold)) + EditorEnumField(cases: [""] + sceneCandidates.map(\.id), selection: $sceneTarget, + accessibilityID: "AdaEditor.Animation.Modifier.Scene", + labels: sceneCandidates.reduce(into: ["": L10n.tr("Choose scene model")]) { $0[$1.id] = $1.label }, localizesValues: false) + TextField(L10n.tr("Target entity ID (empty = model point)"), text: sceneValue(\.targetEntity)) + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + .accessibilityIdentifier("AdaEditor.Animation.Modifier.SceneTarget") + if sceneDraft.targetEntity.isEmpty { + action("Override Target Point", selected: sceneDraft.targetUsesModelPoint) { sceneDraft.targetUsesModelPoint.toggle() } + } + if !sceneDraft.targetEntity.isEmpty || sceneDraft.targetUsesModelPoint { vectorFields(sceneValue(\.targetOffset), identifier: "TargetOffset") } + TextField(L10n.tr("Pole entity ID (empty = model point)"), text: sceneValue(\.poleEntity)) + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + .accessibilityIdentifier("AdaEditor.Animation.Modifier.ScenePole") + if sceneDraft.poleEntity.isEmpty { + action("Override Pole Point", selected: sceneDraft.poleUsesModelPoint) { sceneDraft.poleUsesModelPoint.toggle() } + } + if !sceneDraft.poleEntity.isEmpty || sceneDraft.poleUsesModelPoint { vectorFields(sceneValue(\.poleOffset), identifier: "PoleOffset") } + Text(L10n.tr("Marker offsets use local space. Asset preview uses model-space points.")).font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + action("Reload Scene Bindings") { synchronizeSceneBinding() }.disabled(sceneTarget.isEmpty) + if let selected = sceneCandidates.first(where: { $0.id == sceneTarget }), + sceneDraft.sceneRevision != EditorAgentSceneToolService.revision(for: selected.scene.content) { + Text(L10n.tr("Scene changed. Reload bindings before applying.")).font(.system(size: 11)).foregroundColor(theme.editorColors.blue) + } + Button(L10n.tr("Bind IK to Scene")) { workspace.run { try await bindScene() } } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, selected: true)) + .disabled(document?.isDirty != false || sceneTarget.isEmpty) + } + } + + private var diagnostics: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(workspace.preview.modifierDiagnostics.filter { $0.id == modifier?.id }, id: \.id) { result in + Text(L10n.tr("IK status") + ": " + L10n.tr(result.status.rawValue)).font(.system(size: 11)) + if let error = result.reachError { Text(L10n.tr("Reach error") + ": \(error)").font(.system(size: 11)) } + Text(L10n.tr("Iterations") + ": \(result.iterations)").font(.system(size: 11)) + } + }.foregroundColor(theme.editorColors.muted) + } + + private func field(_ title: String, value: Binding) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(L10n.tr(title)).font(.system(size: 11)) + TextField(L10n.tr(title), text: value).textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors)) + } + } + + private func vectorFields(_ values: Binding<[String]>, identifier: String) -> some View { + HStack(spacing: 4) { + ForEach(0..<3, id: \.self) { index in + EditorAnimationAxisField(axis: ["X", "Y", "Z"][index], text: Binding(get: { values.wrappedValue[index] }, + set: { values.wrappedValue[index] = $0 }), identifier: "AdaEditor.Animation.Modifier.\(identifier).\(index)") + } + } + } + + private func action(_ title: String, primary: Bool = false, selected: Bool = false, perform: @escaping () throws -> Void) -> some View { + Button { workspace.timeline.perform(perform) } label: { Text(L10n.tr(title)).font(.system(size: 11)).lineLimit(2) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, selected: primary || selected)) + } + + private func synchronize() { + guard let modifier else { return } + name = modifier.name ?? L10n.tr(modifier.kind == .twoBone ? "Two-bone IK" : "FABRIK") + rootBone = modifier.joints.first ?? ""; endBone = modifier.joints.last ?? "" + influence = String(modifier.influence); iterations = String(modifier.iterations) + tolerance = modifier.tolerance.map { String($0) } ?? "" + target = [modifier.target.x, modifier.target.y, modifier.target.z].map { String($0) } + pole = modifier.pole.map { [$0.x, $0.y, $0.z].map { String($0) } } ?? ["0", "1", "0"] + usesPole = modifier.pole != nil + } + + private func vector(_ values: [String]) throws -> Vector3 { + let parsed = values.compactMap(Float.init) + guard parsed.count == 3, parsed.allSatisfy(\.isFinite) else { throw AnimationPoseModifierError.invalidModifier("point") } + return Vector3(parsed[0], parsed[1], parsed[2]) + } + + private func apply(_ current: AnimationPoseModifier) throws { + guard let nodes = document?.stack.binding.nodes, let influence = Float(influence), let iterations = Int(iterations) else { + throw AnimationPoseModifierError.invalidModifier(current.id) + } + var path: [String] = [], next: String? = endBone + while let id = next, !path.contains(id), path.count <= 65 { + path.append(id) + if id == rootBone { break } + next = nodes.first { $0.id == id }?.parentID + } + guard path.last == rootBone else { throw AnimationPoseModifierError.invalidChain(current.id) } + var value = current + value.name = name; value.joints = Array(path.reversed()); value.influence = influence; value.iterations = iterations + value.target = try vector(target); value.pole = usesPole ? try vector(pole) : nil + value.tolerance = tolerance.isEmpty ? nil : Float(tolerance) + guard tolerance.isEmpty || value.tolerance != nil else { throw AnimationPoseModifierError.invalidModifier(value.id) } + try workspace.editModifiers([.init(kind: .update, modifierID: value.id, modifier: value)]) + } + + private func move(_ modifier: AnimationPoseModifier, delta: Int) throws { + guard let values = document?.stack.modifiers, let index = values.firstIndex(where: { $0.id == modifier.id }), values.indices.contains(index + delta) else { return } + try workspace.editModifiers([.init(kind: .move, modifierID: modifier.id, index: index + delta)]) + } + + private func bindScene() async throws { + guard let document, let modifier, let workbench = workspace.workbench, let service = workspace.service, + let candidate = sceneCandidates.first(where: { $0.id == sceneTarget }) else { return } + let payload = candidate.scene.sceneModel?.entities.first { $0.id == candidate.entity }?.components[EditorBuiltInComponentType.model3DSource] + var bindings: [String: AnimationIKBinding3D] = [:] + if let value = payload?["modifierBindings"], value != .null { + bindings = try JSONDecoder().decode([String: AnimationIKBinding3D].self, from: JSONEncoder().encode(value)) + } + bindings[modifier.id] = try sceneDraft.binding() + _ = try await service.execute(.init(action: .modifierBind, path: document.relativePath, expectedRevision: document.revision, + scenePath: candidate.scene.relativePath, entityID: candidate.entity, + sceneRevision: sceneDraft.sceneRevision, modifierBindings: bindings), workbench: workbench) + synchronizeSceneBinding() + } + + private func sceneValue(_ path: ReferenceWritableKeyPath) -> Binding { + Binding(get: { sceneDraft[keyPath: path] }, set: { sceneDraft[keyPath: path] = $0 }) + } + + private func synchronizeSceneBinding() { + sceneDraft.load(nil, sceneRevision: "") + guard let modifier, let selected = sceneCandidates.first(where: { $0.id == sceneTarget }), + let payload = selected.scene.sceneModel?.entities.first(where: { $0.id == selected.entity })?.components[EditorBuiltInComponentType.model3DSource] else { return } + do { + let bindings = try payload["modifierBindings"].flatMap { value -> [String: AnimationIKBinding3D]? in + if value == .null { return nil } + return try JSONDecoder().decode([String: AnimationIKBinding3D].self, from: JSONEncoder().encode(value)) + } ?? [:] + sceneDraft.load(bindings[modifier.id], sceneRevision: EditorAgentSceneToolService.revision(for: selected.scene.content)) + } catch { workspace.error = error.localizedDescription } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift index e94bdb905..580ec204a 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationPanel.swift @@ -4,7 +4,7 @@ import Math struct EditorAnimationPanel: View { let viewModel: EditorViewModel - private enum Mode { case skeletalGraph, morphTargets, keyframes } + private enum Mode { case skeletalGraph, skeletalKeys, morphTargets, keyframes } @State private var mode = Mode.skeletalGraph @Environment(\.metrics) private var metrics @@ -12,15 +12,21 @@ struct EditorAnimationPanel: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - if let target = viewModel.skeletalAnimationTarget { + if let asset = viewModel.workbench.animationAssetDocument { + EditorAnimationAssetPanel(workspace: viewModel.workbench.animationWorkspace(for: asset), service: viewModel.workbench.animationService) + .id(asset.id) + } else if let target = viewModel.skeletalAnimationTarget { HStack(spacing: 8) { cycleButton(title: "Skeletal Graph", identifier: "AdaEditor.Animator.GraphMode") { mode = .skeletalGraph } + cycleButton(title: "Bone Keyframes", identifier: "AdaEditor.Animator.BoneMode") { mode = .skeletalKeys } cycleButton(title: "Morph Targets", identifier: "AdaEditor.Animator.MorphMode") { mode = .morphTargets } cycleButton(title: "Keyframes", identifier: "AdaEditor.Animator.KeyframeMode") { mode = .keyframes } Spacer() }.padding(6) if mode == .skeletalGraph { EditorSkeletalAnimatorPanel(viewModel: viewModel, target: target).id(target.documentID + target.entityID) + } else if mode == .skeletalKeys { + EditorSkeletalKeyframePanel(viewModel: viewModel, target: target).id(target.documentID + target.entityID) } else if mode == .morphTargets { EditorMorphTargetsPanel(viewModel: viewModel, target: target).id(target.documentID + target.entityID) } else { diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationTimeline.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationTimeline.swift new file mode 100644 index 000000000..c4b2e90ca --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationTimeline.swift @@ -0,0 +1,252 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +struct EditorAnimationTimeline: View { + let workspace: EditorAnimationAssetWorkspace + private var model: EditorAnimationTimelineModel { workspace.timeline } + @Environment(\.theme) private var theme + @State private var hoverHelp: String? + @State private var seekDraft: String? + static let labelsWidth: Float = 190 + static let rulerHeight: Float = 26 + static let rowHeight: Float = 25 + + var body: some View { + VStack(spacing: 3) { + transport + filters + if model.hasUnkeyedPose { + HStack { + Text(L10n.tr("Unkeyed pose")).font(.system(size: 11)) + action("Insert Key") { model.resolveUnkeyedPose(key: true) } + action("Discard") { model.resolveUnkeyedPose(key: false) } + action("Cancel") { model.resolveUnkeyedPose(key: false, cancel: true) } + } + } + GeometryReader { geometry in + if workspace.mode == .curves { + EditorAnimationCurves(model: model).frame(width: geometry.size.width, height: geometry.size.height) + } else { + let rows = model.rows + let first = max(0, min(rows.count, Int(model.verticalOffset / Self.rowHeight))) + let end = min(rows.count, first + max(1, Int(geometry.size.height / Self.rowHeight) + 2)) + ZStack(anchor: .topLeading) { + RectangleShape().fill(theme.editorColors.background) + Canvas { context, size in draw(rows: rows, first: first, end: end, context: &context, size: size) } + EditorAnimationTimelineRowsLayout(first: first, scroll: model.verticalOffset) { + ForEach(Array(rows[first..= 950 { editControls } + Spacer(minLength: 0) + } + if geometry.size.width < 950 { HStack(spacing: 5) { editControls; Spacer(minLength: 0) } } + } + }.frame(height: model.viewportSize.width >= 950 ? 30 : 62) + } + + @ViewBuilder private var editControls: some View { + Text(L10n.tr("Duration")).font(.system(size: 10)) + field("Duration", text: Binding(get: { model.durationText }, set: { model.durationText = $0 }), width: 75) + action("Apply") { model.perform { + guard let value = Double(model.durationText), value.isFinite else { throw numericInputError() } + try model.configureDuration(value) + } }.disabled(!model.isEditable) + action(model.clip?.repeats == true ? "Loop: On" : "Loop: Off", selected: model.clip?.repeats == true) { + model.perform { if let clip = model.clip { try model.apply([.init(kind: .configureClip, clipID: clip.id, repeats: !clip.repeats)]) } } + }.disabled(!model.isEditable) + action("Insert Key", prominent: true) { model.perform { try model.insertKeys() } }.disabled(!model.isEditable || model.selectedNodes.isEmpty) + action("Undo") { history(redo: false) }.disabled(workspace.library?.canUndo != true) + action("Redo") { history(redo: true) }.disabled(workspace.library?.canRedo != true) + action("Fit") { fit(size: model.viewportSize) } + action("Snap", selected: model.snap > 0) { model.snap = model.snap > 0 ? 0 : 1 / 30 } + } + + private var clipIDs: [String] { (workspace.sourceLibrary?.clips.map(\.id) ?? []) + (workspace.library?.library.clips.map(\.id) ?? []) } + private var clipLabels: [String: String] { + var result: [String: String] = [:] + for clip in workspace.sourceLibrary?.clips ?? [] { result[clip.id] = "◈ " + clip.name } + for clip in workspace.library?.library.clips ?? [] { result[clip.id] = clip.name } + return result + } + + private func fit(size: Size) { + model.viewportSize = size + model.timeOffset = 0 + model.pixelsPerSecond = max(1, (size.width - Self.labelsWidth - 20) / Float(max(0.01, model.duration))) + model.durationText = String(format: "%.5g", model.duration) + } + + private func ruler(width: Float) -> some View { + ZStack(anchor: .topLeading) { + RectangleShape().fill(theme.editorColors.surface) + ForEach(0..<10, id: \.self) { tick in + Text(String(format: "%.2f", model.timeOffset + Double(tick) * Double(max(0, width - Self.labelsWidth)) / 10 / Double(max(1, model.pixelsPerSecond)))) + .font(.system(size: 10)).foregroundColor(theme.editorColors.muted) + .offset(x: Float(tick) * max(0, width - Self.labelsWidth) / 10 + 4, y: 6) + } + }.frame(width: max(0, width - Self.labelsWidth), height: Self.rulerHeight, alignment: .topLeading) + .allowsHitTesting(false).accessibilityIdentifier("AdaEditor.AnimationTimeline.Ruler") + } + + private func draw(rows: [EditorAnimationTimelineModel.Row], first: Int, end: Int, context: inout UIGraphicsContext, size: Size) { + for index in first..= Self.labelsWidth, x <= size.width { + context.drawRect(Rect(x: x - 3, y: y + 9, width: 6, height: 6), color: selected ? .yellow : theme.editorColors.purple) + } + } + } + let endX = Self.labelsWidth + Float(model.duration - model.timeOffset) * model.pixelsPerSecond + if endX >= Self.labelsWidth, endX < size.width { + context.drawRect(Rect(x: endX, y: 0, width: 1, height: size.height), color: theme.editorColors.muted) + } + if let rect = model.marquee { context.drawRect(rect, color: theme.editorColors.blue.opacity(0.18)) } + } + + private func history(redo: Bool) { + guard let document = workspace.library else { return } + model.perform { + if redo { try document.redo(expectedRevision: document.revision) } else { try document.undo(expectedRevision: document.revision) } + workspace.refreshLibrary() + } + } + + private func togglePlayback() { + guard !model.hasUnkeyedPose else { model.error = L10n.tr("Insert or discard the unkeyed pose first."); return } + if workspace.preview.playsAnimation { model.timeText = String(format: "%.4f", model.time) } + workspace.preview.playsAnimation.toggle() + } + + private func field(_ title: String, text: Binding, width: Float) -> some View { + TextField(L10n.tr(title), text: text).textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors, height: 25)) + .frame(width: width, height: 25) + } + + private func icon(_ glyph: String, title: String, id: String, action: @escaping () -> Void) -> some View { + Button(action: action) { Text(glyph).font(.system(size: 12)).frame(width: 26, height: 26) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, selected: id == "Play" && workspace.preview.playsAnimation, horizontalPadding: 0)) + .accessibilityIdentifier("AdaEditor.AnimationTimeline.\(id)") + .onHover { hoverHelp = $0 ? L10n.tr(title) : nil } + } + + private func action(_ title: String, prominent: Bool = false, selected: Bool = false, _ perform: @escaping () -> Void) -> some View { + Button(action: perform) { Text(L10n.tr(title)).font(.system(size: 10)) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, prominent: prominent, selected: selected)) + } + + private func numericInputError() -> AnimationAuthoringError { + .init(.invalidKey, itemID: "time", message: L10n.tr("Enter a finite time in seconds.")) + } +} + +private struct EditorAnimationTimelineRowsLayout: Layout { + let first: Int + let scroll: Float + func sizeThatFits(_ proposal: ProposedViewSize, subviews _: Subviews, cache _: inout ()) -> Size { proposal.replacingUnspecifiedDimensions() } + func placeSubviews(in bounds: Rect, proposal _: ProposedViewSize, subviews: Subviews, cache _: inout ()) { + var childProposal = ProposedViewSize.zero + childProposal.width = EditorAnimationTimeline.labelsWidth + childProposal.height = EditorAnimationTimeline.rowHeight + for (index, child) in subviews.enumerated() { + child.place(at: Point(bounds.minX, bounds.minY + EditorAnimationTimeline.rulerHeight + Float(first + index) * EditorAnimationTimeline.rowHeight - scroll), + anchor: .topLeading, proposal: childProposal) + } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationTimelineInput.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationTimelineInput.swift new file mode 100644 index 000000000..8802f8350 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAnimationTimelineInput.swift @@ -0,0 +1,132 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +struct EditorAnimationTimelineInput: UIViewRepresentable { + let model: EditorAnimationTimelineModel + func makeUIView(in _: Context) -> EditorAnimationTimelineInputView { + let view = EditorAnimationTimelineInputView(frame: .zero) + view.backgroundColor = .clear + return view + } + func updateUIView(_ view: EditorAnimationTimelineInputView, in _: Context) { + view.model = model + model.onCancelPointer = { [weak view] in view?.abortPointer() } + } +} + +final class EditorAnimationTimelineInputView: UIView { + weak var model: EditorAnimationTimelineModel? + private var start: Point? + private var operation = "" + private var retainedSelection: Set = [] + private var originalSelection: Set = [] + private var epoch: UInt64 = 0 + + func abortPointer() { + if operation == "box" { model?.selectedKeys = originalSelection } + operation = ""; start = nil + } + + override func onMouseEvent(_ event: MouseEvent) { + guard let model else { return } + let point = event.mousePosition + let labels = EditorAnimationTimeline.labelsWidth + let ruler = EditorAnimationTimeline.rulerHeight + let height = EditorAnimationTimeline.rowHeight + if event.button == .scrollWheel { + if event.modifierKeys.contains(.control) || event.modifierKeys.contains(.main) { + let anchored = model.timeOffset + Double(max(0, point.x - labels) / model.pixelsPerSecond) + model.pixelsPerSecond = max(8, min(20_000, model.pixelsPerSecond * pow(1.1, event.scrollDelta.y))) + model.timeOffset = max(0, anchored - Double(max(0, point.x - labels) / model.pixelsPerSecond)) + } else if event.modifierKeys.contains(.shift) { + model.timeOffset = max(0, model.timeOffset - Double(event.scrollDelta.y * 20 / model.pixelsPerSecond)) + } else { + model.verticalOffset = max(0, min(max(0, Float(model.rows.count) * height - frame.height + ruler), model.verticalOffset - event.scrollDelta.y * height)) + } + return + } + guard event.button == .left || event.phase == .cancelled else { return } + if event.phase != .began, epoch != model.pointerEpoch { + operation = ""; start = nil + return + } + let rows = model.rows + switch event.phase { + case .began: + epoch = model.pointerEpoch + start = point + retainedSelection = model.selectedKeys + originalSelection = model.selectedKeys + if point.y < ruler, point.x >= labels { operation = "seek"; model.seek(time(at: point.x)); return } + let index = Int((point.y - ruler + model.verticalOffset) / height) + guard rows.indices.contains(index), point.y >= ruler else { operation = ""; return } + let row = rows[index] + let additive = event.modifierKeys.contains(.control) || event.modifierKeys.contains(.main) + if point.x < labels { + if point.x < 22, row.channel == nil { + if !model.expandedChannels.insert(row.nodeID).inserted { model.expandedChannels.remove(row.nodeID) } + } else { + model.selectNode(row.nodeID, additive: additive, range: event.modifierKeys.contains(.shift)) + model.selectedTrackID = row.track?.id + model.selectedChannel = row.channel + } + operation = "label" + } else if let (reference, key) = model.keys(in: row, timeRange: time(at: point.x - 7)...time(at: point.x + 7)) + .min(by: { abs(x($0.1.time) - point.x) < abs(x($1.1.time) - point.x) }), abs(x(key.time) - point.x) < 7 { + let range = event.modifierKeys.contains(.shift) + if !model.selectedKeys.contains(reference) || additive || range { model.selectKey(reference, additive: additive, range: range) } + model.beginKeyDrag() + operation = "keys" + } else { + operation = "box" + if !additive { retainedSelection = []; model.selectedKeys = [] } + } + case .changed: + change(point) + case .ended: + change(point) + if operation == "keys" { model.endKeyDrag() } + if operation == "box", let start, abs(start.x - point.x) + abs(start.y - point.y) < 4 { model.seek(time(at: point.x)) } + model.marquee = nil + operation = ""; start = nil + case .cancelled: + model.endKeyDrag(cancel: true) + model.marquee = nil + abortPointer() + } + } + + private func change(_ point: Point) { + guard let model, let start else { return } + switch operation { + case "seek": model.seek(time(at: point.x)) + case "keys": + let delta = Double((point.x - start.x) / model.pixelsPerSecond) + model.dragDelta = model.snap > 0 ? (delta / model.snap).rounded() * model.snap : delta + case "box": + let left = max(EditorAnimationTimeline.labelsWidth, min(start.x, point.x)) + let top = max(EditorAnimationTimeline.rulerHeight, min(start.y, point.y)) + let right = min(frame.width, max(EditorAnimationTimeline.labelsWidth, max(start.x, point.x))) + let bottom = min(frame.height, max(EditorAnimationTimeline.rulerHeight, max(start.y, point.y))) + let rect = Rect(x: left, y: top, width: max(0, right - left), height: max(0, bottom - top)) + model.marquee = rect + var selection = retainedSelection + for (index, row) in model.rows.enumerated() { + let y = EditorAnimationTimeline.rulerHeight + (Float(index) + 0.5) * EditorAnimationTimeline.rowHeight - model.verticalOffset + guard y >= rect.minY, y <= rect.maxY else { continue } + for (ref, _) in model.keys(in: row, timeRange: time(at: rect.minX)...time(at: rect.maxX)) { selection.insert(ref) } + } + model.selectedKeys = selection + default: break + } + } + + private func time(at x: Float) -> Double { + guard let model else { return 0 } + return model.timeOffset + Double((x - EditorAnimationTimeline.labelsWidth) / max(1, model.pixelsPerSecond)) + } + private func x(_ time: Double) -> Float { + guard let model else { return 0 } + return EditorAnimationTimeline.labelsWidth + Float(time - model.timeOffset) * model.pixelsPerSecond + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorCenterWorkbench.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorCenterWorkbench.swift index 7021bdb41..d593aebf7 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorCenterWorkbench.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorCenterWorkbench.swift @@ -373,7 +373,7 @@ extension EditorCenterWorkbench { switch document.kind { case .shaderGraph: return EditorProjectFileAppearance.shaderGraph.symbol - case .particleEffect, .spriteAnimation, .atlas, + case .particleEffect, .spriteAnimation, .animationLibrary, .animationModifiers, .atlas, .tileSource, .tileMap: return "\u{E8F1}" @@ -414,7 +414,7 @@ extension EditorCenterWorkbench { switch document.kind { case .shaderGraph: return EditorProjectFileAppearance.shaderGraph.color(in: theme.editorColors) - case .particleEffect, .spriteAnimation, .atlas, + case .particleEffect, .spriteAnimation, .animationLibrary, .animationModifiers, .atlas, .tileSource, .tileMap: return theme.editorColors.blue @@ -480,6 +480,8 @@ extension EditorCenterWorkbench { @ViewBuilder private func assetPreview(document: EditorAssetDocument) -> some View { switch document.kind { + case .animationLibrary, .animationModifiers: + EditorAnimationLibraryAssetEditor(document: document, workbench: viewModel).id(document.id) case .shaderGraph: EditorShaderGraphEditor(model: viewModel.shaderGraphModel(for: document), modelDocuments: viewModel.openDocuments.compactMap { if case .asset(let asset) = $0, asset.kind == .model3D { return asset } @@ -494,7 +496,7 @@ extension EditorCenterWorkbench { case .spine2D: EditorSpineAssetPreview(document: document, workbench: viewModel).id(document.id) case .model3D: - EditorModelAssetPreview(document: document, workbench: viewModel, resourceRootURL: sceneResourceRootURL) + EditorModelAssetPreview(document: document, workbench: viewModel) .id(document.id) case .tileSource: EditorTileSourceAssetEditor(document: document, model: viewModel.tileSourceModel(for: document)) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorContextualInspector.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorContextualInspector.swift index cc89fc0ff..b4c0a1313 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorContextualInspector.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorContextualInspector.swift @@ -46,6 +46,9 @@ struct EditorContextualInspector: View { case let .text(document): return AnyView(EditorFileInspector(document: .text(document))) case let .asset(document): + if document.kind == .animationLibrary || document.kind == .animationModifiers || document.kind == .model3D && ["glb", "gltf"].contains(document.fileExtension.lowercased()) { + return AnyView(EditorAnimationInspector(workspace: workbench.animationWorkspace(for: document)).id(document.id)) + } if document.kind == .shaderGraph { return AnyView(EditorShaderGraphEditor( model: workbench.shaderGraphModel(for: document), presentation: .inspector, diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorDocumentHistory.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorDocumentHistory.swift index 6b1673022..f3b4e2fcb 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorDocumentHistory.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorDocumentHistory.swift @@ -39,7 +39,28 @@ extension EditorWorkbenchViewModel { @discardableResult func performDocumentHistory(redo: Bool) -> Bool { + if case let .asset(asset)? = activeDocument, + let workspace = animationWorkspaces[asset.id], workspace.timeline.inspectorMode == "modifiers", + let document = workspace.modifiers, !document.isDiscarded { + guard redo ? document.canRedo : document.canUndo else { return false } + do { + if redo { try document.redo(expectedRevision: document.revision) } else { try document.undo(expectedRevision: document.revision) } + return true + } catch { return false } + } switch activeDocument { + case let .asset(asset) where asset.kind == .animationModifiers: + guard let document = try? animationService?.openModifiers(asset.relativePath), redo ? document.canRedo : document.canUndo else { return false } + do { + if redo { try document.redo(expectedRevision: document.revision) } else { try document.undo(expectedRevision: document.revision) } + return true + } catch { return false } + case let .asset(document) where document.kind == .animationLibrary: + guard let model = try? animationService?.open(document.relativePath), redo ? model.canRedo : model.canUndo else { return false } + do { + if redo { try model.redo(expectedRevision: model.revision) } else { try model.undo(expectedRevision: model.revision) } + return true + } catch { return false } case let .asset(document) where document.kind == .shaderGraph: let model = shaderGraphModel(for: document) guard redo ? model.canRedo : model.canUndo else { return false } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorEnumField.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorEnumField.swift index 2cf3cee90..4b1c32f01 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorEnumField.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorEnumField.swift @@ -4,11 +4,13 @@ struct EditorEnumField: View { let cases: [String] let selection: Binding var accessibilityID = "AdaEditor.Enum.Toggle" + var labels: [String: String] = [:] + var localizesValues = true @Environment(\.theme) private var theme var body: some View { HStack(spacing: 6) { - Text(L10n.tr(selection.wrappedValue)) + Text(label(selection.wrappedValue)) .font(.system(size: 12)) .lineLimit(1) Spacer() @@ -23,11 +25,16 @@ struct EditorEnumField: View { .overlay { RoundedRectangleShape(cornerRadius: 6).stroke(theme.editorColors.border.opacity(0.6), lineWidth: 1) } .contextMenu(opensOnPrimaryAction: true) { ForEach(cases, id: \.self) { value in - ContextMenuOption(L10n.tr(value), isSelected: selection.wrappedValue == value) { + ContextMenuOption(label(value), isSelected: selection.wrappedValue == value) { selection.wrappedValue = value } } } .accessibilityIdentifier(accessibilityID) } + + private func label(_ value: String) -> String { + let title = labels[value] ?? value + return localizesValues ? L10n.tr(title) : title + } } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorModelAssetPreview.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorModelAssetPreview.swift index b6fe595e3..0677f13d4 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorModelAssetPreview.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorModelAssetPreview.swift @@ -12,9 +12,18 @@ final class EditorModelAssetPreviewModel { var isLoading = true var selectedAnimation = "Rest pose" { didSet { updatePlayback() } } var showsBones = false + var selectedNodeIndices: Set = [] + var playbackSpeed: Double = 1 { didSet { updatePlayback() } } + @ObservationIgnored var bonePress: Point? + @ObservationIgnored var boneMoved = false + @ObservationIgnored var onSelectBone: ((Int, Bool) -> Void)? + @ObservationIgnored var onModifierInput: ((any InputEvent, EditorModelPreviewCamera) -> Bool)? + var selectedPoseModifierID: String? var playsAnimation = true { didSet { updatePlayback() } } var animationGraph: AnimationGraph? { didSet { updatePlayback() } } + var animationRepeats = true { didSet { updatePlayback() } } var graphEventLog: [String] = [] + var modifierDiagnostics: [AnimationPoseModifierDiagnostic] = [] /// Morph target weights by name that the preview shows; every target the entity has is set to its entry, so a target removed from /// the map needs its default put back by the caller. var morphWeights: [String: Float] = [:] { didSet { applyMorphWeights() } } @@ -23,28 +32,77 @@ final class EditorModelAssetPreviewModel { @ObservationIgnored private weak var world: World? @ObservationIgnored private var animationRoot: Entity.ID? @ObservationIgnored private var voxelRoot: Entity.ID? + @ObservationIgnored private var authoredClips: [SkeletalAnimationClip] = [] + @ObservationIgnored private var libraryGeneration: UInt64 = 0 + @ObservationIgnored private var appliedLibraryGeneration: UInt64? + @ObservationIgnored private var pendingSeekTime: Double? + @ObservationIgnored private var modifierStack: AnimationModifierStack? + @ObservationIgnored private var modifierNodeIndices: [String: Int] = [:] + + func modifierNodeIndex(_ id: String) -> Int? { modifierNodeIndices[id] } + + func installPoseModifiers(_ stack: AnimationModifierStack?) throws { + if let stack { + try stack.validate() + guard let rig = asset?.animationRig, stack.binding.isCompatible(with: rig) else { throw AnimationPoseModifierError.incompatibleRig } + } + modifierStack = stack + modifierNodeIndices = stack.map { Dictionary(uniqueKeysWithValues: $0.binding.nodes.enumerated().map { ($0.element.id, $0.offset) }) } ?? [:] + updatePlayback() + } + + /// Compiles before publication. A bad library leaves the previous rendered player intact. + func installAnimationLibraries(_ libraries: [AnimationLibrary]) throws { + guard let rig = asset?.animationRig else { + if libraries.isEmpty { return } + throw AssetError.message("Model has no animation rig.") + } + var clips: [SkeletalAnimationClip] = [] + var names = Set(asset?.animationClips.map(\.name) ?? []) + for library in libraries { + for clip in try library.compiledClips(for: rig) { + guard names.insert(clip.name).inserted else { throw AssetError.message("Animation reference collision.") } + clips.append(clip) + } + } + authoredClips = clips + libraryGeneration &+= 1 + updatePlayback() + } + + func capturedAnimationPlayer() -> SkeletalAnimationPlayer? { + guard let world, let animationRoot else { return nil } + return world.getEntityByID(animationRoot)?.components[ModelAnimation3DComponent.self]?.player + } + + var playbackTime: Double { capturedAnimationPlayer()?.time ?? pendingSeekTime ?? 0 } func load(_ document: EditorAssetDocument) { load(path: document.absolutePath) } - func load(path: String?) { + func load(path: String?, resourceRoot: URL? = nil) { loadTask?.cancel() asset = nil normalizationCache = nil world = nil + authoredClips = [] + appliedLibraryGeneration = nil animationRoot = nil voxelRoot = nil + pendingSeekTime = nil error = nil isLoading = true loadTask = Task { [weak self] in do { guard let path else { throw AssetError.message("Model file is unavailable.") } - let handle = try await AssetsManager.load(ModelAsset3D.self, at: path) + let loaded: ModelAsset3D? + if let resourceRoot { loaded = try await Self.loadAuthoringModel(path: path, resourceRoot: resourceRoot) } + else { loaded = try await AssetsManager.load(ModelAsset3D.self, at: path).asset } let lightingPath = Bundle.editor.resourceURL?.appendingPathComponent("Assets/PreviewLighting/Studio.ibl").path let lighting = try await AssetsManager.load(ImageBasedLighting3D.self, at: lightingPath ?? "Assets/PreviewLighting/Studio.ibl") try Task.checkCancellation() - guard let asset = handle.asset else { throw AssetError.message("Model has not loaded.") } + guard let asset = loaded else { throw AssetError.message("Model has not loaded.") } self?.previewLighting = lighting self?.asset = asset } catch is CancellationError { @@ -58,11 +116,23 @@ final class EditorModelAssetPreviewModel { func cancel() { loadTask?.cancel() } + @concurrent + private static func loadAuthoringModel(path: String, resourceRoot: URL) async throws -> ModelAsset3D { + try Task.checkCancellation() + let url = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL + let model = try await ModelAsset3D(gltfAt: url, + loader: NativeGLTFLoader(resourceRoot: resourceRoot, maximumTotalBytes: 32 * 1024 * 1024)) + try Task.checkCancellation() + return model + } + func bind(to world: World) { self.world = world + appliedLibraryGeneration = nil animationRoot = world.getEntities().first { $0.components[ModelAnimation3DComponent.self] != nil }?.id voxelRoot = world.getEntities().first { $0.components[VoxelAnimationPlayback3DComponent.self] != nil }?.id updatePlayback() + if let time = pendingSeekTime { seekGraph(to: time) } applyMorphWeights() } @@ -98,9 +168,18 @@ final class EditorModelAssetPreviewModel { guard let world, let animationRoot, let root = world.getEntityByID(animationRoot), var component = root.components[ModelAnimation3DComponent.self] else { return } do { + let priorClock = component.player.time + let replacedLibrary = appliedLibraryGeneration != libraryGeneration + if appliedLibraryGeneration != libraryGeneration { + let clock = component.player.time + component.player = try SkeletalAnimationPlayer(rig: component.player.rig, clips: (asset?.animationClips ?? []) + authoredClips) + component.player.seek(to: clock) + } + component.player.repeats = animationRepeats + component.player.speed = playbackSpeed.isFinite ? playbackSpeed : 1 if let animationGraph { if component.player.graph != animationGraph { - let clock = component.player.graph == nil ? 0 : component.player.time + let clock = replacedLibrary ? priorClock : component.player.graph == nil ? 0 : component.player.time try component.player.play(animationGraph, transitionDuration: 0) component.player.seek(to: clock) } @@ -109,26 +188,40 @@ final class EditorModelAssetPreviewModel { component.player = try SkeletalAnimationPlayer(rig: component.player.rig, clips: component.player.clips) } else { if component.player.clipIndex.map({ component.player.clips[$0].name }) != animation { - try component.player.play(animation, transitionDuration: 0) + try component.player.play(animation, transitionDuration: 0, startTime: replacedLibrary ? priorClock : 0) } component.player.isPlaying = playsAnimation } + let modifiers = modifierStack?.modifiers ?? [] + if component.player.poseModifiers != modifiers { + let binding = modifierStack?.binding ?? AnimationRigBinding(rig: component.player.rig, sourceAsset: "") + try component.player.setPoseModifiers(modifiers, binding: binding) + } root.components[ModelAnimation3DComponent.self] = component + modifierDiagnostics = component.player.modifierDiagnostics + appliedLibraryGeneration = libraryGeneration + self.error = nil } catch { self.error = error.localizedDescription } } func seekGraph(to time: Double) { + guard time.isFinite else { return } guard let world, let animationRoot, let root = world.getEntityByID(animationRoot), var component = root.components[ModelAnimation3DComponent.self] else { + pendingSeekTime = time return } + pendingSeekTime = nil component.player.seek(to: time) root.components[ModelAnimation3DComponent.self] = component + modifierDiagnostics = component.player.modifierDiagnostics } func pollGraphEvents(in world: World) { + if let animationRoot, let player = world.getEntityByID(animationRoot)?.components[ModelAnimation3DComponent.self]?.player, + modifierDiagnostics != player.modifierDiagnostics { modifierDiagnostics = player.modifierDiagnostics } guard animationGraph != nil, world.supportsEventDelivery else { return } @@ -196,12 +289,10 @@ final class EditorModelAssetPreviewModel { struct EditorModelAssetPreview: View { let document: EditorAssetDocument let workbench: EditorWorkbenchViewModel - let resourceRootURL: URL? - @State private var model = EditorModelAssetPreviewModel() - @State private var camera = EditorModelPreviewCamera() + private var workspace: EditorAnimationAssetWorkspace { workbench.animationWorkspace(for: document) } + private var model: EditorModelAssetPreviewModel { workspace.preview } + private var camera: EditorModelPreviewCamera { workspace.camera } @State private var showsScenePicker = false - @State private var showsGraph = false - @State private var graphModel = EditorAnimationGraphModel() @Environment(\.theme) private var theme var body: some View { @@ -225,36 +316,14 @@ struct EditorModelAssetPreview: View { } ZStack { RoundedRectangleShape(cornerRadius: 10).fill(Color.fromHex(0x24282F)) - if model.isLoading { - Text(L10n.text("Loading model…")).foregroundColor(theme.editorColors.muted) - } else if let error = model.error { - Text(error).foregroundColor(.red).padding(24) - } else if model.asset != nil { - if showsGraph { - HStack(spacing: 8) { - EditorModelPreviewViewport(model: model, camera: camera).frame(width: max(180, geometry.size.width * 0.35)) - EditorAnimationGraphEditor(model: graphModel) - } - } else { - EditorModelPreviewViewport(model: model, camera: camera) - } - } + EditorAnimationAssetViewport(workspace: workspace) } - .frame(height: max(180, geometry.size.height - (showsScenePicker ? 210 : 110))) - animationControls + .frame(height: max(0, geometry.size.height - (showsScenePicker ? 168 : 56))) } } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .accessibilityIdentifier("AdaEditor.ModelPreview") - .onAppear { model.load(document) } - .onDisappear { model.cancel() } - .onChange(of: model.asset.map(ObjectIdentifier.init)) { _, _ in - if let asset = model.asset { graphModel.configure(asset: asset) } - } - .onChange(of: graphModel.graph) { _, _ in - if showsGraph, let graph = graphModel.validGraph { model.animationGraph = graph } - } } private var header: some View { @@ -273,47 +342,15 @@ struct EditorModelAssetPreview: View { } Spacer() Button(action: { camera.reset() }) { - Text(L10n.text("Frame model")).font(.system(size: 12)).foregroundColor(theme.editorColors.text).padding(8) + Text(L10n.text("Frame model")).font(.system(size: 12)) } - .buttonStyle(DefaultButtonStyle()) + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors)) .accessibilityIdentifier("AdaEditor.ModelPreview.Frame") addToScene } .frame(height: 44) } - private var animationControls: some View { - HStack(spacing: 8) { - Text(L10n.text("Animation")).font(.system(size: 11)).foregroundColor(theme.editorColors.muted) - Button(action: { - showsGraph.toggle() - model.animationGraph = showsGraph ? graphModel.validGraph : nil - }) { Text(showsGraph ? L10n.text("Clips") : L10n.text("Animation Graph")).font(.system(size: 11)) } - .buttonStyle(DefaultButtonStyle()) - .disabled(model.asset?.animationClips.isEmpty != false) - .accessibilityIdentifier("AdaEditor.ModelPreview.Graph") - EditorEnumField( - cases: model.animationNames, - selection: Binding(get: { model.selectedAnimation }, set: { model.selectedAnimation = $0 }), - accessibilityID: "AdaEditor.ModelPreview.Animation" - ) - .frame(maxWidth: 260) - Button(action: { model.playsAnimation.toggle() }) { - Text(model.playsAnimation ? L10n.text("Pause") : L10n.text("Play")).font(.system(size: 11)) - } - .buttonStyle(DefaultButtonStyle()) - .disabled(model.animation.isEmpty && model.animationGraph == nil && !model.hasVoxelAnimation) - .accessibilityIdentifier("AdaEditor.ModelPreview.PlayAnimation") - Button(action: { model.showsBones.toggle() }) { - Text(model.showsBones ? "Hide bones" : "Show bones").font(.system(size: 11)) - } - .buttonStyle(DefaultButtonStyle()) - .disabled(model.asset?.skins.isEmpty != false) - .accessibilityIdentifier("AdaEditor.ModelPreview.Bones") - Spacer() - } - } - private var targetScenes: [EditorSceneDocument] { workbench.openDocuments.compactMap { document in guard case let .scene(scene) = document, !scene.isReadOnly, scene.sceneModel != nil else { @@ -332,24 +369,36 @@ struct EditorModelAssetPreview: View { } }) { Text(targetScenes.count == 1 ? L10n.text("Add to Scene") : L10n.text("Add to Scene…")) - .font(.system(size: 12)).foregroundColor(theme.editorColors.blue) - .padding(8) - .background(RoundedRectangleShape(cornerRadius: 6).fill(theme.editorColors.blue.opacity(0.12))) + .font(.system(size: 12)) } - .buttonStyle(DefaultButtonStyle()) - .disabled(model.asset == nil || targetScenes.isEmpty || document.assetReference == nil || showsGraph && graphModel.validGraph == nil) + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, prominent: true)) + .disabled(model.asset == nil || targetScenes.isEmpty || document.assetReference == nil || workspace.library?.isDirty == true + || workspace.mode == .graph && workspace.graph.validGraph == nil) .accessibilityIdentifier("AdaEditor.ModelPreview.AddToScene") } private func place(in sceneID: String) { - workbench.addModelAsset(document, to: sceneID, animation: model.animation, autoplay: model.playsAnimation, animationGraph: showsGraph ? graphModel.validGraph : nil) + if ["glb", "gltf"].contains(document.fileExtension.lowercased()) { + let animation = model.animation + let autoplay = model.playsAnimation + let repeats = model.animationRepeats + let graph = workspace.mode == .graph ? workspace.graph.validGraph : nil + let library = workspace.library + workspace.run { + _ = try await workbench.addAnimatedModelAsset(document, to: sceneID, animation: animation, + autoplay: autoplay, repeats: repeats, graph: graph, library: library) + } + } else { + workbench.addModelAsset(document, to: sceneID, animation: model.animation, autoplay: model.playsAnimation) + } showsScenePicker = false } } extension EditorWorkbenchViewModel { @discardableResult - func addModelAsset(_ asset: EditorAssetDocument, to documentID: String, animation: String = "", autoplay: Bool = false, animationGraph: AnimationGraph? = nil) -> Bool { + func addModelAsset(_ asset: EditorAssetDocument, to documentID: String, animation: String = "", autoplay: Bool = false, + animationGraph: AnimationGraph? = nil, animationLibraries: [String] = [], repeats: Bool = true) -> Bool { guard let reference = asset.assetReference, asset.kind == .model3D, let document = sceneDocument(id: documentID), !document.isReadOnly, document.sceneModel != nil else { return false @@ -364,9 +413,12 @@ extension EditorWorkbenchViewModel { } scene.entities[index].name = URL(fileURLWithPath: asset.title).deletingPathExtension().lastPathComponent scene.entities[index].components[EditorBuiltInComponentType.model3DSource] = [ - "source": .string(reference), "animation": .string(animation), "autoplay": .bool(autoplay), "repeats": .bool(true), + "source": .string(reference), "animation": .string(animation), "autoplay": .bool(autoplay), "repeats": .bool(repeats), ] scene.entities[index].components[EditorBuiltInComponentType.model3DSource]?["animationGraph"] = graphValue + if !animationLibraries.isEmpty { + scene.entities[index].components[EditorBuiltInComponentType.model3DSource]?["animationLibraries"] = .array(animationLibraries.map(EditorSceneValue.string)) + } } return true } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorModelPreviewViewport.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorModelPreviewViewport.swift index 086716d80..08a45cb7a 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorModelPreviewViewport.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorModelPreviewViewport.swift @@ -1,24 +1,31 @@ @_spi(AdaEngine) import AdaEngine import Foundation import Math +import Observation /// Camera state for an asset viewer. Navigation never changes the authored model transform. -@MainActor +@Observable @MainActor final class EditorModelPreviewCamera { static let fieldOfView = Angle.degrees(35) - private weak var world: World? - private var cameraID: Entity.ID? - private var previousPointer: Point? - private var activeButton: MouseButton? - private var pressedKeys: Set = [] - private var pinchScale: Float? - private var lighting: AssetHandle? - private(set) var viewportSize = Size.zero - private(set) var halfExtents = Vector3(1.5) - private(set) var yaw: Float = -0.22 - private(set) var pitch: Float = 0.08 - private(set) var zoom: Float = 1 - private(set) var target = Vector3.zero + @ObservationIgnored private weak var world: World? + @ObservationIgnored private var cameraID: Entity.ID? + @ObservationIgnored private var previousPointer: Point? + @ObservationIgnored private var activeButton: MouseButton? + @ObservationIgnored private var pressedKeys: Set = [] + @ObservationIgnored private var pinchScale: Float? + @ObservationIgnored private var lighting: AssetHandle? + @ObservationIgnored private var selectionRadius: Float? + @ObservationIgnored private(set) var viewportSize = Size.zero + @ObservationIgnored private(set) var halfExtents = Vector3(1.5) + @ObservationIgnored private(set) var yaw: Float = -0.22 + @ObservationIgnored private(set) var pitch: Float = 0.08 + @ObservationIgnored private(set) var zoom: Float = 1 + @ObservationIgnored private(set) var target = Vector3.zero + var isOrthographic = false { didSet { applyCamera() } } + var backgroundStyle = "dark" { didSet { applyCamera() } } + var lightingIntensity: Float = 1 { didSet { applyCamera() } } + var showsGrid = true + var showsAxes = true func attach(to world: World, bounds: AABB, lighting: AssetHandle? = nil) { endNavigation() @@ -40,6 +47,7 @@ final class EditorModelPreviewCamera { pitch = 0.08 zoom = 1 target = .zero + selectionRadius = nil endNavigation() applyCamera() } @@ -55,7 +63,7 @@ final class EditorModelPreviewCamera { var distance: Float { let aspect = max(0.1, viewportSize.width / max(1, viewportSize.height)) // Fit a sphere to keep tall and wide models inside the frame while orbiting. - let radius = max(0.1, halfExtents.length) + let radius = max(0.1, selectionRadius ?? halfExtents.length) let verticalHalfAngle = Self.fieldOfView.radians * 0.5 let horizontalHalfAngle = Float(Foundation.atan(Foundation.tan(Double(verticalHalfAngle)) * Double(aspect))) return radius / Math.sin(min(verticalHalfAngle, horizontalHalfAngle)) * 1.12 * zoom @@ -70,6 +78,50 @@ final class EditorModelPreviewCamera { return Transform(matrix: Transform3D(columns: [Vector4(right, 0), Vector4(up, 0), Vector4(front, 0), Vector4(eye, 1)])) } + func frameSelection(_ points: [Vector3]) { + guard let first = points.first else { return } + var lower = first + var upper = first + for point in points { + lower = Vector3(min(lower.x, point.x), min(lower.y, point.y), min(lower.z, point.z)) + upper = Vector3(max(upper.x, point.x), max(upper.y, point.y), max(upper.z, point.z)) + } + target = (lower + upper) * 0.5 + selectionRadius = max(0.1, (upper - lower).length * 0.65) + zoom = 1 + applyCamera() + } + + func project(_ position: Vector3) -> Vector2? { + let clip = makeProjection().cameraProjection.makeClipView() * cameraTransform().matrix.inverse * Vector4(position, 1) + guard clip.w > 0.000001, clip.z / clip.w >= 0, clip.z / clip.w <= 1 else { return nil } + return Vector2((clip.x / clip.w + 1) * viewportSize.width * 0.5, (1 - clip.y / clip.w) * viewportSize.height * 0.5) + } + + /// Projects the pointer onto a camera-facing plane through a world-space handle. + func unproject(_ point: Point, atDepthOf position: Vector3) -> Vector3? { + guard viewportSize.width > 0, viewportSize.height > 0 else { return nil } + let matrix = makeProjection().cameraProjection.makeClipView() * cameraTransform().matrix.inverse + let depth = matrix * Vector4(position, 1) + guard depth.w > 1e-6 else { return nil } + let clip = Vector4((point.x / viewportSize.width * 2 - 1) * depth.w, + (1 - point.y / viewportSize.height * 2) * depth.w, depth.z, depth.w) + let world = matrix.inverse * clip + guard world.w.isFinite, abs(world.w) > 1e-6 else { return nil } + let value = world.xyz / world.w + return value.x.isFinite && value.y.isFinite && value.z.isFinite ? value : nil + } + + private func makeProjection() -> Projection { + if isOrthographic { + let height = 2 * distance * Math.tanf(Self.fieldOfView.radians * 0.5) + return .orthographic(.init(near: 0.01, far: max(100, distance + halfExtents.length * 4), + scale: max(0.1, viewportSize.height / max(0.01, height)), viewportSize: viewportSize)) + } + return .perspective(.init(near: 0.01, far: max(100, cameraTransform().position.length + halfExtents.length * 4), + fieldOfView: Self.fieldOfView, aspectRation: max(0.1, viewportSize.width / max(1, viewportSize.height)))) + } + func orbit(by delta: Point) { yaw -= delta.x * 0.008 pitch = max(-1.25, min(1.25, pitch + delta.y * 0.008)) @@ -171,21 +223,22 @@ final class EditorModelPreviewCamera { cameraID = world.getEntities().first { $0.name == "SceneView_Camera" && $0.components[Camera.self] != nil }?.id } guard let cameraID, let entity = world.getEntityByID(cameraID), var camera = entity.components[Camera.self] else { return } - camera.projection = .perspective(PerspectiveProjection( - near: 0.01, - far: max(100, cameraTransform().position.length + halfExtents.length * 4), - fieldOfView: Self.fieldOfView, - aspectRation: max(0.1, viewportSize.width / max(1, viewportSize.height)) - )) - camera.backgroundColor = Color.fromHex(0x24282F) + camera.projection = makeProjection() + camera.backgroundColor = Color.fromHex(backgroundStyle == "light" ? 0xD9DDE2 : backgroundStyle == "gray" ? 0x62656B : 0x24282F) entity.components[Camera.self] = camera entity.components[Transform.self] = cameraTransform() entity.components[CameraRenderGraph.self] = CameraRenderGraph(subgraphLabel: .main3D, inputSlot: "view") entity.components[Environment3D.self] = Environment3D( skybox: Skybox3D(isEnabled: false), screenSpaceReflection: ScreenSpaceReflection(isEnabled: false), - imageBasedLighting: lighting.map { ImageBasedLightingSettings(asset: $0) } + imageBasedLighting: lighting.map { ImageBasedLightingSettings(asset: $0, intensity: max(0, min(8, lightingIntensity))) } ) + for light in world.getEntities() { + guard var component = light.components[DirectionalLightComponent.self] else { continue } + let base: Float = light.name == "Preview key light" ? 2.8 : 2.2 + component.intensity = base * max(0, min(8, lightingIntensity)) + light.components[DirectionalLightComponent.self] = component + } } } @@ -237,19 +290,30 @@ struct EditorModelPreviewViewport: View { }, updateContent: { world, deltaTime in if let input = world.getResource(Input.self) { - for event in input.getInputEvents() { camera.handleInput(event) } + for event in input.getInputEvents() { + if model.onModifierInput?(event, camera) == true { continue } + camera.handleInput(event) + model.handleBoneSelection(event, camera: camera) + } } camera.update(deltaTime: Float(deltaTime)) model.pollGraphEvents(in: world) world.getResource(EditorSkeleton3DOverlay.self)?.isEnabled = model.showsBones + world.getResource(EditorSkeleton3DOverlay.self)?.selectedNodes = model.selectedNodeIndices + world.getResource(EditorSkeleton3DOverlay.self)?.showsGrid = camera.showsGrid + world.getResource(EditorSkeleton3DOverlay.self)?.showsAxes = camera.showsAxes + world.getResource(EditorSkeleton3DOverlay.self)?.gridHeight = model.normalizedBounds.min.y + world.getResource(EditorSkeleton3DOverlay.self)?.modifierHandles = model.modifierHandleSegments() } ) .frame(width: geometry.size.width, height: geometry.size.height) - Text(L10n.text("Drag: orbit · Middle drag: pan · Scroll: zoom\nRight drag: look · WASD: move · Q/E: down/up · Shift: faster")) + if geometry.size.width >= 360, geometry.size.height >= 240 { + Text(L10n.text("Drag: orbit · Middle drag: pan · Scroll: zoom\nRight drag: look · WASD: move · Q/E: down/up · Shift: faster")) .font(.system(size: 11)) .foregroundColor(theme.editorColors.muted) .padding(16) .allowsHitTesting(false) + } } .frame(width: geometry.size.width, height: geometry.size.height) .mask(RoundedRectangleShape(cornerRadius: 10)) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorNodeCanvasInteraction.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorNodeCanvasInteraction.swift index d2986aaf6..6aff2418f 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorNodeCanvasInteraction.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorNodeCanvasInteraction.swift @@ -59,7 +59,7 @@ import Observation switch operation { case .nodes(let positions): let delta = (point - origin) / geometry.zoom - if delta.squaredLength > 0 { + if didMove || delta.squaredLength > 0 { if !didMove { begin() didMove = true diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorProjectFileAppearance.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorProjectFileAppearance.swift index 33680ceab..bfc343928 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorProjectFileAppearance.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorProjectFileAppearance.swift @@ -17,7 +17,7 @@ enum EditorProjectFileAppearance: Equatable { case "tilemap": return .tileMap case "atlas": return .atlas case "particles": return .particleEffect - case "spriteanim": return .spriteAnimation + case "spriteanim", "animlib", "animmod": return .spriteAnimation case "ttf", "otf", "woff", "woff2": return .font case "mat": return .material case "mesh", "obj", "gltf", "glb", "fbx", "usdz", "vox": return .mesh diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportView.swift index 2eb929257..9a3795da5 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSceneViewportView.swift @@ -134,8 +134,12 @@ struct EditorSceneViewportView: View { } .frame(width: geometry.size.width, height: geometry.size.height) - viewportSceneOverlay - .frame(width: geometry.size.width, height: geometry.size.height) + // Retained scenes stay prepared behind asset tabs. Their custom drawing + // layers must be removed while inactive; inherited opacity does not hide them. + if isDocumentActive { + viewportSceneOverlay + .frame(width: geometry.size.width, height: geometry.size.height) + } sceneControls(size: geometry.size) statusBar diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSkeletalAnimatorPanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSkeletalAnimatorPanel.swift index ebd517126..77f944fa0 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSkeletalAnimatorPanel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSkeletalAnimatorPanel.swift @@ -31,6 +31,7 @@ struct EditorSkeletalAnimatorPanel: View { } .frame(width: 220) EditorAnimationGraphEditor(model: graphModel) + EditorAnimationGraphEditor(model: graphModel, presentation: .inspector).frame(width: 280) } .frame(maxHeight: .infinity) } @@ -57,7 +58,8 @@ struct EditorSkeletalAnimatorPanel: View { HStack(spacing: 8) { Text(L10n.text("SKELETAL GRAPH")).font(.system(size: 11, weight: .semibold)) action(preview.playsAnimation ? "Pause" : "Play", id: "Play") { preview.playsAnimation.toggle() } - TextField(L10n.text("Seconds"), text: $seekTime).font(.system(size: 11)).frame(width: 55, height: 26) + TextField(L10n.text("Seconds"), text: $seekTime) + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors, height: 26)).frame(width: 70, height: 26) .accessibilityIdentifier("AdaEditor.AnimationGraph.SeekTime") action("Seek", id: "Seek") { if let value = Double(seekTime), value.isFinite { preview.seekGraph(to: value) } @@ -69,22 +71,23 @@ struct EditorSkeletalAnimatorPanel: View { guard let graph = graphModel.validGraph else { return } - if try viewModel.workbench.setAnimationGraph(graph, target: target, autoplay: autoplay) { + if try viewModel.workbench.setAnimationGraph(graph, target: target, autoplay: autoplay, asset: preview.asset) { status = "Applied to scene. Save the scene to keep this graph." } } catch { status = error.localizedDescription } } .disabled(graphModel.validGraph == nil || viewModel.workbench.activeSceneDocument?.isReadOnly == true) action("Use Single Clip", id: "Clear") { - do { _ = try viewModel.workbench.setAnimationGraph(nil, target: target) } catch { status = error.localizedDescription } + do { _ = try viewModel.workbench.setAnimationGraph(nil, target: target, asset: preview.asset) } catch { status = error.localizedDescription } } .disabled(target.source.animationGraph == nil || viewModel.workbench.activeSceneDocument?.isReadOnly == true) } } private func action(_ title: String, id: String, _ action: @escaping () -> Void) -> some View { - Button(action: action) { Text(L10n.tr(title)).font(.system(size: 11)).padding(4) } - .buttonStyle(DefaultButtonStyle()) + Button(action: action) { Text(L10n.tr(title)).font(.system(size: 11)) } + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors, prominent: id == "Apply", + selected: id == "Autoplay" && autoplay || id == "Play" && preview.playsAnimation)) .accessibilityIdentifier("AdaEditor.AnimationGraph.\(id)") } @@ -102,8 +105,25 @@ struct EditorSkeletalAnimatorPanel: View { guard let asset = preview.asset else { return } - graphModel.configure(asset: asset, graph: target.source.animationGraph) - autoplay = target.source.autoplay - if let graph = graphModel.validGraph { preview.animationGraph = graph } + do { + let documents = try viewModel.workbench.animationService?.savedDocuments(references: target.source.animationLibraries, sceneURL: target.sceneURL) ?? [] + let libraries = documents.map(\.library) + try preview.installAnimationLibraries(libraries) + let authored = try libraries.flatMap { library in + guard let rig = asset.animationRig else { throw AssetError.message("Model has no animation rig.") } + return try library.compiledClips(for: rig) + } + var labels: [String: String] = [:] + for document in documents { + let names = Dictionary(grouping: document.library.clips, by: \.name) + for (index, clip) in document.library.clips.enumerated() { + let suffix = names[clip.name, default: []].count > 1 ? " [\(index + 1)]" : "" + labels[document.library.runtimeName(for: clip.id)] = "\(clip.name)\(suffix) · \(document.url.lastPathComponent)" + } + } + graphModel.configure(asset: asset, graph: target.source.animationGraph, authoredClips: authored, clipLabels: labels) + autoplay = target.source.autoplay + if let graph = graphModel.validGraph { preview.animationGraph = graph } + } catch { preview.error = error.localizedDescription } } } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSkeletalKeyframePanel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSkeletalKeyframePanel.swift new file mode 100644 index 000000000..d3240a4f3 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSkeletalKeyframePanel.swift @@ -0,0 +1,113 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +struct EditorSkeletalKeyframePanel: View { + let viewModel: EditorViewModel + let target: EditorSkeletalAnimationTarget + @State private var sourceClips: [String] = [] + @State private var sourceLoadTask: Task? + @State private var library: EditorAnimationLibraryDocument? + @State private var path = "" + @State private var name = "" + @State private var sourceClip = "" + @State private var error = "" + @State private var isWorking = false + @State private var workTask: Task? + @Environment(\.theme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack { + TextField(L10n.text("Animation library path"), text: $path).frame(minWidth: 130).frame(height: 28) + TextField(L10n.text("Clip name"), text: $name).frame(width: 110, height: 28) + Button { cycleSource() } label: { Text(sourceClip.isEmpty ? L10n.tr("Imported clip") : sourceClip).font(.system(size: 11)) } + command("Extract Editable Copy") { try await importClip(fromRest: false) }.disabled(isWorking || sourceClip.isEmpty) + command("New Animation") { try await importClip(fromRest: true) }.disabled(isWorking) + command("Open Library") { + guard let service = viewModel.workbench.animationService else { throw unavailable() } + library = try service.open(path) + }.disabled(isWorking) + if let library { + command("Save and Bind") { + guard let scene = viewModel.workbench.sceneDocument(id: target.documentID), + let clip = library.library.clips.first(where: { $0.id == library.selectedClipID }) ?? library.library.clips.first else { throw unavailable() } + let revision = EditorAgentSceneToolService.revision(for: scene.content) + try library.save(expectedRevision: library.revision) + try await viewModel.workbench.bindAnimationLibrary(library, sceneID: target.documentID, entityID: target.entityID, + expectedSceneRevision: revision, expectedResourceRevision: library.revision, clipID: clip.id) + viewModel.refreshProjectFiles(logsRefresh: false) + }.disabled(isWorking) + } + } + if let library, let service = viewModel.workbench.animationService { + EditorAnimationLibraryEditor(document: library, service: service).id(library.url.path) + } else { + Text(L10n.text("Choose an imported clip to copy, or create an animation from the rig's rest pose.")) + .font(.system(size: 12)).padding(8) + } + if !error.isEmpty { Text(error).foregroundColor(.red).font(.system(size: 11)) } + }.padding(6) + .textFieldStyle(EditorAnimationFieldStyle(colors: theme.editorColors, height: 28)) + .buttonStyle(EditorAnimationButtonStyle(colors: theme.editorColors)) + .foregroundColor(theme.editorColors.text) + .onAppear { load() } + .onDisappear { workTask?.cancel(); sourceLoadTask?.cancel() } + .accessibilityIdentifier("AdaEditor.Animator.BoneKeyframes") + } + + private func command(_ title: String, perform: @escaping () async throws -> Void) -> some View { + Button { + isWorking = true + workTask = Task { @MainActor in + defer { isWorking = false } + do { try await perform(); error = "" } + catch { self.error = L10n.text("Animation edit failed: \(error.localizedDescription)") } + } + } label: { Text(L10n.tr(title)).font(.system(size: 11)).padding(3) } + } + + private func load() { + do { + guard let service = viewModel.workbench.animationService else { throw unavailable() } + let url = try EditorModelResource.resolve(target.source.source, sourceURL: target.sceneURL, resourceRootURL: service.assetsURL) + sourceLoadTask?.cancel() + sourceClips = [] + sourceClip = "" + sourceLoadTask = Task { @MainActor in + do { + let imported = try await service.animationsForModel(String(url.path.dropFirst(service.projectURL.path.count + 1))) + try Task.checkCancellation() + sourceClips = imported.clips.map(\.name) + cycleSource() + } catch is CancellationError { return } + catch { self.error = L10n.text("Animation edit failed: \(error.localizedDescription)") } + } + let assets = String(service.assetsURL.path.dropFirst(service.projectURL.path.count + 1)) + path = assets + "/Animations/" + url.deletingPathExtension().lastPathComponent + ".animlib" + if let first = target.source.animationLibraries.first, + let url = EditorSceneFileLoader.resolveTileMapReference(first, relativeTo: target.sceneURL, + resourceRootURL: service.assetsURL, fileExtension: "animlib") { + path = String(url.path.dropFirst(service.projectURL.path.count + 1)) + library = try service.open(path) + } + } catch { self.error = L10n.text("Animation edit failed: \(error.localizedDescription)") } + } + + private func cycleSource() { + let clips = sourceClips + guard !clips.isEmpty else { sourceClip = ""; return } + sourceClip = clips[((clips.firstIndex(of: sourceClip) ?? -1) + 1) % clips.count] + } + + private func importClip(fromRest: Bool) async throws { + guard let service = viewModel.workbench.animationService else { throw unavailable() } + let url = try EditorModelResource.resolve(target.source.source, sourceURL: target.sceneURL, resourceRootURL: service.assetsURL) + library = try await service.extract(path: path, modelPath: String(url.path.dropFirst(service.projectURL.path.count + 1)), + clipName: fromRest ? nil : sourceClip, name: name.isEmpty ? nil : name, expectedRevision: library?.revision) + library?.selectedClipID = library?.library.clips.last?.id + } + + private func unavailable() -> AnimationAuthoringError { + .init(.missingTarget, itemID: target.entityID, message: "Open an editable project scene with a compatible model.") + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorSkeleton3DOverlay.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorSkeleton3DOverlay.swift index 88c7736f4..0837fbcfa 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorSkeleton3DOverlay.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorSkeleton3DOverlay.swift @@ -5,10 +5,39 @@ import Synchronization /// Preview state shared by UI controls and the post-animation drawing system. final class EditorSkeleton3DOverlay: Resource, Sendable { - private let visibility = Mutex(false) + struct State: Sendable { + var enabled = false + var selected: Set = [] + var grid = false + var axes = false + var height: Float = 0 + var modifierHandles: [Vector3] = [] + } + private let storage = Mutex(State()) + var state: State { storage.withLock { $0 } } var isEnabled: Bool { - get { visibility.withLock { $0 } } - set { visibility.withLock { $0 = newValue } } + get { storage.withLock { $0.enabled } } + set { storage.withLock { $0.enabled = newValue } } + } + var selectedNodes: Set { + get { storage.withLock { $0.selected } } + set { storage.withLock { $0.selected = newValue } } + } + var showsGrid: Bool { + get { storage.withLock { $0.grid } } + set { storage.withLock { $0.grid = newValue } } + } + var showsAxes: Bool { + get { storage.withLock { $0.axes } } + set { storage.withLock { $0.axes = newValue } } + } + var gridHeight: Float { + get { storage.withLock { $0.height } } + set { storage.withLock { $0.height = newValue } } + } + var modifierHandles: [Vector3] { + get { storage.withLock { $0.modifierHandles } } + set { storage.withLock { $0.modifierHandles = newValue } } } /// Skip helper nodes while preserving their transforms in the animated global pose. @@ -47,7 +76,29 @@ struct EditorSkeleton3DSystem { init(world _: World) {} func update(context _: UpdateContext) { - guard overlay.isEnabled else { return } + let settings = overlay.state + if settings.grid { + for step in -20...20 { + let value = Float(step) * 0.25 + debug.line(from: [value, settings.height, -5], to: [value, settings.height, 5], color: Color(0.5, 0.5, 0.5, 0.35), depthTested: true) + debug.line(from: [-5, settings.height, value], to: [5, settings.height, value], color: Color(0.5, 0.5, 0.5, 0.35), depthTested: true) + } + } + if settings.axes { + let origin = Vector3(0, settings.height, 0) + debug.line(from: origin, to: origin + [1, 0, 0], color: .red, depthTested: false) + debug.line(from: origin, to: origin + [0, 1, 0], color: .green, depthTested: false) + debug.line(from: origin, to: origin + [0, 0, 1], color: .blue, depthTested: false) + } + for index in stride(from: 0, to: settings.modifierHandles.count - settings.modifierHandles.count % 2, by: 2) { + let anchor = settings.modifierHandles[index], point = settings.modifierHandles[index + 1] + let color = index == 0 ? Color(0.2, 0.55, 1, 1) : Color(0.8, 0.3, 1, 1) + debug.line(from: anchor, to: point, color: color, depthTested: false) + for axis in [Vector3.right, .up, Vector3(0, 0, 1)] { + debug.line(from: point - axis * 0.05, to: point + axis * 0.05, color: color, depthTested: false) + } + } + guard settings.enabled else { return } models.forEach { root, animation, _ in guard let transform = EditorPicking.worldMatrix(for: root) else { return } var jointSet: Set = [] @@ -63,10 +114,11 @@ struct EditorSkeleton3DSystem { debug.line(from: start, to: end, color: Color(0.15, 1, 0.75, 1), depthTested: false) } let scale = max(0.0001, (transform * Vector4(0.025, 0, 0, 0)).xyz.length) - for joint in joints where pose.indices.contains(joint) { + for joint in jointSet.union(settings.selected).sorted() where pose.indices.contains(joint) { let point = (transform * pose[joint] * Vector4(0, 0, 0, 1)).xyz for axis in [Vector3.right, .up, Vector3(0, 0, 1)] { - debug.line(from: point - axis * scale, to: point + axis * scale, color: .yellow, depthTested: false) + debug.line(from: point - axis * scale, to: point + axis * scale, + color: settings.selected.contains(joint) ? Color(1, 0.4, 0.1, 1) : .yellow, depthTested: false) } } } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorView.swift index 292e3e972..68c6da5b7 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorView.swift @@ -360,6 +360,7 @@ struct EditorWindowContentView: View { viewModel.playerPairingWindow = nil viewModel.stopProjectFileWatching() viewModel.studioTools.close() + viewModel.workbench.animationService?.invalidatePendingWork() viewModel.movieMaker.cancel() EditorNotificationRouter.shared.detach(viewModel) viewModel.debugger.stop() diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift index 008111cd4..ae9c8a6eb 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift @@ -745,7 +745,7 @@ extension EditorViewModel { case .save: saveActiveDocument() case .saveAll: - if workbench.saveAllDocuments() { + if workbench.saveAllDocuments(includeAnimationDrafts: true) { refreshSourceControl() reloadScriptableObjectSupport() } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectConfiguration.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectConfiguration.swift index 066944956..2ebefff01 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectConfiguration.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectConfiguration.swift @@ -14,7 +14,9 @@ extension EditorViewModel { } func scheduleAutosave(documentID: String) { - autosaveTasks[documentID]?.cancel() + autosaveTasks.removeValue(forKey: documentID)?.cancel() + if case let .asset(document)? = workbench.openDocuments.first(where: { $0.id == documentID }), + document.kind == .animationLibrary || document.kind == .animationModifiers { return } let delay = autosaveDelay autosaveTasks[documentID] = Task { [weak self] in do { diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectFiles.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectFiles.swift index d23c18aaf..5f0bc9f58 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectFiles.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectFiles.swift @@ -326,8 +326,14 @@ extension EditorViewModel { sceneDocument.statusMessage = "Reload failed" workbench.replaceSceneDocument(sceneDocument) } - case .asset, - .git: + case let .asset(asset): + if asset.kind == .animationLibrary, !asset.isDirty, let service = workbench.animationService { + do { + let library = try service.open(asset.relativePath) + try service.reload(library, expectedRevision: library.revision) + } catch { appendOutput("Animation reload failed: \(error.localizedDescription)") } + } + case .git: continue } } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift index f88a64cb7..58df9a58e 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift @@ -306,6 +306,8 @@ extension EditorViewModel { if fileExtension == "spriteanim" { return .spriteAnimation } + if fileExtension == "animlib" { return .animationLibrary } + if fileExtension == "animmod" { return .animationModifiers } switch kind { case .image: return .image diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift index 8cd256a33..d8961df5d 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift @@ -331,7 +331,9 @@ final class EditorViewModel { self.workbench.setActiveDocumentWillChangeHandler { [weak self] in self?.saveActiveDocumentIfNeeded() } + self.workbench.onRevealAnimationInspector = { [weak self] in self?.presentSceneInspector() } self.workbench.setActiveDocumentChangedHandler { [weak self] in + self?.revealAssetAnimatorIfNeeded() self?.synchronizeAgentSceneContext() self?.rememberDebugSource() self?.rememberActiveProjectDocument() @@ -347,6 +349,9 @@ final class EditorViewModel { } self.workbench.achievements = EditorAchievementBootstrap.center self.workbench.achievementResourceRoot = projectAssetsURL + if let projectURL, let assetsURL = projectAssetsURL { + self.workbench.configureAnimationAuthoring(projectURL: projectURL, assetsURL: assetsURL) + } self.workbench.achievementAdaScriptProject = savedProject?.build.system.isAdaScript == true libraries.load(for: self) configureDebugger() diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModels.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModels.swift index 73b4abfef..102eece45 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModels.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModels.swift @@ -227,6 +227,8 @@ enum EditorProjectFileKind: Equatable, Sendable { } enum EditorAssetPreviewKind: String, Equatable, Sendable { + case animationLibrary + case animationModifiers case shaderGraph case spine2D case particleEffect diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+AnimationLibrary.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+AnimationLibrary.swift new file mode 100644 index 000000000..ed0d0c064 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+AnimationLibrary.swift @@ -0,0 +1,169 @@ +import AdaEngine +import Foundation + +extension EditorWorkbenchViewModel { + /// Asset Save and scene binding are separate transactions; scene changes use ordinary history. + func bindAnimationLibrary(_ document: EditorAnimationLibraryDocument, sceneID: String, entityID: String, + expectedSceneRevision: String, expectedResourceRevision: String, clipID: String) async throws { + guard let service = animationService, let scene = sceneDocument(id: sceneID), !scene.isReadOnly, + let entity = scene.sceneModel?.entities.first(where: { $0.id == entityID }), + let payload = entity.components[EditorBuiltInComponentType.model3DSource], + let source = payload["source"]?.stringValue, + EditorAgentSceneToolService.revision(for: scene.content) == expectedSceneRevision else { + throw AnimationAuthoringError(.conflict, itemID: entityID, message: "Open editable scene and current scene revision are required for binding.") + } + try document.checkRevision(expectedResourceRevision) + try document.requireSavedSnapshot() + guard let clip = document.library.clips.first(where: { $0.id == clipID }) else { + throw AnimationAuthoringError(.missingClip, itemID: clipID, message: "Clip does not exist.") + } + let reference = "@res://" + String(document.url.path.dropFirst(service.assetsURL.path.count + 1)) + var candidatePayload = payload + var references: [String] = [] + if case let .array(values) = payload["animationLibraries"] { + references = try values.map { value in + guard case let .string(path) = value else { + throw AnimationAuthoringError(.missingTarget, itemID: entityID, message: "Animation library references must be strings.") + } + return path + } + } + let previousDocuments = try service.savedDocuments(references: references, sceneURL: scene.absolutePath.map { URL(fileURLWithPath: $0) }) + if !previousDocuments.contains(where: { $0.url == document.url }) { references.append(reference) } + candidatePayload["animationLibraries"] = .array(references.map(EditorSceneValue.string)) + if payload["animationGraph"] == nil || payload["animationGraph"] == .null { + candidatePayload["animation"] = .string(document.library.runtimeName(for: clip.id)) + candidatePayload["repeats"] = .bool(clip.repeats) + } + guard let candidate = try EditorComponentRegistry.decode(typeName: EditorBuiltInComponentType.model3DSource, payload: candidatePayload) as? Model3DSource else { + throw AnimationAuthoringError(.invalidTrack, itemID: entityID, message: "Invalid Model3DSource binding.") + } + let documents = try service.savedDocuments(references: references, sceneURL: scene.absolutePath.map { URL(fileURLWithPath: $0) }) + let revisions = documents.map(\.revision) + let url = try EditorModelResource.resolve(source, sourceURL: scene.absolutePath.map { URL(fileURLWithPath: $0) }, resourceRootURL: service.assetsURL) + let modelPath = String(url.path.dropFirst(service.projectURL.path.count + 1)) + let imported = try await service.animationsForModel(modelPath) + try Task.checkCancellation() + _ = try candidate.resolvedAnimationClips(importedClips: imported.clips, rig: imported.rig, libraries: documents.map(\.library)) + for (library, revision) in zip(documents, revisions) { + try library.checkRevision(revision) + try library.requireSavedSnapshot() + } + try document.checkRevision(expectedResourceRevision) + try document.requireSavedSnapshot() + guard let current = sceneDocument(id: sceneID), !current.isReadOnly, + EditorAgentSceneToolService.revision(for: current.content) == expectedSceneRevision else { + throw AnimationAuthoringError(.conflict, itemID: sceneID, message: "Scene or animation changed during binding; inspect and retry.") + } + updateSceneModelDocument(id: sceneID, status: "Animation library bound") { model in + guard let index = model.entities.firstIndex(where: { $0.id == entityID }) else { return } + model.entities[index].components[EditorBuiltInComponentType.model3DSource] = candidatePayload + } + } + + /// Placement validates saved resources and the current CPU import before a single scene-history edit. + func addAnimatedModelAsset(_ asset: EditorAssetDocument, to sceneID: String, animation: String, + autoplay: Bool, repeats: Bool, graph: AnimationGraph?, library: EditorAnimationLibraryDocument?) async throws -> Bool { + guard let service = animationService, let reference = asset.assetReference, + let scene = sceneDocument(id: sceneID), !scene.isReadOnly, scene.sceneModel != nil else { return false } + let sceneRevision = EditorAgentSceneToolService.revision(for: scene.content) + let resourceRevision = library?.revision + if let library, let resourceRevision { + try library.checkRevision(resourceRevision) + try library.requireSavedSnapshot() + } + let references = library.map { ["@res://" + String($0.url.path.dropFirst(service.assetsURL.path.count + 1))] } ?? [] + let documents = try service.savedDocuments(references: references, sceneURL: scene.absolutePath.map { URL(fileURLWithPath: $0) }) + let revisions = documents.map(\.revision) + let candidate = Model3DSource(source: reference, animation: animation, autoplay: autoplay, repeats: repeats, + animationGraph: graph, animationLibraries: references) + let imported = try await service.animationsForModel(asset.relativePath) + try Task.checkCancellation() + _ = try candidate.resolvedAnimationClips(importedClips: imported.clips, rig: imported.rig, libraries: documents.map(\.library)) + for (document, revision) in zip(documents, revisions) { + try document.checkRevision(revision) + try document.requireSavedSnapshot() + } + if let library, let resourceRevision { + try library.checkRevision(resourceRevision) + try library.requireSavedSnapshot() + } + guard animationService === service, let current = sceneDocument(id: sceneID), !current.isReadOnly, + EditorAgentSceneToolService.revision(for: current.content) == sceneRevision else { + throw AnimationAuthoringError(.conflict, itemID: sceneID, message: "Scene or animation changed during placement; inspect and retry.") + } + return addModelAsset(asset, to: sceneID, animation: animation, autoplay: autoplay, + animationGraph: graph, animationLibraries: references, repeats: repeats) + } + + + func configureAnimationAuthoring(projectURL: URL, assetsURL: URL) { + for workspace in animationWorkspaces.values { workspace.cancel() } + animationWorkspaces.removeAll() + animationService?.invalidatePendingWork() + let service = EditorAnimationAuthoringService(projectURL: projectURL, assetsURL: assetsURL) + animationService = service + service.onDocumentOpened = { [weak self] document in self?.registerAnimationDocument(document, assetsURL: assetsURL) } + service.onModifierDocumentOpened = { [weak self] document in self?.registerModifierDocument(document, assetsURL: assetsURL) } + service.onModifierDocumentDiscarded = { [weak self] document in + guard let self else { return } + for workspace in self.animationWorkspaces.values where workspace.modifiers === document { + workspace.cancelModifierDrag() + workspace.refreshModifiers() + } + } + service.onDocumentDiscarded = { [weak self] path in self?.discardDocuments(atOrBelow: path) } + } + + private func registerAnimationDocument(_ document: EditorAnimationLibraryDocument, assetsURL: URL) { + let documentID = "asset:\(document.relativePath)" + document.onChange = { [weak self, weak document] in + guard let self, let document, + let index = self.openDocuments.firstIndex(where: { $0.id == documentID }), case .asset(var current) = self.openDocuments[index] else { return } + current.isDirty = document.isDirty + current.errorMessage = document.error + self.openDocuments[index] = .asset(current) + self.onDocumentEdited?(documentID) + } + if !openDocuments.contains(where: { $0.id == documentID }) { + let asset = EditorAssetDocument(id: documentID, title: document.url.lastPathComponent, relativePath: document.relativePath, + absolutePath: document.url.path, assetReference: "@res://" + String(document.url.path.dropFirst(assetsURL.path.count + 1)), + kind: .animationLibrary, fileExtension: "animlib", byteCount: nil, modifiedAt: nil, errorMessage: nil, isDirty: document.isDirty) + // Agent inspection does not replace the user's active scene or selection. + openDocuments.append(.asset(asset)) + } + } + + private func registerModifierDocument(_ document: EditorAnimationModifierDocument, assetsURL: URL) { + let id = "asset:\(document.relativePath)" + document.onChange = { [weak self, weak document] in + guard let self, let document else { return } + if let index = self.openDocuments.firstIndex(where: { $0.id == id }), case var .asset(asset) = self.openDocuments[index] { + asset.isDirty = document.isDirty + self.openDocuments[index] = .asset(asset) + } else if document.isDirty { + self.registerModifierDocument(document, assetsURL: assetsURL) + } + for workspace in self.animationWorkspaces.values where workspace.modifiers === document { workspace.refreshModifiers() } + self.onDocumentEdited?(id) + } + if !openDocuments.contains(where: { $0.id == id }) { + openDocuments.append(.asset(.init(id: id, title: document.url.lastPathComponent, relativePath: document.relativePath, + absolutePath: document.url.path, assetReference: "@res://" + String(document.url.path.dropFirst(assetsURL.path.count + 1)), + kind: .animationModifiers, fileExtension: "animmod", byteCount: nil, modifiedAt: nil, errorMessage: nil, isDirty: document.isDirty))) + } + } + + /// A resource tab and all model workspaces share ownership of a live draft. + func releaseUnreferencedModifierDocuments() { + guard let service = animationService else { return } + let visible = Set(openDocuments.compactMap { value -> String? in + if case let .asset(asset) = value, asset.kind == .animationModifiers { return asset.relativePath }; return nil + }) + let attached = Set(animationWorkspaces.values.compactMap { $0.modifiers?.relativePath }) + for document in Array(service.modifierDocuments.values) + where !document.isDirty && !visible.contains(document.relativePath) && !attached.contains(document.relativePath) { + try? service.closeModifiers(document.relativePath) + } + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift index 0219d815d..b413807b3 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift @@ -95,9 +95,13 @@ extension EditorWorkbenchViewModel { } @discardableResult - func saveAllDocuments() -> Bool { + func saveAllDocuments(includeAnimationDrafts: Bool = false) -> Bool { var didSaveAll = true for document in openDocuments where document.isDirty { + if case let .asset(asset) = document, (asset.kind == .animationLibrary || asset.kind == .animationModifiers), !includeAnimationDrafts { + didSaveAll = false + continue + } if !saveDocument(document) { didSaveAll = false } @@ -110,6 +114,7 @@ extension EditorWorkbenchViewModel { guard let activeDocument, activeDocument.isDirty else { return false } + if case let .asset(asset) = activeDocument, (asset.kind == .animationLibrary || asset.kind == .animationModifiers) { return false } return saveDocument(activeDocument) } @@ -124,7 +129,8 @@ extension EditorWorkbenchViewModel { case .git: nil case let .asset(document): - document.errorMessage + document.errorMessage ?? ((document.kind == .animationLibrary || document.kind == .animationModifiers) && document.isDirty + ? L10n.tr("Save the animation draft explicitly before continuing.") : nil) case nil: nil } @@ -146,6 +152,11 @@ extension EditorWorkbenchViewModel { case let .text(document): return saveTextDocument(id: document.id) case let .asset(document): + if document.kind == .animationLibrary { return (try? animationService?.open(document.relativePath))?.save() ?? false } + if document.kind == .animationModifiers { + do { let draft = try animationService?.openModifiers(document.relativePath); try draft?.save(expectedRevision: draft?.revision ?? ""); return draft != nil } + catch { return false } + } return document.kind == .shaderGraph && shaderGraphModel(for: document).save() case .git: return false diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel.swift index 9360117f0..7c39b827d 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel.swift @@ -51,6 +51,9 @@ final class EditorWorkbenchViewModel { @ObservationIgnored var spriteAnimationModels: [String: EditorSpriteAnimationEditorModel] = [:] @ObservationIgnored var textureSettingsModels: [String: EditorTextureSettingsModel] = [:] @ObservationIgnored var shaderGraphModels: [String: EditorShaderGraphModel] = [:] + @ObservationIgnored var animationService: EditorAnimationAuthoringService? + @ObservationIgnored var onRevealAnimationInspector: (() -> Void)? + @ObservationIgnored var animationWorkspaces: [String: EditorAnimationAssetWorkspace] = [:] @ObservationIgnored var sceneUndoHistory: [String: [EditorSceneDocument]] = [:] @ObservationIgnored var sceneRedoHistory: [String: [EditorSceneDocument]] = [:] @ObservationIgnored var sceneDecodeTasks: [String: Task] = [:] @@ -145,6 +148,13 @@ final class EditorWorkbenchViewModel { func open(_ document: EditorWorkbenchDocument) { if let index = openDocuments.firstIndex(where: { $0.id == document.id }) { + if case let .asset(asset) = document, asset.kind == .animationLibrary, + let model = try? animationService?.open(asset.relativePath), model.isDirty { + selectDocument(id: document.id) + return + } + if case let .asset(asset) = document, asset.kind == .animationModifiers, + let draft = try? animationService?.openModifiers(asset.relativePath), draft.isDirty { selectDocument(id: document.id); return } if let graph = shaderGraphModels[document.id] { if graph.isDirty { selectDocument(id: document.id); return } graph.reload() @@ -244,11 +254,20 @@ final class EditorWorkbenchViewModel { } func closeDocument(id documentID: String) { + if let workspace = animationWorkspaces[documentID], workspace.timeline.hasUnkeyedPose { + workspace.timeline.error = L10n.tr("Insert or discard the unkeyed pose first.") + return + } guard let closingIndex = openDocuments.firstIndex(where: { $0.id == documentID }) else { return } let closingDocument = openDocuments[closingIndex] + if case var .asset(asset) = closingDocument, (asset.kind == .animationLibrary || asset.kind == .animationModifiers), asset.isDirty { + asset.errorMessage = L10n.tr("Save the animation draft explicitly or discard it before closing.") + openDocuments[closingIndex] = .asset(asset) + return + } if case let .git(document) = closingDocument { document.close() } @@ -271,6 +290,9 @@ final class EditorWorkbenchViewModel { spriteAnimationModels.removeValue(forKey: documentID)?.cancelPreview() textureSettingsModels.removeValue(forKey: documentID) shaderGraphModels.removeValue(forKey: documentID) + animationWorkspaces.removeValue(forKey: documentID)?.cancel() + if case let .asset(asset) = closingDocument, asset.kind == .animationLibrary { try? animationService?.close(asset.relativePath) } + releaseUnreferencedModifierDocuments() sceneUndoHistory.removeValue(forKey: documentID) sceneRedoHistory.removeValue(forKey: documentID) reconcilePanes() @@ -291,6 +313,7 @@ final class EditorWorkbenchViewModel { } func discardDocuments(atOrBelow relativePath: String) { + animationService?.discardDocuments(atOrBelow: relativePath) let discardedIDs = openDocuments.compactMap { document -> String? in document.relativePath == relativePath || document.relativePath.hasPrefix("\(relativePath)/") ? document.id : nil } @@ -306,6 +329,7 @@ final class EditorWorkbenchViewModel { tileSourceModels = tileSourceModels.filter { !discardedIDSet.contains($0.key) } tileMapModels = tileMapModels.filter { !discardedIDSet.contains($0.key) } for id in discardedIDSet { + animationWorkspaces.removeValue(forKey: id)?.cancel() particleEffectModels.removeValue(forKey: id)?.cancelPreview() spriteAnimationModels.removeValue(forKey: id)?.cancelPreview() } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkspacePanelsLayout.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkspacePanelsLayout.swift index 4525fb61b..178003d52 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkspacePanelsLayout.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkspacePanelsLayout.swift @@ -16,6 +16,8 @@ final class EditorWorkspaceResizeState { @ObservationIgnored private var leftWidth: Float = AdaEngineStyleLayoutSpec.projectSidebarWidth @ObservationIgnored private var rightWidth: Float = AdaEngineStyleLayoutSpec.inspectorWidth @ObservationIgnored private var bottomHeight: Float = 180 + @ObservationIgnored private var animatorHeight: Float = 380 + @ObservationIgnored private var animatorWasResized = false @ObservationIgnored private var dragStart: Float? func layout(in size: Size, viewModel: EditorViewModel) -> EditorWorkspaceLayout { @@ -26,7 +28,8 @@ final class EditorWorkspaceResizeState { showsBottomPanel: viewModel.showBottomPanel && !viewModel.performance.isExpanded, requestedLeftPanelWidth: leftWidth, requestedRightPanelWidth: rightWidth, - requestedBottomPanelHeight: bottomHeight, + requestedBottomPanelHeight: viewModel.toolStrip.activeLeftBottomTool == "animator" + ? (animatorWasResized ? animatorHeight : min(animatorHeight, max(0, size.height * 0.46))) : bottomHeight, fallbackLeftPanelWidth: AdaEngineStyleLayoutSpec.projectSidebarWidth, fallbackRightPanelWidth: AdaEngineStyleLayoutSpec.inspectorWidth ) @@ -58,15 +61,19 @@ final class EditorWorkspaceResizeState { rightWidth = width } case .bottom: + if viewModel.toolStrip.activeLeftBottomTool == "animator" { animatorWasResized = true } let start = dragStart ?? before.bottomPanelHeight dragStart = start let height = start - translation.height if height < EditorWorkspaceLayout.minimumBottomPanelHeight { viewModel.showBottomPanel = false - bottomHeight = start + if viewModel.toolStrip.activeLeftBottomTool == "animator" { animatorHeight = start } + else { bottomHeight = start } dragStart = nil } else { - bottomHeight = EditorWorkspaceLayout.clampedBottomPanelHeight(height, in: size) + let adjusted = EditorWorkspaceLayout.clampedBottomPanelHeight(height, in: size) + if viewModel.toolStrip.activeLeftBottomTool == "animator" { animatorHeight = adjusted } + else { bottomHeight = adjusted } } } let after = layout(in: size, viewModel: viewModel) diff --git a/Editor/Tests/AdaEditorTests/EditorAnimationAuthoringTests.swift b/Editor/Tests/AdaEditorTests/EditorAnimationAuthoringTests.swift new file mode 100644 index 000000000..fa6e3c615 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorAnimationAuthoringTests.swift @@ -0,0 +1,464 @@ +@_spi(AdaEngine) import AdaEngine +import AdaScriptCompilerCore +import Foundation +import MCP +import Testing + +@testable import AdaEditor + +@Suite("Animation authoring documents", .serialized) +@MainActor +struct EditorAnimationAuthoringTests { + init() { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + RenderWorldPlugin().setup(in: AppWorlds(main: World(name: "Animation authoring tests"))) + } + } + + @Test func draftsAreNotAutosavedOrSavedBySwitchingAndClosingTabs() async throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let service = try #require(fixture.editor.workbench.animationService) + let document = try service.create("Assets/Animations/Draft.animlib", library: library()) + fixture.editor.workbench.selectDocument(id: "asset:\(document.relativePath)") + try document.apply([.init(kind: .configureClip, clipID: "idle", name: "Changed")], expectedRevision: document.revision) + try await Task.sleep(for: .milliseconds(150)) + #expect(!FileManager.default.fileExists(atPath: document.url.path)) + #expect(document.isDirty) + fixture.editor.workbench.selectDocument(id: "scene:Assets/Main.ascn") + #expect(!FileManager.default.fileExists(atPath: document.url.path)) + fixture.editor.workbench.closeDocument(id: "asset:\(document.relativePath)") + #expect(!FileManager.default.fileExists(atPath: document.url.path)) + #expect(fixture.editor.workbench.openDocuments.contains { $0.id == "asset:\(document.relativePath)" }) + try document.save(expectedRevision: document.revision) + fixture.editor.workbench.closeDocument(id: "asset:\(document.relativePath)") + #expect(!fixture.editor.workbench.openDocuments.contains { $0.id == "asset:\(document.relativePath)" }) + #expect(try service.open(document.relativePath).library.clips.first?.name == "Changed") + } + + @Test func sharedRequestsUseRevisionedUndoAndExplicitDiskRecovery() async throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let service = try #require(fixture.editor.workbench.animationService) + let document = try service.create("Assets/Test.animlib", library: library()) + try document.save(expectedRevision: document.revision) + let revision = document.revision + let request = AnimationAuthoringRequest(action: .apply, path: document.relativePath, expectedRevision: revision, + operations: [.init(id: "rename", kind: .configureClip, clipID: "idle", name: "From API")]) + let response = try await service.execute(request, workbench: fixture.editor.workbench) + #expect(response["revision"] as? String == document.revision) + #expect(document.library.clips.first?.name == "From API") + await #expect(throws: AnimationAuthoringError.self) { _ = try await service.execute(request, workbench: fixture.editor.workbench) } + try document.undo(expectedRevision: document.revision) + #expect(document.library.clips.first?.name == "Idle") + try document.redo(expectedRevision: document.revision) + #expect(document.library.clips.first?.name == "From API") + var external = library(); external.clips[0].name = "External" + try external.encodedData().write(to: document.url) + #expect(throws: AnimationAuthoringError.self) { try document.save(expectedRevision: document.revision) } + #expect(throws: AnimationAuthoringError.self) { try service.reload(document, expectedRevision: document.revision) } + try service.reload(document, expectedRevision: document.revision, discardChanges: true) + #expect(document.library == external) + #expect(!document.isDirty && !document.canUndo) + let old = document.revision + _ = try await service.execute(.init(action: .discard, path: document.relativePath, expectedRevision: old, discardChanges: true), workbench: fixture.editor.workbench) + #expect(service.documents.isEmpty) + #expect(document.isDiscarded) + #expect(try service.open(document.relativePath) !== document) + } + + @Test func sceneReferencesAndOpenDocumentsRejectChangedResourceLocations() throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let service = try #require(fixture.editor.workbench.animationService) + let scene = fixture.root.appendingPathComponent("Assets/Scenes/Main.ascn") + // Payload resolution checks existence/extension here; native decoding is exercised by the extraction test. + try Data().write(to: fixture.root.appendingPathComponent("Assets/Robot.glb")) + #expect(EditorSceneFileLoader.resolveTileMapReference("../../Outside.animlib", relativeTo: scene, + resourceRootURL: service.assetsURL, fileExtension: "animlib") == nil) + #expect(throws: (any Error).self) { + _ = try EditorSceneFileLoader.resolvedComponentPayload(typeName: EditorBuiltInComponentType.model3DSource, + payload: ["source": .string("@res://Robot.glb"), "animationLibraries": .array([.string("../../Outside.animlib")])], + sourceURL: scene, resourceRootURL: service.assetsURL) + } + let document = try service.create("Assets/Animations/Location.animlib", library: library()) + let originalDirectory = document.url.deletingLastPathComponent() + let outside = fixture.root.appendingPathComponent("Outside", isDirectory: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + // Removing an empty, exact fixture directory is safe; no recursive path calculation is used. + try FileManager.default.createDirectory(at: originalDirectory, withIntermediateDirectories: true) + try FileManager.default.removeItem(at: originalDirectory) + try FileManager.default.createSymbolicLink(at: originalDirectory, withDestinationURL: outside) + #expect(throws: AnimationAuthoringError.self) { try document.save(expectedRevision: document.revision) } + #expect(!FileManager.default.fileExists(atPath: outside.appendingPathComponent("Location.animlib").path)) + try service.discard(document, expectedRevision: document.revision, discardChanges: true) + #expect(service.documents.isEmpty) + } + + @Test func projectDeletionEvictsCachedDraftAndAllowsRecreation() throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let service = try #require(fixture.editor.workbench.animationService) + let document = try service.create("Assets/Deleted.animlib", library: library()) + try document.save(expectedRevision: document.revision) + try FileManager.default.removeItem(at: document.url) + fixture.editor.workbench.discardDocuments(atOrBelow: document.relativePath) + #expect(service.documents.isEmpty) + #expect(document.isDiscarded) + let recreated = try service.create(document.relativePath, library: library()) + #expect(recreated !== document) + } + + @Test func nativeExtractionBindingGraphAndRuntimeReopenPreserveSourceAndRejectDuplicates() async throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let service = try #require(fixture.editor.workbench.animationService) + let source = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Tests/AdaEngineTests/Fixtures/SkeletalGardenAssets/GardenRobot.glb") + let modelURL = fixture.root.appendingPathComponent("Assets/Robot.glb") + let original = try Data(contentsOf: source) + try original.write(to: modelURL) + let document = try await service.extract(path: "Assets/Robot.animlib", modelPath: "Assets/Robot.glb", clipName: "Walk", name: "Editable Walk") + let clip = try #require(document.library.clips.first) + let initial = try document.library.sample(clipID: clip.id, at: 0) + let animated = try document.library.sample(clipID: clip.id, at: 0.25) + #expect(initial.localTransforms != animated.localTransforms) + #expect(try Data(contentsOf: modelURL) == original) + try document.save(expectedRevision: document.revision) + let sceneID = "scene:Assets/Main.ascn" + fixture.editor.workbench.updateSceneModelDocument(id: sceneID, status: "Test model") { scene in + scene.entities.append(.init(id: "robot", name: "Robot", enabled: true, parent: nil, components: [ + EditorBuiltInComponentType.transform: EditorComponentRegistry.defaultPayload(for: EditorBuiltInComponentType.transform), + EditorBuiltInComponentType.model3DSource: ["source": .string("@res://Robot.glb"), "animation": .string("Walk"), "autoplay": .bool(true), "repeats": .bool(true)] + ])) + } + let before = try #require(fixture.editor.workbench.sceneDocument(id: sceneID)) + try await fixture.editor.workbench.bindAnimationLibrary(document, sceneID: sceneID, entityID: "robot", + expectedSceneRevision: EditorAgentSceneToolService.revision(for: before.content), expectedResourceRevision: document.revision, clipID: clip.id) + let bound = try #require(fixture.editor.workbench.sceneDocument(id: sceneID)) + let reference = try #require(bound.sceneModel?.entities.first { $0.id == "robot" }?.components[EditorBuiltInComponentType.model3DSource]) + #expect(reference["animation"] == .string(document.library.runtimeName(for: clip.id))) + let duplicate = try service.create("Assets/Duplicate.animlib", library: document.library) + try duplicate.save(expectedRevision: duplicate.revision) + await #expect(throws: (any Error).self) { + try await fixture.editor.workbench.bindAnimationLibrary(duplicate, sceneID: sceneID, entityID: "robot", + expectedSceneRevision: EditorAgentSceneToolService.revision(for: bound.content), expectedResourceRevision: duplicate.revision, clipID: clip.id) + } + #expect(fixture.editor.workbench.sceneDocument(id: sceneID)?.content == bound.content) + GLTFLoaderResolver.shared.setLoader(NativeGLTFLoader()) + let model = try #require(AssetsManager.loadSync(ModelAsset3D.self, at: modelURL.path).asset) + var modelSource = try #require(try EditorComponentRegistry.decode(typeName: EditorBuiltInComponentType.model3DSource, payload: reference) as? Model3DSource) + let qualified = document.library.runtimeName(for: clip.id) + modelSource.animationGraph = .init(root: "walk", nodes: [.init(id: "walk", kind: .clip, clip: qualified)]) + let world = World() + let owner = world.spawn("Owner") { Transform() } + let root = try modelSource.instantiate(model, libraries: [document.library], in: world, under: owner) + #expect(root.components[ModelAnimation3DComponent.self]?.player.clips.contains { $0.name == qualified } == true) + let childCount = owner.children.count + modelSource.animationLibraries.append("@res://Duplicate.animlib") + #expect(throws: (any Error).self) { _ = try modelSource.instantiate(model, libraries: [document.library, duplicate.library], in: world, under: owner) } + #expect(owner.children.count == childCount) + #expect(world.getEntityByID(root.id) != nil) + try service.close(document.relativePath) + #expect(try service.open(document.relativePath).library == document.library) + #expect(try Data(contentsOf: modelURL) == original) + } + + @Test func documentedToolPanelUsesTheSameDraftAndRevocationCancelsQueuedJobs() async throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let service = try #require(fixture.editor.workbench.animationService) + let source = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Tests/AdaEngineTests/Fixtures/SkeletalGardenAssets/GardenRobot.glb") + try FileManager.default.copyItem(at: source, to: fixture.root.appendingPathComponent("Assets/Robot.glb")) + let document = try await service.extract(path: "Assets/Test.animlib", modelPath: "Assets/Robot.glb", clipName: "Walk", name: "Editable Walk") + try document.save(expectedRevision: document.revision) + let original = document.library + let sceneID = "scene:Assets/Main.ascn" + fixture.editor.workbench.updateSceneModelDocument(id: sceneID, status: "Test model") { scene in + scene.entities.append(.init(id: "robot", name: "Robot", enabled: true, parent: nil, components: [ + EditorBuiltInComponentType.transform: EditorComponentRegistry.defaultPayload(for: EditorBuiltInComponentType.transform), + EditorBuiltInComponentType.model3DSource: ["source": .string("@res://Robot.glb"), "animation": .string("Walk"), "autoplay": .bool(true), "repeats": .bool(true)] + ])) + } + let example = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Documentation/Examples/StudioTools/AnimationHelper") + try FileManager.default.createDirectory(at: fixture.root.appendingPathComponent("Tools"), withIntermediateDirectories: true) + try FileManager.default.copyItem(at: example, to: fixture.root.appendingPathComponent("Tools/AnimationHelper")) + let host = EditorStudioToolHost(defaults: try #require(UserDefaults(suiteName: "AnimationTools-\(UUID())"))) + defer { host.close() } + host.open(projectURL: fixture.root, editor: fixture.editor) + host.enable("studio.animation-helper") + host.approve(try #require(host.permissionRequest).id) + #expect(host.error == nil) + let panel = try #require(host.selectedPanel) + panel.session.context.set("path", to: .string(document.relativePath)) + func performAndPoll(_ action: String) async throws { + panel.session.context.perform(action) + for _ in 0..<20 { await Task.yield() } + for _ in 0..<200 { + panel.session.context.perform("poll") + for _ in 0..<20 { await Task.yield() } + if panel.runtime.values["job"] == .string("") || panel.error != nil { break } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(panel.error == nil) + #expect(panel.runtime.values["job"] == .string("")) + } + try await performAndPoll("inspect") + #expect(panel.runtime.values["revision"] == .string(document.revision)) + #expect(panel.runtime.values["clipID"] == .string(original.clips[0].id)) + try await performAndPoll("inspectTracks") + try await performAndPoll("inspectKeys") + guard case let .string(trackID) = panel.runtime.values["trackID"], + let track = original.clips[0].tracks.first(where: { $0.id == trackID }), let first = track.keys.first else { + Issue.record("The real Tool must inspect a compatible rotation track and key.") + return + } + #expect(track.channel == .rotation) + #expect(panel.runtime.values["keyID"] == .string(first.id)) + try await performAndPoll("rotateBone") + var key = first + key.value = [0, 0, 0.70710677, 0.70710677] + let edit = AnimationEdit(id: "rotate-bone", kind: .putKey, clipID: original.clips[0].id, trackID: track.id, key: key, replaceExisting: true) + let ui = try service.create("Assets/UI.animlib", library: original) + try ui.apply([edit], expectedRevision: ui.revision) + let agent = try service.create("Assets/Agent.animlib", library: original) + let request = AnimationAuthoringRequest(action: .apply, path: agent.relativePath, expectedRevision: agent.revision, operations: [edit]) + let json = String(decoding: try JSONEncoder().encode(request), as: UTF8.self) + _ = try await EditorAgentAnimationTools.execute(name: "editor.animation.apply", arguments: ["requestJSON": .string(json)], viewModel: fixture.editor) + #expect(ui.library == agent.library) + #expect(document.library == agent.library) + await #expect(throws: (any Error).self) { + _ = try await EditorAgentAnimationTools.execute(name: "editor.animation.inspect", arguments: ["requestJSON": .string(json)], viewModel: fixture.editor) + } + #expect(document.isDirty) + try await performAndPoll("sample") + guard case let .double(sampledZ) = panel.runtime.values["sampledRotationZ"] else { + Issue.record("The Tool must read a sampled bone rotation from a nested pose/list response.") + return + } + #expect(abs(sampledZ - Double(key.value[2])) < 0.00001) + try await performAndPoll("save") + #expect(!document.isDirty) + #expect(try AnimationLibrary.decode(Data(contentsOf: document.url)) == document.library) + try await performAndPoll("bind") + let bound = try #require(fixture.editor.workbench.sceneDocument(id: sceneID)?.sceneModel?.entities.first { $0.id == "robot" }) + #expect(bound.components[EditorBuiltInComponentType.model3DSource]?["animation"] == .string(document.library.runtimeName(for: original.clips[0].id))) + try await performAndPoll("oneShot") + #expect(document.library.clips.first?.repeats == false) + #expect(document.isDirty) + panel.session.context.perform("save") + host.disable("studio.animation-helper") + for _ in 0..<40 { await Task.yield() } + #expect(host.panels.isEmpty) + #expect(document.isDirty) + #expect(try AnimationLibrary.decode(Data(contentsOf: document.url)).clips.first?.repeats == true) + } + + @Test func scriptRequestExpansionIsBoundedBeforeJSONEncoding() throws { + let source = AdaScriptSource(path: "Amplify.ada", source: """ + @tool(id: "test.expansion", api: 2, platforms: ["macos", "windows"], permissions: ["editor.animations.read"]) + class Amplify { + func activate(editor) { editor.addPanel(id: "main", title: "Tests", location: "right", ui: "Panel.ui"); } + func amplify(editor) { + var value = ["leaf": "value"]; + var i = 0; + while (i < 14) { value = ["a": value, "b": value]; i += 1; } + editor.animations.submit(["action": "inspect", "path": "Assets/Test.animlib", "extra": value]); + } + } + """) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + let runtime = try AdaScriptToolRuntime(sources: [source], schema: schema, grantedPermissions: schema.permissions) + defer { runtime.deactivate() } + try runtime.activate() + do { + _ = try runtime.perform("amplify", inputs: [:], scene: nil) + Issue.record("Expanded request graphs must fail before JSON encoding.") + } catch { + #expect(String(describing: error).contains("conversion budget")) + } + #expect(runtime.takeAnimationJobs().isEmpty) + } + + @Test func authoringImportBoundsAccessorAllocationsAndRepeatedDecoding() throws { + let root = FileManager.default.temporaryDirectory + // The oversized accessor is deliberately unused: Red never attempts the giant allocation. + let unused = Data(#"{"asset":{"version":"2.0"},"accessors":[{"componentType":5126,"count":1000000000,"type":"SCALAR"}]}"#.utf8) + #expect(throws: NativeGLTFLoader.GLTFError.self) { _ = try NativeGLTFLoader(resourceRoot: root).load(data: unused, baseURL: root) } + // A small zero-based accessor is legal without a buffer. Repeated references still consume decoded work. + let primitive: [String: Any] = ["attributes": ["POSITION": 0]] + let object: [String: Any] = ["asset": ["version": "2.0"], + "accessors": [["componentType": 5126, "count": 16, "type": "VEC3"]], + "meshes": [["primitives": Array(repeating: primitive, count: 32)]]] + let data = try JSONSerialization.data(withJSONObject: object) + #expect(data.count < 4096) + #expect(throws: NativeGLTFLoader.GLTFError.self) { + _ = try NativeGLTFLoader(resourceRoot: root, maximumTotalBytes: 4096).load(data: data, baseURL: root) + } + } + + private func library() -> AnimationLibrary { + .init(id: "test", binding: .init(sourceAsset: "Assets/Robot.glb", nodes: []), clips: [.init(id: "idle", name: "Idle", duration: 1)]) + } + + @Test func revokingToolCancelsRunningAndQueuedMutations() async throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let service = try #require(fixture.editor.workbench.animationService) + let document = try service.create("Assets/Test.animlib", library: library()) + try document.save(expectedRevision: document.revision) + let original = document.library + let bytes = try Data(contentsOf: document.url) + let directory = fixture.root.appendingPathComponent("Tools/Cancel") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try """ + @tool(id: "test.cancel", api: 2, platforms: ["macos", "windows"], permissions: ["editor.animations.read", "editor.animations.write"]) + class CancelTool { + @export var revision = ""; + @export var submitted = 0; + func activate(editor) { editor.addPanel(id: "main", title: "Cancel", location: "right", ui: "Panel.ui"); } + func mutate(editor) { + editor.animations.submit(["action": "apply", "path": "Assets/Test.animlib", "expectedRevision": revision, + "operations": [["id": "rename", "kind": "clip.configure", "clipID": "idle", "name": "Late mutation"]]]); + submitted += 1; + } + func inspect(editor) { editor.animations.submit(["action": "inspect", "path": "Assets/Test.animlib"]); submitted += 1; } + } + """.write(to: directory.appendingPathComponent("Cancel.ada"), atomically: true, encoding: .utf8) + try """ + format: ada.ui + schemaVersion: 1 + inputs: + - {name: revision, type: string, defaultValue: ''} + actions: + - {name: mutate} + - {name: inspect} + root: + id: cancel + type: Text + arguments: + text: {value: Cancellation test} + """.write(to: directory.appendingPathComponent("Panel.ui"), atomically: true, encoding: .utf8) + let gate = AnimationJobGate() + let defaults = try #require(UserDefaults(suiteName: "AnimationCancel-\(UUID())")) + // Suspend only scheduling; the resumed request still executes the real project service. + let host = EditorStudioToolHost(defaults: defaults, animationExecutor: { request, workbench in + if request.action == .apply { await gate.pause() } + defer { gate.finished = true } + return try await service.execute(request, workbench: workbench) + }) + defer { gate.resume(); host.close() } + host.open(projectURL: fixture.root, editor: fixture.editor) + host.enable("test.cancel") + host.approve(try #require(host.permissionRequest).id) + let panel = try #require(host.selectedPanel) + panel.session.context.set("revision", to: .string(document.revision)) + panel.session.context.perform("mutate") + for _ in 0..<100 where !gate.started { await Task.yield() } + #expect(gate.started) + panel.session.context.perform("inspect") + for _ in 0..<100 where panel.runtime.values["submitted"] != .int(2) { await Task.yield() } + #expect(panel.runtime.values["submitted"] == .int(2)) + host.disable("test.cancel") + gate.resume() + for _ in 0..<100 where !gate.finished { await Task.yield() } + #expect(gate.finished) + #expect(document.library == original) + #expect(try Data(contentsOf: document.url) == bytes) + } + + @Test func detachedToolRequestsPreserveUTF8IncludingNull() throws { + let source = AdaScriptSource(path: "Strings.ada", source: """ + @tool(id: "test.strings", api: 2, platforms: ["macos", "windows"], permissions: ["editor.animations.read"]) + class Strings { + @export var path = ""; + func activate(editor) { editor.addPanel(id: "main", title: "Tests", location: "right", ui: "Panel.ui"); } + func inspect(editor) { editor.animations.submit(["action": "inspect", "path": path]); } + } + """) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + let runtime = try AdaScriptToolRuntime(sources: [source], schema: schema, grantedPermissions: schema.permissions) + defer { runtime.deactivate() } + try runtime.activate() + let path = "Assets/Кость.animlib\0suffix" + _ = try runtime.perform("inspect", inputs: ["path": .string(path)], scene: nil) + #expect(try #require(runtime.takeAnimationJobs().first).request.path == path) + } + + @Test func libraryPreviewUsesBoundedImportBeforeGPUWork() async throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let model = fixture.root.appendingPathComponent("Assets/Oversized.gltf") + // An unused accessor makes the unprotected baseline safe to run while still testing early inventory rejection. + try Data(#"{"asset":{"version":"2.0"},"accessors":[{"componentType":5126,"count":1000000000,"type":"SCALAR"}]}"#.utf8).write(to: model) + let preview = EditorModelAssetPreviewModel() + defer { preview.cancel() } + preview.load(path: model.path, resourceRoot: fixture.root.appendingPathComponent("Assets")) + for _ in 0..<200 where preview.isLoading { try await Task.sleep(for: .milliseconds(10)) } + #expect(!preview.isLoading) + #expect(preview.asset == nil) + #expect(preview.error == NativeGLTFLoader.GLTFError.bufferOutOfBounds.localizedDescription) + } + + @Test func scopedImportRejectsPrimaryModelOutsideAssets() async throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let assets = fixture.root.appendingPathComponent("Assets").resolvingSymlinksInPath().standardizedFileURL + let inside = assets.appendingPathComponent("Inside.gltf") + let outside = fixture.root.appendingPathComponent("Outside.gltf") + let data = Data(#"{"asset":{"version":"2.0"}}"#.utf8) + try data.write(to: inside) + try data.write(to: outside) + let loader = NativeGLTFLoader(resourceRoot: assets) + _ = try await loader.load(url: inside) + await #expect(throws: NativeGLTFLoader.GLTFError.invalidDataURI) { _ = try await loader.load(url: outside) } + } + + @Test func openedModelIsAnAnimatorTargetWithoutAnySceneDocument() throws { + let fixture = try makeFixture() + defer { fixture.editor.studioTools.close(); try? FileManager.default.removeItem(at: fixture.root) } + let workbench = fixture.editor.workbench + workbench.openDocuments = [] + let model = EditorAssetDocument(id: "asset:Assets/Robot.glb", title: "Robot.glb", relativePath: "Assets/Robot.glb", + absolutePath: fixture.root.appendingPathComponent("Assets/Robot.glb").path, assetReference: "@res://Robot.glb", + kind: .model3D, fileExtension: "glb", byteCount: nil, modifiedAt: nil, errorMessage: nil) + workbench.open(.asset(model)) + #expect(workbench.activeSceneDocument == nil) + let target = try #require(fixture.editor.skeletalAnimationTarget) + #expect(target.documentID == model.id) + #expect(target.source.source == "@res://Robot.glb") + #expect(target.entityID.isEmpty) + #expect(fixture.editor.showBottomPanel) + #expect(fixture.editor.toolStrip.activeLeftBottomTool == "animator") + } + + private func makeFixture() throws -> (root: URL, editor: EditorViewModel) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AnimationAuthoring-\(UUID())", isDirectory: true) + try FileManager.default.createDirectory(at: root.appendingPathComponent("Assets"), withIntermediateDirectories: true) + try ProjectSystem.saveProject(ProjectSystem.defaultProject(projectName: "Animation", buildSystem: .adaScript), at: root) + let model = EditorSceneModel.default(projectName: "Animation") + let content = try model.encodedYAML() + let scene = root.appendingPathComponent("Assets/Main.ascn") + try content.write(to: scene, atomically: true, encoding: .utf8) + let editor = EditorViewModel(project: .init(name: "Animation", path: root.path), autosaveDelay: .milliseconds(10)) + editor.workbench.open(.scene(.init(id: "scene:Assets/Main.ascn", title: "Main.ascn", relativePath: "Assets/Main.ascn", absolutePath: scene.path, + content: content, lastSavedContent: content, isReadOnly: false, sceneModel: model, errorMessage: nil, isDirty: false, + statusMessage: nil, loadSummary: EditorSceneFileLoader.summary(from: content)))) + return (root, editor) + } +} + +@MainActor +private final class AnimationJobGate { + var started = false + var finished = false + private var continuation: CheckedContinuation? + func pause() async { + started = true + await withCheckedContinuation { continuation = $0 } + } + func resume() { continuation?.resume(); continuation = nil } +} diff --git a/Editor/Tests/AdaEditorTests/EditorAnimationGraphRequestTests.swift b/Editor/Tests/AdaEditorTests/EditorAnimationGraphRequestTests.swift new file mode 100644 index 000000000..e5df636d1 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorAnimationGraphRequestTests.swift @@ -0,0 +1,218 @@ +@_spi(AdaEngine) import AdaEngine +import AdaScriptCompilerCore +@_spi(Internal) import AdaUI +import Foundation +import Math +import Testing + +@testable import AdaEditor + +extension EditorAnimationAuthoringTests { + @Test func anOpenLibraryCanCreateAnotherClipOnItsSourceRig() async throws { + let fixture = try graphRequestFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = try await fixture.service.extract(path: "Assets/Robot.animlib", modelPath: fixture.workspace.document.relativePath, + clipName: "Walk", name: "First") + try document.save(expectedRevision: document.revision) + let asset = EditorAssetDocument(id: "asset:" + document.relativePath, title: "Robot.animlib", relativePath: document.relativePath, + absolutePath: document.url.path, assetReference: "@res://Robot.animlib", kind: .animationLibrary, + fileExtension: "animlib", byteCount: nil, modifiedAt: nil, errorMessage: nil) + fixture.workbench.open(.asset(asset)) + let workspace = fixture.workbench.animationWorkspace(for: asset) + defer { workspace.cancel() } + workspace.library = document + workspace.libraryPath = document.relativePath + workspace.preview.asset = fixture.workspace.preview.asset + workspace.configureLoadedAsset() + workspace.clipName = "New pose" + try await workspace.extract(fromRest: true) + #expect(document.library.clips.count == 2) + #expect(document.library.clips.last?.name == "New pose") + #expect(document.selectedClipID == document.library.clips.last?.id) + #expect(document.isDirty) + #expect(try Data(contentsOf: fixture.modelURL) == fixture.original) + } + + @Test func denseTimelineUsesBoundedTimeQueriesAndRecordsRealPlayerScrubLatency() async throws { + let fixture = try graphRequestFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = try await fixture.service.extract(path: "Assets/Dense.animlib", modelPath: fixture.workspace.document.relativePath, + clipName: "Walk", name: "Dense") + let node = try #require(document.library.binding.nodes.first { $0.name == "UpperArm.L" }) + let keys = (0..<80_000).map { AnimationLibraryKey(id: "key-\($0)", time: Double($0) / 1000, value: [Float($0 % 100) / 100, 0, 0]) } + let dense = AnimationLibraryClip(id: "dense", name: "Dense", duration: 80, + tracks: [.init(id: "dense-track", nodeID: node.id, channel: .translation, keys: keys)]) + try document.apply([.init(kind: .removeClip, clipID: try #require(document.library.clips.first).id), + .init(kind: .createClip, clip: dense)], expectedRevision: document.revision) + document.selectedClipID = dense.id + fixture.workspace.library = document + fixture.workspace.mode = .keys + fixture.workspace.refreshLibrary() + let world = World(name: "Dense timeline input") + _ = try #require(fixture.workspace.preview.asset).instantiate(in: world) + fixture.workspace.preview.bind(to: world) + let model = fixture.workspace.timeline + model.pixelsPerSecond = 100 + model.snap = 0 + let row = try #require(model.rows.first { $0.nodeID == node.id }) + #expect(model.keys(in: row, timeRange: 10...10.01).count == 11) + let input = EditorAnimationTimelineInputView(frame: Rect(x: 0, y: 0, width: 800, height: 350)) + input.model = model + let started = Date.timeIntervalSinceReferenceDate + for index in 0..<100 { + let point = Point(EditorAnimationTimeline.labelsWidth + Float(index % 50 + 1), 10) + for phase in [MouseEvent.Phase.began, .changed, .ended] { + input.onMouseEvent(MouseEvent(window: RID(), button: .left, mousePosition: point, phase: phase, modifierKeys: [], time: 0)) + } + } + let milliseconds = (Date.timeIntervalSinceReferenceDate - started) * 1000 / 100 + print("Dense timeline: 80000 keys, 100 real-player ruler gestures, mean milliseconds per gesture: \(milliseconds)") + #expect(abs(try #require(fixture.workspace.preview.capturedAnimationPlayer()).time - 0.5) < 0.000001) + #expect(model.error.isEmpty) + withExtendedLifetime(world) {} + } + + @Test func documentedGraphToolEditsTheSameOpenWorkspaceAndKeepsLibraryRevisionSeparate() async throws { + let fixture = try graphRequestFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ada") + let source = AdaScriptSource(path: "AnimationHelper.ada", source: try String(contentsOf: url, encoding: .utf8)) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + let runtime = try AdaScriptToolRuntime(sources: [source], schema: schema, grantedPermissions: schema.permissions) + defer { runtime.deactivate() } + try runtime.activate() + for action in ["inspectGraph", "halfWeightGraph", "previewGraph"] { + _ = try runtime.perform(action, inputs: [:], scene: nil) + let job = try #require(runtime.takeAnimationJobs().first) + let payload = try await fixture.service.execute(job.request, workbench: fixture.workbench) + let data = try EditorAnimationAuthoringService.responseData(["status": "completed", "result": payload]) + try runtime.completeAnimationJob(job.id, responseJSON: String(decoding: data, as: UTF8.self)) + _ = try runtime.perform("poll", inputs: [:], scene: nil) + #expect(runtime.values["graphRevision"] == .string(fixture.workspace.graph.revision)) + #expect(runtime.values["revision"] == .string("")) + } + #expect(fixture.workspace.graph.graph.nodes.last?.inputs.first?.weight == 0.5) + #expect(fixture.workspace.mode == .graph) + } + + @Test func graphRequestsRejectUnsafeIDsAndMaskWorkBeforePublishing() throws { + let model = EditorAnimationGraphModel() + model.graph = EditorAnimationGraphModel.defaultGraph(clip: "Walk") + let original = model.graph, history = model.undoStack.count, revision = model.revision + var duplicate = original + duplicate.nodes.append(duplicate.nodes[0]) + #expect(throws: AnimationAuthoringError.self) { try model.applyGraph(duplicate, expectedRevision: revision) } + #expect(model.graph == original && model.undoStack.count == history && model.revision == revision) + let pose = SkeletalJointPose() + model.rig = try .init(nodes: (0..<512).map { .init(parentIndex: $0 == 0 ? nil : $0 - 1, restPose: pose, restMatrix: pose.matrix) }) + let mask = AnimationGraph.Mask(joints: (0..<256).map { .init(nodeIndex: $0) }) + // Missing root keeps the baseline safe: it fails validation before actually expanding this mask. + let expensive = AnimationGraph(root: "Missing", nodes: [.init(id: "Walk", clip: "Walk"), + .init(id: "Output", kind: .blend, inputs: [.init(node: "Walk", mask: mask)])]) + #expect(throws: AnimationAuthoringError.self) { try model.applyGraph(expensive, expectedRevision: model.revision) } + } + + @Test func graphRequestsShareCanvasHistoryWithRealToolJobsAndRejectStaleEdits() async throws { + let fixture = try graphRequestFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let workspace = fixture.workspace, service = fixture.service, workbench = fixture.workbench + let path = workspace.document.relativePath + let response = try await service.execute(.init(action: .graphInspect, path: path), workbench: workbench) + let revision = try #require(response["revision"] as? String) + #expect(response["ready"] as? Bool == true) + let original = workspace.graph.graph + let source = AdaScriptSource(path: "Graph.ada", source: """ + @tool(id: "test.sharedGraph", api: 2, platforms: ["windows", "macos"], permissions: ["editor.animations.read", "editor.animations.write"]) + class GraphTool { + @export var revision = ""; + func activate(editor) { editor.addPanel(id: "main", title: "Graph", location: "right", ui: "Panel.ui"); } + func apply(editor) { + editor.animations.submit(["action": "graphApply", "path": "Assets/Robot.glb", "expectedRevision": revision, + "graph": ["root": "Output", "nodes": [ + ["id": "Walk", "kind": "clip", "clip": "Walk", "speed": 1, "repeats": true, "inputs": [], "events": []], + ["id": "Output", "kind": "blend", "clip": "", "speed": 1, "repeats": true, + "inputs": [["node": "Walk", "weight": 0.25]], "events": []]]]]); + } + } + """) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + let runtime = try AdaScriptToolRuntime(sources: [source], schema: schema, grantedPermissions: schema.permissions) + defer { runtime.deactivate() } + try runtime.activate() + _ = try runtime.perform("apply", inputs: ["revision": .string(revision)], scene: nil) + let job = try #require(runtime.takeAnimationJobs().first) + _ = try await service.execute(job.request, workbench: workbench) + #expect(workspace.graph.graph.nodes.last?.inputs.first?.weight == 0.25) + #expect(workspace.graph.validGraph != nil) + await #expect(throws: AnimationAuthoringError.self) { _ = try await service.execute(job.request, workbench: workbench) } + _ = try await service.execute(.init(action: .graphUndo, path: path, expectedRevision: workspace.graph.revision), workbench: workbench) + #expect(workspace.graph.graph == original) + _ = try await service.execute(.init(action: .graphRedo, path: path, expectedRevision: workspace.graph.revision), workbench: workbench) + #expect(workspace.graph.graph.nodes.last?.inputs.first?.weight == 0.25) + workspace.graph.beginCanvasTransaction() + await #expect(throws: AnimationAuthoringError.self) { + _ = try await service.execute(.init(action: .graphUndo, path: path, expectedRevision: workspace.graph.revision), workbench: workbench) + } + workspace.graph.cancelCanvasTransaction() + #expect(try Data(contentsOf: fixture.modelURL) == fixture.original) + } + + @Test func graphRequestsRejectUnkeyedPreviewAndBindAsOneSceneTransaction() async throws { + let fixture = try graphRequestFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let sceneModel = EditorSceneModel.default(projectName: "Graph binding") + let content = try sceneModel.encodedYAML(), sceneID = "scene:Assets/Main.ascn" + fixture.workbench.open(.scene(.init(id: sceneID, title: "Main.ascn", relativePath: "Assets/Main.ascn", + absolutePath: fixture.root.appendingPathComponent("Assets/Main.ascn").path, content: content, lastSavedContent: content, + isReadOnly: false, sceneModel: sceneModel, errorMessage: nil, isDirty: false, + loadSummary: EditorSceneFileLoader.summary(from: content)))) + #expect(fixture.workbench.addModelAsset(fixture.workspace.document, to: sceneID, animation: "Walk", autoplay: true)) + let scene = try #require(fixture.workbench.sceneDocument(id: sceneID)) + let entity = try #require(scene.sceneModel?.entities.first { $0.components[EditorBuiltInComponentType.model3DSource] != nil }) + let request = AnimationAuthoringRequest(action: .graphBind, path: fixture.workspace.document.relativePath, + expectedRevision: fixture.workspace.graph.revision, scenePath: scene.relativePath, entityID: entity.id, + sceneRevision: EditorAgentSceneToolService.revision(for: scene.content)) + _ = try await fixture.service.execute(request, workbench: fixture.workbench) + let bound = try #require(fixture.workbench.sceneDocument(id: sceneID)) + #expect(try EditorAnimationGraphPayload.decode(bound.sceneModel?.entities.first { $0.id == entity.id }? + .components[EditorBuiltInComponentType.model3DSource]?["animationGraph"]) == fixture.workspace.graph.graph) + await #expect(throws: AnimationAuthoringError.self) { _ = try await fixture.service.execute(request, workbench: fixture.workbench) } + #expect(fixture.workbench.performDocumentHistory(redo: false)) + #expect(fixture.workbench.sceneDocument(id: sceneID)?.content == scene.content) + let rig = try #require(fixture.workspace.graph.rig) + fixture.workspace.graph.rig = try SkeletalRig(nodes: rig.nodes.enumerated().map { index, node in + .init(parentIndex: node.parentIndex, restPose: node.restPose, restMatrix: node.restMatrix, + name: index == 0 ? "Different root" : node.name, morphRestWeights: node.morphRestWeights) + }) + do { + _ = try await fixture.service.execute(request, workbench: fixture.workbench) + Issue.record("Matching clip names cannot authorize an index-based graph on a different rig.") + } catch { #expect((error as? AnimationAuthoringError)?.code == .incompatibleRig) } + fixture.workspace.timeline.poseValues["test:translation"] = [1, 2, 3] + await #expect(throws: AnimationAuthoringError.self) { + _ = try await fixture.service.execute(.init(action: .graphPreview, path: fixture.workspace.document.relativePath, + expectedRevision: fixture.workspace.graph.revision), workbench: fixture.workbench) + } + } +} + +@MainActor private func graphRequestFixture() throws -> (root: URL, modelURL: URL, original: Data, + workbench: EditorWorkbenchViewModel, service: EditorAnimationAuthoringService, workspace: EditorAnimationAssetWorkspace) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("GraphRequests-" + UUID().uuidString) + let assets = root.appendingPathComponent("Assets") + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + let source = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Tests/AdaEngineTests/Fixtures/SkeletalGardenAssets/GardenRobot.glb") + let bytes = try Data(contentsOf: source), url = assets.appendingPathComponent("Robot.glb") + try bytes.write(to: url) + let workbench = EditorWorkbenchViewModel() + workbench.configureAnimationAuthoring(projectURL: root, assetsURL: assets) + let document = EditorAssetDocument(id: "asset:Assets/Robot.glb", title: "Robot.glb", relativePath: "Assets/Robot.glb", absolutePath: url.path, + assetReference: "@res://Robot.glb", kind: .model3D, fileExtension: "glb", byteCount: nil, modifiedAt: nil, errorMessage: nil) + workbench.open(.asset(document)) + let workspace = workbench.animationWorkspace(for: document) + workspace.preview.asset = try AssetsManager.loadSync(ModelAsset3D.self, at: url.path).asset + workspace.configureLoadedAsset() + return (root, url, bytes, workbench, try #require(workbench.animationService), workspace) +} diff --git a/Editor/Tests/AdaEditorTests/EditorAnimationGraphTests.swift b/Editor/Tests/AdaEditorTests/EditorAnimationGraphTests.swift index 9aece6259..0b28d23a0 100644 --- a/Editor/Tests/AdaEditorTests/EditorAnimationGraphTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorAnimationGraphTests.swift @@ -30,7 +30,7 @@ struct EditorAnimationGraphTests { let track = try SkeletalAnimationTrack(nodeIndex: 0, path: .translation, interpolation: .linear, times: [0, 1], values: [.zero, .zero]) author.clips = [.init(name: "Move", tracks: [track])] author.graph = EditorAnimationGraphModel.defaultGraph(clip: "Move") - let container = UIContainerView(rootView: EditorAnimationGraphEditor(model: author)) + let container = UIContainerView(rootView: EditorAnimationGraphEditor(model: author, presentation: .properties)) container.frame = Rect(x: 0, y: 0, width: 960, height: 360) container.bounds.size = container.frame.size func settle() async throws { @@ -89,14 +89,14 @@ struct EditorAnimationGraphTests { let content = try scene.encodedYAML() try content.write(to: sceneURL, atomically: true, encoding: .utf8) let document = EditorSceneDocument( - id: "Scene", - title: "Main", - relativePath: "Main.ascn", + id: "Scene", + title: "Main", + relativePath: "Main.ascn", absolutePath: sceneURL.path, - content: content, - lastSavedContent: content, - sceneModel: scene, - isDirty: false, + content: content, + lastSavedContent: content, + sceneModel: scene, + isDirty: false, loadSummary: .init(entityCount: 0, warnings: []) ) let workbench = EditorWorkbenchViewModel(openDocuments: [.scene(document)], activeDocumentID: "Scene") @@ -108,9 +108,9 @@ struct EditorAnimationGraphTests { #expect(author.validationMessage == nil) let graph = try #require(author.validGraph) let target = EditorSkeletalAnimationTarget( - documentID: "Scene", + documentID: "Scene", entityID: entity.id, - source: .init(source: "@res://TwoBoneRibbon.glb"), + source: .init(source: "@res://TwoBoneRibbon.glb"), sceneURL: sceneURL ) #expect(try workbench.setAnimationGraph(graph, target: target, autoplay: true)) diff --git a/Editor/Tests/AdaEditorTests/EditorAnimationModifierTests.swift b/Editor/Tests/AdaEditorTests/EditorAnimationModifierTests.swift new file mode 100644 index 000000000..c560c5f03 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorAnimationModifierTests.swift @@ -0,0 +1,270 @@ +import AdaEngine +import AdaScriptCompilerCore +import Foundation +import Math +import Testing + +@testable import AdaEditor + +extension EditorAnimationAuthoringTests { + @Test + func modifierDraftNeverAutosavesThroughTheEditorDocumentCallback() async throws { + let fixture = try await modifierFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + try fixture.document.save(expectedRevision: fixture.document.revision) + let original = try Data(contentsOf: fixture.document.url) + let editor = EditorViewModel(project: .init(name: "IK", path: fixture.root.path), autosaveDelay: .milliseconds(10)) + defer { editor.studioTools.close() } + let service = try #require(editor.workbench.animationService) + let document = try service.openModifiers(fixture.document.relativePath) + var value = try #require(document.stack.modifiers.first) + value.influence = 0.25 + _ = try await service.execute(.init(action: .modifierApply, path: document.relativePath, expectedRevision: document.revision, + modifierOperations: [.init(kind: .update, modifierID: value.id, modifier: value)]), workbench: editor.workbench) + try await Task.sleep(for: .milliseconds(100)) + #expect(document.isDirty) + let unchanged = try Data(contentsOf: document.url) == original + #expect(unchanged) + #expect(editor.autosaveTasks["asset:" + document.relativePath] == nil) + } + + @Test + func ikSceneBindingRejectsStaleRevisionsAndCommitsOneUndoStepFromNullBindings() async throws { + let fixture = try await modifierFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + try fixture.document.save(expectedRevision: fixture.document.revision) + var model = EditorSceneModel.default(projectName: "IK") + model.entities.append(.init(id: "robot", name: "Robot", enabled: true, parent: nil, components: [ + EditorBuiltInComponentType.model3DSource: ["source": .string("@res://Robot.glb"), "modifierBindings": .null] + ])) + model.entities.append(.init(id: "target", name: "Target", enabled: true, parent: nil, components: [:])) + let content = try model.encodedYAML(), path = "Assets/Main.ascn", id = "scene:" + path + fixture.workbench.open(.scene(.init(id: id, title: "Main.ascn", relativePath: path, + absolutePath: fixture.root.appendingPathComponent(path).path, content: content, lastSavedContent: content, + isReadOnly: false, sceneModel: model, errorMessage: nil, isDirty: false, statusMessage: nil, + loadSummary: EditorSceneFileLoader.summary(from: content)))) + let bindings = ["arm": AnimationIKBinding3D(target: .init(entity: "target", point: [1, 2, 3]))] + var request = AnimationAuthoringRequest(action: .modifierBind, path: fixture.document.relativePath, + expectedRevision: fixture.document.revision, scenePath: path, entityID: "robot", sceneRevision: "stale", modifierBindings: bindings) + await #expect(throws: AnimationAuthoringError.self) { + _ = try await fixture.service.execute(request, workbench: fixture.workbench) + } + #expect(fixture.workbench.sceneDocument(id: id)?.content == content) + #expect(fixture.workbench.sceneUndoHistory[id]?.isEmpty != false) + request.sceneRevision = EditorAgentSceneToolService.revision(for: content) + _ = try await fixture.service.execute(request, workbench: fixture.workbench) + let bound = try #require(fixture.workbench.sceneDocument(id: id)) + let payload = try #require(bound.sceneModel?.entities.first(where: { $0.id == "robot" })?.components[EditorBuiltInComponentType.model3DSource]) + #expect(payload["animationModifiers"] == .string("@res://Arm.animmod")) + #expect(fixture.workbench.sceneUndoHistory[id]?.count == 1) + #expect(fixture.workbench.performDocumentHistory(redo: false)) + #expect(fixture.workbench.sceneDocument(id: id)?.content == content) + #expect(fixture.workbench.performDocumentHistory(redo: true)) + #expect(fixture.workbench.sceneDocument(id: id)?.content == bound.content) + await #expect(throws: AnimationAuthoringError.self) { + _ = try await fixture.service.execute(request, workbench: fixture.workbench) + } + #expect(fixture.workbench.sceneDocument(id: id)?.content == bound.content) + } + + @Test + func inspectorSceneDraftRestoresBindingsOffsetsAndLoadedRevision() throws { + let draft = EditorIKSceneBindingDraft() + let original = AnimationIKBinding3D(target: .init(entity: "hand-target", point: [0.1, -0.2, 0.3]), pole: .init(entity: "elbow-pole", point: [2, 3, 4])) + draft.load(original, sceneRevision: "scene-before") + #expect(draft.targetEntity == "hand-target") + #expect(draft.poleEntity == "elbow-pole") + #expect(try draft.binding() == original) + #expect(draft.sceneRevision == "scene-before") + draft.targetEntity = "" + #expect(try draft.binding().target == nil) + draft.load(.init(target: .init(point: [7, 8, 9])), sceneRevision: "other-modifier") + #expect(draft.poleEntity.isEmpty) + #expect(try draft.binding() == .init(target: .init(point: [7, 8, 9]))) + #expect(draft.targetUsesModelPoint) + draft.targetOffset = ["1", "2", "3"] + #expect(try draft.binding().target?.point == Vector3(1, 2, 3)) + draft.targetUsesModelPoint = false + #expect(try draft.binding().target == nil) + draft.load(nil, sceneRevision: "fresh") + #expect(try draft.binding() == .init()) + #expect(!draft.targetUsesModelPoint && !draft.poleUsesModelPoint) + draft.poleUsesModelPoint = true + draft.poleOffset = ["4", "5", "6"] + #expect(try draft.binding().pole == .init(point: [4, 5, 6])) + } + + @Test + func modelTabRoutesModifierUndoAndRedoToTheSharedDraft() async throws { + let fixture = try await modifierFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + fixture.workspace.timeline.inspectorMode = "modifiers" + let before = fixture.document.stack + var value = try #require(before.modifiers.first) + value.influence = 0.25 + try fixture.workspace.editModifiers([.init(kind: .update, modifierID: value.id, modifier: value)]) + #expect(fixture.workbench.performDocumentHistory(redo: false)) + #expect(fixture.document.stack == before) + #expect(fixture.workbench.performDocumentHistory(redo: true)) + #expect(fixture.document.stack.modifiers.first?.influence == 0.25) + } + + @Test + func closingSavedIKTabPreservesAttachedModelDraftAndSubsequentEdits() async throws { + let fixture = try await modifierFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + try fixture.document.save(expectedRevision: fixture.document.revision) + let id = "asset:" + fixture.document.relativePath + fixture.workbench.closeDocument(id: id) + #expect(!fixture.workbench.openDocuments.contains { $0.id == id }) + #expect(!fixture.document.isDiscarded) + var value = try #require(fixture.document.stack.modifiers.first) + value.influence = 0.25 + try fixture.workspace.editModifiers([.init(kind: .update, modifierID: value.id, modifier: value)]) + #expect(fixture.document.isDirty) + #expect(fixture.workbench.openDocuments.contains { $0.id == id && $0.isDirty }) + #expect(fixture.service.modifierDocuments[fixture.document.url.path] === fixture.document) + } + + @Test + func invalidIKSelectionDoesNotCreateOrAttachADraft() async throws { + let fixture = try await modifierFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + fixture.workspace.modifiers = nil + fixture.workspace.modifierPath = "Assets/Rejected.animmod" + fixture.workspace.timeline.selectedNodes = [try #require(fixture.document.stack.modifiers.first?.joints.first)] + #expect(throws: (any Error).self) { try fixture.workspace.addModifier(.twoBone) } + #expect(fixture.workspace.modifiers == nil) + #expect(!fixture.service.modifierDocuments.values.contains { $0.relativePath == "Assets/Rejected.animmod" }) + } + + @Test + func ikDraftIsSharedByWorkspaceAgentAndRealToolWithExplicitSaveAndUndo() async throws { + let fixture = try await modifierFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = fixture.document + let start = document.stack + let cfg = try #require(start.modifiers.first) + var changed = cfg + changed.influence = 0.5 + try fixture.workspace.editModifiers([.init(kind: .update, modifierID: cfg.id, modifier: changed)]) + let uiSample = try await fixture.service.execute(.init(action: .modifierSample, path: document.relativePath, limit: 8), workbench: fixture.workbench) + #expect(document.stack.modifiers[0].influence == 0.5) + #expect(!FileManager.default.fileExists(atPath: document.url.path)) + _ = try await fixture.service.execute(.init(action: .modifierUndo, path: document.relativePath, expectedRevision: document.revision), workbench: fixture.workbench) + #expect(document.stack == start) + try document.redo(expectedRevision: document.revision) + let example = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Documentation/Examples/StudioTools/IKHelper/IKHelper.ada") + let source = AdaScriptSource(path: "IKHelper.ada", source: try String(contentsOf: example, encoding: .utf8)) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + let runtime = try AdaScriptToolRuntime(sources: [source], schema: schema, grantedPermissions: schema.permissions) + defer { runtime.deactivate() } + try runtime.activate() + _ = try runtime.perform("sample", inputs: [:], scene: nil) + let job = try #require(runtime.takeAnimationJobs().first) + let toolSample = try await fixture.service.execute(job.request, workbench: fixture.workbench) + let response = try JSONSerialization.data(withJSONObject: ["status": "completed", "result": toolSample]) + try runtime.completeAnimationJob(job.id, responseJSON: String(decoding: response, as: UTF8.self)) + _ = try runtime.perform("poll", inputs: [:], scene: nil) + let first = try JSONSerialization.data(withJSONObject: uiSample["poses"] ?? [], options: .sortedKeys) + let second = try JSONSerialization.data(withJSONObject: toolSample["poses"] ?? [], options: .sortedKeys) + let equalPages = first == second + #expect(equalPages) + _ = try await fixture.service.execute(.init(action: .modifierSave, path: document.relativePath, expectedRevision: document.revision), workbench: fixture.workbench) + let reopened = try EditorAnimationAuthoringService(projectURL: fixture.root, assetsURL: fixture.root.appendingPathComponent("Assets")).openModifiers(document.relativePath) + #expect(reopened.stack == document.stack) + #expect(try Data(contentsOf: fixture.modelURL) == fixture.original) + } + + @Test + func ikGizmoDragCommitsOneHistoryEntryAndCancelRestoresTheSavedDraft() async throws { + let fixture = try await modifierFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let workspace = fixture.workspace, document = fixture.document + let asset = try #require(workspace.preview.asset) + let world = World() + let root = asset.instantiate(in: world) + root.components[Transform.self] = workspace.preview.normalizationTransform + workspace.preview.bind(to: world) + workspace.camera.attach(to: world, bounds: workspace.preview.normalizedBounds) + workspace.camera.setViewportSize(Size(width: 800, height: 600)) + workspace.timeline.inspectorMode = "modifiers" + workspace.selectedModifierID = document.stack.modifiers.first?.id + let initial = document.stack + let target = try #require(initial.modifiers.first).target + let point = (workspace.preview.normalizationTransform.matrix * Vector4(target, 1)).xyz + let screen = try #require(workspace.camera.project(point)) + func send(_ phase: MouseEvent.Phase, x: Float) { + #expect(workspace.handleModifierInput(MouseEvent(window: RID(), button: .left, + mousePosition: Point(screen.x + x, screen.y), phase: phase, modifierKeys: [], time: 0), camera: workspace.camera)) + } + let revision = document.revision + send(.began, x: 0) + for offset in 1...10 { send(.changed, x: Float(offset) * 2) } + #expect(document.revision == revision) + send(.ended, x: 20) + #expect(document.stack != initial) + try document.undo(expectedRevision: document.revision) + #expect(document.stack == initial) + send(.began, x: 0); send(.changed, x: 20); send(.cancelled, x: 20) + #expect(document.stack == initial) + #expect(workspace.preview.capturedAnimationPlayer()?.poseModifiers == initial.modifiers) + withExtendedLifetime(world) {} + } + + @Test + func staleIKTransactionsAndExternalDiskWritesPreserveTheCurrentDraft() async throws { + let fixture = try await modifierFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = fixture.document + let before = document.stack + await #expect(throws: AnimationAuthoringError.self) { + _ = try await fixture.service.execute(.init(action: .modifierApply, path: document.relativePath, expectedRevision: "stale", + modifierOperations: [.init(kind: .remove, modifierID: "arm")]), workbench: fixture.workbench) + } + #expect(document.stack == before) + try document.save(expectedRevision: document.revision) + try Data("external".utf8).write(to: document.url) + #expect(throws: AnimationAuthoringError.self) { try document.save(expectedRevision: document.revision) } + #expect(try Data(contentsOf: document.url) == Data("external".utf8)) + } +} + +@MainActor +private func modifierFixture() async throws -> (root: URL, modelURL: URL, original: Data, workbench: EditorWorkbenchViewModel, + service: EditorAnimationAuthoringService, workspace: EditorAnimationAssetWorkspace, document: EditorAnimationModifierDocument) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("IKRequests-" + UUID().uuidString) + let assets = root.appendingPathComponent("Assets") + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + let source = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Tests/AdaEngineTests/Fixtures/SkeletalGardenAssets/GardenRobot.glb") + let bytes = try Data(contentsOf: source), modelURL = assets.appendingPathComponent("Robot.glb") + try bytes.write(to: modelURL) + let workbench = EditorWorkbenchViewModel() + workbench.configureAnimationAuthoring(projectURL: root, assetsURL: assets) + let service = try #require(workbench.animationService) + let assetDocument = EditorAssetDocument(id: "asset:Assets/Robot.glb", title: "Robot.glb", relativePath: "Assets/Robot.glb", absolutePath: modelURL.path, + assetReference: "@res://Robot.glb", kind: .model3D, fileExtension: "glb", byteCount: nil, modifiedAt: nil, errorMessage: nil) + workbench.open(.asset(assetDocument)) + let workspace = workbench.animationWorkspace(for: assetDocument) + workspace.preview.asset = try AssetsManager.loadSync(ModelAsset3D.self, at: modelURL.path).asset + workspace.configureLoadedAsset() + let rig = try #require(workspace.preview.asset?.animationRig) + let player = try SkeletalAnimationPlayer(rig: rig, clips: []) + let end = try #require(rig.nodes.indices.first { index in + guard let middle = rig.nodes[index].parentIndex, let root = rig.nodes[middle].parentIndex, + [root, middle, index].allSatisfy({ rig.nodes[$0].restPose != nil }) else { return false } + return (player.globalTransforms[index].origin - player.globalTransforms[middle].origin).length > 1e-5 + && (player.globalTransforms[middle].origin - player.globalTransforms[root].origin).length > 1e-5 + }) + let middle = try #require(rig.nodes[end].parentIndex), first = try #require(rig.nodes[middle].parentIndex) + let document = try service.createModifiers("Assets/Arm.animmod", binding: AnimationRigBinding(rig: rig, sourceAsset: "Assets/Robot.glb")) + var stack = document.stack + try stack.apply([.init(kind: .add, modifier: .init(id: "arm", kind: .twoBone, joints: [first, middle, end].map { "node-\($0)" }, + target: player.globalTransforms[end].origin))]) + try document.commit(stack, expectedRevision: document.revision) + workspace.modifierPath = document.relativePath + try workspace.openModifiers() + return (root, modelURL, bytes, workbench, service, workspace, document) +} diff --git a/Editor/Tests/AdaEditorTests/EditorAnimationPreviewClockTests.swift b/Editor/Tests/AdaEditorTests/EditorAnimationPreviewClockTests.swift new file mode 100644 index 000000000..eeb90cd97 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorAnimationPreviewClockTests.swift @@ -0,0 +1,25 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +extension EditorAnimationAuthoringTests { + @Test func previewSeekWaitsForTheCenterViewportPlayer() throws { + let source = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Tests/AdaEngineTests/Fixtures/SkeletalGardenAssets/GardenRobot.glb") + let asset = try #require(AssetsManager.loadSync(ModelAsset3D.self, at: source.path).asset) + let preview = EditorModelAssetPreviewModel() + preview.asset = asset + preview.playsAnimation = false + preview.selectedAnimation = "Walk" + // API preview can arrive while the GPU viewport is still being mounted. + preview.seekGraph(to: 0.25) + let world = World(name: "Center animation viewport") + _ = asset.instantiate(in: world) + preview.bind(to: world) + let player = try #require(preview.capturedAnimationPlayer()) + #expect(abs(player.time - 0.25) < 0.000001) + #expect(!player.isPlaying) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorAnimationTimelineInteractionTests.swift b/Editor/Tests/AdaEditorTests/EditorAnimationTimelineInteractionTests.swift new file mode 100644 index 000000000..74bbbe007 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorAnimationTimelineInteractionTests.swift @@ -0,0 +1,396 @@ +@_spi(AdaEngine) import AdaEngine +import AdaScriptCompilerCore +@_spi(Internal) import AdaUI +import Foundation +import Testing + +@testable import AdaEditor + +extension EditorAnimationAuthoringTests { + @Test func nativeCurveFieldsCommitOneUndoAndShareTheirPayloadWithTheAgentAndRealToolVM() async throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = fixture.document, model = fixture.workspace.timeline + try document.apply([.init(kind: .configureTrack, clipID: "walk", trackID: "ta", interpolation: .bezier)], expectedRevision: document.revision) + fixture.workspace.refreshLibrary() + model.selectKey(.init(track: "ta", key: "a0")) + let original = document.library + let container = UIContainerView(rootView: EditorAnimationInspector(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 340, height: 3000); container.bounds.size = container.frame.size + func settle() async { for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } } + await settle() + for (suffix, text) in [("Time", "0"), ("Value", "0.5")] { + let selector = UINodeSelector.accessibilityIdentifier("AdaEditor.Animation.Key.Bezier.Outgoing." + suffix) + _ = try container.uiScrollToNode(matching: selector) + _ = try container.uiTapNode(matching: selector) + try #require(container.uiPerformTextEditingCommand(.selectAll)) + for character in text { + container.onTextInputEvent(TextInputEvent(window: RID(), text: String(character), action: .insert, time: 0)) + await settle() + } + } + #expect(document.library == original, "Numeric drafts must not change playback before Apply.") + let apply = UINodeSelector.accessibilityIdentifier("AdaEditor.Animation.Key.Curve.Apply") + _ = try container.uiScrollToNode(matching: apply); _ = try container.uiTapNode(matching: apply) + #expect(model.error.isEmpty) + let changed = document.library + let key = try #require(changed.clips[0].tracks.first { $0.id == "ta" }?.keys.first) + #expect(key.curve?.outgoing?.first == .init(timeOffset: 0, valueOffset: 0.5)) + #expect(try changed.sample(clipID: "walk", at: 0.25).poses[1].translation.x > 0.4) + try document.undo(expectedRevision: document.revision) + #expect(document.library == original) + try document.redo(expectedRevision: document.revision) + #expect(document.library == changed) + try document.save(expectedRevision: document.revision) + #expect(try AnimationLibrary.decode(Data(contentsOf: document.url)) == changed) + + let source = AdaScriptSource(path: "Curves.ada", source: """ + @tool(id: "test.curves", api: 2, platforms: ["macos", "windows"], permissions: ["editor.animations.read", "editor.animations.write"]) + class Curves { + func activate(editor) { editor.addPanel(id: "main", title: "Curves", location: "right", ui: "Panel.ui"); } + func apply(editor) { + editor.animations.submit(["action": "apply", "path": "Assets/Tool.animlib", "expectedRevision": "test", + "operations": [["id": "curve", "kind": "key.put", "clipID": "walk", "trackID": "ta", "replaceExisting": true, + "key": ["id": "a0", "time": 0, "value": [0, 0, 0], "curve": ["easing": ["family": "linear", "direction": "inOut"], + "outgoing": [["timeOffset": 0, "valueOffset": 0.5], ["timeOffset": 0.3333333333333333, "valueOffset": 0], + ["timeOffset": 0.3333333333333333, "valueOffset": 0]]]]]]]); + } + } + """) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + let runtime = try AdaScriptToolRuntime(sources: [source], schema: schema, grantedPermissions: schema.permissions) + defer { runtime.deactivate() } + try runtime.activate(); _ = try runtime.perform("apply", inputs: [:], scene: nil) + let request = try #require(runtime.takeAnimationJobs().first).request + let operations = try #require(request.operations) + var fromTool = original + try fromTool.apply(operations) + #expect(fromTool == changed) + // The agent and native Inspector use this same Codable request and production edit path. + let decoded = try JSONDecoder().decode(AnimationAuthoringRequest.self, from: JSONEncoder().encode(request)) + var fromAgent = original + try fromAgent.apply(try #require(decoded.operations)) + #expect(fromAgent == changed) + } + + @Test func nativeCurveInspectorExposesEasingAndBezierFieldsAndKeyCaptureRetainsTheirData() async throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = fixture.document, model = fixture.workspace.timeline + var track = try #require(document.library.clips[0].tracks.first) + track.interpolation = .bezier + track.keys[0].curve = .init(easing: .init(family: .elastic), outgoing: Array(repeating: .init(timeOffset: 0.1, valueOffset: 0), count: 3)) + try document.apply([.init(kind: .removeTrack, clipID: "walk", trackID: track.id), + .init(kind: .createTrack, clipID: "walk", track: track)], expectedRevision: document.revision) + fixture.workspace.refreshLibrary() + model.selectKey(.init(track: track.id, key: track.keys[0].id)) + model.seek(track.keys[0].time) + let container = UIContainerView(rootView: EditorAnimationInspector(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 340, height: 3000); container.bounds.size = container.frame.size + for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } + for id in ["Easing.Family", "Easing.Direction", "Easing.Amplitude", "Easing.Period", "Bezier.Outgoing.Time", "Bezier.Outgoing.Value", "Curve.Apply"] { + #expect(!container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.Animation.Key." + id)).isEmpty) + } + try model.insertKeys(channels: [track.channel]) + #expect(document.library.clips[0].tracks.first { $0.id == track.id }?.keys.first?.curve == track.keys[0].curve) + } + + @Test func inspectorCompletionChannelRowsInsertOnlyTheirChosenChannel() throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let model = fixture.workspace.timeline + model.expandedChannels = ["root", "b"] + let input = EditorAnimationTimelineInputView(frame: Rect(x: 0, y: 0, width: 800, height: 600)) + input.model = model + func select(_ node: String) throws { + let row = try #require(model.rows.firstIndex { $0.nodeID == node && $0.channel == .rotation }) + let point = Point(80, EditorAnimationTimeline.rulerHeight + (Float(row) + 0.5) * EditorAnimationTimeline.rowHeight) + for phase in [MouseEvent.Phase.began, .ended] { + input.onMouseEvent(MouseEvent(window: RID(), button: .left, mousePosition: point, phase: phase, modifierKeys: [], time: 0)) + } + } + try select("root") + try model.insertKeys() + #expect(fixture.document.library.clips[0].tracks.filter { $0.nodeID == "root" }.map(\.channel) == [.rotation]) + try select("b") + model.seek(0.25) + try model.insertKeys() + #expect(fixture.document.library.clips[0].tracks.filter { $0.nodeID == "b" }.map(\.channel) == [.rotation]) + } + + @Test func inspectorCompletionUnanimatedNodeDoesNotExposeAnotherNodesTrack() async throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + fixture.workspace.timeline.selectNode("root") + let container = UIContainerView(rootView: EditorAnimationInspector(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 340, height: 1200); container.bounds.size = container.frame.size + for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } + #expect(container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.Animation.Track.Interpolation")).isEmpty) + #expect(fixture.workspace.timeline.selectedTrack == nil) + #expect(!fixture.document.canUndo) + } + + @Test func inspectorCompletionRecoversAnExternalConflictAndDiscardsThroughTheSharedService() async throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = fixture.document + try document.apply([.init(kind: .configureClip, clipID: "walk", name: "Dirty")], expectedRevision: document.revision) + var external = document.library + external.clips[0].name = "External" + try external.encodedData().write(to: document.url) + let container = UIContainerView(rootView: EditorAnimationInspector(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 340, height: 1200); container.bounds.size = container.frame.size + func settle() async { for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } } + await settle() + let reload = UINodeSelector.accessibilityIdentifier("AdaEditor.Animation.Library.DiscardReload") + #expect(!container.uiFindNodes(matching: reload).isEmpty) + guard !container.uiFindNodes(matching: reload).isEmpty else { return } + _ = try container.uiScrollToNode(matching: reload) + _ = try container.uiTapNode(matching: reload) + #expect(document.library == external && !document.isDirty) + try document.apply([.init(kind: .configureClip, clipID: "walk", name: "Discard me")], expectedRevision: document.revision) + await settle() + let discard = UINodeSelector.accessibilityIdentifier("AdaEditor.Animation.Library.Discard") + _ = try container.uiScrollToNode(matching: discard) + _ = try container.uiTapNode(matching: discard) + #expect(document.isDiscarded) + #expect(fixture.workspace.library == nil) + #expect(fixture.workspace.service?.documents.isEmpty == true) + #expect(try AnimationLibrary.decode(Data(contentsOf: document.url)) == external) + } + + @Test func finalWorkspaceImplicitPositionsAndActiveGestureHistoryAreStable() throws { + let model = EditorAnimationGraphModel() + model.graph = EditorAnimationGraphModel.defaultGraph(clip: "Walk") + let graph = model.graph, history = model.undoStack.count + let geometry = EditorNodeCanvasGeometry(nodes: model.canvasItems, pan: .zero, zoom: 1) + let pointer = model.canvasInteraction(), start = Vector2(40, 15) + pointer.drag(from: start, to: start + [80, 20], geometry: geometry, wires: model.canvasWires, selection: [], state: model.canvas) + model.undoCanvas() + #expect(model.graph == graph, "Undo is unavailable while a pointer transaction is active.") + pointer.drag(from: start, to: start, geometry: geometry, wires: model.canvasWires, selection: ["Clip1"], state: model.canvas) + pointer.end(at: start, geometry: geometry) + #expect(model.positions.isEmpty) + #expect(model.undoStack.count == history) + #expect(model.graph == graph) + } + + @Test func finalWorkspaceTrackFiltersAreReachableWithoutOpeningInspector() async throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let container = UIContainerView(rootView: EditorAnimationTimeline(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 800, height: 350); container.bounds.size = container.frame.size + for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } + for id in ["Filter", "SelectedOnly", "AnimatedOnly", "ChannelFilter"] { + #expect(!container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.AnimationTimeline." + id)).isEmpty) + } + } + + @Test func visualRegressionReturningWireAndCardToOriginIsANoOp() throws { + let model = EditorAnimationGraphModel() + model.graph = EditorAnimationGraphModel.defaultGraph(clip: "Walk") + model.graph.nodes[1].inputs[0].weight = 0.3 + model.graph.nodes[1].inputs[0].mask = .init(defaultWeight: 0.2) + model.positions = ["Clip1": [0, 0], "Output": [300, 0]] + let before = model.graph, positions = model.positions, history = model.undoStack.count + let geometry = EditorNodeCanvasGeometry(nodes: model.canvasItems, pan: .zero, zoom: 1) + let port = try #require(geometry.port(.init(node: "Output", port: "0", isOutput: false))) + let wire = model.canvasInteraction() + wire.drag(from: port, to: port + [40, 40], geometry: geometry, wires: model.canvasWires, selection: [], state: model.canvas) + wire.end(at: port, geometry: geometry) + #expect(model.graph == before) + #expect(model.undoStack.count == history) + let pointer = model.canvasInteraction() + let start = Vector2(40, 15) + pointer.drag(from: start, to: start + [80, 20], geometry: geometry, wires: model.canvasWires, selection: [], state: model.canvas) + pointer.drag(from: start, to: start, geometry: geometry, wires: model.canvasWires, selection: ["Clip1"], state: model.canvas) + pointer.end(at: start, geometry: geometry) + #expect(model.positions == positions) + #expect(model.undoStack.count == history) + } + + @Test func visualRegressionEscapeEndsTheActualRulerAndMarqueePointerCapture() async throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let container = UIContainerView(rootView: EditorAnimationTimeline(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 800, height: 350); container.bounds.size = container.frame.size + for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } + let ruler = try #require(container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.AnimationTimeline.Ruler")).first).absoluteFrame + func mouse(_ phase: MouseEvent.Phase, _ point: Point) { + container.onMouseEvent(MouseEvent(window: RID(), button: .left, mousePosition: point, phase: phase, modifierKeys: [], time: 0)) + } + func escape() { container.onKeyEvent(KeyEvent(window: RID(), keyCode: .escape, modifiers: [], status: .down, time: 0, isRepeated: false)) } + let start = Point(ruler.minX + 50, ruler.midY) + mouse(.began, start); mouse(.changed, start + Point(30, 0)) + escape() + let time = fixture.workspace.timeline.time + mouse(.changed, start + Point(150, 0)); mouse(.ended, start + Point(150, 0)) + #expect(fixture.workspace.timeline.time == time) + let box = Point(ruler.maxX - 30, ruler.maxY + 10) + mouse(.began, box); mouse(.changed, box + Point(-30, 40)) + escape() + mouse(.changed, box + Point(-80, 80)); mouse(.ended, box + Point(-80, 80)) + #expect(fixture.workspace.timeline.marquee == nil) + #expect(fixture.workspace.timeline.selectedKeys.isEmpty) + } + + @Test func visualRegressionSmallWindowKeepsAMeaningfulModelArea() { + let model = EditorViewModel(project: nil) + defer { model.studioTools.close() } + model.toolStrip.selectLeftBottomTool(.init(identifier: "animator", title: "Animator", icon: "")) + model.showBottomPanel = true + let layout = EditorWorkspaceResizeState().layout(in: Size(width: 1024, height: 550), viewModel: model) + #expect(layout.mainPanelHeight >= 260) + #expect(layout.bottomPanelHeight >= 220) + } + + @Test func previewWorkspaceSettingsCurvesAndPoseFieldsAreRealPanels() async throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let workspace = fixture.workspace + workspace.mode = .curves + workspace.timeline.selectNode("a") + workspace.timeline.selectedTrackID = "ta" + workspace.timeline.inspectorMode = "preview" + let container = UIContainerView(rootView: HStack { + EditorAnimationTimeline(workspace: workspace) + EditorAnimationInspector(workspace: workspace).frame(width: 300) + }.theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 1200, height: 600) + container.bounds.size = container.frame.size + for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } + #expect(!container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.Animation.PreviewSettings")).isEmpty) + #expect(!container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.AnimationCurves.Editor")).isEmpty) + workspace.timeline.inspectorMode = "selection" + for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } + #expect(!container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.Animation.Pose.rotation")).isEmpty) + } + + @Test func previewWorkspaceRemembersGraphAndPanelStateOnReopen() throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let workspace = fixture.workspace + workspace.graph.graph = EditorAnimationGraphModel.defaultGraph(clip: "Walk") + workspace.graph.positions["Clip1"] = [420, 180] + workspace.timeline.modelPanelWidth = 280 + workspace.timeline.showsModelPanel = false + workspace.timeline.selectedNodes = ["b"] + workspace.mode = .graph + workspace.cancel() + let reopened = EditorAnimationAssetWorkspace(document: workspace.document, service: workspace.service) + defer { reopened.cancel() } + #expect(reopened.graph.graph == workspace.graph.graph) + #expect(reopened.graph.positions == workspace.graph.positions) + #expect(reopened.timeline.modelPanelWidth == 280) + #expect(!reopened.timeline.showsModelPanel) + #expect(reopened.timeline.selectedNodes == ["b"]) + #expect(reopened.mode == .graph) + } + + @Test func previewWorkspaceInspectorRefreshesSameKeyAfterSharedEdits() async throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + fixture.workspace.timeline.selectKey(.init(track: "ta", key: "a0")) + let container = UIContainerView(rootView: EditorAnimationInspector(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 320, height: 800) + container.bounds.size = container.frame.size + for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } + let replacement = AnimationLibraryKey(id: "a0", time: 0, value: [2, 0, 0]) + _ = try fixture.workspace.service?.apply(path: fixture.document.relativePath, expectedRevision: fixture.document.revision, + operations: [.init(kind: .putKey, clipID: "walk", trackID: "ta", key: replacement, replaceExisting: true)]) + for _ in 0..<5 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } + _ = try container.uiScrollToNode(matching: .accessibilityIdentifier("AdaEditor.Animation.Key.Update")) + _ = try container.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.Animation.Key.Update")) + #expect(fixture.document.library.clips[0].tracks[0].keys[0].value == [2, 0, 0]) + #expect(fixture.workspace.timeline.error.isEmpty) + } + + @Test func timelineCubicMorphDragIsOneUndoAndCollisionRollsBack() throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let model = fixture.workspace.timeline + let before = fixture.document.library + model.expandedChannels = ["a"] + model.snap = 0 + model.pixelsPerSecond = 100 + model.viewportSize = Size(width: 700, height: 300) + let row = try #require(model.rows.firstIndex { $0.track?.id == "morph" }) + let input = EditorAnimationTimelineInputView(frame: Rect(x: 0, y: 0, width: 700, height: 300)) + input.model = model + let from = Point(EditorAnimationTimeline.labelsWidth, EditorAnimationTimeline.rulerHeight + (Float(row) + 0.5) * EditorAnimationTimeline.rowHeight) + for (phase, point) in [(MouseEvent.Phase.began, from), (.changed, from + Point(100, 0)), (.ended, from + Point(100, 0))] { + input.onMouseEvent(MouseEvent(window: RID(), button: .left, mousePosition: point, phase: phase, modifierKeys: [], time: 0)) + } + #expect(model.error.isEmpty) + #expect(fixture.document.library.clips[0].tracks[2].keys.map(\.time) == [0.5, 1]) + try fixture.document.undo(expectedRevision: fixture.document.revision) + #expect(fixture.document.library == before) + model.selectedKeys = [.init(track: "morph", key: "m0")] + model.beginKeyDrag(); model.dragDelta = 0.5; model.endKeyDrag() + #expect(!model.error.isEmpty) + #expect(fixture.document.library == before) + } + + @Test func timelineDragCannotEditAnotherDuplicatedClip() throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + try fixture.document.apply([.init(kind: .duplicateClip, clipID: "walk", newID: "other", name: "Other")], expectedRevision: fixture.document.revision) + let model = fixture.workspace.timeline + model.selectedKeys = [.init(track: "ta", key: "a0")] + model.beginKeyDrag() + model.dragDelta = 0.25 + let before = fixture.document.library + fixture.document.selectedClipID = "other" + model.endKeyDrag() + #expect(fixture.document.library == before) + #expect(!model.error.isEmpty) + } + + @Test func timelinePlainKeySelectionReplacesBoneScopeAndFiltersClampScroll() throws { + let fixture = try timelineInteractionFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let model = fixture.workspace.timeline + model.selectKey(.init(track: "ta", key: "a0")) + model.selectKey(.init(track: "tb", key: "b0")) + #expect(model.selectedNodes == ["b"]) + model.selectKey(.init(track: "ta", key: "a0"), additive: true) + #expect(model.selectedNodes == ["a", "b"]) + model.viewportSize = Size(width: 700, height: 300) + model.verticalOffset = 10_000 + model.search = "Bone B" + #expect(model.verticalOffset == 0) + #expect(model.rows.contains { $0.nodeID == "b" }) + } +} + +@MainActor +private func timelineInteractionFixture() throws -> (root: URL, workspace: EditorAnimationAssetWorkspace, document: EditorAnimationLibraryDocument) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("TimelineInteraction-" + UUID().uuidString) + let assets = root.appendingPathComponent("Assets") + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + let nodes: [AnimationRigBinding.Node] = [ + .init(id: "root", name: "Root", restTranslation: .zero, restRotation: [0, 0, 0, 1], restScale: [1, 1, 1]), + .init(id: "a", name: "Bone A", parentID: "root", restTranslation: .zero, restRotation: [0, 0, 0, 1], restScale: [1, 1, 1], morphRestWeights: [0]), + .init(id: "b", name: "Bone B", parentID: "root", restTranslation: .zero, restRotation: [0, 0, 0, 1], restScale: [1, 1, 1]), + ] + let library = AnimationLibrary(binding: .init(sourceAsset: "Assets/Robot.glb", nodes: nodes), clips: [ + .init(id: "walk", name: "Walk", duration: 2, tracks: [ + .init(id: "ta", nodeID: "a", channel: .translation, keys: [.init(id: "a0", time: 0, value: [0, 0, 0]), .init(id: "a1", time: 1, value: [1, 0, 0])]), + .init(id: "tb", nodeID: "b", channel: .rotation, keys: [.init(id: "b0", time: 0, value: [0, 0, 0, 1])]), + .init(id: "morph", nodeID: "a", channel: .weights, interpolation: .cubicSpline, keys: [ + .init(id: "m0", time: 0, value: [0], incoming: [0], outgoing: [0]), + .init(id: "m1", time: 0.5, value: [1], incoming: [0], outgoing: [0]), + ]), + ]), + ]) + let service = EditorAnimationAuthoringService(projectURL: root, assetsURL: assets) + let document = try service.create("Assets/Robot.animlib", library: library) + try document.save(expectedRevision: document.revision) + document.selectedClipID = "walk" + let asset = EditorAssetDocument(id: "asset:Assets/Robot.glb", title: "Robot.glb", relativePath: "Assets/Robot.glb", + absolutePath: assets.appendingPathComponent("Robot.glb").path, assetReference: "@res://Robot.glb", kind: .model3D, + fileExtension: "glb", byteCount: nil, modifiedAt: nil, errorMessage: nil) + let workspace = EditorAnimationAssetWorkspace(document: asset, service: service) + workspace.library = document + return (root, workspace, document) +} diff --git a/Editor/Tests/AdaEditorTests/EditorAnimationWorkspaceTests.swift b/Editor/Tests/AdaEditorTests/EditorAnimationWorkspaceTests.swift new file mode 100644 index 000000000..a19d51c2e --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorAnimationWorkspaceTests.swift @@ -0,0 +1,499 @@ +@_spi(AdaEngine) import AdaEngine +@_spi(Internal) @testable import AdaUI +import AdaScriptCompilerCore +import Foundation +import Math +import Testing + +@testable import AdaEditor + +extension EditorAnimationAuthoringTests { + @Test func animationControlStyleActiveTogglesKeepTheirAccentAfterClicking() async throws { + let graph = EditorAnimationGraphModel() + graph.graph = EditorAnimationGraphModel.defaultGraph(clip: "Walk") + graph.selectedNodeID = "Clip1" + let graphView = UIContainerView(rootView: EditorAnimationGraphEditor(model: graph, presentation: .inspector).theme(.adaEditor)) + graphView.frame = Rect(x: 0, y: 0, width: 380, height: 600); graphView.bounds.size = graphView.frame.size + await settlePoseInspector(graphView) + #expect(try animationControlHasAccent(graphView, identifier: "AdaEditor.AnimationGraph.Loop")) + _ = try graphView.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.AnimationGraph.Loop")) + await settlePoseInspector(graphView) + #expect(try !animationControlHasAccent(graphView, identifier: "AdaEditor.AnimationGraph.Loop")) + + let fixture = try await poseWorkspaceFixture() + defer { withExtendedLifetime(fixture.world) {}; fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let timeline = UIContainerView(rootView: EditorAnimationTimeline(workspace: fixture.workspace).theme(.adaEditor)) + timeline.frame = Rect(x: 0, y: 0, width: 1200, height: 400); timeline.bounds.size = timeline.frame.size + await settlePoseInspector(timeline) + for id in ["AdaEditor.AnimationTimeline.SelectedOnly", "AdaEditor.AnimationTimeline.AnimatedOnly"] { + #expect(try !animationControlHasAccent(timeline, identifier: id)) + _ = try timeline.uiTapNode(matching: .accessibilityIdentifier(id)) + await settlePoseInspector(timeline) + #expect(try animationControlHasAccent(timeline, identifier: id), "Active timeline filter must retain a filled accent.") + _ = try timeline.uiTapNode(matching: .accessibilityIdentifier(id)) + await settlePoseInspector(timeline) + #expect(try !animationControlHasAccent(timeline, identifier: id)) + } + } + + @Test func animationControlStyleGraphActionsHaveVisibleSurfacesAtRest() async throws { + let graph = EditorAnimationGraphModel() + graph.graph = EditorAnimationGraphModel.defaultGraph(clip: "Walk") + let container = UIContainerView(rootView: EditorAnimationGraphEditor(model: graph).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 900, height: 380) + container.bounds.size = container.frame.size + await settlePoseInspector(container) + let button = try #require(container.viewTree.rootNode.findNodyByAccessibilityIdentifier("AdaEditor.AnimationGraph.Add.clip")) + let context = UIGraphicsContext() + button.draw(with: context) + #expect(context.getDrawCommands().contains { command in + if case let .drawPath(_, _, .fill(color)) = command { return color == EditorThemeColors.dark.surface } + return false + }, "An idle graph action must draw a visible button surface, before hovering.") + } + + @Test func animationControlStyleInspectorShowsAxisBadgesAndOverflowIndicator() async throws { + let fixture = try await poseWorkspaceFixture() + defer { withExtendedLifetime(fixture.world) {}; fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + fixture.workspace.timeline.selectNode(try #require(fixture.workspace.timeline.nodes.first { $0.name == "UpperArm.L" }).id) + let container = UIContainerView(rootView: EditorAnimationInspector(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 360, height: 600) + container.bounds.size = container.frame.size + await settlePoseInspector(container) + let field = try #require(container.viewTree.rootNode.findNodyByAccessibilityIdentifier("AdaEditor.Animation.Pose.rotation.0")) + var ancestor: ViewNode? = field + while ancestor != nil, !(ancestor is ScrollViewNode) { ancestor = ancestor?.parent } + let scroll = try #require(ancestor as? ScrollViewNode) + #expect(!scroll.scrollIndicatorRects.isEmpty, "Overflowing animation Inspector must expose a scroll indicator.") + _ = try container.uiScrollToNode(matching: .accessibilityIdentifier("AdaEditor.Animation.Pose.rotation")) + await settlePoseInspector(container) + let row = try #require(container.viewTree.rootNode.findNodyByAccessibilityIdentifier("AdaEditor.Animation.Pose.rotation")) + let context = UIGraphicsContext() + row.draw(with: context) + for color in [Color(red: 0.78, green: 0.24, blue: 0.28), Color(red: 0.24, green: 0.60, blue: 0.31), + Color(red: 0.22, green: 0.43, blue: 0.82), Color(red: 0.57, green: 0.32, blue: 0.76)] { + #expect(context.getDrawCommands().contains { command in + if case let .drawPath(_, _, .fill(fill)) = command { return fill == color } + return false + }, "Quaternion components must have the same colored X/Y/Z/W badges as Transform Inspector.") + } + } + + @Test func finalWorkspaceClosingKeepsAnUnkeyedPoseAvailableForTheDecision() async throws { + let fixture = try await poseWorkspaceFixture() + defer { withExtendedLifetime(fixture.world) {}; fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let model = fixture.workspace.timeline + model.selectNode(try #require(model.nodes.first { $0.name == "UpperArm.L" }).id) + try model.setPose(.translation, values: [1, 2, 3]) + fixture.workbench.closeDocument(id: fixture.workspace.document.id) + #expect(fixture.workbench.openDocuments.contains { $0.id == fixture.workspace.document.id }) + #expect(fixture.workbench.animationWorkspaces[fixture.workspace.document.id] === fixture.workspace) + #expect(model.hasUnkeyedPose) + } + + @Test func poseFlowEulerEditingPreservesTheOtherAxes() async throws { + let fixture = try await poseWorkspaceFixture() + defer { withExtendedLifetime(fixture.world) {}; fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let model = fixture.workspace.timeline + let index = try #require(model.nodes.firstIndex { $0.name == "UpperArm.L" }) + model.selectNode(model.nodes[index].id) + let original = Quat.euler([0.3, 0.5, 0.7]).normalized + try model.setPose(.rotation, values: [original.x, original.y, original.z, original.w]) + let container = UIContainerView(rootView: EditorAnimationInspector(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 320, height: 900); container.bounds.size = container.frame.size + await settlePoseInspector(container) + _ = try container.uiScrollToNode(matching: .accessibilityIdentifier("AdaEditor.Animation.Pose.RotationMode")) + _ = try container.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.Animation.Pose.RotationMode")) + await settlePoseInspector(container) + try await editPoseField("rotation.0", text: "45", in: container) + _ = try container.uiScrollToNode(matching: .accessibilityIdentifier("AdaEditor.Animation.Pose.Apply.rotation")) + _ = try container.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.Animation.Pose.Apply.rotation")) + let actual = try #require(fixture.workspace.preview.capturedAnimationPlayer()).poses[index].rotation + let expected = Quat.euler([Float.pi / 4, 0.5, 0.7]).normalized + #expect(abs(actual.dot(expected)) > 0.99999) + } + + @Test func poseFlowMultiBoneComponentEditPreservesEachBonesOtherComponents() async throws { + let fixture = try await poseWorkspaceFixture() + defer { withExtendedLifetime(fixture.world) {}; fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let model = fixture.workspace.timeline + let left = try #require(model.nodes.firstIndex { $0.name == "UpperArm.L" }) + let right = try #require(model.nodes.firstIndex { $0.name == "UpperArm.R" }) + model.selectNode(model.nodes[left].id) + try model.setPose(.translation, values: [1, 2, 3]) + model.selectNode(model.nodes[right].id) + try model.setPose(.translation, values: [4, 5, 6]) + model.selectedNodes = [model.nodes[left].id, model.nodes[right].id] + let container = UIContainerView(rootView: EditorAnimationInspector(workspace: fixture.workspace).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 320, height: 900); container.bounds.size = container.frame.size + await settlePoseInspector(container) + try await editPoseField("translation.0", text: "7", in: container) + _ = try container.uiTapNode(matching: .accessibilityIdentifier("AdaEditor.Animation.Pose.Apply.translation")) + let player = try #require(fixture.workspace.preview.capturedAnimationPlayer()) + #expect(player.poses[left].translation == [7, 2, 3]) + #expect(player.poses[right].translation == [7, 5, 6]) + } + + @Test func poseFlowLibraryActionsRejectUnkeyedPoseWithoutReplacingItsTarget() async throws { + let fixture = try await poseWorkspaceFixture() + defer { withExtendedLifetime(fixture.world) {}; fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let model = fixture.workspace.timeline + model.selectNode(try #require(model.nodes.first { $0.name == "UpperArm.L" }).id) + try model.setPose(.translation, values: [1, 2, 3]) + let before = fixture.document.library + let clipID = fixture.document.selectedClipID + fixture.workspace.clipName = "Another pose" + do { try await fixture.workspace.extract(fromRest: true); Issue.record("Creating a clip must wait for the unkeyed pose decision.") } + catch { #expect(error is AnimationAuthoringError) } + #expect(fixture.workspace.library === fixture.document) + #expect(fixture.document.library == before) + #expect(fixture.document.selectedClipID == clipID) + let other = try fixture.service.create("Assets/Other.animlib", library: AnimationLibrary(binding: before.binding, clips: before.clips)) + try other.save(expectedRevision: other.revision) + fixture.workspace.libraryPath = other.relativePath + #expect(throws: AnimationAuthoringError.self) { try fixture.workspace.openLibrary() } + #expect(fixture.workspace.library === fixture.document) + #expect(model.hasUnkeyedPose) + } + + @Test func poseFlowExternalPreviewCannotMoveAnUnkeyedPoseToAnotherSecond() async throws { + let fixture = try await poseWorkspaceFixture() + defer { withExtendedLifetime(fixture.world) {}; fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let model = fixture.workspace.timeline + let node = try #require(model.nodes.first { $0.name == "UpperArm.L" }) + model.selectNode(node.id) + try model.setPose(.translation, values: [1, 2, 3]) + let clipID = try #require(fixture.document.selectedClipID) + do { + _ = try await fixture.service.execute(.init(action: .preview, path: fixture.document.relativePath, clipID: clipID, time: 0.75), workbench: fixture.workbench) + fixture.workspace.applyRequestedPreview() + } catch { #expect(error is AnimationAuthoringError) } + #expect(abs(model.time - 0.25) < 0.000001) + try model.insertKeys(channels: [.translation]) + let track = try #require(fixture.document.library.clips.first { $0.id == clipID }?.tracks.first { $0.nodeID == node.id && $0.channel == .translation }) + #expect(track.keys.contains { abs($0.time - 0.25) < 0.000001 && $0.value == [1, 2, 3] }) + #expect(!track.keys.contains { abs($0.time - 0.75) < 0.000001 && $0.value == [1, 2, 3] }) + } + + @Test func visualWorkspaceReconnectPreservesWeightsAndMasksAndUndoesOnce() throws { + let model = EditorAnimationGraphModel() + let mask = AnimationGraph.Mask(defaultWeight: 0, joints: [.init(nodeIndex: 0, weight: 0.4, includesDescendants: true)]) + let original = AnimationGraph(root: "Output", nodes: [ + .init(id: "Clip1", clip: "Walk"), + .init(id: "Output", kind: .blend, inputs: [.init(node: "Clip1", weight: 0.25, mask: mask)]), + .init(id: "Additive1", kind: .additive, inputs: []), + ]) + model.graph = original + let geometry = EditorNodeCanvasGeometry(nodes: model.canvasItems, pan: .zero, zoom: 1) + let from = try #require(geometry.port(.init(node: "Output", port: "0", isOutput: false))) + let to = try #require(geometry.port(.init(node: "Additive1", port: "new", isOutput: false))) + let pointer = model.canvasInteraction() + pointer.drag(from: from, to: to, geometry: geometry, wires: model.canvasWires, selection: [], state: model.canvas) + pointer.end(at: to, geometry: geometry) + #expect(model.graph.nodes[2].inputs.first?.weight == 0.25) + #expect(model.graph.nodes[2].inputs.first?.mask == mask) + model.undoCanvas() + #expect(model.graph == original) + } + + @Test func visualWorkspaceNodeDeletionAndStaleWireAreAtomic() { + let model = EditorAnimationGraphModel() + let original = AnimationGraph(root: "Output", nodes: [ + .init(id: "A", clip: "Walk"), .init(id: "B", clip: "Idle"), .init(id: "C", clip: "Jump"), + .init(id: "Output", kind: .blend, inputs: [.init(node: "A"), .init(node: "B"), .init(node: "C")]), + ]) + model.graph = original + model.selectedNodeID = "A" + model.removeSelectedNode() + model.undoCanvas() + #expect(model.graph == original) + model.selectedWire = model.canvasWires.first { $0.source.node == "B" } + model.editNode("Output") { $0.inputs.removeFirst() } + model.deleteCanvasSelection() + #expect(model.graph.nodes.last?.inputs.map(\.node) == ["B", "C"]) + } + + @Test func visualWorkspaceShowsContextualInspectorAndCommonSkeletalRuler() async throws { + let fixture = try animationWorkspaceFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = try await fixture.service.extract(path: "Assets/Robot.animlib", modelPath: fixture.asset.relativePath, + clipName: "Walk", name: "Timeline") + fixture.workspace.start() + fixture.workspace.preview.cancel() + fixture.workspace.library = document + fixture.workspace.preview.asset = try #require(AssetsManager.loadSync(ModelAsset3D.self, at: fixture.asset.absolutePath ?? "").asset) + fixture.workspace.configureLoadedAsset() + let container = UIContainerView(rootView: HStack { + EditorAnimationAssetPanel(workspace: fixture.workspace, service: fixture.service) + EditorContextualInspector(document: .asset(fixture.asset), workbench: fixture.workbench, + sceneInspectorViewModel: EditorInspectorSidebarViewModel(), resourceRootURL: fixture.root) + .frame(width: 300) + }.theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 1200, height: 420) + container.bounds.size = container.frame.size + for _ in 0..<4 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } + #expect(!container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.Animation.Inspector")).isEmpty) + #expect(!container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.AnimationTimeline.Ruler")).isEmpty) + guard let ruler = container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.AnimationTimeline.Ruler")).first else { return } + let rect = ruler.absoluteFrame + let point = Point(rect.minX + rect.width * 0.25, rect.midY) + for phase in [MouseEvent.Phase.began, .ended] { + container.onMouseEvent(MouseEvent(window: RID(), button: .left, mousePosition: point, phase: phase, modifierKeys: [], time: 0)) + } + let inputSnapshot = container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.AnimationTimeline.Input")).map(\.absoluteFrame) + let hitType = container.uiHitTest(at: point)?.node.nodeType + #expect(fixture.workspace.preview.playbackTime > 0, + "Ruler: \(rect), hit: \(String(describing: hitType)), input: \(inputSnapshot)") + #expect(!fixture.workspace.preview.playsAnimation) + } + + @Test func animationGraphRendersPoseCardsOnTheSharedNodeCanvas() throws { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + RenderWorldPlugin().setup(in: AppWorlds(main: World(name: "AnimationCanvas"))) + } + let model = EditorAnimationGraphModel() + model.graph = EditorAnimationGraphModel.defaultGraph(clip: "Walk") + let container = UIContainerView(rootView: EditorAnimationGraphEditor(model: model).theme(.adaEditor)) + container.frame = Rect(x: 0, y: 0, width: 900, height: 380) + container.bounds.size = container.frame.size + container.layoutIfNeeded() + #expect(!container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.NodeCanvas.Node.Clip1")).isEmpty) + #expect(!container.uiFindNodes(matching: .accessibilityIdentifier("AdaEditor.NodeCanvas.Node.Output")).isEmpty) + } + + @Test func documentedToolKeepsTheSecondClipDuringTrackAndKeyInspection() async throws { + let fixture = try animationWorkspaceFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = try await fixture.service.extract(path: "Assets/Robot.animlib", modelPath: fixture.asset.relativePath, + clipName: "Walk", name: "First clip") + _ = try await fixture.service.extract(path: document.relativePath, modelPath: fixture.asset.relativePath, + clipName: "Walk", name: "Second clip", expectedRevision: document.revision) + let clip = try #require(document.library.clips.last) + try #require(document.library.clips.count == 2) + let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ada") + let source = AdaScriptSource(path: "Tools/AnimationHelper/AnimationHelper.ada", source: try String(contentsOf: url, encoding: .utf8)) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + let runtime = try AdaScriptToolRuntime(sources: [source], schema: schema, grantedPermissions: schema.permissions) + defer { runtime.deactivate() } + try runtime.activate() + func performAndPoll(_ action: String, inputs: [String: AdaScriptSchemaField.Value] = [:]) async throws { + _ = try runtime.perform(action, inputs: inputs, scene: nil) + let job = try #require(runtime.takeAnimationJobs().first) + let payload = try await fixture.service.execute(job.request, workbench: fixture.workbench) + let data = try EditorAnimationAuthoringService.responseData(["status": "completed", "result": payload]) + try runtime.completeAnimationJob(job.id, responseJSON: String(decoding: data, as: UTF8.self)) + _ = try runtime.perform("poll", inputs: [:], scene: nil) + } + try await performAndPoll("inspectTracks", inputs: ["path": .string(document.relativePath), "clipID": .string(clip.id)]) + try #require(runtime.values["clipID"] == .string(clip.id)) + guard case let .string(trackID) = runtime.values["trackID"] else { Issue.record("A rotation track must be selected."); return } + let track = try #require(clip.tracks.first { $0.id == trackID }) + try await performAndPoll("inspectKeys") + #expect(runtime.values["clipID"] == .string(clip.id)) + #expect(runtime.values["keyID"] == .string(try #require(track.keys.first).id)) + } + + @Test func documentedToolSamplesLaterBonesThroughSmallPosePages() async throws { + let fixture = try animationWorkspaceFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = try await fixture.service.extract(path: "Assets/Robot.animlib", modelPath: fixture.asset.relativePath, + clipName: "Walk", name: "Paged sample") + let clip = try #require(document.library.clips.first) + let nodes = document.library.binding.nodes + let track = try #require(clip.tracks.filter { $0.channel == .rotation }.max { left, right in + let leftIndex = nodes.firstIndex { $0.id == left.nodeID } ?? 0 + let rightIndex = nodes.firstIndex { $0.id == right.nodeID } ?? 0 + return leftIndex < rightIndex + }) + let nodeIndex = try #require(nodes.firstIndex { $0.id == track.nodeID }) + try #require(nodeIndex >= 8) + let time = try #require(track.keys.first).time + let sampled = try document.library.sample(clipID: clip.id, at: time) + let expected = sampled.poses[nodeIndex].rotation.z + let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Documentation/Examples/StudioTools/AnimationHelper/AnimationHelper.ada") + let source = AdaScriptSource(path: "Tools/AnimationHelper/AnimationHelper.ada", source: try String(contentsOf: url, encoding: .utf8)) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + let runtime = try AdaScriptToolRuntime(sources: [source], schema: schema, grantedPermissions: schema.permissions) + defer { runtime.deactivate() } + try runtime.activate() + _ = try runtime.perform("sample", inputs: ["path": .string(document.relativePath), "clipID": .string(clip.id), + "nodeID": .string(track.nodeID), "keyTime": .double(time), "sampledRotationZ": .double(-99)], scene: nil) + var pages = 0 + repeat { + let job = try #require(runtime.takeAnimationJobs().first) + try #require((job.request.limit ?? 256) <= 8) + #expect(job.request.offset == pages * 8) + let payload = try await fixture.service.execute(job.request, workbench: fixture.workbench) + let data = try EditorAnimationAuthoringService.responseData(["status": "completed", "result": payload]) + try runtime.completeAnimationJob(job.id, responseJSON: String(decoding: data, as: UTF8.self)) + _ = try runtime.perform("poll", inputs: [:], scene: nil) + pages += 1 + } while runtime.values["job"] != .string("") && pages < 256 + #expect(pages > 1) + #expect(runtime.values["job"] == .string("")) + guard case let .double(actual) = runtime.values["sampledRotationZ"] else { + Issue.record("The real Tool must read the target bone from a later page.") + return + } + #expect(abs(actual - Double(expected)) < 0.00001) + } + + @Test func assetWorkspaceSharesTheCenterPlayerAcrossModesAndServiceEdits() async throws { + let fixture = try animationWorkspaceFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let workspace = fixture.workspace + #expect(fixture.workbench.animationWorkspace(for: fixture.asset) === workspace) + let model = try #require(AssetsManager.loadSync(ModelAsset3D.self, at: fixture.asset.absolutePath ?? "").asset) + workspace.preview.asset = model + workspace.configureLoadedAsset() + workspace.sourceClip = "Walk" + try await workspace.extract(fromRest: false) + let document = try #require(workspace.library) + #expect(try fixture.service.open(document.relativePath) === document) + // This is the state an agent's first-open preview request installs before the viewport mounts. + document.previewTime = 0.25 + document.previewSerial += 1 + workspace.configureLoadedAsset() + let world = World(name: "Shared model center") + _ = model.instantiate(in: world) + workspace.preview.bind(to: world) + #expect(abs((try #require(workspace.preview.capturedAnimationPlayer())).time - 0.25) < 0.000001) + let camera = workspace.camera + for mode in [EditorAnimationAssetWorkspace.Mode.graph, .keys, .clips, .keys] { + workspace.mode = mode + workspace.applyModePreview() + #expect(workspace.camera === camera) + #expect(abs((try #require(workspace.preview.capturedAnimationPlayer())).time - 0.25) < 0.000001) + } + let clip = try #require(document.library.clips.first) + _ = try fixture.service.apply(path: document.relativePath, expectedRevision: document.revision, + operations: [.init(kind: .configureClip, clipID: clip.id, name: "Shared edit")]) + workspace.refreshLibrary() + #expect(workspace.library === document) + #expect(workspace.library?.library.clips.first?.name == "Shared edit") + #expect(document.canUndo) + #expect(try Data(contentsOf: URL(fileURLWithPath: fixture.asset.absolutePath ?? "")) == fixture.original) + } + + @Test func authoredPlacementKeepsOneShotAndRejectsStaleSavedLibraries() async throws { + let fixture = try animationWorkspaceFixture() + defer { fixture.workspace.cancel(); try? FileManager.default.removeItem(at: fixture.root) } + let document = try await fixture.service.extract(path: "Assets/Animations/Robot.animlib", modelPath: fixture.asset.relativePath, + clipName: "Walk", name: "One shot") + let clip = try #require(document.library.clips.first) + try document.apply([.init(kind: .configureClip, clipID: clip.id, repeats: false)], expectedRevision: document.revision) + try document.save(expectedRevision: document.revision) + let sceneModel = EditorSceneModel.default(projectName: "Placement") + let content = try sceneModel.encodedYAML() + let sceneID = "scene:Assets/Main.ascn" + fixture.workbench.open(.scene(.init(id: sceneID, title: "Main.ascn", relativePath: "Assets/Main.ascn", + absolutePath: fixture.root.appendingPathComponent("Assets/Main.ascn").path, content: content, lastSavedContent: content, + isReadOnly: false, sceneModel: sceneModel, errorMessage: nil, isDirty: false, statusMessage: nil, + loadSummary: EditorSceneFileLoader.summary(from: content)))) + #expect(try await fixture.workbench.addAnimatedModelAsset(fixture.asset, to: sceneID, + animation: document.library.runtimeName(for: clip.id), autoplay: true, + repeats: try #require(document.library.clips.first).repeats, graph: nil, library: document)) + let placed = try #require(fixture.workbench.sceneDocument(id: sceneID)) + let payload = try #require(placed.sceneModel?.entities.last?.components[EditorBuiltInComponentType.model3DSource]) + #expect(payload["repeats"] == .bool(false)) + #expect(payload["animationLibraries"] == .array([.string("@res://Animations/Robot.animlib")])) + let prior = placed.content + let saved = try Data(contentsOf: document.url) + try Data("externally changed".utf8).write(to: document.url) + await #expect(throws: AnimationAuthoringError.self) { + _ = try await fixture.workbench.addAnimatedModelAsset(fixture.asset, to: sceneID, + animation: document.library.runtimeName(for: clip.id), autoplay: true, repeats: false, graph: nil, library: document) + } + #expect(fixture.workbench.sceneDocument(id: sceneID)?.content == prior) + try saved.write(to: document.url) + try fixture.service.close(document.relativePath) + await #expect(throws: AnimationAuthoringError.self) { + _ = try await fixture.workbench.addAnimatedModelAsset(fixture.asset, to: sceneID, + animation: document.library.runtimeName(for: clip.id), autoplay: true, repeats: false, graph: nil, library: document) + } + #expect(fixture.workbench.sceneDocument(id: sceneID)?.content == prior) + } + + @Test func animatorHasRoomForEditingAndRemembersItsOwnResize() { + let viewModel = EditorViewModel(project: nil) + defer { viewModel.studioTools.close() } + let animator = EditorToolStripItem(identifier: "animator", title: "Animator", icon: "") + let logs = EditorToolStripItem(identifier: "logs", title: "Logs", icon: "") + viewModel.toolStrip.selectLeftBottomTool(animator) + viewModel.showBottomPanel = true + let state = EditorWorkspaceResizeState() + let size = Size(width: 1200, height: 900) + #expect(state.layout(in: size, viewModel: viewModel).bottomPanelHeight == 380) + state.resize(.bottom, translation: Size(width: 0, height: 80), in: size, viewModel: viewModel) + state.endDrag() + #expect(state.layout(in: size, viewModel: viewModel).bottomPanelHeight == 300) + viewModel.toolStrip.selectLeftBottomTool(logs) + #expect(state.layout(in: size, viewModel: viewModel).bottomPanelHeight == 180) + viewModel.toolStrip.selectLeftBottomTool(animator) + #expect(state.layout(in: size, viewModel: viewModel).bottomPanelHeight == 300) + } +} + +@MainActor +private func animationWorkspaceFixture() throws -> (root: URL, workbench: EditorWorkbenchViewModel, + service: EditorAnimationAuthoringService, asset: EditorAssetDocument, workspace: EditorAnimationAssetWorkspace, original: Data) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AnimationWorkspace-\(UUID())") + try FileManager.default.createDirectory(at: root.appendingPathComponent("Assets"), withIntermediateDirectories: true) + let source = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Tests/AdaEngineTests/Fixtures/SkeletalGardenAssets/GardenRobot.glb") + let original = try Data(contentsOf: source) + let url = root.appendingPathComponent("Assets/Robot.glb") + try original.write(to: url) + let workbench = EditorWorkbenchViewModel() + workbench.configureAnimationAuthoring(projectURL: root, assetsURL: root.appendingPathComponent("Assets")) + let asset = EditorAssetDocument(id: "asset:Assets/Robot.glb", title: "Robot.glb", relativePath: "Assets/Robot.glb", absolutePath: url.path, + assetReference: "@res://Robot.glb", kind: .model3D, fileExtension: "glb", byteCount: nil, modifiedAt: nil, errorMessage: nil) + workbench.open(.asset(asset)) + return (root, workbench, try #require(workbench.animationService), asset, workbench.animationWorkspace(for: asset), original) +} + +@MainActor private func poseWorkspaceFixture() async throws -> (root: URL, workbench: EditorWorkbenchViewModel, + service: EditorAnimationAuthoringService, workspace: EditorAnimationAssetWorkspace, document: EditorAnimationLibraryDocument, world: World) { + let fixture = try animationWorkspaceFixture() + let document = try await fixture.service.extract(path: "Assets/Robot.animlib", modelPath: fixture.asset.relativePath, clipName: "Walk", name: "Pose edit") + fixture.workspace.start(); fixture.workspace.preview.cancel() + let asset = try #require(AssetsManager.loadSync(ModelAsset3D.self, at: fixture.asset.absolutePath ?? "").asset) + fixture.workspace.library = document + fixture.workspace.libraryPath = document.relativePath + document.selectedClipID = document.library.clips.first?.id + fixture.workspace.preview.asset = asset + fixture.workspace.configureLoadedAsset() + let world = World(name: "Real pose preview") + _ = asset.instantiate(in: world) + fixture.workspace.preview.bind(to: world) + fixture.workspace.timeline.seek(0.25) + return (fixture.root, fixture.workbench, fixture.service, fixture.workspace, document, world) +} + +@MainActor private func settlePoseInspector(_ container: UIContainerView) async { + for _ in 0..<6 { await Task.yield(); container.update(1 / 60); container.layoutIfNeeded() } +} + +@MainActor private func animationControlHasAccent(_ container: UIContainerView, identifier: String) throws -> Bool { + let node = try #require(container.viewTree.rootNode.findNodyByAccessibilityIdentifier(identifier)) + let context = UIGraphicsContext() + node.draw(with: context) + return context.getDrawCommands().contains { command in + if case let .drawPath(_, _, .fill(color)) = command { return color == EditorThemeColors.dark.blue.opacity(0.16) } + return false + } +} + +@MainActor private func editPoseField(_ suffix: String, text: String, in container: UIContainerView) async throws { + let selector = UINodeSelector.accessibilityIdentifier("AdaEditor.Animation.Pose." + suffix) + _ = try container.uiScrollToNode(matching: selector) + _ = try container.uiTapNode(matching: selector) + try #require(container.uiPerformTextEditingCommand(.selectAll)) + for character in text { + container.onTextInputEvent(TextInputEvent(window: RID(), text: String(character), action: .insert, time: 0)) + await settlePoseInspector(container) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorModel3DTests.swift b/Editor/Tests/AdaEditorTests/EditorModel3DTests.swift index f3bf4ca16..f650e784b 100644 --- a/Editor/Tests/AdaEditorTests/EditorModel3DTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorModel3DTests.swift @@ -162,7 +162,7 @@ struct EditorModel3DTests { #expect(previewRoot.components[ModelAnimation3DComponent.self]?.player.isPlaying == true) } - @Test("Runtime plugin loads serialized references, replaces only generated children and cleans up removed references") + @Test("Runtime plugin retains the last valid model on failed changes and cleans up removed references") func runtimeLifecycle() async throws { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } @@ -181,16 +181,21 @@ struct EditorModel3DTests { app.main.flush() let state = try #require(owner.components[Model3DSourceState.self]) #expect(state.error == nil && state.rootEntityID != nil) + let generatedRoot = try #require(state.rootEntityID) #expect(owner.children.count == 2) owner.components[Model3DSource.self] = Model3DSource(source: source.path, animation: "Missing") app.main.flush() await app.main.runScheduler(.preUpdate) #expect(owner.components[Model3DSourceState.self]?.error?.contains("Missing") == true) - #expect(owner.children.map(\.id) == [other.id]) + #expect(owner.children.map(\.id) == [other.id, generatedRoot]) + #expect(owner.components[Model3DSourceState.self]?.rootEntityID == generatedRoot) + #expect(app.main.getEntityByID(generatedRoot) != nil) owner.components[Model3DSource.self] = nil app.main.flush() await app.main.runScheduler(.preUpdate) #expect(owner.components[Model3DSourceState.self] == nil) + #expect(owner.children.map(\.id) == [other.id]) + #expect(app.main.getEntityByID(generatedRoot) == nil) #expect(app.main.getEntityByID(other.id) != nil) } diff --git a/Package.swift b/Package.swift index 9eddb864e..8242fe7d9 100644 --- a/Package.swift +++ b/Package.swift @@ -464,6 +464,7 @@ var targets: [Target] = [ .adaTarget( name: "AdaScripting", dependencies: [ + "AdaAnimation", "AdaScriptCompilerCore", "AdaApp", "AdaAssets", @@ -527,6 +528,7 @@ var targets: [Target] = [ .adaTarget( name: "AdaAssets", dependencies: [ + "AdaAnimation", "AdaApp", "AdaUtils", "Math", @@ -1366,6 +1368,7 @@ targets += [ .testTarget( name: "AdaAssetsTests", dependencies: [ + "AdaAnimation", "AdaAssets", "Math" ], diff --git a/Sources/AdaAnimation/Authoring/AnimationAuthoringCommands.swift b/Sources/AdaAnimation/Authoring/AnimationAuthoringCommands.swift new file mode 100644 index 000000000..88541b855 --- /dev/null +++ b/Sources/AdaAnimation/Authoring/AnimationAuthoringCommands.swift @@ -0,0 +1,204 @@ +import Foundation +import Math + +/// A detached edit shared by native authoring, script Tools and agent transports. +/// `replaceExisting` permits replacing the same key ID; a different key at that time remains a conflict. +public struct AnimationEdit: Codable, Equatable, Sendable { + public enum Kind: String, Codable, Sendable { + case createClip = "clip.create", duplicateClip = "clip.duplicate", removeClip = "clip.remove", configureClip = "clip.configure" + case createTrack = "track.create", removeTrack = "track.remove", configureTrack = "track.configure" + case putKey = "key.put", removeKey = "key.remove" + } + + public var id: String + public var kind: Kind + public var clipID: String? + public var trackID: String? + public var keyID: String? + public var newID: String? + public var name: String? + public var duration: Double? + public var repeats: Bool? + public var interpolation: AnimationLibraryTrack.Interpolation? + public var clip: AnimationLibraryClip? + public var track: AnimationLibraryTrack? + public var key: AnimationLibraryKey? + public var replaceExisting: Bool + + public init(id: String = UUID().uuidString, kind: Kind, clipID: String? = nil, trackID: String? = nil, keyID: String? = nil, + newID: String? = nil, name: String? = nil, duration: Double? = nil, repeats: Bool? = nil, + interpolation: AnimationLibraryTrack.Interpolation? = nil, clip: AnimationLibraryClip? = nil, + track: AnimationLibraryTrack? = nil, key: AnimationLibraryKey? = nil, replaceExisting: Bool = false) { + self.id = id + self.kind = kind + self.clipID = clipID + self.trackID = trackID + self.keyID = keyID + self.newID = newID + self.name = name + self.duration = duration + self.repeats = repeats + self.interpolation = interpolation + self.clip = clip + self.track = track + self.key = key + self.replaceExisting = replaceExisting + } + + private enum CodingKeys: String, CodingKey { + case id, kind, clipID, trackID, keyID, newID, name, duration, repeats, interpolation, clip, track, key, replaceExisting + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.init( + id: try values.decode(String.self, forKey: .id), + kind: try values.decode(Kind.self, forKey: .kind), + clipID: try values.decodeIfPresent(String.self, forKey: .clipID), + trackID: try values.decodeIfPresent(String.self, forKey: .trackID), + keyID: try values.decodeIfPresent(String.self, forKey: .keyID), + newID: try values.decodeIfPresent(String.self, forKey: .newID), + name: try values.decodeIfPresent(String.self, forKey: .name), + duration: try values.decodeIfPresent(Double.self, forKey: .duration), + repeats: try values.decodeIfPresent(Bool.self, forKey: .repeats), + interpolation: try values.decodeIfPresent(AnimationLibraryTrack.Interpolation.self, forKey: .interpolation), + clip: try values.decodeIfPresent(AnimationLibraryClip.self, forKey: .clip), + track: try values.decodeIfPresent(AnimationLibraryTrack.self, forKey: .track), + key: try values.decodeIfPresent(AnimationLibraryKey.self, forKey: .key), + replaceExisting: try values.decodeIfPresent(Bool.self, forKey: .replaceExisting) ?? false + ) + } +} + +/// The failed operation and a stable authoring diagnostic. No operations in its batch are committed. +public struct AnimationEditError: Error, Equatable, LocalizedError, Sendable { + public let operationID: String + public let diagnostic: AnimationAuthoringError + public var errorDescription: String? { "\(operationID): \(diagnostic.message)" } + + public init(operationID: String, diagnostic: AnimationAuthoringError) { + self.operationID = operationID + self.diagnostic = diagnostic + } +} + +extension AnimationLibrary { + /// Applies a bounded batch to a detached draft. Each operation must leave a valid library. + /// Failed batches preserve the original value; put-key never overwrites an occupied time implicitly. + public mutating func apply(_ operations: [AnimationEdit]) throws(AnimationEditError) { + guard operations.count <= 256 else { + throw .init(operationID: "batch", diagnostic: .init(.limitExceeded, itemID: id, message: "A batch supports at most 256 operations.")) + } + var draft = self + var identities: Set = [] + for operation in operations { + do throws(AnimationAuthoringError) { + guard !operation.id.isEmpty, operation.id.utf8.count <= 256, identities.insert(operation.id).inserted else { + throw AnimationAuthoringError(.invalidIdentity, itemID: operation.id, message: "Operation IDs must be nonempty, unique and bounded.") + } + try draft.perform(operation) + draft.promoteNativeCurveFormat() + try draft.validate() + } catch { + throw .init(operationID: operation.id, diagnostic: error) + } + } + self = draft + } + + private mutating func perform(_ operation: AnimationEdit) throws(AnimationAuthoringError) { + if operation.kind == .createClip { + guard let clip = operation.clip else { throw missing("clip", operation.id) } + clips.append(clip) + return + } + guard let clipIndex = clips.firstIndex(where: { $0.id == operation.clipID }) else { + throw .init(.missingClip, itemID: operation.clipID ?? "", message: "Clip does not exist.") + } + switch operation.kind { + case .createClip: break + case .duplicateClip: + guard let newID = operation.newID, let name = operation.name else { throw missing("newID and name", operation.id) } + var clip = clips[clipIndex] + clip.id = newID + clip.name = name + clips.append(clip) + case .removeClip: clips.remove(at: clipIndex) + case .configureClip: + if let name = operation.name { clips[clipIndex].name = name } + if let duration = operation.duration { clips[clipIndex].duration = duration } + if let repeats = operation.repeats { clips[clipIndex].repeats = repeats } + case .createTrack: + guard let track = operation.track else { throw missing("track", operation.id) } + clips[clipIndex].tracks.append(track) + default: try performTrackEdit(operation, clipIndex: clipIndex) + } + } + + private mutating func performTrackEdit(_ operation: AnimationEdit, clipIndex: Int) throws(AnimationAuthoringError) { + guard let trackIndex = clips[clipIndex].tracks.firstIndex(where: { $0.id == operation.trackID }) else { + throw .init(.missingTrack, itemID: operation.trackID ?? "", message: "Track does not exist.") + } + switch operation.kind { + case .removeTrack: clips[clipIndex].tracks.remove(at: trackIndex) + case .configureTrack: + guard let interpolation = operation.interpolation else { throw missing("interpolation", operation.id) } + clips[clipIndex].tracks[trackIndex].interpolation = interpolation + case .putKey: + guard let key = operation.key, key.time.isFinite, key.time >= 0 else { throw missing("finite key", operation.id) } + var keys = clips[clipIndex].tracks[trackIndex].keys + if let existing = keys.firstIndex(where: { $0.id == key.id }) { + guard operation.replaceExisting else { + throw .init(.conflict, itemID: key.id, message: "Replacing an existing key ID requires replaceExisting.") + } + keys.remove(at: existing) + } + guard !keys.contains(where: { $0.time == key.time }) else { + throw .init(.conflict, itemID: key.id, message: "Another key occupies this time. Remove it explicitly first.") + } + keys.append(key) + clips[clipIndex].tracks[trackIndex].keys = keys.sorted { $0.time < $1.time } + case .removeKey: + guard let keyIndex = clips[clipIndex].tracks[trackIndex].keys.firstIndex(where: { $0.id == operation.keyID }) else { + throw .init(.missingKey, itemID: operation.keyID ?? "", message: "Key does not exist.") + } + clips[clipIndex].tracks[trackIndex].keys.remove(at: keyIndex) + default: throw missing("track operation", operation.id) + } + } + + private func missing(_ field: String, _ id: String) -> AnimationAuthoringError { + .init(.invalidTrack, itemID: id, message: "Operation requires \(field).") + } + + /// Builds a new clip from rest or a compatible captured player, without sharing playback buffers. + public func poseClip(id clipID: String = UUID().uuidString, name: String, duration: Double, + from player: SkeletalAnimationPlayer? = nil) throws -> AnimationLibraryClip { + let rig = try binding.makeRig() + if let player, !binding.isCompatible(with: player.rig) { + throw AnimationAuthoringError(.incompatibleRig, itemID: id, message: "Captured pose uses a different rig.") + } + var tracks: [AnimationLibraryTrack] = [] + for (index, node) in binding.nodes.enumerated() { + if let rest = rig.nodes[index].restPose { + let pose = player?.poses[index] ?? rest + let values: [(AnimationLibraryTrack.Channel, [Float])] = [ + (.translation, [pose.translation.x, pose.translation.y, pose.translation.z]), + (.rotation, [pose.rotation.x, pose.rotation.y, pose.rotation.z, pose.rotation.w]), + (.scale, [pose.scale.x, pose.scale.y, pose.scale.z]) + ] + for (channel, value) in values { + tracks.append(.init(nodeID: node.id, channel: channel, keys: [.init(time: 0, value: value)])) + } + } + if let weights = player?.morphWeights[index] ?? node.morphRestWeights, !weights.isEmpty { + tracks.append(.init(nodeID: node.id, channel: .weights, keys: [.init(time: 0, value: weights)])) + } + } + let clip = AnimationLibraryClip(id: clipID, name: name, duration: duration, tracks: tracks) + var draft = self + draft.clips.append(clip) + try draft.validate() + return clip + } +} diff --git a/Sources/AdaAnimation/Authoring/AnimationAuthoringRequest.swift b/Sources/AdaAnimation/Authoring/AnimationAuthoringRequest.swift new file mode 100644 index 000000000..d779f9569 --- /dev/null +++ b/Sources/AdaAnimation/Authoring/AnimationAuthoringRequest.swift @@ -0,0 +1,89 @@ +/// Version-1 transport envelope for editor authoring clients. Model references remain detached strings. +public struct AnimationAuthoringRequest: Codable, Equatable, Sendable { + public enum Action: String, CaseIterable, Codable, Sendable { + case inspect, create, extract, apply, capture, sample, validate, save, undo, redo, bind, preview, reload, discard + case graphInspect, graphApply, graphPreview, graphUndo, graphRedo, graphBind + case modifierInspect, modifierCreate, modifierApply, modifierSample, modifierSave, modifierUndo, modifierRedo + case modifierPreview, modifierBind, modifierReload, modifierDiscard + + public var writesResource: Bool { + switch self { + case .create, .extract, .apply, .capture, .save, .undo, .redo, .reload, .discard, .graphApply, .graphUndo, .graphRedo: true + case .modifierCreate, .modifierApply, .modifierSave, .modifierUndo, .modifierRedo, .modifierReload, .modifierDiscard: true + default: false + } + } + + public var isReadOnly: Bool { + self == .inspect || self == .sample || self == .validate || self == .graphInspect || self == .modifierInspect || self == .modifierSample + } + + public var requiresPreviewPermission: Bool { self == .preview || self == .graphPreview || self == .modifierPreview } + public var requiresSceneWritePermission: Bool { self == .bind || self == .graphBind || self == .modifierBind } + + public var isModifierAction: Bool { + switch self { + case .modifierInspect, .modifierCreate, .modifierApply, .modifierSample, .modifierSave, .modifierUndo, .modifierRedo, + .modifierPreview, .modifierBind, .modifierReload, .modifierDiscard: true + default: false + } + } + + /// Graph actions address the open asset workspace; expectedRevision is its graph revision. + public var isGraphAction: Bool { + switch self { + case .graphInspect, .graphApply, .graphPreview, .graphUndo, .graphRedo, .graphBind: true + default: false + } + } + } + + public var action: Action + public var path: String + public var expectedRevision: String? + public var modelPath: String? + public var sourceClip: String? + public var name: String? + public var clipID: String? + public var trackID: String? + public var time: Double? + public var duration: Double? + public var operations: [AnimationEdit]? + public var scenePath: String? + public var entityID: String? + public var sceneRevision: String? + public var offset: Int? + public var limit: Int? + public var discardChanges: Bool? + public var graph: AnimationGraph? + public var modifierOperations: [AnimationModifierEdit]? + public var modifierBindings: [String: AnimationIKBinding3D]? + + public init(action: Action, path: String, expectedRevision: String? = nil, modelPath: String? = nil, + sourceClip: String? = nil, name: String? = nil, clipID: String? = nil, trackID: String? = nil, + time: Double? = nil, duration: Double? = nil, operations: [AnimationEdit]? = nil, + scenePath: String? = nil, entityID: String? = nil, sceneRevision: String? = nil, offset: Int? = nil, limit: Int? = nil, + discardChanges: Bool? = nil, graph: AnimationGraph? = nil, modifierOperations: [AnimationModifierEdit]? = nil, + modifierBindings: [String: AnimationIKBinding3D]? = nil) { + self.action = action + self.path = path + self.expectedRevision = expectedRevision + self.modelPath = modelPath + self.sourceClip = sourceClip + self.name = name + self.clipID = clipID + self.trackID = trackID + self.time = time + self.duration = duration + self.operations = operations + self.scenePath = scenePath + self.entityID = entityID + self.sceneRevision = sceneRevision + self.offset = offset + self.limit = limit + self.discardChanges = discardChanges + self.graph = graph + self.modifierOperations = modifierOperations + self.modifierBindings = modifierBindings + } +} diff --git a/Sources/AdaAnimation/Authoring/AnimationLibrary.swift b/Sources/AdaAnimation/Authoring/AnimationLibrary.swift new file mode 100644 index 000000000..c29543716 --- /dev/null +++ b/Sources/AdaAnimation/Authoring/AnimationLibrary.swift @@ -0,0 +1,169 @@ +import Foundation +import Math + +/// Portable, project-owned animation data. Imported model files remain unchanged. +/// Validate decoded or edited data before compiling it for playback. +public struct AnimationLibrary: Codable, Equatable, Sendable { + public static let maximumEncodedBytes = 32 * 1024 * 1024 + /// Bounds the compiled scalar work/storage of native temporal curves in one library. + public static let maximumCurveComponents = 500_000 + public var version: Int + public var id: String + public var binding: AnimationRigBinding + public var clips: [AnimationLibraryClip] + + var requiresNativeCurveFormat: Bool { + clips.contains { $0.tracks.contains { $0.interpolation == .bezier || $0.keys.contains { $0.curve != nil } } } + } + + mutating func promoteNativeCurveFormat() { + if version == 1, requiresNativeCurveFormat { version = 2 } + } + + public init(id: String = UUID().uuidString, binding: AnimationRigBinding, clips: [AnimationLibraryClip] = [], version: Int = 1) { + self.version = version + self.id = id + self.binding = binding + self.clips = clips + } + + /// Decodes portable data. Compilation also validates programmatically edited libraries. + public static func decode(_ data: Data) throws -> Self { + guard data.count <= maximumEncodedBytes else { + throw AnimationAuthoringError(.limitExceeded, itemID: "library", message: "Animation libraries must be at most 32 MiB.") + } + let library = try JSONDecoder().decode(Self.self, from: data) + try library.validate() + return library + } + + /// Encodes the same bounded JSON representation used by Studio and project resources. + public func encodedData() throws -> Data { + try validate() + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + guard data.count <= Self.maximumEncodedBytes else { + throw AnimationAuthoringError(.limitExceeded, itemID: id, message: "Animation libraries must be at most 32 MiB.") + } + return data + } +} + +/// A snapshot of the complete imported hierarchy, not offsets into a skin palette. +/// IDs belong to this binding. A changed source rig requires an explicit remap. +public struct AnimationRigBinding: Codable, Equatable, Sendable { + public struct Node: Codable, Equatable, Sendable { + public var id: String + public var name: String? + public var parentID: String? + public var restMatrix: Transform3D + public var restTranslation: Vector3? + public var restRotation: Vector4? + public var restScale: Vector3? + public var morphRestWeights: [Float]? + + public init(id: String, name: String? = nil, parentID: String? = nil, restMatrix: Transform3D = .identity, + restTranslation: Vector3? = nil, restRotation: Vector4? = nil, restScale: Vector3? = nil, + morphRestWeights: [Float]? = nil) { + self.id = id + self.name = name + self.parentID = parentID + self.restMatrix = restMatrix + self.restTranslation = restTranslation + self.restRotation = restRotation + self.restScale = restScale + self.morphRestWeights = morphRestWeights + } + } + + public var sourceAsset: String + public var nodes: [Node] + + public init(sourceAsset: String, nodes: [Node]) { + self.sourceAsset = sourceAsset + self.nodes = nodes + } +} + +/// A reusable clip with an explicit timeline, including any hold after its final key. +public struct AnimationLibraryClip: Codable, Equatable, Sendable { + public var id: String + public var name: String + public var duration: Double + public var repeats: Bool + public var sourceClip: String? + public var sourceFingerprint: String? + public var tracks: [AnimationLibraryTrack] + + public init(id: String = UUID().uuidString, name: String, duration: Double, repeats: Bool = true, + sourceClip: String? = nil, sourceFingerprint: String? = nil, tracks: [AnimationLibraryTrack] = []) { + self.id = id + self.name = name + self.duration = duration + self.repeats = repeats + self.sourceClip = sourceClip + self.sourceFingerprint = sourceFingerprint + self.tracks = tracks + } +} + +/// A typed bone TRS or mesh morph-weight track addressing a stable binding node ID. +public struct AnimationLibraryTrack: Codable, Equatable, Sendable { + public enum Channel: String, CaseIterable, Codable, Sendable { case translation, rotation, scale, weights } + public enum Interpolation: String, CaseIterable, Codable, Sendable { case linear, step, cubicSpline, bezier } + + public var id: String + public var nodeID: String + public var channel: Channel + public var interpolation: Interpolation + public var keys: [AnimationLibraryKey] + + public init(id: String = UUID().uuidString, nodeID: String, channel: Channel, interpolation: Interpolation = .linear, + keys: [AnimationLibraryKey] = []) { + self.id = id + self.nodeID = nodeID + self.channel = channel + self.interpolation = interpolation + self.keys = keys + } +} + +/// Cubic tangents are derivatives in value units per second, as in glTF CUBICSPLINE. +/// Rotations store XYZW quaternions; translation and scale store XYZ; weights use target order. +public struct AnimationLibraryKey: Codable, Equatable, Sendable { + public var id: String + public var time: Double + public var value: [Float] + public var incoming: [Float]? + public var outgoing: [Float]? + public var curve: AnimationKeyCurve? + + public init(id: String = UUID().uuidString, time: Double, value: [Float], incoming: [Float]? = nil, outgoing: [Float]? = nil, curve: AnimationKeyCurve? = nil) { + self.id = id + self.time = time + self.value = value + self.incoming = incoming + self.outgoing = outgoing + self.curve = curve + } +} + +/// A stable diagnostic code and the authored item that must be corrected. +public struct AnimationAuthoringError: Error, Equatable, LocalizedError, Sendable { + public enum Code: String, Codable, Sendable { + case unsupportedVersion, invalidIdentity, duplicateIdentity, invalidHierarchy, incompatibleRig, invalidCurve + case invalidDuration, invalidTrack, invalidKey, missingTarget, missingClip, missingTrack, missingKey, conflict, limitExceeded + } + + public let code: Code + public let itemID: String + public let message: String + public var errorDescription: String? { message } + + public init(_ code: Code, itemID: String, message: String) { + self.code = code + self.itemID = itemID + self.message = message + } +} diff --git a/Sources/AdaAnimation/Authoring/AnimationLibraryCompiler.swift b/Sources/AdaAnimation/Authoring/AnimationLibraryCompiler.swift new file mode 100644 index 000000000..914e288c8 --- /dev/null +++ b/Sources/AdaAnimation/Authoring/AnimationLibraryCompiler.swift @@ -0,0 +1,152 @@ +import Foundation +import Math + +extension AnimationRigBinding { + /// Captures identities for this imported hierarchy. Later reimports must pass full snapshot compatibility. + public init(rig: SkeletalRig, sourceAsset: String) { + self.init(sourceAsset: sourceAsset, nodes: Self.capture(rig, ids: rig.nodes.indices.map { "node-\($0)" })) + } + + /// Restores the saved rig for detached sampling without touching a scene or source asset. + public func makeRig() throws -> SkeletalRig { + try AnimationLibrary(binding: self).validate() + let indices = Dictionary(uniqueKeysWithValues: nodes.enumerated().map { ($0.element.id, $0.offset) }) + return try SkeletalRig(nodes: nodes.map { node in + let pose: SkeletalJointPose? + if let translation = node.restTranslation, let rotation = node.restRotation, let scale = node.restScale { + pose = .init(translation: translation, rotation: Quat(x: rotation.x, y: rotation.y, z: rotation.z, w: rotation.w), scale: scale) + } else { pose = nil } + return .init(parentIndex: node.parentID.flatMap { indices[$0] }, restPose: pose, restMatrix: node.restMatrix, + name: node.name, morphRestWeights: node.morphRestWeights) + }) + } + + /// Checks full hierarchy/rest data, not just names or a potentially colliding hash. + public func isCompatible(with rig: SkeletalRig) -> Bool { + nodes.count == rig.nodes.count && nodes == Self.capture(rig, ids: nodes.map(\.id)) + } + + private static func capture(_ rig: SkeletalRig, ids: [String]) -> [Node] { + rig.nodes.enumerated().map { index, node in + let rotation = node.restPose.map { Vector4($0.rotation.x, $0.rotation.y, $0.rotation.z, $0.rotation.w) } + return Node(id: ids[index], name: node.name, parentID: node.parentIndex.map { ids[$0] }, restMatrix: node.restMatrix, + restTranslation: node.restPose?.translation, restRotation: rotation, restScale: node.restPose?.scale, + morphRestWeights: node.morphRestWeights) + } + } +} + +extension AnimationLibrary { + /// Extracts an editable copy of a runtime/imported clip, preserving glTF cubic derivatives and morph keys. + public mutating func extract(_ source: SkeletalAnimationClip, rig: SkeletalRig, id clipID: String = UUID().uuidString, + name: String? = nil, sourceFingerprint: String? = nil) throws { + guard binding.isCompatible(with: rig) else { + throw AnimationAuthoringError(.incompatibleRig, itemID: id, message: "The source hierarchy/rest pose has changed; remap explicitly.") + } + var tracks: [AnimationLibraryTrack] = [] + for track in source.tracks { + guard binding.nodes.indices.contains(track.nodeIndex), let channel = AnimationLibraryTrack.Channel(rawValue: track.path.rawValue), + let interpolation = track.authoredCurves?.interpolation ?? AnimationLibraryTrack.Interpolation(rawValue: track.interpolation.rawValue) else { + throw AnimationAuthoringError(.invalidTrack, itemID: clipID, message: "Source track has an unsupported target/channel.") + } + let width = channel == .rotation ? 4 : 3 + let values = track.values.map { Array([$0.x, $0.y, $0.z, $0.w].prefix(width)) } + tracks.append(.init(nodeID: binding.nodes[track.nodeIndex].id, channel: channel, interpolation: interpolation, + keys: Self.importKeys(times: track.times, rows: values, cubic: interpolation == .cubicSpline, curves: track.authoredCurves?.metadata))) + } + for track in source.morphTracks { + guard binding.nodes.indices.contains(track.nodeIndex), + let interpolation = track.authoredCurves?.interpolation ?? AnimationLibraryTrack.Interpolation(rawValue: track.interpolation.rawValue) else { + throw AnimationAuthoringError(.invalidTrack, itemID: clipID, message: "Source morph track has an invalid target.") + } + let width = track.targetCount + let rows = stride(from: 0, to: track.values.count, by: width).map { Array(track.values[$0..<($0 + width)]) } + tracks.append(.init(nodeID: binding.nodes[track.nodeIndex].id, channel: .weights, interpolation: interpolation, + keys: Self.importKeys(times: track.times, rows: rows, cubic: interpolation == .cubicSpline, curves: track.authoredCurves?.metadata))) + } + var draft = self + draft.clips.append(.init(id: clipID, name: name ?? source.name, duration: source.duration, sourceClip: source.name, + sourceFingerprint: sourceFingerprint, tracks: tracks)) + draft.promoteNativeCurveFormat() + try draft.validate() + self = draft + } + + /// Qualified runtime names distinguish authored clip IDs from display labels. + /// Hosts must reject any collisions when merging authored and arbitrarily named imported clips. + public func runtimeName(for clipID: String) -> String { + "animlib:\(id.utf8.count):\(id):\(clipID)" + } + + /// Validates once and compiles to the existing allocation-free skeletal and morph samplers. + /// An unchanged player reuses these tracks; no binding lookup runs per sampled key. + public func compiledClips(for rig: SkeletalRig) throws -> [SkeletalAnimationClip] { + try validate() + guard binding.isCompatible(with: rig) else { + throw AnimationAuthoringError(.incompatibleRig, itemID: id, message: "The animation library needs an explicit rig remap.") + } + let indices = Dictionary(uniqueKeysWithValues: binding.nodes.enumerated().map { ($0.element.id, $0.offset) }) + return try clips.map { clip in + var poseTracks: [SkeletalAnimationTrack] = [] + var morphTracks: [MorphWeightsTrack] = [] + for track in clip.tracks where !track.keys.isEmpty { + guard let node = indices[track.nodeID] else { + throw AnimationAuthoringError(.missingTarget, itemID: track.id, message: "Track binding cannot be resolved.") + } + let interpolation: SkeletalAnimationTrack.Interpolation + switch track.interpolation { + case .linear: interpolation = .linear + case .bezier: interpolation = .bezier + case .step: interpolation = .step + case .cubicSpline: interpolation = .cubicSpline + } + let curves = try CompiledAnimationCurves.make(track, width: track.keys[0].value.count) + let rows = track.keys.flatMap { key -> [[Float]] in + if track.interpolation == .cubicSpline { return [key.incoming ?? [], key.value, key.outgoing ?? []] } + return [key.value] + } + if track.channel == .weights { + var runtime = try MorphWeightsTrack(nodeIndex: node, interpolation: interpolation, times: track.keys.map(\.time), + targetCount: binding.nodes[node].morphRestWeights?.count ?? 0, values: rows.flatMap { $0 }) + runtime.authoredCurves = curves + morphTracks.append(runtime) + } else { + guard let path = SkeletalAnimationTrack.Path(rawValue: track.channel.rawValue) else { + throw AnimationAuthoringError(.invalidTrack, itemID: track.id, message: "Track channel cannot be compiled.") + } + let values = rows.map { Vector4($0[0], $0[1], $0[2], $0.count == 4 ? $0[3] : 0) } + var runtime = try SkeletalAnimationTrack(nodeIndex: node, path: path, interpolation: interpolation, + times: track.keys.map(\.time), values: values) + runtime.authoredCurves = curves + poseTracks.append(runtime) + } + } + return try .init(name: runtimeName(for: clip.id), tracks: poseTracks, morphTracks: morphTracks, duration: clip.duration) + } + } + + /// Samples a detached clip without events, gameplay callbacks or scene mutation. + public func sample(clipID: String, at time: Double) throws -> SkeletalAnimationPlayer { + guard time.isFinite else { + throw AnimationAuthoringError(.invalidKey, itemID: clipID, message: "Sample time must be finite.") + } + guard let clip = clips.first(where: { $0.id == clipID }) else { + throw AnimationAuthoringError(.missingClip, itemID: clipID, message: "Clip does not exist.") + } + let rig = try binding.makeRig() + var player = try SkeletalAnimationPlayer(rig: rig, clips: compiledClips(for: rig)) + player.repeats = clip.repeats + try player.play(runtimeName(for: clipID), transitionDuration: 0) + player.seek(to: time) + player.isPlaying = false + return player + } + + private static func importKeys(times: [Double], rows: [[Float]], cubic: Bool, curves: [AnimationKeyCurve?]? = nil) -> [AnimationLibraryKey] { + times.enumerated().map { index, time in + let offset = index * (cubic ? 3 : 1) + return .init(time: time, value: rows[offset + (cubic ? 1 : 0)], incoming: cubic ? rows[offset] : nil, + outgoing: cubic ? rows[offset + 2] : nil, curve: curves.flatMap { $0[index] }) + } + } +} diff --git a/Sources/AdaAnimation/Authoring/AnimationLibraryValidation.swift b/Sources/AdaAnimation/Authoring/AnimationLibraryValidation.swift new file mode 100644 index 000000000..b6fac48c0 --- /dev/null +++ b/Sources/AdaAnimation/Authoring/AnimationLibraryValidation.swift @@ -0,0 +1,143 @@ +import Foundation +import Math + +extension AnimationLibrary { + /// Checks portable data before saving, committing an edit, or compiling playback tracks. + public func validate() throws(AnimationAuthoringError) { + guard version == 1 || version == 2 else { throw .init(.unsupportedVersion, itemID: id, message: "Unsupported animation library version: \(version).") } + guard version != 1 || !requiresNativeCurveFormat else { + throw .init(.unsupportedVersion, itemID: id, message: "Native temporal curves require animation library version 2.") + } + try validateIdentities([id]) + try validateIdentities(binding.nodes.map(\.id)) + try validateIdentities(clips.map(\.id)) + guard binding.nodes.count <= 16_384, clips.count <= 256 else { + throw .init(.limitExceeded, itemID: id, message: "A library supports at most 16384 rig nodes and 256 clips.") + } + try validateText(binding.sourceAsset, maximum: 4_096, itemID: id) + let indices = Dictionary(uniqueKeysWithValues: binding.nodes.enumerated().map { ($0.element.id, $0.offset) }) + var rigNodes: [SkeletalRig.Node] = [] + for node in binding.nodes { + try validateText(node.name ?? "", maximum: 1_024, itemID: node.id) + guard (node.morphRestWeights?.count ?? 0) <= 1_024 else { + throw .init(.limitExceeded, itemID: node.id, message: "A node supports at most 1024 morph targets.") + } + if let parent = node.parentID, indices[parent] == nil { + throw .init(.missingTarget, itemID: node.id, message: "The parent binding does not exist.") + } + let matrix = node.restMatrix + guard [matrix.x, matrix.y, matrix.z, matrix.w].allSatisfy({ [$0.x, $0.y, $0.z, $0.w].allSatisfy(\.isFinite) }), + node.morphRestWeights?.allSatisfy(\.isFinite) != false else { + throw .init(.invalidHierarchy, itemID: node.id, message: "Rest transforms and morph weights must be finite.") + } + let pose: SkeletalJointPose? + switch (node.restTranslation, node.restRotation, node.restScale) { + case let (translation?, rotation?, scale?): + guard [translation.x, translation.y, translation.z, rotation.x, rotation.y, rotation.z, rotation.w, + scale.x, scale.y, scale.z].allSatisfy(\.isFinite), abs(rotation.dot(rotation) - 1) < 0.001 else { + throw .init(.invalidHierarchy, itemID: node.id, message: "Rest TRS needs finite values and a normalized quaternion.") + } + pose = .init(translation: translation, rotation: Quat(x: rotation.x, y: rotation.y, z: rotation.z, w: rotation.w), scale: scale) + case (nil, nil, nil): pose = nil + default: throw .init(.invalidHierarchy, itemID: node.id, message: "Rest TRS must contain translation, rotation and scale together.") + } + if let pose { + let derived = pose.matrix + for column in 0..<4 { + for row in 0..<4 { + let expected = matrix[column, row] + let difference = abs(derived[column, row] - expected) + guard difference.isFinite, difference <= 0.00001 * max(1, abs(expected)) else { + throw .init(.invalidHierarchy, itemID: node.id, message: "Rest TRS and the stored rest matrix must agree.") + } + } + } + } + rigNodes.append(.init(parentIndex: node.parentID.flatMap { indices[$0] }, restPose: pose, restMatrix: matrix, + name: node.name, morphRestWeights: node.morphRestWeights)) + } + do { _ = try SkeletalRig(nodes: rigNodes) } catch { + throw .init(.invalidHierarchy, itemID: id, message: "The rig binding contains a cycle.") + } + var keyCount = 0 + var curveComponents = 0 + for clip in clips { + try validateText(clip.name, maximum: 1_024, itemID: clip.id) + try validateText(clip.sourceClip ?? "", maximum: 1_024, itemID: clip.id) + try validateText(clip.sourceFingerprint ?? "", maximum: 256, itemID: clip.id) + guard clip.duration.isFinite, clip.duration >= 0 else { + throw .init(.invalidDuration, itemID: clip.id, message: "Clip duration must be finite and nonnegative.") + } + guard !clip.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, clip.tracks.count <= 4_096 else { + throw .init(.invalidTrack, itemID: clip.id, message: "A clip needs a name and at most 4096 tracks.") + } + try validateIdentities(clip.tracks.map(\.id)) + var targets: Set = [] + for track in clip.tracks { + guard let index = indices[track.nodeID] else { + throw .init(.missingTarget, itemID: track.id, message: "Track binding '\(track.nodeID)' does not exist.") + } + // Node IDs are length-delimited so channel-like suffixes cannot collide. + let target = "\(track.nodeID.utf8.count):\(track.nodeID):\(track.channel.rawValue)" + guard targets.insert(target).inserted else { + throw .init(.invalidTrack, itemID: track.id, message: "Multiple tracks write the same node channel.") + } + let node = binding.nodes[index] + let width = track.channel == .weights ? node.morphRestWeights?.count ?? 0 : track.channel == .rotation ? 4 : 3 + guard width > 0, track.channel == .weights || node.restTranslation != nil, + !(track.channel == .weights && track.interpolation == .cubicSpline && track.keys.count == 1) else { + throw .init(.invalidTrack, itemID: track.id, message: "Track channel is incompatible with its binding.") + } + keyCount += track.keys.count + guard keyCount <= 100_000 else { + throw .init(.limitExceeded, itemID: track.id, message: "A library supports at most 100000 keys.") + } + try validateIdentities(track.keys.map(\.id)) + var previous: Double = -1 + for key in track.keys { + guard key.time.isFinite, key.time >= 0, key.time <= clip.duration, key.time > previous, + key.value.count == width, key.value.allSatisfy(\.isFinite) else { + throw .init(.invalidKey, itemID: key.id, message: "Keys need finite ordered times within the duration and correctly typed values.") + } + previous = key.time + if track.channel == .rotation, abs(key.value.reduce(Float(0)) { $0 + $1 * $1 } - 1) >= 0.001 { + throw .init(.invalidKey, itemID: key.id, message: "Rotation keys need normalized XYZW quaternions.") + } + for tangent in [key.incoming, key.outgoing] { + if let tangent { + guard tangent.count == width, tangent.allSatisfy(\.isFinite) else { + throw .init(.invalidKey, itemID: key.id, message: "Tangent dimensions and values must match the channel.") + } + } else if track.interpolation == .cubicSpline { + throw .init(.invalidKey, itemID: key.id, message: "Cubic keys need incoming and outgoing derivatives.") + } + } + } + curveComponents += CompiledAnimationCurves.componentCount(for: track, width: width) + guard curveComponents <= Self.maximumCurveComponents else { + throw .init(.limitExceeded, itemID: track.id, message: "Native curves support at most 500000 compiled scalar segments per library.") + } + do { _ = try CompiledAnimationCurves.make(track, width: width) } + catch { throw .init(.invalidCurve, itemID: track.id, message: "Invalid temporal curve: \(error).") } + } + } + } + + private func validateText(_ text: String, maximum: Int, itemID: String) throws(AnimationAuthoringError) { + guard text.utf8.count <= maximum else { + throw .init(.limitExceeded, itemID: itemID, message: "Metadata exceeds its \(maximum) UTF-8 byte limit.") + } + } + + private func validateIdentities(_ identities: [String]) throws(AnimationAuthoringError) { + var seen: Set = [] + for identity in identities { + guard !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, identity.utf8.count <= 256 else { + throw .init(.invalidIdentity, itemID: identity, message: "IDs must be nonempty and at most 256 UTF-8 bytes.") + } + guard seen.insert(identity).inserted else { + throw .init(.duplicateIdentity, itemID: identity, message: "Duplicate authored ID: \(identity).") + } + } + } +} diff --git a/Sources/AdaAnimation/Curves/AnimationBezier.swift b/Sources/AdaAnimation/Curves/AnimationBezier.swift new file mode 100644 index 000000000..a2cd2e828 --- /dev/null +++ b/Sources/AdaAnimation/Curves/AnimationBezier.swift @@ -0,0 +1,150 @@ +/// A temporal Bézier handle relative to its key, in seconds and value units. +/// It is not a Hermite derivative or a spatial path control point. +public struct AnimationBezierHandle: Codable, Equatable, Sendable { + public var timeOffset: Double + public var valueOffset: Double + + public init(timeOffset: Double, valueOffset: Double) { + self.timeOffset = timeOffset + self.valueOffset = valueOffset + } +} + +/// One scalar temporal segment. Endpoint/control values use the portable Float range. +/// Time is solved first; easing then remaps the Bézier parameter before sampling value. +public struct AnimationBezierSegment: Codable, Equatable, Sendable { + private enum CodingKeys: String, CodingKey { case startTime, endTime, startValue, endValue, outgoing, incoming, easing } + + public var startTime: Double + public var endTime: Double + public var startValue: Double + public var endValue: Double + public var outgoing: AnimationBezierHandle + public var incoming: AnimationBezierHandle + public var easing: AnimationEasing + + public init(startTime: Double, endTime: Double, startValue: Double, endValue: Double, + outgoing: AnimationBezierHandle? = nil, incoming: AnimationBezierHandle? = nil, + easing: AnimationEasing = .init()) { + self.startTime = startTime + self.endTime = endTime + self.startValue = startValue + self.endValue = endValue + self.outgoing = outgoing ?? .init(timeOffset: (endTime - startTime) / 3, valueOffset: (endValue - startValue) / 3) + self.incoming = incoming ?? .init(timeOffset: (startTime - endTime) / 3, valueOffset: (startValue - endValue) / 3) + self.easing = easing + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.init(startTime: try values.decode(Double.self, forKey: .startTime), endTime: try values.decode(Double.self, forKey: .endTime), + startValue: try values.decode(Double.self, forKey: .startValue), endValue: try values.decode(Double.self, forKey: .endValue), + outgoing: try values.decodeIfPresent(AnimationBezierHandle.self, forKey: .outgoing), + incoming: try values.decodeIfPresent(AnimationBezierHandle.self, forKey: .incoming), + easing: try values.decodeIfPresent(AnimationEasing.self, forKey: .easing) ?? .init()) + } + + public func validate() throws(AnimationCurveError) { + _ = try CompiledAnimationBezier(self) + } + + /// Clamps finite time to the segment. Invalid data fails before evaluation. + public func sample(at time: Double) throws(AnimationCurveError) -> Double { + guard time.isFinite else { throw .invalidProgress } + return try CompiledAnimationBezier(self).sample(at: time) + } +} + +/// Coefficients and validation are retained once by a compiled animation track. +struct CompiledAnimationBezier: Sendable { + private let segment: AnimationBezierSegment + private let duration: Double + private let timeA: Double + private let timeB: Double + private let timeC: Double + + init(_ segment: AnimationBezierSegment) throws(AnimationCurveError) { + let startTime = segment.startTime, endTime = segment.endTime + let outgoing = segment.outgoing, incoming = segment.incoming + let startValue = segment.startValue, endValue = segment.endValue + let duration = endTime - startTime + guard startTime.isFinite, endTime.isFinite, duration.isFinite, duration > 0, + outgoing.timeOffset.isFinite, incoming.timeOffset.isFinite else { throw .invalidSegment } + let values = [startValue, endValue, outgoing.valueOffset, incoming.valueOffset, + startValue + outgoing.valueOffset, endValue + incoming.valueOffset] + guard values.allSatisfy({ $0.isFinite && abs($0) <= Double(Float.greatestFiniteMagnitude) }) else { throw .invalidSegment } + try segment.easing.validate() + let first = outgoing.timeOffset / duration, second = 1 + incoming.timeOffset / duration + let a = 1 - 3 * second + 3 * first, b = 2 * (second - 2 * first) + guard first.isFinite, second.isFinite, a.isFinite, b.isFinite else { throw .invalidSegment } + // A cubic is monotone exactly when its derivative is nonnegative on [0, 1]. + // Checking the quadratic's endpoints and interior minimum also permits crossing handles. + guard first >= 0, second <= 1 else { throw .nonMonotoneTime } + if a > 0 { + let minimum = -b / (2 * a) + if minimum > 0, minimum < 1, (a * minimum + b) * minimum + first < 0 { throw .nonMonotoneTime } + } + self.segment = segment + self.duration = duration + self.timeA = a + self.timeB = 3 * second - 6 * first + self.timeC = 3 * first + } + + func sample(at time: Double) -> Double { + guard time.isFinite, time > segment.startTime else { return segment.startValue } + if time >= segment.endTime { return segment.endValue } + let progress = (time - segment.startTime) / duration + let parameter = segment.easing.evaluate(parameter(at: progress)) + return value(at: parameter) + } + + /// Rejects a curve that could overflow a Float property; the curve itself is never clamped. + func requireFloatRange() throws(AnimationCurveError) { + let range = segment.easing.parameterRange + if range.lowerBound >= 0, range.upperBound <= 1 { return } + let first = segment.startValue + segment.outgoing.valueOffset, second = segment.endValue + segment.incoming.valueOffset + let a = -segment.startValue + 3 * first - 3 * second + segment.endValue + let b = 3 * segment.startValue - 6 * first + 3 * second + let c = 3 * (first - segment.startValue) + var candidates = [range.lowerBound, range.upperBound] + if a == 0 { + if b != 0 { candidates.append(-c / (2 * b)) } + } else { + let discriminant = b * b - 3 * a * c + if discriminant >= 0 { + let root = discriminant.squareRoot() + let q = -b - (b < 0 ? -root : root) + if q == 0 { candidates.append(-b / (3 * a)) } + else { candidates.append(q / (3 * a)); candidates.append(c / q) } + } + } + for parameter in candidates where range.contains(parameter) { + let sampled = value(at: parameter) + guard sampled.isFinite, abs(sampled) <= Double(Float.greatestFiniteMagnitude) else { throw .invalidSegment } + } + } + + private func value(at parameter: Double) -> Double { + let one = Self.interpolate(segment.startValue, segment.startValue + segment.outgoing.valueOffset, parameter) + let two = Self.interpolate(segment.startValue + segment.outgoing.valueOffset, segment.endValue + segment.incoming.valueOffset, parameter) + let three = Self.interpolate(segment.endValue + segment.incoming.valueOffset, segment.endValue, parameter) + return Self.interpolate(Self.interpolate(one, two, parameter), Self.interpolate(two, three, parameter), parameter) + } + + private func parameter(at time: Double) -> Double { + var lower = 0.0, upper = 1.0 + // A fixed bound handles vertical/flat tangents without dividing by their derivative. + for _ in 0..<48 { + let middle = (lower + upper) / 2 + let value = ((timeA * middle + timeB) * middle + timeC) * middle + if value == time { return middle } + if value < time { lower = middle } else { upper = middle } + } + return (lower + upper) / 2 + } + + private static func interpolate(_ source: Double, _ target: Double, _ amount: Double) -> Double { + source * (1 - amount) + target * amount + } +} diff --git a/Sources/AdaAnimation/Curves/AnimationEasing.swift b/Sources/AdaAnimation/Curves/AnimationEasing.swift new file mode 100644 index 000000000..bc2554f3e --- /dev/null +++ b/Sources/AdaAnimation/Curves/AnimationEasing.swift @@ -0,0 +1,113 @@ +import Foundation + +/// Invalid portable curve data. Editors validate before replacing an existing draft. +public enum AnimationCurveError: Error, Equatable, Sendable { + case invalidParameters + case invalidProgress + case invalidSegment + case nonMonotoneTime +} + +/// A time-independent easing family, separate from key interpolation and spatial paths. +public struct AnimationEasing: Codable, Equatable, Sendable { + public enum Family: String, CaseIterable, Codable, Sendable { + case linear, quadratic, cubic, quartic, quintic, sine, exponential, circular, back, elastic, bounce + } + + public enum Direction: String, CaseIterable, Codable, Sendable { + case `in`, out, inOut, outIn + } + + private enum CodingKeys: String, CodingKey { case family, direction, overshoot, amplitude, period } + + public var family: Family + public var direction: Direction + public var overshoot: Double + public var amplitude: Double + public var period: Double + + public init(family: Family = .linear, direction: Direction = .inOut, + overshoot: Double = 1.70158, amplitude: Double = 1, period: Double = 0.3) { + self.family = family + self.direction = direction + self.overshoot = overshoot + self.amplitude = amplitude + self.period = period + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.init(family: try values.decodeIfPresent(Family.self, forKey: .family) ?? .linear, + direction: try values.decodeIfPresent(Direction.self, forKey: .direction) ?? .inOut, + overshoot: try values.decodeIfPresent(Double.self, forKey: .overshoot) ?? 1.70158, + amplitude: try values.decodeIfPresent(Double.self, forKey: .amplitude) ?? 1, + period: try values.decodeIfPresent(Double.self, forKey: .period) ?? 0.3) + } + + /// Parameters are bounded before compilation; unused parameters must also be valid. + public func validate() throws(AnimationCurveError) { + guard overshoot.isFinite, (0...8).contains(overshoot), amplitude.isFinite, (1...8).contains(amplitude), + period.isFinite, (0.00001...4).contains(period) else { throw .invalidParameters } + } + + /// Samples normalized progress. Input is clamped to [0, 1]; output may overshoot. + public func sample(_ progress: Double) throws(AnimationCurveError) -> Double { + try validate() + guard progress.isFinite else { throw .invalidProgress } + return evaluate(min(1, max(0, progress))) + } + + func evaluate(_ progress: Double) -> Double { + if family == .linear || progress == 0 || progress == 1 { return progress } + switch direction { + case .in: return easeIn(progress) + case .out: return 1 - easeIn(1 - progress) + case .inOut: + return progress < 0.5 ? easeIn(2 * progress) / 2 : 1 - easeIn(2 * (1 - progress)) / 2 + case .outIn: + return progress < 0.5 ? (1 - easeIn(1 - 2 * progress)) / 2 : (1 + easeIn(2 * progress - 1)) / 2 + } + } + + var parameterRange: ClosedRange { + switch family { + case .back: return (-overshoot)...(1 + overshoot) + case .elastic: return (-amplitude)...(1 + amplitude) + default: return 0...1 + } + } + + private func easeIn(_ progress: Double) -> Double { + if progress == 0 || progress == 1 { return progress } + switch family { + case .linear: return progress + case .quadratic: return progress * progress + case .cubic: return progress * progress * progress + case .quartic: return pow(progress, 4) + case .quintic: return pow(progress, 5) + case .sine: return 1 - cos(progress * .pi / 2) + case .exponential: return pow(2, 10 * (progress - 1)) + case .circular: return 1 - sqrt(max(0, 1 - progress * progress)) + case .back: return progress * progress * ((overshoot + 1) * progress - overshoot) + case .elastic: + let phase = asin(1 / amplitude) * period / (2 * .pi) + return -amplitude * pow(2, 10 * (progress - 1)) * sin((progress - 1 - phase) * 2 * .pi / period) + case .bounce: return 1 - Self.bounceOut(1 - progress) + } + } + + private static func bounceOut(_ progress: Double) -> Double { + let scale = 7.5625, span = 2.75 + if progress < 1 / span { return scale * progress * progress } + if progress < 2 / span { + let value = progress - 1.5 / span + return scale * value * value + 0.75 + } + if progress < 2.5 / span { + let value = progress - 2.25 / span + return scale * value * value + 0.9375 + } + let value = progress - 2.625 / span + return scale * value * value + 0.984375 + } +} diff --git a/Sources/AdaAnimation/Curves/AnimationKeyCurve.swift b/Sources/AdaAnimation/Curves/AnimationKeyCurve.swift new file mode 100644 index 000000000..9203462c6 --- /dev/null +++ b/Sources/AdaAnimation/Curves/AnimationKeyCurve.swift @@ -0,0 +1,13 @@ +/// Optional native curve settings for a key. Easing applies to its outgoing segment. +/// Bézier handles are per component; Hermite derivatives remain in the key's tangent fields. +public struct AnimationKeyCurve: Codable, Equatable, Sendable { + public var easing: AnimationEasing? + public var incoming: [AnimationBezierHandle]? + public var outgoing: [AnimationBezierHandle]? + + public init(easing: AnimationEasing? = nil, incoming: [AnimationBezierHandle]? = nil, outgoing: [AnimationBezierHandle]? = nil) { + self.easing = easing + self.incoming = incoming + self.outgoing = outgoing + } +} diff --git a/Sources/AdaAnimation/Curves/CompiledAnimationCurves.swift b/Sources/AdaAnimation/Curves/CompiledAnimationCurves.swift new file mode 100644 index 000000000..1b89462c4 --- /dev/null +++ b/Sources/AdaAnimation/Curves/CompiledAnimationCurves.swift @@ -0,0 +1,85 @@ +/// Optional native sampling data. Legacy/imported tracks without curve metadata retain their original evaluator. +struct CompiledAnimationCurves: Sendable { + let interpolation: AnimationLibraryTrack.Interpolation + let metadata: [AnimationKeyCurve?] + let requiresSampling: Bool + private let times: [Double] + private let easing: [AnimationEasing] + private let segments: [CompiledAnimationBezier] + private let offsets: [Int] + + static func componentCount(for track: AnimationLibraryTrack, width: Int) -> Int { + guard track.interpolation != .step, track.keys.count > 1 else { return 0 } + if track.interpolation == .bezier { return (track.keys.count - 1) * width } + return track.keys.dropLast().reduce(0) { $0 + (($1.curve?.easing?.family ?? .linear) == .linear ? 0 : width) } + } + + static func make(_ track: AnimationLibraryTrack, width: Int) throws(AnimationCurveError) -> Self? { + guard track.interpolation == .bezier || track.keys.contains(where: { $0.curve != nil }) else { return nil } + return try Self(track, width: width) + } + + private init(_ track: AnimationLibraryTrack, width: Int) throws(AnimationCurveError) { + let rotation = track.channel == .rotation + guard width > 0, !(rotation && track.interpolation == .bezier), + Self.componentCount(for: track, width: width) <= AnimationLibrary.maximumCurveComponents else { throw .invalidSegment } + for key in track.keys { + guard key.value.count == width else { throw .invalidSegment } + if let value = key.curve { + try value.easing?.validate() + for handles in [value.incoming, value.outgoing] { + guard let handles else { continue } + guard !rotation, handles.count == width, + handles.allSatisfy({ $0.timeOffset.isFinite && $0.valueOffset.isFinite && abs($0.valueOffset) <= Double(Float.greatestFiniteMagnitude) }) else { + throw .invalidSegment + } + } + } + } + self.interpolation = track.interpolation + self.metadata = track.keys.map(\.curve) + self.times = track.keys.map(\.time) + self.easing = track.keys.map { $0.curve?.easing ?? .init() } + self.requiresSampling = Self.componentCount(for: track, width: width) > 0 + if !requiresSampling || rotation && track.interpolation == .linear { self.segments = []; self.offsets = []; return } + var segments: [CompiledAnimationBezier] = [] + var offsets = Array(repeating: -1, count: track.keys.count - 1) + segments.reserveCapacity(Self.componentCount(for: track, width: width)) + for index in 0..<(track.keys.count - 1) { + if track.interpolation != .bezier, easing[index].family == .linear { continue } + offsets[index] = segments.count + let source = track.keys[index], target = track.keys[index + 1] + let duration = target.time - source.time + for component in 0.. Bool { + interpolation != .step && (interpolation == .bezier || easing[lower].family != .linear) + } + + func amount(at time: Double, lower: Int) -> Double { + easing[lower].evaluate((time - times[lower]) / (times[lower + 1] - times[lower])) + } + + func sample(at time: Double, lower: Int, component: Int) -> Double { + segments[offsets[lower] + component].sample(at: time) + } +} diff --git a/Sources/AdaAnimation/Modifiers/AnimationModifierBindings.swift b/Sources/AdaAnimation/Modifiers/AnimationModifierBindings.swift new file mode 100644 index 000000000..68cf5bc25 --- /dev/null +++ b/Sources/AdaAnimation/Modifiers/AnimationModifierBindings.swift @@ -0,0 +1,26 @@ +import Math + +/// With an entity ID, `point` is an offset in that entity's local space; otherwise it is a model-space point. +public struct AnimationIKTarget3D: Codable, Equatable, Sendable { + public var entity: String? + public var point: Vector3 + + public init(entity: String? = nil, point: Vector3 = .zero) { self.entity = entity; self.point = point } + + public func validate() throws { + guard point.x.isFinite, point.y.isFinite, point.z.isFinite, + entity.map({ !$0.isEmpty && $0.utf8.count <= 256 }) ?? true else { + throw AnimationPoseModifierError.invalidModifier(entity ?? "target") + } + } +} + +/// Instance overrides for a stable modifier ID. Nil members retain the resource's model-space point. +public struct AnimationIKBinding3D: Codable, Equatable, Sendable { + public var target: AnimationIKTarget3D? + public var pole: AnimationIKTarget3D? + + public init(target: AnimationIKTarget3D? = nil, pole: AnimationIKTarget3D? = nil) { self.target = target; self.pole = pole } + + public func validate() throws { try target?.validate(); try pole?.validate() } +} diff --git a/Sources/AdaAnimation/Modifiers/AnimationModifierEdit.swift b/Sources/AdaAnimation/Modifiers/AnimationModifierEdit.swift new file mode 100644 index 000000000..c4fcba149 --- /dev/null +++ b/Sources/AdaAnimation/Modifiers/AnimationModifierEdit.swift @@ -0,0 +1,44 @@ +/// An atomic semantic edit, shared by native controls, agents and developer Tools. +public struct AnimationModifierEdit: Codable, Equatable, Sendable { + public enum Kind: String, Codable, Sendable { case add, remove, move, update } + public var kind: Kind + public var modifierID: String? + public var modifier: AnimationPoseModifier? + public var index: Int? + + public init(kind: Kind, modifierID: String? = nil, modifier: AnimationPoseModifier? = nil, index: Int? = nil) { + self.kind = kind; self.modifierID = modifierID; self.modifier = modifier; self.index = index + } +} + +extension AnimationModifierStack { + /// Applies the entire transaction or leaves this stack unchanged. + public mutating func apply(_ operations: [AnimationModifierEdit]) throws { + guard operations.count <= 256 else { throw AnimationPoseModifierError.limitExceeded } + var candidate = self + for operation in operations { + switch operation.kind { + case .add: + guard let modifier = operation.modifier, candidate.modifiers.count < 32 else { throw AnimationPoseModifierError.limitExceeded } + let index = operation.index ?? candidate.modifiers.count + guard (0...candidate.modifiers.count).contains(index) else { throw AnimationPoseModifierError.invalidModifier(modifier.id) } + candidate.modifiers.insert(modifier, at: index) + case .remove, .move, .update: + guard let id = operation.modifierID, let current = candidate.modifiers.firstIndex(where: { $0.id == id }) else { + throw AnimationPoseModifierError.invalidModifier(operation.modifierID ?? "") + } + if operation.kind == .remove { candidate.modifiers.remove(at: current) } + else if operation.kind == .move { + guard let index = operation.index, candidate.modifiers.indices.contains(index) else { throw AnimationPoseModifierError.invalidModifier(id) } + let modifier = candidate.modifiers.remove(at: current) + candidate.modifiers.insert(modifier, at: index) + } else { + guard let modifier = operation.modifier, modifier.id == id else { throw AnimationPoseModifierError.invalidModifier(id) } + candidate.modifiers[current] = modifier + } + } + } + try candidate.validate() + self = candidate + } +} diff --git a/Sources/AdaAnimation/Modifiers/AnimationModifierStack.swift b/Sources/AdaAnimation/Modifiers/AnimationModifierStack.swift new file mode 100644 index 000000000..98dfe05c8 --- /dev/null +++ b/Sources/AdaAnimation/Modifiers/AnimationModifierStack.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Portable `.animmod` data. Scene target bindings belong to a model instance, not this resource. +public struct AnimationModifierStack: Codable, Equatable, Sendable { + public static let maximumEncodedBytes = 1_048_576 + public var version: Int + public var id: String + public var binding: AnimationRigBinding + public var modifiers: [AnimationPoseModifier] + + public init(id: String = UUID().uuidString, binding: AnimationRigBinding, modifiers: [AnimationPoseModifier] = [], version: Int = 1) { + self.version = version + self.id = id + self.binding = binding + self.modifiers = modifiers + } + + /// Validates before any playback or resource replacement. Unknown versions are never rewritten. + public func validate() throws { + guard version == 1, !id.isEmpty, id.utf8.count <= 256 else { throw AnimationPoseModifierError.invalidModifier(id) } + let rig = try binding.makeRig() + _ = try CompiledPoseModifiers(modifiers, rig: rig, binding: binding) + } + + public static func decode(_ data: Data) throws -> Self { + guard data.count <= maximumEncodedBytes else { throw AnimationPoseModifierError.limitExceeded } + let stack = try JSONDecoder().decode(Self.self, from: data) + try stack.validate() + return stack + } + + public func encodedData() throws -> Data { + try validate() + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + guard data.count <= Self.maximumEncodedBytes else { throw AnimationPoseModifierError.limitExceeded } + return data + } +} diff --git a/Sources/AdaAnimation/Modifiers/AnimationPoseModifier.swift b/Sources/AdaAnimation/Modifiers/AnimationPoseModifier.swift new file mode 100644 index 000000000..e33130a14 --- /dev/null +++ b/Sources/AdaAnimation/Modifiers/AnimationPoseModifier.swift @@ -0,0 +1,78 @@ +import Foundation +import Math + +/// An ordered, model-space constraint. Joint IDs belong to the complete rig binding. +public struct AnimationPoseModifier: Codable, Equatable, Sendable { + public enum Kind: String, Codable, Sendable { case twoBone, fabrik } + public var id: String + public var name: String? + public var kind: Kind + public var joints: [String] + public var target: Vector3 + public var pole: Vector3? + public var enabled: Bool + public var influence: Float + public var iterations: Int + public var tolerance: Float? + + public init(id: String = UUID().uuidString, kind: Kind, joints: [String], target: Vector3, + pole: Vector3? = nil, enabled: Bool = true, influence: Float = 1, + iterations: Int = 16, tolerance: Float? = nil, name: String? = nil) { + self.id = id + self.name = name + self.kind = kind + self.joints = joints + self.target = target + self.pole = pole + self.enabled = enabled + self.influence = influence + self.iterations = iterations + self.tolerance = tolerance + } +} + +public enum AnimationPoseModifierError: Error, Equatable, Sendable { + case incompatibleRig + case invalidModifier(String) + case invalidChain(String) + case limitExceeded +} + +extension AnimationPoseModifierError: LocalizedError { + public var errorDescription: String? { + switch self { + case .incompatibleRig: "IK binding does not match the complete model rig. Remap its stable bone IDs explicitly." + case let .invalidModifier(id): "Invalid IK settings for '\(id)'. Check finite points, influence, tolerance and unique IDs." + case let .invalidChain(id): "Invalid IK chain for '\(id)'. Choose directly parented, editable joints with nonzero segments." + case .limitExceeded: "IK limits exceeded: 32 modifiers, 64 segments, 1–64 iterations and 32,768 segment-iterations per stack." + } + } +} + +/// Ephemeral input resolved from one current scene snapshot, never persisted into a reusable stack. +/// A nil target explicitly means that a bound scene target is unavailable. +public struct AnimationPoseModifierInput: Sendable { + public var target: Vector3? + public var pole: Vector3? + public var supportsTransform: Bool + + public init(target: Vector3?, pole: Vector3? = nil, supportsTransform: Bool = true) { + self.target = target + self.pole = pole + self.supportsTransform = supportsTransform + } +} + +/// A bounded result for one modifier. Errors are measured in model-space units. +public struct AnimationPoseModifierDiagnostic: Equatable, Sendable { + public enum Status: String, Codable, Sendable { + case solved, unreachable, iterationLimit, disabled, invalidTarget, unsupportedTransform, invalidPose + } + public var id: String + public var status: Status + public var iterations: Int + /// Distance after the full-strength solve, before influence blending. + public var solverError: Float? + /// Distance from the endpoint actually published after influence blending. + public var reachError: Float? +} diff --git a/Sources/AdaAnimation/Modifiers/CompiledPoseModifiers.swift b/Sources/AdaAnimation/Modifiers/CompiledPoseModifiers.swift new file mode 100644 index 000000000..2bbd6f319 --- /dev/null +++ b/Sources/AdaAnimation/Modifiers/CompiledPoseModifiers.swift @@ -0,0 +1,210 @@ +import Math + +/// Compiles stable IDs once; each player owns its own stack and scratch storage. +struct CompiledPoseModifiers: Sendable { + struct Entry: Sendable { + var configuration: AnimationPoseModifier + var joints: [Int] + } + var entries: [Entry] + private var positions: [Vector3] + private var lengths: [Float] + private var rotations: [Quat] + private var bestPositions: [Vector3] + + init(_ modifiers: [AnimationPoseModifier], rig: SkeletalRig, binding: AnimationRigBinding) throws(AnimationPoseModifierError) { + guard Set(binding.nodes.map(\.id)).count == binding.nodes.count, + binding.nodes.allSatisfy({ !$0.id.isEmpty && $0.id.utf8.count <= 256 }), + binding.isCompatible(with: rig) else { throw .incompatibleRig } + guard modifiers.count <= 32 else { throw .limitExceeded } + let indices = Dictionary(uniqueKeysWithValues: binding.nodes.enumerated().map { ($0.element.id, $0.offset) }) + var restGlobals = Array(repeating: Transform3D.identity, count: rig.nodes.count) + for index in rig.evaluationOrder { + let local = rig.nodes[index].restPose?.matrix ?? rig.nodes[index].restMatrix + restGlobals[index] = rig.nodes[index].parentIndex.map { restGlobals[$0] * local } ?? local + } + var ids = Set() + var budget = 0 + var compiled: [Entry] = [] + for modifier in modifiers { + guard !modifier.id.isEmpty, modifier.id.utf8.count <= 256, ids.insert(modifier.id).inserted, + modifier.name.map({ $0.utf8.count <= 256 }) ?? true, + IKMath.finite(modifier.target), modifier.pole.map(IKMath.finite) ?? true, + modifier.influence.isFinite, (0...1).contains(modifier.influence), + (1...64).contains(modifier.iterations), + modifier.tolerance.map({ $0.isFinite && $0 > 0 }) ?? true else { throw .invalidModifier(modifier.id) } + guard (2...65).contains(modifier.joints.count), + modifier.kind != .twoBone || modifier.joints.count == 3 else { throw .invalidChain(modifier.id) } + let joints = modifier.joints.compactMap { indices[$0] } + guard joints.count == modifier.joints.count, Set(joints).count == joints.count, + joints.allSatisfy({ rig.nodes[$0].restPose != nil }), + zip(joints, joints.dropFirst()).allSatisfy({ rig.nodes[$1].parentIndex == $0 }) else { throw .invalidChain(modifier.id) } + guard zip(joints, joints.dropFirst()).allSatisfy({ + let length = IKMath.length(restGlobals[$1].origin - restGlobals[$0].origin) + return length.isFinite && length > 1e-8 + }) else { throw .invalidChain(modifier.id) } + budget += (joints.count - 1) * (modifier.kind == .twoBone ? 1 : modifier.iterations) + guard budget <= 32_768 else { throw .limitExceeded } + compiled.append(Entry(configuration: modifier, joints: joints)) + } + entries = compiled + let capacity = compiled.map { $0.joints.count }.max() ?? 0 + positions = Array(repeating: .zero, count: capacity) + lengths = Array(repeating: 0, count: capacity) + rotations = Array(repeating: .identity, count: capacity) + bestPositions = Array(repeating: .zero, count: capacity) + } + + mutating func apply(rig: SkeletalRig, inputs: [String: AnimationPoseModifierInput], poses: inout [SkeletalJointPose], locals: inout [Transform3D], + globals: inout [Transform3D], diagnostics: inout [AnimationPoseModifierDiagnostic]) { + diagnostics.removeAll(keepingCapacity: true) + for entry in entries { + var configuration = entry.configuration + var result = AnimationPoseModifierDiagnostic(id: configuration.id, status: .disabled, iterations: 0) + guard configuration.enabled else { diagnostics.append(result); continue } + if let input = inputs[configuration.id] { + guard input.supportsTransform else { result.status = .unsupportedTransform; diagnostics.append(result); continue } + guard let target = input.target, IKMath.finite(target), input.pole.map(IKMath.finite) ?? true else { + result.status = .invalidTarget; diagnostics.append(result); continue + } + configuration.target = target + configuration.pole = input.pole ?? configuration.pole + } + let count = entry.joints.count + var valid = true + // Validate only the participating branch, including the current ancestor transforms. + var ancestor: Int? = entry.joints[count - 1] + while let index = ancestor { + if !IKMath.supported(locals[index]) || !IKMath.supported(globals[index]) { valid = false; break } + ancestor = rig.nodes[index].parentIndex + } + guard valid else { result.status = .unsupportedTransform; diagnostics.append(result); continue } + for offset in 0.. 0 { + lengths[offset - 1] = IKMath.length(positions[offset] - positions[offset - 1]) + valid = valid && lengths[offset - 1].isFinite && lengths[offset - 1] > 1e-8 + } + } + guard valid, IKMath.length(configuration.target - positions[0]).isFinite else { + result.status = .invalidPose; diagnostics.append(result); continue + } + switch configuration.kind { + case .twoBone: solveTwoBone(configuration, result: &result) + case .fabrik: solveFABRIK(configuration, count: count, result: &result) + } + guard (0.. 1e-12 ? (a * a - b * b + reach * reach) / (2 * reach) : 0 + let height = sqrt(max(0, a * a - along * along)) + positions[1] = root + direction * Float(along) + bend * Float(height) + positions[2] = root + direction * Float(reach) + result.iterations = 1 + result.solverError = IKMath.length(positions[2] - configuration.target) + let tolerance = configuration.tolerance ?? max(1e-5, Float(a + b) * 1e-4) + result.status = (result.solverError ?? 0) <= tolerance ? .solved : .unreachable + } + + private mutating func solveFABRIK(_ configuration: AnimationPoseModifier, count: Int, + result: inout AnimationPoseModifierDiagnostic) { + let root = positions[0] + var total: Float = 0 + var longest: Float = 0 + for index in 0..<(count - 1) { total += lengths[index]; longest = max(longest, lengths[index]) } + guard total.isFinite else { positions[0] = Vector3(.infinity); return } + let tolerance = configuration.tolerance ?? max(1e-5, total * 1e-4) + let distance = IKMath.length(configuration.target - root) + let direction = IKMath.unit(configuration.target - root, fallback: IKMath.unit(positions[count - 1] - root)) + if distance >= total { + for index in 1.. max(total * 1e-6, tolerance * 0.1) { collinear = false; bend = offAxis; break } + } + if collinear, count > 2 { + bend = IKMath.unit(bend, fallback: IKMath.perpendicular(direction)) + let seed = max(total * 0.1, tolerance * 2) + for index in 1..<(count - 1) { + positions[index] += bend * (seed * sin(Float.pi * Float(index) / Float(count - 1))) + } + } + var bestError = Float.infinity + for iteration in 1...configuration.iterations { + positions[count - 1] = configuration.target + for index in stride(from: count - 2, through: 0, by: -1) { + let backwards = IKMath.unit(positions[index] - positions[index + 1], fallback: -direction) + positions[index] = positions[index + 1] + backwards * lengths[index] + } + positions[0] = root + for index in 1.. Bool { value.x.isFinite && value.y.isFinite && value.z.isFinite } + + static func length(_ value: Vector3) -> Float { + Float(sqrt(Double(value.x) * Double(value.x) + Double(value.y) * Double(value.y) + Double(value.z) * Double(value.z))) + } + + static func unit(_ value: Vector3, fallback: Vector3 = [1, 0, 0]) -> Vector3 { + let magnitude = length(value) + return magnitude.isFinite && magnitude > 1e-12 ? value / magnitude : fallback + } + + /// Stable axis choice, with X/Y/Z tie ordering. + static func perpendicular(_ direction: Vector3) -> Vector3 { + let axis: Vector3 + if abs(direction.x) <= abs(direction.y), abs(direction.x) <= abs(direction.z) { axis = [1, 0, 0] } + else if abs(direction.y) <= abs(direction.z) { axis = [0, 1, 0] } + else { axis = [0, 0, 1] } + return unit(axis - direction * axis.dot(direction)) + } + + static func multiply(_ lhs: Quat, _ rhs: Quat) -> Quat { + Quat(x: lhs.w * rhs.x + lhs.x * rhs.w + lhs.y * rhs.z - lhs.z * rhs.y, + y: lhs.w * rhs.y - lhs.x * rhs.z + lhs.y * rhs.w + lhs.z * rhs.x, + z: lhs.w * rhs.z + lhs.x * rhs.y - lhs.y * rhs.x + lhs.z * rhs.w, + w: lhs.w * rhs.w - lhs.x * rhs.x - lhs.y * rhs.y - lhs.z * rhs.z).normalized + } + + static func rotation(from: Vector3, to: Vector3) -> Quat { + let from = unit(from) + let to = unit(to) + let dot = min(max(from.dot(to), -1), 1) + let cross = from.cross(to) + let crossLength = length(cross) + if crossLength > 0 { + // atan2 retains small deviations around pi even when Float dot has rounded to -1. + return Quat(axis: cross / crossLength, angle: atan2(crossLength, dot)) + } + return dot < 0 ? Quat(axis: perpendicular(from), angle: .pi) : .identity + } + + static func supported(_ matrix: Transform3D) -> Bool { + let x = matrix.x.xyz, y = matrix.y.xyz, z = matrix.z.xyz + guard finite(matrix.origin), finite(x), finite(y), finite(z) else { return false } + let scale = length(x) + guard scale.isFinite, scale > 1e-8 else { return false } + let nx = x / scale, ny = y / scale, nz = z / scale + return abs(length(ny) - 1) < 0.0001 && abs(length(nz) - 1) < 0.0001 + && abs(nx.dot(ny)) < 0.0001 && abs(nx.dot(nz)) < 0.0001 && abs(ny.dot(nz)) < 0.0001 + && nx.cross(ny).dot(nz) > 0.9999 + } + + static func rebuild(rig: SkeletalRig, poses: [SkeletalJointPose], locals: inout [Transform3D], globals: inout [Transform3D]) { + for index in rig.evaluationOrder { + let node = rig.nodes[index] + locals[index] = node.restPose == nil ? node.restMatrix : poses[index].matrix + globals[index] = node.parentIndex.map { globals[$0] * locals[index] } ?? locals[index] + } + } +} diff --git a/Sources/AdaAnimation/Skeletal/MorphWeightsTrack.swift b/Sources/AdaAnimation/Skeletal/MorphWeightsTrack.swift index 0b88f87e7..b0cf23f4d 100644 --- a/Sources/AdaAnimation/Skeletal/MorphWeightsTrack.swift +++ b/Sources/AdaAnimation/Skeletal/MorphWeightsTrack.swift @@ -10,6 +10,9 @@ public struct MorphWeightsTrack: Sendable { public let targetCount: Int /// `times.count * targetCount` values, or three times that for cubic keys. public let values: [Float] + var authoredCurves: CompiledAnimationCurves? + /// Native key metadata, preserved when a compiled clip is extracted for editing. + public var keyCurves: [AnimationKeyCurve?]? { authoredCurves?.metadata } public init( nodeIndex: Int, @@ -49,6 +52,10 @@ public struct MorphWeightsTrack: Sendable { let middle = (lower + upper) / 2 if times[middle] <= time { lower = middle } else { upper = middle } } + if let curves = authoredCurves, curves.requiresSampling, curves.hasCustomSegment(lower) { + for target in 0..= 0, !times.isEmpty, times.allSatisfy({ $0.isFinite && $0 >= 0 }), + guard nodeIndex >= 0, !(path == .rotation && interpolation == .bezier), !times.isEmpty, times.allSatisfy({ $0.isFinite && $0 >= 0 }), zip(times, times.dropFirst()).allSatisfy({ $0 < $1 }), values.count / multiplier == times.count, values.count.isMultiple(of: multiplier), values.allSatisfy({ $0.x.isFinite && $0.y.isFinite && $0.z.isFinite && $0.w.isFinite }) @@ -126,9 +129,12 @@ public struct SkeletalAnimationTrack: Sendable { return start } let end = value(at: upper) + if let curves = authoredCurves, curves.requiresSampling, curves.hasCustomSegment(lower) { + return sampleNative(curves, at: time, lower: lower, start: start, end: end) + } let span = Float(times[upper] - times[lower]) let amount = Float((time - times[lower]) / (times[upper] - times[lower])) - if interpolation == .linear { + if interpolation == .linear || interpolation == .bezier { if path == .rotation { return vector(slerpQuat(quaternion(start), quaternion(end), t: amount)) } @@ -149,6 +155,23 @@ public struct SkeletalAnimationTrack: Sendable { return result } + private func sampleNative(_ curves: CompiledAnimationCurves, at time: Double, lower: Int, start: Vector4, end: Vector4) -> Vector4 { + if path == .rotation, interpolation == .linear { + return vector(slerpQuat(quaternion(start), quaternion(end), t: Float(curves.amount(at: time, lower: lower)))) + } + let x = curves.sample(at: time, lower: lower, component: 0) + let y = curves.sample(at: time, lower: lower, component: 1) + let z = curves.sample(at: time, lower: lower, component: 2) + if path == .rotation { + let w = curves.sample(at: time, lower: lower, component: 3) + let squared = x * x + y * y + z * z + w * w + guard squared > 0.000001 else { return start } + let length = squared.squareRoot() + return Vector4(Float(x / length), Float(y / length), Float(z / length), Float(w / length)) + } + return Vector4(Float(x), Float(y), Float(z), 0) + } + private func value(at index: Int) -> Vector4 { values[index * (interpolation == .cubicSpline ? 3 : 1) + (interpolation == .cubicSpline ? 1 : 0)] } @@ -175,4 +198,15 @@ public struct SkeletalAnimationClip: Sendable { self.morphTracks = morphTracks self.duration = (tracks.compactMap { $0.times.last } + morphTracks.compactMap { $0.times.last }).max() ?? 0 } + + /// An authored timeline may hold the final pose after its last key, including an empty pose clip. + /// The explicit duration must include every key. Legacy initializers retain their derived duration. + public init(name: String, tracks: [SkeletalAnimationTrack], morphTracks: [MorphWeightsTrack] = [], duration: Double) throws(SkeletalAnimationError) { + let lastKey = (tracks.compactMap { $0.times.last } + morphTracks.compactMap { $0.times.last }).max() ?? 0 + guard duration.isFinite, duration >= lastKey else { throw .invalidTrack } + self.name = name + self.tracks = tracks + self.morphTracks = morphTracks + self.duration = duration + } } diff --git a/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift b/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift index 514cea640..d1df46b27 100644 --- a/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift +++ b/Sources/AdaAnimation/Skeletal/SkeletalAnimationPlayer.swift @@ -15,6 +15,14 @@ public struct SkeletalAnimationPlayer: Sendable { public var isPlaying = false public var repeats = true public private(set) var poses: [SkeletalJointPose] + /// Animation/graph/LOD output before procedural constraints. Never contains IK corrections. + public private(set) var basePoses: [SkeletalJointPose] + public private(set) var basePoseGeneration: UInt64 = 0 + public private(set) var modifierDiagnostics: [AnimationPoseModifierDiagnostic] = [] + private var poseModifierState: CompiledPoseModifiers? + public var poseModifiers: [AnimationPoseModifier] { poseModifierState?.entries.map(\.configuration) ?? [] } + /// Enabled constraints require finalization even when base animation sampling is skipped. + public var hasActivePoseModifiers: Bool { poseModifierState?.entries.contains(where: { $0.configuration.enabled }) ?? false } public private(set) var localTransforms: [Transform3D] public private(set) var globalTransforms: [Transform3D] /// Morph target weights by rig node: the weights of the mesh on that node, or an empty array for a node without morph targets. @@ -43,6 +51,7 @@ public struct SkeletalAnimationPlayer: Sendable { self.rig = rig self.clips = clips self.poses = rig.nodes.map { $0.restPose ?? SkeletalJointPose() } + self.basePoses = self.poses self.localTransforms = rig.nodes.map(\.restMatrix) self.globalTransforms = Array(repeating: .identity, count: rig.nodes.count) self.morphWeights = rig.nodes.map { $0.morphRestWeights ?? [] } @@ -61,7 +70,7 @@ public struct SkeletalAnimationPlayer: Sendable { guard let index = clips.firstIndex(where: { $0.name == name }) else { throw .unknownClip(name) } graphState = nil pendingGraphEvents.removeAll(keepingCapacity: true) - self.transitionSource = poses + self.transitionSource = basePoses self.transitionMorphSource = morphWeights self.transitionDuration = transitionDuration.isFinite ? max(0, transitionDuration) : 0 self.transitionTime = 0 @@ -85,7 +94,7 @@ public struct SkeletalAnimationPlayer: Sendable { public mutating func advance(by deltaTime: Double) { advance(by: deltaTime, evaluatePose: true) } /// Advances playback and transitions independently from the visual pose sampling rate. - public mutating func advance(by deltaTime: Double, evaluatePose: Bool) { + public mutating func advance(by deltaTime: Double, evaluatePose: Bool, finalizeModifiers: Bool = true) { guard isPlaying, deltaTime.isFinite, deltaTime >= 0, speed.isFinite else { return } @@ -115,7 +124,7 @@ public struct SkeletalAnimationPlayer: Sendable { pendingGraphEvents.append(contentsOf: crossings.events) droppedGraphEventCount += min(Int.max / 2 - droppedGraphEventCount, crossings.dropped) } - if evaluatePose { evaluate() } + if evaluatePose { evaluate(finalizeModifiers: finalizeModifiers) } } /// Installs a validated graph atomically. Invalid edits leave current playback unchanged. @@ -123,7 +132,7 @@ public struct SkeletalAnimationPlayer: Sendable { let compiled = try CompiledAnimationGraph(graph: graph, rig: rig, clips: clips) graphState = compiled clipIndex = nil - transitionSource = poses + transitionSource = basePoses transitionMorphSource = morphWeights self.transitionDuration = transitionDuration.isFinite ? max(0, transitionDuration) : 0 transitionTime = 0 @@ -151,10 +160,26 @@ public struct SkeletalAnimationPlayer: Sendable { } /// Evaluates the current playback clock without advancing gameplay time. - public mutating func sampleCurrentPose() { evaluate() } + public mutating func sampleCurrentPose(finalizeModifiers: Bool = true) { evaluate(finalizeModifiers: finalizeModifiers) } + + /// Installs an ordered stack atomically. Rejected configurations preserve the previous stack and pose. + public mutating func setPoseModifiers(_ modifiers: [AnimationPoseModifier], binding: AnimationRigBinding) throws(AnimationPoseModifierError) { + let compiled = try CompiledPoseModifiers(modifiers, rig: rig, binding: binding) + poseModifierState = compiled + finalizePoseModifiers() + } + + /// Rebuilds the final pose from the unchanged presented animation pose. + /// Call after updating model-space targets, including while playback is paused. + public mutating func finalizePoseModifiers(inputs: [String: AnimationPoseModifierInput] = [:]) { + for index in poses.indices { poses[index] = basePoses[index] } + rebuildTransforms() + poseModifierState?.apply(rig: rig, inputs: inputs, poses: &poses, locals: &localTransforms, + globals: &globalTransforms, diagnostics: &modifierDiagnostics) + } /// Interpolates visual poses; playback/transition clocks remain unchanged. - public mutating func presentPose(from source: [SkeletalJointPose], to target: [SkeletalJointPose], amount: Float) { + public mutating func presentPose(from source: [SkeletalJointPose], to target: [SkeletalJointPose], amount: Float, finalizeModifiers: Bool = true) { guard source.count == poses.count, target.count == poses.count else { return } @@ -164,10 +189,10 @@ public struct SkeletalAnimationPlayer: Sendable { poses[index].scale = source[index].scale + (target[index].scale - source[index].scale) * t poses[index].rotation = slerpQuat(source[index].rotation, target[index].rotation, t: t) } - rebuildTransforms() + captureBasePose(finalizeModifiers: finalizeModifiers) } - private mutating func evaluate() { + private mutating func evaluate(finalizeModifiers: Bool = true) { for index in rig.nodes.indices { poses[index] = rig.nodes[index].restPose ?? SkeletalJointPose() if let rest = rig.nodes[index].morphRestWeights { @@ -220,7 +245,14 @@ public struct SkeletalAnimationPlayer: Sendable { transitionMorphSource.removeAll(keepingCapacity: true) transitionDuration = 0 } - rebuildTransforms() + captureBasePose(finalizeModifiers: finalizeModifiers) + } + + private mutating func captureBasePose(finalizeModifiers: Bool) { + for index in poses.indices { basePoses[index] = poses[index] } + basePoseGeneration &+= 1 + if finalizeModifiers { finalizePoseModifiers() } + else { rebuildTransforms() } } private mutating func rebuildTransforms() { diff --git a/Sources/AdaAssets/AnimationLibraryResource.swift b/Sources/AdaAssets/AnimationLibraryResource.swift new file mode 100644 index 000000000..ac72ef2ef --- /dev/null +++ b/Sources/AdaAssets/AnimationLibraryResource.swift @@ -0,0 +1,31 @@ +import AdaAnimation + +/// A native `.animlib` resource, independent of its source GLB and playback instances. +public struct AnimationLibraryResource: Asset, Sendable { + public let library: AnimationLibrary + public var assetMetaInfo: AssetMetaInfo? + + public init(library: AnimationLibrary) throws { + try library.validate() + self.library = library + } + + public init(from assetDecoder: any AssetDecoder) async throws { + if assetDecoder.decoder != nil { + try self.init(library: assetDecoder.decode(AnimationLibrary.self)) + } else { + try self.init(library: AnimationLibrary.decode(assetDecoder.assetData)) + } + } + + public func encodeContents(with assetEncoder: any AssetEncoder) async throws { + try library.validate() + if assetEncoder.encoder != nil { + try assetEncoder.encode(library) + } else { + try assetEncoder.encode(library.encodedData()) + } + } + + public static func extensions() -> [String] { ["animlib"] } +} diff --git a/Sources/AdaAssets/AnimationModifierResource.swift b/Sources/AdaAssets/AnimationModifierResource.swift new file mode 100644 index 000000000..f61480af5 --- /dev/null +++ b/Sources/AdaAssets/AnimationModifierResource.swift @@ -0,0 +1,25 @@ +import AdaAnimation + +/// An independently saved native `.animmod` resource with complete rig compatibility. +public struct AnimationModifierResource: Asset, Sendable { + public let stack: AnimationModifierStack + public var assetMetaInfo: AssetMetaInfo? + + public init(stack: AnimationModifierStack) throws { + try stack.validate() + self.stack = stack + } + + public init(from assetDecoder: any AssetDecoder) async throws { + if assetDecoder.decoder != nil { try self.init(stack: assetDecoder.decode(AnimationModifierStack.self)) } + else { try self.init(stack: AnimationModifierStack.decode(assetDecoder.assetData)) } + } + + public func encodeContents(with assetEncoder: any AssetEncoder) async throws { + try stack.validate() + if assetEncoder.encoder != nil { try assetEncoder.encode(stack) } + else { try assetEncoder.encode(stack.encodedData()) } + } + + public static func extensions() -> [String] { ["animmod"] } +} diff --git a/Sources/AdaAssets/GLTF/GLTFAnimationAdapter.swift b/Sources/AdaAssets/GLTF/GLTFAnimationAdapter.swift new file mode 100644 index 000000000..b29abf2d4 --- /dev/null +++ b/Sources/AdaAssets/GLTF/GLTFAnimationAdapter.swift @@ -0,0 +1,85 @@ +import AdaAnimation +import Math + +/// Shared CPU conversion for imported model playback and animation authoring. +public enum GLTFAnimationAdapter { + /// The weights a node's mesh has when nothing animates them, or `nil` when it has no morph targets. + private static func restWeights(of node: GLTFImportResult.Node, meshes: [GLTFImportResult.Mesh]) -> [Float]? { + guard let meshIndex = node.meshIndex, meshes.indices.contains(meshIndex), meshes[meshIndex].morphTargetCount > 0 else { return nil } + return node.weights ?? meshes[meshIndex].defaultWeights + } + + public static func makeRig(nodes: [GLTFImportResult.Node], meshes: [GLTFImportResult.Mesh] = []) throws -> SkeletalRig { + var parents = [Int?](repeating: nil, count: nodes.count) + for (index, node) in nodes.enumerated() { + for child in node.children { + guard nodes.indices.contains(child), parents[child] == nil else { throw SkeletalAnimationError.invalidHierarchy } + parents[child] = index + } + } + return try SkeletalRig(nodes: nodes.enumerated().map { index, node in + let pose = node.restPose.map { SkeletalJointPose(translation: $0.translation, rotation: $0.rotation, scale: $0.scale) } + return SkeletalRig.Node( + parentIndex: parents[index], + restPose: pose, + restMatrix: node.transform, + name: node.name, + morphRestWeights: restWeights(of: node, meshes: meshes) + ) + }) + } + + public static func makeClips( + _ clips: [GLTFImportResult.Animation], + nodes: [GLTFImportResult.Node] = [], + meshes: [GLTFImportResult.Mesh] = [] + ) throws -> [SkeletalAnimationClip] { + try clips.enumerated().map { index, clip in + // Weight channels become morph tracks; the others are pose tracks. + let tracks = try clip.channels.filter { $0.path != .weights }.map { channel in + guard clip.samplers.indices.contains(channel.samplerIndex) else { throw SkeletalAnimationError.invalidTrack } + let sampler = clip.samplers[channel.samplerIndex] + guard sampler.output.componentCount == (channel.path == .rotation ? 4 : 3) else { throw SkeletalAnimationError.invalidTrack } + let path: SkeletalAnimationTrack.Path + switch channel.path { + case .translation: path = .translation + case .rotation: path = .rotation + case .scale: path = .scale + case .weights: preconditionFailure("Weight channels are filtered out above.") + } + let interpolation: SkeletalAnimationTrack.Interpolation + switch sampler.interpolation { + case .linear: interpolation = .linear + case .step: interpolation = .step + case .cubicSpline: interpolation = .cubicSpline + } + let values: [Vector4] = sampler.output.componentCount == 4 + ? sampler.output.vector4Values() + : sampler.output.vector3Values().map { Vector4($0, 0) } + return try SkeletalAnimationTrack( + nodeIndex: channel.nodeIndex, path: path, interpolation: interpolation, times: sampler.times.map(Double.init), values: values + ) + } + let morphTracks = try clip.channels.filter { $0.path == .weights }.map { channel in + guard clip.samplers.indices.contains(channel.samplerIndex) else { throw SkeletalAnimationError.invalidTrack } + let sampler = clip.samplers[channel.samplerIndex] + guard sampler.output.componentCount == 1 else { throw SkeletalAnimationError.invalidTrack } + let interpolation: SkeletalAnimationTrack.Interpolation + switch sampler.interpolation { + case .linear: interpolation = .linear + case .step: interpolation = .step + case .cubicSpline: interpolation = .cubicSpline + } + let targets = nodes.indices.contains(channel.nodeIndex) ? Self.restWeights(of: nodes[channel.nodeIndex], meshes: meshes)?.count ?? 0 : 0 + return try MorphWeightsTrack( + nodeIndex: channel.nodeIndex, + interpolation: interpolation, + times: sampler.times.map(Double.init), + targetCount: targets, + values: sampler.output.values + ) + } + return SkeletalAnimationClip(name: clip.name ?? "Animation \(index)", tracks: tracks, morphTracks: morphTracks) + } + } +} diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Accessors.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Accessors.swift index 4278bb10a..f3932245a 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader+Accessors.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader+Accessors.swift @@ -152,7 +152,7 @@ extension NativeGLTFLoader { } } - private func componentCount(for accessorType: String) throws -> Int { + func componentCount(for accessorType: String) throws -> Int { switch accessorType { case "SCALAR": return 1 case "VEC2": return 2 diff --git a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift index 6c269f8f6..27543a741 100644 --- a/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift +++ b/Sources/AdaAssets/GLTF/NativeGLTFLoader.swift @@ -9,10 +9,40 @@ import Foundation import Math public struct NativeGLTFLoader: GLTFLoader { - public init() {} + private let resourceBoundary: URL? + private let maximumTotalBytes: Int? + + public init() { + resourceBoundary = nil + maximumTotalBytes = nil + } + + /// Restricts external dependencies and bounds input/resource bytes plus cumulative accessor decoding work. + /// The ordinary loader retains its existing project-scope behavior when this initializer is unused. + public init(resourceRoot: URL, maximumTotalBytes: Int = 32 * 1024 * 1024) { + resourceBoundary = resourceRoot.resolvingSymlinksInPath().standardizedFileURL + self.maximumTotalBytes = max(0, maximumTotalBytes) + } public func load(url: URL) async throws -> GLTFImportResult { - let data = try Data(contentsOf: url) + if let root = resourceBoundary { + // Authoring callers resolve a canonical regular file before loading. Recheck immediately + // before opening it so a replaced parent junction cannot redirect the primary model. + let canonical = url.resolvingSymlinksInPath().standardizedFileURL + guard url.isFileURL, root.isFileURL, + root.resolvingSymlinksInPath().standardizedFileURL.path == root.path, + canonical.path == url.standardizedFileURL.path, + canonical.path.hasPrefix(root.path + "/"), + try canonical.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { + throw GLTFError.invalidDataURI + } + } + let data: Data + if let maximumTotalBytes { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + data = try handle.read(upToCount: min(maximumTotalBytes, Int.max - 1) + 1) ?? Data() + } else { data = try Data(contentsOf: url) } return try load(data: data, baseURL: url.deletingLastPathComponent()) } @@ -46,6 +76,7 @@ public struct NativeGLTFLoader: GLTFLoader { } public func load(data: Data, baseURL: URL? = nil) throws -> GLTFImportResult { + if let maximumTotalBytes, data.count > maximumTotalBytes { throw GLTFError.bufferOutOfBounds } let resourceBaseURL = baseURL ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath) let gltf: GLTF @@ -64,11 +95,70 @@ public struct NativeGLTFLoader: GLTFLoader { throw GLTFError.unsupportedRequiredExtension(unsupportedExtension) } + if let maximumTotalBytes { + try validateDecodedWork(gltf, maximumBytes: maximumTotalBytes) + var remaining = maximumTotalBytes - data.count + let uris = (gltf.buffers ?? []).compactMap(\.uri) + (gltf.images ?? []).compactMap(\.uri) + for uri in uris { + let count: Int + if uri.hasPrefix("data:") { + guard let comma = uri.firstIndex(of: ",") else { throw GLTFError.invalidDataURI } + let payloadBytes = uri[uri.index(after: comma)...].utf8.count + count = uri[...stride + let costs = try (gltf.accessors ?? []).map { accessor -> Int in + let components = try componentCount(for: accessor.type) + guard accessor.count > 0, accessor.count <= maximumElements / components else { throw GLTFError.bufferOutOfBounds } + var cost = accessor.count * components + if let sparse = accessor.sparse { + guard sparse.count > 0, sparse.count <= accessor.count else { throw GLTFError.bufferOutOfBounds } + // decodeAccessor retains the dense values while decoding sparse values and Int indices. + let temporary = sparse.count * (components + 1) + guard temporary <= maximumElements - cost else { throw GLTFError.bufferOutOfBounds } + cost += temporary + } + return cost + } + var remaining = maximumElements + func charge(_ index: Int) throws { + guard costs.indices.contains(index), costs[index] <= remaining else { throw GLTFError.bufferOutOfBounds } + remaining -= costs[index] + } + for mesh in gltf.meshes ?? [] { + guard mesh.primitives.count <= 4_096 else { throw GLTFError.bufferOutOfBounds } + for primitive in mesh.primitives { + for index in primitive.attributes.values { try charge(index) } + if let index = primitive.indices { try charge(index) } + for target in primitive.targets ?? [] { for index in target.values { try charge(index) } } + } + } + for animation in gltf.animations ?? [] { + guard animation.samplers.count <= 4_096, animation.channels.count <= 4_096 else { throw GLTFError.bufferOutOfBounds } + for sampler in animation.samplers { try charge(sampler.input); try charge(sampler.output) } + } + for skin in gltf.skins ?? [] { if let index = skin.inverseBindMatrices { try charge(index) } } + } + private func parseGLB(_ data: Data) throws -> (GLTF, Data?) { guard data.count >= 12 else { throw GLTFError.invalidGLB @@ -282,7 +372,7 @@ public struct NativeGLTFLoader: GLTFLoader { private func resourceURL(_ uri: String, relativeTo base: URL) throws -> URL { let decoded = uri.removingPercentEncoding ?? uri let target = base.appendingPathComponent(decoded).standardizedFileURL - if let root = AssetsManager.restrictedResourceRoot { + if let root = resourceBoundary ?? AssetsManager.restrictedResourceRoot { let canonicalRoot = root.resolvingSymlinksInPath().standardizedFileURL.path let canonicalTarget = target.resolvingSymlinksInPath().standardizedFileURL.path guard !decoded.hasPrefix("/"), !decoded.contains(":"), !decoded.contains("\\"), diff --git a/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md b/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md index 6fcb6bdec..710382e4e 100644 --- a/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md +++ b/Sources/AdaEngine/AdaEngine.docc/AdaEngine.md @@ -38,5 +38,7 @@ For more details and guides, check out the tutorials below. - - - +- +- - - diff --git a/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationAuthoring.md b/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationAuthoring.md new file mode 100644 index 000000000..1d5fd8f21 --- /dev/null +++ b/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationAuthoring.md @@ -0,0 +1,192 @@ +# Authoring skeletal animation + +Copy an imported animation or capture a rest/current pose into a project-owned +`.animlib` resource. Source GLB/glTF bytes remain unchanged. Swift code, Studio's +Animator, the editor agent and project Tools share the same data and edit commands. + +Opening a GLB/glTF or `.animlib` in Studio reveals the bottom **Animator**. A +resizable **Model** panel shows the imported node hierarchy, and the right Inspector +follows bone, track, key and graph selection. The timeline places all bones on a +shared seconds ruler with a draggable playhead, clip/duration/loop controls, key +insertion and grouped editing. Graph uses connected pose cards on a canvas. +These surfaces share the large center viewport's player and camera. Asset editing +does not require an open scene. See the Studio animation-authoring guide for the +complete pointer and keyboard workflow. + +## Create a library + +A binding snapshots the complete hierarchy: stable node IDs, parents, names, rest +matrices/TRS and morph target widths. Tracks target hierarchy nodes, including +non-joint nodes, rather than offsets in a skin palette. Changed rigs fail binding +explicitly; guessing by duplicate bone names or reordered indices is not remapping. + +```swift +import AdaAnimation +import Foundation + +let rig = try SkeletalRig(nodes: [ + .init(parentIndex: nil, restPose: .init(), restMatrix: .identity, name: "Root") +]) +var library = AnimationLibrary(id: "hero", binding: .init(rig: rig, sourceAsset: "Assets/Hero.glb")) +let clip = try library.poseClip(id: "wave", name: "Wave", duration: 1) +try library.apply([.init(id: "create-wave", kind: .createClip, clip: clip)]) +// This known one-bone rig has one rotation track in the captured pose clip. +if let rotation = clip.tracks.first(where: { $0.channel == .rotation }), var key = rotation.keys.first { + key.value = [0, 0, 0.70710677, 0.70710677] + try library.apply([.init(id: "rotate-root", kind: .putKey, clipID: clip.id, + trackID: rotation.id, key: key, replaceExisting: true)]) +} +let reopened = try AnimationLibrary.decode(library.encodedData()) +let player = try reopened.sample(clipID: "wave", at: 0.5) +``` + +Use `library.extract(clip, rig: rig)` to copy a runtime clip from the same source rig. +Translation, normalized XYZW quaternion rotation, scale and morph-weight tracks +retain their linear/step/cubic-spline values and derivatives. Explicit clip duration +preserves a hold after the last key. `poseClip(name:duration:from:)` can capture a +compatible player's local pose/morphs. Matrix-authored static nodes retain matrices. + +## Edit keys and play clips + +`AnimationEdit` supports `clip.create/duplicate/remove/configure`, +`track.create/remove/configure` and `key.put/remove`. IDs are scoped to the library, +clip and track respectively. Duplicated clips own independent value data. + +Batches are atomic. Each operation leaves a valid document; an invalid operation +reports its ID and preserves the original library. Updating/moving an existing key +ID requires `replaceExisting:true`. Another key at the same time remains a conflict +and must be removed explicitly. Keys use finite seconds in strictly increasing +order within duration, typed finite values, and normalized quaternion rotations. +Cubic incoming/outgoing tangents are component derivatives per second. + +## Native temporal curves + +Key `curve` metadata adds easing to the outgoing segment. Families are linear, +quadratic, cubic, quartic, quintic, sine, exponential, circular, back, elastic and +bounce; directions are `in`, `out`, `inOut` and `outIn`. Back exposes overshoot; +elastic exposes amplitude and period. Overshoot is retained rather than clamped. + +```swift +import AdaAnimation + +let pose = SkeletalJointPose() +let rig = try SkeletalRig(nodes: [.init(parentIndex: nil, restPose: pose, restMatrix: pose.matrix)]) +var library = AnimationLibrary(binding: .init(rig: rig, sourceAsset: "Assets/Hero.glb")) +let track = AnimationLibraryTrack(id: "move", nodeID: "node-0", channel: .translation, keys: [ + .init(id: "start", time: 0, value: [0, 0, 0]), + .init(id: "end", time: 1, value: [1, 0, 0]) +]) +try library.apply([.init(kind: .createClip, clip: .init(id: "move", name: "Move", duration: 1, tracks: [track]))]) +var key = track.keys[0] +key.curve = .init(easing: .init(family: .quadratic, direction: .in)) +try library.apply([.init(kind: .putKey, clipID: "move", trackID: "move", key: key, replaceExisting: true)]) +let player = try library.sample(clipID: "move", at: 0.5) // translation.x is 0.25 +let reopened = try AnimationLibrary.decode(library.encodedData()) +``` + +For translation, scale and morph weights, `bezier` interpolation accepts per-component +`curve.incoming` and `curve.outgoing` arrays of `AnimationBezierHandle`. Each handle +stores a time offset in seconds and a value offset relative to its key. Missing +handles default to one third of the straight segment. They are distinct from +Hermite's `incoming`/`outgoing` derivatives on the key itself. Sampling first inverts +monotone Bézier time, applies easing to that parameter and then evaluates the value. +Reversing temporal curves, invalid dimensions, non-finite values and unsafe Float +ranges fail before replacing the document. Flat and crossing temporal controls are +supported when their full time polynomial remains monotone. + +Quaternion linear tracks use shortest-path slerp with eased progress. Imported cubic +quaternion tracks retain their normalized component interpolation; easing remaps +their segment parameter. Arbitrary scalar Bézier handles are rejected on rotations. +Unedited linear/step/imported cubic tracks retain their existing evaluator. + +Edits and extraction promote native curve data to `.animlib` schema **2**. Legacy +libraries without that data remain schema 1; old readers must reject schema 2 rather +than silently remove its curves. A library permits at most 500,000 compiled native +scalar segments in addition to its existing key/byte limits. A sparse easing edit +only compiles the changed segments; Bézier tracks compile every scalar segment. +The same key payload goes through `key.put` from native Inspector, agents and Tools, +including resource revisions, atomic rejection, history and explicit Save. + +Load `AnimationLibraryResource` through AssetsManager or decode its JSON, then +provide libraries to `Model3DSource.instantiate(_:libraries:in:under:)`. The source's +`animationLibraries` references and loaded libraries must match in count/order. +Distinct library IDs, compatible bindings, imported/authored name collisions and +the complete graph validate before runtime entities are allocated. + +Use `library.runtimeName(for: clipID)` for a single animation or an existing +`AnimationGraph` clip reference. Display names may repeat; qualified names encode +library/clip identity. Native compiled tracks retain the existing mixer, crossfade, +repeat/one-shot clock and skin-palette behavior. Each player owns mutable poses. + +## Studio and automation + +Select a Model 3D entity and open **Animator → Bone Keyframes**. Extract a copy or +create a rest-pose clip, select bone/node tracks and edit keys. The model viewport +shows the animated mesh and bones. **Save and Bind** explicitly saves the resource +and performs a separate undoable scene transaction. The graph picker includes +bound saved clips. Inspector edits easing and per-component Bézier offsets; Curves +plots the production evaluator. Additional tangent modes, canvas handle dragging, +state machines/blend spaces, paths and native 2D spline deformation remain later work. +Procedural 3D IK is described below. + +The editor agent calls `editor.animation.` with a `requestJSON` string +containing `AnimationAuthoringRequest`. Studio API 2 project Tools use +`editor.animations.submit(requestMap)` and `poll(jobID)`. All clients share the same +project drafts/history. Inspect first for stable IDs and `revision`; writes require +`expectedRevision`. Binding also needs an open scene revision and saved resource. +Drafts require explicit Save. Reload/Discard recover external-file conflicts. + +Requests support 64 KiB, responses 1 MiB, pages 256 items and batches 256 operations. +Libraries/model imports support 32 MiB; a library allows 16,384 nodes, 256 clips, +4,096 tracks per clip, 100,000 keys and 1,024 morph targets per node. IDs are limited +to 256 UTF-8 bytes, names to 1,024, source paths to 4,096 and fingerprints to 256. +Large morph-key pages may need smaller limits. CPU samples and preview +acknowledgements do not establish pixels: verify the viewport and visible Play. + +## Save and bind from Swift + +Given a model compatible with the library's complete rig and an existing owner in +its world, explicitly save the resource and instantiate an authored clip: + +```swift +import AdaEngine +import Foundation + +func saveAndBind(_ library: AnimationLibrary, resourceURL: URL, resourceReference: String, + model: ModelAsset3D, world: World, owner: Entity, clipID: String) throws { + try FileManager.default.createDirectory(at: resourceURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try library.encodedData().write(to: resourceURL, options: .atomic) + let reopened = try AnimationLibrary.decode(Data(contentsOf: resourceURL)) + let source = Model3DSource(source: reopened.binding.sourceAsset, + animation: reopened.runtimeName(for: clipID), autoplay: true, + animationLibraries: [resourceReference]) + _ = try source.instantiate(model, libraries: [reopened], in: world, under: owner) +} +``` + +Use a project-owned resource URL and its portable `@res://Animations/Hero.animlib` +reference. For an imported model, derive the binding from +`GLTFAnimationAdapter.makeRig(nodes:meshes:)` using that model's native import data, +then extract its compatible clip. The synthetic Root rig above is only for a model +with that same rig; incompatible models fail explicitly. Studio Tools can follow +the complete bone-key, sample, Save and Bind workflow in the AnimationHelper example +linked from the Studio guide. Native Tools bound expanded request conversion to +8,192 values and 64 KiB of UTF-8 strings, and restricted imports preflight repeated +decoded accessor work before allocation. + +Native hosts can construct a preview with `ModelAsset3D(gltfAt:loader:)`, supplying +`NativeGLTFLoader(resourceRoot:maximumTotalBytes:)` for the full model import. Studio's +library viewport uses this scoped loader; it does not change the global resolver. + +Studio's shared request envelope also carries `graphInspect`, `graphApply`, +`graphUndo`, `graphRedo`, `graphPreview` and `graphBind`. These address an open +model/library workspace and use its graph revision rather than the clip-library +revision. `graphApply` replaces a detached `AnimationGraph` in the same canvas +history. Binding validates the complete destination rig and saved libraries, +then commits one scene-history edit. See the Studio guide and AnimationHelper +example for request maps, separate permissions, budgets and explicit scene Save. + +## Procedural IK after animation + +See for ordered two-bone/FABRIK constraints, model-space targets, +current scene marker bindings, `.animmod` resources and the shared Tool/agent API. diff --git a/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationIK.md b/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationIK.md new file mode 100644 index 000000000..04a348a88 --- /dev/null +++ b/Sources/AdaEngine/AdaEngine.docc/Articles/Developers/AnimationIK.md @@ -0,0 +1,92 @@ +# Animation IK and pose modifiers + +Constrain a skeletal chain after its clip, Animation Graph and visual LOD pose have been mixed. + +IK belongs to each playback instance. Editing a target does not rewrite the imported GLB or +bake corrections into an animation library. An ordered stack supports two-bone IK and FABRIK. + +## Solve an editable chain + +This complete example constructs two unit segments and moves their endpoint. For an imported +model, use its full rig binding; skin-palette indices are not stable bone IDs. + +```swift +import AdaAnimation +import Math + +let poses = [SkeletalJointPose(), SkeletalJointPose(translation: [1, 0, 0]), + SkeletalJointPose(translation: [1, 0, 0])] +let rig = try SkeletalRig(nodes: poses.indices.map { index in + .init(parentIndex: index == 0 ? nil : index - 1, + restPose: poses[index], restMatrix: poses[index].matrix) +}) +let binding = AnimationRigBinding(rig: rig, sourceAsset: "Assets/Robot.glb") +var player = try SkeletalAnimationPlayer(rig: rig, clips: []) +let arm = AnimationPoseModifier(id: "arm", kind: .twoBone, + joints: ["node-0", "node-1", "node-2"], target: [1, 1, 0], pole: [0, 0, 1]) +try player.setPoseModifiers([arm], binding: binding) +assert((player.globalTransforms[2].origin - Vector3(1, 1, 0)).length < 0.0002) + +// Transient scene input does not change the saved stack or advance the clip clock. +player.finalizePoseModifiers(inputs: ["arm": .init(target: [0, 2, 0])]) +assert(player.basePoses == poses) +``` + +`setPoseModifiers` validates before replacing the stack. Chains are contiguous and have +editable TRS and nonzero segments. Two-bone IK requires exactly three joints. FABRIK accepts +1–64 segments, 1–64 iterations (default 16) and a positive tolerance. Its automatic tolerance +is `max(1e-5, length * 1e-4)`. Influence is in `0...1`; coordinates must be finite. A stack has +at most 32 modifiers and 32,768 compiled segment-iterations. Neither solver stretches bones. + +## Separate base and final poses + +`basePoses` contains animation/graph/LOD output. `poses`, local/global transforms, scene +bones and skin palettes contain the final constrained result. Repeated finalization starts +from the same base pose, without accumulating corrections. Crossfade sources and LOD +endpoints also contain base poses. Moving targets and paused clips still finalize active IK; +target movement does not emit animation markers or advance playback clocks. + +Modifiers execute in saved order, each observing the previous influenced result. Influence +blends local rotations once. Local translation, scale and the end joint's authored local +rotation remain intact; its world orientation is not independently locked. Rigid transforms +and positive uniform scale are supported. An unsupported participating hierarchy skips that +modifier; unrelated nonuniform branches do not disable valid chains. + +Read `modifierDiagnostics` for each stable modifier ID. `solverError` measures the +full-strength solution; `reachError` measures the published endpoint after influence. +Unreachable targets and iteration exhaustion can produce valid approximate results. Missing +targets skip their modifier instead of holding an old solved frame. CPU samples do not prove +rendered output. + +## Resources and scene bindings + +`AnimationModifierStack` stores a versioned full-rig snapshot and an ordered list. Bounded +JSON uses `.animmod`; `AnimationModifierResource` supplies normal asset codecs. Unknown +versions and changed rigs are rejected. `Model3DSource.animationModifiers` loads the stack +before replacing a live model. Scene overrides remain in `Model3DSource.modifierBindings`. + +An `AnimationIKTarget3D` is a model-space point or a stable scene entity ID with a local +offset. Studio assigns `AnimationSceneIdentity3D` to authored entities. Programmatic scenes +assign a shared namespace to the model owner and its ordinary target markers. Namespaces +isolate loaded scene instances; runtime Entity.ID values are never persisted as targets. + +Owners and marker offsets resolve from one current local-transform snapshot before applying +any stack. A marker beneath any animated rig is rejected, including another instance. + +## Studio, agents and developer Tools + +Open a GLB/glTF or `.animmod`. **Modifiers** is in the resizable Model panel. Select a +contiguous chain, then add **Two-bone IK** or **FABRIK**. The Inspector owns chain, target, +pole, influence and solver settings. Blue target and purple pole handles preview a drag; +release creates one Undo entry and Escape cancels. Save explicitly before binding to a scene. + +Agents call `editor.animation.modifierInspect/Create/Apply/Sample/Save/Undo/Redo/Preview/Bind/Reload/Discard` +(each suffix is a separate action, for example `editor.animation.modifierApply`). API 2 Tools +submit the same `AnimationAuthoringRequest` through `editor.animations.submit` and poll +asynchronously. Edits use the inspected `expectedRevision`; binding also requires a current +`sceneRevision` and a saved compatible stack. Standard animation read/write, preview and +document-write permissions apply. See for job limits and transport. + +This article describes 3D skeletal IK. Native 2D weighted deformation, continuous-turn angles, +spline-chain authoring and typed timeline bindings for modifier parameters remain separate +implementation stages. A 3D numerical solve does not establish those workflows. diff --git a/Sources/AdaScene/3D/AnimationIKTargets3D.swift b/Sources/AdaScene/3D/AnimationIKTargets3D.swift new file mode 100644 index 000000000..82dfe9585 --- /dev/null +++ b/Sources/AdaScene/3D/AnimationIKTargets3D.swift @@ -0,0 +1,101 @@ +import AdaAnimation +import AdaECS +import AdaTransform +import Math + +/// Stable authored identity within a loaded scene instance. Runtime Entity.ID is never serialized as a target. +public struct AnimationSceneIdentity3D: Component, Sendable { + public var namespace: String + public var id: String + + public init(namespace: String, id: String) { self.namespace = namespace; self.id = id } +} + +/// One pre-modifier snapshot for all model instances. Stops at transformless gaps, like ChildTransformSystem. +struct AnimationIKSnapshot3D { + struct Identity: Hashable { var namespace: String; var id: String } + var locals: [Entity.ID: Transform3D] = [:] + var parents: [Entity.ID: Entity.ID] = [:] + var identities: [Identity: Entity.ID] = [:] + var ambiguous: Set = [] + var roots: Set = [] + + mutating func insert(_ identity: AnimationSceneIdentity3D, entity: Entity.ID) { + let key = Identity(namespace: identity.namespace, id: identity.id) + if identities.updateValue(entity, forKey: key) != nil { ambiguous.insert(key) } + } + + func inputs(for animation: ModelAnimation3DComponent, root: Entity.ID) -> [String: AnimationPoseModifierInput] { + var result: [String: AnimationPoseModifierInput] = [:] + guard animation.player.hasActivePoseModifiers else { return result } + let owner = resolve(root) + let ownerSupported = owner.map { Self.supported($0) } ?? false + && supportsHierarchy(root) && !(parents[root].map(isAnimated) ?? false) + let inverse = ownerSupported ? owner?.inverse : nil + for modifier in animation.player.poseModifiers where modifier.enabled { + let binding = animation.modifierBindings[modifier.id] + let target: Vector3? + let pole: Vector3? + if let override = binding?.target { target = resolve(override, namespace: animation.modifierNamespace, inverseOwner: inverse) } + else { target = modifier.target } + if let override = binding?.pole { pole = resolve(override, namespace: animation.modifierNamespace, inverseOwner: inverse) } + else { pole = modifier.pole } + let missingPole = binding?.pole != nil && pole == nil + result[modifier.id] = .init(target: missingPole ? nil : target, pole: pole, supportsTransform: ownerSupported) + } + return result + } + + private func resolve(_ target: AnimationIKTarget3D, namespace: String, inverseOwner: Transform3D?) -> Vector3? { + guard (try? target.validate()) != nil else { return nil } + guard let id = target.entity else { return target.point } + let key = Identity(namespace: namespace, id: id) + guard !ambiguous.contains(key), let entity = identities[key], !isAnimated(entity), + let global = resolve(entity), let inverseOwner else { return nil } + let point = (inverseOwner * global * Vector4(target.point, 1)).xyz + return point.x.isFinite && point.y.isFinite && point.z.isFinite ? point : nil + } + + private func isAnimated(_ entity: Entity.ID) -> Bool { + var current: Entity.ID? = entity + var visited: Set = [] + while let id = current { + guard visited.insert(id).inserted else { return true } + if roots.contains(id) { return true } + current = parents[id] + } + return false + } + + private func resolve(_ entity: Entity.ID) -> Transform3D? { + guard var matrix = locals[entity] else { return nil } + var current = parents[entity] + var visited: Set = [entity] + while let id = current, let parent = locals[id] { + guard visited.insert(id).inserted else { return nil } + matrix = parent * matrix + current = parents[id] + } + return matrix + } + + private func supportsHierarchy(_ entity: Entity.ID) -> Bool { + var current: Entity.ID? = entity + var visited: Set = [] + while let id = current, let local = locals[id] { + guard visited.insert(id).inserted, Self.supported(local) else { return false } + current = parents[id] + } + return true + } + + private static func supported(_ matrix: Transform3D) -> Bool { + let x = matrix.x.xyz, y = matrix.y.xyz, z = matrix.z.xyz + let scale = x.length + guard scale.isFinite, scale > 1e-8, matrix.origin.x.isFinite, matrix.origin.y.isFinite, matrix.origin.z.isFinite else { return false } + let nx = x / scale, ny = y / scale, nz = z / scale + return abs(ny.length - 1) < 0.0001 && abs(nz.length - 1) < 0.0001 + && abs(nx.dot(ny)) < 0.0001 && abs(nx.dot(nz)) < 0.0001 && abs(ny.dot(nz)) < 0.0001 + && nx.cross(ny).dot(nz) > 0.9999 + } +} diff --git a/Sources/AdaScene/3D/AnimationLOD3D.swift b/Sources/AdaScene/3D/AnimationLOD3D.swift index 2bf24e66d..48a5a4b4e 100644 --- a/Sources/AdaScene/3D/AnimationLOD3D.swift +++ b/Sources/AdaScene/3D/AnimationLOD3D.swift @@ -67,18 +67,18 @@ struct AnimationLOD3DState: Sendable { ) let rate = visible && settings.sampleRates.indices.contains(nextLevel) ? settings.sampleRates[nextLevel] : settings.offscreenRate let nextInterval = visible && rate == 0 ? 0 : 1 / Double(rate.isFinite ? min(max(rate, 1), 120) : 12) - let externalChange = generation != player.poseGeneration + let externalChange = generation != player.basePoseGeneration let wasPlaying = player.isPlaying let dt = delta.isFinite ? max(0, delta) : 0 // When rendering is already below the selected sampling rate, interpolation // adds cost and delay without skipping any evaluations. Present the current pose. if visible, nextInterval > 0, dt >= nextInterval { - player.advance(by: dt) + player.advance(by: dt, evaluatePose: true, finalizeModifiers: false) level = nextLevel; interval = nextInterval; elapsed = 0 - source = player.poses; target = player.poses; generation = player.poseGeneration + source = player.basePoses; target = player.basePoses; generation = player.basePoseGeneration return } - player.advance(by: dt, evaluatePose: nextInterval == 0) + player.advance(by: dt, evaluatePose: nextInterval == 0, finalizeModifiers: false) elapsed += dt let immediate = externalChange || level != nextLevel || interval != nextInterval || wasPlaying && !player.isPlaying level = nextLevel @@ -89,9 +89,9 @@ struct AnimationLOD3DState: Sendable { elapsed = 0 } else { if immediate || player.isPlaying && elapsed >= nextInterval { - source = target.isEmpty ? player.poses : target - player.sampleCurrentPose() - target = player.poses + source = target.isEmpty ? player.basePoses : target + player.sampleCurrentPose(finalizeModifiers: false) + target = player.basePoses if immediate { source = target elapsed = 0 @@ -100,9 +100,9 @@ struct AnimationLOD3DState: Sendable { } } if visible, source != target, source.count == player.poses.count, target.count == player.poses.count { - player.presentPose(from: source, to: target, amount: Float(elapsed / nextInterval)) + player.presentPose(from: source, to: target, amount: Float(elapsed / nextInterval), finalizeModifiers: false) } } - generation = player.poseGeneration + generation = player.basePoseGeneration } } diff --git a/Sources/AdaScene/3D/Model3DPlugin.swift b/Sources/AdaScene/3D/Model3DPlugin.swift index 63c8c0328..38695974a 100644 --- a/Sources/AdaScene/3D/Model3DPlugin.swift +++ b/Sources/AdaScene/3D/Model3DPlugin.swift @@ -6,6 +6,7 @@ // import AdaApp +import AdaAssets import AdaCorePipelines import AdaECS @_spi(Internal) import AdaRender @@ -24,6 +25,8 @@ public struct Model3DPlugin: Plugin { app.addSystem(EventsUpdateSystem.self, on: .preUpdate) } Model3DSource.registerComponent() + AnimationSceneIdentity3D.registerComponent() + AssetsManager.registerAssetType(AnimationModifierResource.self) Model3DSourceState.registerComponent() ModelAnimationPlayback3DComponent.registerComponent() app.addSystem(ModelAnimationPlayback3DSystem.self, on: .postUpdate) diff --git a/Sources/AdaScene/3D/Model3DSource.swift b/Sources/AdaScene/3D/Model3DSource.swift index bb42befab..cbdd0b3b0 100644 --- a/Sources/AdaScene/3D/Model3DSource.swift +++ b/Sources/AdaScene/3D/Model3DSource.swift @@ -35,6 +35,11 @@ public struct Model3DSource: Codable, Equatable, Sendable { public var repeats: Bool /// Optional authored pose graph. When present it takes precedence over the single clip. public var animationGraph: AnimationGraph? + /// Project-owned `.animlib` resources. Authored graph clips use their qualified stable runtime names. + public var animationLibraries: [String] + /// Optional reusable `.animmod` stack and instance-local stable scene target bindings. + public var animationModifiers: String? + public var modifierBindings: [String: AnimationIKBinding3D] public var lod: Model3DSourceLOD? /// Import settings for MagicaVoxel (`.vox`) sources; ignored for other formats. public var voxel: VoxelImportSettings? @@ -49,7 +54,11 @@ public struct Model3DSource: Codable, Equatable, Sendable { public init(source: String = "", animation: String = "", autoplay: Bool = false, repeats: Bool = true, animationGraph: AnimationGraph? = nil, lod: Model3DSourceLOD? = nil, voxel: VoxelImportSettings? = nil, - morphWeights: [String: Float] = [:]) { + morphWeights: [String: Float] = [:], animationLibraries: [String] = [], animationModifiers: String? = nil, + modifierBindings: [String: AnimationIKBinding3D] = [:]) { + self.animationModifiers = animationModifiers + self.modifierBindings = modifierBindings + self.animationLibraries = animationLibraries self.morphWeights = morphWeights self.voxel = voxel self.source = source @@ -61,7 +70,8 @@ public struct Model3DSource: Codable, Equatable, Sendable { } private enum CodingKeys: CodingKey { - case source, animation, autoplay, repeats, animationGraph, lod, voxel, morphWeights, graphMaterials, lightmapSource, lightmapIntensity + case source, animation, autoplay, repeats, animationGraph, animationLibraries, animationModifiers, modifierBindings + case lod, voxel, morphWeights, graphMaterials, lightmapSource, lightmapIntensity } public init(from decoder: Decoder) throws { @@ -74,6 +84,9 @@ public struct Model3DSource: Codable, Equatable, Sendable { autoplay = try container.decode(Bool.self, forKey: .autoplay) repeats = try container.decode(Bool.self, forKey: .repeats) animationGraph = try container.decodeIfPresent(AnimationGraph.self, forKey: .animationGraph) + animationLibraries = try container.decodeIfPresent([String].self, forKey: .animationLibraries) ?? [] + animationModifiers = try container.decodeIfPresent(String.self, forKey: .animationModifiers) + modifierBindings = try container.decodeIfPresent([String: AnimationIKBinding3D].self, forKey: .modifierBindings) ?? [:] lod = try container.decodeIfPresent(Model3DSourceLOD.self, forKey: .lod) voxel = try container.decodeIfPresent(VoxelImportSettings.self, forKey: .voxel) // Scenes written before morph targets have no weights. @@ -90,19 +103,58 @@ public struct Model3DSource: Codable, Equatable, Sendable { return "\(source)#\(voxel.assetQuery)" } - /// Installs an already loaded asset. Used by hosts that prepare models before their first frame. - /// An invalid clip fails before creating entities. Each call creates an independent animation player. - @discardableResult - public func instantiate(_ model: ModelAsset3D, alternatives: [ModelAsset3D] = [], in world: World, under owner: Entity) throws -> Entity { - if let lod, alternatives.count != lod.sources.count { - throw AssetError.message("Model LOD alternatives have not been loaded.") + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(source, forKey: .source) + try container.encode(animation, forKey: .animation) + try container.encode(autoplay, forKey: .autoplay) + try container.encode(repeats, forKey: .repeats) + try container.encodeIfPresent(animationGraph, forKey: .animationGraph) + try container.encode(animationLibraries, forKey: .animationLibraries) + try container.encodeIfPresent(animationModifiers, forKey: .animationModifiers) + if !modifierBindings.isEmpty { try container.encode(modifierBindings, forKey: .modifierBindings) } + try container.encodeIfPresent(lod, forKey: .lod) + try container.encodeIfPresent(voxel, forKey: .voxel) + try container.encode(morphWeights, forKey: .morphWeights) + try container.encode(graphMaterials, forKey: .graphMaterials) + try container.encode(lightmapSource, forKey: .lightmapSource) + try container.encode(lightmapIntensity, forKey: .lightmapIntensity) + } + + /// Validates the complete imported/authored clip set and graph without allocating runtime entities. + /// Editor binding uses the same checks as scene instantiation. + public func resolvedAnimationClips(importedClips: [SkeletalAnimationClip], rig: SkeletalRig?, libraries: [AnimationLibrary]) throws -> [SkeletalAnimationClip] { + guard libraries.count == animationLibraries.count else { throw AssetError.message("Animation libraries have not been loaded.") } + var clips = importedClips + var libraryIDs: Set = [] + for library in libraries { + guard let rig, libraryIDs.insert(library.id).inserted else { + throw AssetError.message("Animation libraries need a rig and unique library IDs.") + } + let compiled = try library.compiledClips(for: rig) + guard !compiled.contains(where: { authored in clips.contains { $0.name == authored.name } }) else { + throw AssetError.message("An authored animation reference conflicts with an existing clip.") + } + clips.append(contentsOf: compiled) } - guard animationGraph != nil || animation.isEmpty || model.animationClips.contains(where: { $0.name == animation }) else { + guard animationGraph != nil || animation.isEmpty || clips.contains(where: { $0.name == animation }) else { throw AssetError.message("Model has no animation named '\(animation)'.") } if let animationGraph { - guard let rig = model.animationRig else { throw AssetError.message("Model has no animation rig.") } - try animationGraph.validate(rig: rig, clips: model.animationClips) + guard let rig else { throw AssetError.message("Model has no animation rig.") } + try animationGraph.validate(rig: rig, clips: clips) + } + return clips + } + + /// Installs an already loaded asset. An invalid animation fails before creating entities. + @discardableResult + public func instantiate(_ model: ModelAsset3D, alternatives: [ModelAsset3D] = [], libraries: [AnimationLibrary] = [], modifiers: AnimationModifierStack? = nil, + in world: World, under owner: Entity) throws -> Entity { + let clips = try resolvedAnimationClips(importedClips: model.animationClips, rig: model.animationRig, libraries: libraries) + try validateModifiers(modifiers, rig: model.animationRig) + if let lod, alternatives.count != lod.sources.count { + throw AssetError.message("Model LOD alternatives have not been loaded.") } var graphOverrides: [Int: PBRMaterial] = [:] for key in graphMaterials.keys.sorted() { @@ -136,6 +188,15 @@ public struct Model3DSource: Codable, Equatable, Sendable { } } let root = model.instantiate(in: world) + if !libraries.isEmpty, let rig = model.animationRig { + root.components[ModelAnimation3DComponent.self] = ModelAnimation3DComponent(player: try SkeletalAnimationPlayer(rig: rig, clips: clips)) + } + if let modifiers, var animation = root.components[ModelAnimation3DComponent.self] { + try animation.player.setPoseModifiers(modifiers.modifiers, binding: modifiers.binding) + animation.modifierBindings = modifierBindings + animation.modifierNamespace = owner.components[AnimationSceneIdentity3D.self]?.namespace ?? "" + root.components[ModelAnimation3DComponent.self] = animation + } if !graphOverrides.isEmpty { var pending = [root] while let node = pending.popLast() { @@ -194,6 +255,25 @@ public struct Model3DSource: Codable, Equatable, Sendable { owner.components[Model3DSourceState.self] = .init(reference: self, rootEntityID: root.id) return root } + + /// Checks reusable configuration and scene overrides before a replacement creates any entities. + public func validateModifiers(_ stack: AnimationModifierStack?, rig: SkeletalRig?) throws { + guard animationModifiers != nil || stack == nil else { throw AssetError.message("Unexpected animation modifier resource.") } + guard let reference = animationModifiers else { + guard modifierBindings.isEmpty else { throw AssetError.message("Modifier bindings require an animation modifier resource.") } + return + } + guard !reference.isEmpty, reference.utf8.count <= 4_096, let stack, let rig else { + throw AssetError.message("Animation modifier resource or rig has not loaded.") + } + try stack.validate() + guard stack.binding.isCompatible(with: rig) else { throw AnimationPoseModifierError.incompatibleRig } + let ids = Set(stack.modifiers.map(\.id)) + guard modifierBindings.count <= 32, modifierBindings.keys.allSatisfy({ ids.contains($0) }) else { + throw AssetError.message("Scene modifier bindings must reference modifiers in the loaded stack.") + } + for binding in modifierBindings.values { try binding.validate() } + } } /// Runtime status of a model reference. Generated nodes are not part of the authored scene. @@ -218,19 +298,27 @@ public struct Model3DSourceSystem { // The asset cache keeps weak handles. Retaining only handle.asset drops the // cache key and reimports the same mesh/textures for every scene instance. @Local private var modelHandles: [String: AssetHandle] = [:] + @Local private var libraryHandles: [String: AssetHandle] = [:] + @Local private var modifierHandles: [String: AssetHandle] = [:] public init(world _: World) {} public func update(context _: UpdateContext) async { var pending: [(Entity.ID, Model3DSource)] = [] var livePaths: Set = [] + var liveLibraries: Set = [] + var liveModifiers: Set = [] sources.forEach { entity, reference in if !reference.source.isEmpty { livePaths.insert(reference.assetPath) } livePaths.formUnion(reference.lod?.sources ?? []) + liveLibraries.formUnion(reference.animationLibraries) + if let path = reference.animationModifiers { liveModifiers.insert(path) } let state = entity.components[Model3DSourceState.self] if state?.reference != reference { pending.append((entity.id, reference)) } } for path in modelHandles.keys where !livePaths.contains(path) { modelHandles.removeValue(forKey: path) } + for path in libraryHandles.keys where !liveLibraries.contains(path) { libraryHandles.removeValue(forKey: path) } + for path in modifierHandles.keys where !liveModifiers.contains(path) { modifierHandles.removeValue(forKey: path) } instances.forEach { entity, state in guard entity.components[Model3DSource.self] == nil else { return @@ -245,7 +333,7 @@ public struct Model3DSourceSystem { } } for (id, reference) in pending { - let result: Result<(ModelAsset3D?, [ModelAsset3D]), AssetError> + let result: Result<(ModelAsset3D?, [ModelAsset3D], [AnimationLibrary], AnimationModifierStack?), AssetError> do { let model = reference.source.isEmpty ? nil : try await loadModel(reference.assetPath) var alternatives: [ModelAsset3D] = [] @@ -255,25 +343,42 @@ public struct Model3DSourceSystem { } alternatives.append(level) } - result = .success((model, alternatives)) + var libraries: [AnimationLibrary] = [] + for path in reference.animationLibraries { + let handle: AssetHandle + if let cached = libraryHandles[path] { handle = cached } + else { handle = try await AssetsManager.load(AnimationLibraryResource.self, at: path) } + guard let library = handle.asset?.library else { throw AssetError.message("Animation library has not loaded: \(path)") } + libraryHandles[path] = handle + libraries.append(library) + } + var modifiers: AnimationModifierStack? + if let path = reference.animationModifiers { + let handle: AssetHandle + if let cached = modifierHandles[path] { handle = cached } + else { handle = try await AssetsManager.load(AnimationModifierResource.self, at: path) } + guard let stack = handle.asset?.stack else { throw AssetError.message("Animation modifiers have not loaded: \(path)") } + modifierHandles[path] = handle + modifiers = stack + } + result = .success((model, alternatives, libraries, modifiers)) } catch { result = .failure(.message(error.localizedDescription)) } commands.queue.push { world in guard let owner = world.getEntityByID(id), owner.components[Model3DSource.self] == reference, owner.components[Model3DSourceState.self]?.reference != reference else { return } - if let rootID = owner.components[Model3DSourceState.self]?.rootEntityID { - world.removeEntity(rootID, recursively: true) - } + let previousRoot = owner.components[Model3DSourceState.self]?.rootEntityID do { - let (model, alternatives) = try result.get() + let (model, alternatives, libraries, modifiers) = try result.get() if let model { - try reference.instantiate(model, alternatives: alternatives, in: world, under: owner) + try reference.instantiate(model, alternatives: alternatives, libraries: libraries, modifiers: modifiers, in: world, under: owner) } else { owner.components[Model3DSourceState.self] = .init(reference: reference) } + if let previousRoot { world.removeEntity(previousRoot, recursively: true) } } catch { - owner.components[Model3DSourceState.self] = .init(reference: reference, error: error.localizedDescription) + owner.components[Model3DSourceState.self] = .init(reference: reference, rootEntityID: previousRoot, error: error.localizedDescription) } } } diff --git a/Sources/AdaScene/3D/ModelAsset3D+Animation.swift b/Sources/AdaScene/3D/ModelAsset3D+Animation.swift index 839309d32..62dc756b0 100644 --- a/Sources/AdaScene/3D/ModelAsset3D+Animation.swift +++ b/Sources/AdaScene/3D/ModelAsset3D+Animation.swift @@ -1,81 +1,13 @@ import AdaAnimation import AdaAssets -import Math extension ModelAsset3D { - /// The weights a node's mesh has when nothing animates them, or `nil` when it has no morph targets. - private static func restWeights(of node: GLTFImportResult.Node, meshes: [GLTFImportResult.Mesh]) -> [Float]? { - guard let meshIndex = node.meshIndex, meshes.indices.contains(meshIndex), meshes[meshIndex].morphTargetCount > 0 else { return nil } - return node.weights ?? meshes[meshIndex].defaultWeights - } - static func makeRig(nodes: [GLTFImportResult.Node], meshes: [GLTFImportResult.Mesh] = []) throws -> SkeletalRig { - var parents = [Int?](repeating: nil, count: nodes.count) - for (index, node) in nodes.enumerated() { - for child in node.children { - guard nodes.indices.contains(child), parents[child] == nil else { throw SkeletalAnimationError.invalidHierarchy } - parents[child] = index - } - } - return try SkeletalRig(nodes: nodes.enumerated().map { index, node in - let pose = node.restPose.map { SkeletalJointPose(translation: $0.translation, rotation: $0.rotation, scale: $0.scale) } - return SkeletalRig.Node( - parentIndex: parents[index], - restPose: pose, - restMatrix: node.transform, - name: node.name, - morphRestWeights: restWeights(of: node, meshes: meshes) - ) - }) + try GLTFAnimationAdapter.makeRig(nodes: nodes, meshes: meshes) } - static func makeClips( - _ clips: [GLTFImportResult.Animation], - nodes: [GLTFImportResult.Node] = [], - meshes: [GLTFImportResult.Mesh] = [] - ) throws -> [SkeletalAnimationClip] { - try clips.enumerated().map { index, clip in - // Weight channels become morph tracks; the others are pose tracks. - let tracks = try clip.channels.filter { $0.path != .weights }.map { channel in - let sampler = clip.samplers[channel.samplerIndex] - let path: SkeletalAnimationTrack.Path - switch channel.path { - case .translation: path = .translation - case .rotation: path = .rotation - case .scale: path = .scale - case .weights: preconditionFailure("Weight channels are filtered out above.") - } - let interpolation: SkeletalAnimationTrack.Interpolation - switch sampler.interpolation { - case .linear: interpolation = .linear - case .step: interpolation = .step - case .cubicSpline: interpolation = .cubicSpline - } - let values: [Vector4] = sampler.output.componentCount == 4 - ? sampler.output.vector4Values() - : sampler.output.vector3Values().map { Vector4($0, 0) } - return try SkeletalAnimationTrack( - nodeIndex: channel.nodeIndex, path: path, interpolation: interpolation, times: sampler.times.map(Double.init), values: values - ) - } - let morphTracks = try clip.channels.filter { $0.path == .weights }.map { channel in - let sampler = clip.samplers[channel.samplerIndex] - let interpolation: SkeletalAnimationTrack.Interpolation - switch sampler.interpolation { - case .linear: interpolation = .linear - case .step: interpolation = .step - case .cubicSpline: interpolation = .cubicSpline - } - let targets = nodes.indices.contains(channel.nodeIndex) ? Self.restWeights(of: nodes[channel.nodeIndex], meshes: meshes)?.count ?? 0 : 0 - return try MorphWeightsTrack( - nodeIndex: channel.nodeIndex, - interpolation: interpolation, - times: sampler.times.map(Double.init), - targetCount: targets, - values: sampler.output.values - ) - } - return SkeletalAnimationClip(name: clip.name ?? "Animation \(index)", tracks: tracks, morphTracks: morphTracks) - } + static func makeClips(_ clips: [GLTFImportResult.Animation], nodes: [GLTFImportResult.Node] = [], + meshes: [GLTFImportResult.Mesh] = []) throws -> [SkeletalAnimationClip] { + try GLTFAnimationAdapter.makeClips(clips, nodes: nodes, meshes: meshes) } } diff --git a/Sources/AdaScene/3D/ModelAsset3D.swift b/Sources/AdaScene/3D/ModelAsset3D.swift index d5c4dd224..7df2829af 100644 --- a/Sources/AdaScene/3D/ModelAsset3D.swift +++ b/Sources/AdaScene/3D/ModelAsset3D.swift @@ -89,10 +89,23 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { self.animationClips = animationClips } - public init(from assetDecoder: any AssetDecoder) async throws { - if assetDecoder.assetMeta.filePath.pathExtension.lowercased() == "vox" { - let settings = VoxelImportSettings(queryParams: assetDecoder.assetMeta.queryParams) - let contents = try await Self.loadVoxelContents(url: assetDecoder.assetMeta.filePath, options: settings.options) + public convenience init(from assetDecoder: any AssetDecoder) async throws { + try await self.init(fileURL: assetDecoder.assetMeta.filePath, queryParams: assetDecoder.assetMeta.queryParams, gltfLoader: nil) + } + + /// Constructs a glTF model using a caller-owned import policy, without changing the global loader resolver. + /// A host can supply a restricted native loader to bound files, dependencies and decoded accessor work. + public convenience init(gltfAt url: URL, loader: any GLTFLoader) async throws { + guard ["glb", "gltf"].contains(url.pathExtension.lowercased()) else { + throw AssetDecodingError.invalidAssetExtension(url.pathExtension) + } + try await self.init(fileURL: url, queryParams: [], gltfLoader: loader) + } + + private init(fileURL: URL, queryParams: [AssetQuery], gltfLoader: (any GLTFLoader)?) async throws { + if fileURL.pathExtension.lowercased() == "vox" { + let settings = VoxelImportSettings(queryParams: queryParams) + let contents = try await Self.loadVoxelContents(url: fileURL, options: settings.options) self.nodes = contents.nodes self.meshes = contents.meshes self.materials = contents.materials @@ -105,9 +118,9 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { return } - if assetDecoder.assetMeta.filePath.pathExtension.lowercased() == "obj" { + if fileURL.pathExtension.lowercased() == "obj" { let loader = OBJLoaderResolver.shared.getLoader() - let result = try await loader.load(url: assetDecoder.assetMeta.filePath) + let result = try await loader.load(url: fileURL) let device = unsafe RenderEngine.shared.renderDevice self.materials = result.materials.map { _ in PBRMaterial() } @@ -145,11 +158,11 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { return } - let loader = GLTFLoaderResolver.shared.getLoader() - let result = try await loader.load(url: assetDecoder.assetMeta.filePath) + let loader = gltfLoader ?? GLTFLoaderResolver.shared.getLoader() + let result = try await loader.load(url: fileURL) importWarnings = result.warnings var preparedMeshlets: MeshletSidecar? - let sidecarURL = assetDecoder.assetMeta.filePath.appendingPathExtension("meshlets") + let sidecarURL = fileURL.appendingPathExtension("meshlets") if FileManager.default.fileExists(atPath: sidecarURL.path) { do { let attributes = try FileManager.default.attributesOfItem(atPath: sidecarURL.path) @@ -162,7 +175,7 @@ public final class ModelAsset3D: Asset, @unchecked Sendable { } } var preparedMeshletLOD: MeshletLODSidecar? - let hierarchyURL = assetDecoder.assetMeta.filePath.appendingPathExtension("meshletlod") + let hierarchyURL = fileURL.appendingPathExtension("meshletlod") if FileManager.default.fileExists(atPath: hierarchyURL.path) { do { let attributes = try FileManager.default.attributesOfItem(atPath: hierarchyURL.path) diff --git a/Sources/AdaScene/3D/SkeletalAnimation3D.swift b/Sources/AdaScene/3D/SkeletalAnimation3D.swift index 9bc33db9b..955aed70a 100644 --- a/Sources/AdaScene/3D/SkeletalAnimation3D.swift +++ b/Sources/AdaScene/3D/SkeletalAnimation3D.swift @@ -23,12 +23,15 @@ public struct ModelAnimation3DEvent: Event, Sendable { public struct ModelAnimation3DComponent: Component { public var player: SkeletalAnimationPlayer public var evaluationLOD: AnimationLOD3DSettings? + /// Scene-local stable marker bindings, separate from reusable model-space modifier resources. + public var modifierBindings: [String: AnimationIKBinding3D] = [:] + public var modifierNamespace = "" var lodState = AnimationLOD3DState() var appliedGeneration: UInt64? var appliedPlayer: UUID? var canSkipUpdate: Bool { - evaluationLOD == nil && !player.isPlaying && !player.hasPendingGraphEvents + evaluationLOD == nil && !player.isPlaying && !player.hasPendingGraphEvents && !player.hasActivePoseModifiers && appliedPlayer == player.instanceID && appliedGeneration == player.poseGeneration } @@ -90,7 +93,7 @@ public struct SkinnedMesh3DComponent: Component { } /// Evaluates poses before transform propagation so extraction and shadows observe the same frame. -@PlainSystem(dependencies: [.before(TransformSystem.self)]) +@PlainSystem(dependencies: [.before(TransformSystem.self), .after(VoxelAnimation3DSystem.self)]) public struct SkeletalAnimation3DSystem { @Query> private var models @Query> private var nodes @@ -100,11 +103,25 @@ public struct SkeletalAnimation3DSystem { @EventsSender private var animationEvents @Query private var cameras @Query private var transformAccess + @Query private var currentTransforms + @Query private var currentRelationships + @Query private var sceneIdentities public init(world _: World) {} public func update(context _: UpdateContext) { _ = transformAccess.wrappedValue + var snapshot = AnimationIKSnapshot3D() + var needsSnapshot = false + models.batched().forEach { root, animation in + snapshot.roots.insert(root.id) + needsSnapshot = needsSnapshot || animation.wrappedValue.player.hasActivePoseModifiers + } + if needsSnapshot { + currentTransforms.batched().forEach { entity, transform in snapshot.locals[entity.id] = transform.matrix } + currentRelationships.batched().forEach { entity, relationship in snapshot.parents[entity.id] = relationship.parent } + sceneIdentities.batched().forEach { entity, identity in snapshot.insert(identity, entity: entity.id) } + } var observers: [AnimationLOD3DObserver] = [] cameras.batched().forEach { camera, graph in if camera.isActive, graph.subgraphLabel == .main3D { @@ -127,7 +144,11 @@ public struct SkeletalAnimation3DSystem { observers: observers ) } else { - value.player.advance(by: Double(time.deltaTime)) + value.player.advance(by: Double(time.deltaTime), evaluatePose: true, finalizeModifiers: false) + } + if value.player.hasActivePoseModifiers { + let inputs = snapshot.inputs(for: value, root: root.id) + value.player.finalizePoseModifiers(inputs: inputs) } for event in value.player.drainGraphEvents() { animationEvents.send(ModelAnimation3DEvent(modelRoot: root.id, animation: event)) diff --git a/Sources/AdaScriptCompilerCore/AdaScriptAPICatalog.swift b/Sources/AdaScriptCompilerCore/AdaScriptAPICatalog.swift index a1003eae1..2eef7c4b9 100644 --- a/Sources/AdaScriptCompilerCore/AdaScriptAPICatalog.swift +++ b/Sources/AdaScriptCompilerCore/AdaScriptAPICatalog.swift @@ -108,6 +108,7 @@ public extension AdaScriptTypeEnvironment { ), ]), "$AdaEditorToolContext": members([ + property("animations", type: .named("$AdaEditorAnimations"), detail: "API 2 detached animation jobs; requires editor.animations.read"), property("entityCount", type: .int, detail: "Entity count in the action's scene snapshot; requires editor.documents.read"), property("scenePath", type: .string, detail: "Path of the active scene; requires editor.documents.read"), property("sceneRevision", type: .string, detail: "Revision of the active scene; requires editor.documents.read"), @@ -157,6 +158,12 @@ public extension AdaScriptTypeEnvironment { insertText: "subscribe(event: \"\", action: \"\")" ), ]), + "$AdaEditorAnimations": members([ + method("submit", parameters: [.any], returning: .string, + detail: "submit(requestMap) -> job ID; stage an AnimationAuthoringRequest after the callback succeeds. No editor objects enter the VM."), + method("poll", parameters: [.string], returning: .any, + detail: "poll(jobID) -> detached Map: status pending/completed/failed; a successful terminal poll consumes the result.") + ]), "$AdaEntity": members([ property("id", type: .int, detail: "Entity identifier") ]), diff --git a/Sources/AdaScriptCompilerCore/AdaScriptSchemaModels.swift b/Sources/AdaScriptCompilerCore/AdaScriptSchemaModels.swift index 3d16c134a..cce42cdd6 100644 --- a/Sources/AdaScriptCompilerCore/AdaScriptSchemaModels.swift +++ b/Sources/AdaScriptCompilerCore/AdaScriptSchemaModels.swift @@ -278,12 +278,17 @@ public struct AdaScriptSystemCapabilities: Equatable, Sendable { public enum AdaScriptToolPlatform: String, CaseIterable, Equatable, Sendable { /// The native macOS AdaEditor host. case macOS = "macos" + /// The native Windows Ada Studio host. + case windows /// The native iPadOS AdaEditor host. case iPadOS = "ipados" } /// A host capability requested statically by an AdaEditor tool. public enum AdaScriptToolPermission: String, CaseIterable, Equatable, Sendable { + case animationRead = "editor.animations.read" + case animationWrite = "editor.animations.write" + case animationPreview = "editor.animations.preview" case clipboardRead = "clipboard.read" case clipboardWrite = "clipboard.write" case documentRead = "editor.documents.read" diff --git a/Sources/AdaScripting/AdaScriptAnimationToolBridge.swift b/Sources/AdaScripting/AdaScriptAnimationToolBridge.swift new file mode 100644 index 000000000..a81b84c42 --- /dev/null +++ b/Sources/AdaScripting/AdaScriptAnimationToolBridge.swift @@ -0,0 +1,79 @@ +import AdaAnimation +import AdaScriptCompilerCore +import Foundation + +/// Detached work published by a successful synchronous Tool callback. +public struct AdaScriptAnimationToolJob: Sendable { + public let id: String + public let request: AnimationAuthoringRequest +} + +/// Accessed only by the owning main-actor runtime under the shared VM lock. +/// Queues values and bounded JSON; it never retains an editor service or scene. +final class AdaScriptAnimationToolBridge { + let permissions: Set + let apiVersion: Int + private var results: [String: String] = [:] + private var terminal: Set = [] + private var staged: [AdaScriptAnimationToolJob] = [] + private var consumed: Set = [] + private var pending: [AdaScriptAnimationToolJob] = [] + + init(permissions: Set, apiVersion: Int) { + self.permissions = permissions + self.apiVersion = apiVersion + } + + var hasStagedJobs: Bool { !staged.isEmpty } + func begin() { staged = []; consumed = [] } + + func finish(success: Bool) { + if success { + pending.append(contentsOf: staged) + for id in consumed { results[id] = nil; terminal.remove(id) } + } else { + for job in staged { results[job.id] = nil } + } + staged = []; consumed = [] + } + + func submit(_ text: String) throws -> String { + guard apiVersion == 2, permissions.contains(.animationRead) else { throw failure("API 2 and editor.animations.read are required.") } + guard text.utf8.count <= 65_536, staged.count < 8, results.count < 16 else { + throw failure("Animation jobs support 64 KiB requests, 8 submissions per callback and 16 retained jobs; poll completed jobs to release them.") + } + let request = try JSONDecoder().decode(AnimationAuthoringRequest.self, from: Data(text.utf8)) + if request.action.writesResource, !permissions.contains(.animationWrite) { throw failure("editor.animations.write is required.") } + if request.action.requiresPreviewPermission, !permissions.contains(.animationPreview) { throw failure("editor.animations.preview is required.") } + if request.action.requiresSceneWritePermission, !permissions.contains(.documentWrite) { throw failure("editor.documents.write is required to bind animations.") } + let id = UUID().uuidString + results[id] = #"{"status":"pending"}"# + staged.append(.init(id: id, request: request)) + return id + } + + func poll(_ id: String) throws -> String { + guard apiVersion == 2, permissions.contains(.animationRead), let result = results[id], !consumed.contains(id) else { + throw failure("Unknown, consumed or unavailable animation job.") + } + if terminal.contains(id) { consumed.insert(id) } + return result + } + + func takePending() -> [AdaScriptAnimationToolJob] { + defer { pending = [] } + return pending + } + + func complete(_ id: String, responseJSON: String) throws { + guard results[id] != nil, !terminal.contains(id) else { return } + guard responseJSON.utf8.count <= 1_048_576, + let object = try JSONSerialization.jsonObject(with: Data(responseJSON.utf8)) as? [String: Any], + ["completed", "failed"].contains(object["status"] as? String ?? "") else { throw failure("Invalid animation job response.") } + results[id] = responseJSON + terminal.insert(id) + } + + func retire() { results = [:]; terminal = []; staged = []; consumed = []; pending = [] } + private func failure(_ text: String) -> AdaScriptError { .invalidManifest(text) } +} diff --git a/Sources/AdaScripting/AdaScriptToolRuntime.swift b/Sources/AdaScripting/AdaScriptToolRuntime.swift index 6db364a8e..7fc8cdc2d 100644 --- a/Sources/AdaScripting/AdaScriptToolRuntime.swift +++ b/Sources/AdaScripting/AdaScriptToolRuntime.swift @@ -1,4 +1,5 @@ import AdaScriptCompilerCore +import AdaUI import Foundation import Gravity @@ -53,10 +54,10 @@ public final class AdaScriptToolRuntime { guard try AdaScriptSchemaParser.parseTools(sources: sources).first(where: { $0.id == schema.id }) == schema else { throw AdaScriptError.invalidManifest("Tool metadata does not match its sources.") } - guard schema.apiVersion == 1, schema.platforms.contains(.macOS) else { + guard [1, 2].contains(schema.apiVersion), schema.platforms.contains(.macOS) || schema.platforms.contains(.windows) else { throw AdaScriptError.invalidManifest("This tool requires an unsupported Studio API or platform.") } - let supported: Set = [.documentRead, .documentWrite] + let supported = Self.supportedPermissions(apiVersion: schema.apiVersion) guard Set(schema.permissions).isSubset(of: supported), Set(schema.permissions).isSubset(of: Set(grantedPermissions)) else { throw AdaScriptError.invalidManifest("The tool's requested permissions are unsupported or have not been granted.") } @@ -71,10 +72,11 @@ public final class AdaScriptToolRuntime { } let delegate = AnnotatedGravityRuntimeDelegate(module: module, sourcesByPath: guardedSources) self.delegate = delegate - let bridge = AdaScriptToolBridge.make(permissions: Set(schema.permissions)) + let bridge = AdaScriptToolBridge.make(permissions: Set(schema.permissions), apiVersion: schema.apiVersion) self.bridge = bridge let bundle = try AdaScriptRuntimeCoordinator.lock.withLock { let vm = GravityVirtualMachine(settings: .init(), delegate: delegate) + bridge.virtualMachine = vm try AdaScriptIdentifiers.bind(to: vm) let budget = try AdaScriptCommunityBudget.install(in: vm, policy: .init(maximumCheckpoints: 20_000, maximumCallbackSeconds: 0.05)) try vm.bindClass(with: AdaScriptToolBridge.self) @@ -137,7 +139,10 @@ public final class AdaScriptToolRuntime { try validateAction(action) bridge.scene = scene bridge.entities = [] + bridge.animations.begin() + var succeeded = false defer { + bridge.animations.finish(success: succeeded) bridge.scene = nil bridge.entities = [] } @@ -149,6 +154,10 @@ public final class AdaScriptToolRuntime { } try invoke(action, arguments: [context]) try refreshValues() + guard bridge.entities.isEmpty || !bridge.animations.hasStagedJobs else { + throw AdaScriptError.invalidManifest("Submit animation jobs and entity-creation batches in separate callbacks.") + } + succeeded = true return bridge.entities } } @@ -161,12 +170,31 @@ public final class AdaScriptToolRuntime { // A retired bridge cannot write documents or register new panels. bridge.scene = nil bridge.isRetired = true + bridge.animations.retire() if instance.hasMethod(named: "deactivate") { try? invoke("deactivate", arguments: []) } isActive = false panels = [] } } + /// Supported capabilities are versioned independently of project consent. + public static func supportedPermissions(apiVersion: Int) -> Set { + if apiVersion == 1 { return [.documentRead, .documentWrite] } + if apiVersion == 2 { return [.documentRead, .documentWrite, .animationRead, .animationWrite, .animationPreview] } + return [] + } + + /// The host owns cancellation and generation checks after `perform` succeeds. + public func takeAnimationJobs() -> [AdaScriptAnimationToolJob] { + AdaScriptRuntimeCoordinator.lock.withLock { bridge.animations.takePending() } + } + + /// Terminal results are consumed by a successful poll callback. + public func completeAnimationJob(_ id: String, responseJSON: String) throws { + guard isActive, !isFailed, !bridge.isRetired else { return } + try AdaScriptRuntimeCoordinator.lock.withLock { try bridge.animations.complete(id, responseJSON: responseJSON) } + } + private func invoke(_ method: String, arguments: [Any]) throws { budget.reset() bridge.failure = nil @@ -228,9 +256,16 @@ public final class AdaScriptToolRuntime { // bridge receives positional raw values and validates them before conversion. private static let contextPrelude = """ - class __AdaStudioContext { + class __AdaStudioAnimations { var __host; func init(host) { __host = host; } + func submit(request) { return __host.submitAnimation(request); } + func poll(job) { return __host.pollAnimation(job); } + } + class __AdaStudioContext { + var __host; + var animations; + func init(host) { __host = host; animations = __AdaStudioAnimations(host); } var scenePath { get { return __host.scenePath; } }; var sceneRevision { get { return __host.sceneRevision; } }; var entityCount { get { return __host.entityCount; } }; @@ -245,6 +280,8 @@ public final class AdaScriptToolRuntime { // Bridge calls collect detached data; they never invoke AdaUI or editor models. @GSExportable("AdaEditorToolContext") final class AdaScriptToolBridge { + @GSExportableIgnore weak var virtualMachine: GravityVirtualMachine? + @GSExportableIgnore let animations: AdaScriptAnimationToolBridge @GSExportableIgnore let permissions: Set @GSExportableIgnore var panels: [AdaScriptToolPanel] = [] @GSExportableIgnore var entities: [AdaScriptToolEntity] = [] @@ -267,9 +304,43 @@ final class AdaScriptToolBridge { } @GSExportableIgnore - static func make(permissions: Set) -> AdaScriptToolBridge { AdaScriptToolBridge(permissions: permissions) } + static func make(permissions: Set, apiVersion: Int) -> AdaScriptToolBridge { AdaScriptToolBridge(permissions: permissions, apiVersion: apiVersion) } - private init(permissions: Set) { self.permissions = permissions } + private init(permissions: Set, apiVersion: Int) { + self.permissions = permissions + self.animations = .init(permissions: permissions, apiVersion: apiVersion) + } + + func submitAnimation(request: GSValue) -> String { + guard !isRetired, !isRegistering else { + failure = "Animation requests can only be submitted from an active action." + return "" + } + do { + guard let detached = AdaScriptUIValueBridge.detached(request, maximumNodes: 8_192, maximumStringBytes: 65_536), case .object = detached else { + throw AdaScriptError.invalidManifest("Animation request conversion budget exceeded (8,192 values, 64 KiB UTF-8 strings, depth 64); use a bounded Map.") + } + let data = try JSONEncoder().encode(detached) + return try animations.submit(String(decoding: data, as: UTF8.self)) + } + catch { failure = error.localizedDescription; return "" } + } + + func pollAnimation(job: GSValue) -> GSValue? { + guard let virtualMachine else { return nil } + guard !isRetired, !isRegistering, job.isString else { + failure = "Animation jobs can only be polled from an active action." + return AdaScriptUIValueBridge.make(.object(["status": .string("failed"), "error": .string(failure ?? "Animation job is unavailable.")]), in: virtualMachine) + } + do { + let data = Data(try animations.poll(job.toString).utf8) + return AdaScriptUIValueBridge.make(try JSONDecoder().decode(UIValue.self, from: data), in: virtualMachine) + } + catch { + failure = error.localizedDescription + return AdaScriptUIValueBridge.make(.object(["status": .string("failed"), "error": .string("Animation job is unavailable.")]), in: virtualMachine) + } + } func addPanel(id: GSValue, title: GSValue, location: GSValue, ui: GSValue) -> Bool { guard id.isString, title.isString, location.isString, ui.isString else { diff --git a/Sources/AdaScripting/AdaScriptUIValueBridge.swift b/Sources/AdaScripting/AdaScriptUIValueBridge.swift index 95d289097..8421024fc 100644 --- a/Sources/AdaScripting/AdaScriptUIValueBridge.swift +++ b/Sources/AdaScripting/AdaScriptUIValueBridge.swift @@ -23,12 +23,17 @@ enum AdaScriptUIValueBridge { } } - static func detached(_ value: GSValue) -> UIValue? { unsafe detach(value.value, depth: 0) } + static func detached(_ value: GSValue, maximumNodes: Int = .max, maximumStringBytes: Int = .max) -> UIValue? { + var nodes = maximumNodes + var bytes = maximumStringBytes + return unsafe detach(value.value, depth: 0, nodes: &nodes, bytes: &bytes) + } - private static func detach(_ value: gravity_value_t, depth: Int) -> UIValue? { - guard depth < 64 else { + private static func detach(_ value: gravity_value_t, depth: Int, nodes: inout Int, bytes: inout Int) -> UIValue? { + guard depth < 64, nodes > 0 else { return nil } + nodes -= 1 if unsafe gravity_value_isa_null(value) { return .null } @@ -41,14 +46,20 @@ enum AdaScriptUIValueBridge { if unsafe gravity_value_isa_float(value) { return value.f.isFinite ? .number(value.f) : nil } - if unsafe gravity_value_isa_string(value), let pointer = unsafe gravity_cast_value_as_cString(value) { - return .string(unsafe String(cString: pointer)) + if unsafe gravity_value_isa_string(value), let string = unsafe gravity_cast_value_as_string(value), + let pointer = unsafe gravity_cast_value_as_cString(value) { + let count = unsafe Int(string.pointee.len) + guard count <= bytes else { return nil } + bytes -= count + let contents = unsafe UnsafeBufferPointer(start: UnsafeRawPointer(pointer).assumingMemoryBound(to: UInt8.self), count: count) + return .string(unsafe String(decoding: contents, as: UTF8.self)) } if unsafe gravity_value_isa_list(value) { var values: [UIValue] = [] let count = unsafe gravity_list_count(value) + guard count <= nodes else { return nil } for index in 0.. 1) + #expect(abs(try AnimationEasing(family: .bounce, direction: .out).sample(0.5) - 0.765625) < 1e-12) + let bounce = AnimationEasing(family: .bounce, direction: .out) + #expect(try bounce.sample(0.4) > bounce.sample(0.5)) + let back = AnimationEasing(family: .back, direction: .in, overshoot: 3) + #expect(try back.sample(0.5) == -0.25) + let elastic = AnimationEasing(family: .elastic, direction: .out, period: 0.6) + #expect(abs(try elastic.sample(0.25) - AnimationEasing(family: .elastic, direction: .out).sample(0.25)) > 0.1) + } + + @Test + func easingEndpointsAreExactAndInvalidParametersAreRejected() throws { + for family in AnimationEasing.Family.allCases { + for direction in AnimationEasing.Direction.allCases { + let easing = AnimationEasing(family: family, direction: direction) + #expect(try easing.sample(-1) == 0) + #expect(try easing.sample(0) == 0) + #expect(try easing.sample(1) == 1) + #expect(try easing.sample(2) == 1) + for index in 1..<100 { #expect(try easing.sample(Double(index) / 100).isFinite) } + } + } + #expect(throws: AnimationCurveError.invalidProgress) { try AnimationEasing().sample(.nan) } + #expect(throws: AnimationCurveError.invalidParameters) { try AnimationEasing(period: 0).sample(0.5) } + #expect(throws: AnimationCurveError.invalidParameters) { try AnimationEasing(amplitude: .infinity).validate() } + #expect(throws: AnimationCurveError.invalidParameters) { try AnimationEasing(overshoot: -1).validate() } + } + + @Test + func bezierInvertsTimeBeforeSamplingValueAndEasing() throws { + let segment = AnimationBezierSegment(startTime: 2, endTime: 6, startValue: 0, endValue: 1, + outgoing: .init(timeOffset: 0, valueOffset: 0), incoming: .init(timeOffset: -4, valueOffset: 0)) + #expect(abs(try segment.sample(at: 2.5) - 0.5) < 1e-10) + var eased = segment + eased.easing = .init(family: .quadratic, direction: .in) + #expect(abs(try eased.sample(at: 2.5) - 0.15625) < 1e-10) + let linear = AnimationBezierSegment(startTime: 2, endTime: 6, startValue: -3, endValue: 5) + #expect(abs(try linear.sample(at: 3) - -1) < 1e-10) + #expect(try segment.sample(at: -100) == 0) + #expect(try segment.sample(at: 100) == 1) + } + + @Test + func bezierAcceptsMonotoneCrossingHandlesAndRejectsTimeReversal() throws { + let flat = AnimationBezierSegment(startTime: 0, endTime: 1, startValue: 0, endValue: 1, + outgoing: .init(timeOffset: 1, valueOffset: 0), incoming: .init(timeOffset: -1, valueOffset: 0)) + #expect(abs(try flat.sample(at: 0.500000004) - 0.501499998) < 1e-7) + let outside = AnimationBezierSegment(startTime: 0, endTime: 1, startValue: 0, endValue: 1, + outgoing: .init(timeOffset: 1.1, valueOffset: 0), incoming: .init(timeOffset: -0.1, valueOffset: 0)) + try outside.validate() + var previous = -1.0 + for index in 0...20 { + let value = try outside.sample(at: Double(index) / 20) + #expect(value >= previous) + previous = value + } + var reversed = flat + reversed.outgoing.timeOffset = 2 + reversed.incoming.timeOffset = -2 + #expect(throws: AnimationCurveError.nonMonotoneTime) { try reversed.validate() } + reversed = flat + reversed.endTime = reversed.startTime + #expect(throws: AnimationCurveError.invalidSegment) { try reversed.sample(at: 0) } + } +} diff --git a/Tests/AdaAnimationTests/AnimationLibraryCurveTests.swift b/Tests/AdaAnimationTests/AnimationLibraryCurveTests.swift new file mode 100644 index 000000000..d8bb4fb44 --- /dev/null +++ b/Tests/AdaAnimationTests/AnimationLibraryCurveTests.swift @@ -0,0 +1,159 @@ +import AdaAnimation +import Foundation +import Math +import Testing + +@Suite +struct AnimationLibraryCurveTests { + @Test + func extractingCompiledNativeCurvesPreservesTheirShapeAndSchema() throws { + for kind: AnimationLibraryTrack.Interpolation in [.linear, .bezier, .cubicSpline] { + var source = try fixture() + source.clips[0].tracks.append(.init(id: "weights", nodeID: "node-0", channel: .weights, keys: [ + .init(time: 0, value: [0, 0]), .init(time: 1, value: [1, 0]) + ])) + for index in source.clips[0].tracks.indices { + source.clips[0].tracks[index].interpolation = kind + let width = source.clips[0].tracks[index].keys[0].value.count + source.clips[0].tracks[index].keys[0].curve = .init(easing: .init(family: .quadratic, direction: .in)) + if kind == .bezier { + source.clips[0].tracks[index].keys[0].curve?.outgoing = Array(repeating: .init(timeOffset: 0, valueOffset: 0), count: width) + source.clips[0].tracks[index].keys[1].curve = .init(incoming: Array(repeating: .init(timeOffset: -1, valueOffset: 0), count: width)) + } + if kind == .cubicSpline { + for key in 0..<2 { + source.clips[0].tracks[index].keys[key].incoming = [1] + Array(repeating: 0, count: width - 1) + source.clips[0].tracks[index].keys[key].outgoing = [1] + Array(repeating: 0, count: width - 1) + } + } + } + let rig = try source.binding.makeRig(), clip = try #require(source.compiledClips(for: rig).first) + var copy = AnimationLibrary(binding: source.binding) + let extracted = (try? copy.extract(clip, rig: rig, id: "copy")) != nil + #expect(extracted) + #expect(copy.version == 2) + let expected = try source.sample(clipID: "clip", at: 0.125) + let actual = try? copy.sample(clipID: "copy", at: 0.125) + #expect(abs((actual?.poses[0].translation.x ?? -1) - expected.poses[0].translation.x) < 0.000001) + #expect(abs((actual?.morphWeights[0][0] ?? -1) - expected.morphWeights[0][0]) < 0.000001) + } + } + + @Test + func rotationEasingUsesSlerpAndUnchangedCubicMetadataRetainsExactImportedSampling() throws { + var library = try fixture() + let end = Quat(axis: [0, 0, 1], angle: .pi / 2) + library.clips[0].tracks = [.init(id: "rotation", nodeID: "node-0", channel: .rotation, keys: [ + .init(time: 0, value: [0, 0, 0, 1], curve: .init(easing: .init(family: .quadratic, direction: .in))), + .init(time: 1, value: [end.x, end.y, end.z, end.w]) + ])] + let eased = try library.sample(clipID: "clip", at: 0.5).poses[0].rotation + let expected = Quat(axis: [0, 0, 1], angle: .pi / 8) + #expect(abs(eased.z - expected.z) < 0.000001 && abs(eased.w - expected.w) < 0.000001) + library.clips[0].tracks[0].interpolation = .cubicSpline + for key in 0..<2 { + library.clips[0].tracks[0].keys[key].curve = nil + library.clips[0].tracks[0].keys[key].incoming = [0, 0, 0, 0] + library.clips[0].tracks[0].keys[key].outgoing = [0, 0, 0, 0] + } + let original = try library.sample(clipID: "clip", at: 0.3).poses[0].rotation + library.clips[0].tracks[0].keys[0].curve = .init(easing: .init()) + #expect(try library.sample(clipID: "clip", at: 0.3).poses[0].rotation == original) + let quarter = try library.sample(clipID: "clip", at: 0.25).poses[0].rotation + library.clips[0].tracks[0].keys[0].curve = .init(easing: .init(family: .quadratic, direction: .in)) + let warped = try library.sample(clipID: "clip", at: 0.5).poses[0].rotation + #expect(abs(warped.z - quarter.z) < 0.000001 && abs(warped.w - quarter.w) < 0.000001) + } + + @Test + func sparseEasingCompilesOnlyTheEditedSegmentsOfADenseMorphTrack() throws { + let pose = SkeletalJointPose(), width = 128 + let rig = try SkeletalRig(nodes: [.init(parentIndex: nil, restPose: pose, restMatrix: pose.matrix, + morphRestWeights: Array(repeating: 0, count: width))]) + var keys = (0..<4_000).map { AnimationLibraryKey(id: "weight-\($0)", time: Double($0) / 10, value: Array(repeating: Float($0 % 2), count: width)) } + keys[0].curve = .init(easing: .init(family: .quadratic, direction: .in)) + let library = AnimationLibrary(binding: .init(rig: rig, sourceAsset: "Dense.glb"), clips: [ + .init(id: "dense", name: "Dense", duration: 400, repeats: false, tracks: [.init(nodeID: "node-0", channel: .weights, keys: keys)]) + ], version: 2) + let first = try? library.sample(clipID: "dense", at: 0.05) + let unchanged = try? library.sample(clipID: "dense", at: 0.15) + #expect(abs((first?.morphWeights.first?.last ?? -1) - 0.25) < 0.00001) + #expect(abs((unchanged?.morphWeights.first?.last ?? -1) - 0.5) < 0.00001) + } + + @Test + func nativeCurveEditsPromoteSchemaWithoutChangingLegacyAssets() throws { + var library = try fixture() + library.version = 1 + let legacy = try library.encodedData() + #expect(!String(decoding: legacy, as: UTF8.self).contains("\"curve\"")) + var key = library.clips[0].tracks[0].keys[0] + key.curve = .init(easing: .init(family: .quadratic, direction: .in)) + try library.apply([.init(kind: .putKey, clipID: "clip", trackID: "position", key: key, replaceExisting: true)]) + #expect(library.version == 2) + #expect(try AnimationLibrary.decode(library.encodedData()).version == 2) + var disguised = library + disguised.version = 1 + #expect(throws: AnimationAuthoringError.self) { try disguised.encodedData() } + #expect(try AnimationLibrary.decode(legacy).version == 1) + var future = library + future.version = 3 + #expect(throws: AnimationAuthoringError.self) { try future.validate() } + } + + @Test + func authoredEasingSurvivesPortableRoundTripAndDrivesTheProductionPlayer() throws { + var library = try fixture() + library.clips[0].tracks[0].keys[0].curve = .init(easing: .init(family: .quadratic, direction: .in)) + let reopened = try AnimationLibrary.decode(library.encodedData()) + #expect(reopened == library) + #expect(abs(try reopened.sample(clipID: "clip", at: 0.5).poses[0].translation.x - 0.25) < 0.00001) + } + + @Test + func bezierVectorAndMorphTracksUseTheSameTemporalEvaluator() throws { + var library = try fixture() + library.clips[0].tracks[0].interpolation = .bezier + library.clips[0].tracks[0].keys[0].curve = .init(outgoing: Array(repeating: .init(timeOffset: 0, valueOffset: 0), count: 3)) + library.clips[0].tracks[0].keys[1].curve = .init(incoming: Array(repeating: .init(timeOffset: -1, valueOffset: 0), count: 3)) + library.clips[0].tracks.append(.init(id: "morph", nodeID: "node-0", channel: .weights, interpolation: .bezier, keys: [ + .init(id: "m0", time: 0, value: [0, 2], curve: .init(outgoing: [.init(timeOffset: 0, valueOffset: 0), .init(timeOffset: 1 / 3, valueOffset: 0)])), + .init(id: "m1", time: 1, value: [1, -1], curve: .init(incoming: [.init(timeOffset: -1, valueOffset: 0), .init(timeOffset: -1 / 3, valueOffset: 0)])) + ])) + let player = try? library.sample(clipID: "clip", at: 0.125) + #expect(abs((player?.poses[0].translation.x ?? -1) - 0.5) < 0.00001) + #expect(abs((player?.morphWeights[0][0] ?? -1) - 0.5) < 0.00001) + #expect(abs((player?.morphWeights[0][1] ?? -1) - 1.87109375) < 0.00001) + let clips = try? library.compiledClips(for: library.binding.makeRig()) + #expect(clips?.first?.tracks.first?.interpolation.rawValue == "bezier") + } + + @Test + func malformedCurveEditsAreAtomicAndQuaternionBezierIsRejected() throws { + var library = try fixture() + let original = library + var key = library.clips[0].tracks[0].keys[0] + key.curve = .init(easing: .init(period: 0)) + #expect(throws: AnimationEditError.self) { + try library.apply([.init(kind: .putKey, clipID: "clip", trackID: "position", key: key, replaceExisting: true)]) + } + #expect(library == original) + var rotation = original + rotation.clips[0].tracks = [.init(nodeID: "node-0", channel: .rotation, interpolation: .bezier, keys: [ + .init(time: 0, value: [0, 0, 0, 1]), .init(time: 1, value: [0, 0, 0, 1]) + ])] + #expect(throws: AnimationAuthoringError.self) { try rotation.validate() } + } + + private func fixture() throws -> AnimationLibrary { + let pose = SkeletalJointPose() + let rig = try SkeletalRig(nodes: [.init(parentIndex: nil, restPose: pose, restMatrix: pose.matrix, morphRestWeights: [0, 0])]) + return .init(id: "curves", binding: .init(rig: rig, sourceAsset: "Robot.glb"), clips: [ + .init(id: "clip", name: "Curve", duration: 1, repeats: false, tracks: [ + .init(id: "position", nodeID: "node-0", channel: .translation, keys: [ + .init(id: "first", time: 0, value: [0, 0, 0]), .init(id: "last", time: 1, value: [1, 0, 0]) + ]) + ]) + ], version: 2) + } +} diff --git a/Tests/AdaAnimationTests/AnimationLibraryTests.swift b/Tests/AdaAnimationTests/AnimationLibraryTests.swift new file mode 100644 index 000000000..543afd10e --- /dev/null +++ b/Tests/AdaAnimationTests/AnimationLibraryTests.swift @@ -0,0 +1,227 @@ +import AdaAnimation +import Foundation +import Math +import Testing + +@Suite +struct AnimationLibraryTests { + @Test + func rejectsNegativeDurationWhenDecodingAnAuthoredLibrary() throws { + let library = AnimationLibrary(binding: .init(sourceAsset: "hero.glb", nodes: []), clips: [ + .init(id: "wave", name: "Wave", duration: -1) + ]) + let data = try JSONEncoder().encode(library) + #expect(throws: AnimationAuthoringError.self) { + _ = try AnimationLibrary.decode(data) + } + } + + @Test(arguments: 0..<14) + func rejectsMalformedBindingsAndTracks(_ scenario: Int) throws { + var library = Self.library() + switch scenario { + case 0: library.version = 3 + case 1: library.clips.append(library.clips[0]) + case 2: library.binding.nodes[0].parentID = "bone" + case 3: library.clips[0].tracks[0].nodeID = "missing" + case 4: library.clips[0].tracks[0].keys[0].time = 2 + case 5: library.clips[0].tracks[0].keys[0].value = [.nan, 0, 0] + case 6: library.clips[0].tracks[0].keys.append(.init(time: 0, value: [1, 0, 0])) + case 7: + library.clips[0].tracks[0].channel = .rotation + library.clips[0].tracks[0].keys[0].value = [0, 0, 0, 0] + case 8: library.clips[0].tracks[0].interpolation = .cubicSpline + case 9: library.clips[0].tracks.append(library.clips[0].tracks[0]) + case 10: library.binding.nodes[0].restScale = nil + case 11: library.clips[0].tracks[0].channel = .weights + case 12: library.clips[0].name = String(repeating: "x", count: 1025) + default: library.binding.sourceAsset = String(repeating: "x", count: 4097) + } + #expect(throws: AnimationAuthoringError.self) { try library.validate() } + } + + @Test + func validLibraryRoundTripsWithStableIdentities() throws { + let library = Self.library() + try library.validate() + let decoded = try AnimationLibrary.decode(JSONEncoder().encode(library)) + #expect(decoded == library) + #expect(decoded.clips[0].tracks[0].keys[0].id == "start") + } + + @Test + func extractionPreservesCubicPoseAndMorphSamples() throws { + let rig = try Self.rig() + let translation = try SkeletalAnimationTrack(nodeIndex: 1, path: .translation, interpolation: .cubicSpline, times: [0, 2], + values: [.zero, .zero, [1, 0, 0, 0], .zero, [2, 0, 0, 0], .zero]) + let rotation = try SkeletalAnimationTrack(nodeIndex: 1, path: .rotation, interpolation: .cubicSpline, times: [0, 2], + values: [.zero, [0, 0, 0, 1], [0, 0, 1, 0], .zero, [0, 0, 1, 0], .zero]) + let morph = try MorphWeightsTrack(nodeIndex: 1, interpolation: .cubicSpline, times: [0, 2], targetCount: 2, + values: [0, 0, 0.25, 0.75, 1, -1, 0, 0, 0.75, 0.25, 0, 0]) + let source = SkeletalAnimationClip(name: "Walk", tracks: [translation, rotation], morphTracks: [morph]) + var library = AnimationLibrary(id: "hero", binding: .init(rig: rig, sourceAsset: "hero.glb")) + try library.extract(source, rig: rig, id: "copy", sourceFingerprint: "source-checksum") + library.clips[0].repeats = false + let decoded = try AnimationLibrary.decode(JSONEncoder().encode(library)) + var original = try SkeletalAnimationPlayer(rig: rig, clips: [source]) + original.repeats = false + try original.play("Walk", transitionDuration: 0) + for time in [0, 0.25, 1, 1.75, 2] { + original.seek(to: time) + var copy = try decoded.sample(clipID: "copy", at: time) + #expect(copy.poses == original.poses) + #expect(copy.globalTransforms == original.globalTransforms) + #expect(copy.morphWeights == original.morphWeights) + #expect(copy.drainGraphEvents().isEmpty) + } + #expect(decoded.clips[0].tracks[0].keys[0].outgoing == [1, 0, 0]) + #expect(decoded.clips[0].sourceFingerprint == "source-checksum") + #expect(source.tracks[0].values == translation.values) + } + + @Test + func explicitDurationHoldsAfterLastKeyAndSupportsEmptyPoseClips() throws { + var library = Self.library() + library.clips[0].duration = 3 + library.clips[0].repeats = false + library.clips[0].tracks[0].keys.append(.init(id: "end", time: 1, value: [2, 0, 0])) + let rig = try library.binding.makeRig() + let compiled = try library.compiledClips(for: rig) + #expect(compiled[0].duration == 3) + #expect(try library.sample(clipID: "wave", at: 2).poses[0].translation.x == 2) + library.clips[0].tracks = [] + #expect(try library.compiledClips(for: rig)[0].duration == 3) + #expect(try library.sample(clipID: "wave", at: 2).poses[0].translation == .zero) + } + + @Test + func rejectsInconsistentRestTransformsBeforeSampling() { + var library = Self.library() + library.binding.nodes[0].restTranslation = [10, 0, 0] + #expect(throws: AnimationAuthoringError.self) { try library.validate() } + } + + @Test + func changedSourceRigRequiresExplicitRemapping() throws { + let rig = try Self.rig() + let library = AnimationLibrary(binding: .init(rig: rig, sourceAsset: "hero.glb")) + let changed = try SkeletalRig(nodes: [.init(parentIndex: nil, restPose: .init(), restMatrix: .identity, name: "Changed")]) + #expect(!library.binding.isCompatible(with: changed)) + #expect(throws: AnimationAuthoringError.self) { _ = try library.compiledClips(for: changed) } + #expect(library.binding.isCompatible(with: try library.binding.makeRig())) + } + + @Test + func clipsWithSameDisplayNameHaveDistinctGraphReferences() throws { + var library = Self.library() + var second = library.clips[0] + second.id = "other" + second.tracks[0].keys[0].value = [4, 0, 0] + library.clips.append(second) + let rig = try library.binding.makeRig() + var player = try SkeletalAnimationPlayer(rig: rig, clips: library.compiledClips(for: rig)) + try player.play(AnimationGraph(root: "blend", nodes: [ + .init(id: "a", clip: library.runtimeName(for: "wave")), + .init(id: "b", clip: library.runtimeName(for: "other")), + .init(id: "blend", kind: .blend, inputs: [.init(node: "a", weight: 0.5), .init(node: "b", weight: 0.5)]) + ]), transitionDuration: 0) + #expect(player.poses[0].translation.x == 2) + } + + @Test + func failedCommandBatchPreservesOriginalAndIdentifiesTheOperation() throws { + var library = Self.library() + let original = library + let edits: [AnimationEdit] = [ + .init(id: "rename", kind: .configureClip, clipID: "wave", name: "Changed"), + .init(id: "bad-key", kind: .putKey, clipID: "wave", trackID: "hand-position", key: .init(time: 2, value: [1, 0, 0])) + ] + do { + try library.apply(edits) + Issue.record("Invalid key edit was committed") + } catch { + #expect(error.operationID == "bad-key") + #expect(error.diagnostic.code == .invalidKey) + } + #expect(library == original) + } + + @Test + func keyReplacementAndTimeConflictsAreExplicit() throws { + var library = Self.library() + let replacement = AnimationLibraryKey(id: "start", time: 0, value: [2, 0, 0]) + #expect(throws: AnimationEditError.self) { + try library.apply([.init(kind: .putKey, clipID: "wave", trackID: "hand-position", key: replacement)]) + } + try library.apply([.init(kind: .putKey, clipID: "wave", trackID: "hand-position", key: replacement, replaceExisting: true)]) + #expect(try library.sample(clipID: "wave", at: 0).poses[0].translation.x == 2) + #expect(throws: AnimationEditError.self) { + try library.apply([.init(kind: .putKey, clipID: "wave", trackID: "hand-position", + key: .init(id: "different", time: 0, value: [4, 0, 0]), replaceExisting: true)]) + } + } + + @Test + func scriptCommandMayOmitOptionalReplacementFlag() throws { + let json = Data(#"{"id":"rename","kind":"clip.configure","clipID":"wave","name":"Renamed"}"#.utf8) + let edit = try? JSONDecoder().decode(AnimationEdit.self, from: json) + #expect(edit != nil) + var library = Self.library() + try library.apply([try #require(edit)]) + #expect(library.clips[0].name == "Renamed") + } + + @Test + func capturedPoseClipAndDuplicateDoNotShareSourceKeys() throws { + var library = Self.library() + var player = try library.sample(clipID: "wave", at: 0) + player.isPlaying = true + let pose = try library.poseClip(id: "pose", name: "Pose", duration: 2, from: player) + try library.apply([ + .init(kind: .createClip, clip: pose), + .init(kind: .duplicateClip, clipID: "wave", newID: "duplicate", name: "Copy") + ]) + let copyIndex = try #require(library.clips.firstIndex { $0.id == "duplicate" }) + library.clips[copyIndex].tracks[0].keys[0].value = [5, 0, 0] + #expect(library.clips[0].tracks[0].keys[0].value == [0, 0, 0]) + #expect(try library.sample(clipID: "pose", at: 1).poses == player.poses) + } + + @Test + func capturedPosePreservesMatrixAuthoredNodesAndTheirMorphs() throws { + let staticMatrix = Transform3D(translation: [3, 0, 0]) + let rig = try SkeletalRig(nodes: [ + .init(parentIndex: nil, restPose: nil, restMatrix: staticMatrix, morphRestWeights: [0.3]), + .init(parentIndex: 0, restPose: .init(), restMatrix: .identity) + ]) + let player = try SkeletalAnimationPlayer(rig: rig, clips: []) + var library = AnimationLibrary(binding: .init(rig: rig, sourceAsset: "mixed.glb")) + let clip = try? library.poseClip(id: "pose", name: "Pose", duration: 1, from: player) + #expect(clip != nil) + try library.apply([.init(kind: .createClip, clip: try #require(clip))]) + let captured = try library.sample(clipID: "pose", at: 0.5) + #expect(captured.globalTransforms == player.globalTransforms) + #expect(captured.morphWeights == player.morphWeights) + #expect(library.clips[0].tracks.filter { $0.nodeID == "node-0" }.allSatisfy { $0.channel == .weights }) + } + + private static func rig() throws -> SkeletalRig { + let child = SkeletalJointPose(translation: [0, 1, 0]) + return try SkeletalRig(nodes: [ + .init(parentIndex: nil, restPose: .init(), restMatrix: .identity, name: "Bone"), + .init(parentIndex: 0, restPose: child, restMatrix: child.matrix, name: "Bone", morphRestWeights: [0.25, 0.75]) + ]) + } + + private static func library() -> AnimationLibrary { + .init(id: "hero", binding: .init(sourceAsset: "hero.glb", nodes: [ + .init(id: "bone", name: "Hand", restTranslation: .zero, restRotation: [0, 0, 0, 1], restScale: .one) + ]), clips: [ + .init(id: "wave", name: "Wave", duration: 1, tracks: [ + .init(id: "hand-position", nodeID: "bone", channel: .translation, keys: [ + .init(id: "start", time: 0, value: [0, 0, 0]) + ]) + ]) + ]) + } +} diff --git a/Tests/AdaAnimationTests/SkeletalIKTests.swift b/Tests/AdaAnimationTests/SkeletalIKTests.swift new file mode 100644 index 000000000..9d7fd35a8 --- /dev/null +++ b/Tests/AdaAnimationTests/SkeletalIKTests.swift @@ -0,0 +1,202 @@ +import AdaAnimation +import Math +import Testing + +@Suite +struct SkeletalIKTests { + @Test + func twoBoneReachesTargetWithoutChangingBaseOrRestPose() throws { + var player = try chain() + let base = player.poses + let binding = AnimationRigBinding(rig: player.rig, sourceAsset: "Robot.glb") + try player.setPoseModifiers([ + .init(id: "arm", kind: .twoBone, joints: ["node-0", "node-1", "node-2"], target: [1, 1, 0], pole: [0, 0, 1]) + ], binding: binding) + #expect((player.globalTransforms[2].origin - Vector3(1, 1, 0)).length < 0.0001) + #expect(player.basePoses == base) + #expect(player.rig.nodes[1].restPose == base[1]) + let final = player.poses + for _ in 0..<50 { player.finalizePoseModifiers() } + #expect(player.poses == final) + #expect(player.poses.map(\.translation) == base.map(\.translation)) + #expect(player.poses.map(\.scale) == base.map(\.scale)) + #expect(player.poses[2].rotation == base[2].rotation) + } + + @Test + func fabrikBendsStraightChainToInteriorTargetAndPreservesLengths() throws { + var player = try chain() + let binding = AnimationRigBinding(rig: player.rig, sourceAsset: "Robot.glb") + try player.setPoseModifiers([ + .init(id: "tail", kind: .fabrik, joints: ["node-0", "node-1", "node-2"], target: [1, 0, 0], iterations: 64) + ], binding: binding) + #expect((player.globalTransforms[2].origin - Vector3(1, 0, 0)).length < 0.0002) + #expect(player.modifierDiagnostics.first?.status == .solved) + for index in 1...2 { + #expect(abs((player.globalTransforms[index].origin - player.globalTransforms[index - 1].origin).length - 1) < 0.0001) + } + let result = player.poses + player.sampleCurrentPose() + #expect(player.poses == result) + #expect(player.globalTransforms[0].origin == .zero) + } + + @Test(arguments: [AnimationPoseModifier.Kind.twoBone, .fabrik]) + func unreachableOuterAndInnerTargetsDoNotStretch(kind: AnimationPoseModifier.Kind) throws { + for target: Vector3 in [[8, 0, 0], [0, 0, 0]] { + var player = try chain(lengths: [2, 1]) + let binding = AnimationRigBinding(rig: player.rig, sourceAsset: "Robot.glb") + try player.setPoseModifiers([.init(id: "ik", kind: kind, joints: ["node-0", "node-1", "node-2"], target: target)], binding: binding) + #expect(player.modifierDiagnostics.first?.status == .unreachable) + #expect((player.modifierDiagnostics.first?.reachError ?? 0) > 0.99) + #expect(abs((player.globalTransforms[1].origin - player.globalTransforms[0].origin).length - 2) < 0.0001) + #expect(abs((player.globalTransforms[2].origin - player.globalTransforms[1].origin).length - 1) < 0.0001) + } + } + + @Test + func influenceIsAppliedOnceAndZeroRestoresBase() throws { + var player = try chain() + let binding = AnimationRigBinding(rig: player.rig, sourceAsset: "Robot.glb") + var modifier = AnimationPoseModifier(id: "ik", kind: .twoBone, joints: ["node-0", "node-1", "node-2"], target: [0, 2, 0], influence: 0.5) + try player.setPoseModifiers([modifier], binding: binding) + #expect((player.globalTransforms[2].origin - Vector3(sqrt(2), sqrt(2), 0)).length < 0.0001) + #expect((player.modifierDiagnostics.first?.solverError ?? 1) < 0.0001) + #expect((player.modifierDiagnostics.first?.reachError ?? 0) > 1) + modifier.influence = 0 + try player.setPoseModifiers([modifier], binding: binding) + #expect(player.poses == player.basePoses) + modifier.enabled = false + try player.setPoseModifiers([modifier], binding: binding) + #expect(player.modifierDiagnostics.first?.status == .disabled) + } + + @Test + func invalidChainsAndBindingIDsAreRejectedAtomically() throws { + var player = try chain() + let binding = AnimationRigBinding(rig: player.rig, sourceAsset: "Robot.glb") + let valid = AnimationPoseModifier(id: "ik", kind: .twoBone, joints: ["node-0", "node-1", "node-2"], target: [1, 1, 0]) + try player.setPoseModifiers([valid], binding: binding) + let final = player.poses + var invalid = valid + invalid.joints = ["node-0", "node-2", "node-1"] + #expect(throws: AnimationPoseModifierError.invalidChain("ik")) { try player.setPoseModifiers([invalid], binding: binding) } + #expect(player.poseModifiers == [valid]) + #expect(player.poses == final) + var duplicate = binding + duplicate.nodes[1].id = duplicate.nodes[0].id + #expect(throws: AnimationPoseModifierError.incompatibleRig) { try player.setPoseModifiers([valid], binding: duplicate) } + var zero = try chain(lengths: [0, 1]) + #expect(throws: AnimationPoseModifierError.invalidChain("ik")) { + try zero.setPoseModifiers([valid], binding: AnimationRigBinding(rig: zero.rig, sourceAsset: "Robot.glb")) + } + } + + @Test + func nearOppositeDirectionsAndNearRootFoldingRetainSmallTargetDeviations() throws { + for target: Vector3 in [[-2, 0.002, 0], [0.001, 0, 0]] { + var player = try chain() + try player.setPoseModifiers([.init(id: "arm", kind: .twoBone, joints: ["node-0", "node-1", "node-2"], target: target)], + binding: AnimationRigBinding(rig: player.rig, sourceAsset: "Robot.glb")) + #expect((player.globalTransforms[2].origin - target).length < 0.0002) + #expect((player.modifierDiagnostics.first?.reachError ?? 1) < 0.0002) + } + } + + @Test + func pausedTargetInputsReplaceOnlyTheFinalPoseAndMissingTargetsRestoreBase() throws { + var player = try chain() + let configuration = AnimationPoseModifier(id: "ik", kind: .twoBone, joints: ["node-0", "node-1", "node-2"], target: [2, 0, 0]) + try player.setPoseModifiers([configuration], binding: AnimationRigBinding(rig: player.rig, sourceAsset: "Robot.glb")) + let generation = player.basePoseGeneration + player.isPlaying = false + player.finalizePoseModifiers(inputs: ["ik": .init(target: [0, 2, 0])]) + #expect((player.globalTransforms[2].origin - Vector3(0, 2, 0)).length < 0.0001) + #expect(player.basePoseGeneration == generation) + #expect(player.time == 0) + #expect(player.poseModifiers == [configuration]) + player.finalizePoseModifiers(inputs: ["ik": .init(target: nil)]) + #expect(player.poses == player.basePoses) + #expect(player.modifierDiagnostics.first?.status == .invalidTarget) + player.finalizePoseModifiers(inputs: ["ik": .init(target: [0, 2, 0], supportsTransform: false)]) + #expect(player.poses == player.basePoses) + #expect(player.modifierDiagnostics.first?.status == .unsupportedTransform) + } + + @Test + func reversedAndRotatedBoneAxesUseActualParentFrames() throws { + for axis: Vector3 in [[-1, 0, 0], [0, 1, 0], [0, 0, 1]] { + let root = SkeletalJointPose(translation: [3, 2, -1], rotation: Quat(axis: [1, 1, 0], angle: 1.1), scale: Vector3(2)) + let child = SkeletalJointPose(translation: axis) + let rig = try SkeletalRig(nodes: [ + .init(parentIndex: nil, restPose: root, restMatrix: root.matrix), + .init(parentIndex: 0, restPose: child, restMatrix: child.matrix), + .init(parentIndex: 1, restPose: child, restMatrix: child.matrix) + ]) + var player = try SkeletalAnimationPlayer(rig: rig, clips: []) + let target = root.translation + Vector3(1, -1, 1) + try player.setPoseModifiers([.init(kind: .twoBone, joints: ["node-0", "node-1", "node-2"], target: target)], + binding: AnimationRigBinding(rig: rig, sourceAsset: "Robot.glb")) + #expect((player.globalTransforms[2].origin - target).length < 0.0001) + } + } + + private func chain(lengths: [Float] = [1, 1]) throws -> SkeletalAnimationPlayer { + let poses = [SkeletalJointPose(), SkeletalJointPose(translation: [lengths[0], 0, 0]), SkeletalJointPose(translation: [lengths[1], 0, 0])] + let rig = try SkeletalRig(nodes: poses.enumerated().map { index, pose in + .init(parentIndex: index == 0 ? nil : index - 1, restPose: pose, restMatrix: pose.matrix) + }) + return try SkeletalAnimationPlayer(rig: rig, clips: []) + } + + @Test + func longFabrikChainPreservesExportedAxesAndRepeatsFromTheSameBase() throws { + let poses = [SkeletalJointPose(translation: [2, -1, 3], rotation: Quat(axis: [1, 2, 3], angle: 0.8), scale: Vector3(1.5))] + + ([Vector3(0, 1, 0), Vector3(0, 0, -0.7), Vector3(-0.8, 0, 0), Vector3(0, 0.6, 0), Vector3(0, 0, 0.9)]).map { + SkeletalJointPose(translation: $0, rotation: Quat(axis: [1, 1, 0], angle: 0.2)) + } + let rig = try SkeletalRig(nodes: poses.enumerated().map { index, pose in + .init(parentIndex: index == 0 ? nil : index - 1, restPose: pose, restMatrix: pose.matrix) + }) + var player = try SkeletalAnimationPlayer(rig: rig, clips: []) + let base = player.basePoses, globals = player.globalTransforms + let lengths = (1.. 0.01) + } +} diff --git a/Tests/AdaAssetsTests/AnimationLibraryResourceTests.swift b/Tests/AdaAssetsTests/AnimationLibraryResourceTests.swift new file mode 100644 index 000000000..04ef9b8b0 --- /dev/null +++ b/Tests/AdaAssetsTests/AnimationLibraryResourceTests.swift @@ -0,0 +1,64 @@ +import AdaAnimation +@testable import AdaAssets +import Foundation +import Testing + +@Suite +struct AnimationLibraryResourceTests { + @Test + func persistsPortableJSONThroughProductionAssetCodecs() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AnimationLibrary-\(UUID())", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let path = root.appendingPathComponent("hero.animlib") + let meta = AssetMeta(filePath: path, queryParams: []) + let library = AnimationLibrary(id: "hero", binding: .init(sourceAsset: "hero.glb", nodes: []), clips: [ + .init(id: "idle", name: "Idle", duration: 2) + ]) + let resource = try AnimationLibraryResource(library: library) + let encoder = TextAssetEncoder(meta: meta) + try await resource.encodeContents(with: encoder) + let data = try #require(encoder.encodedData) + try data.write(to: path) + #expect(try JSONSerialization.jsonObject(with: data) is [String: Any]) + let loaded = try await AnimationLibraryResource(from: TextAssetDecoder(meta: meta, data: Data(contentsOf: path))) + #expect(loaded.library == library) + #expect(AnimationLibraryResource.extensions() == ["animlib"]) + } + + @Test + func resourceRejectsInvalidDecodedLibrary() async throws { + let meta = AssetMeta(filePath: URL(fileURLWithPath: "/hero.animlib"), queryParams: []) + let invalid = AnimationLibrary(binding: .init(sourceAsset: "hero.glb", nodes: []), version: 99) + let data = try JSONEncoder().encode(invalid) + await #expect(throws: AnimationAuthoringError.self) { + _ = try await AnimationLibraryResource(from: TextAssetDecoder(meta: meta, data: data)) + } + } + + @Test(arguments: [-1, 0, 1]) + func publicImportAdapterRejectsMissingSamplerIndices(_ index: Int) { + let animation = GLTFImportResult.Animation(name: "Malformed", samplers: [], channels: [ + .init(samplerIndex: index, nodeIndex: 0, path: .translation) + ]) + #expect(throws: SkeletalAnimationError.invalidTrack) { + _ = try GLTFAnimationAdapter.makeClips([animation]) + } + } + + @Test + func authoringImportRestrictsDependenciesAndTotalBytes() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("AnimationImport-\(UUID())", isDirectory: true) + let assets = root.appendingPathComponent("Assets", isDirectory: true) + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data([0]).write(to: root.appendingPathComponent("outside.bin")) + let escaping = Data(#"{"asset":{"version":"2.0"},"buffers":[{"byteLength":1,"uri":"../outside.bin"}]}"#.utf8) + #expect(throws: NativeGLTFLoader.GLTFError.self) { _ = try NativeGLTFLoader(resourceRoot: assets).load(data: escaping, baseURL: assets) } + try Data(repeating: 0, count: 4096).write(to: assets.appendingPathComponent("large.bin")) + let large = Data(#"{"asset":{"version":"2.0"},"buffers":[{"byteLength":4096,"uri":"large.bin"}]}"#.utf8) + #expect(throws: NativeGLTFLoader.GLTFError.self) { + _ = try NativeGLTFLoader(resourceRoot: assets, maximumTotalBytes: 1024).load(data: large, baseURL: assets) + } + } +} diff --git a/Tests/AdaAssetsTests/AnimationModifierResourceTests.swift b/Tests/AdaAssetsTests/AnimationModifierResourceTests.swift new file mode 100644 index 000000000..45a0338b1 --- /dev/null +++ b/Tests/AdaAssetsTests/AnimationModifierResourceTests.swift @@ -0,0 +1,50 @@ +import AdaAnimation +@testable import AdaAssets +import Foundation +import Math +import Testing + +@Suite +struct AnimationModifierResourceTests { + @Test + func portableStackRoundTripsThroughProductionAssetCodecs() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AnimationIK-\(UUID())", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("arm.animmod") + let meta = AssetMeta(filePath: path, queryParams: []) + let stack = try fixture() + let resource = try AnimationModifierResource(stack: stack) + let encoder = TextAssetEncoder(meta: meta) + try await resource.encodeContents(with: encoder) + let bytes = try #require(encoder.encodedData) + try bytes.write(to: path) + let loaded = try await AnimationModifierResource(from: TextAssetDecoder(meta: meta, data: Data(contentsOf: path))) + #expect(loaded.stack == stack) + #expect(AnimationModifierResource.extensions() == ["animmod"]) + #expect(try AnimationModifierStack.decode(stack.encodedData()) == stack) + } + + @Test + func invalidVersionsDuplicateIDsAndOversizedStacksFailBeforeInstallation() throws { + var stack = try fixture() + stack.version = 99 + #expect(throws: AnimationPoseModifierError.self) { _ = try AnimationModifierStack.decode(JSONEncoder().encode(stack)) } + stack.version = 1 + stack.modifiers.append(stack.modifiers[0]) + #expect(throws: AnimationPoseModifierError.self) { _ = try AnimationModifierResource(stack: stack) } + #expect(throws: AnimationPoseModifierError.limitExceeded) { + _ = try AnimationModifierStack.decode(Data(repeating: 32, count: AnimationModifierStack.maximumEncodedBytes + 1)) + } + } + + private func fixture() throws -> AnimationModifierStack { + let poses = [SkeletalJointPose(), SkeletalJointPose(translation: [1, 0, 0]), SkeletalJointPose(translation: [1, 0, 0])] + let rig = try SkeletalRig(nodes: poses.indices.map { + .init(parentIndex: $0 == 0 ? nil : $0 - 1, restPose: poses[$0], restMatrix: poses[$0].matrix) + }) + return AnimationModifierStack(id: "arm", binding: AnimationRigBinding(rig: rig, sourceAsset: "Robot.glb"), modifiers: [ + .init(id: "elbow", kind: .twoBone, joints: ["node-0", "node-1", "node-2"], target: [1, 1, 0]) + ]) + } +} diff --git a/Tests/AdaSceneTests/SkeletalIKPlaybackTests.swift b/Tests/AdaSceneTests/SkeletalIKPlaybackTests.swift new file mode 100644 index 000000000..980dc6edc --- /dev/null +++ b/Tests/AdaSceneTests/SkeletalIKPlaybackTests.swift @@ -0,0 +1,91 @@ +import AdaAnimation +import AdaApp +import AdaAssets +import AdaECS +@testable import AdaScene +import AdaTransform +import Math +import Testing + +@MainActor +@Suite(.serialized) +struct SkeletalIKPlaybackTests { + @Test(arguments: [false, true]) + func pausedMovingTargetsPublishTheSamePoseToNodesAndSkin(withLOD: Bool) async throws { + let world = World() + let app = AppWorlds(main: world) + app.addPlugin(TransformPlugin()).addPlugin(Model3DPlugin()) + let fixture = try makeInstance(world: world, namespace: "first", withLOD: withLOD) + try await app.build() + for target: Vector3 in [[12, 2, 0], [10, 4, 0], [8, 2, 0]] { + fixture.marker.components[Transform.self]?.position = target + await world.runScheduler(.postUpdate, deltaTime: 0.01) + let animation = try #require(fixture.root.components[ModelAnimation3DComponent.self]) + let endpoint = animation.player.globalTransforms[2].origin + #expect((endpoint - (target - Vector3(10, 0, 0)) / 2).length < 0.0002) + let palette = try #require(fixture.mesh.components[SkinnedMesh3DComponent.self]) + #expect((palette.matrices[0].origin - endpoint).length < 0.0001) + let node = try #require(fixture.end.components[GlobalTransform.self]) + #expect((node.matrix.origin - target).length < 0.0003) + #expect(animation.player.time == 0) + var player = animation.player + #expect(player.drainGraphEvents().isEmpty) + if withLOD { #expect(animation.lodState.target == animation.player.basePoses) } + } + world.removeEntity(fixture.marker.id) + await world.runScheduler(.postUpdate, deltaTime: 0) + let animation = try #require(fixture.root.components[ModelAnimation3DComponent.self]) + #expect(animation.player.modifierDiagnostics.first?.status == .invalidTarget) + #expect(animation.player.poses == animation.player.basePoses) + } + + @Test + func targetsUnderAnotherAnimatedRigAreRejectedAndInstancesRemainIndependent() async throws { + let world = World() + let app = AppWorlds(main: world) + app.addPlugin(TransformPlugin()).addPlugin(Model3DPlugin()) + let first = try makeInstance(world: world, namespace: "first", withLOD: false) + let second = try makeInstance(world: world, namespace: "second", withLOD: false) + second.marker.components[Transform.self]?.position = [10, 4, 0] + try await app.build() + await world.runScheduler(.postUpdate, deltaTime: 0) + let firstPose = try #require(first.root.components[ModelAnimation3DComponent.self]?.player.poses) + let secondPose = try #require(second.root.components[ModelAnimation3DComponent.self]?.player.poses) + #expect(firstPose != secondPose) + second.root.addChild(first.marker) + await world.runScheduler(.postUpdate, deltaTime: 0) + let rejected = try #require(first.root.components[ModelAnimation3DComponent.self]) + #expect(rejected.player.modifierDiagnostics.first?.status == .invalidTarget) + #expect(rejected.player.poses == rejected.player.basePoses) + #expect(second.root.components[ModelAnimation3DComponent.self]?.player.poses == secondPose) + } + + private func makeInstance(world: World, namespace: String, withLOD: Bool) throws -> (root: Entity, end: Entity, mesh: Entity, marker: Entity) { + let poses = [SkeletalJointPose(), SkeletalJointPose(translation: [1, 0, 0]), + SkeletalJointPose(translation: [1, 0, 0]), SkeletalJointPose()] + let parents: [Int?] = [nil, 0, 1, nil] + let rig = try SkeletalRig(nodes: poses.indices.map { + .init(parentIndex: parents[$0], restPose: poses[$0], restMatrix: poses[$0].matrix) + }) + var player = try SkeletalAnimationPlayer(rig: rig, clips: []) + try player.setPoseModifiers([.init(id: "arm", kind: .twoBone, joints: ["node-0", "node-1", "node-2"], target: [2, 0, 0])], + binding: AnimationRigBinding(rig: rig, sourceAsset: "Robot.glb")) + var animation = ModelAnimation3DComponent(player: player) + animation.modifierNamespace = namespace + animation.modifierBindings = ["arm": .init(target: .init(entity: "target"))] + if withLOD { animation.evaluationLOD = AnimationLOD3DSettings(offscreenRate: 1) } + let root = world.spawn("Model") { Transform(scale: Vector3(2), position: [10, 0, 0]); animation } + let first = world.spawn("Root") { Transform(); ModelNode3DComponent(modelRoot: root.id, nodeIndex: 0) } + let middle = world.spawn("Middle") { Transform(position: [1, 0, 0]); ModelNode3DComponent(modelRoot: root.id, nodeIndex: 1) } + let end = world.spawn("End") { Transform(position: [1, 0, 0]); ModelNode3DComponent(modelRoot: root.id, nodeIndex: 2) } + root.addChild(first); first.addChild(middle); middle.addChild(end) + let mesh = world.spawn("Mesh") { + Transform() + SkinnedMesh3DComponent(modelRoot: root.id, meshNodeIndex: 3, + skin: .init(name: nil, joints: [2], skeletonRootIndex: nil, inverseBindMatrices: [.identity])) + } + root.addChild(mesh) + let marker = world.spawn("Target") { Transform(position: [12, 2, 0]); AnimationSceneIdentity3D(namespace: namespace, id: "target") } + return (root, end, mesh, marker) + } +} diff --git a/Tests/AdaSceneTests/SkeletalPlaybackTests.swift b/Tests/AdaSceneTests/SkeletalPlaybackTests.swift index a0eb3fa6a..5b7893138 100644 --- a/Tests/AdaSceneTests/SkeletalPlaybackTests.swift +++ b/Tests/AdaSceneTests/SkeletalPlaybackTests.swift @@ -12,6 +12,42 @@ import Testing @MainActor @Suite(.serialized) struct SkeletalPlaybackTests { + @Test func failedLibraryReplacementPreservesTheLiveModelAndSkin() async throws { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + try RenderEngine.setupRenderEngine() + } + GLTFLoaderResolver.shared.setLoader(NativeGLTFLoader()) + let tests = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + let modelURL = tests.appendingPathComponent("AdaEngineTests/Fixtures/SkeletalGardenAssets/GardenRobot.glb") + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent("BadLibrary-\(UUID()).animlib") + defer { try? FileManager.default.removeItem(at: temporary) } + let incompatible = AnimationLibrary(binding: .init(sourceAsset: "Robot.glb", nodes: []), clips: [.init(name: "Invalid rig", duration: 1)]) + try incompatible.encodedData().write(to: temporary) + let world = World() + let app = AppWorlds(main: world) + app.addPlugin(TransformPlugin()).addPlugin(Model3DPlugin()) + let owner = world.spawn("Owner") { Transform(); Model3DSource(source: modelURL.path, animation: "Walk", autoplay: true) } + try await app.build() + for _ in 0..<200 where owner.components[Model3DSourceState.self]?.rootEntityID == nil { + await world.runScheduler(.preUpdate, deltaTime: 0.01) + await world.runScheduler(.postUpdate, deltaTime: 0.01) + try await Task.sleep(for: .milliseconds(10)) + } + let originalRoot = try #require(owner.components[Model3DSourceState.self]?.rootEntityID) + let originalChildren = owner.children.map(\.id) + owner.components[Model3DSource.self]?.animationLibraries = [temporary.path] + for _ in 0..<200 where owner.components[Model3DSourceState.self]?.error == nil { + await world.runScheduler(.preUpdate, deltaTime: 0.01) + await world.runScheduler(.postUpdate, deltaTime: 0.01) + try await Task.sleep(for: .milliseconds(10)) + } + #expect(owner.components[Model3DSourceState.self]?.error != nil) + #expect(owner.components[Model3DSourceState.self]?.rootEntityID == originalRoot) + #expect(owner.children.map(\.id) == originalChildren) + #expect(world.getEntityByID(originalRoot)?.components[ModelAnimation3DComponent.self] != nil) + } + @Test func sceneReplacementReservesExclusiveWorldAccess() { let system = SceneNavigationSystem(world: World()) diff --git a/Tests/AdaScriptingTests/AnimationGraphToolRuntimeTests.swift b/Tests/AdaScriptingTests/AnimationGraphToolRuntimeTests.swift new file mode 100644 index 000000000..6cc3b7759 --- /dev/null +++ b/Tests/AdaScriptingTests/AnimationGraphToolRuntimeTests.swift @@ -0,0 +1,53 @@ +import AdaScriptCompilerCore +import AdaScripting +import Foundation +import Testing + +extension AnimationToolRuntimeTests { + @Test func graphRequestsCarryDetachedPoseGraphDataFromRealTools() throws { + let runtime = try graphRuntime(permissions: ["editor.animations.read", "editor.animations.write"]) + defer { runtime.deactivate() } + try runtime.activate() + let result = try? runtime.perform("configure", inputs: [:], scene: nil) + #expect(result != nil) + let jobs = runtime.takeAnimationJobs() + #expect(jobs.count == 1) + guard let job = jobs.first else { return } + let encoded = try JSONEncoder().encode(job.request) + let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + #expect((object["graph"] as? [String: Any])?["root"] as? String == "Output") + } + + @Test(arguments: [("graphApply", "editor.animations.write"), ("graphPreview", "editor.animations.preview"), ("graphBind", "editor.documents.write")]) + func graphActionsEnforceTheirSpecificHostPermissions(_ action: String, _ permission: String) throws { + let runtime = try graphRuntime(permissions: ["editor.animations.read"]) + defer { runtime.deactivate() } + try runtime.activate() + do { + _ = try runtime.perform("request", inputs: ["operation": .string(action)], scene: nil) + Issue.record("A graph action must require its specific permission.") + } catch { + #expect(error.localizedDescription.contains(permission)) + } + #expect(runtime.takeAnimationJobs().isEmpty) + } + + private func graphRuntime(permissions: [String]) throws -> AdaScriptToolRuntime { + let names = permissions.map { "\"\($0)\"" }.joined(separator: ",") + let source = AdaScriptSource(path: "GraphTool.ada", source: """ + @tool(id: "test.graph", api: 2, platforms: ["macos", "windows"], permissions: [\(names)]) + class GraphTool { + @export var job = ""; + @export var operation = "graphApply"; + func activate(editor) { editor.addPanel(id: "main", title: "Graph", location: "right", ui: "Panel.ui"); } + func configure(editor) { + job = editor.animations.submit(["action": "graphApply", "path": "Assets/Robot.glb", "expectedRevision": "graph-revision", + "graph": ["root": "Output", "nodes": []]]); + } + func request(editor) { job = editor.animations.submit(["action": operation, "path": "Assets/Robot.glb"]); } + } + """) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + return try .init(sources: [source], schema: schema, grantedPermissions: schema.permissions) + } +} diff --git a/Tests/AdaScriptingTests/AnimationModifierToolRuntimeTests.swift b/Tests/AdaScriptingTests/AnimationModifierToolRuntimeTests.swift new file mode 100644 index 000000000..248b65e1c --- /dev/null +++ b/Tests/AdaScriptingTests/AnimationModifierToolRuntimeTests.swift @@ -0,0 +1,53 @@ +import AdaScriptCompilerCore +import AdaScripting +import Foundation +import Testing + +extension AnimationToolRuntimeTests { + @Test(arguments: [("modifierApply", "editor.animations.write"), ("modifierPreview", "editor.animations.preview"), ("modifierBind", "editor.documents.write")]) + func ikActionsEnforceTheirSpecificHostPermissions(_ action: String, _ permission: String) throws { + let runtime = try modifierRuntime(permissions: ["editor.animations.read"]) + defer { runtime.deactivate() } + try runtime.activate() + do { + _ = try runtime.perform("request", inputs: ["operation": .string(action)], scene: nil) + Issue.record("IK actions must require their specific permissions.") + } catch { #expect(error.localizedDescription.contains(permission)) } + #expect(runtime.takeAnimationJobs().isEmpty) + } + + @Test + func realToolsCarryTypedIKCommandsWithoutRetainingSceneObjects() throws { + let runtime = try modifierRuntime(permissions: ["editor.animations.read", "editor.animations.write"]) + defer { runtime.deactivate() } + try runtime.activate() + _ = try runtime.perform("configure", inputs: [:], scene: nil) + let jobs = runtime.takeAnimationJobs() + #expect(jobs.count == 1) + let request = try #require(jobs.first).request + #expect(request.action.rawValue == "modifierApply") + let command = try #require(request.modifierOperations?.first) + #expect(command.modifier?.joints == ["shoulder", "elbow", "wrist"]) + #expect(command.modifier?.influence == 0.5) + } + + private func modifierRuntime(permissions: [String]) throws -> AdaScriptToolRuntime { + let names = permissions.map { "\"\($0)\"" }.joined(separator: ",") + let source = AdaScriptSource(path: "IKTool.ada", source: """ + @tool(id: "test.ik", api: 2, platforms: ["macos", "windows"], permissions: [\(names)]) + class IKTool { + @export var operation = "modifierInspect"; + func activate(editor) { editor.addPanel(id: "main", title: "IK", location: "right", ui: "Panel.ui"); } + func request(editor) { editor.animations.submit(["action": operation, "path": "Assets/Arm.animmod"]); } + func configure(editor) { + editor.animations.submit(["action": "modifierApply", "path": "Assets/Arm.animmod", "expectedRevision": "current", + "modifierOperations": [["kind": "add", "modifier": ["id": "arm", "kind": "twoBone", + "joints": ["shoulder", "elbow", "wrist"], "target": ["x": 1, "y": 1, "z": 0], + "enabled": true, "influence": 0.5, "iterations": 16]]]]); + } + } + """) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + return try .init(sources: [source], schema: schema, grantedPermissions: schema.permissions) + } +} diff --git a/Tests/AdaScriptingTests/AnimationToolRuntimeTests.swift b/Tests/AdaScriptingTests/AnimationToolRuntimeTests.swift new file mode 100644 index 000000000..744a94aba --- /dev/null +++ b/Tests/AdaScriptingTests/AnimationToolRuntimeTests.swift @@ -0,0 +1,116 @@ +import AdaScriptCompilerCore +import AdaScripting +import Testing + +@Suite("Animation Tool runtime", .serialized) +@MainActor +struct AnimationToolRuntimeTests { + @Test func apiTwoStagesDetachedAnimationJobsFromRealScriptMaps() throws { + let source = AdaScriptSource(path: "AnimationTool.ada", source: """ + @tool(id: "test.animations", api: 2, platforms: ["macos", "windows"], permissions: ["editor.animations.read"]) + class AnimationTool { + @export var job = ""; + @export var status = ""; + func activate(editor) { editor.addPanel(id: "main", title: "Animation", location: "right", ui: "Panel.ui"); } + func inspect(editor) { + job = editor.animations.submit(["action": "inspect", "path": "Assets/Test.animlib"]); + status = editor.animations.poll(job)["status"]; + } + } + """) + let schemas = try? AdaScriptSchemaParser.parseTools(sources: [source]) + #expect(schemas?.count == 1) + guard let schema = schemas?.first else { return } + let runtime = try? AdaScriptToolRuntime(sources: [source], schema: schema, grantedPermissions: schema.permissions) + #expect(runtime != nil) + guard let runtime else { return } + defer { runtime.deactivate() } + try runtime.activate() + #expect(try runtime.perform("inspect", inputs: [:], scene: nil).isEmpty) + #expect(runtime.values["job"] != .string("")) + #expect(runtime.values["status"] == .string("pending")) + } + + @Test func completedResultsAreDetachedConsumedAndRetiredWithTheVM() throws { + let runtime = try makeRuntime() + defer { runtime.deactivate() } + try runtime.activate() + _ = try runtime.perform("inspect", inputs: [:], scene: nil) + let job = try #require(runtime.takeAnimationJobs().first) + #expect(job.request.path == "Assets/Test.animlib") + #expect(job.request.action == .inspect) + #expect(runtime.takeAnimationJobs().isEmpty) + try runtime.completeAnimationJob(job.id, responseJSON: #"{"status":"completed","result":{"revision":"r1"}}"#) + _ = try runtime.perform("poll", inputs: [:], scene: nil) + #expect(runtime.values["status"] == .string("completed")) + #expect(runtime.values["revision"] == .string("r1")) + #expect(throws: AdaScriptError.self) { _ = try runtime.perform("poll", inputs: [:], scene: nil) } + #expect(!runtime.isFailed) + _ = try runtime.perform("inspect", inputs: [:], scene: nil) + runtime.deactivate() + #expect(runtime.takeAnimationJobs().isEmpty) + #expect(throws: AdaScriptError.self) { _ = try runtime.perform("inspect", inputs: [:], scene: nil) } + } + + @Test(arguments: ["write", "preview", "overflow", "failAfterSubmit"]) + func rejectedOrFailedCallbacksPublishNoJobs(_ action: String) throws { + let runtime = try makeRuntime() + defer { runtime.deactivate() } + try runtime.activate() + #expect(throws: AdaScriptError.self) { _ = try runtime.perform(action, inputs: [:], scene: nil) } + #expect(runtime.takeAnimationJobs().isEmpty) + } + + @Test func retainedJobsHaveABoundedLifetimeAndInputBudget() throws { + let runtime = try makeRuntime() + defer { runtime.deactivate() } + try runtime.activate() + for _ in 0..<16 { + _ = try runtime.perform("inspect", inputs: [:], scene: nil) + #expect(runtime.takeAnimationJobs().count == 1) + } + #expect(throws: AdaScriptError.self) { _ = try runtime.perform("inspect", inputs: [:], scene: nil) } + #expect(runtime.takeAnimationJobs().isEmpty) + let inputRuntime = try makeRuntime() + defer { inputRuntime.deactivate() } + try inputRuntime.activate() + #expect(throws: AdaScriptError.self) { + _ = try inputRuntime.perform("large", inputs: ["payload": .string(String(repeating: "x", count: 65_536))], scene: nil) + } + } + + @Test func oldDocumentGrantsDoNotConferAnimationAccess() throws { + let runtime = try makeRuntime(api: 1, permissions: ["editor.documents.read", "editor.documents.write"]) + defer { runtime.deactivate() } + try runtime.activate() + #expect(throws: AdaScriptError.self) { _ = try runtime.perform("inspect", inputs: [:], scene: nil) } + #expect(runtime.takeAnimationJobs().isEmpty) + } + + private func makeRuntime(api: Int = 2, permissions: [String] = ["editor.animations.read"]) throws -> AdaScriptToolRuntime { + let names = permissions.map { "\"\($0)\"" }.joined(separator: ",") + let source = AdaScriptSource(path: "AnimationTool.ada", source: """ + @tool(id: "test.jobs", api: \(api), platforms: ["macos", "windows"], permissions: [\(names)]) + class AnimationTool { + @export var job = ""; + @export var status = ""; + @export var revision = ""; + @export var payload = ""; + func activate(editor) { editor.addPanel(id: "main", title: "Animation", location: "right", ui: "Panel.ui"); } + func inspect(editor) { job = editor.animations.submit(["action": "inspect", "path": "Assets/Test.animlib"]); } + func poll(editor) { + var result = editor.animations.poll(job); + if (result["status"] != null) { status = result["status"]; } + if (result["result"] != null) { revision = result["result"]["revision"]; } + } + func write(editor) { job = editor.animations.submit(["action": "save", "path": "Assets/Test.animlib"]); } + func preview(editor) { job = editor.animations.submit(["action": "preview", "path": "Assets/Test.animlib"]); } + func overflow(editor) { var i = 0; while (i < 9) { inspect(editor); i += 1; } } + func failAfterSubmit(editor) { inspect(editor); editor.createEntity(name: "Denied", x: 0.0, y: 0.0); } + func large(editor) { job = editor.animations.submit(["action": "inspect", "path": payload]); } + } + """) + let schema = try #require(AdaScriptSchemaParser.parseTools(sources: [source]).first) + return try .init(sources: [source], schema: schema, grantedPermissions: schema.permissions) + } +}