sunrize
Version:
Sunrize — A Multi-Platform X3D Editor
1,510 lines (1,145 loc) • 95.8 kB
JavaScript
"use strict";
const
$ = require ("jquery"),
electron = require ("electron"),
capitalize = require ("capitalize"),
X3D = require ("../X3D"),
Interface = require ("../Application/Interface"),
Splitter = require ("../Controls/Splitter"),
NodeList = require ("./NodeList"),
MemberList = require ("./AnimationMemberList"),
Editor = require ("../Undo/Editor"),
_ = require ("../Application/GetText");
require ("../Bits/Validate");
module .exports = class AnimationEditor extends Interface
{
constructor (element)
{
super (`Sunrize.AnimationEditor.${element .attr ("id")}.`);
this .animationEditor = element;
this .verticalSplitter = $("<div></div>")
.attr ("id", "animation-editor-content")
.addClass (["animation-editor-content", "vertical-splitter"])
.appendTo (this .animationEditor)
.on ("mouseleave", () => this .requestDrawTimeline ());
this .verticalSplitterLeft = $("<div></div>")
.addClass ("vertical-splitter-left")
.css ("width", "30%")
.appendTo (this .verticalSplitter)
.on ("mouseleave", () => this .requestDrawTimeline ());
this .timelineElement = $("<div></div>")
.attr ("tabindex", 0)
.addClass (["timeline", "vertical-splitter-right"])
.css ("width", "70%")
.on ("mouseleave", () => this .clearPointer ())
.on ("mousedown", event => this .on_mousedown (event))
.on ("mouseup", event => this .on_mouseup (event))
.on ("mousemove", event => this .on_mousemove (event))
.on ("wheel", event => this .on_wheel (event))
.on ("keydown", event => this .on_keydown (event))
.appendTo (this .verticalSplitter);
this .scrollbarElement = $("<div></div>")
.addClass ("scrollbar")
.css ("width", "100%")
.on ("mousedown", event => this .on_mousedown_scrollbar (event))
.on ("mouseup", event => this .on_mouseup_scrollbar (event))
.on ("mousemove", event => this .on_mousemove_scrollbar (event))
.appendTo (this .timelineElement);
this .vSplitter = new Splitter (this .verticalSplitter, "vertical");
// Toolbar
this .toolbar = $("<div></div>")
.attr ("id", "animation-editor-toolbar")
.addClass (["animation-editor-toolbar", "toolbar", "horizontal-toolbar"])
.appendTo (this .animationEditor);
this .createAnimationIcon = $("<span></span>")
.addClass (["material-symbols-outlined", "disabled"])
.attr ("title", _("Create animation."))
.text ("animation")
.appendTo (this .toolbar)
.on ("click", () => this .createAnimation ());
$("<span></span>") .addClass ("separator") .appendTo (this .toolbar);
this .addMembersIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Add member(s) to animation."))
.text ("add")
.appendTo (this .toolbar)
.on ("click", () => this .addMembers ());
$("<span></span>") .addClass ("separator") .appendTo (this .toolbar);
this .cutFrameIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Cut selected keyframes."))
.text ("content_cut")
.appendTo (this .toolbar)
.on ("click", () => this .cutKeyframes ());
this .copyFrameIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Copy selected keyframes."))
.text ("content_copy")
.appendTo (this .toolbar)
.on ("click", () => this .copyKeyframes ());
this .pasteFrameIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Paste keyframes at current frame."))
.text ("content_paste")
.appendTo (this .toolbar)
.on ("click", () => this .pasteKeyframes ());
$("<span></span>") .addClass ("separator") .appendTo (this .toolbar);
this .firstFrameIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Go to first frame."))
.text ("first_page")
.appendTo (this .toolbar)
.on ("click", () => this .firstFrame ());
this .toggleAnimationIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Start animation."))
.text ("play_arrow")
.appendTo (this .toolbar)
.on ("click", () => this .toggleAnimation ());
this .lastFrameIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Go to last frame."))
.text ("last_page")
.appendTo (this .toolbar)
.on ("click", () => this .lastFrame ());
this .loopIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Loop animation."))
.text ("loop")
.appendTo (this .toolbar)
.on ("click", () => this .toggleLoop ());
this .frameInput = $("<input></input>")
.addClass ("input")
.attr ("type", "number")
.attr ("step", 1)
.attr ("min", 0)
.attr ("max", 0)
.attr ("title", _("Current frame."))
.css ("width", "70px")
.appendTo (this .toolbar)
.on ("change input", () => this .setCurrentFrame (this .getCurrentFrame ()));
this .propertiesIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Edit animation properties."))
.text ("access_time")
.appendTo (this .toolbar)
.on ("click", () => this .showProperties ());
$("<span></span>") .addClass ("separator") .appendTo (this .toolbar);
this .keyTypeElement = $("<select></select>")
.addClass ("select")
.attr ("title", _("Select keyframe type."))
.append ($("<option></option>") .text ("CONSTANT"))
.append ($("<option></option>") .text ("LINEAR") .attr ("selected", ""))
.append ($("<option></option>") .text ("SPLINE"))
.append ($("<option></option>") .text ("SPLIT"))
.append ($("<option></option>") .text ("MIXED") .hide ())
.appendTo (this .toolbar)
.on ("change", () => this .setKeyType ());
this .timeElement = $("<span></span>")
.addClass (["text", "right"])
.attr ("title", _("Current frame time (hours:minutes:seconds:frames)."))
.css ("top", "7.5px")
.css ("margin-right", "6px")
.text (this .formatFrames (0, 10))
.appendTo (this .toolbar);
// Navigation toolbar
this .navigation = $("<div></div>")
.attr ("id", "animation-editor-navigation")
.addClass (["animation-editor-navigation", "toolbar", "vertical-toolbar"])
.appendTo (this .animationEditor);
this .zoomOutIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Zoom timeline out."))
.css ("transform", "scale(1.4)")
.css ("margin-bottom", "15px")
.text ("zoom_out")
.appendTo (this .navigation)
.on ("click", () => this .zoomOut ());
this .zoomInIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Zoom timeline in."))
.css ("transform", "scale(1.4)")
.css ("margin-bottom", "15px")
.text ("zoom_in")
.appendTo (this .navigation)
.on ("click", () => this .zoomIn ());
this .zoomFitIcon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Zoom timeline to fit in window."))
.css ("transform", "scale(1.4)")
.css ("margin-bottom", "15px")
.text ("fit_screen")
.appendTo (this .navigation)
.on ("click", () => this .zoomFit ());
this .zoom100Icon = $("<span></span>")
.addClass ("material-icons")
.attr ("title", _("Default timeline zoom."))
.css ("transform", "scale(1.4)")
.css ("margin-bottom", "15px")
.text ("1x_mobiledata")
.appendTo (this .navigation)
.on ("click", () => this .zoom100 ());
// Animations List
this .nodeListElement = $("<div></div>")
.addClass (["alternating", "node-list"])
.appendTo (this .verticalSplitterLeft);
this .membersListElement = $("<div></div>")
.addClass ("node-list")
.appendTo (this .verticalSplitterLeft)
.on ("scroll mousemove", () => this .drawTimeline ());
this .animationName = $("<input></input>")
.addClass ("node-name")
.attr ("title", _("Rename animation."))
.attr ("placeholder", _("Enter animation name."))
.appendTo (this .verticalSplitterLeft)
.validate (Editor .Id, () =>
{
electron .shell .beep ();
this .highlight ();
})
.on ("keydown", event => this .renameAnimation (event));
// Tracks
this .tracks = $("<canvas></canvas>")
.addClass ("tracks")
.prependTo (this .animationEditor);
this .tracksResizer = new ResizeObserver (() => this .resizeTimeline ());
this .tracksResizer .observe (this .timelineElement [0]);
// Lists
this .memberList = new MemberList (this, this .membersListElement);
this .nodeList = new NodeList (this .nodeListElement,
{
filter: node => this .isAnimation (node),
callback: animation => this .setAnimation (animation),
});
// Selection
const selection = require ("../Application/Selection");
selection .addInterest (this, () => this .setSelection (selection));
this .setSelection (selection);
// Setup
this .setup ();
}
configure ()
{
this .config .file .setDefaultValues ({
scaleKeyframes: true,
keyType: "LINEAR",
});
this .keyTypeElement .val (this .config .file .keyType);
}
colorScheme (shouldUseDarkColors)
{
this .requestDrawTimeline ();
}
isAnimation (node)
{
if (!node .getType () .includes (X3D .X3DConstants .Group))
return false;
if (!node .hasMetaData ("Animation/duration"))
return false;
if (!node ._children .find (node => node .getValue () .getType () .includes (X3D .X3DConstants .TimeSensor)))
return false;
return true;
}
setAnimation (animation)
{
// Remove
this .setPickedKeyframes ([ ]);
this .setSelectedKeyframes ([ ]);
this .setSelectionRange (0, 0);
this .animation ?._children .removeInterest ("updateMemberList", this);
this .animation ?.name_changed .removeInterest ("updateAnimationName", this);
if (this .timeSensor)
{
this .timeSensor ._loop .removeInterest ("set_loop", this);
this .timeSensor ._isActive .removeInterest ("set_active", this);
this .timeSensor ._fraction_changed .removeInterest ("set_fraction", this);
this .timeSensor ._evenLive = false;
this .timeSensor ._range = [0, 0, 1];
this .timeSensor ._resumeTime = 0;
this .timeSensor ._pauseTime = 0;
if (this .timeSensor ._loop .getValue () && this .timeSensor ._isActive .getValue ())
{
this .timeSensor ._stopTime = 0;
this .timeSensor ._startTime = 0;
}
else
{
this .timeSensor ._startTime = 0;
this .timeSensor ._stopTime = 1;
}
for (const interpolator of this .interpolators)
interpolator ._set_fraction = 0;
}
// Set
this .animation = animation;
// Add
this .enableIcons (this .animation);
if (this .animation)
{
// TimeSensor
this .timeSensor = this .animation ._children
.find (node => node .getValue () .getType () .includes (X3D .X3DConstants .TimeSensor)) .getValue ();
this .timeSensor ._loop .addInterest ("set_loop", this);
this .timeSensor ._isActive .addInterest ("set_active", this);
this .timeSensor ._fraction_changed .addInterest ("set_fraction", this);
this .timeSensor ._evenLive = true;
this .timeSensor ._range = [0, 0, 1];
this .set_loop (this .timeSensor ._loop);
this .set_active (this .timeSensor ._isActive);
// Show Member List
this .animation ._children .addInterest ("updateMemberList", this);
this .nodeListElement .hide ();
this .membersListElement .show ();
this .memberList .setAnimation (this .animation, this .timeSensor);
this .updateMemberList ();
// Animation Name
this .animationName .removeAttr ("disabled");
this .animation .name_changed .addInterest ("updateAnimationName", this);
this .updateAnimationName ();
// Timeline
this .frameInput .attr ("max", this .getDuration ());
}
else
{
// Show Animations List
this .updateMemberList ();
this .membersListElement .hide ();
this .nodeListElement .show ();
// Animation Name
this .animationName .val ("");
this .animationName .attr ("disabled", "");
// Timeline
this .frameInput .attr ("max", 0);
}
// Timeline
this .setSelection (require ("../Application/Selection"));
this .zoomFit ();
this .requestDrawTimeline ();
this .browser .nextFrame () .then (() => this .setCurrentFrame (0));
}
enableIcons (enabled)
{
$([
this .addMembersIcon,
this .cutFrameIcon,
this .copyFrameIcon,
this .pasteFrameIcon,
this .firstFrameIcon,
this .toggleAnimationIcon,
this .lastFrameIcon,
this .loopIcon,
this .frameInput,
this .propertiesIcon,
this .keyTypeElement,
this .timeElement,
]
.flatMap (object => [... object]))
.removeClass (enabled ? "disabled" : [ ])
.addClass (enabled ? [ ] : "disabled");
}
setSelection (selection)
{
if (this .isGroupingNodeLike (selection .nodes .at (-1)))
this .createAnimationIcon .removeClass ("disabled");
else
this .createAnimationIcon .addClass ("disabled");
if (!this .animation)
return;
if (selection .nodes .at (-1))
this .addMembersIcon .removeClass ("disabled");
else
this .addMembersIcon .addClass ("disabled");
}
#groupingNodes = new Set ([
X3D .X3DConstants .X3DLayerNode,
X3D .X3DConstants .X3DGroupingNode,
X3D .X3DConstants .ViewpointGroup,
]);
isGroupingNodeLike (node)
{
if (!node)
return true; // X3DScene
if (node .getType () .some (type => this .#groupingNodes .has (type)))
return true;
return false;
}
createAnimation ()
{
Editor .undoManager .beginUndo (_("Add Animation"));
const
selection = require ("../Application/Selection"),
group = selection .nodes .at (-1),
executionContext = group ?.getExecutionContext () ?? this .browser .currentScene,
node = group ?? executionContext,
field = group ?._children ?? executionContext ._rootNodes;
Editor .addComponent (executionContext .getLocalScene (), "Grouping");
Editor .addComponent (executionContext .getLocalScene (), "Time");
const
animation = executionContext .createNode ("Group", false),
timeSensor = executionContext .createNode ("TimeSensor", false);
animation ._children .push (timeSensor);
timeSensor ._description = "New Animation";
timeSensor .setup ();
animation .setup ();
executionContext .addNamedNode (executionContext .getUniqueName ("NewAnimation"), animation);
executionContext .addNamedNode (executionContext .getUniqueName ("NewAnimationTimer"), timeSensor);
animation .setMetaData ("Animation/duration", new X3D .SFInt32 (10));
animation .setMetaData ("Animation/frameRate", new X3D .SFInt32 (10));
Editor .insertValueIntoArray (executionContext, node, field, 0, animation);
Editor .undoManager .endUndo ();
// Wait until NodeList knows animation, to have it restored after reload.
setTimeout (() => this .nodeList .setNode (animation));
}
resizeAnimation (newDuration, newFrameRate, scaleKeyframes)
{
this .config .file .scaleKeyframes = scaleKeyframes;
const
duration = this .getDuration (),
frameRate = this .getFrameRate ();
if (newDuration === duration && newFrameRate === frameRate)
return;
if (newDuration < 1)
return;
Editor .undoManager .beginUndo (_("Resize Animation"));
const
timeSensor = this .timeSensor,
executionContext = timeSensor .getExecutionContext ()
Editor .setFieldValue (executionContext, timeSensor, timeSensor ._cycleInterval, newDuration / newFrameRate);
Editor .setNodeMetaData (this .animation, "Animation/duration", new X3D .SFInt32 (newDuration));
Editor .setNodeMetaData (this .animation, "Animation/frameRate", new X3D .SFInt32 (newFrameRate));
if (scaleKeyframes)
{
const scale = newDuration / duration;
for (const interpolator of this .interpolators)
{
const key = interpolator .getMetaData ("Interpolator/key", new X3D .MFInt32 ())
.map (value => value * scale);
Editor .setNodeMetaData (interpolator, "Interpolator/key", key);
}
this .setCurrentFrame (Math .floor (this .getCurrentFrame () * scale));
}
else
{
// Remove keyframes greater than duration.
for (const interpolator of this .interpolators)
{
const key = interpolator .getMetaData ("Interpolator/key", new X3D .MFInt32 ());
const keyValue = interpolator .getMetaData ("Interpolator/keyValue", new X3D .MFDouble ());
const keyType = interpolator .getMetaData ("Interpolator/keyType", new X3D .MFString ());
const index = X3D .Algorithm .upperBound (key, 0, key .length, newDuration);
Editor .setNodeMetaData (interpolator, "Interpolator/key", key .slice (0, index));
Editor .setNodeMetaData (interpolator, "Interpolator/keyValue", keyValue .slice (0, index));
Editor .setNodeMetaData (interpolator, "Interpolator/keyType", keyType .slice (0, index));
}
this .setCurrentFrame (Math .min (this .getCurrentFrame (), newDuration));
}
this .updateInterpolators ()
this .registerZoomFit ();
Editor .undoManager .endUndo ();
this .frameInput .attr ("max", newDuration);
}
closeAnimation ()
{
this .nodeList .setNode (null);
}
updateAnimationName ()
{
const name = this .animation .getDisplayName ();
this .animationName .val (name);
this .memberList .setAnimationName (name);
}
renameAnimation (event)
{
if (event .key !== "Enter")
return;
const getDescription = (animation) =>
{
return animation .getDisplayName ()
.replace (/(\d+)/g, " $1")
.replace (/([A-Z]+[a-z\d ]+)/g, " $1")
.replace (/([A-Z][a-z]+)/g, " $1")
.replace (/\s+/g, " ")
.trim ();
}
Editor .undoManager .beginUndo (_("Rename Animation"));
const { animation, timeSensor } = this;
const executionContext = animation .getExecutionContext ();
const name = this .animationName .val ();
const oldDescription = getDescription (animation);
Editor .updateNamedNode (executionContext, executionContext .getUniqueName (`${name}`), animation);
Editor .updateNamedNode (executionContext, executionContext .getUniqueName (`${name}Timer`), timeSensor);
// Don't update description if manually set.
if (!timeSensor ._description .getValue () || timeSensor ._description .getValue () === oldDescription)
Editor .setFieldValue (executionContext, timeSensor, timeSensor ._description, getDescription (animation));
for (const interpolator of this .interpolators)
{
const name = this .getInterpolatorName (interpolator);
if (!name)
continue;
Editor .updateNamedNode (executionContext, executionContext .getUniqueName (name), interpolator);
}
Editor .undoManager .endUndo ();
}
// Members Handling
addMembers ()
{
const selection = require ("../Application/Selection");
this .memberList .addNodes (selection .nodes);
this .requestDrawTimeline ();
}
removeMembers (nodes)
{
const
animation = this .animation,
executionContext = animation .getExecutionContext ();
Editor .undoManager .beginUndo (_("Remove Member from »%s«"), animation .getDisplayName ());
for (const node of nodes)
{
const
interpolators = Array .from (node .getFields (), field => this .fields .get (field)),
children = animation ._children .filter (node => !interpolators .includes (node .getValue ()));
Editor .setFieldValue (executionContext, animation, animation ._children, children);
}
this .registerRequestDrawTimeline ();
Editor .undoManager .endUndo ();
// Update member list.
this .updateMembers ();
this .memberList .removeNodes (nodes);
// Prevent losing members without interpolator.
this .#changing = true;
this .browser .nextFrame () .then (() => this .#changing = false);
}
#interpolatorTypes = new Set ([
X3D .X3DConstants .BooleanSequencer,
X3D .X3DConstants .IntegerSequencer,
X3D .X3DConstants .ColorInterpolator,
X3D .X3DConstants .ScalarInterpolator,
X3D .X3DConstants .OrientationInterpolator,
X3D .X3DConstants .PositionInterpolator2D,
X3D .X3DConstants .PositionInterpolator,
X3D .X3DConstants .CoordinateInterpolator2D,
X3D .X3DConstants .CoordinateInterpolator,
X3D .X3DConstants .NormalInterpolator,
]);
members = new Set ();
fields = new Map (); // [field, interpolator]
interpolators = new Set ();
updateMembers ()
{
for (const interpolator of this .interpolators)
interpolator ._value_changed .removeRouteCallback (this);
this .members .clear ();
this .fields .clear ();
this .interpolators .clear ();
for (const node of this .animation ?._children ?? [ ])
{
const interpolator = node .getValue ();
if (!interpolator .getType () .some (type => this .#interpolatorTypes .has (type)))
continue;
this .interpolators .add (interpolator);
for (const route of interpolator ._value_changed .getOutputRoutes ())
{
const node = route .getDestinationNode ();
if (!(node instanceof X3D .X3DNode))
continue;
const field = node .getField (route .getDestinationField ());
this .members .add (node);
this .fields .set (field, interpolator);
}
}
for (const interpolator of this .interpolators)
interpolator ._value_changed .addRouteCallback (this, () => this .updateMemberList ());
}
updateMemberList ()
{
if (this .#changing)
return;
this .updateMembers ();
this .memberList .saveScrollbars ();
this .memberList .clearNodes ();
this .memberList .addNodes (Array .from (this .members));
this .memberList .restoreScrollbars ();
this .requestDrawTimeline ();
}
// Interpolators
#interpolatorTypeNames = new Map ([
[X3D .X3DConstants .SFBool, "BooleanSequencer"],
[X3D .X3DConstants .SFInt32, "IntegerSequencer"],
[X3D .X3DConstants .SFColor, "ColorInterpolator"],
[X3D .X3DConstants .SFFloat, "ScalarInterpolator"],
[X3D .X3DConstants .SFRotation, "OrientationInterpolator"],
[X3D .X3DConstants .SFVec2f, "PositionInterpolator2D"],
[X3D .X3DConstants .SFVec3f, "PositionInterpolator"],
[X3D .X3DConstants .MFVec2f, "CoordinateInterpolator2D"],
[X3D .X3DConstants .MFVec3f, "CoordinateInterpolator"],
// NormalInterpolator
]);
#components = new Map ([
[X3D .X3DConstants .BooleanSequencer, 1],
[X3D .X3DConstants .IntegerSequencer, 1],
[X3D .X3DConstants .ColorInterpolator, 3],
[X3D .X3DConstants .ScalarInterpolator, 1],
[X3D .X3DConstants .OrientationInterpolator, 4],
[X3D .X3DConstants .PositionInterpolator2D, 2],
[X3D .X3DConstants .PositionInterpolator, 3],
[X3D .X3DConstants .CoordinateInterpolator2D, 2],
[X3D .X3DConstants .CoordinateInterpolator, 3],
[X3D .X3DConstants .NormalInterpolator, 3],
]);
getKeyType ()
{
return this .keyTypeElement .val ();
}
setKeyType ()
{
const value = this .getKeyType ();
this .config .file .keyType = value;
// Update interpolators.
const keyframes = this .getSelectedKeyframes ();
if (keyframes .length)
{
Editor .undoManager .beginUndo (_("Change Key Type of Selected Keyframes"));
for (const { field, interpolator, index } of keyframes)
{
const keyType = interpolator .getMetaData ("Interpolator/keyType", new X3D .MFString ());
keyType [index] = this .restrictKeyType (field, interpolator, value);
Editor .setNodeMetaData (interpolator, "Interpolator/keyType", keyType);
}
for (const interpolator of new Set (keyframes .map (({ interpolator }) => interpolator)))
this .updateInterpolator (interpolator);
Editor .undoManager .endUndo ();
}
}
updateKeyType ()
{
if (this .getSelectedKeyframes () .length)
{
const keyTypes = {
CONSTANT: 0,
LINEAR: 0,
SPLINE: 0,
SPLIT: 0,
};
for (const { interpolator, index } of this .getSelectedKeyframes ())
{
const keyType = interpolator .getMetaData ("Interpolator/keyType", new X3D .MFString ());
++ keyTypes [keyType [index]];
}
const keyType = Object .entries (keyTypes)
.find (([key, value]) => value === this .getSelectedKeyframes () .length);
this .keyTypeElement .val (keyType ?.[0] ?? "MIXED");
}
else
{
this .keyTypeElement .val (this .config .file .keyType);
}
}
restrictKeyType (field, interpolator, keyType)
{
switch (field .getType ())
{
case X3D .X3DConstants .SFBool:
case X3D .X3DConstants .SFInt32:
{
return "CONSTANT";
}
case X3D .X3DConstants .SFColor:
{
if (keyType .match (/^(?:SPLINE|SPLIT)$/))
return "LINEAR";
return keyType;
}
case X3D .X3DConstants .MFVec3f:
{
if (keyType .match (/^(?:SPLINE|SPLIT)$/))
{
if (interpolator instanceof X3D .NormalInterpolator)
return "LINEAR";
}
return keyType;
}
default:
{
return keyType;
}
}
}
addKeyframes (keyframes)
{
// Create interpolators.
const count = keyframes .reduce ((p, { field }) => p + !this .fields .has (field), 0);
if (count === 1)
Editor .undoManager .beginUndo (_("Add Interpolator to »%s«"), this .animation .getDisplayName ());
else
Editor .undoManager .beginUndo (_("Add Interpolators to »%s«"), this .animation .getDisplayName ());
for (const { node, field, typeName } of keyframes)
this .getInterpolator (node, field, typeName)
Editor .undoManager .endUndo ();
// Add keyframes.
if (keyframes .length === 1)
Editor .undoManager .beginUndo (_("Add Keyframe to »%s«"), this .animation .getDisplayName ());
else
Editor .undoManager .beginUndo (_("Add Keyframes to »%s«"), this .animation .getDisplayName ());
for (const { node, field, typeName } of keyframes)
this .addKeyframe (node, field, typeName);
Editor .undoManager .endUndo ();
}
addKeyframe (node, field, typeName)
{
Editor .undoManager .beginUndo (_("Add Keyframe to »%s«"), this .animation .getDisplayName ());
const
interpolator = this .getInterpolator (node, field, typeName),
frame = this .getCurrentFrame (),
type = this .restrictKeyType (field, interpolator, this .getKeyType ());
switch (field .getType ())
{
case X3D .X3DConstants .SFBool:
case X3D .X3DConstants .SFInt32:
case X3D .X3DConstants .SFColor:
case X3D .X3DConstants .SFFloat:
case X3D .X3DConstants .SFRotation:
case X3D .X3DConstants .SFVec2f:
case X3D .X3DConstants .SFVec3f:
{
this .addKeyframeToInterpolator (interpolator, frame, type, field);
break;
}
case X3D .X3DConstants .MFVec2f:
case X3D .X3DConstants .MFVec3f:
{
if (field .length === 0)
break;
const keySize = interpolator .getMetaData ("Interpolator/keySize", new X3D .SFInt32 ());
if (keySize .getValue () !== 0 && keySize .getValue () !== field .length)
{
this .showArraySizeErrorDialog (keySize .getValue ());
break;
}
keySize .setValue (field .length);
Editor .setNodeMetaData (interpolator, "Interpolator/keySize", keySize);
const value = Array .from (field) .flatMap (value => Array .from (value));
this .addKeyframeToInterpolator (interpolator, frame, type, value);
break;
}
}
this .updateInterpolator (interpolator);
Editor .undoManager .endUndo ();
}
#changing = false;
getInterpolator (node, field, typeName)
{
if (this .fields .has (field))
return this .fields .get (field);
typeName ??= this .#interpolatorTypeNames .get (field .getType ());
Editor .undoManager .beginUndo (_("Add Interpolator"));
const executionContext = this .animation .getExecutionContext ();
if (typeName .includes ("Sequencer"))
Editor .addComponent (executionContext .getLocalScene (), "EventUtilities");
else if (typeName .includes ("Interpolator"))
Editor .addComponent (executionContext .getLocalScene (), "Interpolation");
const interpolator = executionContext .createNode (typeName, false);
interpolator .setup ();
this .fields .set (field, interpolator);
this .interpolators .add (interpolator);
Editor .appendValueToArray (executionContext, this .animation, this .animation ._children, interpolator);
Editor .addRoute (executionContext, this .timeSensor, "fraction_changed", interpolator, "set_fraction");
Editor .addRoute (executionContext, interpolator, "value_changed", node, field .getName ());
const name = this .getInterpolatorName (interpolator);
Editor .updateNamedNode (executionContext, executionContext .getUniqueName (name), interpolator);
Editor .undoManager .endUndo ();
// Prevent losing members without interpolator.
this .#changing = true;
this .browser .nextFrame () .then (() => this .#changing = false);
return interpolator;
}
getInterpolatorName (interpolator)
{
const route = Array .from (interpolator ._value_changed .getOutputRoutes ()) [0];
if (!route)
return;
const
destinationNode = route .getDestinationNode (),
destinationField = route .getDestinationField (),
nodeName = destinationNode .getDisplayName () || destinationNode .getTypeName (),
fieldName = capitalize (destinationField .replace (/^set_|_changed$/g, ""), true),
typeName = interpolator .getTypeName () .match (/(Sequencer|Interpolator)$/) [1];
return `${nodeName}${fieldName}${typeName}`;
}
removeInterpolator (node, field)
{
const
animation = this .animation,
executionContext = animation .getExecutionContext (),
interpolator = this .fields .get (field),
children = animation ._children .filter (node => node .getValue () !== interpolator);
Editor .undoManager .beginUndo (_("Remove Interpolator from »%s«"), animation .getDisplayName ());
Editor .setFieldValue (executionContext, animation, animation ._children, children);
this .registerRequestDrawTimeline ();
Editor .undoManager .endUndo ();
}
updateInterpolators ()
{
Editor .undoManager .beginUndo (_("Update Interpolators"));
for (const interpolator of this .interpolators)
this .updateInterpolator (interpolator)
Editor .undoManager .endUndo ();
}
updateInterpolator (interpolator)
{
Editor .undoManager .beginUndo (_("Update Interpolator"));
switch (interpolator .getType () .at (-1))
{
case X3D .X3DConstants .BooleanSequencer:
case X3D .X3DConstants .IntegerSequencer:
{
this .updateSequencer (interpolator);
break;
}
case X3D .X3DConstants .ColorInterpolator:
case X3D .X3DConstants .ScalarInterpolator:
case X3D .X3DConstants .OrientationInterpolator:
case X3D .X3DConstants .PositionInterpolator2D:
case X3D .X3DConstants .PositionInterpolator:
{
this .updateScalarInterpolator (interpolator);
break;
}
case X3D .X3DConstants .CoordinateInterpolator2D:
case X3D .X3DConstants .CoordinateInterpolator:
case X3D .X3DConstants .NormalInterpolator:
{
this .updateArrayInterpolator (interpolator);
break;
}
}
interpolator ._set_fraction .addEvent ();
Editor .undoManager .endUndo ();
}
updateSequencer (interpolator)
{
this .resizeInterpolator (interpolator);
const components = this .#components .get (interpolator .getType () .at (-1));
const key = interpolator .getMetaData ("Interpolator/key", new X3D .MFInt32 ());
const keyValue = interpolator .getMetaData ("Interpolator/keyValue", new X3D .MFDouble ());
const keyType = interpolator .getMetaData ("Interpolator/keyType", new X3D .MFString ());
keyValue .length = key .length * components;
keyType .length = key .length;
const size = key .length;
const duration = this .getDuration ();
const keys = [ ];
const keyValues = [ ];
let i = 0; // index in key
let iN = 0; // index in meta data keyValue
while (i < size)
{
if (key [i] < 0 || key [i] > duration)
continue;
const fraction = key [i] / duration;
const value = keyValue [iN];
keys .push (fraction);
keyValues .push (value);
++ i;
iN += components;
}
const executionContext = interpolator .getExecutionContext ();
Editor .setFieldValue (executionContext, interpolator, interpolator ._key, keys);
Editor .setFieldValue (executionContext, interpolator, interpolator ._keyValue, keyValues);
this .registerRequestDrawTimeline ();
}
#vectors = new Map ([
[2, X3D .Vector2],
[3, X3D .Vector3],
[4, X3D .Vector4],
]);
updateScalarInterpolator (interpolator)
{
this .resizeInterpolator (interpolator);
const components = this .#components .get (interpolator .getType () .at (-1));
const key = interpolator .getMetaData ("Interpolator/key", new X3D .MFInt32 ());
const keyValue = interpolator .getMetaData ("Interpolator/keyValue", new X3D .MFDouble ());
const keyType = interpolator .getMetaData ("Interpolator/keyType", new X3D .MFString ());
keyValue .length = key .length * components;
keyType .length = key .length;
const size = key .length;
const duration = this .getDuration ();
const keys = [ ];
const keyValues = [ ];
let i = 0; // index in key
let iN = 0; // index in meta data keyValue
while (i < size)
{
if (key [i] < 0 || key [i] > duration)
{
++ i;
continue;
}
const value = this .getValue (keyValue, iN, components);
const fraction = key [i] / duration;
let iT = i;
if (keyType [iT] === "SPLIT" && iT + 1 < size)
++ iT;
switch (keyType [iT])
{
case "CONSTANT":
{
keys .push (fraction);
keyValues .push (... value);
if (key [i] < duration)
{
const nextFraction = i === size - 1 ? 1 : key [i + 1] / duration;
keys .push (nextFraction);
keyValues .push (... value);
}
break;
}
case "LINEAR":
case "SPLIT":
{
keys .push (fraction);
keyValues .push (... value);
break;
}
case "SPLINE":
{
const currentKeys = new X3D .MFFloat ();
const currentKeyValues = interpolator instanceof X3D .OrientationInterpolator
? new X3D .MFRotation ()
: components === 1 ? new X3D .MFFloat () : new X3D [`MFVec${components}f`] ();
const currentKeyVelocities = currentKeyValues .create ();
const Vector = this .#vectors .get (components);
for (; i < size; ++ i, iN += components)
{
let value = this .getValue (keyValue, iN, components);
if (interpolator instanceof X3D .ColorInterpolator)
value = new X3D .Color3 (... value) .getHSV ();
currentKeys .push (key [i]);
currentKeyValues .push (components === 1 ? value [0] : new Vector (... value));
if (currentKeys .length === 1)
continue;
if (keyType [i] !== "SPLINE")
break;
}
if (currentKeys .length < 2)
{
// This can happen if only the last frame is of type SPLINE.
keys .push (fraction);
keyValues .push (... value);
break;
}
// currentKeyVelocities .length = currentKeys .length;
const closed = currentKeys .at (0) === 0
&& currentKeys .at (-1) === duration
&& (components === 1
? currentKeyValues .at (0) === currentKeyValues .at (-1)
: currentKeyValues .at (0) .equals (currentKeyValues .at (-1)));
const normalizeVelocity = false;
const spline = interpolator instanceof X3D .OrientationInterpolator
? new X3D .SquadInterpolator ()
: new X3D [`CatmullRomSplineInterpolator${components}`] ();
spline .generate (closed,
currentKeys,
currentKeyValues,
currentKeyVelocities,
normalizeVelocity);
const length = currentKeys .length - 1;
for (let k = 0; k < length; ++ k)
{
const frames = currentKeys [k + 1] - currentKeys [k];
const fraction = currentKeys [k] / duration;
const distance = frames / duration;
const framesN = frames + (k + 1 === length && i === key .length);
for (let f = 0; f < framesN; ++ f)
{
const weight = f / frames;
let value = spline .interpolate (k, k + 1, weight, currentKeyValues);
if (interpolator instanceof X3D .ColorInterpolator)
value = new X3D .Color3 () .setHSV (... value);
keys .push (fraction + weight * distance);
keyValues .push (... (components === 1 ? [value] : value));
}
}
if (i + 1 !== size)
{
i -= 1;
iN -= components;
}
break;
}
}
i += 1;
iN += components;
}
const executionContext = interpolator .getExecutionContext ();
Editor .setFieldValue (executionContext, interpolator, interpolator ._key, keys);
Editor .setFieldValue (executionContext, interpolator, interpolator ._keyValue, keyValues);
this .registerRequestDrawTimeline ();
}
updateArrayInterpolator (interpolator)
{
this .resizeInterpolator (interpolator);
const components = this .#components .get (interpolator .getType () .at (-1));
const key = interpolator .getMetaData ("Interpolator/key", new X3D .MFInt32 ());
const keyValue = interpolator .getMetaData ("Interpolator/keyValue", new X3D .MFDouble ());
const keyType = interpolator .getMetaData ("Interpolator/keyType", new X3D .MFString ());
const keySize = interpolator .getMetaData ("Interpolator/keySize", new X3D .SFInt32 ());
keyValue .length = key .length * components * keySize;
keyType .length = key .length;
const size = key .length;
const duration = this .getDuration ();
const keys = [ ];
const keyValues = [ ];
let i = 0; // index in key
let iN = 0; // index in meta data keyValue
while (i < size)
{
if (key [i] < 0 && key [i] > duration)
{
++ i;
continue;
}
const fraction = key [i] / duration;
let iT = i;
if (keyType [iT] === "SPLIT" && iT + 1 < size)
++ iT;
switch (keyType [iT])
{
case "CONSTANT":
{
const length = components * keySize;
keys .push (fraction);
for (let a = 0; a < length; a += components)
keyValues .push (... this .getValue (keyValue, iN + a, components));
if (key [i] < duration)
{
const nextFraction = (i === size - 1 ? 1 : key [i + 1] / duration);
keys .push (nextFraction);
for (let a = 0; a < length; a += components)
keyValues .push (... this .getValue (keyValue, iN + a, components));
}
break;
}
case "LINEAR":
case "SPLIT":
{
const length = components * keySize;
keys .push (fraction);
for (let a = 0; a < length; a += components)
keyValues .push (... this .getValue (keyValue, iN + a, components));
break;
}
case "SPLINE":
{
const first = keyValues .length;
// Generate key.
const currentKeys = interpolator ._key .create ();
for (; i < size; ++ i)
{
currentKeys .push (key [i]);
if (currentKeys .length === 1)
continue;
if (keyType [i] !== "SPLINE")
break;
}
if (currentKeys .length < 2)
{
// This can happen if only the last frame is of type SPLINE.
const length = components * keySize;
keys .push (fraction);
for (let a = 0; a < length; a += components)
keyValues .push (... this .getValue (keyValue, iN + a, components));
break;
}
const length = currentKeys .length - 1;
for (let k = 0; k < length; ++ k)
{
const frames = currentKeys [k + 1] - currentKeys [k];
const fraction = currentKeys [k] / duration;
const distance = frames / duration;
const framesN = k + 1 === length && i === key .length ? frames + 1 : frames;
for (let f = 0; f < framesN; ++ f)
{
const weight = f / frames;
keys .push (fraction + weight * distance);
}
}
// Generate keyValue.
for (let a = 0; a < keySize; ++ a)
{
const currentKeyValues = interpolator ._keyValue .create ();
const currentKeyVelocities = interpolator ._keyValue .create ();
const Vector = this .#vectors .get (components);
for (let i = 0, aiN = iN + a * components; i < currentKeys .length; ++ i, aiN += components * keySize)
currentKeyValues .push (new Vector (... this .getValue (keyValue, aiN, components)));
// currentKeyVelocities .length = currentKeys .length;
const closed = currentKeys .at (0) === 0
&& currentKeys .at (-1) === duration
&& currentKeyValues .at (0) .equals (currentKeyValues .at (-1));
const normalizeVelocity = false;
const spline = new X3D [`CatmullRomSplineInterpolator${components}`] ();
spline .generate (closed,
currentKeys,
currentKeyValues,
currentKeyVelocities,
normalizeVelocity);
const length = currentKeys .length - 1;
let totalFrames = 0;
for (let k = 0; k < length; ++ k)
{
const frames = currentKeys [k + 1] - currentKeys [k];
const framesN = frames + (k + 1 === length && i === key .length);
for (let f = 0; f < framesN; ++ f)
{
const weight = f / frames;
const value = spline .interpolate (k, k + 1, weight, currentKeyValues);
const index = first + (a + (totalFrames + f) * keySize) * components;
if (index >= keyValues .length)
keyValues .length = index + 1;
keyValues .splice (index, components, ... value);
}
totalFrames += frames;
}
}
if (i + 1 !== size)
i -= 1;
iN += components * keySize * (currentKeys .length - 2);
break;
}
}
i += 1;
iN += components * keySize;
}
const executionContext = interpolator .getExecutionContext ();
Editor .setFieldValue (executionContext, interpolator, interpolator ._key, keys);
Editor .setFieldValue (executionContext, interpolator, interpolator ._keyValue, keyValues);
this .registerRequestDrawTimeline ();
}
getValue (keyValue, index, components)
{
const value = [ ];
for (let i = 0; i < components; ++ i)
value .push (keyValue [index + i]);
return value;
}
resizeInterpolator (interpolator)
{
const components = this .#components .get (interpolator .getType () .at (-1));
const key = interpolator .getMetaData ("Interpolator/key", new X3D .MFInt32 ());
const keyValue = interpolator .getMetaData ("Interpolator/keyValue", new X3D .MFDouble ());
const keyType = interpolator .getMetaData ("Interpolator/keyType", new X3D .MFString ());
const keySize = interpolator .getMetaData ("Interpolator/keySize", new X3D .SFInt32 (1));
const size = X3D .Algorithm .upperBound (key, 0, key .length, this .getDuration ());
const sizeN = size * components * keySize;
// Remove frames greater than duration.
key .length = size;
keyValue .length = sizeN;
keyType .length = size;
Editor .setNodeMetaData (interpolator, "Interpolator/key", key);
Editor .setNodeMetaData (interpolator, "Interpolator/keyValue", keyValue);
Editor .setNodeMetaData (interpolator, "Interpolator/keyType", keyType);
if (key .length === 0)
Editor .removeNodeMetaData (interpolator, "Interpolator/keySize", new X3D .SFInt32 ());
this .registerRequestDrawTimeline ();
}
addKeyframeToInterpolator (interpolator, frame, type, value)
{
const components = this .#components .get (interpolator .getType ()