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