Browse Source

Work on FXScene. Many fixes and improvements to CurveEditor

trethaller 7 years ago
parent
commit
c1684b5f54
10 changed files with 706 additions and 132 deletions
  1. 1 1
      .vscode/tasks.json
  2. 102 4
      bin/style.css
  3. 116 3
      bin/style.less
  4. 153 94
      hide/comp/CurveEditor.hx
  5. 3 3
      hide/comp/SceneEditor.hx
  6. 7 1
      hide/prefab/Curve.hx
  7. 10 0
      hide/prefab/Prefab.hx
  8. 64 0
      hide/prefab/fx/FXScene.hx
  9. 250 23
      hide/view/FXScene.hx
  10. 0 3
      hide/view/Prefab.hx

+ 1 - 1
.vscode/tasks.json

@@ -15,7 +15,7 @@
                 "isDefault": true
             },
             "presentation": {
-                "reveal": "none"
+                "reveal": "silent"
             }
         }
     ],

+ 102 - 4
bin/style.css

@@ -441,9 +441,6 @@ input[type=checkbox]:checked:after {
   margin-bottom: 5px;
 }
 /* Curve editor */
-.hide-curve-editor {
-  border: 1px solid black;
-}
 .hide-curve-editor line {
   stroke-width: 1px;
 }
