WIP WYSIWYG ~3D SVG editor.
1class Zoodle {
2 constructor(sceneElem, overlayElem, uiElem, propElem, outlineElem) {
3 // TODO: Calculate appropriate zooms and sizes
4 let zoom = 30;
5 let rotate = { x: -Math.atan(1 / Math.sqrt(2)), y: Zdog.TAU / 8 };
6
7 this.scene = new Zdog.Illustration({
8 element: sceneElem,
9 zoom: zoom,
10 resize: 'fullscreen',
11 rotate: rotate,
12 dragRotate: false,
13 });
14 this.overlay = new Zdog.Illustration({
15 element: overlayElem,
16 zoom: zoom,
17 resize: 'fullscreen',
18 rotate: rotate,
19 dragRotate: false,
20 });
21 this.ui = new Zdog.Illustration({
22 element: uiElem,
23 zoom: zoom,
24 resize: 'fullscreen',
25 rotate: rotate,
26 dragRotate: false,
27 });
28
29 this.selection = [];
30 this.history = new History();
31 this.tools = {
32 orbit: new OrbitTool(this),
33 translate: new TranslateTool(this),
34 rotate: new RotateTool(this),
35 };
36 this.tool = this.tools.translate;
37
38 this.props = new Properties(propElem, this);
39 this.outliner = new Outliner(outlineElem, this);
40
41 this.presets = {
42 solar: new Solar(),
43 }
44
45 this.presets.solar.load(this.scene);
46
47 // TODO: We should capture input when dragging, but this seems to break clicking...
48 this.sceneInput = new Zfetch({
49 scene: this.scene,
50 click: this.click.bind(this),
51 dragStart: this.dragStart.bind(this),
52 dragMove: this.dragMove.bind(this),
53 dragEnd: this.dragEnd.bind(this),
54 });
55
56 this.uiInput = new Zfetch({
57 scene: this.ui,
58 capture: true,
59 click: this.click.bind(this),
60 dragStart: this.dragStart.bind(this),
61 dragMove: this.dragMove.bind(this),
62 dragEnd: this.dragEnd.bind(this),
63 });
64
65 uiElem.addEventListener("gotpointercapture", _ => uiElem.classList.add("active"));
66 uiElem.addEventListener("lostpointercapture", _ => uiElem.classList.remove("active"));
67
68 // TODO: Maybe we shouldn't listen to these if they're in an input element
69 this.registerShortcut("z", this.history.undo.bind(this.history), true);
70 this.registerShortcut("Z", this.history.redo.bind(this.history), true);
71 this.registerShortcut("y", this.history.redo.bind(this.history), true);
72
73 this.selection = [this.scene.children[0].children[0]];
74
75 this.props.updatePanel();
76 this.outliner.updatePanel();
77 this.updateHighlights();
78 this.updateUI();
79 this.update();
80 }
81
82 registerShortcut(key, callback, preventDefault) {
83 document.body.addEventListener("keydown", e => {
84 if (e.ctrlKey && e.key === key) {
85 if (preventDefault) {
86 e.preventDefault();
87 }
88 callback();
89 }
90 });
91 }
92
93 update() {
94 this.syncLayers();
95 this.scene.updateRenderGraph();
96 this.overlay.updateRenderGraph();
97 this.ui.updateRenderGraph();
98 //this.scene.element.setAttribute("data-zdog", JSON.stringify(this.scene));
99 requestAnimationFrame(this.update.bind(this));
100 }
101
102 set tool(tool) {
103 this._tool = tool;
104 this.updateUI();
105 }
106 get tool() { return this._tool; }
107
108 // Perform a command and add it to the history.
109 do(command) {
110 this.history.push(command);
111 command.do();
112 }
113
114 // Store a record of a command that has already been performed.
115 did(command) {
116 this.history.push(command);
117 }
118
119 setSelection(targets) {
120 if (!Array.isArray(targets)) {
121 targets = [targets];
122 }
123
124 this.selection = targets;
125 }
126
127 toggleSelection(target) {
128 const index = this.selection.indexOf(target);
129 if (index !== -1) {
130 this.selection.splice(index, 1);
131 } else {
132 this.selection.push(target);
133 }
134 }
135
136 clearSelection() {
137 this.selection.length = 0;
138 }
139
140 updateHighlights() {
141 this.outliner.updateHighlights();
142
143 this.overlay.children = [];
144 this.selection.forEach((selected) => {
145 let highlight = selected.copyGraph({
146 addTo: this.overlay,
147 color: "#E62",
148 backface: "#E62",
149 ...this.getWorldTransforms(selected),
150 });
151
152 // Highlight all children
153 highlight.flatGraph.forEach((child) => {
154 child.color = '#E62';
155 child.backface = '#E62';
156 });
157
158 // Apply parent transforms to selected object.
159 // Zdog.extend(highlight, this.getWorldTransforms(selected));
160 });
161 }
162
163 updateUI() {
164 this.ui.children = [];
165
166 if (this.selection.length === 0) {
167 return;
168 }
169
170 this.tool.drawWidget(this.selection);
171 }
172
173 syncLayers() {
174 const scene = this.scene;
175 const targets = [this.overlay, this.ui];
176
177 targets.forEach((target) => {
178 target.translate = scene.translate;
179 target.rotate = scene.rotate;
180 target.scale = scene.scale;
181 target.zoom = scene.zoom;
182 target.updateGraph();
183 });
184 }
185
186 getWorldTransforms(target) {
187 let translate = new Zdog.Vector();
188 let rotate = new Zdog.Vector();
189 let scale = new Zdog.Vector({x: 1, y: 1, z: 1});
190
191 let right = new Zdog.Vector({x: 1, y: 0, z: 0});
192 let down = new Zdog.Vector({x: 0, y: 1, z: 0});
193 let forward = new Zdog.Vector({x: 0, y: 0, z: 1});
194
195 // Condense transforms from all ancestors except the root.
196 while (target.addTo) {
197 translate.transform(target.translate, target.rotate, target.scale);
198 forward.rotate(target.rotate);
199 down.rotate(target.rotate);
200 right.rotate(target.rotate);
201 scale.multiply(target.scale);
202
203 target = target.addTo;
204 }
205
206 if (Math.abs(forward.x) > 0.99999) {
207 // Gimbal lock case
208 rotate.x = 0;
209 rotate.y = forward.x > 0 ? -Math.PI/2 : Math.PI/2;
210 rotate.z = Math.atan2(right.y, down.y);
211 } else {
212 rotate.y = -Math.asin(forward.x);
213 rotate.x = Math.atan2(-forward.y, forward.z);
214 rotate.z = Math.atan2(-down.x, right.x);
215 }
216 return {translate: translate, rotate: rotate, scale: scale};
217 }
218
219 // input
220 click(ptr, target, x, y) {
221 target.layer = this.getLayer(target.element)
222 this.do(new SelectCommand(this, target));
223 }
224
225 dragStart(ptr, target, x, y) {
226 target.layer = this.getLayer(target.element);
227 if (!target || !target.addTo) {
228 this.tool = new TemporaryTool(this, this.tool, this.tools.orbit, true);
229 }
230
231 this.tool.start(ptr, target, x, y);
232 }
233
234 dragMove(ptr, target, x, y) {
235 target.layer = this.getLayer(target.element);
236 this.tool.move(ptr, target, x, y);
237 }
238
239 dragEnd(ptr, target, x, y) {
240 target.layer = this.getLayer(target.element);
241 this.tool.end(ptr, target, x, y);
242 }
243
244 getLayer(element) {
245 switch (element) {
246 case this.scene.element:
247 return Zoodle.LAYER_CANVAS;
248 case this.overlay.element:
249 return Zoodle.LAYER_OVERLAY;
250 case this.ui.element:
251 return Zoodle.LAYER_UI;
252 default:
253 console.error(`Unsupported element ${element}`);
254 }
255 }
256
257 // returns the distance of a point (x, y) from the origin along the axis defined by the angle.
258 getAxisDistance(x, y, angle) {
259 return x * Math.cos(angle) + y * Math.sin(angle);
260 }
261
262 static get LAYER_CANVAS() { return 1; }
263 static get LAYER_OVERLAY() { return 2; }
264 static get LAYER_UI() { return 3; }
265}
266
267class History {
268 constructor() {
269 this.undoStack = [];
270 this.redoStack = [];
271 }
272
273 push(command) {
274 this.undoStack.push(command);
275 this.redoStack = [];
276 }
277
278 undo() {
279 if (this.undoStack.length === 0) {
280 return;
281 }
282
283 let command = this.undoStack.pop();
284 command.undo();
285 this.redoStack.push(command);
286 }
287
288 redo() {
289 if (this.redoStack.length === 0) {
290 return;
291 }
292
293 let command = this.redoStack.pop();
294 command.do();
295 this.undoStack.push(command);
296 }
297}
298
299const sceneElem = document.querySelector("#canvas");
300const overlayElem = document.querySelector("#overlay");
301const uiElem = document.querySelector("#ui");
302const propElem = document.querySelector("#properties");
303const outlineElem = document.querySelector("#outliner");
304const zoodle = new Zoodle(sceneElem, overlayElem, uiElem, propElem, outlineElem);