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