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