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