Phi: nicer scroll animation for METAR widget
[fg:fgdata.git] / Nasal / dynamic_view.nas
1 # Dynamic Cockpit View manager. Tries to simulate the pilot's most likely
2 # deliberate view direction. Doesn't consider forced view changes due to
3 # acceleration.
4 #
5 # To override the default recipes, put something like this into one of
6 # your aircraft's Nasal files:
7 #
8 #   dynamic_view.register(func {
9 #           # me.default_plane();      # uncomment one of these if you want
10 #           # me.default_helicopter(); # to base your code on the defaults
11 #
12 #                                      # positive values rotate (deg) or move (m)
13 #           me.heading_offset = ...    #     left
14 #           me.pitch_offset = ...      #     up
15 #           me.roll_offset = ...       #     right
16 #           me.x_offset = ...          #     right     (transversal axis)
17 #           me.y_offset = ...          #     up        (vertical axis)
18 #           me.z_offset = ...          #     back/aft  (longitudinal axis)
19 #           me.fov_offset = ...        #     zoom out  (field of view)
20 #   });
21 #
22 # All offsets are by default 0, and you only need to set them if they should
23 # be non-zero. The registered function is called for each frame and the respective
24 # view parameters are set accordingly. The function can access all internal
25 # variables of the view_manager class, such as me.roll, me.pitch, etc., and it
26 # can, of course, also use module variables from the file where it's defined.
27 #
28 # The following commands move smoothly to a fixed view position and back.
29 # All values are relative to aircraft origin (absolute), not relative to
30 # the default cockpit view position. The time and field-of-view argument
31 # is optional.
32 #
33 #   dynamic_view.lookat(hdg, pitch, roll, x, y, z [, time=0.2 [, fov=55]]);
34 #   dynamic_view.resume();
35
36
37 var FREEZE_DURATION = 2;
38 var BLEND_TIME = 0.2;
39
40
41 var sin = func(a) math.sin(a * D2R);
42 var cos = func(a) math.cos(a * D2R);
43 var sigmoid = func(x) { 1 / (1 + math.exp(-x)) }
44 var nsigmoid = func(x) { 2 / (1 + math.exp(-x)) - 1 }
45 var pow = func(v, w) { v < 0 ? nil : v == 0 ? 0 : math.exp(math.ln(v) * w) }
46 var npow = func(v, w) { v == 0 ? 0 : math.exp(math.ln(abs(v)) * w) * (v < 0 ? -1 : 1) }
47 var clamp = func(v, min, max) { v < min ? min : v > max ? max : v }
48 var normatan = func(x) { math.atan2(x, 1) * 2 / math.pi }
49
50 var normdeg = func(a) {
51         while (a >= 180)
52                 a -= 360;
53         while (a < -180)
54                 a += 360;
55         return a;
56 }
57
58
59
60 # Class that reads a property value, applies factor & offset, clamps to min & max,
61 # and optionally lowpass filters.
62 #
63 var Input = {
64         new : func(prop = "/null", factor = 1, offset = 0, filter = 0, min = nil, max = nil) {
65                 var m = { parents : [Input] };
66                 m.prop = isa(props.Node, prop) ? prop : props.globals.getNode(prop, 1);
67                 m.factor = factor;
68                 m.offset = offset;
69                 m.min = min;
70                 m.max = max;
71                 m.lowpass = filter ? aircraft.lowpass.new(filter) : nil;
72                 return m;
73         },
74         get : func {
75                 var v = me.prop.getValue() * me.factor + me.offset;
76                 if (me.min != nil and v < me.min)
77                         v = me.min;
78                 if (me.max != nil and v > me.max)
79                         v = me.max;
80
81                 return me.lowpass == nil ? v : me.lowpass.filter(v);
82         },
83         set : func(v) {
84                 me.prop.setDoubleValue(v);
85         },
86 };
87
88
89
90 # Class that maintains one sim/current-view/goal-*-offset-deg property.
91 #
92 var ViewAxis = {
93         new : func(prop) {
94                 var m = { parents : [ViewAxis] };
95                 m.prop = props.globals.getNode(prop, 1);
96                 if (m.prop.getType() == "NONE")
97                         m.prop.setDoubleValue(0);
98
99                 m.reset();
100                 return m;
101         },
102         reset : func {
103                 me.applied_offset = 0;
104         },
105         add_offset : func {
106                 me.prop.setValue(me.prop.getValue() + me.applied_offset);
107         },
108         sub_offset : func {
109                 var raw = me.prop.getValue() - me.applied_offset;
110                 me.prop.setValue(raw);
111                 return raw;
112         },
113         apply : func(v) {
114                 var raw = me.prop.getValue() - me.applied_offset;
115                 me.applied_offset = v;
116                 me.prop.setDoubleValue(raw + me.applied_offset);
117         },
118         static : func(v) {
119                 normdeg(v - me.prop.getValue() + me.applied_offset);
120         },
121 };
122
123
124
125 # Singleton class that manages a dynamic cockpit view by manipulating
126 # sim/current-view/goal-*-offset-deg properties.
127 #
128 var view_manager = {
129         init : func {
130                 me.elapsedN = props.globals.getNode("/sim/time/elapsed-sec", 1);
131                 me.deltaN = props.globals.getNode("/sim/time/delta-realtime-sec", 1);
132
133                 me.headingN = props.globals.getNode("/orientation/heading-deg", 1);
134                 me.pitchN = props.globals.getNode("/orientation/pitch-deg", 1);
135                 me.rollN = props.globals.getNode("/orientation/roll-deg", 1);
136                 me.slipN = props.globals.getNode("/orientation/side-slip-deg", 1);
137                 me.speedN = props.globals.getNode("velocities/airspeed-kt", 1);
138
139                 me.wind_dirN = props.globals.getNode("/environment/wind-from-heading-deg", 1);
140                 me.wind_speedN = props.globals.getNode("/environment/wind-speed-kt", 1);
141
142                 me.axes = [
143                         me.heading_axis = ViewAxis.new("/sim/current-view/goal-heading-offset-deg"),
144                         me.pitch_axis = ViewAxis.new("/sim/current-view/goal-pitch-offset-deg"),
145                         me.roll_axis = ViewAxis.new("/sim/current-view/goal-roll-offset-deg"),
146                         me.x_axis = ViewAxis.new("/sim/current-view/x-offset-m"),
147                         me.y_axis = ViewAxis.new("/sim/current-view/y-offset-m"),
148                         me.z_axis = ViewAxis.new("/sim/current-view/z-offset-m"),
149                         me.fov_axis = ViewAxis.new("/sim/current-view/field-of-view"),
150                 ];
151
152                 # accelerations are converted to G (Earth gravitation is omitted)
153                 me.ax = Input.new("/accelerations/pilot/x-accel-fps_sec", 0.03108095, 0, 0.58, 0);
154                 me.ay = Input.new("/accelerations/pilot/y-accel-fps_sec", 0.03108095, 0, 0.95);
155                 me.az = Input.new("/accelerations/pilot/z-accel-fps_sec", -0.03108095, -1, 0.46);
156
157                 # velocities are converted to knots
158                 me.vx = Input.new("/velocities/uBody-fps", 0.5924838, 0, 0.45);
159                 me.vy = Input.new("/velocities/vBody-fps", 0.5924838, 0);
160                 me.vz = Input.new("/velocities/wBody-fps", 0.5924838, 0);
161
162                 # turn WoW bool into smooth values ranging from 0 to 1
163                 me.wow = Input.new("/gear/gear/wow", 1, 0, 0.74);
164                 me.hdg_change = aircraft.lowpass.new(0.95);
165                 me.ubody = aircraft.lowpass.new(0.95);
166                 me.last_heading = me.headingN.getValue();
167                 me.size_factor = getprop("/sim/chase-distance-m") / -25;
168
169                 # "lookat" blending
170                 me.blendN = props.globals.getNode("/sim/view/dynamic/blend", 1);
171                 me.blendN.setDoubleValue(0);
172                 me.blendtime = BLEND_TIME;
173                 me.frozen = 0;
174
175                 if (props.globals.getNode("rotors", 0) != nil)
176                         me.calculate = me.default_helicopter;
177                 else
178                         me.calculate = me.default_plane;
179                 me.reset();
180         },
181         reset : func {
182                 me.heading_offset = me.heading = me.target_heading = 0;
183                 me.pitch_offset = me.pitch = me.target_pitch = 0;
184                 me.roll_offset = me.roll = me.target_roll = 0;
185                 me.x_offset = me.x = me.target_x = 0;
186                 me.y_offset = me.y = me.target_y = 0;
187                 me.z_offset = me.z = me.target_z = 0;
188                 me.fov_offset = me.fov = me.target_fov = 0;
189
190                 interpolate(me.blendN);
191                 me.blendN.setDoubleValue(0);
192                 foreach (var a; me.axes)
193                         a.reset();
194
195                 me.add_offset();
196         },
197         add_offset : func {
198                 me.heading_axis.add_offset();
199                 me.pitch_axis.add_offset();
200                 me.roll_axis.add_offset();
201                 me.fov_axis.add_offset();
202         },
203         apply : func {
204                 if (me.elapsedN.getValue() < me.frozen)
205                         return;
206                 elsif (me.frozen)
207                         me.unfreeze();
208
209                 me.pitch = me.pitchN.getValue();
210                 me.roll = me.rollN.getValue();
211
212                 me.calculate();
213
214                 var b = me.blendN.getValue();
215                 var B = 1 - b;
216                 me.heading = me.target_heading * b + me.heading_offset * B;
217                 me.pitch = me.target_pitch * b + me.pitch_offset * B;
218                 me.roll = me.target_roll * b + me.roll_offset * B;
219                 me.x = me.target_x * b + me.x_offset * B;
220                 me.y = me.target_y * b + me.y_offset * B;
221                 me.z = me.target_z * b + me.z_offset * B;
222                 me.fov = me.target_fov * b + me.fov_offset * B;
223
224                 me.heading_axis.apply(me.heading);
225                 me.pitch_axis.apply(me.pitch);
226                 me.roll_axis.apply(me.roll);
227                 me.x_axis.apply(me.x);
228                 me.y_axis.apply(me.y);
229                 me.z_axis.apply(me.z);
230                 me.fov_axis.apply(me.fov);
231         },
232         lookat : func(heading, pitch, roll, x, y, z, time, fov) {
233                 me.target_heading = me.heading_axis.static(heading);
234                 me.target_pitch = me.pitch_axis.static(pitch);
235                 me.target_roll = me.roll_axis.static(roll);
236                 me.target_x = me.x_axis.static(x);
237                 me.target_y = me.y_axis.static(y);
238                 me.target_z = me.z_axis.static(z);
239                 me.target_fov = me.fov_axis.static(fov);
240
241                 me.blendtime = time;
242                 me.blendN.setValue(0);
243                 interpolate(me.blendN, 1, me.blendtime);
244         },
245         resume : func {
246                 interpolate(me.blendN, 0, me.blendtime);
247                 me.blendtime = BLEND_TIME;
248         },
249         freeze : func {
250                 if (!me.frozen) {
251                         me.target_heading = me.heading;
252                         me.target_pitch = me.pitch;
253                         me.target_roll = me.roll;
254                         me.target_x = me.x;
255                         me.target_y = me.y;
256                         me.target_z = me.z;
257                         me.target_fov = me.fov;
258                         me.blendN.setDoubleValue(1);
259                 }
260                 me.frozen = me.elapsedN.getValue() + FREEZE_DURATION;
261         },
262         unfreeze : func {
263                 if (me.frozen) {
264                         me.frozen = 0;
265                         me.resume();
266                 }
267         },
268 };
269
270
271
272 # default calculations for a plane
273 #
274 view_manager.default_plane = func {
275         var wow = me.wow.get();
276
277         # calculate steering factor
278         var hdg = me.headingN.getValue();
279         var hdiff = normdeg(me.last_heading - hdg);
280         me.last_heading = hdg;
281         var steering = 0; # normatan(me.hdg_change.filter(hdiff)) * me.size_factor;
282
283         var az = me.az.get();
284         var vx = me.vx.get();
285
286         # calculate sideslip factor (zeroed when no forward ground speed)
287         var wspd = me.wind_speedN.getValue();
288         var wdir = me.headingN.getValue() - me.wind_dirN.getValue();
289         var u = vx - wspd * cos(wdir);
290         var slip = sin(me.slipN.getValue()) * me.ubody.filter(normatan(u / 10));
291
292         me.heading_offset =                                                     # view heading
293                 -15 * sin(me.roll) * cos(me.pitch)                              #     due to roll
294                 + 40 * steering * wow                                           #     due to ground steering
295                 + 10 * slip * (1 - wow);                                        #     due to sideslip (in air)
296
297         me.pitch_offset =                                                       # view pitch
298                 10 * sin(me.roll) * sin(me.roll)                                #     due to roll
299                 + 30 * (1 / (1 + math.exp(2 - az))                              #     due to G load
300                         - 0.119202922);                                         #         [move to origin; 1/(1+exp(2)) ]
301
302         me.roll_offset = 0;
303 }
304
305
306
307 # default calculations for a helicopter
308 #
309 view_manager.default_helicopter = func {
310         var lowspeed = 1 - normatan(me.speedN.getValue() / 20);
311
312         me.heading_offset =                                                     # view heading due to
313                 -50 * npow(sin(me.roll) * cos(me.pitch), 2);                    #    roll
314
315         me.pitch_offset =                                                       # view pitch due to
316                 (me.pitch < 0 ? -35 : -40) * sin(me.pitch) * lowspeed           #    pitch
317                 + 15 * sin(me.roll) * sin(me.roll);                             #    roll
318
319         me.roll_offset =                                                        # view roll due to
320                 -15 * sin(me.roll) * cos(me.pitch) * lowspeed;                  #    roll
321 }
322
323
324
325 # Update loop for the whole dynamic view manager. It only runs if
326 # /sim/current-view/dynamic-view is true.
327 #
328 var main_loop = func(id) {
329         id == loop_id or return;
330         if (cockpit_view and !panel_visible) {
331                 if (mouse_button)
332                         freeze();
333                 else
334                         view_manager.apply();
335         }
336         settimer(func { main_loop(id) }, 0);
337 }
338
339
340
341 var freeze = func {
342         if (mouse_mode == 0)
343                 view_manager.freeze();
344 }
345
346 var register = func(f) {
347         view_manager.calculate = f;
348 }
349
350 var reset = func {
351         view_manager.reset();
352 }
353
354 var lookat = func {
355         call(view_manager.lookat, arg, view_manager);
356 }
357
358 var resume = func {
359         view_manager.resume();
360 }
361
362
363 var original_resetView = nil;
364 var panel_visibilityN = nil;
365 var dynamic_view = nil;
366
367 var cockpit_view = nil;
368 var panel_visible = nil;        # whether 2D panel is visible
369 var elapsedN = nil;
370 var mouse_mode = nil;
371 var mouse_button = nil;
372 var enabled = nil;
373
374 var loop_id = 0;
375
376
377 # Initialization.
378 #
379 _setlistener("/sim/signals/nasal-dir-initialized", func {
380         # disable menu entry and return for inappropriate FDMs  (see Main/fg_init.cxx)
381         var fdms = {
382                 acms:0, ada:0, balloon:0, external:0,
383                 jsb:1, larcsim:1, magic:0, network:0,
384                 null:0, pipe:0, ufo:0, yasim:1,
385         };
386         var fdm = getprop("/sim/flight-model");
387         if (!contains(fdms, fdm) or !fdms[fdm])
388                 return;
389
390         enabled = props.globals.getNode("/sim").getChildren("view");
391         forindex (var i; enabled)
392                 enabled[i] = ((var n = enabled[i].getNode("config/dynamic-view")) != nil) and n.getBoolValue();
393
394         # some properties may still be unavailable or nil
395         props.globals.initNode("/accelerations/pilot/x-accel-fps_sec", 0);
396         props.globals.initNode("/accelerations/pilot/y-accel-fps_sec", 0);
397         props.globals.initNode("/accelerations/pilot/z-accel-fps_sec", -32);
398         props.globals.initNode("/orientation/side-slip-deg", 0);
399         props.globals.initNode("/gear/gear/wow", 1, "BOOL");
400         elapsedN = props.globals.getNode("/sim/time/elapsed-sec", 1);
401
402         # let listeners keep some variables up-to-date, so that they don't have
403         # to be queried in the loop
404         setlistener("/sim/panel/visibility", func(n) { panel_visible = n.getValue() }, 1);
405         setlistener("/sim/current-view/view-number", func(n) { cockpit_view = enabled[n.getValue()] }, 1);
406         setlistener("/devices/status/mice/mouse/button", func(n) { mouse_button = n.getValue() }, 1);
407         setlistener("/devices/status/mice/mouse/x", freeze);
408         setlistener("/devices/status/mice/mouse/y", freeze);
409         setlistener("/devices/status/mice/mouse/mode", func(n) {
410                 if (mouse_mode = n.getValue())
411                         view_manager.unfreeze();
412         }, 1);
413
414         setlistener("/sim/signals/reinit", func(n) {
415                 n.getValue() and return;
416                 cockpit_view = enabled[getprop("/sim/current-view/view-number")];
417                 view_manager.reset();
418         }, 0);
419
420         view_manager.init();
421
422         original_resetView = view.resetView;
423         view.resetView = func {
424                 original_resetView();
425                 if (cockpit_view and dynamic_view)
426                         view_manager.add_offset();
427         }
428
429         settimer(func {
430                 setlistener("/sim/current-view/dynamic-view", func(n) {
431                         dynamic_view = n.getBoolValue();
432                         loop_id += 1;
433                         view.resetView();
434                         if (dynamic_view)
435                                 main_loop(loop_id);
436                 }, 1);
437         }, 0);
438 });
439
440