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 }