-
Notifications
You must be signed in to change notification settings - Fork 2
3D Model System
Renders interactive Xbox 360 and DualShock 4 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)
+-- ControllerModelDS4 (DualShock 4 meshes, colors, rotation points)
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 |
"XBOX360" or "DS4". 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 resource path prefix (
"XBOX360"or"DS4"). -
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 |
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)
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)
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()Selects model from OutputType and VJoyConfig.Preset:
-
DualShock4orVJoywithPreset == DualShock4->"DS4" - All others ->
"XBOX360"
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