-
Notifications
You must be signed in to change notification settings - Fork 6
2D Overlay System
The 2D controller visualization uses PNG overlay images positioned on a Canvas to show controller state. Supports Xbox 360 and DualShock 4 layouts. For custom vJoy controllers (non-gamepad presets), a procedurally-generated schematic view is used instead. Keyboard+Mouse and MIDI controller types have their own dedicated preview views.
overlay_positions.py (SVG parsing + alpha-match refinement)
|
v
ControllerOverlayLayout.cs (auto-generated C# layout data)
|
v
ControllerModel2DView.xaml.cs (2D PNG overlay view)
VJoySlotConfig.ComputeAxisLayout()
|
v
ControllerSchematicView.xaml.cs (procedural vJoy view)
KeyboardKeyItem.BuildLayout()
|
v
KBMPreviewView.xaml.cs (keyboard + mouse view)
MidiSlotConfig (NoteCount, CcCount, ...)
|
v
MidiPreviewView.xaml.cs (piano + CC slider view)
The PadPage.ApplyViewMode() method determines which view to show. Priority order: KeyboardMouse > Midi > Custom vJoy > standard gamepad (2D/3D toggle).
| Condition | View | Toggle |
|---|---|---|
OutputType == KeyboardMouse |
KBMPreviewView | Hidden |
OutputType == Midi |
MidiPreviewView | Hidden |
Custom vJoy (VJoyConfig.IsGamepadPreset == false) |
ControllerSchematicView | Hidden |
Xbox 360 / DS4 / vJoy gamepad preset, Use2DControllerView == true
|
ControllerModel2DView | Visible |
Xbox 360 / DS4 / vJoy gamepad preset, Use2DControllerView == false
|
ControllerModelView (3D) | Visible |
File: PadForge.App/Models2D/ControllerOverlayLayout.cs
Auto-generated by tools/overlay_positions.py. Do not edit manually.
public enum OverlayElementType
{
Button, // Show/hide on press
Trigger, // Clip-based fill level
StickRing, // Translates with stick input
StickClick, // Quadrant highlight for hover/flash
FaceButtonGroup // Reserved (unused)
}
public record OverlayElement(
string ImageFile,
string TargetName,
OverlayElementType ElementType,
double X,
double Y,
double Width,
double Height
);public static class Xbox360Layout
{
public const int BaseWidth = 1545;
public const int BaseHeight = 955;
public const string BasePath = "2DModels/XBOX360/XB360_base.png";
public const double StickMaxTravel = 30;
public static readonly OverlayElement[] Overlays; // 19 elements
}Overlay Elements (19 total):
| TargetName | ElementType | Image | Position (X,Y) | Size (WxH) |
|---|---|---|---|---|
| ButtonA | Button | XB360_A_Button.png | 1178, 528 | 127x106 |
| ButtonB | Button | XB360_B_Button.png | 1312, 415 | 122x115 |
| ButtonX | Button | XB360_X_Button.png | 1058, 423 | 126x113 |
| ButtonY | Button | XB360_Y_Button.png | 1190, 314 | 129x118 |
| LeftShoulder | Button | XB360_LeftBumper_Active.png | 138, 134 | 312x141 |
| RightShoulder | Button | XB360_RightBumper_Active.png | 1125, 131 | 285x141 |
| LeftTrigger | Trigger | XB360_LeftTrigger_Active.png | 273, 26 | 143x152 |
| RightTrigger | Trigger | XB360_RightTrigger_Active.png | 1160, 26 | 143x152 |
| ButtonBack | Button | XB360_BackButton.png | 546, 439 | 92x65 |
| ButtonStart | Button | XB360_StartButton.png | 914, 440 | 92x65 |
| ButtonGuide | Button | XB360_GuideButton.png | 689, 414 | 171x139 |
| LeftThumbRing | StickRing | XB360_LeftStick.png | 192, 439 | 211x185 |
| RightThumbRing | StickRing | XB360_RightStick.png | 892, 677 | 211x178 |
| LeftThumbButton | StickClick | XB360_LeftStick_Click.png | 180, 434 | 233x195 |
| RightThumbButton | StickClick | XB360_RightStick_Click.png | 884, 662 | 232x191 |
| DPadUp | Button | XB360_D-PAD_Up.png | 484, 592 | 108x114 |
| DPadDown | Button | XB360_D-PAD_Down.png | 484, 729 | 108x114 |
| DPadLeft | Button | XB360_D-PAD_Left.png | 383, 664 | 134x108 |
| DPadRight | Button | XB360_D-PAD_Right.png | 558, 664 | 134x107 |
public static class DS4Layout
{
public const int BaseWidth = 1466;
public const int BaseHeight = 783;
public const string BasePath = "2DModels/DS4/DS4_V2_base.png";
public const double StickMaxTravel = 25;
public static readonly OverlayElement[] Overlays; // 19 elements
}DS4 reuses DS4_Face_Button.png for all four face buttons (same highlight overlay at different positions). Also reuses DS4_OptionsShare_Button.png for both Back and Start, and DS4_AnalogStick_Click.png for both stick clicks.
PadForge.App/2DModels/
XBOX360/ (21 images)
XB360_base.png (1545x955, base controller image)
Xbox 360 Controller Overlay.png (composite overlay for refinement tool)
XB360_A_Button.png, XB360_B_Button.png, ...
XB360_LeftBumper_Active.png, XB360_RightBumper_Active.png
XB360_LeftTrigger_Active.png, XB360_RightTrigger_Active.png
XB360_LeftStick.png, XB360_RightStick.png
XB360_LeftStick_Click.png, XB360_RightStick_Click.png
XB360_D-PAD_Up/Down/Left/Right.png
XB360_BackButton.png, XB360_StartButton.png, XB360_GuideButton.png
DS4/ (16 images)
DS4_V2_base.png (1466x783, base controller image)
DualShock 4 Controller V2 Model Overlay.png (composite overlay for refinement tool)
DS4_Face_Button.png (single image for all 4 face buttons)
DS4_D-PAD_Up/Down/Left/Right.png
DS4_L1-Active.png, DS4_R1-Active.png
DS4_L2-Active.png, DS4_R2-Active.png
DS4_OptionsShare_Button.png (shared for Back and Start)
DS4_Home_Button.png
DS4_V2_LeftAnalogStick.png, DS4_V2_RightAnalogStick.png
DS4_AnalogStick_Click.png (shared for both stick clicks)
All PNG files are included as WPF Resource (not EmbeddedResource), loaded via pack:// URI:
<Resource Include="2DModels\**\*.png" />Source artwork: Gamepad-Asset-Pack by AL2009man (MIT license).
File: PadForge.App/Views/ControllerModel2DView.xaml, ControllerModel2DView.xaml.cs
WPF UserControl with a Canvas inside a Viewbox for resolution-independent scaling. ~636 lines of code-behind.
<Viewbox Stretch="Uniform">
<Canvas x:Name="ModelCanvas" ClipToBounds="True" />
</Viewbox>The Viewbox scales the canvas uniformly to fit the available space. The canvas dimensions are set to match the layout's BaseWidth/BaseHeight (e.g., 1545x955 for Xbox 360).
public event EventHandler<string> ControllerElementRecordRequested;| Field | Type | Description |
|---|---|---|
_vm |
PadViewModel |
Bound ViewModel |
_loadedModel |
string |
"XBOX360" or "DS4"
|
_dirty |
bool |
Render-frame update flag |
_baseImage |
Image |
Base controller image at Z=0 |
_overlayImages |
Dictionary<string, Image> |
Target name to overlay Image element |
_stickTransforms |
Dictionary<string, TranslateTransform> |
Target name to stick translation transform |
_triggerClips |
Dictionary<string, RectangleGeometry> |
Target name to trigger clip geometry |
_elementTypes |
Dictionary<string, OverlayElementType> |
Target name to element type |
_stickHighlights |
Dictionary<string, Image> |
Ring target to stick click overlay image (for quadrant highlights) |
_stickMaxTravel |
double |
Maximum stick overlay travel in pixels |
_flashTimer |
DispatcherTimer |
Flash animation timer (400ms) |
_flashTarget |
string |
Resolved flash target (ring for axes) |
_flashRawTarget |
string |
Original target before resolution (e.g., "LeftThumbAxisXNeg") |
_flashStickClip |
Geometry |
Stored quadrant clip for stick flash |
_flashOn |
bool |
Current flash toggle state |
_hoverTarget |
string |
Currently hovered target |
public void Bind(PadViewModel vm)
public void Unbind()Bind subscribes to PropertyChanged, hooks CompositionTarget.Rendering, calls EnsureModel().
Unbind stops flash, unhooks rendering, clears VM reference.
private void EnsureModel()Same logic as the 3D view:
-
DualShock4->"DS4" -
VJoywithPreset == DualShock4->"DS4" - All others ->
"XBOX360"
If the model hasn't changed, returns immediately. Otherwise calls BuildCanvas().
private void BuildCanvas(string modelName)Clears the canvas and rebuilds from layout data. Creates four layers at different Z-indices:
Layer 0 — Base Image:
_baseImage = CreateImage(basePath, 0, 0, baseW, baseH);Layer 1 — Overlay Images:
For each OverlayElement in the layout:
| ElementType | Initial State | Transform/Clip |
|---|---|---|
StickRing |
Visible |
TranslateTransform stored in _stickTransforms
|
Trigger |
Visible, full clip |
RectangleGeometry clip stored in _triggerClips (starts at bottom = empty fill) |
Button |
Collapsed | None (toggled by SetOverlayVisible()) |
StickClick |
Collapsed | None (used as source for quadrant highlights) |
All overlay images have IsHitTestVisible = false — clicks go through to hit-test rectangles.
Layer 10 — Hit-Test Rectangles:
Transparent Rectangle elements with Cursor = Hand and Tag = TargetName. Event handlers:
-
MouseLeftButtonDown->HitArea_Click -
MouseEnter->HitArea_MouseEnter -
MouseLeave->HitArea_MouseLeave -
MouseMove->StickHitArea_MouseMove(stick rings only)
StickClick elements have no hit rect — they're handled by the StickRing's center-click detection.
Layer 5 — Stick Quadrant Highlights: Created from StickClick overlay images at 40% opacity. Initially collapsed. Used for hover and flash quadrant visualization.
private static Image CreateImage(string resourcePath, double x, double y, double w, double h)Loads PNG via pack://application:,,,/{resourcePath} URI into a BitmapImage. Positions with Canvas.SetLeft/SetTop.
CompositionTarget.Rendering handler, gated by _dirty:
Sets overlay Visibility for 15 button targets:
SetOverlayVisible("ButtonA", _vm.ButtonA);
SetOverlayVisible("ButtonB", _vm.ButtonB);
// ... 13 moreSetOverlayVisible() skips elements currently being flash-animated or hovered.
Adjusts RectangleGeometry clip to create a gas tank fill effect (fills from bottom to top):
double clipY = h * (1.0 - v); // v = 0: empty (clip at bottom), v = 1: full (clip at top)
clip.Rect = new Rect(0, clipY, w, h - clipY);Updates TranslateTransform.X/Y from normalized stick values:
lt.X = lx * _stickMaxTravel;
lt.Y = -ly * _stickMaxTravel; // Y inverted for screen coordinatesRaw short values (-32768..32767) are normalized to -1..1. StickMaxTravel is 30px for Xbox 360, 25px for DS4.
private void HitArea_Click(object sender, MouseButtonEventArgs e)For non-stick elements, fires ControllerElementRecordRequested directly with the Tag value.
For stick rings, calls DetermineAxisFromQuadrant():
private static string DetermineAxisFromQuadrant(
Point pos, double w, double h, string stickTarget)Determines axis from click position relative to ring center:
-
Center (distance < 30% of radius): Returns
LeftThumbButtonorRightThumbButton. -
Dominant X (
|dx| >= |dy|): ReturnsLeftThumbAxisXorLeftThumbAxisXNeg. -
Dominant Y: Returns
LeftThumbAxisYorLeftThumbAxisYNeg.
Down = positive Y direction (screen coordinates). Step 3's NegateAxis inverts this so that screen-down maps to game-down.
Buttons:
HitArea_MouseEnter shows overlay at 40% opacity. HitArea_MouseLeave triggers _dirty = true to restore proper state on next render frame.
Triggers:
During hover, clip is opened to full image (Rect(0, 0, w, h)).
Stick Rings:
StickHitArea_MouseMove tracks mouse position and creates a CombinedGeometry clip on the stick highlight image:
- Full ellipse (ring boundary)
- Minus center ellipse (30% radius — this is the stick button area)
- Intersected with half-rectangle based on dominant axis direction
This produces a quadrant wedge that follows the mouse:
var quadrant = new CombinedGeometry(GeometryCombineMode.Intersect,
fullEllipse, new RectangleGeometry(halfRect));
clip = new CombinedGeometry(GeometryCombineMode.Exclude,
quadrant, centerEllipse);For center hover (distance < 30%), shows just the center ellipse.
private void UpdateFlashTarget(string target)Target resolution:
- Stick axis targets resolve to ring:
"LeftThumbAxisX"->"LeftThumbRing". - Stick button targets also resolve to ring:
"LeftThumbButton"->"LeftThumbRing". - All others pass through.
Flash timer: 400ms DispatcherTimer toggles _flashOn.
| Element Type | Flash On | Flash Off |
|---|---|---|
| Stick axes | Quadrant highlight visible (with stored clip) | Quadrant highlight collapsed |
| Stick ring | Opacity 1.0 | Opacity 0.2 |
| Buttons | Overlay visible, full opacity | Overlay collapsed |
| Triggers | Clip opened to full fill | (restored by StopFlash) |
Stick quadrant clip for flash:
private static Geometry GetStickQuadrantClip(string target, double w, double h)Returns pre-computed clip geometry for the specified axis direction:
-
LeftThumbButton/RightThumbButton: Center ellipse (30% radius). -
AxisX/AxisXNeg: Right or left half-ellipse minus center. -
AxisY/AxisYNeg: Bottom or top half-ellipse minus center.
The clip is stored in _flashStickClip and re-applied on every tick to guard against it being cleared by other interactions.
File: PadForge.App/Views/ControllerSchematicView.xaml, ControllerSchematicView.xaml.cs
Procedurally generated view for custom vJoy controllers. Displays stick circles, trigger bars, POV compasses, and button grids. ~795 lines of code-behind.
<Viewbox Stretch="Uniform">
<Canvas x:Name="SchematicCanvas" ClipToBounds="True" />
</Viewbox>Same Viewbox > Canvas pattern as the 2D overlay view. Canvas dimensions are computed dynamically to fit all widgets.
public event EventHandler<string> ControllerElementRecordRequested;| Name | Color | Usage |
|---|---|---|
AccentBrush |
#0078D4 |
Pressed/active elements |
FlashBrush |
#FFA500 |
Flash animation highlight (orange) |
HoverBrush |
#40A0E0 |
Hover highlight |
DimBrush |
#606060 |
Inactive borders |
BgBrush |
#2D2D2D |
Widget backgrounds |
LabelBrush |
#BBBBBB |
Text labels |
DotBrush |
#FFFFFF |
Stick position dot |
const double StickSize = 100; // Stick circle diameter
const double TriggerWidth = 24; // Trigger bar width
const double TriggerHeight = 80; // Trigger bar height
const double PovSize = 60; // POV circle diameter
const double ButtonSize = 22; // Button circle diameter
const double SectionGap = 24; // Horizontal gap between widget sections
const double LabelHeight = 18; // Space reserved for labels above widgets
const double Padding = 12; // Canvas edge padding
const int ButtonsPerRow = 8; // Button grid wrap countprivate struct StickWidget
{
public int AxisXIndex, AxisYIndex;
public Ellipse Dot; // Position indicator dot
public Polygon DirectionArrow; // Flash/hover direction arrow
public Canvas ArrowCanvas; // Arrow container for rotation
public Ellipse OuterCircle; // Outer boundary circle
public double X, Y; // Canvas position
}
private struct TriggerWidget
{
public int AxisIndex;
public Rectangle Background; // Border rectangle
public Rectangle Fill; // Fill bar (grows from bottom)
public double X, Y;
}
private struct PovWidget
{
public int PovIndex;
public Polygon Arrow; // Direction arrow polygon
public Canvas ArrowCanvas; // Arrow container for rotation
public Ellipse Outer; // Outer boundary circle
public double CenterX, CenterY;
}
private struct ButtonWidget
{
public int ButtonIndex;
public Ellipse Circle; // Button circle
}public void Bind(PadViewModel vm)
public void Unbind()In addition to the standard Bind/Unbind pattern, subscribes to _vm.VJoyConfig.PropertyChanged so that layout rebuilds automatically when vJoy config properties (axis counts, button count) change.
Property change handling:
-
VJoyOutputSnapshot-> sets_dirtyflag. -
OutputType-> callsRebuildLayout(). -
CurrentRecordingTarget-> callsUpdateFlashTarget(). - Any
VJoyConfigproperty -> callsRebuildLayout().
private void RebuildLayout()Called on bind and when VJoyConfig properties change. Clears all widget lists and canvas children, then creates widgets.
Axis index assignment: Calls VJoySlotConfig.ComputeAxisLayout() to get:
-
stickAxisX[]/stickAxisY[]: HID axis indices for each stick pair. -
triggerAxis[]: HID axis indices for standalone triggers.
Layout flow (left to right, starting at x = Padding):
+-- Sticks --+-- Triggers --+-- POVs --+
| | | |
| Stick 1 | T1 T2 | D-Pad |
| [circle] | [bar][bar] | [circle] |
| | | |
+---- Buttons (wrapped grid, below main row) ----+
| [1][2][3][4][5][6][7][8] |
| [9][10][11]... |
+------------------------------------------------+
Canvas dimensions are set to fit all widgets plus padding, enabling Viewbox auto-scaling.
private StickWidget CreateStickWidget(int index, int axisXIdx, int axisYIdx, double x, double y)Creates:
-
Outer circle (
Ellipse): Dim stroke, dark background, hand cursor. -
Crosshair lines (
Linex2): Horizontal and vertical at 50% opacity. -
Position dot (
Ellipse): 10px accent-colored dot at center. -
Direction arrow (
PolygoninsideCanvas): Hidden until flash/hover. Rotated viaRotateTransformcentered on the stick. -
Label (
TextBlock): "Stick N" above the circle.
Hover: MouseMove on the outer circle determines quadrant (right/left/down/up) from mouse position, shows direction arrow with HoverBrush, highlights stroke.
Click-to-record: MouseLeftButtonDown uses quadrant detection:
-
|dx| > |dy|anddx > 0->VJoyAxis{axisXIdx}(positive X) -
|dx| > |dy|anddx <= 0->VJoyAxis{axisXIdx}Neg(negative X) -
|dy| > |dx|anddy > 0->VJoyAxis{axisYIdx}(positive Y = down) -
|dy| > |dx|anddy <= 0->VJoyAxis{axisYIdx}Neg(negative Y = up)
private TriggerWidget CreateTriggerWidget(int index, int axisIdx, double x, double y)Creates:
-
Background (
Rectangle): Dim stroke, dark fill, rounded corners, hand cursor. -
Fill bar (
Rectangle): Accent-colored, initially height 0. Grows from bottom. - Label: "TN" above the bar.
Click-to-record: Fires VJoyAxis{axisIdx}.
private PovWidget CreatePovWidget(int index, double x, double y)Creates:
-
Outer circle (
Ellipse): Dim stroke, dark background. -
Arrow (
PolygoninsideCanvas): Triangular arrow, rotated around POV center. Initially hidden. - Label: "D-Pad" (if 1 POV) or "POV N" (if multiple).
Hover: Shows direction arrow rotated to the mouse quadrant (0/90/180/270 degrees).
Click-to-record: Quadrant detection fires VJoyPov{index}Up, VJoyPov{index}Down, VJoyPov{index}Left, or VJoyPov{index}Right.
private ButtonWidget CreateButtonWidget(int index, double x, double y)Creates:
-
Circle (
Ellipse): Dim stroke, dark background, hand cursor. -
Label (
TextBlock): Button number (1-indexed) centered in circle.
Click-to-record: Fires VJoyBtn{index}.
| Widget | Target Format | Quadrant-Based |
|---|---|---|
| Stick |
VJoyAxis{X} / VJoyAxis{X}Neg
|
Yes (X vs Y, positive vs negative) |
| Trigger | VJoyAxis{N} |
No |
| POV |
VJoyPov{N}Up / Down / Left / Right
|
Yes (4 cardinal directions) |
| Button | VJoyBtn{N} |
No |
CompositionTarget.Rendering handler reads VJoyOutputSnapshot from PadViewModel:
Sticks:
double nx = (raw.Axes[w.AxisXIndex] - (double)short.MinValue) / 65535.0; // 0-1
double dotX = w.X + nx * (StickSize - 10);Maps VJoyRawState.Axes[index] from signed short (-32768..32767) to 0-1 normalized. Positions the dot within the stick circle.
Triggers:
double value = (raw.Axes[w.AxisIndex] - (double)short.MinValue) / 65535.0; // 0-1
double fillH = Math.Clamp(value, 0, 1) * (TriggerHeight - 4);
w.Fill.Height = fillH;
Canvas.SetTop(w.Fill, w.Y + TriggerHeight - 2 - fillH); // Grows from bottomPOVs:
int povValue = raw.Povs[w.PovIndex]; // Centidegrees (0-35900) or -1
if (povValue >= 0 && povValue <= 36000)
{
w.Arrow.Visibility = Visibility.Visible;
w.ArrowCanvas.RenderTransform = new RotateTransform(povValue / 100.0, ...);
}POV updates are skipped when the widget is hovered or flash-targeted to prevent visual flickering from competing rotations.
Buttons:
bool pressed = raw.IsButtonPressed(w.ButtonIndex);
w.Circle.Fill = pressed ? AccentBrush : BgBrush;private void UpdateFlashTarget(string target)
private void ApplyFlashState(bool highlight)Flash timer at 400ms, same interval as the 2D overlay and other views.
Strips "Neg" suffix for matching, then checks each widget list:
| Widget | Flash On | Flash Off |
|---|---|---|
| Stick | Orange stroke, direction arrow visible, arrow rotated to target axis | Dim stroke, arrow hidden |
| Trigger | Fill color = FlashBrush (orange) |
Fill color = AccentBrush (blue) |
| Button | Stroke = FlashBrush, thicker |
Stroke = DimBrush, normal |
| POV | Arrow visible, FlashBrush fill, rotated to target direction |
Arrow visible, AccentBrush fill |
POV direction mapping:
string dir = target.Substring($"VJoyPov{w.PovIndex}".Length); // "Up", "Down", "Left", "Right"
double angle = dir switch
{
"Up" => 0,
"Right" => 90,
"Down" => 180,
"Left" => 270,
_ => 0
};File: PadForge.App/Views/KBMPreviewView.xaml, KBMPreviewView.xaml.cs
WPF UserControl for the Keyboard+Mouse virtual controller type. Displays a full QWERTY keyboard above a mouse graphic. ~502 lines of code-behind.
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/> <!-- Keyboard (majority) -->
<RowDefinition Height="Auto"/> <!-- Mouse below -->
</Grid.RowDefinitions>
<Viewbox Grid.Row="0" Stretch="Uniform">
<Canvas x:Name="KeyboardCanvas" Width="556" Height="136" ClipToBounds="True"/>
</Viewbox>
<Viewbox Grid.Row="1" Stretch="Uniform" HorizontalAlignment="Center" MaxHeight="180">
<Canvas x:Name="MouseCanvas" Width="160" Height="195" ClipToBounds="True"/>
</Viewbox>
</Grid>BuildKeyboardCanvas() generates individual Border elements per key from KeyboardKeyItem.BuildLayout() (full ANSI QWERTY with numpad, 556x136 layout units). Each key has:
-
CornerRadius(3)rounded corners -
KeyNormalBrushbackground (semi-transparent gray),KeyPressedBrush(accent blue) when pressed -
ToolTipwith the key label -
MouseLeftButtonDownfiresControllerElementRecordRequestedwith target name"KbmKey{VKeyIndex:X2}" - Hover: border highlight via
HoverBrush
BuildMouseCanvas() draws a contoured mouse graphic using WPF Path geometry:
| Element | Target Name | Description |
|---|---|---|
| Mouse body | — | Rounded Path outline (MouseBodyBrush fill) |
| LMB | KbmMBtn0 |
Contoured Path curving around scroll wheel gap |
| RMB | KbmMBtn1 |
Mirror of LMB |
| MMB channel | — | Background fill between button paths |
| Scroll wheel pill | KbmMBtn2 |
Rounded Rectangle for middle-click |
| Scroll up arrow | KbmScroll |
Triangular Polygon on wheel |
| Scroll down arrow | KbmScrollNeg |
Triangular Polygon on wheel |
| Movement circle | KbmMouseX/Y/Neg |
Ellipse with direction arrow, quadrant click detection |
| Side buttons |
KbmMBtn3, KbmMBtn4
|
Small Rectangle on left body edge (X1, X2) |
Movement circle interaction:
-
MouseMovedetermines quadrant (right/left/down/up) and shows direction arrow withHoverBrush -
MouseLeftButtonDownuses quadrant detection:KbmMouseX(right),KbmMouseXNeg(left),KbmMouseY(up),KbmMouseYNeg(down) - Movement dot tracks
KbmOutputSnapshot.MouseDeltaX/Ywithin the circle
CompositionTarget.Rendering handler reads KbmOutputSnapshot from PadViewModel:
-
Keyboard keys:
kbm.GetKey(vKeyIndex)-> accent/normal brush -
Mouse buttons:
kbm.GetMouseButton(0/1/2)-> accent fill on LMB/RMB/scroll wheel -
Movement dot:
kbm.MouseDeltaX/Ymapped to circle position, accent fill when non-zero -
Scroll arrows:
kbm.ScrollDelta-> accent fill on up/down arrow
400ms DispatcherTimer, same pattern as other views. Target elements get FlashBrush (orange) on tick, restored on stop.
public event EventHandler<string> ControllerElementRecordRequested;Same click-to-record pattern as all controller visualization views. Bind(vm) / Unbind() lifecycle matches the 2D/3D/Schematic views.
File: tools/overlay_positions.py
Python tool that auto-generates ControllerOverlayLayout.cs from Gamepad-Asset-Pack SVG files.
pip install svgpathtools lxml opencv-python numpy
-
Parse SVG elements — Reads labeled elements from Xbox 360 and DS4 SVG theme files using lxml. Computes cumulative SVG transforms (translate, scale, matrix) to get pixel-space bounding boxes.
-
Center overlays — For each element, loads the corresponding PNG overlay image and centers it on the SVG bounding box center.
-
Alpha-channel refinement — Uses OpenCV template matching (
cv2.matchTemplatewithTM_CCOEFF_NORMED) against a full composite overlay image to refine positions within a 40-pixel search radius. Only accepts matches with confidence > 0.3. -
Generate C# — Outputs the
ControllerOverlayLayout.csfile with layout constants, overlay element arrays, and stick travel values.
def parse_transform(transform_str) -> np.ndarray # SVG transform -> 3x3 matrix
def get_cumulative_transform(elem) -> np.ndarray # Walk ancestors for total transform
def element_bbox(elem) -> tuple # Single element bbox (path/ellipse/rect)
def group_bbox(group_elem) -> tuple # Combined bbox of group children
def get_element_pixel_bbox(root, label, scale) -> tuple # Label lookup + pixel conversion
def center_overlay_on_bbox(bbox, overlay_path) -> tuple # Center overlay image on bbox
def refine_with_composite(composite_path, results, search_radius=40) -> list # Template matching
def process_xbox360() -> dict # Full Xbox 360 pipeline
def process_ds4() -> dict # Full DS4 pipeline
def generate_csharp(xbox_data, ds4_data, output_path) # C# code generationpython tools/overlay_positions.pyExpects Gamepad-Asset-Pack/Controller Asset Pack/ to be a sibling of the PadForge repository directory.
File: PadForge.App/Views/MidiPreviewView.xaml, MidiPreviewView.xaml.cs
WPF UserControl for the MIDI virtual controller type. Displays a piano keyboard for note outputs and vertical CC sliders for continuous controller outputs. ~530 lines of code-behind.
<Viewbox Stretch="Uniform">
<Canvas x:Name="MidiCanvas" ClipToBounds="True" />
</Viewbox>Same Viewbox > Canvas pattern as the Schematic view. Canvas dimensions are computed dynamically to fit all widgets.
public event EventHandler<string> ControllerElementRecordRequested;| Name | Color | Usage |
|---|---|---|
AccentBrush |
#0078D4 |
Active CC fill |
AccentDimBrush |
#005090 |
(reserved) |
DimBrush |
#606060 |
Inactive borders, labels |
BgBrush |
#2D2D2D |
CC bar backgrounds |
LabelBrush |
#BBBBBB |
Text labels |
WhiteKeyBrush |
#F0F0F0 |
White piano key fill |
WhiteKeyPressedBrush |
#40A0E0 |
White key pressed |
BlackKeyBrush |
#202020 |
Black piano key fill |
BlackKeyPressedBrush |
#0060B0 |
Black key pressed |
KeyBorderBrush |
#404040 |
Piano key outlines |
HoverBrush |
#40A0E0 |
Hover highlight |
FlashBrush |
#FFA500 |
Flash animation highlight (orange) |
const double WhiteKeyWidth = 28;
const double WhiteKeyHeight = 120;
const double BlackKeyWidth = 18;
const double BlackKeyHeight = 75;
const double CcBarWidth = 20;
const double CcBarHeight = 100;
const double SectionGap = 20;
const double LabelHeight = 16;
const double Padding = 12;private struct CcSliderWidget
{
public int CcIndex;
public Rectangle Background;
public Rectangle Fill;
public double X, Y;
}
private struct PianoKeyWidget
{
public int NoteIndex;
public bool IsBlack;
public Rectangle Rect;
public Brush NormalBrush;
public Brush PressedBrush;
}public void Bind(PadViewModel vm)
public void Unbind()Subscribes to both _vm.PropertyChanged and _vm.MidiConfig.PropertyChanged. Any MidiConfig property change triggers a full RebuildLayout().
Property change handling:
-
MidiOutputSnapshot-> sets_dirtyflag. -
OutputType-> callsRebuildLayout(). -
CurrentRecordingTarget-> callsUpdateFlashTarget(). - Any
MidiConfigproperty -> callsRebuildLayout().
private void RebuildLayout()Called on bind and when MidiConfig properties change. Layout order:
-
CC sliders (if
mc.CcCount > 0): Section label + oneCreateCcSlider()per CC output, arranged horizontally. CC numbers obtained frommc.GetCcNumbers(). -
Piano keyboard (if
mc.NoteCount > 0): Section label +BuildPianoKeys(). Note numbers obtained frommc.GetNoteNumbers().
Canvas dimensions computed to fit both sections.
Creates:
-
Background (
Rectangle): Dim stroke, dark fill, rounded corners, hand cursor. -
Fill bar (
Rectangle): Accent-colored, initially height 0. Grows from bottom. - Label: CC number below the bar (centered, 9pt).
Click-to-record: Fires MidiCC{index}.
Two-pass layout:
- White keys first: Placed sequentially left-to-right. Positions stored in a dictionary by MIDI note number.
-
Black keys on top: Positioned relative to the preceding white key, offset by
WhiteKeyWidth - BlackKeyWidth/2. Black keys get Z-index 10 to appear above white keys.
Note labels (e.g., "C4", "F#5") are placed below white keys only.
Note identification: Uses a 12-element IsBlackKey[] array (C=white, C#=black, D=white, ...) and NoteNames[] for display labels.
Click-to-record: Fires MidiNote{noteIndex}.
CompositionTarget.Rendering handler reads MidiOutputSnapshot from PadViewModel:
CC sliders:
double value = raw.CcValues[w.CcIndex] / 127.0; // 0-1
double fillH = Math.Clamp(value, 0, 1) * (CcBarHeight - 4);
w.Fill.Height = fillH;
Canvas.SetTop(w.Fill, w.Y + CcBarHeight - 2 - fillH); // Grows from bottomPiano keys:
bool pressed = raw.Notes != null && w.NoteIndex < raw.Notes.Length && raw.Notes[w.NoteIndex];
w.Rect.Fill = pressed ? w.PressedBrush : w.NormalBrush;Keys currently being flash-animated are skipped during render to avoid overwriting the flash highlight.
400ms DispatcherTimer, same pattern as all other views.
| Widget | Flash On | Flash Off |
|---|---|---|
| CC slider | Fill color = FlashBrush (orange) |
Fill color = AccentBrush (blue) |
| Piano key | Key fill = FlashBrush (orange) |
Key fill = NormalBrush (white or black) |
CC flash also matches MidiCC{N}Neg targets (negative direction).
| Widget | Target Format | Quadrant-Based |
|---|---|---|
| CC slider | MidiCC{N} |
No |
| Piano key | MidiNote{N} |
No |
All five controller visualization views share the same architecture:
-
Same ViewModel interface: All read from
PadViewModelproperties and fireControllerElementRecordRequestedwith PadSetting target names. -
Same Bind/Unbind lifecycle:
PadPage.BindActiveModelView()callsUnbind()on all five views, thenBind(vm)on the active one. This ensures only one view is processingCompositionTarget.Renderingat a time. -
Same dirty-flag rendering:
CompositionTarget.Renderinghandler gated by_dirtyflag, set byPropertyChanged. -
Same interactions: Click-to-record, hover highlight, flash animation (Map All). Same PadSetting target names across all views.
-
Same model selection logic (2D/3D only):
EnsureModel()in both views mapsOutputType + VJoyConfig.Presetto"XBOX360"or"DS4".
The key differences:
| Aspect | 3D View | 2D View | Schematic View | KBM View | MIDI View |
|---|---|---|---|---|---|
| Technology | HelixToolkit.WPF | Canvas + BitmapImage | Canvas + WPF Shapes | Canvas + WPF Shapes/Paths | Canvas + WPF Shapes |
| Rotation | Left-drag turntable | None | None | None | None |
| Quadrant detection | 3D ray-cast + position | 2D mouse position | 2D mouse position | 2D mouse position | None |
| Flash rate | 400ms | 400ms | 400ms | 400ms | 400ms |
| Output type | Xbox 360, DS4 | Xbox 360, DS4 | Custom vJoy | KeyboardMouse | MIDI |
| Assets | OBJ meshes (EmbeddedResource) | PNG images (Resource) | None (procedural) | None (procedural) | None (procedural) |
| Config rebuild | Model type change | Model type change | VJoyConfig change | OutputType change | MidiConfig change |