@@ -507,7 +504,108 @@ input[type=checkbox]:checked:after {
 }
 /* FX Editor */
 .fx-animpanel {
-  flex-basis: 300px;
+  max-width: 1000px;
+  position: relative;
+}
+.fx-animpanel .anim-scroll {
+  overflow-y: scroll;
+  height: 400px;
+}
+.fx-animpanel .top-bar {
+  margin-left: 120px;
+  position: relative;
+  height: 100%;
+}
+.fx-animpanel .timeline {
+  background: #000;
+  position: relative;
+  height: 20px;
+  overflow: hidden;
+}
+.fx-animpanel .timeline .mark {
+  height: 100%;
+  border-left: 1px solid white;
+  vertical-align: middle;
+  padding-left: 4px;
+  position: absolute;
+  line-height: 20px;
+  user-select: none;
+}
+.fx-animpanel .overlay-container {
+  top: 0;
+  position: absolute;
+  height: 100%;
+  pointer-events: none;
+  left: 120px;
+  right: 0px;
+}
+.fx-animpanel .overlay-container .overlay {
+  position: absolute;
+  top: 0px;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+.fx-animpanel .overlay-container .overlay .line {
+  position: absolute;
+  height: 100%;
+  width: 1px;
+  background: green;
+}
+.fx-animpanel .overlay-container .overlay .selection {
+  position: absolute;
+  height: 100%;
+  background: #17ffeb12;
+  mix-blend-mode: hard-light;
+  border-left: 1px black;
+  border-right: 1px black;
+}
+.fx-animpanel .track .track-header {
+  height: 20px;
+  display: flex;
+}
+.fx-animpanel .track .track-header .track-prop {
+  text-align: right;
+  width: 120px;
+  height: 100%;
+}
+.fx-animpanel .track .track-header .track-prop label {
+  padding-right: 4px;
+}
+.fx-animpanel .track .track-header .track-prop .track-toggle {
+  width: 16px;
+  height: 16px;
+  display: inline-block;
+  text-align: center;
+}
+.fx-animpanel .track .track-header .track-prop .track-toggle:hover {
+  background: black;
+}
+.fx-animpanel .track .track-header .dopesheet {
+  position: relative;
+  height: 100%;
+  flex-grow: 1;
+  background: #333;
+  box-shadow: 0px -1px 0px black inset;
+  overflow: hidden;
+}
+.fx-animpanel .track .track-header .dopesheet .key {
+  position: absolute;
+  width: 5px;
+  margin-top: 1px;
+  margin-left: -2px;
+  height: 15px;
+  background: #9a9a9a;
+  border: 1px solid black;
+}
+.fx-animpanel .track .track-header .dopesheet .key:hover {
+  background: white;
+}
+.fx-animpanel .track .curve {
+  margin-left: 120px;
+}
+.fx-animpanel .track .curve.hidden {
+  display: none;
 }
 /* Golden Layout Fixes */
 .lm_header .lm_tabs {

+ 116 - 3
bin/style.less

@@ -486,7 +486,6 @@ input[type=checkbox] {
 	@selectCol: rgb(255, 255, 255);
 	@lineCol: rgb(255, 31, 31);
 
-	border: 1px solid black;
 	line {
 		stroke-width: 1px;
 	}
@@ -533,7 +532,7 @@ input[type=checkbox] {
 			}
 		}
 
-		.handles {	
+		.handles {
 			circle, rect {
 				fill: rgba(187, 187, 187, 0.644);
 			}
@@ -559,7 +558,121 @@ input[type=checkbox] {
 
 /* FX Editor */
 .fx-animpanel {
-	flex-basis: 300px;
+	// flex-basis: 500px;
+	max-width: 1000px;
+	@leftPanelWidth: 120px;
+	@timelineHeight: 20px;
+	position: relative;
+
+	.anim-scroll {
+		overflow-y: scroll;
+		height: 400px;
+	}
+	.top-bar {
+		margin-left: @leftPanelWidth;
+		position: relative;
+		height: 100%;
+	}
+
+	.timeline {
+		background: #000;
+		position: relative;
+		height: 20px;
+		overflow: hidden;
+
+		.mark {
+			height: 100%;
+			border-left: 1px solid white;
+			vertical-align: middle;
+			padding-left: 4px;
+			position: absolute;
+			line-height: @timelineHeight;
+			user-select: none;
+		}
+	}
+
+	.overlay-container {
+		top: 0;
+		position: absolute;
+		height: 100%;
+		pointer-events: none;
+		left: @leftPanelWidth;
+		right: 0px;
+		.overlay {
+			position: absolute;
+			top: 0px;
+			width: 100%;
+			height: 100%;
+			overflow: hidden;
+			.line {
+				position: absolute;
+				height: 100%;
+				width: 1px;
+				background: green;
+			}
+			.selection {
+				position: absolute;
+				height: 100%;
+				background: #17ffeb12;
+				mix-blend-mode: hard-light;
+				border-left: 1px black;
+				border-right: 1px black;
+			}
+		}
+	}
+
+	.track {
+		.track-header {
+			height: 20px;
+			display: flex;
+
+			.track-prop {
+				text-align: right;
+				width: @leftPanelWidth;
+				height: 100%;
+
+				label {
+					padding-right: 4px;
+				}
+
+				.track-toggle {
+					width: 16px;
+					height: 16px;
+					display: inline-block;
+					text-align: center;
+					&:hover {
+						background: black;
+					}
+				}
+			}
+			.dopesheet {
+				position: relative;
+				height: 100%;
+				flex-grow: 1;
+				background: #333;
+				box-shadow: 0px -1px 0px black inset;
+				overflow: hidden;
+				.key {
+					position: absolute;
+					width: 5px;
+					margin-top: 1px;
+					margin-left: -2px;
+					height: 15px;
+					background: #9a9a9a;
+					border: 1px solid black;
+					&:hover {
+						background: white;
+					}
+				}
+			}
+		}
+		.curve {
+			margin-left: @leftPanelWidth;
+		}
+		.curve.hidden {
+			display: none;
+		}
+	}
 }
 
 /* Golden Layout Fixes */

+ 153 - 94
hide/comp/CurveEditor.hx

@@ -7,9 +7,12 @@ class CurveEditor extends Component {
 	public var xOffset = 0.;
 	public var yOffset = 0.;
 
-	public var curve : hide.prefab.Curve;
+	public var curve(default, set) : hide.prefab.Curve;
 	public var undo : hide.ui.UndoHistory;
 
+	public var lockViewX = false;
+	public var lockViewY = false;
+
 	var svg : hide.comp.SVG;
 	var width = 0;
 	var height = 0;
@@ -19,24 +22,20 @@ class CurveEditor extends Component {
 
 	var refreshTimer : haxe.Timer = null;
 	var lastValue : Dynamic;
+	var lastMode : hide.prefab.Curve.CurveKeyMode = Constant;
 
 	var selectedKeys: Array<hide.prefab.Curve.CurveKey> = [];
 
-	public function new(parent, curve : hide.prefab.Curve, undo) {
+	public function new(parent, undo) {
 		super(parent);
 		this.undo = undo;
-		this.curve = curve;
-		var div = new Element("<div></div>");
+		var div = new Element("<div></div>").appendTo(parent);
 		div.attr({ tabindex: "1" });
 		div.css({ width: "100%", height: "100%" });
-
-		div.appendTo(parent);
 		div.focus();
 		svg = new hide.comp.SVG(div);
 		var root = svg.element;
 
-		lastValue = haxe.Json.parse(haxe.Json.stringify(curve.save()));
-
 		gridGroup = svg.group(root, "grid");
 		graphGroup = svg.group(root, "graph");
 		selectGroup = svg.group(root, "selection-overlay");
@@ -52,7 +51,7 @@ class CurveEditor extends Component {
 			div.focus();
 			if(e.which == 1) {
 				if(e.ctrlKey) {
-					addPoint(ixt(px), iyt(py));
+					addKey(ixt(px), iyt(py));
 				}
 				else {
 					startSelectRect(px, py);
@@ -69,13 +68,27 @@ class CurveEditor extends Component {
 		});
 		root.on("mousewheel", function(e) {
 			var step = e.originalEvent.wheelDelta > 0 ? 1.0 : -1.0;
-			if(hxd.Key.isDown(hxd.Key.SHIFT))
-				yScale *= Math.pow(1.125, step);
-			else
-				xScale *= Math.pow(1.125, step);
-			refresh();
+			var changed = false;
+			if(hxd.Key.isDown(hxd.Key.SHIFT)) {
+				if(!lockViewY) {
+					yScale *= Math.pow(1.125, step);
+					changed = true;
+				}
+			}
+			else {
+				if(!lockViewX) {
+					xScale *= Math.pow(1.125, step);
+					changed = true;
+				}
+			}
+			if(changed) {
+				e.preventDefault();
+				e.stopPropagation();
+				refresh();
+			}
 		});
 		div.keydown(function(e) {
+			if(curve == null) return;
 			if(e.keyCode == 46) {
 				var newVal = [for(k in curve.keys) if(selectedKeys.indexOf(k) < 0) k];
 				curve.keys = newVal;
@@ -87,7 +100,25 @@ class CurveEditor extends Component {
 		});
 	}
 
-	function addPoint(time: Float, ?val: Float) {
+	function set_curve(curve) {
+		this.curve = curve;
+		lastValue = haxe.Json.parse(haxe.Json.stringify(curve.save()));
+		var view = getDisplayState("view");
+		if(view != null) {
+			if(!lockViewX) {
+				xOffset = view.xOffset;
+				xScale = view.xScale;
+			}
+			if(!lockViewY) {
+				yOffset = view.yOffset;
+				yScale = view.yScale;
+			}
+		}
+		refresh();
+		return curve;
+	}
+
+	function addKey(time: Float, ?val: Float) {
 		var index = 0;
 		for(ik in 0...curve.keys.length) {
 			var key = curve.keys[ik];
@@ -101,7 +132,7 @@ class CurveEditor extends Component {
 		var key : hide.prefab.Curve.CurveKey = {
 			time: time,
 			value: val,
-			mode: Linear
+			mode: lastMode
 		};
 		curve.keys.insert(index, key);
 		afterChange();
@@ -149,6 +180,7 @@ class CurveEditor extends Component {
 		if(next != null && key.time > next.time)
 			key.time = next.time - 0.01;
 
+		// TODO: Prevent backwards handles
 		if(next != null && key.nextHandle != null) {
 			var slope = key.nextHandle.dv / key.nextHandle.dt;
 			slope = hxd.Math.clamp(slope, -1000, 1000);
@@ -173,7 +205,7 @@ class CurveEditor extends Component {
 		var selY = p1y;
 		var selW = 0.;
 		var selH = 0.;
-		startDrag(root, function(e) {
+		startDrag(function(e) {
 			var p2x = e.clientX - offset.left;
 			var p2y = e.clientY - offset.top;
 			selX = hxd.Math.min(p1x, p2x);
@@ -185,41 +217,50 @@ class CurveEditor extends Component {
 		}, function(e) {
 			selectGroup.empty();
 			var minT = ixt(selX);
-			var minV = iyt(selY);
+			var minV = iyt(selY + selH);
 			var maxT = ixt(selX + selW);
-			var maxV = iyt(selY + selH);
+			var maxV = iyt(selY);
 			selectedKeys = [for(key in curve.keys)
 				if(key.time >= minT && key.time <= maxT && key.value >= minV && key.value <= maxV) key];
-			refresh();
+			refreshGraph();
 		});
 	}
 
 	function startPan(e) {
 		var lastX = e.clientX;
 		var lastY = e.clientY;
-		startDrag(root, function(e) {
+		startDrag(function(e) {
 			var dt = (e.clientX - lastX) / xScale;
 			var dv = (e.clientY - lastY) / yScale;
-			xOffset += dt;
-			yOffset += dv;
+			if(!lockViewX)
+				xOffset -= dt;
+			if(!lockViewY)
+				yOffset += dv;
 			lastX = e.clientX;
 			lastY = e.clientY;
-			refresh(true);
+			setPan(xOffset, yOffset);
 		}, function(e) {
-			refresh();
 		});
 	}
 
-	inline function xt(x: Float) return Math.round((x + xOffset) * xScale);
-	inline function yt(y: Float) return Math.round((y + yOffset) * yScale + height/2);
-	inline function ixt(px: Float) return px / xScale - xOffset;
-	inline function iyt(py: Float) return (py - height/2) / yScale - yOffset;
+	public function setPan(xoff, yoff) {
+		xOffset = xoff;
+		yOffset = yoff;
+		refreshGrid();
+		graphGroup.attr({transform: 'translate(${xt(0)},${yt(0)})'});
+	}
 
-	function startDrag(el: Element, onMove, onStop) {
-		el.mousemove(onMove);
-		el.mouseup(function(e) {
-			el.off("mousemove");
-			el.off("mouseup");
+	inline function xt(x: Float) return Math.round((x - xOffset) * xScale);
+	inline function yt(y: Float) return Math.round((-y + yOffset) * yScale + height/2);
+	inline function ixt(px: Float) return px / xScale + xOffset;
+	inline function iyt(py: Float) return -(py - height/2) / yScale + yOffset;
+
+	function startDrag(onMove: js.jquery.Event->Void, onStop: js.jquery.Event->Void) {
+		var el = new Element(root[0].ownerDocument.body);
+		el.on("mousemove.curveeditor", onMove);
+		el.on("mouseup.curveeditor", function(e: js.jquery.Event) {
+			el.off("mousemove.curveeditor");
+			el.off("mouseup.curveeditor");
 			e.preventDefault();
 			e.stopPropagation();
 			onStop(e);
@@ -248,33 +289,28 @@ class CurveEditor extends Component {
 		refresh();
 	}
 
-	public function resetView() {
-		// var margin = 20;
-		// var minT = ixt(-margin);
-		// var maxT = ixt(width + margin);
-		// var minV = iyt(0);
-		// var maxV = iyt(height);
-		// xOffset = minT;
-		// xScale = (maxT - minT)
-		// TODO
+	public function refresh(?anim: Bool) {
+		if(false) {
+			// Auto-gc
+			if(refreshTimer != null)
+				refreshTimer.stop();
+			if(!anim) {
+				refreshTimer = haxe.Timer.delay(function() {
+					refreshTimer = null;
+					untyped window.gc();
+				}, 500);
+			}
+		}
+
+		refreshGrid();
+		refreshGraph(anim);
 	}
 
-	public function refresh(?anim: Bool = false, ?animKey: hide.prefab.Curve.CurveKey) {
+	public function refreshGrid() {
 		width = Math.round(svg.element.width());
 		height = Math.round(svg.element.height());
-		gridGroup.empty();
-		graphGroup.empty();
-		selectGroup.empty();
-
-		if(refreshTimer != null)
-			refreshTimer.stop();
-		if(!anim) {
-			refreshTimer = haxe.Timer.delay(function() {
-				refreshTimer = null;
-				untyped window.gc();
-			}, 100);
-		}
 
+		gridGroup.empty();
 		var minX = Math.floor(ixt(0));
 		var maxX = Math.ceil(ixt(width));
 		var hgrid = svg.group(gridGroup, "hgrid");
@@ -286,8 +322,8 @@ class CurveEditor extends Component {
 				l.addClass("axis");
 		}
 
-		var minY = Math.floor(iyt(0));
-		var maxY = Math.ceil(iyt(height));
+		var minY = Math.floor(iyt(height));
+		var maxY = Math.ceil(iyt(0));
 		var vgrid = svg.group(gridGroup, "vgrid");
 		for(iy in minY...(maxY+1)) {
 			var l = svg.line(vgrid, 0, yt(iy), width, yt(iy)).attr({
@@ -297,6 +333,23 @@ class CurveEditor extends Component {
 				l.addClass("axis");
 		}
 
+		saveDisplayState("view", {
+			xOffset: xOffset,
+			yOffset: yOffset,
+			xScale: xScale,
+			yScale: yScale
+		});
+	}
+
+	public function refreshGraph(?anim: Bool = false, ?animKey: hide.prefab.Curve.CurveKey) {
+		if(curve == null)
+			return;
+
+		graphGroup.empty();
+		var graphOffX = xt(0);
+		var graphOffY = yt(0);
+		graphGroup.attr({transform: 'translate($graphOffX, $graphOffY)'});
+
 		var curveGroup = svg.group(graphGroup, "curve");
 		var vectorsGroup = svg.group(graphGroup, "vectors");
 		var handlesGroup = svg.group(graphGroup, "handles");
@@ -306,23 +359,30 @@ class CurveEditor extends Component {
 		var size = 7;
 
 		// Draw curve
-		{
+		if(curve.keys.length > 0) {
 			var keys = curve.keys;
-			var lines = ['M ${xt(keys[0].time)},${yt(keys[0].value)}'];
+			var lines = ['M ${xScale*(keys[0].time)},${-yScale*(keys[0].value)}'];
 			for(ik in 1...keys.length) {
 				var prev = keys[ik-1];
 				var cur = keys[ik];
-				lines.push('C
-					${xt(prev.time + (prev.nextHandle != null ? prev.nextHandle.dt : 0.))}, ${yt(prev.value + (prev.nextHandle != null ? prev.nextHandle.dv : 0.))}
-					${xt(cur.time + (cur.prevHandle != null ? cur.prevHandle.dt : 0.))}, ${yt(cur.value + (cur.prevHandle != null ? cur.prevHandle.dv : 0.))}
-					${xt(cur.time)}, ${yt(cur.value)} ');
+				if(prev.mode == Constant) {
+					lines.push('L ${xScale*(prev.time)} ${-yScale*(prev.value)}
+					L ${xScale*(cur.time)} ${-yScale*(prev.value)}
+					L ${xScale*(cur.time)} ${-yScale*(cur.value)}');
+				}
+				else {
+					lines.push('C
+						${xScale*(prev.time + (prev.nextHandle != null ? prev.nextHandle.dt : 0.))},${-yScale*(prev.value + (prev.nextHandle != null ? prev.nextHandle.dv : 0.))}
+						${xScale*(cur.time + (cur.prevHandle != null ? cur.prevHandle.dt : 0.))}, ${-yScale*(cur.value + (cur.prevHandle != null ? cur.prevHandle.dv : 0.))}
+						${xScale*(cur.time)}, ${-yScale*(cur.value)} ');
+				}
 			}
 			svg.make(curveGroup, "path", {d: lines.join("")});
 			// var pts = curve.sample(200);
 			// var poly = [];
 			// for(i in 0...pts.length) {
-			// 	var x = xt(curve.duration * i / (pts.length - 1));
-			// 	var y = yt(pts[i]);
+			// 	var x = xScale * (curve.duration * i / (pts.length - 1));
+			// 	var y = yScale * (pts[i]);
 			// 	poly.push(new h2d.col.Point(x, y));
 			// }
 			// svg.polygon(curveGroup, poly);
@@ -336,8 +396,8 @@ class CurveEditor extends Component {
 		}
 
 		for(key in curve.keys) {
-			var kx = xt(key.time);
-			var ky = yt(key.value);
+			var kx = xScale*(key.time);
+			var ky = -yScale*(key.value);
 			var keyHandle = addRect(keyHandles, kx, ky);
 			var selected = selectedKeys.indexOf(key) >= 0;
 			if(selected)
@@ -347,32 +407,31 @@ class CurveEditor extends Component {
 					if(e.which != 1) return;
 					e.preventDefault();
 					e.stopPropagation();
-					var offx = e.clientX - keyHandle.offset().left;
-					var offy = e.clientY - keyHandle.offset().top;
-					var offset = svg.element.offset();
-					startDrag(root, function(e) {
-						var lx = e.clientX - offset.left - offx;
-						var ly = e.clientY - offset.top - offy;
+					var offset = root.offset();
+					startDrag(function(e) {
+						var lx = e.clientX - offset.left;
+						var ly = e.clientY - offset.top;
 						var nkx = ixt(lx);
 						var nky = iyt(ly);
 						key.time = nkx;
 						key.value = nky;
 						fixKey(key);
-						refresh(true, key);
+						refreshGraph(true, key);
 					}, function(e) {
 						selectedKeys = [key];
 						fixKey(key);
 						afterChange();
 					});
 					selectedKeys = [key];
-					refresh();
+					refreshGraph();
 				});
 				keyHandle.contextmenu(function(e) {
 					e.preventDefault();
 					function setMode(m: hide.prefab.Curve.CurveKeyMode) {
 						key.mode = m;
+						lastMode = m;
 						fixKey(key);
-						refresh();
+						refreshGraph();
 					}
 					new ContextMenu([
 						{ label : "Mode", menu :[
@@ -389,8 +448,8 @@ class CurveEditor extends Component {
 				var handle = next ? key.nextHandle : key.prevHandle;
 				var other = next ? key.prevHandle : key.nextHandle;
 				if(handle == null) return null;
-				var px = xt(key.time + handle.dt);
-				var py = yt(key.value + handle.dv);
+				var px = xScale*(key.time + handle.dt);
+				var py = -yScale*(key.value + handle.dv);
 				var line = svg.line(vectorsGroup, kx, ky, px, py);
 				var circle = svg.circle(tangentsHandles, px, py, size/2);
 				if(selected) {
@@ -403,26 +462,26 @@ class CurveEditor extends Component {
 					if(e.which != 1) return;
 					e.preventDefault();
 					e.stopPropagation();
-					var offx = e.clientX - circle.offset().left;
-					var offy = e.clientY - circle.offset().top;
-					var offset = svg.element.offset();
+					var offset = root.offset();
 					var otherLen = hxd.Math.distance(other.dt * xScale, other.dv * yScale);
-					startDrag(root, function(e) {
-						var lx = e.clientX - offset.left - offx;
-						var ly = e.clientY - offset.top - offy;
-						if(next && lx < kx || !next && lx > kx)
-							lx = kx;
+					startDrag(function(e) {
+						var lx = e.clientX - offset.left;
+						var ly = e.clientY - offset.top;
+						var abskx = xt(key.time);
+						var absky = yt(key.value);
+						if(next && lx < abskx || !next && lx > abskx)
+						 	lx = kx;
 						var ndt = ixt(lx) - key.time;
 						var ndv = iyt(ly) - key.value;
 						handle.dt = ndt;
 						handle.dv = ndv;
 						if(key.mode == Aligned) {
-							var angle = Math.atan2(ly - ky, lx - kx);
+							var angle = Math.atan2(absky - ly, lx - abskx);
 							other.dt = Math.cos(angle + Math.PI) * otherLen / xScale;
 							other.dv = Math.sin(angle + Math.PI) * otherLen / yScale;
 						}
 						fixKey(key);
-						refresh(true, key);
+						refreshGraph(true, key);
 					}, function(e) {
 						afterChange();
 					});
@@ -438,7 +497,7 @@ class CurveEditor extends Component {
 		if(selectedKeys.length > 1) {
 			var bounds = new h2d.col.Bounds();
 			for(key in selectedKeys)
-				bounds.addPoint(new h2d.col.Point(xt(key.time), yt(key.value)));
+				bounds.addPoint(new h2d.col.Point(xScale*(key.time), -yScale*(key.value)));
 			var margin = 12.5;
 			bounds.xMin -= margin;
 			bounds.yMin -= margin;
@@ -454,20 +513,20 @@ class CurveEditor extends Component {
 					e.stopPropagation();
 					var lastX = e.clientX;
 					var lastY = e.clientY;
-					startDrag(root, function(e) {
+					startDrag(function(e) {
 						var dx = e.clientX - lastX;
 						var dy = e.clientY - lastY;
 						for(key in selectedKeys) {
 							key.time += dx / xScale;
-							key.value += dy / yScale;
+							key.value -= dy / yScale;
 						}
 						lastX = e.clientX;
 						lastY = e.clientY;
-						refresh(true);
+						refreshGraph(true);
 					}, function(e) {
 						afterChange();
 					});
-					refresh();
+					refreshGraph();
 				});
 			}
 		}

+ 3 - 3
hide/comp/SceneEditor.hx

@@ -115,7 +115,7 @@ class SceneEditor {
 	}
 
 	public function getSelection() {
-		return curEdit.elements;
+		return curEdit != null ? curEdit.elements : [];
 	}
 
 	public function addSearchBox(parent : Element) {
@@ -456,7 +456,7 @@ class SceneEditor {
 		return null;
 	}
 
-	function getObject(elt: PrefabElement) {
+	public function getObject(elt: PrefabElement) {
 		var ctx = getContext(elt);
 		if(ctx != null)
 			return ctx.local3d;
@@ -464,7 +464,7 @@ class SceneEditor {
 	}
 
 	function getSelfMeshes(p : PrefabElement) {
-		var childObjs = [for(c in p.children) getContext(c).local3d];
+		var childObjs = [for(c in p.children) {var ctx = getContext(c); if(ctx != null) ctx.local3d; }];
 		var ret = [];
 		function rec(o : Object) {
 			var m = Std.instance(o, h3d.scene.Mesh);

+ 7 - 1
hide/prefab/Curve.hx

@@ -24,7 +24,7 @@ typedef CurveKeys = Array<CurveKey>;
 
 class Curve extends Prefab {
 
-	public var duration : Float = 0.;
+	public var duration : Float = 0.; // TODO: optional?
 	public var keys : CurveKeys = [];
 
    	public function new(?parent) {
@@ -119,4 +119,10 @@ class Curve extends Prefab {
 		}
 		return vals;
 	}
+
+	override function getHideProps() {
+		return { icon : "paint-brush", name : "Curve", fileSource : null };
+	}
+
+	static var _ = Library.register("curve", Curve);
 }

+ 10 - 0
hide/prefab/Prefab.hx

@@ -191,4 +191,14 @@ class Prefab {
 	public function to<T:Prefab>( c : Class<T> ) : Null<T> {
 		return Std.instance(this, c);
 	}
+
+	public function getAbsPath() {
+		var p = this;
+		var path = [];
+		while(p != null) {
+			path.unshift(p.name + '[${p.type}]');
+			p = p.parent;
+		}
+		return path.join('.');
+	}
 }

+ 64 - 0
hide/prefab/fx/FXScene.hx

@@ -1,4 +1,19 @@
 package hide.prefab.fx;
+import hide.prefab.Curve;
+
+typedef ObjectCurves = {
+	?x: Curve,
+	?y: Curve,
+	?z: Curve,
+	?rotationX: Curve,
+	?rotationY: Curve,
+	?rotationZ: Curve,
+	?scaleX: Curve,
+	?scaleY: Curve,
+	?scaleZ: Curve,
+	?visibility: Curve,
+	?custom: Array<Curve>
+}
 
 class FXScene extends Library {
 
@@ -32,5 +47,54 @@ class FXScene extends Library {
 		return { icon : "cube", name : "FX", fileSource : ["fx"] };
 	}
 
+	public function getCurves(element : hide.prefab.Prefab) : ObjectCurves {
+		var ret : ObjectCurves = {};
+		for(c in element.children) {
+			var curve = c.to(Curve);
+			if(curve == null)
+				continue;
+			switch(c.name) {
+				case "x": ret.x = curve;
+				case "y": ret.y = curve;
+				case "z": ret.z = curve;
+				case "rotationX": ret.rotationX = curve;
+				case "rotationY": ret.rotationY = curve;
+				case "rotationZ": ret.rotationZ = curve;
+				case "scaleX": ret.scaleX = curve;
+				case "scaleY": ret.scaleY = curve;
+				case "scaleZ": ret.scaleZ = curve;
+				case "visibility": ret.visibility = curve;
+				default: 
+					if(ret.custom == null)
+						ret.custom = [];
+					ret.custom.push(curve);
+			}
+		}
+		return ret;
+	}
+
+	public function getTransform(curves: ObjectCurves, time: Float, ?m: h3d.Matrix) {
+		if(m == null)
+			m = new h3d.Matrix();
+
+		var x = curves.x == null ? 0. : curves.x.getVal(time);
+		var y = curves.y == null ? 0. : curves.y.getVal(time);
+		var z = curves.z == null ? 0. : curves.z.getVal(time);
+
+		var rotationX = curves.rotationX == null ? 0. : curves.rotationX.getVal(time);
+		var rotationY = curves.rotationY == null ? 0. : curves.rotationY.getVal(time);
+		var rotationZ = curves.rotationZ == null ? 0. : curves.rotationZ.getVal(time);
+
+		var scaleX = curves.scaleX == null ? 1. : curves.scaleX.getVal(time);
+		var scaleY = curves.scaleY == null ? 1. : curves.scaleY.getVal(time);
+		var scaleZ = curves.scaleZ == null ? 1. : curves.scaleZ.getVal(time);
+
+		m.initScale(scaleX, scaleY, scaleZ);
+		m.rotate(rotationX, rotationY, rotationZ);
+		m.translate(x, y, z);
+
+		return m;
+	}
+
 	static var _ = Library.register("fx", FXScene);
 }

+ 250 - 23
hide/view/FXScene.hx

@@ -72,7 +72,6 @@ class FXScene extends FileView {
 	var light : h3d.scene.DirLight;
 	var lightDirection = new h3d.Vector( 1, 2, -4 );
 
-
 	var scene(get, null):  hide.comp.Scene;
 	function get_scene() return sceneEditor.scene;
 	var properties(get, null):  hide.comp.PropsEditor;
@@ -84,6 +83,16 @@ class FXScene extends FileView {
 	var lastSyncChange : Float = 0.;
 	var currentSign : String;
 
+	var xScale = 200.;
+	var xOffset = 0.;
+
+	var currentTime : Float;
+	var selectMin : Float;
+	var selectMax : Float;
+	var curveEdits : Array<hide.comp.CurveEditor>;
+	var timeLineEl : Element;
+	var panCallbacks : Array<Bool->Void>;
+
 	override function getDefaultContent() {
 		return haxe.io.Bytes.ofString(ide.toJSON(new hide.prefab.fx.FXScene().save()));
 	}
@@ -106,7 +115,8 @@ class FXScene extends FileView {
 	}
 
 	override function onDisplay() {
-		saveDisplayKey = "FX:" + getPath().split("\\").join("/").substr(0,-1);
+		saveDisplayKey = "FXScene/" + getPath().split("\\").join("/").substr(0,-1);
+		currentTime = 0.;
 		data = new hide.prefab.fx.FXScene();
 		var content = sys.io.File.getContent(getPath());
 		data.load(haxe.Json.parse(content));
@@ -134,33 +144,73 @@ class FXScene extends FileView {
 					</div>
 				</div>
 				<div class="fx-animpanel">
+					<div class="top-bar">
+						<div class="timeline">
+							<div class="timeline-scroll"/>
+						</div>
+					</div>
+					<div class="anim-scroll"></div>
+					<div class="overlay-container">
+						<div class="overlay"></div>
+					</div>
 				</div>
-			</div>
-		');
+			</div>');
 		tools = new hide.comp.Toolbar(root.find(".toolbar"));
 		tabs = new hide.comp.Tabs(root.find(".tabs"));
 		sceneEditor = new FXSceneEditor(this, context, data);
 		root.find(".hide-scene-tree").first().append(sceneEditor.tree.root);
 		root.find(".tab").first().append(sceneEditor.properties.root);
 		root.find(".scene").first().append(sceneEditor.scene.root);
+		root.resize(function(e) {
+			refreshTimeline(false);
+			rebuildAnimPanel();
+		});
 		currentVersion = undo.currentID;
 
-		var animPanel = root.find(".fx-animpanel");
-		var curve = new hide.prefab.Curve();
-		curve.duration = 3.;
-		curve.keys.push({
-			time: 0.1,
-			value: 1.0,
-			mode: Linear,
-		});
-		for(i in 0...2) {
-			curve.keys.push({
-				time: i + 1.0,
-				value: -1.0,
-				mode: Linear
+		var timeline = root.find(".timeline");
+		timeline.mousedown(function(e) {
+			var lastX = e.clientX;
+			root.mousemove(function(e) {
+				var dt = (e.clientX - lastX) / xScale;
+				if(e.which == 2) {
+					xOffset -= dt;
+					xOffset = hxd.Math.max(xOffset, 0);
+				}
+				else if(e.which == 1) {
+					currentTime = ixt(e.clientX - timeline.offset().left);
+					currentTime = hxd.Math.max(currentTime, 0);
+				}
+				lastX = e.clientX;
+				refreshTimeline(true);
+				afterPan(true);
 			});
-		}
-		var curveAnim = new hide.comp.CurveEditor(animPanel, curve, this.undo);
+			root.mouseup(function(e) {
+				root.off("mousemove");
+				root.off("mouseup");
+				e.preventDefault();
+				e.stopPropagation();
+				refreshTimeline(false);
+				afterPan(false);
+			});
+			e.preventDefault();
+			e.stopPropagation();
+		});
+		timeline.on("mousewheel", function(e) {
+			var step = e.originalEvent.wheelDelta > 0 ? 1.0 : -1.0;
+			xScale *= Math.pow(1.125, step);
+			e.preventDefault();
+			e.stopPropagation();
+			refreshTimeline(false);
+			for(ce in curveEdits) {
+				ce.xOffset = xOffset;
+				ce.xScale = xScale;
+				ce.refresh();
+			}
+		});
+
+		selectMin = 0.6;
+		selectMax = 3.2;
+		refreshTimeline(false);
 	}
 
 	public function onSceneReady() {
@@ -171,10 +221,7 @@ class FXScene extends FileView {
 		} else
 			light = null;
 
-
-		this.saveDisplayKey = "Scene:" + state.path;
-
-		tools.saveDisplayKey = "Prefab/tools";
+		tools.saveDisplayKey = "FXScene/tools";
 		tools.addButton("video-camera", "Perspective camera", () -> sceneEditor.resetCamera(false));
 		tools.addToggle("sun-o", "Enable Lights/Shadows", function(v) {
 			if( !v ) {
@@ -200,10 +247,190 @@ class FXScene extends FileView {
 	}
 
 	function onSelect(elts : Array<PrefabElement>) {
+		rebuildAnimPanel();
+	}
+
+	inline function xt(x: Float) return Math.round((x - xOffset) * xScale);
+	inline function ixt(px: Float) return px / xScale + xOffset;
+
+	function refreshTimeline(anim: Bool) {
+		var scroll = root.find(".timeline-scroll");
+		scroll.empty();
+		var width = scroll.parent().width();
+		var minX = Math.floor(ixt(0));
+		var maxX = Math.ceil(ixt(width));
+		for(ix in minX...(maxX+1)) {
+			var mark = new Element('<span class="mark"></span>').appendTo(scroll);
+			mark.css({left: xt(ix)});
+			mark.text(ix + ".00");
+		}
+
+		var overlay = root.find(".overlay");
+		overlay.empty();
+		timeLineEl = new Element('<span class="line"></span>').appendTo(overlay);
+		timeLineEl.css({left: xt(currentTime)});
 
+		var select = new Element('<span class="selection"></span>').appendTo(overlay);
+		select.css({left: xt(selectMin), width: xt(selectMax) - xt(selectMin)});
+	}
+
+	function afterPan(anim: Bool) {
+		for(curve in curveEdits) {
+			curve.setPan(xOffset, curve.yOffset);
+		}
+		for(clb in panCallbacks) {
+			clb(anim);
+		}
+	}
+
+	function rebuildAnimPanel() {
+		var selection = sceneEditor.getSelection();
+		var scrollPanel = root.find(".anim-scroll");
+		scrollPanel.empty();
+		curveEdits = [];
+		panCallbacks = [];
+
+		for(elt in selection) {
+			var objPanel = new Element('<div>
+				<label>${elt.name}</label><input class="addtrack" type="button" value="[+]"></input><div class="tracks"></div>
+			</div>').appendTo(scrollPanel);
+			var addTrackEl = objPanel.find(".addtrack");
+			addTrackEl.click(function(e) {
+				var menuItems: Array<hide.comp.ContextMenu.ContextMenuItem>= [];
+				inline function hasTrack(pname) {
+					return getTrack(elt, pname) != null;
+				}
+
+				if(Std.is(elt, hide.prefab.Object3D)) {
+					var defaultTracks = ["x", "y", "z", "rotationX", "rotationY", "rotationZ", "scaleX", "scaleY", "scaleZ", "visibility"];
+					for(t in defaultTracks) {
+						menuItems.push({
+							label: t,
+							click: ()->addTrack(elt, t),
+							enabled: !hasTrack(t)});
+					}
+				}
+				new hide.comp.ContextMenu(menuItems);
+			});
+			var tracksEl = objPanel.find(".tracks");
+			var curves = elt.getAll(hide.prefab.Curve);
+			for(curve in curves) {
+				var trackEl = new Element('<div class="track">
+					<div class="track-header">
+						<div class="track-prop">
+							<label>${curve.name}</label>
+							<div class="track-toggle"><div class="icon fa"></div></div>
+						</div>
+						<div class="dopesheet"></div>
+					</div>
+					<div class="curve"></div>
+				</div>');
+				var trackToggle = trackEl.find(".track-toggle");
+				tracksEl.append(trackEl);
+				
+				var curveEl = trackEl.find(".curve");
+				var curveEdit = new hide.comp.CurveEditor(curveEl, this.undo);
+				var cpath = curve.getAbsPath();
+				var trackKey = "trackVisible:" + cpath;
+				var expand = getDisplayState(trackKey) == true;
+				curveEdit.saveDisplayKey = getPath() + "/" + cpath;
+				curveEdit.lockViewX = true;
+				curveEdit.xOffset = xOffset;
+				curveEdit.xScale = xScale;
+				curveEdit.curve = curve;
+				curveEdits.push(curveEdit);
+				function updateExpanded() {
+					var icon = trackToggle.find(".icon");
+					if(expand)
+						icon.removeClass("fa-angle-right").addClass("fa-angle-down");
+					else
+						icon.removeClass("fa-angle-down").addClass("fa-angle-right");
+					curveEl.toggleClass("hidden", !expand);
+				}
+				trackToggle.click(function(e) {
+					expand = !expand;
+					saveDisplayState(trackKey, expand);
+					updateExpanded();
+				});
+				var dopesheet = trackEl.find(".dopesheet");
+				for(key in curve.keys) {
+					var keyEl = new Element('<span class="key">').appendTo(dopesheet);
+					inline function update() keyEl.css({left: xt(key.time)});
+					update();
+					keyEl.mousedown(function(e) {
+						var offset = dopesheet.offset();
+						startDrag(function(e) {
+							var x = ixt(e.clientX - offset.left);
+							key.time = x;
+							curveEdit.refreshGraph(true, key);
+							update();
+						}, function(e) {
+							curveEdit.refreshGraph();
+						});
+					});
+					panCallbacks.push(function(anim) {
+						update();
+					});
+				}
+				updateExpanded();
+			}
+		}
+	}
+
+	function startDrag(onMove: js.jquery.Event->Void, onStop: js.jquery.Event->Void) {
+		var el = new Element(root[0].ownerDocument.body);
+		el.on("mousemove.fxedit", onMove);
+		el.on("mouseup.fxedit", function(e: js.jquery.Event) {
+			el.off("mousemove.fxedit");
+			el.off("mouseup.fxedit");
+			e.preventDefault();
+			e.stopPropagation();
+			onStop(e);
+		});
+	}
+
+	static function getTrack(element : PrefabElement, propName : String) {
+		return element.getOpt(hide.prefab.Curve, propName);
+	}
+
+	function addTrack(element : PrefabElement, propName : String) {
+		var curve = new hide.prefab.Curve(element);
+		curve.name = propName;
+		rebuildAnimPanel();
+		return curve;
+	}
+
+	function removeTrack(element : PrefabElement, propName : String) {
+		// TODO
+		// return element.get(hide.prefab.Curve, propName);
 	}
 
 	function onUpdate(dt:Float) {
+
+		var allObjects = data.getAll(hide.prefab.Object3D);
+		for(element in allObjects) {
+			var obj3d = sceneEditor.getObject(element);
+			if(obj3d == null)
+				continue;
+			var curves = data.getCurves(element);
+			var mat = data.getTransform(curves, currentTime);
+			mat.multiply(element.getTransform(), mat);
+			obj3d.setTransform(mat);
+			if(curves.visibility != null) {
+				var visible = curves.visibility.getVal(currentTime) > 0.5;
+				obj3d.visible = element.visible && visible;
+			}
+		}
+
+		if(true) {
+			currentTime += dt / hxd.Timer.wantedFPS;
+			if(timeLineEl != null)
+				timeLineEl.css({left: xt(currentTime)});
+			if(currentTime >= selectMax) {
+				currentTime = selectMin;
+			}
+		}
+
 		var cam = scene.s3d.camera;
 		if( light != null ) {
 			var angle = Math.atan2(cam.target.y - cam.pos.y, cam.target.x - cam.pos.x);

+ 0 - 3
hide/view/Prefab.hx

@@ -144,9 +144,6 @@ class Prefab extends FileView {
 		} else
 			light = null;
 
-
-		this.saveDisplayKey = "Scene:" + state.path;
-
 		tools.saveDisplayKey = "Prefab/tools";
 		tools.addButton("video-camera", "Perspective camera", () -> sceneEditor.resetCamera(false));
 		tools.addToggle("sun-o", "Enable Lights/Shadows", function(v) {