1 /**
2 Knob.
3 
4 Copyright: Copyright Auburn Sounds 2015-2017.
5 License:   $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0)
6 Authors:   Guillaume Piolat
7 */
8 module dplug.pbrwidgets.knob;
9 
10 import std.math: PI, exp, abs, sin, cos;
11 
12 import dplug.core.math;
13 
14 import dplug.gui.element;
15 
16 import dplug.client.params;
17 
18 enum KnobStyle
19 {
20     thumb, // with a hole
21     cylinder,
22     cone,
23     ball
24 }
25 
26 class UIKnob : UIElement, IParameterListener
27 {
28 public:
29 nothrow:
30 @nogc:
31 
32     // This will change to 1.0f at one point for consistency, so better express your knob
33     // sensivity with that.
34     enum defaultSensivity = 0.25f;
35 
36     //
37     // Modify these public members to customize knobs!
38     //
39     @ScriptProperty float knobRadius = 0.75f;
40     @ScriptProperty RGBA knobDiffuse = RGBA(233, 235, 236, 0); // Last channel is ignored.
41     @ScriptProperty RGBA knobMaterial = RGBA(0, 255, 128, 255);
42     @ScriptProperty KnobStyle style = KnobStyle.thumb;
43     @ScriptProperty ubyte emissive = 0;
44     @ScriptProperty ubyte emissiveHovered = 30;
45     @ScriptProperty ubyte emissiveDragged = 0;
46 
47     // LEDs
48     @ScriptProperty int numLEDs = 7;
49     @ScriptProperty float LEDRadiusMin = 0.127f;
50     @ScriptProperty float LEDRadiusMax = 0.127f;
51     @ScriptProperty RGBA LEDDiffuseLit = RGBA(255, 140, 220, 215);
52     @ScriptProperty RGBA LEDDiffuseUnlit = RGBA(255, 140, 220, 40);
53     @ScriptProperty RGBA LEDMaterial = RGBA(128, 128, 255, 255);
54     @ScriptProperty float LEDDistanceFromCenter = 0.8f;
55     @ScriptProperty float LEDDistanceFromCenterDragged = 0.7f;
56     @ScriptProperty ushort LEDDepth = 65000;
57 
58     // trail
59     @ScriptProperty bool hasTrail = true;
60     @ScriptProperty RGBA litTrailDiffuse = RGBA(230, 80, 43, 192);
61     @ScriptProperty RGBA unlitTrailDiffuse = RGBA(150, 40, 20, 8);
62     @ScriptProperty float trailRadiusMin = 0.85f;
63     @ScriptProperty float trailRadiusMax = 0.97f;
64     @ScriptProperty float trailOffsetX = 0.0f; // in ratio of knob size
65     @ScriptProperty float trailOffsetY = 0.0f; // in ratio of knob size
66 
67     @ScriptProperty float trailMinAngle = -PI * 0.75f;
68     @ScriptProperty float trailBaseAngle = -PI * 0.75f;
69     @ScriptProperty float trailMaxAngle = +PI * 0.75f;
70 
71     // alternate trail is for values below base angle
72     // For example, knob trails can be blue for positive values,
73     // and orange for negative.
74     @ScriptProperty RGBA litTrailDiffuseAlt = RGBA(43, 80, 230, 192);
75     @ScriptProperty bool hasAlternateTrail = false;
76 
77 
78     @ScriptProperty float animationTimeConstant = 40.0f;
79 
80     this(UIContext context, Parameter param)
81     {
82         super(context, flagAnimated | flagPBR);
83         _param = param;
84         _sensivity = defaultSensivity;
85         _param.addListener(this);
86         _pushedAnimation = 0;
87         clearCrosspoints();
88         setCursorWhenDragged(MouseCursor.hidden);
89         setCursorWhenMouseOver(MouseCursor.move);
90     }
91 
92     ~this()
93     {
94         _param.removeListener(this);
95     }
96 
97     override void onAnimate(double dt, double time) nothrow @nogc
98     {
99         float target = isDragged() ? 1 : 0;
100 
101         float newAnimation = lerp(_pushedAnimation, target, 1.0 - exp(-dt * animationTimeConstant));
102 
103         if (abs(newAnimation - _pushedAnimation) > 0.001f)
104         {
105             _pushedAnimation = newAnimation;
106             setDirtyWhole();
107         }
108     }
109 
110     /// Returns: sensivity.
111     float sensivity()
112     {
113         return _sensivity;
114     }
115 
116     /// Sets sensivity.
117     float sensivity(float sensivity)
118     {
119         return _sensivity = sensivity;
120     }
121 
122     override void onDrawPBR(ImageRef!RGBA diffuseMap, ImageRef!L16 depthMap, ImageRef!RGBA materialMap, box2i[] dirtyRects) nothrow @nogc
123     {
124         if (hasTrail)
125             drawTrail(diffuseMap, depthMap, materialMap, dirtyRects);
126         drawKnob(diffuseMap, depthMap, materialMap, dirtyRects);
127     }
128 
129     void drawTrail(ImageRef!RGBA diffuseMap, ImageRef!L16 depthMap, ImageRef!RGBA materialMap, box2i[] dirtyRects) nothrow @nogc
130     {
131         float radius = getRadius();
132         vec2f center = getCenter();
133 
134         float knobRadiusPx = radius * knobRadius;
135 
136         vec2f trailOffset = vec2f(knobRadiusPx * trailOffsetX, knobRadiusPx * trailOffsetY);
137 
138         // Eventually, use the alternative trail color
139         RGBA litTrail = litTrailDiffuse;
140         bool alternateTrail = false;
141         if (getValueAngle < getBaseAngle)
142         {
143             alternateTrail = true;
144             if (hasAlternateTrail)
145             {
146                 litTrail = litTrailDiffuseAlt;
147             }
148         }
149 
150         // when dragged, trail is two times brighter
151         if (isDragged)
152         {
153             int alpha = 2 * litTrail.a;
154             if (alpha > 255) alpha = 255;
155             litTrail.a = cast(ubyte) alpha;
156         }
157 
158         foreach(dirtyRect; dirtyRects)
159         {
160             auto croppedDiffuse = diffuseMap.cropImageRef(dirtyRect);
161             auto croppedDepth = depthMap.cropImageRef(dirtyRect);
162             auto croppedMaterial = materialMap.cropImageRef(dirtyRect);
163 
164             int bx = dirtyRect.min.x;
165             int by = dirtyRect.min.y;
166 
167             //
168             // Draw trail
169             //
170             {
171                 float trailCenterX = center.x - bx + trailOffset.x;
172                 float trailCenterY = center.y - by + trailOffset.y;
173 
174                 float valueAngle = getValueAngle();
175                 float minAngle = getMinAngle();
176                 float maxAngle = getMaxAngle();
177                 float baseAngle = getBaseAngle();
178 
179                 // Order is minAngle <= valueAngle-when-alt <= baseAngle <= valueAngle-when-not-alt <= maxAngle
180 
181                 float rmin = radius * trailRadiusMin;
182                 float rmax = radius * trailRadiusMax;
183 
184                 // trail below baseAngle
185                 if (alternateTrail)
186                 {
187                     croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, minAngle, valueAngle, unlitTrailDiffuse);
188                     croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, valueAngle, baseAngle, litTrail);
189                 }
190                 else
191                 {
192                     croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, minAngle, baseAngle, unlitTrailDiffuse);
193                 }
194 
195                 // trail above baseAngle
196                 if (alternateTrail)
197                 {
198                     croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, baseAngle, maxAngle, unlitTrailDiffuse);
199                 }
200                 else
201                 {
202                     croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, baseAngle, valueAngle, litTrail);
203                     croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, rmin, rmax, valueAngle, maxAngle, unlitTrailDiffuse);
204                 }
205             }
206         }
207     }
208 
209     void drawKnob(ImageRef!RGBA diffuseMap, ImageRef!L16 depthMap, ImageRef!RGBA materialMap, box2i[] dirtyRects) nothrow @nogc
210     {
211         float radius = getRadius();
212         vec2f center = getCenter();
213 
214         float knobRadiusPx = radius * knobRadius;
215 
216         foreach(dirtyRect; dirtyRects)
217         {
218             auto croppedDiffuse = diffuseMap.cropImageRef(dirtyRect);
219             auto croppedDepth = depthMap.cropImageRef(dirtyRect);
220             auto croppedMaterial = materialMap.cropImageRef(dirtyRect);
221 
222             int bx = dirtyRect.min.x;
223             int by = dirtyRect.min.y;
224 
225             //
226             // Draw knob
227             //
228             float angle = getValueAngle + PI * 0.5f;
229             float depthRadius = knobRadiusPx * 3.0f / 5.0f;
230             if (depthRadius < 0) 
231                 depthRadius = 0;
232             float depthRadius2 = knobRadiusPx * 3.0f / 5.0f;
233             if (depthRadius2 < 0) 
234                 depthRadius2 = 0;
235 
236             float posEdgeX = center.x + sin(angle) * depthRadius2;
237             float posEdgeY = center.y - cos(angle) * depthRadius2;
238 
239             ubyte emissiveComputed = emissive;
240             if (_shouldBeHighlighted)
241                 emissiveComputed = emissiveHovered;
242             if (isDragged)
243                 emissiveComputed = emissiveDragged;
244 
245             if (style == KnobStyle.thumb)
246             {
247                 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, depthRadius, knobRadiusPx, L16(65535));
248                 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, 0, depthRadius, L16(38400));
249             }
250             else if (style == KnobStyle.cylinder)
251             {
252                 L16 depth = L16(cast(ushort)(0.5f + lerp(65535.0f, 45000.0f, _pushedAnimation)) );
253                 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, knobRadiusPx - 5, knobRadiusPx, depth);
254             }
255             else if (style == KnobStyle.cone)
256             {
257                 L16 depth = L16(cast(ushort)(0.5f + lerp(65535.0f, 45000.0f, _pushedAnimation)) );
258                 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, 0, knobRadiusPx, depth);
259             }
260             else if (style == KnobStyle.ball)
261             {
262                 L16 depth = L16(cast(ushort)(0.5f + lerp(65535.0f, 45000.0f, _pushedAnimation)) );
263                 croppedDepth.aaSoftDisc!1.2f(center.x - bx, center.y - by, 2, knobRadiusPx, depth);
264             }
265             RGBA knobDiffuseLit = knobDiffuse;
266             knobDiffuseLit.a = emissiveComputed;
267             croppedDiffuse.aaSoftDisc(center.x - bx, center.y - by, knobRadiusPx - 1, knobRadiusPx, knobDiffuseLit);
268             croppedMaterial.aaSoftDisc(center.x - bx, center.y - by, knobRadiusPx - 5, knobRadiusPx, knobMaterial);
269 
270 
271             // LEDs
272             for (int i = 0; i < numLEDs; ++i)
273             {
274                 float disp = i * 2 * PI / numLEDs;
275                 float distance = lerp(LEDDistanceFromCenter, LEDDistanceFromCenterDragged, _pushedAnimation);
276                 float x = center.x + sin(angle + disp) * knobRadiusPx * distance;
277                 float y = center.y - cos(angle + disp) * knobRadiusPx * distance;
278 
279                 float t = -1 + 2 * abs(disp - PI) / PI;
280 
281                 float LEDRadius = lerp(LEDRadiusMin, LEDRadiusMax, t);
282                 if (LEDRadius < 0)
283                     LEDRadius = 0;
284 
285                 float smallRadius = knobRadiusPx * LEDRadius * 0.714f;
286                 float largerRadius = knobRadiusPx * LEDRadius;
287 
288                 RGBA LEDDiffuse;
289                 LEDDiffuse.r = cast(ubyte)lerp!float(LEDDiffuseUnlit.r, LEDDiffuseLit.r, _pushedAnimation);
290                 LEDDiffuse.g = cast(ubyte)lerp!float(LEDDiffuseUnlit.g, LEDDiffuseLit.g, _pushedAnimation);
291                 LEDDiffuse.b = cast(ubyte)lerp!float(LEDDiffuseUnlit.b, LEDDiffuseLit.b, _pushedAnimation);
292                 LEDDiffuse.a = cast(ubyte)lerp!float(LEDDiffuseUnlit.a, LEDDiffuseLit.a, _pushedAnimation);
293 
294                 croppedDepth.aaSoftDisc(x - bx, y - by, 0, largerRadius, L16(LEDDepth));
295                 croppedDiffuse.aaSoftDisc(x - bx, y - by, 0, largerRadius, LEDDiffuse);
296                 croppedMaterial.aaSoftDisc(x - bx, y - by, smallRadius, largerRadius, LEDMaterial);
297             }
298         }
299     }
300 
301     /// Override this to round the parameter normalized value to some particular values.
302     /// For example for a special drag movement with right-click or control-click.
303     /// Default does nothing.
304     /// Params:
305     ///      normalizedValue The main parameter normalized value (0 to 1).
306     /// Returns: the mapped value in normalized space (0 to 1).
307     double roundParamValue(double normalizedValue, MouseState mstate)
308     {
309         return normalizedValue;
310     }
311 
312     override Click onMouseClick(int x, int y, int button, bool isDoubleClick, MouseState mstate)
313     {
314         if (!containsPoint(x, y))
315             return Click.unhandled;
316 
317         // double-click => set to default
318         if (isDoubleClick || mstate.altPressed)
319         {
320             _param.beginParamEdit();
321             if (auto fp = cast(FloatParameter)_param)
322                 fp.setFromGUI(fp.defaultValue());
323             else if (auto ip = cast(IntegerParameter)_param)
324                 ip.setFromGUI(ip.defaultValue());
325             else
326                 assert(false);
327             _param.endParamEdit();
328         }
329 
330         _normalizedValueWhileDragging = _param.getNormalized();
331         _displacementInHeightDebt = 0;
332         return Click.startDrag;
333     }
334 
335     // Called when mouse drag this Element.
336     override void onMouseDrag(int x, int y, int dx, int dy, MouseState mstate)
337     {
338         _displacementInHeightDebt += cast(float)(dy) / _position.height;
339 
340         float modifier = 1.0f;
341         if (mstate.shiftPressed || mstate.ctrlPressed)
342             modifier *= 0.1f;
343 
344         double oldParamValue = _normalizedValueWhileDragging;
345         double newParamValue = oldParamValue - _displacementInHeightDebt * modifier * _sensivity;
346         if (mstate.altPressed)
347             newParamValue = _param.getNormalizedDefault();
348 
349         if (y > _mousePosOnLast0Cross)
350             return;
351         if (y < _mousePosOnLast1Cross)
352             return;
353 
354         if (newParamValue <= 0 && oldParamValue > 0)
355             _mousePosOnLast0Cross = y;
356 
357         if (newParamValue >= 1 && oldParamValue < 1)
358             _mousePosOnLast1Cross = y;
359 
360         if (newParamValue < 0)
361             newParamValue = 0;
362         if (newParamValue > 1)
363             newParamValue = 1;
364 
365         if (newParamValue > 0)
366             _mousePosOnLast0Cross = float.infinity;
367 
368         if (newParamValue < 1)
369             _mousePosOnLast1Cross = -float.infinity;
370 
371         newParamValue = roundParamValue(newParamValue, mstate);
372 
373         if (newParamValue != oldParamValue)
374         {
375             if (auto fp = cast(FloatParameter)_param)
376                 fp.setFromGUINormalized(newParamValue);
377             else if (auto ip = cast(IntegerParameter)_param)
378                 ip.setFromGUINormalized(newParamValue);
379             else
380                 assert(false);
381             _normalizedValueWhileDragging = newParamValue;
382             _displacementInHeightDebt = 0;
383         }
384 
385     }
386 
387     // For lazy updates 
388     override void onBeginDrag()
389     {
390         _param.beginParamEdit();
391         setDirtyWhole();
392     }
393 
394     override void onStopDrag()
395     {
396         _param.endParamEdit();
397         clearCrosspoints();
398         setDirtyWhole();
399     }
400 
401     override void onMouseMove(int x, int y, int dx, int dy, MouseState mstate)
402     {
403         _shouldBeHighlighted = containsPoint(x, y);
404         setDirtyWhole();
405     }
406 
407     override void onMouseEnter()
408     {
409         _param.beginParamHover();
410     }
411 
412     override void onMouseExit()
413     {
414         _param.endParamHover();
415         _shouldBeHighlighted = false;
416         setDirtyWhole();
417     }
418 
419     override void onParameterChanged(Parameter sender) nothrow @nogc
420     {
421         setDirtyWhole();
422     }
423 
424     override void onBeginParameterEdit(Parameter sender)
425     {
426     }
427 
428     override void onEndParameterEdit(Parameter sender)
429     {
430     }
431 
432     override void onBeginParameterHover(Parameter sender)
433     {
434     }
435 
436     override void onEndParameterHover(Parameter sender)
437     {
438     }
439 
440 protected:
441 
442     /// The parameter this knob is linked with.
443     Parameter _param;
444 
445     float _pushedAnimation;
446 
447     /// Sensivity: given a mouse movement in 100th of the height of the knob,
448     /// how much should the normalized parameter change.
449     float _sensivity;
450 
451     bool _shouldBeHighlighted = false;
452 
453     float _mousePosOnLast0Cross;
454     float _mousePosOnLast1Cross;
455 
456     // Normalized value last set while dragging. Necessary for integer paramerers 
457     // that may round that normalized value when setting the parameter.
458     float _normalizedValueWhileDragging;
459 
460 
461     // To support rounded mappings properly.
462     double _displacementInHeightDebt = 0.0;
463 
464     /// Exists because public angle properties are given in a
465     /// different referential, where 0 is at the top
466     static float angleConvert(float angle) nothrow @nogc pure
467     {
468         return angle + PI * 1.5f;
469     }
470 
471     /// Min angle of the trail, fit for `aaFillSector`.
472     float getMinAngle() const pure nothrow @nogc
473     {
474         return angleConvert(trailMinAngle);
475     }
476 
477     /// Min angle of the trail, fit for `aaFillSector`.
478     float getMaxAngle() const pure nothrow @nogc
479     {
480         return angleConvert(trailMaxAngle);
481     }
482 
483     /// Max angle of the trail, fit for `aaFillSector`.
484     float getBaseAngle() const pure nothrow @nogc
485     {
486         return angleConvert(trailBaseAngle);
487     }
488 
489     /// Angle of the trail, fit for `aaFillSector`.
490     float getValueAngle() nothrow @nogc
491     {
492         return lerp(getMinAngle, getMaxAngle, _param.getNormalized());
493     }
494 
495     void clearCrosspoints() nothrow @nogc
496     {
497         _mousePosOnLast0Cross = float.infinity;
498         _mousePosOnLast1Cross = -float.infinity;
499     }
500 
501     final bool containsPoint(int x, int y) nothrow @nogc
502     {
503         vec2f center = getCenter();
504         return vec2f(x, y).distanceTo(center) < getRadius();
505     }
506 
507     /// Returns: largest square centered in _position
508     final box2i getSubsquare() pure const nothrow @nogc
509     {
510         // We'll draw entirely in the largest centered square in _position.
511         box2i subSquare;
512         if (_position.width > _position.height)
513         {
514             int offset = (_position.width - _position.height) / 2;
515             int minX = offset;
516             subSquare = box2i(minX, 0, minX + _position.height, _position.height);
517         }
518         else
519         {
520             int offset = (_position.height - _position.width) / 2;
521             int minY = offset;
522             subSquare = box2i(0, minY, _position.width, minY + _position.width);
523         }
524         return subSquare;
525     }
526 
527     final float getRadius() pure const nothrow @nogc
528     {
529         return getSubsquare().width * 0.5f;
530 
531     }
532 
533     final vec2f getCenter() pure const nothrow @nogc
534     {
535         box2i subSquare = getSubsquare();
536         float centerx = (subSquare.min.x + subSquare.max.x - 1) * 0.5f;
537         float centery = (subSquare.min.y + subSquare.max.y - 1) * 0.5f;
538         return vec2f(centerx, centery);
539     }
540 }