-
Notifications
You must be signed in to change notification settings - Fork 6
3D Model System
Renders interactive Xbox and PlayStation controller models from Wavefront OBJ meshes using HelixToolkit.WPF. Adapted from Handheld Companion (CC BY-NC-SA 4.0).
Namespace: PadForge.Models3D (model classes), PadForge.Views (view)
ControllerModelBase (abstract)
|
+-- ControllerModelXbox360 (Xbox 360 meshes, colors, rotation points)
+-- ControllerModelXboxOne (Xbox One / Elite / Adaptive / Series mesh share)
+-- ControllerModelDS4 (DualShock 4 meshes, colors, rotation points)
+-- ControllerModelDualSense (DualSense / DualSense Edge meshes)
ControllerModelView (UserControl)
|
+-- HelixViewport3D (3D rendering viewport)
+-- ModelVisual3D (hosts the model3DGroup scene graph)
+-- CompositionTarget.Rendering (per-frame visual updates)
Model classes own geometry and materials. The view class owns the viewport, input handling, and animation. ControllerModelView.EnsureModel() instantiates the correct model class and assigns it to ModelVisual3D.Content.
File: PadForge.App/Models3D/ControllerModelBase.cs
Abstract base class. Each subclass defines its own meshes, colors, and rotation points.
public abstract class ControllerModelBase : IDisposable| Field | Type | Description |
|---|---|---|
ButtonMap |
Dictionary<string, List<Model3DGroup>> |
PadSetting name to Model3DGroups for highlighting (supports multi-mesh buttons like button + overlay). |
ClickMap |
Dictionary<Model3DGroup, string> |
Model3DGroup to PadSetting name for hit-test click-to-record. Reverse of ButtonMap. |
DefaultMaterials |
Dictionary<Model3DGroup, Material> |
Original material per group. Restored after highlight/flash. |
HighlightMaterials |
Dictionary<Model3DGroup, Material> |
Accent-colored material per group. Applied on press or flash. |
| Field | Type | Description |
|---|---|---|
model3DGroup |
Model3DGroup |
Root scene group containing all child meshes. Assigned to ModelVisual3D.Content. |
ModelName |
string |
One of "XBOX360", "XBOXONE", "DS4", "DualSense". Used for embedded resource path resolution. |
| Field | Loaded From | Description |
|---|---|---|
MainBody |
MainBody.obj |
Main controller body mesh |
LeftThumb |
LeftStickClick.obj |
Left stick click mesh |
LeftThumbRing |
Joystick-Left-Ring.obj |
Left stick ring mesh (torus) |
RightThumb |
RightStickClick.obj |
Right stick click mesh |
RightThumbRing |
Joystick-Right-Ring.obj |
Right stick ring mesh (torus) |
LeftShoulderTrigger |
Shoulder-Left-Trigger.obj |
Left shoulder trigger mesh |
RightShoulderTrigger |
Shoulder-Right-Trigger.obj |
Right shoulder trigger mesh |
LeftMotor |
MotorLeft.obj |
Left rumble motor mesh |
RightMotor |
MotorRight.obj |
Right rumble motor mesh |
| Field | Type | Description |
|---|---|---|
JoystickRotationPointCenterLeftMillimeter |
Vector3D |
Left stick tilt pivot |
JoystickRotationPointCenterRightMillimeter |
Vector3D |
Right stick tilt pivot |
JoystickMaxAngleDeg |
float |
Max stick tilt angle (degrees) |
ShoulderTriggerRotationPointCenterLeftMillimeter |
Vector3D |
Left trigger rotation pivot |
ShoulderTriggerRotationPointCenterRightMillimeter |
Vector3D |
Right trigger rotation pivot |
TriggerMaxAngleDeg |
float |
Max trigger depression angle (degrees) |
UpwardVisibilityRotationAxisLeft/Right |
Vector3D |
Shoulder visibility correction axis |
UpwardVisibilityRotationPointLeft/Right |
Vector3D |
Shoulder visibility correction origin |
Maps Handheld Companion .obj filenames (using ButtonFlags enum names) to PadSetting property names (used by the recording system).
protected static readonly Dictionary<string, string> ButtonFileMap = new()
{
{ "B1.obj", "ButtonA" },
{ "B2.obj", "ButtonB" },
{ "B3.obj", "ButtonX" },
{ "B4.obj", "ButtonY" },
{ "L1.obj", "LeftShoulder" },
{ "R1.obj", "RightShoulder" },
{ "Back.obj", "ButtonBack" },
{ "Start.obj", "ButtonStart" },
{ "Special.obj", "ButtonGuide" },
{ "DPadUp.obj", "DPadUp" },
{ "DPadDown.obj", "DPadDown" },
{ "DPadLeft.obj", "DPadLeft" },
{ "DPadRight.obj", "DPadRight" },
{ "LeftStickClick.obj", "LeftThumbButton" },
{ "RightStickClick.obj", "RightThumbButton" },
};protected ControllerModelBase(string modelName)Steps are order-dependent:
-
Set ModelName. Determines the embedded-resource folder. One of
"XBOX360","XBOXONE","DS4", or"DualSense". Picked byHMaestroProfileCatalog.ResolveAssetFoldersagainst the slot'sProfileIdandOutputType. -
Load common geometry via
LoadModel(): MainBody, stick rings, motors, triggers. -
Register trigger ClickMap entries:
LeftShoulderTrigger->"LeftTrigger",RightShoulderTrigger->"RightTrigger". Triggers use ClickMap (not ButtonMap) because they are continuous axes, not toggle buttons. -
Iterate ButtonFileMap: Calls
TryLoadModel()per entry, thenRegisterButton()to populate bothButtonMapandClickMap. Special cases:LeftStickClick.objandRightStickClick.objalso setLeftThumb/RightThumbreferences for tilt animation. -
Add all parts to
model3DGroup.Children. Assigned toModelVisual3D.Content. -
Subclass constructor continues. Loads extra meshes (face button overlays, symbol meshes), assigns colored materials, calls
DrawAccentHighlights()last.
Note: Stick rings are NOT in ClickMap. The view handles ring clicks via IsStickRingHit() with quadrant-based axis detection, since ring clicks must determine axis direction from click position.
protected void RegisterButton(string padSettingName, Model3DGroup group)Adds group to ButtonMap[padSettingName] (creates list if needed) and sets ClickMap[group] = padSettingName. This bidirectional mapping enables highlighting (name -> groups) and click detection (group -> name).
protected virtual void DrawAccentHighlights()Creates accent-colored DiffuseMaterial for all children using the app's AccentButtonBackground resource (ModernWpfUI). Falls back to #2196F3 blue. Called at the end of each subclass constructor.
protected Model3DGroup LoadModel(string filename) // Throws FileNotFoundException
protected Model3DGroup TryLoadModel(string filename) // Returns null on failureLoads .obj meshes from embedded resources via HelixToolkit's ObjReader. Searches manifest resource names by suffix (.{ModelName}.{filename}) to handle MSBuild digit-prefix mangling.
MSBuild mangling: 3DModels becomes _3DModels in resource names because MSBuild prefixes digit-leading folder names. Suffix matching avoids hard-coding the prefix.
string suffix = $".{ModelName}.{filename}";
foreach (var name in assembly.GetManifestResourceNames())
if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
// Found itpublic void Dispose()
protected virtual void Dispose(bool disposing)
~ControllerModelBase()Clears all dictionaries and model3DGroup.Children. Standard dispose pattern with finalizer. Called by EnsureModel() when switching model types.
File: PadForge.App/Models3D/ControllerModelXbox360.cs
public class ControllerModelXbox360 : ControllerModelBaseCalls base("XBOX360").
| Field | Loaded From | Description |
|---|---|---|
MainBodyCharger |
MainBody-Charger.obj |
Battery pack/charger compartment |
SpecialRing |
SpecialRing.obj |
Guide button ring |
SpecialLED |
SpecialLED.obj |
Guide button LED indicator |
LeftShoulderBottom |
LeftShoulderBottom.obj |
Left bumper bottom piece |
RightShoulderBottom |
RightShoulderBottom.obj |
Right bumper bottom piece |
B1Button |
B1Button.obj |
A button colored overlay |
B2Button |
B2Button.obj |
B button colored overlay |
B3Button |
B3Button.obj |
X button colored overlay |
B4Button |
B4Button.obj |
Y button colored overlay |
| Name | Hex | Usage |
|---|---|---|
ColorPlasticBlack |
#707477 |
Default for most parts |
ColorPlasticWhite |
#D4D4D4 |
Main body, motors, shoulder bottoms |
ColorPlasticSilver |
#CEDAE1 |
Guide button |
ColorPlasticGreen |
#7cb63b |
A button |
ColorPlasticRed |
#ff5f4b |
B button |
ColorPlasticBlue |
#6ac4f6 |
X button |
ColorPlasticYellow |
#faa51f |
Y button |
Face button overlays (B1Button–B4Button) use transparent variants (Alpha = 150) so the base button color shows through.
| Parameter | Value |
|---|---|
JoystickRotationPointCenterLeftMillimeter |
(-42.231, -6.10, 21.436) |
JoystickRotationPointCenterRightMillimeter |
(21.013, -6.1, -3.559) |
JoystickMaxAngleDeg |
19.0 |
ShoulderTriggerRotationPointCenterLeftMillimeter |
(-44.668, 3.087, 39.705) |
ShoulderTriggerRotationPointCenterRightMillimeter |
(44.668, 3.087, 39.705) |
TriggerMaxAngleDeg |
16.0 |
- Face button overlays (
B1Button–B4Button) get transparent color materials and register intoButtonMapalongside base meshes for joint highlighting. -
SpecialLEDgets green transparent material. - Base face buttons (
B1.obj–B4.obj) get opaque color materials. - Guide button gets silver material.
- White parts:
MainBody,LeftMotor,RightMotor,LeftShoulderBottom,RightShoulderBottom. - Remaining parts default to black.
-
DrawAccentHighlights()called last.
File: PadForge.App/Models3D/ControllerModelDS4.cs
public class ControllerModelDS4 : ControllerModelBaseCalls base("DS4").
| Field | Loaded From | Description |
|---|---|---|
LeftShoulderMiddle |
Shoulder-Left-Middle.obj |
Left shoulder middle piece |
RightShoulderMiddle |
Shoulder-Right-Middle.obj |
Right shoulder middle piece |
Screen |
Screen.obj |
Touchpad/screen area |
MainBodyBack |
MainBodyBack.obj |
Back panel |
AuxPort |
Aux-Port.obj |
Auxiliary port |
Triangle |
Triangle.obj |
Decorative triangle element |
DPadDownArrow |
DPadDownArrow.obj |
D-pad down arrow indicator |
DPadUpArrow |
DPadUpArrow.obj |
D-pad up arrow indicator |
DPadLeftArrow |
DPadLeftArrow.obj |
D-pad left arrow indicator |
DPadRightArrow |
DPadRightArrow.obj |
D-pad right arrow indicator |
| Name | Hex | Usage |
|---|---|---|
ColorPlasticBlack |
#38383A |
Default body color |
ColorPlasticWhite |
#E0E0E0 |
Main body, motors, triangle |
MaterialPlasticTriangle |
#66a0a4 |
Triangle face button symbol |
MaterialPlasticCross |
#96b2d9 |
Cross (X) face button symbol |
MaterialPlasticCircle |
#d66673 |
Circle face button symbol |
MaterialPlasticSquare |
#d7bee5 |
Square face button symbol |
DS4 loads separate symbol meshes (B1-Symbol.obj through B4-Symbol.obj) via TryLoadModel(). Each symbol shares its ButtonMap entry with the base button mesh for joint highlighting. Symbol meshes get PlayStation-specific colors; base meshes default to black.
| Parameter | Value |
|---|---|
JoystickRotationPointCenterLeftMillimeter |
(-25.5, -5.086, -21.582) |
JoystickRotationPointCenterRightMillimeter |
(25.5, -5.086, -21.582) |
JoystickMaxAngleDeg |
19.0 |
ShoulderTriggerRotationPointCenterLeftMillimeter |
(-38.061, 3.09, 26.842) |
ShoulderTriggerRotationPointCenterRightMillimeter |
(38.061, 3.09, 26.842) |
TriggerMaxAngleDeg |
16.0 |
File: PadForge.App/Models3D/ControllerModelXboxOne.cs
public class ControllerModelXboxOne : ControllerModelBase
public ControllerModelXboxOne(bool enableShare)Calls base("XBOXONE"). Used by Xbox One, Xbox Elite, Xbox Series, and Xbox Adaptive profiles. Handheld Companion has no Xbox Series mesh, so Series profiles borrow this one.
| Field | Loaded From | Description |
|---|---|---|
MainBodyBack |
MainBodyBack.obj |
Back shell |
MainBodyTop |
MainBodyTop.obj |
Top shell |
MainBodySide |
MainBodySide.obj |
Side grips |
BackSymbol |
BackSymbol.obj |
View-button glyph |
StartSymbol |
StartSymbol.obj |
Menu-button glyph |
BatteryDoor |
BatteryDoor.obj |
AA-pack door |
BatteryDoorInner |
BatteryDoorInner.obj |
Door interior |
SpecialOuter |
SpecialOuter.obj |
Guide button outer ring |
ShareButton |
ShareButton.obj |
Share button (Series only) |
ShareButtonSymbol |
ShareButtonSymbol.obj |
Share-button glyph |
USBPortInner / USBPortOuter
|
USBPortInner.obj, USBPortOuter.obj
|
USB-C port |
B1Button–B4Button
|
B1-Button.obj–B4-Button.obj
|
Translucent face-button caps |
B1Interior–B4Interior
|
B1-Interior.obj–B4-Interior.obj
|
Colored face-button interiors |
B1Interior2–B4Interior2
|
B1-Interior2.obj–B4-Interior2.obj
|
Inner face-button cores |
The constructor takes a bool enableShare argument. true is passed only when ProfileId starts with xbox-series-. Xbox One, Elite, and Adaptive profiles get false. When false, the Share mesh stays inert: visible body geometry, no hover, no click, no accent highlight. HM silently drops the Share bit on non-Series profiles, so the mapping UI does not surface it either.
| Name | Hex | Usage |
|---|---|---|
ColorPlasticBlack |
#26272C |
Shoulders, triggers, stick clicks, dpad, guide, USB outer, button-interior shells, symbols |
ColorPlasticWhite |
#D8D7DC |
Main shell, share button face, view/menu faces, default fallback |
ColorPlasticGreen |
#76BA58 |
A button interior |
ColorPlasticRed |
#FA3D45 |
B button interior |
ColorPlasticBlue |
#119AE5 |
X button interior |
ColorPlasticYellow |
#E4D70E |
Y button interior |
ColorPlasticTransparent |
#232323 α=50 |
Translucent face-button caps over the colored interior |
| Parameter | Value |
|---|---|
JoystickRotationPointCenterLeftMillimeter |
(-39.0, -8.0, 22.2) |
JoystickRotationPointCenterRightMillimeter |
(20.0, -8.0, -1.1) |
JoystickMaxAngleDeg |
17.0 |
ShoulderTriggerRotationPointCenterLeftMillimeter |
(-44.668, 3.087, 39.705) |
ShoulderTriggerRotationPointCenterRightMillimeter |
(44.668, 3.087, 39.705) |
TriggerMaxAngleDeg |
16.0 |
- Load Xbox One-specific meshes. Register
ButtonShareifenableShare. - Paint face-button interiors green / red / blue / yellow (A / B / X / Y).
- Paint View, Menu, and Share faces white.
- Paint shoulders, triggers, stick clicks, guide, and dpad black.
- Generic pass: USB outer, button-interior shells, stick rings, shoulder triggers, and glyph meshes go black.
- Face-button caps get the translucent material.
- Everything else defaults to white plastic.
-
DrawAccentHighlights()called last.
File: PadForge.App/Models3D/ControllerModelDualSense.cs
public class ControllerModelDualSense : ControllerModelBaseCalls base("DualSense"). Used by DualSense and DualSense Edge profiles.
| Field | Loaded From | Description |
|---|---|---|
MainBodyBack |
MainBodyBack.obj |
Back grip shell |
MainBodyFront |
MainBodyFront.obj |
Front face plate |
Touchpad |
Touchpad.obj |
Central touch surface (split out of upstream MainBody.obj so it is its own click-mappable and highlight-able mesh) |
AudioJack |
AudioJack.obj |
3.5 mm jack |
USBPort |
USBPort.obj |
USB-C port |
Charger |
Charger.obj |
Charging contacts |
LED1, LED2, LED3
|
LED1.obj–LED3.obj
|
Player-indicator LEDs |
ShareSymbol |
ShareSymbol.obj |
Create-button glyph |
MenuSymbol |
MenuSymbol.obj |
Options-button glyph |
B1Button–B4Button
|
B1Button.obj–B4Button.obj
|
Translucent face-button caps |
B1ButtonSymbol–B4ButtonSymbol
|
B1ButtonSymbol.obj–B4ButtonSymbol.obj
|
Cross / Circle / Square / Triangle glyphs |
DPadUpArrow, DPadDownArrow, DPadLeftArrow, DPadRightArrow
|
DPad*Arrow.obj |
Arrow glyphs on the dpad |
DPadUpCover, DPadDownCover, DPadLeftCover, DPadRightCover
|
DPad*Cover.obj |
Translucent dpad covers |
MainBody.obj from Handheld Companion is one file with multiple connected components: grip handles, the central front-face area, dpad pieces, and face-button pieces are joined together. tools/overlay_positions.py runs a one-time split that writes MainBody.obj minus the central front-face component and a separate Touchpad.obj with just the touch surface. The DualSense constructor loads both so MainBody renders the grip and button areas while Touchpad is independently clickable and highlight-able. ClickMap[Touchpad] = "TouchpadClick".
| Name | Hex | Usage |
|---|---|---|
ColorPlasticBlack |
#21242E |
Front face plate, audio jack, USB port, stick rings, shoulder triggers |
ColorPlasticGrey |
#7C7F8C |
Share / Menu glyphs, dpad arrows, face-button glyphs |
ColorPlasticWhite |
#DADFE8 |
Back shell, default fallback |
ColorPlasticTransparent |
#DADFE8 α=100 |
Dpad covers, face-button caps |
ColorMetal |
#5A4928 |
Charging contacts |
ColorLEDOff |
#35383E |
Unlit player-indicator LED |
AccentButtonBackground (theme) |
accent | Lit player-indicator LEDs (LED1, LED2) |
| Parameter | Value |
|---|---|
JoystickRotationPointCenterLeftMillimeter |
(-30.339, -10.7, -1.507) |
JoystickRotationPointCenterRightMillimeter |
(30.339, -10.7, -1.507) |
JoystickMaxAngleDeg |
14.0 |
ShoulderTriggerRotationPointCenterLeftMillimeter |
(-65.4, -0.64, 45.8) |
ShoulderTriggerRotationPointCenterRightMillimeter |
(65.4, -0.64, 45.8) |
TriggerMaxAngleDeg |
16.0 |
Handheld Companion modeled the DualSense larger than the DS4 in raw mesh units (MainBody width 199.9 mm vs 165.7 mm, ~21 % bigger) even though the real-world controllers are nearly the same size. The shared viewport camera is sized for DS4-class meshes, so this class overrides ModelScale = 165.7 / 199.9. The view applies the scale at the ModelVisual3D level, which scales the controller mesh AND the sibling finger-sphere visuals together so stick highlights and touchpad finger dots stay glued to the correct surface.
The split-out Touchpad mesh extends beyond the actual touch-sensitive surface: bounds are roughly X ∈ [−42, 38] (80 mm) and Z ∈ [19, 63] (44 mm) versus the real touch area of ~52 × 32 mm. The class overrides TouchpadXInsetFrac = 0.175, TouchpadZTopInsetFrac = 0.10, TouchpadZBottomInsetFrac = 0.02 so the rendered finger dot lands where a real finger would land instead of sliding past the visual edges.
- Load DualSense-specific meshes. Insert
TouchpadintoClickMapasTouchpadClick. - Per
ButtonMaptarget: black for shoulders, triggers, stick clicks, and guide. White for everything else. - Generic pass: front shell, audio jack, USB, stick rings, and shoulder triggers go black.
- Share / Menu glyphs, dpad arrows, and face-button glyphs go grey.
- LED1 and LED2 take the accent material; LED3 takes the unlit-LED material.
- Charger gets the metal material.
- Dpad covers and face-button caps get the translucent material.
- Everything else defaults to white plastic.
-
DrawAccentHighlights()called last.
PadForge.App/3DModels/
XBOX360/ (31 meshes)
MainBody.obj (body)
MainBody-Charger.obj (battery pack)
Joystick-Left-Ring.obj (left stick torus ring)
Joystick-Right-Ring.obj (right stick torus ring)
MotorLeft.obj, MotorRight.obj (rumble motors)
Shoulder-Left-Trigger.obj (left trigger)
Shoulder-Right-Trigger.obj (right trigger)
SpecialRing.obj, SpecialLED.obj (guide button ring + LED)
LeftShoulderBottom.obj (left bumper bottom)
RightShoulderBottom.obj (right bumper bottom)
B1.obj, B2.obj, B3.obj, B4.obj (base face buttons: A, B, X, Y)
B1Button.obj, B2Button.obj, (colored face-button overlays)
B3Button.obj, B4Button.obj
L1.obj, R1.obj (shoulder bumpers)
Back.obj, Start.obj, Special.obj (center buttons)
DPadUp.obj, DPadDown.obj, (D-pad directions)
DPadLeft.obj, DPadRight.obj
LeftStickClick.obj, RightStickClick.obj (stick click caps)
XBOXONE/ (46 meshes)
MainBody.obj (body)
MainBodyBack.obj, MainBodyTop.obj, (shell panels)
MainBodySide.obj
Joystick-Left-Ring.obj (left stick torus ring)
Joystick-Right-Ring.obj (right stick torus ring)
MotorLeft.obj, MotorRight.obj (rumble motors)
Shoulder-Left-Trigger.obj (left trigger)
Shoulder-Right-Trigger.obj (right trigger)
BatteryDoor.obj, BatteryDoorInner.obj (AA-pack door)
SpecialOuter.obj (guide button outer ring)
ShareButton.obj, ShareButtonSymbol.obj (Series Share button + glyph)
StartSymbol.obj, BackSymbol.obj (Menu + View glyphs)
USBPortInner.obj, USBPortOuter.obj (USB-C port)
B1.obj, B2.obj, B3.obj, B4.obj (base face buttons: A, B, X, Y)
B1-Button.obj, B2-Button.obj, (translucent face-button caps)
B3-Button.obj, B4-Button.obj
B1-Interior.obj, B2-Interior.obj, (colored face-button interiors)
B3-Interior.obj, B4-Interior.obj
B1-Interior2.obj, B2-Interior2.obj, (inner cores)
B3-Interior2.obj, B4-Interior2.obj
L1.obj, R1.obj (shoulder bumpers)
Back.obj, Start.obj, Special.obj (View, Menu, Guide buttons)
DPadUp.obj, DPadDown.obj, (D-pad directions)
DPadLeft.obj, DPadRight.obj
LeftStickClick.obj, RightStickClick.obj (stick click caps)
DS4/ (36 meshes)
MainBody.obj (body)
MainBodyBack.obj (back panel)
Joystick-Left-Ring.obj (left stick torus ring)
Joystick-Right-Ring.obj (right stick torus ring)
MotorLeft.obj, MotorRight.obj (rumble motors)
Shoulder-Left-Trigger.obj (left trigger L2)
Shoulder-Right-Trigger.obj (right trigger R2)
Shoulder-Left-Middle.obj (left shoulder middle)
Shoulder-Right-Middle.obj (right shoulder middle)
Screen.obj (touchpad area)
Aux-Port.obj (auxiliary port)
Triangle.obj (decorative triangle)
DPadDownArrow.obj, DPadUpArrow.obj, (D-pad arrow indicators)
DPadLeftArrow.obj, DPadRightArrow.obj
B1.obj, B2.obj, B3.obj, B4.obj (base face buttons: Cross, Circle, Square, Triangle)
B1-Symbol.obj, B2-Symbol.obj, (PlayStation symbol overlays)
B3-Symbol.obj, B4-Symbol.obj
L1.obj, R1.obj (shoulder bumpers)
Back.obj, Start.obj, Special.obj (Share, Options, PS buttons)
DPadUp.obj, DPadDown.obj, (D-pad directions)
DPadLeft.obj, DPadRight.obj
LeftStickClick.obj, RightStickClick.obj (stick click caps)
DualSense/ (48 meshes)
MainBody.obj (body, minus the touchpad area)
MainBodyBack.obj, MainBodyFront.obj (back shell, front face plate)
Touchpad.obj (split-out central touch surface)
Joystick-Left-Ring.obj (left stick torus ring)
Joystick-Right-Ring.obj (right stick torus ring)
MotorLeft.obj, MotorRight.obj (rumble motors)
Shoulder-Left-Trigger.obj (L2 adaptive trigger)
Shoulder-Right-Trigger.obj (R2 adaptive trigger)
AudioJack.obj (3.5 mm jack)
USBPort.obj (USB-C port)
Charger.obj (charging contacts)
LED1.obj, LED2.obj, LED3.obj (player-indicator LEDs)
ShareSymbol.obj, MenuSymbol.obj (Create + Options glyphs)
B1.obj, B2.obj, B3.obj, B4.obj (base face buttons: Cross, Circle, Square, Triangle)
B1Button.obj, B2Button.obj, (translucent face-button caps)
B3Button.obj, B4Button.obj
B1ButtonSymbol.obj, B2ButtonSymbol.obj, (glyph overlays)
B3ButtonSymbol.obj, B4ButtonSymbol.obj
L1.obj, R1.obj (shoulder bumpers)
Back.obj, Start.obj, Special.obj (Create, Options, PS buttons)
DPadUp.obj, DPadDown.obj, (D-pad directions)
DPadLeft.obj, DPadRight.obj
DPadUpArrow.obj, DPadDownArrow.obj, (D-pad arrow glyphs)
DPadLeftArrow.obj, DPadRightArrow.obj
DPadUpCover.obj, DPadDownCover.obj, (translucent dpad covers)
DPadLeftCover.obj, DPadRightCover.obj
LeftStickClick.obj, RightStickClick.obj (stick click caps)
All OBJ files are embedded as EmbeddedResource in the project file:
<EmbeddedResource Include="3DModels\**\*.obj" />File: PadForge.App/Views/ControllerModelView.xaml, ControllerModelView.xaml.cs
WPF UserControl hosting a HelixViewport3D for 3D controller visualization (~1438 lines code-behind).
<helix:HelixViewport3D
IsRotationEnabled="False" IsPanEnabled="False"
IsMoveEnabled="False" IsZoomEnabled="False"
ShowViewCube="False" Background="Transparent"
IsManipulationEnabled="False">
<helix:SunLight />
<helix:DirectionalHeadLight Brightness="0.35" />
<ModelVisual3D x:Name="ModelVisual3D" />
<PerspectiveCamera FieldOfView="50"
LookDirection="0,0.793,-0.609"
Position="0,-159,122" UpDirection="0,0,1" />
</helix:HelixViewport3D>All built-in HelixToolkit camera controls are disabled. Rotation, zoom, and pan are handled by custom event handlers to avoid conflicts with PadForge's click-to-map and touch gesture handling.
public event EventHandler<string> ControllerElementRecordRequested;Fired when the user clicks a mappable 3D element. The string argument is the PadSetting target name (e.g., "ButtonA", "LeftThumbAxisXNeg").
| Field | Type | Description |
|---|---|---|
_vm |
PadViewModel |
Bound ViewModel |
_currentModel |
ControllerModelBase |
Active 3D model |
_dirty |
bool |
Render-frame update flag |
_triggerAngleLeft/Right |
float |
Current trigger angles (change detection) |
_flashTimer |
DispatcherTimer |
Map All flash timer (400 ms) |
_flashTarget |
string |
PadSetting name being flashed |
_flashOn |
bool |
Flash toggle state |
_arrowVisual |
ModelVisual3D |
Directional arrow for axis recording |
_quadrantRingVisual |
ModelVisual3D |
Stick ring quadrant highlight |
_quadrantRingMaterial |
DiffuseMaterial |
Quadrant ring material (alpha toggled for flash) |
_hoverGroup |
Model3DGroup |
Hovered button/trigger group |
_hoverStickRing |
Model3DGroup |
Hovered stick ring |
_hoverQuadrant |
string |
Hover quadrant axis string |
_hoverQuadrantVisual |
ModelVisual3D |
Quadrant wedge overlay for hover |
_isLeftDragging |
bool |
Left-drag active (rotation) |
_leftDragStart |
Point |
Left-button down position (drag threshold) |
_isRightDragging |
bool |
Right-drag active (panning) |
_rightDragLast |
Point |
Last mouse position during drag |
_modelYaw |
double |
Yaw rotation (degrees, Z axis) |
_modelPitch |
double |
Pitch rotation (degrees, X axis, clamped −60–60) |
_touchDragId |
int? |
First touch ID (rotation) |
_touchSecondId |
int? |
Second touch ID (pinch-to-zoom) |
_touchSecondLast |
Point |
Last second-touch position |
_pinchStartDist |
double |
Inter-finger distance at pinch start |
_pinchMidpoint |
Point |
Two-finger midpoint for panning |
_modelRotation |
Transform3DGroup |
Persistent rotation on ModelVisual3D |
_yawRotation |
AxisAngleRotation3D |
Yaw: axis (0,0,1) |
_pitchRotation |
AxisAngleRotation3D |
Pitch: axis (1,0,0) |
public void Bind(PadViewModel vm)
public void Unbind()Bind subscribes to PropertyChanged, hooks CompositionTarget.Rendering, and calls EnsureModel(). OutputType changes trigger EnsureModel() to switch models. CurrentRecordingTarget changes trigger flash animation and arrow overlays. All other changes set _dirty.
private void EnsureModel()Resolves the asset folder via HMaestroProfileCatalog.ResolveAssetFolders(ProfileId, OutputType), which returns one of four model names:
| Profile family | Resolved folder | Model class |
|---|---|---|
| DualSense / DualSense Edge | DualSense |
ControllerModelDualSense |
| Xbox One, Xbox Elite, Xbox Series, Xbox Adaptive | XBOXONE |
ControllerModelXboxOne |
| DualShock 4 | DS4 |
ControllerModelDS4 |
| Xbox 360 (fallback) | XBOX360 |
ControllerModelXbox360 |
When XBOXONE is selected, EnsureModel() also checks whether the ProfileId starts with xbox-series-. If it does, ControllerModelXboxOne is constructed with enableShare: true so the Share mesh becomes click-mappable. Xbox One, Elite, and Adaptive profiles get enableShare: false (mesh is rendered but inert).
The model swap is skipped when _currentModel.ModelName == needed and the Share-enabled flag has not changed, so re-entrancy from PropertyChanged storms is cheap.
Extended slots route to ControllerSchematicView instead of this view, so this control only ever sees Xbox or PlayStation slots in practice. The v2 ExtendedConfig.Preset enum that previously gated DS4-vs-Xbox360 model selection on Extended slots was dropped in v3 (commit d57a725).
Returns immediately if the current model matches. Otherwise disposes the old model, creates the new one, and assigns to ModelVisual3D.Content.
CompositionTarget.Rendering handler (~60 fps), gated by _dirty flag:
OnRendering()
|
+-> _dirty check (skip if clean)
|
+-> HighlightButtons() -- swap materials for 15 buttons
+-> UpdateJoystick() x2 -- tilt left/right stick meshes
+-> UpdateTrigger() x2 -- rotate left/right trigger meshes
Iterates the 15-element ButtonProperties array, reads each PadViewModel bool via GetButtonState(), and swaps between DefaultMaterials and HighlightMaterials:
private static readonly string[] ButtonProperties =
{
"ButtonA", "ButtonB", "ButtonX", "ButtonY",
"LeftShoulder", "RightShoulder",
"ButtonBack", "ButtonStart", "ButtonGuide",
"DPadUp", "DPadDown", "DPadLeft", "DPadRight",
"LeftThumbButton", "RightThumbButton"
};For each button, iterates all Model3DGroup entries in ButtonMap (multi-mesh support). Only modifies GeometryModel3D children with DiffuseMaterial.
private void UpdateJoystick(
short rawX, short rawY,
Model3DGroup thumbRing, Model3D thumb,
Vector3D rotationPoint, float maxAngleDeg)- Normalizes raw values (
short.MaxValue) to −1–1 range. -
Gradient highlight: Blends default/highlight materials by deflection magnitude via
GradientHighlight(). -
Rotation:
AxisAngleRotation3Dfor X (around Z) and Y (around X), centered atrotationPoint. Both ring and thumb meshes share the sameTransform3DGroup.
private void UpdateTrigger(
double triggerNorm,
Model3DGroup triggerModel,
Vector3D rotationPoint,
float maxAngleDeg,
ref float prevAngle)- Gradient color: Blends default/highlight materials by trigger value (0–1).
-
Rotation:
AxisAngleRotation3Daround X axis atrotationPoint. Max angle:-maxAngleDeg * value. - Change detection: Skips update if angle delta < 0.01 degrees.
private static DiffuseMaterial GradientHighlight(Material default, Material highlight, float factor)ARGB linear interpolation between default and highlight colors. Creates a new DiffuseMaterial per call (no caching; only called when values change).
Turntable rotation (left-drag) and camera panning (right-drag) via Preview (tunneling) events, which fire before HelixToolkit's built-in handlers and mark e.Handled = true to prevent double-processing.
| Event | Action |
|---|---|
PreviewMouseLeftButtonDown |
Record start position, capture mouse for rotation |
PreviewMouseLeftButtonUp |
Drag < 5 px -> hit-test for click-to-record; otherwise end drag |
PreviewMouseRightButtonDown |
Capture mouse, store start position for panning |
PreviewMouseRightButtonUp |
Release capture |
PreviewMouseMove |
Left-drag: rotate; right-drag: pan; no button: hover highlight |
PreviewMouseWheel |
Zoom camera along look direction |
PreviewTouchDown |
First finger: rotation. Second finger: pinch-to-zoom + pan. |
PreviewTouchMove |
One finger: rotation. Two fingers: pinch-to-zoom + midpoint pan. |
PreviewTouchUp |
Release touch; demote second finger to first if needed |
PreviewStylusSystemGesture |
Block WPF press-and-hold / flick gestures |
ManipulationStarting |
Cancel WPF manipulation HelixToolkit may re-enable |
Rotation is applied to ModelVisual3D.Transform (not the camera), keeping lighting screen-relative:
-
Yaw: axis
(0,0,1), angle =_modelYaw -
Pitch: axis
(1,0,0), angle =_modelPitch(clamped −60–+60 degrees) - Sensitivity: 0.5 degrees per pixel. "Reset View" sets both to 0.
Touch details:
-
Single finger: Rotation (same as left-drag), captured via
_touchDragId. -
Second finger: Pinch-to-zoom (inter-finger distance vs
_pinchStartDist, camera moves along look direction) + pan (midpoint tracking, camera moves perpendicular). -
Stylus suppression: WPF press-and-hold (synthesized right-click) and flick gestures blocked via
PreviewStylusSystemGesture.
private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)-
Viewport3DHelper.FindHits()at click position. - For each hit
GeometryModel3D:-
Stick ring:
IsStickRingHit()checksLeftThumbRing/RightThumbRing, delegates toDetermineAxisFromQuadrant(). -
ClickMap: Walks entries to find the containing
Model3DGroup.
-
Stick ring:
- Fires
ControllerElementRecordRequestedwith the PadSetting target name.
private bool IsStickRingHit(GeometryModel3D hitGeo, Point3D hitPos, out string axis)Checks if hit geometry belongs to a stick ring, then calls:
private static string DetermineAxisFromQuadrant(
Point3D hitPos, Vector3D center, string xAxis, string yAxis)Uses hit position relative to joystick rotation center:
-
Dominant X (
|deltaX| > |deltaZ|): ReturnsxAxisorxAxis + "Neg"by deltaX sign. -
Dominant Z: Returns
yAxisoryAxis + "Neg"by deltaZ sign. -
Y-axis inversion: Model Z-up = stick up.
deltaZ >= 0maps toyAxis + "Neg"because Step 3's NegateAxis inverts Y output, so stick-up in-game maps to the positive direction.
Viewport_MouseMove hit-tests at the cursor on every move:
-
Buttons/triggers:
ApplyHoverHighlight()sets highlight material.RestoreHoverGroup()restores default (skipped during flash animation). -
Stick rings:
ShowHoverQuadrant()creates a semi-transparent wedge overlay from the ring's mesh triangles, clipped to the target quadrant. -
ClearHover()removes all hover state and resets the cursor. -
Viewport_MouseLeavealso clears hover (and releases dangling right-drag).
private void UpdateFlashTarget(string target)Starts when CurrentRecordingTarget changes. A DispatcherTimer at 400 ms toggles highlight/default materials:
-
Buttons/triggers: Swaps materials via
ResolveFlashGroups(). -
Stick axes:
ShowQuadrantRingOverlay()for the target quadrant +ShowArrowForTarget()for direction.FlashQuadrantRing()toggles overlay alpha between 200 and 0. - Stops when
CurrentRecordingTargetbecomes null.
private void ShowArrowForTarget(string target)
private void RemoveArrow()Creates a 3D arrow (ModelVisual3D) via CreateFlatArrow():
- Flat box (shaft) + triangular prism (head).
- Positioned at stick center, offset forward (Y = center.Y − 25) for visibility.
- Direction from target:
LeftThumbAxisX= right,LeftThumbAxisXNeg= left, etc. - Uses the app's accent color.
private void ShowQuadrantRingOverlay(string target)
private MeshGeometry3D BuildClippedQuadrantMesh(
Model3DGroup ring, Vector3D center, bool isX, bool isNeg)Builds a highlight overlay from the ring's mesh triangles:
- Two half-planes at +/−45 degrees isolate one quadrant.
-
Sutherland-Hodgman clipping: Clips each triangle against both half-planes via
ClipPolygonByHalfPlane(). -
Torus-outward offset:
OffsetTorusOutward()pushes vertices 0.8 mm outward along the tube's radial direction to prevent z-fighting (computes nearest point on torus center circle, offsets along tube normal). - Triangulates clipped polygons as fans.
All models use WPF DiffuseMaterial with SolidColorBrush. Three categories:
| Category | Source | Storage | Usage |
|---|---|---|---|
| Default | Static Color fields per subclass (e.g., ColorPlasticWhite). Xbox 360 face overlays use Alpha = 150. |
DefaultMaterials[group] |
Restored after highlight/flash |
| Highlight |
DrawAccentHighlights() reads AccentButtonBackground (ModernWpfUI), falls back to #2196F3. |
HighlightMaterials[group] |
Applied on press or flash |
| Gradient |
GradientHighlight() ARGB-interpolates default/highlight per call. |
None (created per call) | Sticks and triggers (proportional) |
Gradient materials are not cached. The dirty flag limits updates to one per render frame, WPF3D materials are lightweight, and gradients only update when values change.
Standard WPF3D right-handed coordinates:
| Axis | Direction |
|---|---|
| X | Left (negative) / Right (positive) |
| Y | Forward-backward (camera view axis) |
| Z | Up (positive) / Down (negative) |
PerspectiveCamera: Position (0, -159, 122) (behind and above), LookDirection (0, 0.793, -0.609) (forward + slightly down), Z-up, 50-degree FOV.
Applied to ModelVisual3D.Transform (not the camera) so lighting stays screen-relative.
| Transform | Axis | Source | Clamp |
|---|---|---|---|
| Yaw | Z (0,0,1) | Left-drag horizontal | None |
| Pitch | X (1,0,0) | Left-drag vertical | −60–+60 degrees |
Transform3DGroup on both ring and thumb meshes:
-
X tilt: Around Z axis, proportional to stick X, centered at
JoystickRotationPointCenter{Left/Right}Millimeter. - Y tilt: Around X axis, proportional to stick Y, same center.
Both capped at JoystickMaxAngleDeg (19 degrees for Xbox 360 and DS4).
Rotates around X axis at ShoulderTriggerRotationPointCenter{Left/Right}Millimeter. Angle: -TriggerMaxAngleDeg * value (negative = downward). Skips update if angle delta < 0.01 degrees.
Two light sources in XAML:
-
SunLight: HelixToolkit built-in (ambient + directional, world-space). -
DirectionalHeadLight: Camera-relative at brightness 0.35, prevents dark spots during rotation.
| Technique | Detail |
|---|---|
| Dirty flag batching | 15 buttons + 4 axes + 2 triggers coalesced into one render-frame update. |
| Trigger change detection | Skips rotation if angle delta < 0.01 degrees. |
| No gradient caching | New DiffuseMaterial per call; acceptable since dirty flag limits frequency and WPF3D materials are lightweight. |
| One-time mesh loading | OBJ meshes loaded in constructor; EnsureModel() only recreates on Xbox 360/DS4 switch. |
| Preview events | Tunneling events prevent double-processing by HelixToolkit and PadForge. |
-
2D Overlay System:
ControllerModel2DView(PNG overlay alternative to 3D),ControllerSchematicView,KBMPreviewView,MidiPreviewView -
ViewModels:
PadViewModelproperties bound byControllerModelView -
XAML Views:
PadPagehosts and switches between 3D, 2D, schematic, KBM, and MIDI views - Virtual Controllers: Output type determines which preview view is active
-
Engine Library:
Gamepadstruct providing button/axis state for 3D animation -
Build and Publish: 3D OBJ meshes (
3DModels/) included asEmbeddedResourceitems