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 }