1 /**
2 * PBR widget: knob.
3 * Copyright: Copyright Auburn Sounds 2015 and later.
4 * License:   $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0)
5 * Authors:   Guillaume Piolat
6 */
7 module dplug.pbrwidgets.knob;
8 
9 import std.math;
10 import std.algorithm.comparison;
11 
12 import dplug.core.math;
13 import dplug.graphics.drawex;
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     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         foreach(dirtyRect; dirtyRects)
134         {
135             auto croppedDiffuse = diffuseMap.cropImageRef(dirtyRect);
136             auto croppedDepth = depthMap.cropImageRef(dirtyRect);
137             auto croppedMaterial = materialMap.cropImageRef(dirtyRect);
138 
139             int bx = dirtyRect.min.x;
140             int by = dirtyRect.min.y;
141 
142             //
143             // Draw trail
144             //
145             {
146                 float trailCenterX = center.x - bx + trailOffset.x;
147                 float trailCenterY = center.y - by + trailOffset.y;
148 
149                 croppedDiffuse.aaFillSector(trailCenterX, trailCenterY,
150                                             radius * trailRadiusMin, radius * trailRadiusMax,
151                                             getMinAngle, getMaxAngle, unlitTrailDiffuse);
152 
153                 // Eventually, use the alternative trail color
154                 RGBA litTrail = litTrailDiffuse;
155                 if (hasAlternateTrail && getValueAngle < getBaseAngle)
156                     litTrail = litTrailDiffuseAlt;
157 
158                 // when dragged, trail is two times brighter
159                 if (isDragged)
160                 {
161                     litTrail.a = cast(ubyte) std.algorithm.min(255, 2 * litTrail.a);                    
162                 }
163 
164                 croppedDiffuse.aaFillSector(trailCenterX, trailCenterY, radius * trailRadiusMin, radius * trailRadiusMax, 
165                                             min(getBaseAngle, getValueAngle), max(getBaseAngle, getValueAngle), litTrail);
166             }
167         }
168     }
169 
170     void drawKnob(ImageRef!RGBA diffuseMap, ImageRef!L16 depthMap, ImageRef!RGBA materialMap, box2i[] dirtyRects) nothrow @nogc
171     {
172         float radius = getRadius();
173         vec2f center = getCenter();
174 
175         float knobRadiusPx = radius * knobRadius;
176 
177         foreach(dirtyRect; dirtyRects)
178         {
179             auto croppedDiffuse = diffuseMap.cropImageRef(dirtyRect);
180             auto croppedDepth = depthMap.cropImageRef(dirtyRect);
181             auto croppedMaterial = materialMap.cropImageRef(dirtyRect);
182 
183             int bx = dirtyRect.min.x;
184             int by = dirtyRect.min.y;
185 
186             //
187             // Draw knob
188             //
189             float angle = getValueAngle + PI * 0.5f;
190             float depthRadius = std.algorithm.max(knobRadiusPx * 3.0f / 5.0f, 0);
191             float depthRadius2 = std.algorithm.max(knobRadiusPx * 3.0f / 5.0f, 0);
192 
193             float posEdgeX = center.x + sin(angle) * depthRadius2;
194             float posEdgeY = center.y - cos(angle) * depthRadius2;
195 
196             ubyte emissive = 0;
197             if (_shouldBeHighlighted)
198                 emissive = 30;
199             if (isDragged)
200                 emissive = 0;
201 
202             if (style == KnobStyle.thumb)
203             {
204                 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, depthRadius, knobRadiusPx, L16(65535));
205                 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, 0, depthRadius, L16(38400));
206             }
207             else if (style == KnobStyle.cylinder)
208             {
209                 L16 depth = L16(cast(ushort)(0.5f + lerp(65535.0f, 45000.0f, _pushedAnimation)) );
210                 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, knobRadiusPx - 5, knobRadiusPx, depth);
211             }
212             else if (style == KnobStyle.cone)
213             {
214                 L16 depth = L16(cast(ushort)(0.5f + lerp(65535.0f, 45000.0f, _pushedAnimation)) );
215                 croppedDepth.aaSoftDisc(center.x - bx, center.y - by, 0, knobRadiusPx, depth);
216             }
217             else if (style == KnobStyle.ball)
218             {
219                 L16 depth = L16(cast(ushort)(0.5f + lerp(65535.0f, 45000.0f, _pushedAnimation)) );
220                 croppedDepth.aaSoftDisc!1.2f(center.x - bx, center.y - by, 2, knobRadiusPx, depth);
221             }
222             RGBA knobDiffuseLit = knobDiffuse;
223             knobDiffuseLit.a = emissive;
224             croppedDiffuse.aaSoftDisc(center.x - bx, center.y - by, knobRadiusPx - 1, knobRadiusPx, knobDiffuseLit);
225             croppedMaterial.aaSoftDisc(center.x - bx, center.y - by, knobRadiusPx - 5, knobRadiusPx, knobMaterial);
226 
227 
228             // LEDs
229             for (int i = 0; i < numLEDs; ++i)
230             {
231                 float disp = i * 2 * PI / numLEDs;
232                 float distance = lerp(LEDDistanceFromCenter, LEDDistanceFromCenterDragged, _pushedAnimation);
233                 float x = center.x + sin(angle + disp) * knobRadiusPx * distance;
234                 float y = center.y - cos(angle + disp) * knobRadiusPx * distance;
235 
236                 float t = -1 + 2 * abs(disp - PI) / PI;
237 
238                 float LEDRadius = std.algorithm.max(0.0f, lerp(LEDRadiusMin, LEDRadiusMax, t));
239 
240                 float smallRadius = knobRadiusPx * LEDRadius * 0.714f;
241                 float largerRadius = knobRadiusPx * LEDRadius;
242 
243                 RGBA LEDDiffuse;
244                 LEDDiffuse.r = cast(ubyte)lerp!float(LEDDiffuseUnlit.r, LEDDiffuseLit.r, _pushedAnimation);
245                 LEDDiffuse.g = cast(ubyte)lerp!float(LEDDiffuseUnlit.g, LEDDiffuseLit.g, _pushedAnimation);
246                 LEDDiffuse.b = cast(ubyte)lerp!float(LEDDiffuseUnlit.b, LEDDiffuseLit.b, _pushedAnimation);
247                 LEDDiffuse.a = cast(ubyte)lerp!float(LEDDiffuseUnlit.a, LEDDiffuseLit.a, _pushedAnimation);
248 
249                 croppedDepth.aaSoftDisc(x - bx, y - by, 0, largerRadius, L16(LEDDepth));
250                 croppedDiffuse.aaSoftDisc(x - bx, y - by, 0, largerRadius, LEDDiffuse);
251                 croppedMaterial.aaSoftDisc(x - bx, y - by, smallRadius, largerRadius, RGBA(128, 128, 255, defaultPhysical));
252             }
253         }
254     }
255 
256 
257     
258     override bool onMouseClick(int x, int y, int button, bool isDoubleClick, MouseState mstate)
259     {
260         if (!containsPoint(x, y))
261             return false;
262 
263         // double-click => set to default
264         if (isDoubleClick)
265         {
266             _param.beginParamEdit();
267             _param.setFromGUI(_param.defaultValue());
268             _param.endParamEdit();
269         }
270 
271         return true; // to initiate dragging
272     }
273 
274     // Called when mouse drag this Element.
275     override void onMouseDrag(int x, int y, int dx, int dy, MouseState mstate)
276     {
277         float displacementInHeight = cast(float)(dy) / _position.height;
278 
279         float modifier = 1.0f;
280         if (mstate.shiftPressed || mstate.ctrlPressed)
281             modifier *= 0.1f;
282 
283         double oldParamValue = _param.getNormalized();
284 
285         double newParamValue = oldParamValue - displacementInHeight * modifier * _sensivity;
286 
287         if (y > _mousePosOnLast0Cross)
288             return;
289         if (y < _mousePosOnLast1Cross)
290             return;
291 
292         if (newParamValue <= 0 && oldParamValue > 0)
293             _mousePosOnLast0Cross = y;
294 
295         if (newParamValue >= 1 && oldParamValue < 1)
296             _mousePosOnLast1Cross = y;
297         
298         if (newParamValue < 0)
299             newParamValue = 0;
300         if (newParamValue > 1)
301             newParamValue = 1;
302 
303         if (newParamValue > 0)
304             _mousePosOnLast0Cross = float.infinity;
305 
306         if (newParamValue < 1)
307             _mousePosOnLast1Cross = -float.infinity;
308 
309         if (newParamValue != oldParamValue)
310             _param.setFromGUINormalized(newParamValue);
311     }
312 
313     // For lazy updates
314     override void onBeginDrag()
315     {
316         _param.beginParamEdit();
317         setDirtyWhole();
318     }
319 
320     override void onStopDrag()
321     {
322         _param.endParamEdit();
323         clearCrosspoints();
324         setDirtyWhole();
325     }
326 
327     override void onMouseMove(int x, int y, int dx, int dy, MouseState mstate)
328     {
329         _shouldBeHighlighted = containsPoint(x, y);
330         setDirtyWhole();
331     }
332 
333     override void onMouseExit()
334     {
335         _shouldBeHighlighted = false;
336         setDirtyWhole();
337     }
338 
339     override void onParameterChanged(Parameter sender) nothrow @nogc
340     {
341         setDirtyWhole();
342     }
343 
344     override void onBeginParameterEdit(Parameter sender)
345     {
346     }
347 
348     override void onEndParameterEdit(Parameter sender)
349     {
350     }
351 
352 protected:
353 
354     /// The parameter this knob is linked with.
355     FloatParameter _param;
356 
357     float _pushedAnimation;
358 
359     /// Sensivity: given a mouse movement in 100th of the height of the knob,
360     /// how much should the normalized parameter change.
361     float _sensivity;
362 
363     bool _shouldBeHighlighted = false;
364 
365     float _mousePosOnLast0Cross;
366     float _mousePosOnLast1Cross;
367 
368     /// Exists because public angle properties are given in a 
369     /// different referential, where 0 is at the top 
370     static float angleConvert(float angle) nothrow @nogc pure
371     {
372         return angle + PI * 1.5f;
373     }
374 
375     /// Min angle of the trail, fit for `aaFillSector`.
376     float getMinAngle() const pure nothrow @nogc
377     {
378         return angleConvert(trailMinAngle);
379     }
380 
381     /// Min angle of the trail, fit for `aaFillSector`.
382     float getMaxAngle() const pure nothrow @nogc
383     {
384         return angleConvert(trailMaxAngle);
385     }
386 
387     /// Max angle of the trail, fit for `aaFillSector`.
388     float getBaseAngle() const pure nothrow @nogc
389     {
390         return angleConvert(trailBaseAngle);
391     }
392 
393     /// Angle of the trail, fit for `aaFillSector`.
394     float getValueAngle() nothrow @nogc
395     {
396         return lerp(getMinAngle, getMaxAngle, _param.getNormalized());
397     }    
398 
399     void clearCrosspoints() nothrow @nogc
400     {
401         _mousePosOnLast0Cross = float.infinity;
402         _mousePosOnLast1Cross = -float.infinity;
403     }
404 
405     final bool containsPoint(int x, int y) nothrow @nogc
406     {
407         vec2f center = getCenter();
408         return vec2f(x, y).distanceTo(center) < getRadius();
409     }
410 
411     /// Returns: largest square centered in _position
412     final box2i getSubsquare() pure const nothrow @nogc
413     {
414         // We'll draw entirely in the largest centered square in _position.
415         box2i subSquare;
416         if (_position.width > _position.height)
417         {
418             int offset = (_position.width - _position.height) / 2;
419             int minX = offset;
420             subSquare = box2i(minX, 0, minX + _position.height, _position.height);
421         }
422         else
423         {
424             int offset = (_position.height - _position.width) / 2;
425             int minY = offset;
426             subSquare = box2i(0, minY, _position.width, minY + _position.width);
427         }
428         return subSquare;
429     }
430 
431     final float getRadius() pure const nothrow @nogc
432     {
433         return getSubsquare().width * 0.5f;
434 
435     }
436 
437     final vec2f getCenter() pure const nothrow @nogc
438     {
439         box2i subSquare = getSubsquare();
440         float centerx = (subSquare.min.x + subSquare.max.x - 1) * 0.5f;
441         float centery = (subSquare.min.y + subSquare.max.y - 1) * 0.5f;
442         return vec2f(centerx, centery);
443     }
444 }