## ## view.nas ## ## Nasal code for implementing view-specific functionality. Right ## now, it does intelligent FOV scaling in the view.increase() and ## view.decrease() handlers. ## # # This is a neat trick. We want these globals to be initialized at # startup, but there is no guarantee that the props.nas module will be # loaded yet when we are run. So set the values to nil at startup (so # that there is a value in the lexical environment -- otherwise # assigning them in INIT() will only make local variables), # and then assign them from inside a timer that we set to run # immediately *after* startup. # # Nifty hacks notwithstanding, this really isn't the right way to do # this. There ought to be an "import" mechanism we can use to resolve # dependencies between modules. # fovProp = nil; INIT = func { fovProp = props.globals.getNode("/sim/current-view/field-of-view"); } settimer(INIT, 0); # Dynamically calculate limits so that it takes STEPS iterations to # traverse the whole range, the maximum FOV is fixed at 120 degrees, # and the minimum corresponds to normal maximum human visual acuity # (~1 arc minute of resolution, although apparently people vary widely # in this ability). Quick derivation of the math: # # mul^steps = max/min # steps * ln(mul) = ln(max/min) # mul = exp(ln(max/min) / steps) STEPS = 40; ACUITY = 1/60; # Maximum angle subtended by one pixel (== 1 arc minute) max = min = mul = 0; calcMul = func { max = 120; # Fixed at 120 degrees min = getprop("/sim/startup/xsize") * ACUITY; mul = math.exp(math.ln(max/min) / STEPS); } ## # Handler. Increase FOV by one step # increase = func { calcMul(); val = fovProp.getValue() * mul; if(val == max) { return; } if(val > max) { val = max } fovProp.setDoubleValue(val); gui.popupTip(sprintf("FOV: %.1f", val)); } ## # Handler. Decrease FOV by one step # decrease = func { calcMul(); val = fovProp.getValue() / mul; fovProp.setDoubleValue(val); gui.popupTip(sprintf("FOV: %.1f%s", val, val < min ? " (overzoom)" : "")); } ## # Handler. Reset FOV to default. # resetFOV = func { setprop("/sim/current-view/field-of-view", getprop("/sim/current-view/config/default-field-of-view-deg")); } ## # Handler. Reset view to default. # resetView = func { setprop("/sim/current-view/goal-heading-offset-deg", getprop("/sim/current-view/config/heading-offset-deg")); setprop("/sim/current-view/goal-pitch-offset-deg", getprop("/sim/current-view/config/pitch-offset-deg")); setprop("/sim/current-view/goal-roll-offset-deg", getprop("/sim/current-view/config/roll-offset-deg")); resetFOV(); } ## # Handler. Step to the next view. # stepView = func { curr = getprop("/sim/current-view/view-number"); views = props.globals.getNode("/sim").getChildren("view"); curr = curr + arg[0]; if (curr < 0) { curr = size(views) - 1; } elsif(curr >= size(views)) { curr = 0; } setprop("/sim/current-view/view-number", curr); # And pop up a nice reminder gui.popupTip(views[curr].getNode("name").getValue()); } ## # Standard view "slew" rate, in degrees/sec. # VIEW_PAN_RATE = 60; ## # Pans the view horizontally. The argument specifies a relative rate # (or number of "steps" -- same thing) to the standard rate. # panViewDir = func { controls.slewProp("/sim/current-view/goal-heading-offset-deg", arg[0] * VIEW_PAN_RATE); } ## # Pans the view vertically. The argument specifies a relative rate # (or number of "steps" -- same thing) to the standard rate. # panViewPitch = func { controls.slewProp("/sim/current-view/goal-pitch-offset-deg", arg[0] * VIEW_PAN_RATE); } #-- view manager -------------------------------------------------------------- # # Saves/restores/moves the view point (position, orientation, field-of-view). # Moves are interpolated with sinusoidal characteristic. There's only one # instance of this class, available as "view.point". # # Usage: # view.point.save(); ... save current view and return reference to # saved values in the form of a props.Node # # view.point.restore(); ... restore saved view parameters # # view.point.move(<prop> [, <time>]); # ... set view parameters from a props.Node with # optional move time in seconds. <prop> may be # nil, in which case nothing happens. # # A parameter set as expected by set() and returned by save() is a props.Node # object containing any (or none) of these children: # # <heading-offset-deg> # <pitch-offset-deg> # <roll-offset-deg> # <x-offset-m> # <y-offset-m> # <z-offset-m> # <field-of-view> # <move-time-sec> # # The <move-time> isn't really a property of the view, but is available # for convenience. The time argument in the move() method overrides it. ## # Normalize angle to -180 <= angle < 180 # var normdeg = func(a) { while (a >= 180) { a -= 360; } while (a < -180) { a += 360; } return a; } ## # Manages one translation/rotation axis. (For simplicity reasons the # field-of-view parameter is also managed by this class.) # var ViewAxis = { new : func(prop) { var m = { parents : [ViewAxis] }; m.prop = props.globals.getNode(prop, 1); if (m.prop.getType() == "NONE") { m.prop.setDoubleValue(0); } m.from = m.to = m.prop.getValue(); return m; }, reset : func { me.from = me.to = normdeg(me.prop.getValue()); }, target : func(v) { me.to = normdeg(v); }, move : func(blend) { me.prop.setValue(me.from + blend * (me.to - me.from)); }, }; var ViewManager = { new : func { var m = { parents : [ViewManager] }; m.axes = { "heading-offset-deg" : ViewAxis.new("/sim/current-view/goal-heading-offset-deg"), "pitch-offset-deg" : ViewAxis.new("/sim/current-view/goal-pitch-offset-deg"), "roll-offset-deg" : ViewAxis.new("/sim/current-view/goal-roll-offset-deg"), "x-offset-m" : ViewAxis.new("/sim/current-view/x-offset-m"), "y-offset-m" : ViewAxis.new("/sim/current-view/y-offset-m"), "z-offset-m" : ViewAxis.new("/sim/current-view/z-offset-m"), "field-of-view" : ViewAxis.new("/sim/current-view/field-of-view"), }; m.storeN = props.Node.new(); m.dtN = props.globals.getNode("/sim/time/delta-realtime-sec", 1); m.currviewN = props.globals.getNode("/sim/current-view", 1); m.blend = 0; m.loop_id = 0; props.copy(props.globals.getNode("/sim/view/config"), m.storeN); return m; }, save : func { me.storeN = props.Node.new(); props.copy(me.currviewN, me.storeN); return me.storeN; }, restore : func { me.move(me.storeN); }, move : func(prop, time = nil) { prop != nil or return; foreach (var a; keys(me.axes)) { var n = prop.getNode(a); me.axes[a].reset(); if (n != nil) { me.axes[a].target(n.getValue()); } } var m = prop.getNode("move-time-sec"); if (m != nil) { time = m.getValue(); } if (time == nil) { time = 1; } me.blend = -1; # range -1 .. 1 me._loop_(me.loop_id += 1, time); }, _loop_ : func(id, time) { me.loop_id == id or return; me.blend += me.dtN.getValue() / time; if (me.blend > 1) { me.blend = 1; } var b = (math.sin(me.blend * math.pi / 2) + 1) / 2; # range 0 .. 1 foreach (var a; keys(me.axes)) { me.axes[a].move(b); } if (me.blend < 1) { settimer(func { me._loop_(id, time) }, 0); } }, }; var point = nil; _setlistener("/sim/signals/nasal-dir-initialized", func { point = ViewManager.new(); ViewManager.new = nil; });