diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53c311fd3..f942febdd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ Built with Unity 6.5
### Added
+- Wire Rail component with native spline authoring, fixtures (rings, rungs, cradles, stands, hairpins, and elbows), and an inferred ball-channel collider ([Documentation](https://docs.visualpinball.org/creators-guide/editor/wire-rails/index.html)).
- Make packaging functional ([#557](https://github.com/freezy/VisualPinball.Engine/pull/557))
- New threading model ([#552](https://github.com/freezy/VisualPinball.Engine/pull/552))
- Free transformation ([#500](https://github.com/freezy/VisualPinball.Engine/pull/500))
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/ramp_wires.png b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/ramp_wires.png
new file mode 100644
index 000000000..5b6d2984f
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/ramp_wires.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_gray/ramp_wires.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/ramp_wires.png.meta
new file mode 100644
index 000000000..a4ff6d482
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/large_gray/ramp_wires.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: c45b926fba18f794786d943065aad4de
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_orange/ramp_wires.png b/VisualPinball.Unity/Assets/Editor/Icons/large_orange/ramp_wires.png
new file mode 100644
index 000000000..6dd9ae390
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/large_orange/ramp_wires.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/large_orange/ramp_wires.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/large_orange/ramp_wires.png.meta
new file mode 100644
index 000000000..f5ec7548f
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/large_orange/ramp_wires.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: 7ead0a060fe9870438f0df30d029f0bf
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other.meta b/VisualPinball.Unity/Assets/Editor/Icons/other.meta
new file mode 100644
index 000000000..4ca88001d
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/other.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ce1d188d275e9a546a8feb7e73f9a768
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_cradle.png b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_cradle.png
new file mode 100644
index 000000000..d99334fea
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_cradle.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_cradle.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_cradle.png.meta
new file mode 100644
index 000000000..d6a118c6a
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_cradle.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: a08d5403c45e720438d41c01e212d274
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_elbow.png b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_elbow.png
new file mode 100644
index 000000000..3eb2a91f1
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_elbow.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_elbow.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_elbow.png.meta
new file mode 100644
index 000000000..6e53449dc
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_elbow.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: 02a5cbf73f0ea2b47aca34ae6e11ccb4
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_hairpin.png b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_hairpin.png
new file mode 100644
index 000000000..8a6f6c7d4
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_hairpin.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_hairpin.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_hairpin.png.meta
new file mode 100644
index 000000000..ef381ebfe
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_hairpin.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: a43237636d93e4241a6fd9d715d5d169
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_ring.png b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_ring.png
new file mode 100644
index 000000000..23fd02462
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_ring.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_ring.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_ring.png.meta
new file mode 100644
index 000000000..bc0ed8d1f
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_ring.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: cda252e5bc079a444af3e64a915e5309
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_rung.png b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_rung.png
new file mode 100644
index 000000000..8a93eade6
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_rung.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_rung.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_rung.png.meta
new file mode 100644
index 000000000..c2febe6c6
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_rung.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: 3755fed38c3e9474092c83f20214aa17
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_stand.png b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_stand.png
new file mode 100644
index 000000000..ed17bfcd4
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_stand.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_stand.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_stand.png.meta
new file mode 100644
index 000000000..43eda8af2
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_stand.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: cb6973b0766444148af308c838faf4e3
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_trim.png b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_trim.png
new file mode 100644
index 000000000..f86a1b442
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_trim.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_trim.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_trim.png.meta
new file mode 100644
index 000000000..dfcd1686b
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/other/wire_rail_trim.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: 6c03817ff828040499e4ad729fb70ab0
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 1
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 1
+ aniso: 1
+ mipBias: 0
+ wrapU: 0
+ wrapV: 0
+ wrapW: 0
+ nPOTScale: 1
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 0
+ spriteTessellationDetail: -1
+ textureType: 0
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/horizon.png b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/horizon.png
new file mode 100644
index 000000000..fe641913a
Binary files /dev/null and b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/horizon.png differ
diff --git a/VisualPinball.Unity/Assets/Editor/Icons/small_gray/horizon.png.meta b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/horizon.png.meta
new file mode 100644
index 000000000..2eb51083c
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Editor/Icons/small_gray/horizon.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: 4233de7d47db64f4fa7103536b28f106
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 0
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 2
+ aniso: 1
+ mipBias: 0
+ wrapU: 1
+ wrapV: 1
+ wrapW: 0
+ nPOTScale: 0
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteTessellationDetail: -1
+ textureType: 2
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Assets/Presets/Asset Thumbcam/Wire Rails (front).preset b/VisualPinball.Unity/Assets/Presets/Asset Thumbcam/Wire Rails (front).preset
new file mode 100644
index 000000000..fc8da0f85
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Presets/Asset Thumbcam/Wire Rails (front).preset
@@ -0,0 +1,76 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!181963792 &2655988077585873504
+Preset:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Wire Rails (front)
+ m_TargetType:
+ m_NativeTypeID: 4
+ m_ManagedTypePPtr: {fileID: 0}
+ m_ManagedTypeFallback:
+ m_Properties:
+ - target: {fileID: 0}
+ propertyPath: m_LocalRotation.x
+ value: 0.33910447
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalRotation.y
+ value: -0.0014145328
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalRotation.z
+ value: 0.0039241794
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalRotation.w
+ value: 0.9407395
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalPosition.x
+ value: 0.0283
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalPosition.y
+ value: 0.1366
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalPosition.z
+ value: -0.1001
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalScale.x
+ value: 1
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalScale.y
+ value: 1
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalScale.z
+ value: 1
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_ConstrainProportionsScale
+ value: 0
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalEulerAnglesHint.x
+ value: 0
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalEulerAnglesHint.y
+ value: 45
+ objectReference: {fileID: 0}
+ - target: {fileID: 0}
+ propertyPath: m_LocalEulerAnglesHint.z
+ value: 0
+ objectReference: {fileID: 0}
+ m_ExcludedProperties: []
+ m_CoupledType:
+ m_NativeTypeID: 0
+ m_ManagedTypePPtr: {fileID: 0}
+ m_ManagedTypeFallback:
+ m_CoupledProperties: []
diff --git a/VisualPinball.Unity/Assets/Presets/Asset Thumbcam/Wire Rails (front).preset.meta b/VisualPinball.Unity/Assets/Presets/Asset Thumbcam/Wire Rails (front).preset.meta
new file mode 100644
index 000000000..b186399f6
--- /dev/null
+++ b/VisualPinball.Unity/Assets/Presets/Asset Thumbcam/Wire Rails (front).preset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 965041c85a43c2046aa75bdd16b01f7f
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 2655988077585873504
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/collider-preview.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/collider-preview.png
new file mode 100644
index 000000000..b4e086dd9
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/collider-preview.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/collider-settings.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/collider-settings.png
new file mode 100644
index 000000000..354158dc7
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/collider-settings.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-cradle.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-cradle.png
new file mode 100644
index 000000000..ec942c8d0
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-cradle.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-elbow.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-elbow.png
new file mode 100644
index 000000000..9843fb34c
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-elbow.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-hairpin.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-hairpin.png
new file mode 100644
index 000000000..916d0c803
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-hairpin.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-rail-trim.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-rail-trim.png
new file mode 100644
index 000000000..a75e3dff2
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-rail-trim.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-ring.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-ring.png
new file mode 100644
index 000000000..ff2a9bdb3
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-ring.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-rung.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-rung.png
new file mode 100644
index 000000000..ee9dc20d6
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-rung.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-solder.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-solder.png
new file mode 100644
index 000000000..7bd1f3299
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-solder.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-stand.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-stand.png
new file mode 100644
index 000000000..cc9903573
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixture-stand.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixtures.md b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixtures.md
new file mode 100644
index 000000000..41300e5c2
--- /dev/null
+++ b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/fixtures.md
@@ -0,0 +1,152 @@
+---
+uid: wire_rail_fixtures
+title: Fixtures
+description: Rings, rungs, cradles, stands and the end fittings that make a generated rail look built.
+---
+
+# Fixtures
+
+Wires on their own float. Fixtures are the metalwork that holds a real rail together and attaches it to the playfield: rings around the bundle, rungs between the bottom rails, legs down to the wood, and the hairpins and elbows where the rail ends.
+
+There are two families. **Supports** (ring, rung, cradle, stand) sit at a **Position** along the route, and you can have as many as you like. **End fittings** (hairpin, elbow, rail trim) attach to the start or the end of an open route and reshape the rails there.
+
+All of them share a few rules:
+
+- Every fixture uses the rail's global **Wire Diameter** and render material.
+- A fixture's position is a distance along the route and independent from the layouts. Its shape, though, is fitted to the wires at that point: change the layout there and the fixture refits in place. Reshape the spline and the fixture travels with the route.
+- Each panel previews the evaluated shape, with the rails it touches in gray.
+- **Enabled** hides a fixture from the render mesh only. It stays in the collider, so a hidden elbow or rail trim still shortens the channel. Use it to compare variants, not to keep spare fixtures around.
+- Supports are soldered to the rails they touch. See [Solder](#solder).
+- Dragging a fixture in the list changes its numbering only.
+
+## Supports
+
+### Ring
+
+
+
+A ring around the whole bundle. It's fitted automatically: centered on the active wires at its position, and sized to just touch the outermost one. The fit follows the layout, so a ring inside a transition shrinks or grows with the wires.
+
+- **Scale** multiplies the fitted radius. 1 hugs the wires.
+- **Ring Density** is the number of tube segments around a full circle. A partial ring gets its proportional share.
+- **Offset X / Z** slides the whole ring in the cross-section.
+- **Cutout** removes an angular range, leaving an open arc with capped ends.
+- **Straight Line** replaces a range with a straight chord, for the flat-topped or flat-bottomed brackets many rails use. Both can be active at once.
+
+Angles run counter-clockwise from the right: 0° is to the right of the rail, 90° straight up, 180° left, 270° down. A range may wrap past 360°. The small horizon button beside each range keeps its width but re-centers it on straight up or straight down, whichever is closer, so both ends sit at the same height. Use it to level a chord you've roughed in with the slider.
+
+**Apply to All** copies everything except Position to every other ring on the rail. Tune one, then apply.
+
+
+
+### Rung
+
+
+
+A straight rung between the two bottom rails, wires 1 and 2. Its span is measured from wire surface to wire surface.
+
+- **Angle** turns it around the center of the wire bundle. 0° is horizontal, 90° vertical.
+- **Length** is a signed adjustment to the span. Positive sticks out past both rails, negative pulls the ends in.
+- **Offset X / Z** moves the rung in the cross-section.
+
+The rung disappears wherever either bottom rail is inactive.
+
+
+
+### Cradle
+
+
+
+A rung with an arm rising from each end: the U-shaped bracket that cradles a habitrail from below. Its default shape is fitted to the default four-wire layout, with the arms leaning out at 53.13° and just touching the outside of the bottom and raised rails.
+
+- **Bottom Length** is the width of the straight bottom wire.
+- **Left / Right Arm Length** set each arm. Set one to 0 to leave it out. With both at 0 you have a plain capped rung.
+- **Arm Angle** is the angle between the two arms.
+- **Rotation** turns the whole fixture around the route.
+- **Corner Radius** rounds the bend where each arm meets the bottom.
+- **Ring Density** only affects the rounded corners. Bends are never stepped by more than 15° regardless, so a low value can't flatten them.
+
+The fixture stays anchored where its default shape fits, so editing lengths and angles reshapes it in place instead of sliding it around. Two limits are enforced quietly: the corner radius can't go below half the wire diameter, and an arm set to a small positive length is raised to the shortest length that fits its rounded corner. With fewer than four active rails, the fixture is anchored under whatever wires remain. Use the offsets to place it.
+
+
+
+### Stand
+
+
+
+A leg from the rail down to the playfield, ending in the U-shaped hook that gets screwed down. The stand is one continuous wire: an attachment across the two bottom rails, a leg, and a foot.
+
+**Rail Attachment** behaves like a rung, with the same **Offset** and signed **Length**.
+
+**Leg**
+
+- **Side** chooses which end of the attachment the leg leaves from. Changing it moves the leg to the other rail and nothing else. **Mirror** flips the entire stand, foot included, to the other side of the rail.
+- **Start Vector** is the direction the leg sets off in, in route-local coordinates: X across the rail, Y along it, Z up. The default (0, 0, -1) goes straight down.
+- **Start Length** is how far it travels before bending toward the foot. Give it some Y and the leg leans along the route, which is how the long diagonal struts on real rails are made.
+
+**U-Hook Foot**
+
+- **Position** and **Rotation** place the foot relative to the leg's attachment point, in the same route-local frame. Rotate it flat to sit on the playfield, or upright to mount against a wall.
+- **Width** is the width across the U, **Arm Length** the free arm, **Connected Arm Length** the arm the leg joins. The leg always meets that point, so moving the foot re-routes the leg.
+- **Clockwise** flips the winding of the U.
+
+Every bend in the leg is rounded to the wire diameter. The stand is omitted where either bottom rail is inactive, or when the leg would fold straight back through its attachment.
+
+
+
+### Solder
+
+Wherever a support's wire touches a rail, the generator adds a small solder blob.
+
+
+
+- **Solder Threshold** is the largest surface gap that still counts as touching, 2 units by default. It's an absolute distance: a thicker wire doesn't get a more forgiving threshold.
+- **Solder Size** scales the blob. All three dimensions scale, so doubling it gives eight times the volume.
+
+Blobs are placed deterministically and don't jump around between rebuilds. End fittings don't get solder. They are continuous with the rails.
+
+## End fittings
+
+End fittings need an open route. Hairpins and elbows take over two rails at one endpoint, **Rail A** and **Rail B**, and only one fitting can own a rail at a given end. If another fitting or a rail trim already shortens one of the rails you pick, the fitting isn't generated and its panel tells you why.
+
+### Hairpin
+
+
+
+Joins two rails into one semicircle past the end of the route, the shape that ends most habitrails where the ball drops out. The rails blend into the loop through smooth leads, without a notch.
+
+- **Loop Diameter** is the centerline diameter of the semicircle. Make it wider than the rail spacing and the leads flare out to meet it.
+- **Lead Length** is how far past the endpoint the loop's center sits.
+- **Tangent Length** controls how gradually each lead leaves its rail. It's capped at about half the lead, so a lead can't overshoot.
+- **Rotation** turns the loop around the route, for a loop that stands up or lies flat.
+- **Offset** pulls the loop back from the endpoint. The two attached rails are shortened by the same distance, so the loop stays welded to their ends. Use it to end a rail's loop before the other wires do.
+- **Ring Density** is the tube resolution around the loop, for rendering only.
+
+The loop's collider is a coarse square tube along the leads and the arc, and its Offset shortens the two rails in the collider as well. The arc is its own collider part and uses the **Terminal Impact Material** from the Ball Channel Collider section when one is assigned, so the last hit as the ball leaves the rail can be softer or livelier than the rails themselves. See [Physics](geometry.md#physics).
+
+
+
+### Elbow
+
+
+
+Two rails that bend straight down, side by side: the mouth of a rail feeding a hole, a scoop or a subway.
+
+- **Offset** moves the elbow inward from the endpoint and shortens the two attached rails to that point. 0 bends right at the endpoint.
+- **Drop Length** is the total vertical distance from the start of the bend.
+- **Z Angle** turns the bend around the vertical, for an elbow that twists as it descends.
+- **Other Rail Cutoffs** shorten each remaining rail by its own distance from the endpoint, so upper guide wires can stop before the elbow without a separate rail trim. The attached rails are fixed at 0.
+
+The bend radius equals the wire diameter (an elbow shorter than that tightens it). In the collider, the two attached rails are trimmed at the elbow and the two floor facets the ball rests on are extended straight down by the drop length, so the ball is guided into the hole rather than falling off a cliff. Other Rail Cutoffs are visual only.
+
+
+
+### Rail Trim
+
+
+
+Not a shape, just a distance per rail, measured inward from one endpoint. Use it when individual rails should start or end at different points without adding a layout: a top wire that stops short, a guide that starts late. 0 leaves a rail alone.
+
+A trim can cross any number of layouts. It cuts both the tube, with a beveled cap, and the collider. Several trims at the same endpoint combine by taking the largest value per rail, so their order never matters. Where a trim leaves too few rails to form a channel, that stretch of the route has no collider.
+
+
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/geometry.md b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/geometry.md
new file mode 100644
index 000000000..3febee175
--- /dev/null
+++ b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/geometry.md
@@ -0,0 +1,73 @@
+---
+uid: wire_rail_geometry
+title: Generated Geometry
+description: What the Wire Rail builds from your route and layouts, and how to tune the render tubes and the ball channel collider.
+---
+
+# Generated Geometry
+
+Nothing you see of a wire rail is modeled by hand. The component keeps two meshes up to date from the route, the layouts and the fixtures: a render mesh of tubes, and a collider. Both are regenerated whenever anything changes, and neither is saved with the scene. They're rebuilt on load. Should a mesh ever look stale, **Rebuild Render Geometry** and **Rebuild Collider** force the issue, and each section shows its current vertex or triangle count so you can keep an eye on cost.
+
+## Render Geometry
+
+
+
+- **Rails** is the wire count, described under [Wire Layouts](layouts.md#how-many-wires).
+- **Material** is applied to everything the rail generates.
+- **Wire Diameter** is the thickness of every wire in the rail, fixtures included. The default of 6.5 units is a typical habitrail wire next to a 50 unit ball.
+- **Tube Sides** is the polygon count around each tube, 6 to 16. Ten looks round at playing distance.
+- **Wire Cap Bevel** chamfers every exposed wire end by one segment so that cut ends catch the light. It's clamped to half the wire diameter. Ends that butt into an elbow or a hairpin are left square so the joint is flush.
+- **Minimum Samples Per Layout Span** is the base number of rings along each span. Bends are refined on their own: wherever a wire turns more than 5° between two rings, rings are added, up to three levels deep. Raise the base value only for long, gently curving spans that still look faceted.
+
+Wires are capped only where they actually end: at the ends of the route, at a trim, at a layout boundary where the wire is inactive on the other side, or at a non-continuous transition. A wire that continues, even through a jump in position, is one unbroken tube.
+
+
+
+## Ball Channel Collider
+
+
+
+The collider is not a tube around each wire. That would cost a lot of triangles and, worse, leave seams between the wires for the ball to catch on. Instead, the component works out where a ball of **Ball Diameter** would rest in the bundle, finds the point on each wire it would touch, and lays a flat facet through each contact point. The facets are joined into one channel profile, and the profile is swept along the route. The ball rolls on a smooth, continuous channel that behaves the way the wires would, at a fraction of the geometry.
+
+**Ball Diameter** changes only this fit. It doesn't resize the visible wires, nor the ball in the game.
+
+
+
+
+Show Collider Preview draws the channel in green. Note the open top, and the faceted floor between the two bottom rails.
+
+### What the profile looks like
+
+The profile is refitted wherever the layout changes, and adapts to how many wires are active:
+
+| Active wires | Channel |
+|---|---|
+| 1 | A narrow flat strip under the ball |
+| 2 | A faceted floor, open at the top |
+| 3 | Floor plus a wall on the Third Rail side |
+| 4 | Floor and both walls, open at the top |
+| 5 or 6 | Floor, walls and a roof, unless the ball fits through the top |
+
+The last rule matters for rails with top wires. If the clear gap between the two wires bounding the top is wider than the ball, the channel stays open there and the ball can leave the rail upward, as it could in reality. If the gap is narrower, the roof closes. Within one span between layouts, the opening has to stay between the same two wires. If your design moves the opening to a different pair, or closes it partway, add a layout at the point where that changes. The inspector says so when it's needed.
+
+**Curvature Detail** controls how densely the channel is sampled along the route. Curved and changing spans get more rows, straight spans stay sparse no matter how many knots they contain. Raise it if a tight bend shows visible flats in the collider preview.
+
+### Physics
+
+By default the channel uses the values under **Overwrite Physics**: elasticity, falloff, friction and scatter, as on any VPE collider. Untick it to use a **Physics Material** asset instead. While it's ticked, the asset is ignored.
+
+**Terminal Impact Material** is the exception. It applies only to the arc of a [hairpin](fixtures.md#hairpin), and it applies whenever it's assigned, overwrite or not. That's deliberate: the impact that ends a rail is the one you most often want to tune on its own.
+
+### How fixtures affect the collider
+
+- A **rail trim** cuts the channel at the trim distance. Beyond it, the profile is refitted from the rails that remain. If they can't form a channel (a lone upper wire, say), that stretch has no collider.
+- An **elbow** trims its two rails and extends the floor straight down at the bend. Its other-rail cutoffs don't touch the collider.
+- A **hairpin** trims its two rails by its Offset and adds a coarse square tube along its leads and arc.
+- Rings, rungs, cradles and stands are visual only.
+- A fixture's **Enabled** toggle hides it visually only. The collider is built from all fixtures.
+
+### When the collider can't be built
+
+If the channel's shape changes fundamentally inside a single span (a roof appearing, or the top opening jumping to different wires), the collider isn't built at all and the inspector shows the reason in red. A rail without a collider is a rail the ball falls through, so treat the message as a blocker. The fix is nearly always a layout at the point where the profile changes.
+
+Validation is lazy. The inspector shows *Collider validation is pending* until the Ball Channel Collider section is expanded or its preview is on. Play mode always builds it.
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/index.md b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/index.md
new file mode 100644
index 000000000..c73931452
--- /dev/null
+++ b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/index.md
@@ -0,0 +1,46 @@
+---
+uid: wire_rail_authoring
+title: Wire Rails
+description: Route wire ramps and guides with a spline, shape the wires along the way, and let VPE generate the tubes, the fixtures and a ball channel collider.
+---
+
+
+
+# Wire Rails
+
+Wire rails are the bent-steel ramps, habitrails and guides that carry the ball above the playfield. In VPE, a wire rail is one component. You draw a route with a Unity spline, describe how the wires sit around that route, add rings and supports where a real one would have them, and the component generates both the visible tubes and a collider the ball rolls in.
+
+## How to think about it
+
+A wire rail is four ideas stacked on top of each other:
+
+1. **The route** is a spline (like a Curve in Blender). It's the centerline every wire follows, and its knots are the what you position by hand in the Scene view.
+2. **Wire layouts** describe the cross-section: which wires exist, and where each one sits to the left, right, above or below the centerline. A layout is placed at a distance along the route and holds until the next one. Between two layouts, the wires glide from one arrangement to the other.
+3. **Fixtures** are the metalwork that holds the wires together: rings, rungs, stands, and the fittings at either end of the rail.
+4. **Generated geometry** is what comes out: a render mesh of tubes, and a separate *ball channel* collider fitted to the space the ball actually occupies rather than to each wire.
+
+The route and the cross-section are edited independently, and that's the point. Adding a knot never changes the wires, and adding a layout never changes the route.
+
+## Create a wire rail
+
+
+
+
+Click **Wire Rail** in the toolbox, or choose *GameObject -> Pinball -> Wire Rail*. VPE creates a Wire Rail GameObject under the playfield with a straight 500 unit route and a four-wire habitrail layout: two bottom rails the ball rests on, and two raised rails that keep it from falling out.
+
+That is already a working rail. It renders, and in play mode the ball rolls along it. Everything from here on is refinement:
+
+- [Shape the route](xref:wire_rail_route) in the Scene view and grade its height.
+- [Change the wire layout](xref:wire_rail_layouts) where the rail opens up, narrows, or gains and loses wires.
+- [Add fixtures](xref:wire_rail_fixtures) so the rail looks built rather than floating.
+- [Tune the generated geometry](xref:wire_rail_geometry), from tube resolution to how the collider treats the ends of the rail.
+
+## Units
+
+Everything you type into the Wire Rail inspector is in VPX units, like the rest of the table. A standard ball is 50 units across, and the default wire is 6.5 units thick. See [Units and 3D Space](xref:units_3d_space) for how those map to Unity units.
+
+## What it doesn't do yet
+
+- No import from or export to `.vpx` files. Wire rails are authored in VPE only.
+- No branching. One component is one route. A junction is two components meeting.
+- No switch events. A wire rail is only a collider. If the game needs to know the ball is on it, put a trigger there.
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/layout-panel.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/layout-panel.png
new file mode 100644
index 000000000..47a56a397
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/layout-panel.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/layout-transition.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/layout-transition.png
new file mode 100644
index 000000000..3b41f4a78
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/layout-transition.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/layouts.md b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/layouts.md
new file mode 100644
index 000000000..8bff55809
--- /dev/null
+++ b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/layouts.md
@@ -0,0 +1,71 @@
+---
+uid: wire_rail_layouts
+title: Wire Layouts
+description: Define which wires make up the rail and where they sit, and change that along the route.
+---
+
+# Wire Layouts
+
+A layout is a snapshot of the cross-section at one point of the route. For each of the rail's wires it says whether the wire exists here, and where it sits. The layout holds from its position until the next layout, and between two layouts the wires glide from one arrangement to the other.
+
+A new rail has a single layout at the start of the route, which is all a rail with a constant cross-section needs. You add layouts where the cross-section changes: where a guide opens into a two-wire habitrail, where a rail narrows to funnel the ball, or where an extra wire begins.
+
+
+A six-wire layout with wire 3 selected. Disabled wires draw gray.
+
+## How many wires
+
+**Rails**, in the *Render Geometry* section, sets how many wires the rail has, from one to six. The count applies to the whole rail. Individual layouts then turn wires on and off. The default arrangements are sized around a 50 unit ball:
+
+| Rails | Arrangement |
+|---|---|
+| 1 | A single wire under the ball |
+| 2 | Two bottom rails, 30 units apart |
+| 3 | Two bottom rails plus one raised rail, on the side chosen with **Third Rail** |
+| 4 | Bottom pair plus a raised pair on both sides: the classic habitrail |
+| 5 | The four-wire rail with a top center wire |
+| 6 | The four-wire rail with two top wires |
+
+Bottom rails sit 15 units either side of center, raised rails 30 units out and 30 up, top rails 60 up. They are starting points, not constraints.
+
+Changing the count keeps offsets you've customized and adds new wires at their default positions, enabled in every layout. A layout that is still exactly at its defaults is re-laid out for the new count.
+
+> [!warning]
+> With three rails, switching **Third Rail** between Left and Right resets that layout to the defaults, including the two bottom wires.
+
+## Positioning wires
+
+The cross-section view is interactive. Click a wire to select it, shift-click to add to the selection, ctrl-click (cmd on macOS) to toggle. Drag a selected wire and every selected wire moves with it. Or type into **X** (sideways, positive to the right) and **Z** (up). When the selected wires have different values, the field shows a dash and only the axis you edit is applied to all of them.
+
+The checkbox before **Position** enables or disables the selected wires for this layout's span. A disabled wire is drawn gray, keeps its position and stays editable. It just isn't generated between this layout and the next.
+
+**Apply to All** copies the selected wires' positions into every other layout, without touching which wires are enabled there. **Reset** puts the whole layout back to the defaults, not only the selection.
+
+> [!tip]
+> Keep wires 1 and 2 as the pair the ball rolls on. The collider fits the ball between those two, and rungs and stands attach to them. A rail where the ball rests on other wires gets its channel fitted around the wrong ones.
+
+## Adding layouts along the route
+
+A layout sits at a **Position** in units along the route, independent of the knots. Inserting a knot doesn't add a layout, and moving a layout doesn't move the route. The first layout is pinned at 0. Every other position can be anything along the route: move a layout past its neighbor and the two simply swap places, keeping their names.
+
+**Add Wire Layout** appends a layout halfway between the last two layouts on the route, or halfway to the end if there is only one. With a layout selected, the button turns into **Duplicate Layout N** and places the copy halfway to the next layout, or halfway to the end of the route for the last one. Either way, the new layout starts as a copy of the one before it, so you only change what differs.
+
+With a layout selected, the Scene view shows an arrow at the start of its span and one at the start of the next. Drag either along the route to move that layout.
+
+Layouts can be dragged in the list to reorder them. That changes their numbering and nothing else. A layout's place on the route is its **Position**.
+
+## Transitions
+
+Below each layout is its **Transition to Layout N** box. By default every wire is *continuous*: it leaves this layout's position and arrives at the next layout's position over the whole span between them, moving at a constant rate. The tube is unbroken, the collider follows, and the bend is smoothed across the boundary so a wire that jumps far sideways doesn't crease.
+
+
+The outside bend can be achieved by moving all wires to the outside, along with a curve that represents the crease.
+
+Wires only get a row when you ask for one. The numbered **Override Wires** buttons expose a wire's controls:
+
+- Clear **Continuous** when the wire should end at the boundary and start fresh at the next layout's position. Both ends get caps.
+- The curve reshapes the move over the span. Horizontal is the span, vertical is how much of the move has happened. The endpoints always count as 0 and 1, so the wire lands exactly on both layouts however you bend the middle.
+
+Turning an override off restores the continuous, linear default. Buttons are disabled for wires that aren't active in both layouts. A wire that is inactive on one side simply ends or begins at the boundary, with a cap.
+
+When wires actually move between two layouts, the box says so (*Blending Wires 3, 4*), which is handy for spotting an accidental nudge.
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/overview-example.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/overview-example.png
new file mode 100644
index 000000000..afbf66184
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/overview-example.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/render-geometry.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/render-geometry.png
new file mode 100644
index 000000000..4f69ea94e
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/render-geometry.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/route.md b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/route.md
new file mode 100644
index 000000000..d065791a0
--- /dev/null
+++ b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/route.md
@@ -0,0 +1,53 @@
+---
+uid: wire_rail_route
+title: The Route
+description: Edit the wire rail spline, grade its height, and read the Scene view preview.
+---
+
+# The Route
+
+The route is a Unity spline on a generated child GameObject called **Wire Rail Spline**. The Wire Rail component owns that child: its transform is what makes spline coordinates come out in VPX units, and the collider is mapped back through it. That transform is locked in the inspector for a reason. When the whole rail needs to move, move the parent.
+
+## Editing knots
+
+Click **Edit Spline** in *Scene View* in the inspector, or the **Edit Wire Rail Spline** button in the Scene view overlay.
+
+
+Knots are circles, the active one is gold. Tangent grips only appear on knots whose tangent mode allows manual control, i.e. if you switch to *Bézier* in the element inspector on the bottom right.
+
+- Drag a knot to move it freely. Click it first when you want the regular position gizmo to lock movement to an axis or a plane.
+- Double-click the route to insert a knot there. Double-click a knot to remove it. An open route keeps at least two knots, a closed one three.
+- Drag the smaller grips to shape Bézier tangents. Knots in *Auto Smooth* mode have no grips. Change the knot's tangent mode in Unity's spline inspector if you need them.
+
+The route lives in three dimensions and the cross-section follows it: wire offsets are measured to the right of the tangent and upward from it. Rotating a knot rolls the cross-section around the route, which is how you bank a rail through a curve. A rail that merely rises needs nothing special. As long as the knots aren't rotated, the cross-section stays upright.
+
+## Draw flat, then grade
+
+Real rails are drawn in plan view and rise at a constant grade, and the workflow follows suit. Draw the whole route from the top, set the heights of the first and last knots, then click **Grade Heights First → Last**. Every knot in between lands on a straight grade, weighted by how far along the horizontal route it is, and the Bézier handles are tilted to match. The rail rises evenly instead of in steps.
+
+Select exactly two knots and the button becomes **Grade Heights Between Selected Knots**. Only that interval is graded, the rest is untouched. With one knot or more than two selected, the button is disabled.
+
+Grading changes only heights. The plan-view shape stays exactly as drawn. That is why knots in *Auto Smooth* mode are switched to *Continuous* (or *Broken* at the ends of a graded interval): Auto Smooth would recompute the handles and move the route sideways.
+
+## Center Pivot
+
+A new rail has its pivot at the first knot. **Center Pivot** moves the GameObject's pivot to the halfway point of the route (by traveled distance, not knot count) and shifts the knots the other way, so nothing moves in the scene. Do this before rotating or mirroring a rail as a whole. A pivot in the middle turns the way you expect.
+
+## Reading the Scene view
+
+Select the Wire Rail (or its spline child) and the Scene view overlays a preview on the generated tubes.
+
+
+
+- Each wire has its own color, always in the same order. Wire 1 is cyan and wire 2 orange on every rail of the table. Wire 6 reuses wire 1's color.
+- Every layout is labeled at its start with its number and how many of the available wires are active there, for example *Layout 2: 3/4 rails*. Selecting a layout in the inspector highlights its span in orange and marks the label with an arrow.
+- Fixtures draw their centerline in amber, with their name.
+- **Show Collider Preview**, in the Ball Channel Collider section, draws the generated channel in translucent green.
+
+Line widths in the preview are for legibility. The tubes underneath show the real wire thickness, as do the wire circles in the inspector cross-section.
+
+## Closed routes
+
+A spline can be closed, and the wires and collider close with it. A closed route has no ends, so anything that attaches to an end is unavailable: hairpins, elbows and rail trims. The transition from the last layout back into the first is shown on the last layout's panel.
+
+Only the first spline in the container is used. If you've added more, the inspector warns you. Remove them.
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/scene-editing.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/scene-editing.png
new file mode 100644
index 000000000..044085c9b
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/scene-editing.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/scene-preview.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/scene-preview.png
new file mode 100644
index 000000000..b329313e6
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/scene-preview.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/toolbox.png b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/toolbox.png
new file mode 100644
index 000000000..38143c836
Binary files /dev/null and b/VisualPinball.Unity/Documentation~/creators-guide/editor/wire-rails/toolbox.png differ
diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml
index 7a84af853..a8459402d 100644
--- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml
+++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml
@@ -18,6 +18,17 @@
href: editor/units-3d-space.md
- name: Unity Components
href: editor/unity-components.md
+ - name: Wire Rails
+ href: editor/wire-rails/index.md
+ items:
+ - name: The Route
+ href: editor/wire-rails/route.md
+ - name: Wire Layouts
+ href: editor/wire-rails/layouts.md
+ - name: Fixtures
+ href: editor/wire-rails/fixtures.md
+ - name: Generated Geometry
+ href: editor/wire-rails/geometry.md
- name: Materials
href: editor/materials.md
- name: Asset Library Style Guide
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs
index c23d73ac6..085a6d5d2 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs
@@ -738,6 +738,10 @@ private void WritePackables(string folderName, Func getPackab
case SkinnedMeshRenderer:
break;
+ // carried by the owning component's packable (wire rail route)
+ case UnityEngine.Splines.SplineContainer:
+ break;
+
default:
getNativeData?.Invoke(component);
break;
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs
index 9cff0a622..e51904de3 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Toolbox/ToolboxEditor.cs
@@ -75,7 +75,7 @@ private void OnGUI()
if (GUILayout.Button("New Table")) {
var tableContainer = new FileTableContainer();
var converter = new VpxSceneConverter(tableContainer);
- var rootGameObj = converter.Convert();
+ var rootGameObj = converter.Convert();
Selection.activeGameObject = rootGameObj;
Undo.RegisterCreatedObjectUndo(rootGameObj, "New Table");
}
@@ -104,94 +104,98 @@ private void OnGUI()
CreateItem(Ramp.GetDefault, "New Ramp");
}
- if (CreateButton("Flipper", Icons.Flipper(color: iconColor), iconSize, buttonStyle)) {
- CreateItem(Flipper.GetDefault, "New Flipper");
+ if (CreateButton("Wire Rail", Icons.RampWires(color: iconColor), iconSize, buttonStyle)) {
+ CreateWireRail();
}
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
+ if (CreateButton("Flipper", Icons.Flipper(color: iconColor), iconSize, buttonStyle)) {
+ CreateItem(Flipper.GetDefault, "New Flipper");
+ }
+
if (CreateButton("Plunger", Icons.Plunger(color: iconColor), iconSize, buttonStyle)) {
CreateItem(Plunger.GetDefault, "New Plunger");
}
+ GUILayout.EndHorizontal();
+ GUILayout.BeginHorizontal();
+
if (CreateButton("Bumper", Icons.Bumper(color: iconColor), iconSize, buttonStyle)) {
CreateItem(Bumper.GetDefault, "New Bumper");
}
- GUILayout.EndHorizontal();
- GUILayout.BeginHorizontal();
-
if (CreateButton("Spinner", Icons.Spinner(color: iconColor), iconSize, buttonStyle)) {
CreateItem(Spinner.GetDefault, "New Spinner");
}
+ GUILayout.EndHorizontal();
+ GUILayout.BeginHorizontal();
+
if (CreateButton("Trigger", Icons.Trigger(color: iconColor), iconSize, buttonStyle)) {
CreateItem(Trigger.GetDefault, "New Trigger");
}
- GUILayout.EndHorizontal();
- GUILayout.BeginHorizontal();
-
if (CreateButton("Kicker", Icons.Kicker(color: iconColor), iconSize, buttonStyle)) {
CreateItem(Kicker.GetDefault, "New Kicker");
}
+ GUILayout.EndHorizontal();
+ GUILayout.BeginHorizontal();
+
if (CreateButton("Light", Icons.Light(color: iconColor), iconSize, buttonStyle)) {
CreateItem(Light.GetDefault, "New Light");
}
- GUILayout.EndHorizontal();
- GUILayout.BeginHorizontal();
-
if (CreateButton("Hit Target", Icons.HitTarget(color: iconColor), iconSize, buttonStyle)) {
CreateItem(HitTarget.GetHitTarget, "New Hit Target");
}
+ GUILayout.EndHorizontal();
+ GUILayout.BeginHorizontal();
+
if (CreateButton("Drop Target", Icons.DropTarget(color: iconColor), iconSize, buttonStyle)) {
CreateItem(HitTarget.GetDropTarget, "New Target");
}
- GUILayout.EndHorizontal();
- GUILayout.BeginHorizontal();
-
if (CreateButton("Rubber", Icons.Rubber(color: iconColor), iconSize, buttonStyle)) {
CreateItem(Rubber.GetDefault, "New Rubber");
}
+ GUILayout.EndHorizontal();
+ GUILayout.BeginHorizontal();
+
if (CreateButton("Primitive", Icons.Primitive(color: iconColor), iconSize, buttonStyle)) {
CreateItem(Primitive.GetDefault, "New Primitive");
}
- GUILayout.EndHorizontal();
- GUILayout.BeginHorizontal();
-
if (CreateButton("Trough", Icons.Trough(color: iconColor), iconSize, buttonStyle)) {
CreateItem(Trough.GetDefault, "New Trough");
}
+ GUILayout.EndHorizontal();
+ GUILayout.BeginHorizontal();
+
if (CreateButton("Drop Target\nBank", Icons.DropTargetBank(color: iconColor), iconSize, buttonStyle))
{
CreatePrefab("Drop Target Banks", "Prefabs/DropTargetBank");
}
+ if (CreateButton("Slingshot", Icons.Slingshot(color: iconColor), iconSize, buttonStyle)) {
+ CreatePrefab("Slingshots", "Prefabs/Slingshot");
+ }
+
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
- if (CreateButton("Slingshot", Icons.Slingshot(color: iconColor), iconSize, buttonStyle)) {
- CreatePrefab("Slingshots", "Prefabs/Slingshot");
- }
-
- if (CreateButton("Magnet", Icons.Magnet(color: iconColor), iconSize, buttonStyle)) {
- CreatePrefab("Magnets", "Prefabs/Magnet");
- }
-
- GUILayout.EndHorizontal();
- GUILayout.BeginHorizontal();
-
- if (CreateButton("Metal Wire\nGuide", Icons.MetalWireGuide(color: iconColor), iconSize, buttonStyle)) {
- CreateItem(MetalWireGuide.GetDefault, "New MetalWireGuide", centerSplineOrigin: true);
- }
+ if (CreateButton("Magnet", Icons.Magnet(color: iconColor), iconSize, buttonStyle)) {
+ CreatePrefab("Magnets", "Prefabs/Magnet");
+ }
+
+ if (CreateButton("Metal Wire\nGuide", Icons.MetalWireGuide(color: iconColor), iconSize, buttonStyle)) {
+ CreateItem(MetalWireGuide.GetDefault, "New MetalWireGuide", centerSplineOrigin: true);
+ }
GUILayout.EndHorizontal();
@@ -236,6 +240,19 @@ private void CreateItem(Func create, string actionName,
Undo.RegisterCreatedObjectUndo(Selection.activeGameObject, actionName);
}
+ private void CreateWireRail()
+ {
+ var playfield = TableComponent.GetComponentInChildren();
+ var parent = playfield ? playfield.gameObject : TableComponent.gameObject;
+ var gameObject = new GameObject(
+ GameObjectUtility.GetUniqueNameForSibling(parent.transform, "Wire Rail"));
+ GameObjectUtility.SetParentAndAlign(gameObject, parent);
+ gameObject.AddComponent();
+ Undo.RegisterCreatedObjectUndo(gameObject, "New Wire Rail");
+ Selection.activeGameObject = gameObject;
+ ItemCreated?.Invoke(Selection.activeGameObject);
+ }
+
private GameObject CreateRenderable(IItem item)
{
var converter = new VpxSceneConverter(TableComponent);
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs
index 4daf9a470..7f948fceb 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/Icons.cs
@@ -77,14 +77,15 @@ public IconVariant(string name, IconSize size, IconColor color)
private const string GateBracketName = "gate_bracket";
private const string GateLifterName = "gate_lifter";
private const string HitTargetName = "hit_target";
+ private const string HorizonName = "horizon";
private const string KeyName = "keyboard";
private const string KickerName = "kicker";
- private const string LightGroupName = "light_group";
- private const string LightName = "light";
- private const string LoopButtonName = "player";
- private const string MagnetName = "magnet";
- private const string MechName = "mech";
- private const string MechPinMameName = "mech_pinmame";
+ private const string LightGroupName = "light_group";
+ private const string LightName = "light";
+ private const string LoopButtonName = "player";
+ private const string MagnetName = "magnet";
+ private const string MechName = "mech";
+ private const string MechPinMameName = "mech_pinmame";
private const string PlayfieldName = "playfield";
private const string PlayButtonName = "player";
private const string PlugName = "plug";
@@ -92,6 +93,7 @@ public IconVariant(string name, IconSize size, IconColor color)
private const string PrimitiveName = "primitive";
private const string PhysicsName = "physics";
private const string RampName = "ramp";
+ private const string RampWiresName = "ramp_wires";
private const string RotatorName = "rotator";
private const string RubberName = "rubber";
private const string ScoreReelName = "score_reel";
@@ -121,11 +123,18 @@ public IconVariant(string name, IconSize size, IconColor color)
private const string TableVariableEventName = "table_variable_event";
private const string UpdateDisplayName = "update_display";
private const string DisplayEventName = "display_event";
+ private const string WireRailRingName = "wire_rail_ring";
+ private const string WireRailCradleName = "wire_rail_cradle";
+ private const string WireRailRungName = "wire_rail_rung";
+ private const string WireRailStandName = "wire_rail_stand";
+ private const string WireRailHairpinName = "wire_rail_hairpin";
+ private const string WireRailElbowName = "wire_rail_elbow";
+ private const string WireRailTrimName = "wire_rail_trim";
private static readonly string[] Names = {
AssetLibraryName, BallRollerName, BallName, BoltName, BumperName, CalendarName, CannonName, CoilName, DropTargetBankName, DropTargetName, FlasherName,
- FlipperName, GateName, GateLifterName, HitTargetName, KeyName, KickerName, LightGroupName, LightName, LoopButtonName, MagnetName, MechName, MechPinMameName, PlayfieldName, PlayButtonName, PlugName,
- PhysicsName, PlungerName, PrimitiveName, RampName, RotatorName, RubberName, ScoreReelName, ScoreReelSingleName, SlingshotName, SpinnerName, StopButtonName, SurfaceName,
+ FlipperName, GateName, GateLifterName, HitTargetName, HorizonName, KeyName, KickerName, LightGroupName, LightName, LoopButtonName, MagnetName, MechName, MechPinMameName, PlayfieldName, PlayButtonName, PlugName,
+ PhysicsName, PlungerName, PrimitiveName, RampName, RampWiresName, RotatorName, RubberName, ScoreReelName, ScoreReelSingleName, SlingshotName, SpinnerName, StopButtonName, SurfaceName,
SwitchNcName, SwitchNoName, TableName, TeleporterName, TriggerName, TroughName,
CoilEventName, SwitchEventName, LampEventName, LampSeqName, MetalWireGuideName,
PlayerVariableName, PlayerVariableEventName, TableVariableName, TableVariableEventName, UpdateDisplayName, DisplayEventName,
@@ -133,6 +142,16 @@ public IconVariant(string name, IconSize size, IconColor color)
};
private readonly Dictionary _icons = new Dictionary();
+
+ ///
+ /// Illustrative icons that have no size/color variants, stored in the "other" folder.
+ ///
+ private static readonly string[] OtherNames = {
+ WireRailRingName, WireRailCradleName, WireRailRungName, WireRailStandName,
+ WireRailHairpinName, WireRailElbowName, WireRailTrimName
+ };
+
+ private readonly Dictionary _otherIcons = new Dictionary();
private static readonly MethodInfo CopyMonoScriptIconToImporters = typeof(MonoImporter).GetMethod("CopyMonoScriptIconToImporters", BindingFlags.Static | BindingFlags.NonPublic);
private static readonly MethodInfo SetIconForObject = typeof(EditorGUIUtility).GetMethod("SetIconForObject", BindingFlags.Static | BindingFlags.NonPublic);
private static readonly MethodInfo SetGizmoEnabled = Assembly.GetAssembly(typeof(UnityEditor.Editor))?.GetType("UnityEditor.AnnotationUtility")?.GetMethod("SetGizmoEnabled", BindingFlags.Static | BindingFlags.NonPublic);
@@ -160,6 +179,12 @@ private Icons()
}
}
}
+ foreach (var name in OtherNames) {
+ var path = $"{iconPath}/other/{name}.png";
+ if (File.Exists(path)) {
+ _otherIcons[name] = AssetDatabase.LoadAssetAtPath(path);
+ }
+ }
}
private static IIconLookup[] GetLookups() {
@@ -188,14 +213,15 @@ private static IIconLookup[] GetLookups() {
public static Texture2D GateBracket(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(GateBracketName, size, color);
public static Texture2D GateLifter(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(GateLifterName, size, color);
public static Texture2D HitTarget(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(HitTargetName, size, color);
+ public static Texture2D Horizon(IconSize size = IconSize.Small, IconColor color = IconColor.Gray) => Instance.GetItem(HorizonName, size, color);
public static Texture2D Key(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(KeyName, size, color);
public static Texture2D Kicker(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(KickerName, size, color);
public static Texture2D Light(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LightName, size, color);
public static Texture2D LoopButton(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LoopButtonName, size, color);
- public static Texture2D LightGroup(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LightGroupName, size, color);
- public static Texture2D Locked(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LockedName, size, color);
- public static Texture2D Magnet(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MagnetName, size, color);
- public static Texture2D Mech(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MechName, size, color);
+ public static Texture2D LightGroup(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LightGroupName, size, color);
+ public static Texture2D Locked(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(LockedName, size, color);
+ public static Texture2D Magnet(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MagnetName, size, color);
+ public static Texture2D Mech(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MechName, size, color);
public static Texture2D MechPinMame(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MechPinMameName, size, color);
public static Texture2D MetalWireGuide(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(MetalWireGuideName, size, color);
public static Texture2D Physics(IconSize size = IconSize.Small, IconColor color = IconColor.Gray) => Instance.GetItem(PhysicsName, size, color);
@@ -205,6 +231,7 @@ private static IIconLookup[] GetLookups() {
public static Texture2D Plunger(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(PlungerName, size, color);
public static Texture2D Primitive(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(PrimitiveName, size, color);
public static Texture2D Ramp(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(RampName, size, color);
+ public static Texture2D RampWires(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(RampWiresName, size, color);
public static Texture2D Rotator(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(RotatorName, size, color);
public static Texture2D Rubber(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(RubberName, size, color);
public static Texture2D ScoreReel(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(ScoreReelName, size, color);
@@ -220,6 +247,14 @@ private static IIconLookup[] GetLookups() {
public static Texture2D Trough(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(TroughName, size, color);
public static Texture2D Unlocked(IconSize size = IconSize.Large, IconColor color = IconColor.Gray) => Instance.GetItem(UnlockedName, size, color);
+ public static Texture2D WireRailRing => Instance.GetOther(WireRailRingName);
+ public static Texture2D WireRailCradle => Instance.GetOther(WireRailCradleName);
+ public static Texture2D WireRailRung => Instance.GetOther(WireRailRungName);
+ public static Texture2D WireRailStand => Instance.GetOther(WireRailStandName);
+ public static Texture2D WireRailHairpin => Instance.GetOther(WireRailHairpinName);
+ public static Texture2D WireRailElbow => Instance.GetOther(WireRailElbowName);
+ public static Texture2D WireRailTrim => Instance.GetOther(WireRailTrimName);
+
public static Texture2D CoilEvent => Instance.GetItem(CoilEventName, IconSize.Large, IconColor.Colored);
public static Texture2D SwitchEvent => Instance.GetItem(SwitchEventName, IconSize.Large, IconColor.Colored);
@@ -262,6 +297,9 @@ public static void ApplyToComponent(Object target, Texture2D tex) where T : M
}
}
+ private Texture2D GetOther(string name)
+ => _otherIcons.TryGetValue(name, out var texture) ? texture : null;
+
private Texture2D GetItem(string name, IconSize size, IconColor color)
{
var variant = new IconVariant(name, size, color);
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs
index 9ba7dc45b..f13f2cde7 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs
@@ -39,6 +39,7 @@ public class MagnetInspector : ItemInspector
private SerializedProperty _isEnabledOnStartProperty;
private SerializedProperty _isKinematicProperty;
private SerializedProperty _drawDebugForcesProperty;
+ private SerializedProperty _hitThresholdProperty;
protected override MonoBehaviour UndoTarget => target as MonoBehaviour;
@@ -63,6 +64,7 @@ protected override void OnEnable()
_isEnabledOnStartProperty = serializedObject.FindProperty(nameof(MagnetComponent.IsEnabledOnStart));
_isKinematicProperty = serializedObject.FindProperty(nameof(MagnetComponent.IsKinematic));
_drawDebugForcesProperty = serializedObject.FindProperty(nameof(MagnetComponent.DrawDebugForces));
+ _hitThresholdProperty = serializedObject.FindProperty(nameof(MagnetComponent.HitThreshold));
}
public override void OnInspectorGUI()
@@ -88,12 +90,21 @@ public override void OnInspectorGUI()
PropertyField(_cylinderHeightProperty);
using (new EditorGUI.DisabledScope(Application.isPlaying)) {
PropertyField(_generateCylinderColliderProperty, updateColliders: true);
+ if (_generateCylinderColliderProperty.boolValue) {
+ PropertyField(_hitThresholdProperty, "Hit Threshold");
+ }
+ }
+ if (!_generateCylinderColliderProperty.boolValue) {
+ EditorGUILayout.HelpBox("Enable Generate Cylinder Collider to emit Hit switch pulses.", MessageType.Info);
}
DrawColliderFit();
DrawOverlappingColliderWarning();
} else if (!isSpatial) {
PropertyField(_heightRangeProperty);
}
+ if (!isCylindrical) {
+ EditorGUILayout.HelpBox("The Hit switch requires a Cylindrical magnet with Generate Cylinder Collider enabled.", MessageType.Info);
+ }
PropertyField(_strengthProperty);
if (isCylindrical) {
PropertyField(_cylindricalDampingProperty, "Damping");
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail.meta
new file mode 100644
index 000000000..ad860e8e9
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 189ac226441842b19b7abd6ef65201d0
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailCrossSectionEditor.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailCrossSectionEditor.cs
new file mode 100644
index 000000000..9e39b63e4
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailCrossSectionEditor.cs
@@ -0,0 +1,1261 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System.Collections.Generic;
+using Unity.Mathematics;
+using UnityEditor;
+using UnityEngine;
+
+namespace VisualPinball.Unity.Editor
+{
+ internal sealed class WireRailCrossSectionEditor
+ {
+ private const int ControlHint = 0x57495245;
+ private const float CanvasHeight = 170f;
+ private const float LineHeight = 20f;
+ public const float Height = CanvasHeight + LineHeight * 2f + 8f;
+ private const float GridStep = 10f;
+ private static readonly Color CanvasColor = new(0.105f, 0.115f, 0.13f, 1f);
+ private static readonly Color GridColor = new(1f, 1f, 1f, 0.07f);
+ private static readonly Color AxisColor = new(1f, 1f, 1f, 0.28f);
+ private static readonly Color SelectedColor = new(1f, 0.78f, 0.08f, 1f);
+ private static readonly Color InactiveWireColor = new(0.43f, 0.45f, 0.48f, 1f);
+ private static readonly Color[] WireColors = {
+ new(0.05f, 0.75f, 1f, 1f),
+ new(1f, 0.55f, 0.05f, 1f),
+ new(0.45f, 1f, 0.2f, 1f),
+ new(1f, 0.2f, 0.65f, 1f),
+ new(0.65f, 0.35f, 1f, 1f),
+ };
+
+ private readonly Dictionary> _selectedWires = new();
+ private readonly List _dragIndices = new();
+ private readonly List _dragOffsets = new();
+ private Vector2 _dragStartMouse;
+ private int _dragSegmentIndex = -1;
+ private int _dragUndoGroup = -1;
+ private static Texture2D _circleTexture;
+ private static GUIStyle _wireLabelStyle;
+
+ public void Draw(Rect rect, WireRailComponent component, int segmentIndex)
+ {
+ var segment = component.Segments[segmentIndex];
+ var selected = GetSelection(segmentIndex, segment.RailCount);
+ var canvasRect = new Rect(rect.x, rect.y, rect.width, CanvasHeight);
+ var view = CrossSectionView.Create(canvasRect, segment);
+ var controlId = GUIUtility.GetControlID(ControlHint + segmentIndex * 397,
+ FocusType.Passive, canvasRect);
+ HandleInput(component, segmentIndex, segment, selected, view, controlId);
+ DrawCanvas(segment, selected, view);
+ GUI.Label(canvasRect, new GUIContent(string.Empty,
+ "Click to select · Shift/Ctrl/Cmd-click for multiple · Drag to move"));
+ DrawSelectedWireControls(new Rect(rect.x, canvasRect.yMax + 4f, rect.width,
+ LineHeight * 2f + 4f), component, segmentIndex, segment, selected);
+ }
+
+ private HashSet GetSelection(int segmentIndex, int railCount)
+ {
+ if (!_selectedWires.TryGetValue(segmentIndex, out var selected)) {
+ selected = new HashSet();
+ _selectedWires.Add(segmentIndex, selected);
+ }
+ selected.RemoveWhere(index => index < 0 || index >= railCount);
+ return selected;
+ }
+
+ private void HandleInput(WireRailComponent component, int segmentIndex,
+ WireRailSegment segment, HashSet selected, CrossSectionView view,
+ int controlId)
+ {
+ var evt = Event.current;
+ switch (evt.GetTypeForControl(controlId)) {
+ case EventType.MouseDown when evt.button == 0 && view.Rect.Contains(evt.mousePosition):
+ var hit = PickWire(segment, view, evt.mousePosition);
+ if (hit < 0) {
+ if (!HasSelectionModifier(evt)) {
+ selected.Clear();
+ }
+ evt.Use();
+ GUI.changed = true;
+ return;
+ }
+
+ UpdateSelection(selected, hit, evt);
+ if (!selected.Contains(hit)) {
+ evt.Use();
+ GUI.changed = true;
+ return;
+ }
+ GUIUtility.hotControl = controlId;
+ GUIUtility.keyboardControl = controlId;
+ _dragStartMouse = evt.mousePosition;
+ _dragSegmentIndex = segmentIndex;
+ _dragUndoGroup = -1;
+ CaptureDragValues(segment, selected);
+ evt.Use();
+ GUI.changed = true;
+ break;
+
+ case EventType.MouseDrag when GUIUtility.hotControl == controlId
+ && _dragSegmentIndex == segmentIndex:
+ if (_dragUndoGroup < 0) {
+ _dragUndoGroup = BeginDragUndo(component);
+ }
+ var delta = view.ToVpxDelta(evt.mousePosition - _dragStartMouse);
+ var movedOffsets = new Vector2[_dragOffsets.Count];
+ for (var i = 0; i < movedOffsets.Length; i++) {
+ movedOffsets[i] = _dragOffsets[i] + delta;
+ }
+ component.SetWireProperties(segmentIndex, _dragIndices,
+ movedOffsets);
+ Apply(component);
+ evt.Use();
+ GUI.changed = true;
+ break;
+
+ case EventType.MouseUp when GUIUtility.hotControl == controlId:
+ EndDragUndo(_dragUndoGroup);
+ GUIUtility.hotControl = 0;
+ _dragSegmentIndex = -1;
+ _dragUndoGroup = -1;
+ evt.Use();
+ GUI.changed = true;
+ break;
+ }
+ }
+
+ private static int BeginDragUndo(WireRailComponent component)
+ {
+ Undo.IncrementCurrentGroup();
+ var undoGroup = Undo.GetCurrentGroup();
+ Undo.SetCurrentGroupName("Move Wire Rail Wires");
+ Undo.RegisterCompleteObjectUndo(component, "Move Wire Rail Wires");
+ return undoGroup;
+ }
+
+ private static void EndDragUndo(int undoGroup)
+ {
+ if (undoGroup >= 0) {
+ Undo.CollapseUndoOperations(undoGroup);
+ }
+ }
+
+ private static void UpdateSelection(HashSet selected, int hit, Event evt)
+ {
+ if (evt.control || evt.command) {
+ if (!selected.Add(hit)) {
+ selected.Remove(hit);
+ }
+ return;
+ }
+ if (evt.shift) {
+ selected.Add(hit);
+ return;
+ }
+ if (selected.Count == 1 && selected.Contains(hit)) {
+ return;
+ }
+ if (!selected.Contains(hit)) {
+ selected.Clear();
+ selected.Add(hit);
+ }
+ }
+
+ private static bool HasSelectionModifier(Event evt)
+ => evt.shift || evt.control || evt.command;
+
+ private void CaptureDragValues(WireRailSegment segment, HashSet selected)
+ {
+ _dragIndices.Clear();
+ _dragOffsets.Clear();
+ foreach (var railIndex in selected) {
+ _dragIndices.Add(railIndex);
+ }
+ _dragIndices.Sort();
+ foreach (var railIndex in _dragIndices) {
+ _dragOffsets.Add(segment.GetRailOffset(railIndex));
+ }
+ }
+
+ private static int PickWire(WireRailSegment segment, CrossSectionView view,
+ Vector2 mousePosition)
+ {
+ var hit = -1;
+ var closest = float.MaxValue;
+ for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) {
+ var center = view.ToScreen(segment.GetRailOffset(railIndex));
+ var radius = math.max(8f,
+ segment.GetWireDiameter(railIndex) * 0.5f * view.Scale + 3f);
+ var distance = Vector2.Distance(mousePosition, center);
+ if (distance <= radius && distance < closest) {
+ closest = distance;
+ hit = railIndex;
+ }
+ }
+ return hit;
+ }
+
+ private static void DrawCanvas(WireRailSegment segment, HashSet selected,
+ CrossSectionView view)
+ {
+ EditorGUI.DrawRect(view.Rect, CanvasColor);
+ DrawGrid(view);
+ for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) {
+ DrawWire(segment, railIndex, selected.Contains(railIndex), view);
+ }
+ GUI.Label(new Rect(view.Rect.x + 6f, view.Rect.y + 4f, 30f, 18f), "Z ↑",
+ EditorStyles.miniLabel);
+ GUI.Label(new Rect(view.Rect.xMax - 34f, view.Rect.yMax - 20f, 30f, 18f),
+ "X →", EditorStyles.miniLabel);
+ }
+
+ private static void DrawGrid(CrossSectionView view)
+ {
+ for (var x = math.ceil(view.Min.x / GridStep) * GridStep;
+ x <= view.Max.x; x += GridStep) {
+ var screen = view.ToScreen(new Vector2(x, 0f));
+ EditorGUI.DrawRect(new Rect(screen.x, view.Rect.y, 1f, view.Rect.height),
+ math.abs(x) < 0.01f ? AxisColor : GridColor);
+ }
+ for (var z = math.ceil(view.Min.y / GridStep) * GridStep;
+ z <= view.Max.y; z += GridStep) {
+ var screen = view.ToScreen(new Vector2(0f, z));
+ EditorGUI.DrawRect(new Rect(view.Rect.x, screen.y, view.Rect.width, 1f),
+ math.abs(z) < 0.01f ? AxisColor : GridColor);
+ }
+ }
+
+ private static void DrawWire(WireRailSegment segment, int railIndex, bool selected,
+ CrossSectionView view)
+ {
+ var offset = segment.GetRailOffset(railIndex);
+ var diameter = segment.GetWireDiameter(railIndex);
+ var center = view.ToScreen(offset);
+ var radius = math.max(6f, diameter * 0.5f * view.Scale);
+ DrawCircle(center, radius + (selected ? 4f : 2f),
+ selected ? SelectedColor : new Color(0f, 0f, 0f, 0.75f));
+ DrawCircle(center, radius, segment.IsRailActive(railIndex)
+ ? WireColors[railIndex % WireColors.Length] : InactiveWireColor);
+ var labelRect = new Rect(center.x - radius, center.y - 9f, radius * 2f, 18f);
+ GUI.Label(labelRect, (railIndex + 1).ToString(), WireLabelStyle);
+ GUI.Label(new Rect(center.x - radius - 4f, center.y - radius - 4f,
+ radius * 2f + 8f, radius * 2f + 8f),
+ new GUIContent(string.Empty, $"Wire {railIndex + 1}: X {offset.x:0.##}, "
+ + $"Z {offset.y:0.##}, diameter {diameter:0.##}, "
+ + (segment.IsRailActive(railIndex) ? "active" : "inactive")));
+ }
+
+ private static void DrawCircle(Vector2 center, float radius, Color color)
+ {
+ var previous = GUI.color;
+ GUI.color = color;
+ GUI.DrawTexture(new Rect(center.x - radius, center.y - radius,
+ radius * 2f, radius * 2f), CircleTexture, ScaleMode.StretchToFill, true);
+ GUI.color = previous;
+ }
+
+ private static void DrawSelectedWireControls(Rect rect, WireRailComponent component,
+ int segmentIndex, WireRailSegment segment, HashSet selected)
+ {
+ var selectionRect = new Rect(rect.x, rect.y, rect.width, LineHeight);
+ var noneRect = new Rect(selectionRect.xMax - 44f, selectionRect.y, 44f,
+ LineHeight);
+ var allRect = new Rect(noneRect.x - 38f, selectionRect.y, 38f, LineHeight);
+ var labelRect = new Rect(selectionRect.x, selectionRect.y,
+ math.max(0f, allRect.x - selectionRect.x - 4f), LineHeight);
+ EditorGUI.LabelField(labelRect, selected.Count == 0 ? "No wires selected"
+ : selected.Count == 1 ? "1 wire selected" : $"{selected.Count} wires selected",
+ EditorStyles.boldLabel);
+ if (GUI.Button(allRect, "All", EditorStyles.miniButtonLeft)) {
+ for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) {
+ selected.Add(railIndex);
+ }
+ }
+ if (GUI.Button(noneRect, "None", EditorStyles.miniButtonRight)) {
+ selected.Clear();
+ }
+
+ using (new EditorGUI.DisabledScope(selected.Count == 0)) {
+ var indices = new List(selected);
+ indices.Sort();
+ var firstIndex = indices.Count > 0 ? indices[0] : 0;
+ var firstOffset = indices.Count > 0
+ ? segment.GetRailOffset(firstIndex) : Vector2.zero;
+ var mixedX = false;
+ var mixedZ = false;
+ var firstActive = indices.Count > 0 && segment.IsRailActive(firstIndex);
+ var mixedActive = false;
+ for (var i = 1; i < indices.Count; i++) {
+ var offset = segment.GetRailOffset(indices[i]);
+ mixedX |= !Mathf.Approximately(offset.x, firstOffset.x);
+ mixedZ |= !Mathf.Approximately(offset.y, firstOffset.y);
+ mixedActive |= segment.IsRailActive(indices[i]) != firstActive;
+ }
+
+ var controlsRect = new Rect(rect.x, selectionRect.yMax + 4f, rect.width,
+ LineHeight);
+ const float resetWidth = 54f;
+ const float applyToAllWidth = 78f;
+ const float activeWidth = 18f;
+ const float positionLabelWidth = 50f;
+ const float axisLabelWidth = 14f;
+ const float spacing = 4f;
+ var numericFieldWidth = math.max(20f, (controlsRect.width - activeWidth
+ - positionLabelWidth - axisLabelWidth * 2f - resetWidth - applyToAllWidth
+ - spacing * 7f)
+ * 0.5f);
+ var axisFieldWidth = axisLabelWidth + numericFieldWidth;
+ var x = controlsRect.x;
+ EditorGUI.BeginChangeCheck();
+ EditorGUI.showMixedValue = mixedActive;
+ var active = EditorGUI.Toggle(new Rect(x, controlsRect.y, activeWidth,
+ LineHeight), new GUIContent(string.Empty,
+ "Enable the selected wires for this layout span."), firstActive);
+ var activeChanged = EditorGUI.EndChangeCheck();
+ EditorGUI.showMixedValue = false;
+ x += activeWidth + spacing;
+ EditorGUI.LabelField(new Rect(x, controlsRect.y, positionLabelWidth,
+ LineHeight), "Position");
+ x += positionLabelWidth + spacing;
+ var previousLabelWidth = EditorGUIUtility.labelWidth;
+ EditorGUIUtility.labelWidth = axisLabelWidth;
+ EditorGUI.BeginChangeCheck();
+ EditorGUI.showMixedValue = mixedX;
+ var editedX = EditorGUI.FloatField(new Rect(x, controlsRect.y, axisFieldWidth,
+ LineHeight), new GUIContent("X",
+ "Lateral centerline position in VPX units."), firstOffset.x);
+ var xChanged = EditorGUI.EndChangeCheck();
+ x += axisFieldWidth + spacing;
+ EditorGUI.BeginChangeCheck();
+ EditorGUI.showMixedValue = mixedZ;
+ var editedZ = EditorGUI.FloatField(new Rect(x, controlsRect.y, axisFieldWidth,
+ LineHeight), new GUIContent("Z",
+ "Vertical centerline position in VPX units."), firstOffset.y);
+ var zChanged = EditorGUI.EndChangeCheck();
+ EditorGUI.showMixedValue = false;
+ EditorGUIUtility.labelWidth = previousLabelWidth;
+
+ if (indices.Count > 0 && activeChanged) {
+ Undo.RegisterCompleteObjectUndo(component, "Toggle Wire Rail Wires");
+ component.SetRailsActive(segmentIndex, indices, active);
+ Apply(component);
+ }
+
+ if (indices.Count > 0 && (xChanged || zChanged)) {
+ var offsets = new Vector2[indices.Count];
+ for (var i = 0; i < indices.Count; i++) {
+ var currentOffset = segment.GetRailOffset(indices[i]);
+ offsets[i] = new Vector2(xChanged ? editedX : currentOffset.x,
+ zChanged ? editedZ : currentOffset.y);
+ }
+ Undo.RegisterCompleteObjectUndo(component, "Edit Wire Rail Wires");
+ component.SetWireProperties(segmentIndex, indices, offsets);
+ Apply(component);
+ }
+ }
+ const float buttonSpacing = 4f;
+ const float resetButtonWidth = 54f;
+ const float applyToAllButtonWidth = 78f;
+ var applyToAllRect = new Rect(rect.xMax - applyToAllButtonWidth,
+ selectionRect.yMax + 4f, applyToAllButtonWidth, LineHeight);
+ var resetRect = new Rect(applyToAllRect.x - buttonSpacing - resetButtonWidth,
+ applyToAllRect.y, resetButtonWidth, LineHeight);
+ using (new EditorGUI.DisabledScope(selected.Count == 0)) {
+ if (GUI.Button(resetRect, "Reset")) {
+ Undo.RegisterCompleteObjectUndo(component, "Reset Wire Rail Layout");
+ component.ResetSegmentLayout(segmentIndex);
+ Apply(component);
+ }
+ if (GUI.Button(applyToAllRect, new GUIContent("Apply to All",
+ "Copy the selected wires' X and Z positions to every layout."))) {
+ var indices = new List(selected);
+ indices.Sort();
+ Undo.RegisterCompleteObjectUndo(component,
+ "Apply Wire Positions to All Layouts");
+ component.ApplyWirePositionsToAllLayouts(segmentIndex, indices);
+ Apply(component);
+ }
+ }
+ }
+
+ private static void Apply(WireRailComponent component)
+ {
+ EditorUtility.SetDirty(component);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(component);
+ SceneView.RepaintAll();
+ }
+
+ private static Texture2D CircleTexture {
+ get {
+ if (_circleTexture) {
+ return _circleTexture;
+ }
+ const int size = 64;
+ _circleTexture = new Texture2D(size, size, TextureFormat.RGBA32, false) {
+ name = "Wire Rail Cross-Section Circle",
+ hideFlags = HideFlags.HideAndDontSave,
+ filterMode = FilterMode.Bilinear,
+ wrapMode = TextureWrapMode.Clamp,
+ };
+ var pixels = new Color32[size * size];
+ for (var y = 0; y < size; y++) {
+ for (var x = 0; x < size; x++) {
+ var normalized = new float2((x + 0.5f) / size * 2f - 1f,
+ (y + 0.5f) / size * 2f - 1f);
+ var alpha = (byte)math.round(math.saturate((1f - math.length(normalized))
+ * size) * 255f);
+ pixels[y * size + x] = new Color32(255, 255, 255, alpha);
+ }
+ }
+ _circleTexture.SetPixels32(pixels);
+ _circleTexture.Apply(false, true);
+ return _circleTexture;
+ }
+ }
+
+ private static GUIStyle WireLabelStyle {
+ get {
+ if (_wireLabelStyle != null) {
+ return _wireLabelStyle;
+ }
+ _wireLabelStyle = new GUIStyle(EditorStyles.boldLabel) {
+ alignment = TextAnchor.MiddleCenter,
+ fontSize = 10,
+ normal = { textColor = Color.white },
+ };
+ return _wireLabelStyle;
+ }
+ }
+
+ private readonly struct CrossSectionView
+ {
+ public readonly Rect Rect;
+ public readonly Vector2 Min;
+ public readonly Vector2 Max;
+ public readonly float Scale;
+
+ private CrossSectionView(Rect rect, Vector2 min, Vector2 max, float scale)
+ {
+ Rect = rect;
+ Min = min;
+ Max = max;
+ Scale = scale;
+ }
+
+ public static CrossSectionView Create(Rect rect, WireRailSegment segment)
+ {
+ var min = new Vector2(-35f, -12f);
+ var max = new Vector2(35f, 68f);
+ for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) {
+ var offset = segment.GetRailOffset(railIndex);
+ var padding = segment.GetWireDiameter(railIndex) * 0.5f + 8f;
+ min = Vector2.Min(min, offset - Vector2.one * padding);
+ max = Vector2.Max(max, offset + Vector2.one * padding);
+ }
+ var size = max - min;
+ var scale = math.max(0.01f, math.min((rect.width - 20f) / size.x,
+ (rect.height - 20f) / size.y));
+ var fittedSize = new Vector2((rect.width - 20f) / scale,
+ (rect.height - 20f) / scale);
+ var center = (min + max) * 0.5f;
+ min = center - fittedSize * 0.5f;
+ max = center + fittedSize * 0.5f;
+ return new CrossSectionView(rect, min, max, scale);
+ }
+
+ public Vector2 ToScreen(Vector2 vpx)
+ => new(Rect.x + 10f + (vpx.x - Min.x) * Scale,
+ Rect.yMax - 10f - (vpx.y - Min.y) * Scale);
+
+ public Vector2 ToVpxDelta(Vector2 pixels)
+ => new(pixels.x / Scale, -pixels.y / Scale);
+ }
+ }
+
+ internal sealed class WireRailRingPreviewEditor
+ {
+ private const float FullTurn = math.PI * 2f;
+ private const int CircleSegments = 48;
+ public const float Height = 170f;
+ private static readonly Color CanvasColor = new(0.105f, 0.115f, 0.13f, 1f);
+ private static readonly Color GridColor = new(1f, 1f, 1f, 0.07f);
+ private static readonly Color AxisColor = new(1f, 1f, 1f, 0.28f);
+ private static readonly Color OutlineColor = new(0f, 0f, 0f, 0.8f);
+ private static readonly Color RailColor = new(0.55f, 0.58f, 0.62f, 1f);
+ private static readonly Color RingColor = new(1f, 0.67f, 0.12f, 1f);
+
+ public void Draw(Rect rect, WireRailComponent component, int fixtureIndex,
+ WireRailRingFixture ring)
+ {
+ EditorGUI.DrawRect(rect, CanvasColor);
+ if (!component.TryGetRingCrossSection(fixtureIndex, out var crossSection)) {
+ EditorGUI.LabelField(rect, "Ring preview unavailable",
+ EditorStyles.centeredGreyMiniLabel);
+ return;
+ }
+ if (!ring.TryGetVisibleArc(out var startAngle, out var sweepAngle,
+ out _)) {
+ EditorGUI.LabelField(rect, "Ring fully cut out",
+ EditorStyles.centeredGreyMiniLabel);
+ return;
+ }
+
+ var angles = BuildAngles(ring, startAngle, sweepAngle);
+ var offsets = new List(angles.Count);
+ foreach (var angle in angles) {
+ var centerline = ring.EvaluateCenterlineOffset(angle, crossSection.Radius);
+ offsets.Add(crossSection.CenterOffset
+ + new Vector2(centerline.x, centerline.y));
+ }
+ var view = RingPreviewView.Create(rect, offsets, ring.Diameter * 0.5f,
+ crossSection.RailOffsets, crossSection.RailRadii);
+ DrawGrid(view);
+
+ var points = new Vector3[offsets.Count];
+ for (var index = 0; index < offsets.Count; index++) {
+ points[index] = view.ToScreen(offsets[index]);
+ }
+ var railWidth = math.clamp(ring.Diameter * view.Scale, 2f, 12f);
+ var width = math.clamp(ring.Diameter * view.Scale, 3f, 16f);
+ Handles.BeginGUI();
+ var previousColor = Handles.color;
+ // The wire rails the ring wraps around, drawn behind the ring.
+ for (var railIndex = 0; railIndex < crossSection.RailOffsets.Length; railIndex++) {
+ DrawRail(view, crossSection.RailOffsets[railIndex],
+ crossSection.RailRadii[railIndex], railWidth);
+ }
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(width + 3f, points);
+ Handles.color = RingColor;
+ Handles.DrawAAPolyLine(width, points);
+ Handles.color = previousColor;
+ Handles.EndGUI();
+
+ GUI.Label(new Rect(rect.x + 6f, rect.y + 4f, 30f, 18f), "Z ↑",
+ EditorStyles.miniLabel);
+ GUI.Label(new Rect(rect.xMax - 34f, rect.yMax - 20f, 30f, 18f), "X →",
+ EditorStyles.miniLabel);
+ GUI.Label(rect, new GUIContent(string.Empty,
+ "Ring cross-section at its route position"));
+ }
+
+ private static void DrawRail(RingPreviewView view, Vector2 center,
+ float radius, float width)
+ {
+ var points = new Vector3[CircleSegments + 1];
+ for (var index = 0; index <= CircleSegments; index++) {
+ var angle = math.PI * 2f * index / CircleSegments;
+ points[index] = view.ToScreen(center
+ + new Vector2(math.cos(angle), math.sin(angle)) * radius);
+ }
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(width + 2f, points);
+ Handles.color = RailColor;
+ Handles.DrawAAPolyLine(width, points);
+ }
+
+ private static List BuildAngles(WireRailRingFixture ring,
+ float startAngle, float sweepAngle)
+ {
+ var segmentCount = math.max(2,
+ (int)math.ceil(ring.RingDensity * sweepAngle / FullTurn));
+ var angles = new List(segmentCount + 3);
+ for (var index = 0; index <= segmentCount; index++) {
+ angles.Add(startAngle + sweepAngle * index / segmentCount);
+ }
+ if (ring.TryGetStraightSection(out var straightStart, out var straightSweep)) {
+ AddBoundary(straightStart);
+ AddBoundary(straightStart + straightSweep);
+ }
+ angles.Sort();
+ for (var index = angles.Count - 1; index > 0; index--) {
+ if (math.abs(angles[index] - angles[index - 1]) < 1e-5f) {
+ angles.RemoveAt(index);
+ }
+ }
+ return angles;
+
+ void AddBoundary(float boundary)
+ {
+ for (var turn = -2; turn <= 2; turn++) {
+ var unwrapped = boundary + turn * FullTurn;
+ if (unwrapped >= startAngle - 1e-5f
+ && unwrapped <= startAngle + sweepAngle + 1e-5f) {
+ angles.Add(unwrapped);
+ }
+ }
+ }
+ }
+
+ private static void DrawGrid(RingPreviewView view)
+ {
+ var span = math.max(view.Max.x - view.Min.x, view.Max.y - view.Min.y);
+ var gridStep = span > 400f ? 100f : span > 200f ? 50f : span > 100f ? 20f : 10f;
+ for (var x = math.ceil(view.Min.x / gridStep) * gridStep;
+ x <= view.Max.x; x += gridStep) {
+ var screen = view.ToScreen(new Vector2(x, 0f));
+ EditorGUI.DrawRect(new Rect(screen.x, view.Rect.y, 1f, view.Rect.height),
+ math.abs(x) < 0.01f ? AxisColor : GridColor);
+ }
+ for (var z = math.ceil(view.Min.y / gridStep) * gridStep;
+ z <= view.Max.y; z += gridStep) {
+ var screen = view.ToScreen(new Vector2(0f, z));
+ EditorGUI.DrawRect(new Rect(view.Rect.x, screen.y, view.Rect.width, 1f),
+ math.abs(z) < 0.01f ? AxisColor : GridColor);
+ }
+ }
+
+ private readonly struct RingPreviewView
+ {
+ public readonly Rect Rect;
+ public readonly Vector2 Min;
+ public readonly Vector2 Max;
+ public readonly float Scale;
+
+ private RingPreviewView(Rect rect, Vector2 min, Vector2 max, float scale)
+ {
+ Rect = rect;
+ Min = min;
+ Max = max;
+ Scale = scale;
+ }
+
+ public static RingPreviewView Create(Rect rect, IReadOnlyList offsets,
+ float tubeRadius, IReadOnlyList railOffsets,
+ IReadOnlyList railRadii)
+ {
+ var min = Vector2.zero;
+ var max = Vector2.zero;
+ var padding = math.max(8f, tubeRadius + 8f);
+ foreach (var offset in offsets) {
+ min = Vector2.Min(min, offset - Vector2.one * padding);
+ max = Vector2.Max(max, offset + Vector2.one * padding);
+ }
+ if (railOffsets != null) {
+ for (var railIndex = 0; railIndex < railOffsets.Count; railIndex++) {
+ var extent = Vector2.one * (railRadii[railIndex] + 8f);
+ min = Vector2.Min(min, railOffsets[railIndex] - extent);
+ max = Vector2.Max(max, railOffsets[railIndex] + extent);
+ }
+ }
+ var size = Vector2.Max(max - min, new Vector2(1f, 1f));
+ var scale = math.max(0.01f, math.min((rect.width - 20f) / size.x,
+ (rect.height - 20f) / size.y));
+ var fittedSize = new Vector2((rect.width - 20f) / scale,
+ (rect.height - 20f) / scale);
+ var center = (min + max) * 0.5f;
+ min = center - fittedSize * 0.5f;
+ max = center + fittedSize * 0.5f;
+ return new RingPreviewView(rect, min, max, scale);
+ }
+
+ public Vector3 ToScreen(Vector2 vpx)
+ => new(Rect.x + 10f + (vpx.x - Min.x) * Scale,
+ Rect.yMax - 10f - (vpx.y - Min.y) * Scale, 0f);
+ }
+ }
+
+ internal sealed class WireRailCradlePreviewEditor
+ {
+ private const int CircleSegments = 48;
+ public const float Height = 190f;
+ private static readonly Color CanvasColor = new(0.105f, 0.115f, 0.13f, 1f);
+ private static readonly Color GridColor = new(1f, 1f, 1f, 0.07f);
+ private static readonly Color AxisColor = new(1f, 1f, 1f, 0.28f);
+ private static readonly Color OutlineColor = new(0f, 0f, 0f, 0.8f);
+ private static readonly Color RailColor = new(0.55f, 0.58f, 0.62f, 1f);
+ private static readonly Color RingColor = new(1f, 0.67f, 0.12f, 1f);
+
+ public void Draw(Rect rect, WireRailComponent component, int fixtureIndex,
+ WireRailCradleFixture cradle)
+ {
+ EditorGUI.DrawRect(rect, CanvasColor);
+ if (!component.TryGetCradlePreview(fixtureIndex, out var preview)) {
+ EditorGUI.LabelField(rect,
+ "Cradle preview unavailable at this position",
+ EditorStyles.centeredGreyMiniLabel);
+ return;
+ }
+
+ var view = CradlePreviewView.Create(rect, preview, cradle.Diameter * 0.5f);
+ DrawGrid(view);
+ var railWidth = math.clamp(cradle.Diameter * view.Scale, 2f, 12f);
+ var ringWidth = math.clamp(cradle.Diameter * view.Scale, 3f, 16f);
+ Handles.BeginGUI();
+ var previousColor = Handles.color;
+ for (var railIndex = 0; railIndex < preview.RailOffsets.Count; railIndex++) {
+ DrawRail(view, preview.RailOffsets[railIndex],
+ preview.RailRadii[railIndex], railWidth);
+ }
+ var points = new Vector3[preview.CenterlinePoints.Count];
+ for (var pointIndex = 0; pointIndex < points.Length; pointIndex++) {
+ points[pointIndex] = view.ToScreen(preview.CenterlinePoints[pointIndex]);
+ }
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(ringWidth + 3f, points);
+ Handles.color = RingColor;
+ Handles.DrawAAPolyLine(ringWidth, points);
+ Handles.color = previousColor;
+ Handles.EndGUI();
+
+ GUI.Label(new Rect(rect.x + 6f, rect.y + 4f, 30f, 18f), "Z ↑",
+ EditorStyles.miniLabel);
+ GUI.Label(new Rect(rect.xMax - 34f, rect.yMax - 20f, 30f, 18f), "X →",
+ EditorStyles.miniLabel);
+ GUI.Label(rect, new GUIContent(string.Empty,
+ "Cradle at its route position"));
+ }
+
+ private static void DrawRail(CradlePreviewView view, Vector2 center,
+ float radius, float width)
+ {
+ var points = new Vector3[CircleSegments + 1];
+ for (var index = 0; index <= CircleSegments; index++) {
+ var angle = math.PI * 2f * index / CircleSegments;
+ points[index] = view.ToScreen(center
+ + new Vector2(math.cos(angle), math.sin(angle)) * radius);
+ }
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(width + 2f, points);
+ Handles.color = RailColor;
+ Handles.DrawAAPolyLine(width, points);
+ }
+
+ private static void DrawGrid(CradlePreviewView view)
+ {
+ var span = math.max(view.Max.x - view.Min.x, view.Max.y - view.Min.y);
+ var gridStep = span > 400f ? 100f : span > 200f ? 50f : span > 100f ? 20f : 10f;
+ for (var x = math.ceil(view.Min.x / gridStep) * gridStep;
+ x <= view.Max.x; x += gridStep) {
+ var screen = view.ToScreen(new Vector2(x, 0f));
+ EditorGUI.DrawRect(new Rect(screen.x, view.Rect.y, 1f, view.Rect.height),
+ math.abs(x) < 0.01f ? AxisColor : GridColor);
+ }
+ for (var z = math.ceil(view.Min.y / gridStep) * gridStep;
+ z <= view.Max.y; z += gridStep) {
+ var screen = view.ToScreen(new Vector2(0f, z));
+ EditorGUI.DrawRect(new Rect(view.Rect.x, screen.y, view.Rect.width, 1f),
+ math.abs(z) < 0.01f ? AxisColor : GridColor);
+ }
+ }
+
+ private readonly struct CradlePreviewView
+ {
+ public readonly Rect Rect;
+ public readonly Vector2 Min;
+ public readonly Vector2 Max;
+ public readonly float Scale;
+
+ private CradlePreviewView(Rect rect, Vector2 min, Vector2 max, float scale)
+ {
+ Rect = rect;
+ Min = min;
+ Max = max;
+ Scale = scale;
+ }
+
+ public static CradlePreviewView Create(Rect rect, WireRailCradlePreview preview,
+ float tubeRadius)
+ {
+ var padding = math.max(8f, tubeRadius + 8f);
+ var min = new Vector2(float.PositiveInfinity, float.PositiveInfinity);
+ var max = new Vector2(float.NegativeInfinity, float.NegativeInfinity);
+ for (var railIndex = 0; railIndex < preview.RailOffsets.Count; railIndex++) {
+ Include(preview.RailOffsets[railIndex],
+ preview.RailRadii[railIndex] + padding);
+ }
+ foreach (var point in preview.CenterlinePoints) {
+ Include(point, padding);
+ }
+ var size = Vector2.Max(max - min, new Vector2(1f, 1f));
+ var scale = math.max(0.01f, math.min((rect.width - 20f) / size.x,
+ (rect.height - 20f) / size.y));
+ var fittedSize = new Vector2((rect.width - 20f) / scale,
+ (rect.height - 20f) / scale);
+ var center = (min + max) * 0.5f;
+ return new CradlePreviewView(rect, center - fittedSize * 0.5f,
+ center + fittedSize * 0.5f, scale);
+
+ void Include(Vector2 point, float radius)
+ {
+ min = Vector2.Min(min, point - Vector2.one * radius);
+ max = Vector2.Max(max, point + Vector2.one * radius);
+ }
+ }
+
+ public Vector3 ToScreen(Vector2 vpx)
+ => new(Rect.x + 10f + (vpx.x - Min.x) * Scale,
+ Rect.yMax - 10f - (vpx.y - Min.y) * Scale, 0f);
+ }
+ }
+
+ internal sealed class WireRailRungPreviewEditor
+ {
+ private const int CircleSegments = 48;
+ public const float Height = 170f;
+ private static readonly Color CanvasColor = new(0.105f, 0.115f, 0.13f, 1f);
+ private static readonly Color GridColor = new(1f, 1f, 1f, 0.07f);
+ private static readonly Color AxisColor = new(1f, 1f, 1f, 0.28f);
+ private static readonly Color OutlineColor = new(0f, 0f, 0f, 0.8f);
+ private static readonly Color RailColor = new(0.55f, 0.58f, 0.62f, 1f);
+ private static readonly Color RungColor = new(1f, 0.67f, 0.12f, 1f);
+
+ public void Draw(Rect rect, WireRailComponent component, int fixtureIndex,
+ WireRailRungFixture rung)
+ {
+ EditorGUI.DrawRect(rect, CanvasColor);
+ if (!component.TryGetRungCrossSection(fixtureIndex, out var crossSection)) {
+ EditorGUI.LabelField(rect,
+ "Rung preview unavailable at this position",
+ EditorStyles.centeredGreyMiniLabel);
+ return;
+ }
+
+ var view = RungPreviewView.Create(rect, crossSection,
+ rung.Diameter * 0.5f);
+ DrawGrid(view);
+ var railWidth = math.clamp(rung.Diameter * view.Scale, 2f, 12f);
+ var rungWidth = math.clamp(rung.Diameter * view.Scale, 3f, 16f);
+ var start = view.ToScreen(crossSection.StartOffset);
+ var end = view.ToScreen(crossSection.EndOffset);
+ Handles.BeginGUI();
+ var previousColor = Handles.color;
+ DrawRail(view, crossSection.StartRailOffset,
+ crossSection.StartRailRadius, railWidth);
+ DrawRail(view, crossSection.EndRailOffset,
+ crossSection.EndRailRadius, railWidth);
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(rungWidth + 3f, start, end);
+ Handles.color = RungColor;
+ Handles.DrawAAPolyLine(rungWidth, start, end);
+ Handles.color = previousColor;
+ Handles.EndGUI();
+
+ GUI.Label(new Rect(rect.x + 6f, rect.y + 4f, 30f, 18f), "Z ↑",
+ EditorStyles.miniLabel);
+ GUI.Label(new Rect(rect.xMax - 34f, rect.yMax - 20f, 30f, 18f), "X →",
+ EditorStyles.miniLabel);
+ GUI.Label(rect, new GUIContent(string.Empty,
+ "Rung at its route position"));
+ }
+
+ private static void DrawRail(RungPreviewView view, Vector2 center,
+ float radius, float width)
+ {
+ var points = new Vector3[CircleSegments + 1];
+ for (var index = 0; index <= CircleSegments; index++) {
+ var angle = math.PI * 2f * index / CircleSegments;
+ points[index] = view.ToScreen(center
+ + new Vector2(math.cos(angle), math.sin(angle)) * radius);
+ }
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(width + 2f, points);
+ Handles.color = RailColor;
+ Handles.DrawAAPolyLine(width, points);
+ }
+
+ private static void DrawGrid(RungPreviewView view)
+ {
+ var span = math.max(view.Max.x - view.Min.x, view.Max.y - view.Min.y);
+ var gridStep = span > 400f ? 100f : span > 200f ? 50f : span > 100f ? 20f : 10f;
+ for (var x = math.ceil(view.Min.x / gridStep) * gridStep;
+ x <= view.Max.x; x += gridStep) {
+ var screen = view.ToScreen(new Vector2(x, 0f));
+ EditorGUI.DrawRect(new Rect(screen.x, view.Rect.y, 1f, view.Rect.height),
+ math.abs(x) < 0.01f ? AxisColor : GridColor);
+ }
+ for (var z = math.ceil(view.Min.y / gridStep) * gridStep;
+ z <= view.Max.y; z += gridStep) {
+ var screen = view.ToScreen(new Vector2(0f, z));
+ EditorGUI.DrawRect(new Rect(view.Rect.x, screen.y, view.Rect.width, 1f),
+ math.abs(z) < 0.01f ? AxisColor : GridColor);
+ }
+ }
+
+ private readonly struct RungPreviewView
+ {
+ public readonly Rect Rect;
+ public readonly Vector2 Min;
+ public readonly Vector2 Max;
+ public readonly float Scale;
+
+ private RungPreviewView(Rect rect, Vector2 min, Vector2 max, float scale)
+ {
+ Rect = rect;
+ Min = min;
+ Max = max;
+ Scale = scale;
+ }
+
+ public static RungPreviewView Create(Rect rect,
+ WireRailRungCrossSection crossSection, float tubeRadius)
+ {
+ var padding = math.max(8f, tubeRadius + 8f);
+ var min = Vector2.Min(crossSection.StartOffset, crossSection.EndOffset)
+ - Vector2.one * padding;
+ var max = Vector2.Max(crossSection.StartOffset, crossSection.EndOffset)
+ + Vector2.one * padding;
+ min = Vector2.Min(min, crossSection.StartRailOffset
+ - Vector2.one * (crossSection.StartRailRadius + padding));
+ max = Vector2.Max(max, crossSection.StartRailOffset
+ + Vector2.one * (crossSection.StartRailRadius + padding));
+ min = Vector2.Min(min, crossSection.EndRailOffset
+ - Vector2.one * (crossSection.EndRailRadius + padding));
+ max = Vector2.Max(max, crossSection.EndRailOffset
+ + Vector2.one * (crossSection.EndRailRadius + padding));
+ var size = Vector2.Max(max - min, new Vector2(1f, 1f));
+ var scale = math.max(0.01f, math.min((rect.width - 20f) / size.x,
+ (rect.height - 20f) / size.y));
+ var fittedSize = new Vector2((rect.width - 20f) / scale,
+ (rect.height - 20f) / scale);
+ var center = (min + max) * 0.5f;
+ min = center - fittedSize * 0.5f;
+ max = center + fittedSize * 0.5f;
+ return new RungPreviewView(rect, min, max, scale);
+ }
+
+ public Vector3 ToScreen(Vector2 vpx)
+ => new(Rect.x + 10f + (vpx.x - Min.x) * Scale,
+ Rect.yMax - 10f - (vpx.y - Min.y) * Scale, 0f);
+ }
+ }
+
+ internal sealed class WireRailStandPreviewEditor
+ {
+ private const int CircleSegments = 48;
+ public const float Height = 190f;
+ private static readonly Color CanvasColor = new(0.105f, 0.115f, 0.13f, 1f);
+ private static readonly Color GridColor = new(1f, 1f, 1f, 0.07f);
+ private static readonly Color AxisColor = new(1f, 1f, 1f, 0.28f);
+ private static readonly Color OutlineColor = new(0f, 0f, 0f, 0.8f);
+ private static readonly Color RailColor = new(0.55f, 0.58f, 0.62f, 1f);
+ private static readonly Color LegColor = new(1f, 0.67f, 0.12f, 1f);
+
+ public void Draw(Rect rect, WireRailComponent component, int fixtureIndex,
+ WireRailStandFixture leg)
+ {
+ EditorGUI.DrawRect(rect, CanvasColor);
+ if (!component.TryGetStandPreview(fixtureIndex, out var preview)) {
+ EditorGUI.LabelField(rect,
+ "Leg and foot preview unavailable at this position",
+ EditorStyles.centeredGreyMiniLabel);
+ return;
+ }
+
+ var view = LegPreviewView.Create(rect, preview, leg.Diameter * 0.5f);
+ DrawGrid(view);
+ var railWidth = math.clamp(leg.Diameter * view.Scale, 2f, 12f);
+ var tubeWidth = math.clamp(leg.Diameter * view.Scale, 3f, 16f);
+ Handles.BeginGUI();
+ var previousColor = Handles.color;
+ DrawRail(view, preview.StartRailOffset, preview.StartRailRadius, railWidth);
+ DrawRail(view, preview.EndRailOffset, preview.EndRailRadius, railWidth);
+ DrawPath(view, preview.CenterlinePoints, tubeWidth, LegColor);
+ Handles.color = previousColor;
+ Handles.EndGUI();
+
+ GUI.Label(new Rect(rect.x + 6f, rect.y + 4f, 88f, 18f),
+ "X / Y / Z", EditorStyles.miniLabel);
+ GUI.Label(rect, new GUIContent(string.Empty,
+ "Projected route-local view of the rounded leg and U-hook centerline."));
+ }
+
+ private static void DrawRail(LegPreviewView view, Vector3 center,
+ float radius, float width)
+ {
+ var points = new Vector3[CircleSegments + 1];
+ for (var index = 0; index <= CircleSegments; index++) {
+ var angle = math.PI * 2f * index / CircleSegments;
+ points[index] = view.ToScreen(center
+ + new Vector3(math.cos(angle) * radius, 0f,
+ math.sin(angle) * radius));
+ }
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(width + 2f, points);
+ Handles.color = RailColor;
+ Handles.DrawAAPolyLine(width, points);
+ }
+
+ private static void DrawPath(LegPreviewView view,
+ IReadOnlyList source, float width, Color color)
+ {
+ if (source == null || source.Count < 2) {
+ return;
+ }
+ var points = new Vector3[source.Count];
+ for (var pointIndex = 0; pointIndex < source.Count; pointIndex++) {
+ points[pointIndex] = view.ToScreen(source[pointIndex]);
+ }
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(width + 3f, points);
+ Handles.color = color;
+ Handles.DrawAAPolyLine(width, points);
+ }
+
+ private static void DrawGrid(LegPreviewView view)
+ {
+ var span = math.max(view.Max.x - view.Min.x, view.Max.y - view.Min.y);
+ var gridStep = span > 400f ? 100f : span > 200f ? 50f : span > 100f ? 20f : 10f;
+ for (var x = math.ceil(view.Min.x / gridStep) * gridStep;
+ x <= view.Max.x; x += gridStep) {
+ var screen = view.ToScreenProjected(new Vector2(x, 0f));
+ EditorGUI.DrawRect(new Rect(screen.x, view.Rect.y, 1f, view.Rect.height),
+ math.abs(x) < 0.01f ? AxisColor : GridColor);
+ }
+ for (var y = math.ceil(view.Min.y / gridStep) * gridStep;
+ y <= view.Max.y; y += gridStep) {
+ var screen = view.ToScreenProjected(new Vector2(0f, y));
+ EditorGUI.DrawRect(new Rect(view.Rect.x, screen.y, view.Rect.width, 1f),
+ math.abs(y) < 0.01f ? AxisColor : GridColor);
+ }
+ }
+
+ private readonly struct LegPreviewView
+ {
+ public readonly Rect Rect;
+ public readonly Vector2 Min;
+ public readonly Vector2 Max;
+ public readonly float Scale;
+
+ private LegPreviewView(Rect rect, Vector2 min, Vector2 max, float scale)
+ {
+ Rect = rect;
+ Min = min;
+ Max = max;
+ Scale = scale;
+ }
+
+ public static LegPreviewView Create(Rect rect, WireRailStandPreview preview,
+ float tubeRadius)
+ {
+ var padding = math.max(8f, tubeRadius + 8f);
+ var min = new Vector2(float.PositiveInfinity, float.PositiveInfinity);
+ var max = new Vector2(float.NegativeInfinity, float.NegativeInfinity);
+ Include(preview.StartRailOffset, preview.StartRailRadius + padding);
+ Include(preview.EndRailOffset, preview.EndRailRadius + padding);
+ foreach (var point in preview.CenterlinePoints) {
+ Include(point, padding);
+ }
+ var size = Vector2.Max(max - min, new Vector2(1f, 1f));
+ var scale = math.max(0.01f, math.min((rect.width - 20f) / size.x,
+ (rect.height - 20f) / size.y));
+ var fittedSize = new Vector2((rect.width - 20f) / scale,
+ (rect.height - 20f) / scale);
+ var center = (min + max) * 0.5f;
+ return new LegPreviewView(rect, center - fittedSize * 0.5f,
+ center + fittedSize * 0.5f, scale);
+
+ void Include(Vector3 point, float radius)
+ {
+ var projected = Project(point);
+ min = Vector2.Min(min, projected - Vector2.one * radius);
+ max = Vector2.Max(max, projected + Vector2.one * radius);
+ }
+ }
+
+ public Vector3 ToScreen(Vector3 vpx) => ToScreenProjected(Project(vpx));
+
+ public Vector3 ToScreenProjected(Vector2 projected)
+ => new(Rect.x + 10f + (projected.x - Min.x) * Scale,
+ Rect.yMax - 10f - (projected.y - Min.y) * Scale, 0f);
+
+ private static Vector2 Project(Vector3 vpx)
+ => new(vpx.x + vpx.y * 0.35f, vpx.z + vpx.y * 0.22f);
+ }
+ }
+
+ internal sealed class WireRailElbowPreviewEditor
+ {
+ public const float Height = WireRailPathPreview.Height;
+
+ public void Draw(Rect rect, WireRailComponent component, int fixtureIndex,
+ WireRailElbowFixture elbow)
+ {
+ if (!component.TryGetElbowPreview(fixtureIndex, out var preview)) {
+ WireRailPathPreview.DrawUnavailable(rect,
+ "Elbow preview unavailable at this position");
+ return;
+ }
+ WireRailPathPreview.Draw(rect, elbow.Diameter,
+ "Projected route-local view of the two dropping rails.",
+ new[] { preview.FirstRailPoints, preview.SecondRailPoints });
+ }
+ }
+
+ internal sealed class WireRailHairpinPreviewEditor
+ {
+ public const float Height = WireRailPathPreview.Height;
+
+ public void Draw(Rect rect, WireRailComponent component, int fixtureIndex,
+ WireRailHairpinFixture hairpin)
+ {
+ if (!component.TryGetHairpinPreview(fixtureIndex, out var preview)) {
+ WireRailPathPreview.DrawUnavailable(rect,
+ "Hairpin preview unavailable at this position");
+ return;
+ }
+ WireRailPathPreview.Draw(rect, hairpin.Diameter,
+ "Top-down route-local view of the hairpin centerline.",
+ new[] { preview.CenterlinePoints }, WireRailPreviewProjection.Top);
+ }
+ }
+
+ // How the shared path preview flattens route-local geometry onto the 2D canvas.
+ internal enum WireRailPreviewProjection
+ {
+ // Skewed 3D look (lateral × height, with a slight along-spline shear).
+ Isometric,
+ // Straight top-down view (lateral × along-spline), looking down the route's up axis.
+ Top,
+ }
+
+ // Shared projected route-local preview for the endpoint fittings whose geometry is one or
+ // more 3D centerline paths (Drop, Hairpin), matching the leg preview's isometric look.
+ internal static class WireRailPathPreview
+ {
+ public const float Height = 190f;
+ private static readonly Color CanvasColor = new(0.105f, 0.115f, 0.13f, 1f);
+ private static readonly Color GridColor = new(1f, 1f, 1f, 0.07f);
+ private static readonly Color AxisColor = new(1f, 1f, 1f, 0.28f);
+ private static readonly Color OutlineColor = new(0f, 0f, 0f, 0.8f);
+ private static readonly Color PathColor = new(1f, 0.67f, 0.12f, 1f);
+
+ public static void DrawUnavailable(Rect rect, string label)
+ {
+ EditorGUI.DrawRect(rect, CanvasColor);
+ EditorGUI.LabelField(rect, label, EditorStyles.centeredGreyMiniLabel);
+ }
+
+ public static void Draw(Rect rect, float diameter, string tooltip,
+ IReadOnlyList> paths,
+ WireRailPreviewProjection projection = WireRailPreviewProjection.Isometric)
+ {
+ EditorGUI.DrawRect(rect, CanvasColor);
+ var view = PathPreviewView.Create(rect, paths, diameter * 0.5f, projection);
+ DrawGrid(view);
+ var tubeWidth = math.clamp(diameter * view.Scale, 3f, 16f);
+ Handles.BeginGUI();
+ var previousColor = Handles.color;
+ foreach (var path in paths) {
+ DrawPath(view, path, tubeWidth);
+ }
+ Handles.color = previousColor;
+ Handles.EndGUI();
+
+ var axisLabel = projection == WireRailPreviewProjection.Top
+ ? "Top (X / Y)" : "X / Y / Z";
+ GUI.Label(new Rect(rect.x + 6f, rect.y + 4f, 88f, 18f), axisLabel,
+ EditorStyles.miniLabel);
+ GUI.Label(rect, new GUIContent(string.Empty, tooltip));
+ }
+
+ private static void DrawPath(PathPreviewView view, IReadOnlyList source,
+ float width)
+ {
+ if (source == null || source.Count < 2) {
+ return;
+ }
+ var points = new Vector3[source.Count];
+ for (var pointIndex = 0; pointIndex < source.Count; pointIndex++) {
+ points[pointIndex] = view.ToScreen(source[pointIndex]);
+ }
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(width + 3f, points);
+ Handles.color = PathColor;
+ Handles.DrawAAPolyLine(width, points);
+ }
+
+ private static void DrawGrid(PathPreviewView view)
+ {
+ var span = math.max(view.Max.x - view.Min.x, view.Max.y - view.Min.y);
+ var gridStep = span > 400f ? 100f : span > 200f ? 50f : span > 100f ? 20f : 10f;
+ for (var x = math.ceil(view.Min.x / gridStep) * gridStep;
+ x <= view.Max.x; x += gridStep) {
+ var screen = view.ToScreenProjected(new Vector2(x, 0f));
+ EditorGUI.DrawRect(new Rect(screen.x, view.Rect.y, 1f, view.Rect.height),
+ math.abs(x) < 0.01f ? AxisColor : GridColor);
+ }
+ for (var y = math.ceil(view.Min.y / gridStep) * gridStep;
+ y <= view.Max.y; y += gridStep) {
+ var screen = view.ToScreenProjected(new Vector2(0f, y));
+ EditorGUI.DrawRect(new Rect(view.Rect.x, screen.y, view.Rect.width, 1f),
+ math.abs(y) < 0.01f ? AxisColor : GridColor);
+ }
+ }
+
+ private readonly struct PathPreviewView
+ {
+ public readonly Rect Rect;
+ public readonly Vector2 Min;
+ public readonly Vector2 Max;
+ public readonly float Scale;
+ private readonly WireRailPreviewProjection _projection;
+
+ private PathPreviewView(Rect rect, Vector2 min, Vector2 max, float scale,
+ WireRailPreviewProjection projection)
+ {
+ Rect = rect;
+ Min = min;
+ Max = max;
+ Scale = scale;
+ _projection = projection;
+ }
+
+ public static PathPreviewView Create(Rect rect,
+ IReadOnlyList> paths, float tubeRadius,
+ WireRailPreviewProjection projection)
+ {
+ var padding = math.max(8f, tubeRadius + 8f);
+ var min = new Vector2(float.PositiveInfinity, float.PositiveInfinity);
+ var max = new Vector2(float.NegativeInfinity, float.NegativeInfinity);
+ foreach (var path in paths) {
+ if (path == null) {
+ continue;
+ }
+ foreach (var point in path) {
+ var projected = Project(point, projection);
+ min = Vector2.Min(min, projected - Vector2.one * padding);
+ max = Vector2.Max(max, projected + Vector2.one * padding);
+ }
+ }
+ if (min.x > max.x) {
+ min = Vector2.zero;
+ max = Vector2.one;
+ }
+ var size = Vector2.Max(max - min, new Vector2(1f, 1f));
+ var scale = math.max(0.01f, math.min((rect.width - 20f) / size.x,
+ (rect.height - 20f) / size.y));
+ var fittedSize = new Vector2((rect.width - 20f) / scale,
+ (rect.height - 20f) / scale);
+ var center = (min + max) * 0.5f;
+ return new PathPreviewView(rect, center - fittedSize * 0.5f,
+ center + fittedSize * 0.5f, scale, projection);
+ }
+
+ public Vector3 ToScreen(Vector3 vpx) => ToScreenProjected(Project(vpx, _projection));
+
+ public Vector3 ToScreenProjected(Vector2 projected)
+ => new(Rect.x + 10f + (projected.x - Min.x) * Scale,
+ Rect.yMax - 10f - (projected.y - Min.y) * Scale, 0f);
+
+ // vpx is route-local: x = lateral (right), y = along-spline (tangent), z = height (up).
+ private static Vector2 Project(Vector3 vpx, WireRailPreviewProjection projection)
+ => projection == WireRailPreviewProjection.Top
+ ? new Vector2(vpx.x, vpx.y)
+ : new Vector2(vpx.x + vpx.y * 0.35f, vpx.z + vpx.y * 0.22f);
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailCrossSectionEditor.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailCrossSectionEditor.cs.meta
new file mode 100644
index 000000000..f0e39fa2f
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailCrossSectionEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f7ea4545344c4abe93e4d51d1083ef9c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailInspector.cs
new file mode 100644
index 000000000..94abca7df
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailInspector.cs
@@ -0,0 +1,3323 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Unity.Mathematics;
+using Unity.Profiling;
+using UnityEditor;
+using UnityEditor.EditorTools;
+using UnityEditor.SceneManagement;
+using UnityEditor.Splines;
+using UnityEditorInternal;
+using UnityEngine;
+using UnityEngine.Rendering;
+using UnityEngine.Splines;
+
+namespace VisualPinball.Unity.Editor
+{
+ [CustomEditor(typeof(WireRailComponent))]
+ public class WireRailInspector : UnityEditor.Editor
+ {
+ private static readonly string[] ThirdRailSides = { "Left", "Right" };
+ private static readonly GUIContent[][] RailOptions = Enumerable.Range(0, 7)
+ .Select(count => Enumerable.Range(1, count)
+ .Select(railIndex => new GUIContent($"Rail {railIndex}"))
+ .ToArray())
+ .ToArray();
+ private static readonly string[] RailCounts = { "1", "2", "3", "4", "5", "6" };
+ private static readonly Color TransitionCurveColor = new(0.05f, 0.75f, 1f, 1f);
+ private static readonly List SelectedGradeKnots = new();
+ private static readonly List SelectedGradeTangents = new();
+ private static readonly List SelectedGradeKnotIndices = new();
+ private static readonly List BlendedWireIndices = new();
+ private static readonly GUIContent TransitionBlendHeightContent = new(
+ "Blending Wires 1, 2, 3, 4, 5, 6" + TransitionBlendMessageSuffix);
+ private static GUIContent _alignAngleRangeContent;
+ private const string TransitionBlendMessageSuffix =
+ ": offset or diameter changes across this physical span.";
+ private const float LayoutLineHeight = 20f;
+ private const float LayoutPadding = 7f;
+ // The list insets an element by the drag handle on the left but only by a few
+ // pixels on the right; this evens out the padding inside the element frame.
+ private const float ElementRightInset = 14f;
+ // Extra inset of the element frame's right border.
+ private const float FrameRightInset = 2f;
+ private const float SolderRowsContentHeight = LayoutLineHeight;
+ private const float SolderRowHeight = SolderRowsContentHeight + 3f;
+ private const float HelpBoxHeight = 38f;
+
+ ///
+ /// Width of a list element's content, estimated from the inspector width so element
+ /// heights can be computed before the element is laid out.
+ ///
+ private static float EstimatedElementContentWidth
+ => math.max(120f, EditorGUIUtility.currentViewWidth - 91f);
+
+ private static float HelpBoxHeightFor(string message)
+ => math.max(HelpBoxHeight, EditorStyles.helpBox.CalcHeight(
+ new GUIContent(message), EstimatedElementContentWidth));
+
+ private static string GetHairpinMessage(WireRailComponent component,
+ WireRailHairpinFixture hairpin, WireRailEndpoint endpoint, int firstRailIndex,
+ int secondRailIndex, float railOffset, out bool warning)
+ {
+ var spline = component.SplineContainer ? component.SplineContainer.Spline : null;
+ warning = true;
+ if (spline != null && spline.Closed) {
+ return "Hairpins require an open spline with a real start and end.";
+ }
+ if (firstRailIndex == secondRailIndex) {
+ return "Select two different rails. Invalid Hairpins are not generated.";
+ }
+ if (railOffset >= component.SplineLength - 1e-5f) {
+ return "The offset consumes the whole route. Move it back so the loop has rail to attach to; the Hairpin is not generated.";
+ }
+ if (component.HasRailTrimConflict(endpoint, firstRailIndex, secondRailIndex, hairpin)) {
+ return "A Rail Trim shortens an attached rail at this endpoint. Remove the conflict or select different rails; the Hairpin is not generated.";
+ }
+ warning = false;
+ return "The terminal semicircle uses Terminal Impact Material; its leads use the ordinary physics material.";
+ }
+
+ private static string GetElbowMessage(WireRailComponent component,
+ WireRailElbowFixture elbow, WireRailEndpoint endpoint, int firstRailIndex,
+ int secondRailIndex, float offset, out bool warning)
+ {
+ var spline = component.SplineContainer ? component.SplineContainer.Spline : null;
+ warning = true;
+ if (spline != null && spline.Closed) {
+ return "Elbows require an open spline with a real start and end.";
+ }
+ if (firstRailIndex == secondRailIndex) {
+ return "Select two different rails. Invalid Elbows are not generated.";
+ }
+ if (offset >= component.SplineLength - 1e-5f) {
+ return "The offset consumes the whole route. Move it back so the elbow has rail to attach to; the Elbow is not generated.";
+ }
+ if (!component.AreEndpointRailsActive(endpoint, firstRailIndex, secondRailIndex, offset)) {
+ return "Both selected rails must be active at this endpoint. Inactive Elbows are not generated.";
+ }
+ if (component.HasRailTrimConflict(endpoint, firstRailIndex, secondRailIndex, elbow)) {
+ return "Another endpoint cutoff shortens an attached rail. Remove the conflict or select different rails; the Elbow is not generated.";
+ }
+ warning = false;
+ return "The offset shortens the two attached rails' colliders, and two vertical faces extend the floor down at the drop point. Other Rail Cutoffs change only the visible tubes.";
+ }
+
+ private static string GetRailTrimMessage(WireRailComponent component, out bool warning)
+ {
+ var spline = component.SplineContainer ? component.SplineContainer.Spline : null;
+ warning = spline != null && spline.Closed;
+ return warning
+ ? "Rail Trims require an open spline with a real start and end."
+ : "Zero leaves a rail unchanged. Multiple trims at one endpoint use the largest offset per rail.";
+ }
+ private const float FixtureScaleMinimum = 0.1f;
+ private const float FixtureScaleMaximum = 4f;
+ private const int PlanarSplineLengthResolution = 64;
+ private static SplineContainer _pendingSplineEdit;
+ private readonly WireRailCrossSectionEditor _crossSectionEditor = new();
+ private readonly WireRailRingPreviewEditor _ringPreviewEditor = new();
+ private readonly WireRailCradlePreviewEditor _cradlePreviewEditor = new();
+ private readonly WireRailRungPreviewEditor _rungPreviewEditor = new();
+ private readonly WireRailStandPreviewEditor _standPreviewEditor = new();
+ private readonly WireRailElbowPreviewEditor _elbowPreviewEditor = new();
+ private readonly WireRailHairpinPreviewEditor _hairpinPreviewEditor = new();
+ private readonly float[] _railTrimOffsets = new float[RailCounts.Length];
+ [SerializeField] private bool _showRenderGeometry = true;
+ [SerializeField] private bool _showBallChannelCollider = true;
+ [SerializeField] private bool _showFixtures = true;
+ [SerializeField] private bool _showWireLayouts = true;
+ private readonly List _fixtureOrder = new();
+ private readonly List _layoutOrder = new();
+ private ReorderableList _fixtureOrderList;
+ private ReorderableList _layoutOrderList;
+ private int _layoutSelectionBeforePointerDown = -1;
+
+ private void OnEnable()
+ {
+ _fixtureOrderList = CreateFixtureOrderList();
+ _layoutOrderList = CreateLayoutOrderList();
+ SplineSelection.changed -= Repaint;
+ SplineSelection.changed += Repaint;
+ Undo.undoRedoPerformed -= OnUndoRedo;
+ Undo.undoRedoPerformed += OnUndoRedo;
+ }
+
+ private void OnDisable()
+ {
+ SplineSelection.changed -= Repaint;
+ Undo.undoRedoPerformed -= OnUndoRedo;
+ WireRailLayoutEditorSelection.Clear();
+ WireRailFixtureEditorSelection.Clear();
+ }
+
+ ///
+ /// Mirrors the fixture list's selection into the static selection the Scene view
+ /// reads, so the highlighted fixture always matches the highlighted panel.
+ ///
+ private void SynchronizeFixtureSelection(WireRailComponent component)
+ {
+ var displayIndex = _fixtureOrderList?.index ?? -1;
+ var fixtureIndex = displayIndex >= 0 && displayIndex < _fixtureOrder.Count
+ ? _fixtureOrder[displayIndex] : -1;
+ if (fixtureIndex < 0 || fixtureIndex >= component.Fixtures.Count) {
+ if (WireRailFixtureEditorSelection.GetSelectedIndex(component) >= 0) {
+ WireRailFixtureEditorSelection.Clear();
+ }
+ return;
+ }
+ if (!WireRailFixtureEditorSelection.IsSelected(component, fixtureIndex)) {
+ WireRailFixtureEditorSelection.Select(component, fixtureIndex);
+ SceneView.RepaintAll();
+ }
+ }
+
+ private void OnUndoRedo()
+ {
+ WireRailLayoutEditorSelection.Clear();
+ WireRailFixtureEditorSelection.Clear();
+ if (_layoutOrderList != null) {
+ _layoutOrderList.index = -1;
+ }
+ if (_fixtureOrderList != null) {
+ _fixtureOrderList.index = -1;
+ }
+ _layoutSelectionBeforePointerDown = -1;
+ if (target is WireRailComponent component && component) {
+ component.SynchronizeSegments();
+ SynchronizeLayoutOrder(_layoutOrder, component, true);
+ }
+ Repaint();
+ SceneView.RepaintAll();
+ }
+
+ [MenuItem("GameObject/Pinball/Wire Rail", false, 11)]
+ private static void CreateWireRailGameObject(MenuCommand menuCommand)
+ {
+ var undoGroup = Undo.GetCurrentGroup();
+ Undo.SetCurrentGroupName("Create Wire Rail");
+ var parent = menuCommand.context as GameObject;
+ parent ??= Selection.activeGameObject;
+ var gameObject = new GameObject {
+ name = GameObjectUtility.GetUniqueNameForSibling(
+ parent ? parent.transform : null, "Wire Rail"),
+ };
+ StageUtility.PlaceGameObjectInCurrentStage(gameObject);
+ GameObjectUtility.SetParentAndAlign(gameObject, parent);
+ gameObject.AddComponent();
+ Undo.RegisterCreatedObjectUndo(gameObject, "Create Wire Rail");
+ Undo.CollapseUndoOperations(undoGroup);
+
+ Selection.activeGameObject = gameObject;
+ EditorGUIUtility.PingObject(gameObject);
+ }
+
+ public override void OnInspectorGUI()
+ {
+ var component = (WireRailComponent)target;
+ if (Event.current.type == EventType.Layout) {
+ component.SynchronizeSegments();
+ SynchronizeFixtureSelection(component);
+ }
+ var container = component.SplineContainer;
+ if (!container) {
+ EditorGUILayout.HelpBox(
+ "The generated Wire Rail Spline child is missing.", MessageType.Error);
+ if (GUILayout.Button("Recreate Wire Rail Spline")) {
+ Undo.RecordObject(component, "Recreate Wire Rail Spline");
+ container = component.EnsureSplineContainerExists();
+ component.SynchronizeSegments();
+ component.RebuildGeneratedMeshes();
+ EditorUtility.SetDirty(component);
+ }
+ return;
+ }
+
+ EditorGUILayout.HelpBox(
+ "Spline knots and rail offsets use VPX units. The default route points along +Y; "
+ + "rail offsets are X (lateral) and Z (vertical). Knot rotations control the "
+ + "cross-section orientation when the route turns through 3D.",
+ MessageType.Info);
+
+ using (new EditorGUI.DisabledScope(true)) {
+ EditorGUILayout.ObjectField("Spline", container, typeof(SplineContainer), true);
+ }
+ EditorGUILayout.Space(3f);
+ var editButtonStyle = new GUIStyle(GUI.skin.button) {
+ fontStyle = FontStyle.Bold,
+ };
+ using (new EditorGUILayout.HorizontalScope()) {
+ if (GUILayout.Button("Edit Spline in Scene View", editButtonStyle,
+ GUILayout.Height(30f))) {
+ EditSpline(container);
+ }
+ if (GUILayout.Button(new GUIContent("Center Pivot",
+ "Move the Wire Rail GameObject pivot to the route midpoint without moving the rail."),
+ GUILayout.Height(30f))) {
+ CenterPivot(component);
+ }
+ }
+ EditorGUILayout.HelpBox(
+ "While editing, click a knot to show the position gizmo. Double-click the "
+ + "spline to add a knot or double-click a knot to remove it.", MessageType.None);
+ var hasGradeRange = TryGetGradeSplineRange(container, out var gradeStartKnot,
+ out var gradeEndKnot, out var selectedGradeKnotCount);
+ var gradeButtonLabel = selectedGradeKnotCount == 2
+ ? "Grade Heights Between Selected Knots" : "Grade Heights First → Last";
+ var gradeButtonTooltip = selectedGradeKnotCount == 1 || selectedGradeKnotCount > 2
+ ? "Select either no knots to grade the complete route, or exactly two knots "
+ + "to grade only the interval between them."
+ : "Set every knot and Bézier handle in the target interval to a constant "
+ + "grade weighted by horizontal spline distance. Auto Smooth knots become "
+ + "editable modes so the plan-view route and unselected intervals stay fixed.";
+ using (new EditorGUI.DisabledScope(!hasGradeRange)) {
+ if (GUILayout.Button(new GUIContent(gradeButtonLabel, gradeButtonTooltip))) {
+ if (!GradeSplineHeights(component, gradeStartKnot, gradeEndKnot)) {
+ Debug.LogWarning("Cannot grade a Wire Rail spline without horizontal length.",
+ component);
+ }
+ }
+ }
+
+ if (container.Splines.Count > 1) {
+ EditorGUILayout.HelpBox(
+ "This first wire-rail slice uses the first spline only. Remove additional splines "
+ + "from the container before authoring wire layouts.", MessageType.Warning);
+ }
+
+ EditorGUILayout.Space(8f);
+ _showRenderGeometry = EditorGUILayout.BeginFoldoutHeaderGroup(
+ _showRenderGeometry, "Render Geometry");
+ if (_showRenderGeometry) {
+ DrawRenderGeometrySettings(component);
+ }
+ EditorGUILayout.EndFoldoutHeaderGroup();
+ EditorGUILayout.Space(4f);
+ _showBallChannelCollider = EditorGUILayout.BeginFoldoutHeaderGroup(
+ _showBallChannelCollider, "Ball Channel Collider");
+ if (_showBallChannelCollider) {
+ DrawBallChannelColliderSettings(component);
+ }
+ EditorGUILayout.EndFoldoutHeaderGroup();
+ if (component.ColliderGeometryDirty) {
+ EditorGUILayout.HelpBox(
+ "Collider validation is pending. Expand Ball Channel Collider or enable its "
+ + "Scene preview to validate the current rail geometry.", MessageType.Info);
+ } else if (!string.IsNullOrEmpty(component.GenerationError)) {
+ EditorGUILayout.HelpBox(component.GenerationError, MessageType.Error);
+ }
+
+ EditorGUILayout.Space(4f);
+ _showWireLayouts = EditorGUILayout.BeginFoldoutHeaderGroup(
+ _showWireLayouts, "Wire Layouts");
+ if (_showWireLayouts) {
+ DrawWireLayouts(component);
+ }
+ EditorGUILayout.EndFoldoutHeaderGroup();
+
+ EditorGUILayout.Space(4f);
+ _showFixtures = EditorGUILayout.BeginFoldoutHeaderGroup(_showFixtures, "Fixtures");
+ if (_showFixtures) {
+ DrawFixtures(component);
+ }
+ EditorGUILayout.EndFoldoutHeaderGroup();
+ }
+
+ private void DrawWireLayouts(WireRailComponent component)
+ {
+ EditorGUILayout.LabelField(
+ "Layouts are positioned by distance along the route and are independent from spline knots.",
+ EditorStyles.wordWrappedMiniLabel);
+ if (component.Segments.Count == 0) {
+ EditorGUILayout.HelpBox("Add at least two spline knots to create a wire layout.",
+ MessageType.Warning);
+ return;
+ }
+ SynchronizeLayoutOrder(_layoutOrder, component);
+ if (Event.current.type == EventType.MouseDown) {
+ _layoutSelectionBeforePointerDown = GetSelectedLayoutIndex(component);
+ }
+ _layoutOrderList.DoLayoutList();
+ var selectedLayoutIndex = GetSelectedLayoutIndex(component);
+ var buttonContent = selectedLayoutIndex >= 0
+ ? new GUIContent($"Duplicate Layout {component.GetLayoutDisplayIndex(selectedLayoutIndex) + 1}",
+ "Duplicate the selected layout halfway to the next layout, or halfway to the end of the route.")
+ : new GUIContent("Add Wire Layout",
+ "Add a new layout last in the list and midway between the last two physical positions.");
+ using (new EditorGUILayout.HorizontalScope()) {
+ if (GUILayout.Button(buttonContent)) {
+ AddOrDuplicateLayout(component, selectedLayoutIndex);
+ GUIUtility.ExitGUI();
+ }
+ using (new EditorGUI.DisabledScope(selectedLayoutIndex < 0)) {
+ if (GUILayout.Button(new GUIContent("Deselect",
+ "Clear the selected layout so the Add action is available."),
+ GUILayout.Width(72f))) {
+ DeselectLayout();
+ GUIUtility.ExitGUI();
+ }
+ }
+ }
+ }
+
+ private void DrawFixtures(WireRailComponent component)
+ {
+ EditorGUILayout.LabelField(
+ "Supports are positioned by distance along the complete spline; end fittings "
+ + "attach to its start or end. Both are independent from wire layouts.",
+ EditorStyles.wordWrappedMiniLabel);
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count);
+ _fixtureOrderList.DoLayoutList();
+ var splineLength = component.SplineLength;
+ var spline = component.SplineContainer
+ ? component.SplineContainer.Spline : null;
+ var canAddSupport = splineLength > 0f;
+ var canAddRailPairSupport = canAddSupport && component.RailCount >= 2;
+ var isOpenRoute = canAddSupport && spline != null && !spline.Closed;
+ var canAddRailPairFitting = isOpenRoute && component.RailCount >= 2;
+
+ EditorGUILayout.LabelField("Supports", EditorStyles.miniBoldLabel);
+ DrawAddFixtureButtons(component, new[] {
+ new AddFixtureButton("Ring", Icons.WireRailRing,
+ "A ring around the wire bundle, fitted to the wires at its position.",
+ canAddSupport, "Add Wire Rail Ring",
+ () => component.AddRingFixture(splineLength * 0.5f)),
+ new AddFixtureButton("Cradle", Icons.WireRailCradle,
+ "A bottom wire with two angled arms that holds the rail from below.",
+ canAddSupport, "Add Wire Rail Cradle",
+ () => component.AddCradleFixture(splineLength * 0.5f)),
+ new AddFixtureButton("Rung", Icons.WireRailRung,
+ "A straight wire between the two bottom rails.",
+ canAddRailPairSupport, "Add Wire Rail Rung",
+ () => component.AddRungFixture(splineLength * 0.5f)),
+ new AddFixtureButton("Stand", Icons.WireRailStand,
+ "A leg from the bottom rails down to a U-hook foot on the playfield.",
+ canAddRailPairSupport, "Add Wire Rail Stand",
+ () => component.AddStandFixture(splineLength * 0.5f)),
+ });
+
+ EditorGUILayout.LabelField("End Fittings", EditorStyles.miniBoldLabel);
+ DrawAddFixtureButtons(component, new[] {
+ new AddFixtureButton("Hairpin", Icons.WireRailHairpin,
+ "Join two rails at an endpoint with a terminal loop.",
+ canAddRailPairFitting, "Add Wire Rail Hairpin",
+ () => component.AddHairpinFixture()),
+ new AddFixtureButton("Elbow", Icons.WireRailElbow,
+ "Bend two endpoint rails vertically down toward a hole.",
+ canAddRailPairFitting, "Add Wire Rail Elbow",
+ () => component.AddElbowFixture()),
+ new AddFixtureButton("Rail Trim", Icons.WireRailTrim,
+ "Independently move each rail start or end inward along the route.",
+ isOpenRoute, "Add Wire Rail Trim",
+ () => component.AddRailTrimFixture()),
+ });
+ }
+
+ private readonly struct AddFixtureButton
+ {
+ public readonly string Label;
+ public readonly Texture2D Icon;
+ public readonly string Tooltip;
+ public readonly bool Enabled;
+ public readonly string UndoName;
+ public readonly Action Add;
+
+ public AddFixtureButton(string label, Texture2D icon, string tooltip, bool enabled,
+ string undoName, Action add)
+ {
+ Label = label;
+ Icon = icon;
+ Tooltip = tooltip;
+ Enabled = enabled;
+ UndoName = undoName;
+ Add = add;
+ }
+ }
+
+ private const float AddFixtureButtonWidth = 108f;
+ private const float AddFixtureButtonHeight = 124f;
+ private const float AddFixtureButtonSpacing = 4f;
+ private const float AddFixtureButtonMargins = 40f;
+ private static GUIStyle _addFixtureButtonStyle;
+
+ private static GUIStyle AddFixtureButtonStyle => _addFixtureButtonStyle ??= new GUIStyle(GUI.skin.button) {
+ alignment = TextAnchor.LowerCenter,
+ imagePosition = ImagePosition.ImageAbove,
+ padding = new RectOffset(6, 6, 6, 6),
+ };
+
+ ///
+ /// Draws fixed-size icon buttons, wrapping into as many rows as the inspector width needs.
+ ///
+ private void DrawAddFixtureButtons(WireRailComponent component,
+ IReadOnlyList buttons)
+ {
+ var availableWidth = EditorGUIUtility.currentViewWidth - AddFixtureButtonMargins;
+ var perRow = math.max(1, (int)math.floor((availableWidth + AddFixtureButtonSpacing)
+ / (AddFixtureButtonWidth + AddFixtureButtonSpacing)));
+ for (var rowStart = 0; rowStart < buttons.Count; rowStart += perRow) {
+ using (new EditorGUILayout.HorizontalScope()) {
+ var rowEnd = math.min(buttons.Count, rowStart + perRow);
+ for (var index = rowStart; index < rowEnd; index++) {
+ var button = buttons[index];
+ bool clicked;
+ using (new EditorGUI.DisabledScope(!button.Enabled)) {
+ clicked = GUILayout.Button(
+ new GUIContent(button.Label, button.Icon, button.Tooltip),
+ AddFixtureButtonStyle,
+ GUILayout.Width(AddFixtureButtonWidth),
+ GUILayout.Height(AddFixtureButtonHeight));
+ }
+ if (clicked) {
+ Edit(component, button.UndoName, button.Add);
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ }
+ }
+ GUILayout.FlexibleSpace();
+ }
+ }
+ }
+
+ ///
+ /// Frames each list element with a bordered box and a header band, so it is obvious
+ /// where one layout or fixture ends and the next begins. The selected element keeps
+ /// Unity's selection tint underneath.
+ ///
+ private static void DrawListElementFrame(Rect rect, int index, bool active, bool focused)
+ {
+ if (Event.current.type != EventType.Repaint) {
+ return;
+ }
+ rect.y -= 2f;
+ rect.height += 2f;
+ var frame = new Rect(rect.x + 1f, rect.y + 1f, rect.width - 2f - FrameRightInset,
+ rect.height - 4f);
+ var pro = EditorGUIUtility.isProSkin;
+ if (active) {
+ ReorderableList.defaultBehaviours.DrawElementBackground(frame, index, true,
+ focused, true);
+ } else {
+ EditorGUI.DrawRect(frame,
+ pro ? new Color(1f, 1f, 1f, 0.035f) : new Color(0f, 0f, 0f, 0.035f));
+ }
+ var header = new Rect(frame.x, frame.y, frame.width, LayoutPadding + LayoutLineHeight);
+ EditorGUI.DrawRect(header,
+ pro ? new Color(1f, 1f, 1f, 0.06f) : new Color(0f, 0f, 0f, 0.06f));
+ var border = pro ? new Color(0f, 0f, 0f, 0.6f) : new Color(0f, 0f, 0f, 0.3f);
+ EditorGUI.DrawRect(new Rect(frame.x, frame.y, frame.width, 1f), border);
+ EditorGUI.DrawRect(new Rect(frame.x, frame.yMax - 1f, frame.width, 1f), border);
+ EditorGUI.DrawRect(new Rect(frame.x, frame.y, 1f, frame.height), border);
+ EditorGUI.DrawRect(new Rect(frame.xMax - 1f, frame.y, 1f, frame.height), border);
+ }
+
+ private ReorderableList CreateFixtureOrderList()
+ {
+ var list = new ReorderableList(_fixtureOrder, typeof(int), true, false, false, false) {
+ headerHeight = 0f,
+ footerHeight = 0f,
+ };
+ list.drawElementBackgroundCallback = DrawListElementFrame;
+ list.elementHeightCallback = index => {
+ if (target is not WireRailComponent component || index >= _fixtureOrder.Count) {
+ return LayoutLineHeight;
+ }
+ var fixtureIndex = _fixtureOrder[index];
+ return fixtureIndex >= 0 && fixtureIndex < component.Fixtures.Count
+ ? GetFixtureElementHeight(component, component.Fixtures[fixtureIndex])
+ : LayoutLineHeight;
+ };
+ list.drawElementCallback = (rect, index, _, _) => {
+ if (target is not WireRailComponent component || index >= _fixtureOrder.Count) {
+ return;
+ }
+ var fixtureIndex = _fixtureOrder[index];
+ if (fixtureIndex < 0 || fixtureIndex >= component.Fixtures.Count) {
+ return;
+ }
+ DrawFixtureElement(rect, component, fixtureIndex);
+ };
+ list.onReorderCallbackWithDetails = (_, fromIndex, toIndex) => {
+ if (target is WireRailComponent component) {
+ Edit(component, "Reorder Wire Rail Fixtures",
+ () => component.MoveFixture(fromIndex, toIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ }
+ };
+ return list;
+ }
+
+ private ReorderableList CreateLayoutOrderList()
+ {
+ var list = new ReorderableList(_layoutOrder, typeof(int), true, false, false, false) {
+ headerHeight = 0f,
+ footerHeight = 0f,
+ };
+ list.drawElementBackgroundCallback = DrawListElementFrame;
+ list.elementHeightCallback = index => {
+ if (target is not WireRailComponent component || index >= _layoutOrder.Count) {
+ return LayoutLineHeight;
+ }
+ return GetLayoutElementHeight(component, _layoutOrder[index]);
+ };
+ list.drawElementCallback = (rect, index, _, _) => {
+ if (target is not WireRailComponent component || index >= _layoutOrder.Count) {
+ return;
+ }
+ var layoutIndex = _layoutOrder[index];
+ if (layoutIndex < 0 || layoutIndex >= component.Segments.Count) {
+ return;
+ }
+ DrawLayoutElement(rect, component, layoutIndex);
+ };
+ list.onReorderCallbackWithDetails = (_, fromIndex, toIndex) => {
+ if (target is WireRailComponent component) {
+ Edit(component, "Reorder Wire Rail Layouts",
+ () => component.MoveLayout(fromIndex, toIndex));
+ SynchronizeLayoutOrder(_layoutOrder, component, true);
+ if (toIndex >= 0 && toIndex < _layoutOrder.Count) {
+ SelectLayout(component, _layoutOrder[toIndex]);
+ }
+ }
+ };
+ list.onSelectCallback = _ => {
+ if (target is WireRailComponent component) {
+ var layoutIndex = GetSelectedLayoutIndex(component);
+ if (layoutIndex >= 0) {
+ SelectLayout(component, layoutIndex);
+ }
+ }
+ };
+ return list;
+ }
+
+ private static void SynchronizeLayoutOrder(List order,
+ WireRailComponent component, bool force = false)
+ {
+ var displayOrder = component.LayoutDisplayOrder;
+ var matches = !force && order.Count == displayOrder.Count;
+ if (matches) {
+ for (var index = 0; index < order.Count; index++) {
+ if (order[index] != displayOrder[index]) {
+ matches = false;
+ break;
+ }
+ }
+ }
+ if (matches) {
+ return;
+ }
+ order.Clear();
+ for (var index = 0; index < displayOrder.Count; index++) {
+ order.Add(displayOrder[index]);
+ }
+ }
+
+ private static void SynchronizeOrder(List order, int count, bool force = false)
+ {
+ if (!force && order.Count == count) {
+ return;
+ }
+ order.Clear();
+ for (var index = 0; index < count; index++) {
+ order.Add(index);
+ }
+ }
+
+ private static float GetFixtureElementHeight(WireRailComponent component,
+ WireRailFixture fixture)
+ {
+ var componentRailCount = component.RailCount;
+ return fixture switch {
+ WireRailRingFixture => LayoutPadding * 2f + LayoutLineHeight * 8f
+ + WireRailRingPreviewEditor.Height + 25f + SolderRowHeight,
+ WireRailCradleFixture => LayoutPadding * 2f + LayoutLineHeight * 10f
+ + WireRailCradlePreviewEditor.Height + 35f + SolderRowHeight,
+ WireRailRungFixture => LayoutPadding * 2f + LayoutLineHeight * 5f
+ + WireRailRungPreviewEditor.Height + 25f + SolderRowHeight,
+ WireRailStandFixture => LayoutPadding * 2f + LayoutLineHeight * 16f
+ + (GetVector3FieldHeight() - LayoutLineHeight) * 3f
+ + WireRailStandPreviewEditor.Height + 55f + SolderRowHeight,
+ // Hairpin and Drop are endpoint fittings that WireRailSolderMeshGenerator
+ // never solders, so they carry no solder-threshold row.
+ WireRailHairpinFixture hairpin => LayoutPadding * 2f
+ + (LayoutLineHeight + 3f) * 10f + WireRailHairpinPreviewEditor.Height + 6f
+ + HelpBoxHeightFor(GetHairpinMessage(component, hairpin, hairpin.Endpoint,
+ hairpin.FirstRailIndex, hairpin.SecondRailIndex, hairpin.RailOffset, out _)),
+ WireRailElbowFixture elbow => LayoutPadding * 2f
+ + (LayoutLineHeight + 3f) * (componentRailCount + 8)
+ + WireRailElbowPreviewEditor.Height + 6f
+ + HelpBoxHeightFor(GetElbowMessage(component, elbow, elbow.Endpoint,
+ elbow.FirstRailIndex, elbow.SecondRailIndex, elbow.Offset, out _)),
+ WireRailTrimFixture => LayoutPadding * 2f
+ + (LayoutLineHeight + 3f) * (componentRailCount + 2)
+ + HelpBoxHeightFor(GetRailTrimMessage(component, out _)),
+ _ => LayoutLineHeight * 2f,
+ };
+ }
+
+ private void DrawFixtureElement(Rect rect, WireRailComponent component,
+ int fixtureIndex)
+ {
+ rect.y -= 1f;
+ rect.height -= 1f;
+ var content = new Rect(rect.x + LayoutPadding, rect.y + LayoutPadding,
+ rect.width - LayoutPadding * 2f - ElementRightInset, rect.height - LayoutPadding * 2f);
+
+ // Per-fixture "Enabled" toggle, drawn in the header row to the left of the
+ // duplicate/trash icons so it applies uniformly to every fixture type. Disabling
+ // only hides the fixture from the render mesh; colliders are unaffected.
+ var enabledRect = new Rect(content.xMax - LayoutLineHeight * 2f - 6f - 72f,
+ content.y - 2f, 72f, LayoutLineHeight);
+ EditorGUI.BeginChangeCheck();
+ var fixtureEnabled = EditorGUI.ToggleLeft(enabledRect,
+ new GUIContent("Enabled",
+ "Uncheck to hide this fixture from rendering. Colliders are unaffected."),
+ component.Fixtures[fixtureIndex].Enabled);
+ if (EditorGUI.EndChangeCheck()) {
+ Edit(component, "Toggle Wire Rail Fixture",
+ () => component.SetFixtureEnabled(fixtureIndex, fixtureEnabled));
+ }
+
+ if (component.Fixtures[fixtureIndex] is WireRailRingFixture ring) {
+ DrawRingFixtureElement(content, component, fixtureIndex, ring);
+ } else if (component.Fixtures[fixtureIndex] is WireRailCradleFixture cradle) {
+ DrawCradleFixtureElement(content, component, fixtureIndex, cradle);
+ } else if (component.Fixtures[fixtureIndex] is WireRailRungFixture rung) {
+ DrawRungFixtureElement(content, component, fixtureIndex, rung);
+ } else if (component.Fixtures[fixtureIndex] is WireRailStandFixture leg) {
+ DrawStandFixtureElement(content, component, fixtureIndex, leg);
+ } else if (component.Fixtures[fixtureIndex] is WireRailHairpinFixture hairpin) {
+ DrawHairpinFixtureElement(content, component, fixtureIndex, hairpin);
+ } else if (component.Fixtures[fixtureIndex] is WireRailElbowFixture elbow) {
+ DrawElbowFixtureElement(content, component, fixtureIndex, elbow);
+ } else if (component.Fixtures[fixtureIndex] is WireRailTrimFixture railTrim) {
+ DrawRailTrimFixtureElement(content, component, fixtureIndex, railTrim);
+ return;
+ } else {
+ EditorGUI.HelpBox(content, $"Fixture {fixtureIndex + 1} has an unsupported type.",
+ MessageType.Warning);
+ return;
+ }
+
+ // Endpoint fittings (Drop, Hairpin) are never soldered, so they get no row.
+ if (component.Fixtures[fixtureIndex] is WireRailElbowFixture
+ or WireRailHairpinFixture) {
+ return;
+ }
+ // Rings keep their Apply to All button as the very last row, so their solder
+ // line moves up by one row.
+ var solderBottomInset = component.Fixtures[fixtureIndex] is WireRailRingFixture
+ ? LayoutLineHeight + 3f : 0f;
+ DrawSolderRow(new Rect(content.x,
+ content.yMax - SolderRowsContentHeight - solderBottomInset,
+ content.width, LayoutLineHeight), component, fixtureIndex);
+ }
+
+ ///
+ /// One line: a "Solder" caption, then the threshold and the size fields.
+ ///
+ private static void DrawSolderRow(Rect rect, WireRailComponent component,
+ int fixtureIndex)
+ {
+ const float captionWidth = 52f;
+ const float spacing = 8f;
+ var fixture = component.Fixtures[fixtureIndex];
+ EditorGUI.LabelField(new Rect(rect.x, rect.y, captionWidth, rect.height),
+ new GUIContent("Solder:", "Blobs where this fixture touches a rail."));
+ var fieldsWidth = rect.width - captionWidth - spacing;
+ var sizeRect = new Rect(rect.x + captionWidth, rect.y,
+ fieldsWidth * 0.44f, rect.height);
+ var thresholdRect = new Rect(sizeRect.xMax + spacing, rect.y,
+ fieldsWidth * 0.56f - spacing, rect.height);
+ var previousLabelWidth = EditorGUIUtility.labelWidth;
+
+ EditorGUIUtility.labelWidth = 64f;
+ EditorGUI.BeginChangeCheck();
+ var solderThreshold = math.max(0f, EditorGUI.DelayedFloatField(thresholdRect,
+ new GUIContent("Threshold",
+ "Maximum surface gap in VPX units at which this fixture is soldered to a rail."),
+ fixture.SolderThreshold));
+ if (EditorGUI.EndChangeCheck()) {
+ Edit(component, "Edit Wire Rail Solder Threshold", () =>
+ component.SetFixtureSolderThreshold(fixtureIndex, solderThreshold));
+ }
+
+ EditorGUIUtility.labelWidth = 32f;
+ EditorGUI.BeginChangeCheck();
+ var solderSize = math.max(0.01f, EditorGUI.DelayedFloatField(sizeRect,
+ new GUIContent("Size",
+ "Uniform scale of this fixture's solder blobs. 1 is the default size; "
+ + "doubling it produces eight times the volume."),
+ fixture.SolderSize));
+ if (EditorGUI.EndChangeCheck()) {
+ Edit(component, "Edit Wire Rail Solder Size", () =>
+ component.SetFixtureSolderSize(fixtureIndex, solderSize));
+ }
+ EditorGUIUtility.labelWidth = previousLabelWidth;
+ }
+
+ private void DrawHairpinFixtureElement(Rect content, WireRailComponent component,
+ int fixtureIndex, WireRailHairpinFixture hairpin)
+ {
+ var row = new Rect(content.x, content.y - 2f, content.width, LayoutLineHeight);
+ EditorGUI.LabelField(row, $"Hairpin {fixtureIndex + 1}", EditorStyles.boldLabel);
+ var trashRect = new Rect(row.xMax - LayoutLineHeight, row.y, LayoutLineHeight,
+ LayoutLineHeight);
+ var duplicateRect = new Rect(trashRect.x - LayoutLineHeight - 2f, row.y,
+ LayoutLineHeight, LayoutLineHeight);
+ var duplicate = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Duplicate")) {
+ tooltip = "Duplicate this hairpin",
+ };
+ var trash = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Trash")) {
+ tooltip = "Remove this hairpin",
+ };
+ EditorGUIUtility.AddCursorRect(duplicateRect, MouseCursor.Link);
+ EditorGUIUtility.AddCursorRect(trashRect, MouseCursor.Link);
+ if (GUI.Button(duplicateRect, duplicate, GUIStyle.none)) {
+ Edit(component, "Duplicate Wire Rail Hairpin",
+ () => component.DuplicateHairpinFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+ if (GUI.Button(trashRect, trash, GUIStyle.none)) {
+ Edit(component, "Remove Wire Rail Hairpin",
+ () => component.RemoveFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+
+ EditorGUI.BeginChangeCheck();
+ row.y = content.y + LayoutLineHeight + 3f;
+ var endpoint = (WireRailEndpoint)EditorGUI.EnumPopup(row,
+ new GUIContent("Endpoint", "Spline end where the fitting is attached."),
+ hairpin.Endpoint);
+
+ var railNames = RailOptions[component.RailCount];
+ row.y += LayoutLineHeight + 3f;
+ var firstRailIndex = EditorGUI.Popup(row,
+ new GUIContent("Rail A", "First attached rail."),
+ math.clamp(hairpin.FirstRailIndex, 0, component.RailCount - 1), railNames);
+
+ row.y += LayoutLineHeight + 3f;
+ var secondRailIndex = EditorGUI.Popup(row,
+ new GUIContent("Rail B", "Second attached rail."),
+ math.clamp(hairpin.SecondRailIndex, 0, component.RailCount - 1), railNames);
+
+ row.y += LayoutLineHeight + 3f;
+ var ringDensity = EditorGUI.IntSlider(row, new GUIContent("Ring Density",
+ "Longitudinal sampling density around the complete circular loop."),
+ hairpin.RingDensity, 4, 128);
+
+ row.y += LayoutLineHeight + 3f;
+ var railOffset = math.max(0f, EditorGUI.FloatField(row, new GUIContent("Offset",
+ "How far the loop sits back from the endpoint along the rails. The two connected "
+ + "rails shorten to follow it, so the loop stays attached."),
+ hairpin.RailOffset));
+
+ row.y += LayoutLineHeight + 3f;
+ var loopDiameter = EditorGUI.DelayedFloatField(row,
+ new GUIContent("Loop Diameter",
+ "Centerline diameter of the terminal semicircle."),
+ hairpin.LoopDiameter);
+
+ row.y += LayoutLineHeight + 3f;
+ var leadLength = EditorGUI.DelayedFloatField(row,
+ new GUIContent("Lead Length",
+ "Distance the loop center extends beyond the spline endpoint."),
+ hairpin.LeadLength);
+
+ row.y += LayoutLineHeight + 3f;
+ var tangentLength = EditorGUI.DelayedFloatField(row,
+ new GUIContent("Tangent Length",
+ "Bezier handle length used to blend each rail into the loop."),
+ hairpin.TangentLength);
+
+ row.y += LayoutLineHeight + 3f;
+ var rotation = EditorGUI.Slider(row, new GUIContent("Rotation",
+ "Rotate the loop diameter around the spline tangent."),
+ hairpin.Rotation, 0f, 360f);
+
+ row.y += LayoutLineHeight + 3f;
+ var loopPreviewRect = new Rect(content.x, row.y, content.width,
+ WireRailHairpinPreviewEditor.Height);
+ _hairpinPreviewEditor.Draw(loopPreviewRect, component, fixtureIndex, hairpin);
+ row.y = loopPreviewRect.yMax + 6f;
+ var message = GetHairpinMessage(component, hairpin, endpoint, firstRailIndex,
+ secondRailIndex, railOffset, out var warning);
+ EditorGUI.HelpBox(new Rect(row.x, row.y, row.width, HelpBoxHeightFor(message)),
+ message, warning ? MessageType.Warning : MessageType.Info);
+
+ if (EditorGUI.EndChangeCheck()) {
+ Edit(component, "Edit Wire Rail Hairpin", () =>
+ component.SetHairpinFixtureProperties(fixtureIndex, endpoint,
+ firstRailIndex, secondRailIndex, loopDiameter, leadLength,
+ tangentLength, ringDensity, railOffset, rotation));
+ }
+ }
+
+ private void DrawElbowFixtureElement(Rect content, WireRailComponent component,
+ int fixtureIndex, WireRailElbowFixture elbow)
+ {
+ var row = new Rect(content.x, content.y - 2f, content.width, LayoutLineHeight);
+ EditorGUI.LabelField(row, $"Elbow {fixtureIndex + 1}", EditorStyles.boldLabel);
+ var trashRect = new Rect(row.xMax - LayoutLineHeight, row.y, LayoutLineHeight,
+ LayoutLineHeight);
+ var duplicateRect = new Rect(trashRect.x - LayoutLineHeight - 2f, row.y,
+ LayoutLineHeight, LayoutLineHeight);
+ var duplicate = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Duplicate")) {
+ tooltip = "Duplicate this elbow",
+ };
+ var trash = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Trash")) {
+ tooltip = "Remove this elbow",
+ };
+ EditorGUIUtility.AddCursorRect(duplicateRect, MouseCursor.Link);
+ EditorGUIUtility.AddCursorRect(trashRect, MouseCursor.Link);
+ if (GUI.Button(duplicateRect, duplicate, GUIStyle.none)) {
+ Edit(component, "Duplicate Wire Rail Elbow",
+ () => component.DuplicateElbowFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+ if (GUI.Button(trashRect, trash, GUIStyle.none)) {
+ Edit(component, "Remove Wire Rail Elbow",
+ () => component.RemoveFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+
+ EditorGUI.BeginChangeCheck();
+ row.y = content.y + LayoutLineHeight + 3f;
+ var endpoint = (WireRailEndpoint)EditorGUI.EnumPopup(row,
+ new GUIContent("Endpoint", "Spline end where the fitting is attached."),
+ elbow.Endpoint);
+
+ var railNames = RailOptions[component.RailCount];
+ row.y += LayoutLineHeight + 3f;
+ var firstRailIndex = EditorGUI.Popup(row,
+ new GUIContent("Rail A", "First rail that continues into the hole."),
+ math.clamp(elbow.FirstRailIndex, 0, component.RailCount - 1), railNames);
+
+ row.y += LayoutLineHeight + 3f;
+ var secondRailIndex = EditorGUI.Popup(row,
+ new GUIContent("Rail B", "Second rail that continues into the hole."),
+ math.clamp(elbow.SecondRailIndex, 0, component.RailCount - 1), railNames);
+
+ row.y += LayoutLineHeight + 3f;
+ var offset = math.max(0f, EditorGUI.DelayedFloatField(row,
+ new GUIContent("Offset",
+ "Moves the elbow inward from the spline endpoint, shortening the two rails. "
+ + "Zero bends at the endpoint."),
+ elbow.Offset));
+
+ row.y += LayoutLineHeight + 3f;
+ var dropLength = math.max(0.1f, EditorGUI.DelayedFloatField(row,
+ new GUIContent("Drop Length",
+ "Vertical length from the bend down into the hole."),
+ elbow.DropLength));
+
+ row.y += LayoutLineHeight + 3f;
+ var zAngle = EditorGUI.DelayedFloatField(row, new GUIContent("Z Angle",
+ "Rotate the horizontal leg around the route-local vertical axis."),
+ elbow.ZAngle);
+
+ row.y += LayoutLineHeight + 3f;
+ EditorGUI.LabelField(row, "Other Rail Cutoffs", EditorStyles.boldLabel);
+ for (var railIndex = 0; railIndex < component.RailCount; railIndex++) {
+ row.y += LayoutLineHeight + 3f;
+ var attached = railIndex == firstRailIndex || railIndex == secondRailIndex;
+ var currentOffset = railIndex < elbow.RailCount
+ ? elbow.GetRailOffset(railIndex) : 0f;
+ using (new EditorGUI.DisabledScope(attached)) {
+ var edited = EditorGUI.DelayedFloatField(row,
+ new GUIContent($"Rail {railIndex + 1}" + (attached
+ ? " (attached)" : string.Empty),
+ attached
+ ? "Attached rails continue into the elbow and are not cut off."
+ : "Distance measured inward from the selected endpoint."),
+ attached ? 0f : currentOffset);
+ _railTrimOffsets[railIndex] = attached ? 0f : math.max(0f, edited);
+ }
+ }
+
+ row.y += LayoutLineHeight + 3f;
+ var dropPreviewRect = new Rect(content.x, row.y, content.width,
+ WireRailElbowPreviewEditor.Height);
+ _elbowPreviewEditor.Draw(dropPreviewRect, component, fixtureIndex, elbow);
+ row.y = dropPreviewRect.yMax + 6f;
+ var message = GetElbowMessage(component, elbow, endpoint, firstRailIndex,
+ secondRailIndex, offset, out var warning);
+ EditorGUI.HelpBox(new Rect(row.x, row.y, row.width, HelpBoxHeightFor(message)),
+ message, warning ? MessageType.Warning : MessageType.Info);
+
+ if (EditorGUI.EndChangeCheck()) {
+ Edit(component, "Edit Wire Rail Elbow", () =>
+ component.SetElbowFixtureProperties(fixtureIndex, endpoint,
+ firstRailIndex, secondRailIndex, offset, dropLength,
+ zAngle, _railTrimOffsets));
+ }
+ }
+
+ private void DrawRailTrimFixtureElement(Rect content, WireRailComponent component,
+ int fixtureIndex, WireRailTrimFixture railTrim)
+ {
+ var row = new Rect(content.x, content.y - 2f, content.width, LayoutLineHeight);
+ EditorGUI.LabelField(row, $"Rail Trim {fixtureIndex + 1}",
+ EditorStyles.boldLabel);
+ var trashRect = new Rect(row.xMax - LayoutLineHeight, row.y, LayoutLineHeight,
+ LayoutLineHeight);
+ var duplicateRect = new Rect(trashRect.x - LayoutLineHeight - 2f, row.y,
+ LayoutLineHeight, LayoutLineHeight);
+ var duplicate = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Duplicate")) {
+ tooltip = "Duplicate this rail trim",
+ };
+ var trash = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Trash")) {
+ tooltip = "Remove this rail trim",
+ };
+ EditorGUIUtility.AddCursorRect(duplicateRect, MouseCursor.Link);
+ EditorGUIUtility.AddCursorRect(trashRect, MouseCursor.Link);
+ if (GUI.Button(duplicateRect, duplicate, GUIStyle.none)) {
+ Edit(component, "Duplicate Wire Rail Trim",
+ () => component.DuplicateRailTrimFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+ if (GUI.Button(trashRect, trash, GUIStyle.none)) {
+ Edit(component, "Remove Wire Rail Trim",
+ () => component.RemoveFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+
+ EditorGUI.BeginChangeCheck();
+ row.y = content.y + LayoutLineHeight + 3f;
+ var endpoint = (WireRailEndpoint)EditorGUI.EnumPopup(row,
+ new GUIContent("Endpoint", "Spline end whose rails are shortened."),
+ railTrim.Endpoint);
+ for (var railIndex = 0; railIndex < component.RailCount; railIndex++) {
+ row.y += LayoutLineHeight + 3f;
+ var currentOffset = railIndex < railTrim.RailCount
+ ? railTrim.GetRailOffset(railIndex) : 0f;
+ _railTrimOffsets[railIndex] = math.max(0f, EditorGUI.DelayedFloatField(row,
+ new GUIContent($"Rail {railIndex + 1}",
+ "Distance measured inward from the selected spline endpoint."),
+ currentOffset));
+ }
+
+ row.y += LayoutLineHeight + 3f;
+ var message = GetRailTrimMessage(component, out var warning);
+ EditorGUI.HelpBox(new Rect(row.x, row.y, row.width, HelpBoxHeightFor(message)),
+ message, warning ? MessageType.Warning : MessageType.Info);
+
+ if (EditorGUI.EndChangeCheck()) {
+ Edit(component, "Edit Wire Rail Trim", () =>
+ component.SetRailTrimFixtureProperties(fixtureIndex, endpoint,
+ _railTrimOffsets));
+ }
+ }
+
+ private void DrawCradleFixtureElement(Rect content, WireRailComponent component,
+ int fixtureIndex, WireRailCradleFixture cradle)
+ {
+ var row = new Rect(content.x, content.y - 2f, content.width, LayoutLineHeight);
+ EditorGUI.LabelField(row, $"Cradle {fixtureIndex + 1}",
+ EditorStyles.boldLabel);
+ var trashRect = new Rect(row.xMax - LayoutLineHeight, row.y, LayoutLineHeight,
+ LayoutLineHeight);
+ var duplicateRect = new Rect(trashRect.x - LayoutLineHeight - 2f, row.y,
+ LayoutLineHeight, LayoutLineHeight);
+ var duplicate = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Duplicate")) {
+ tooltip = "Duplicate this cradle",
+ };
+ var trash = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Trash")) {
+ tooltip = "Remove this cradle",
+ };
+ EditorGUIUtility.AddCursorRect(duplicateRect, MouseCursor.Link);
+ EditorGUIUtility.AddCursorRect(trashRect, MouseCursor.Link);
+ if (GUI.Button(duplicateRect, duplicate, GUIStyle.none)) {
+ Edit(component, "Duplicate Wire Rail Cradle",
+ () => component.DuplicateCradleFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+ if (GUI.Button(trashRect, trash, GUIStyle.none)) {
+ Edit(component, "Remove Wire Rail Cradle",
+ () => component.RemoveFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+
+ EditorGUI.BeginChangeCheck();
+ row.y = content.y + LayoutLineHeight + 3f;
+ var distance = EditorGUI.Slider(row, new GUIContent("Position",
+ "Distance along the complete spline in VPX units."), cradle.Distance,
+ 0f, math.max(0f, component.SplineLength));
+
+ row.y += LayoutLineHeight + 3f;
+ var ringDensity = EditorGUI.IntSlider(row, new GUIContent("Ring Density",
+ "Minimum sampling density for the rounded arm corners. "
+ + "A 15° safety limit adds rings when needed to preserve wire thickness."),
+ cradle.RingDensity, 3, 128);
+
+ row.y += LayoutLineHeight + 4f;
+ var previewRect = new Rect(content.x, row.y, content.width,
+ WireRailCradlePreviewEditor.Height);
+ _cradlePreviewEditor.Draw(previewRect, component, fixtureIndex, cradle);
+
+ row.y = previewRect.yMax + 4f;
+ var lateralOffset = cradle.LateralOffset;
+ var verticalOffset = cradle.VerticalOffset;
+ DrawFixtureOffsetRow(row, ref lateralOffset, ref verticalOffset,
+ out var resetOffset);
+ if (resetOffset) {
+ Edit(component, "Reset Wire Rail Cradle Offset", () =>
+ component.SetCradleFixtureProperties(fixtureIndex, cradle.Distance,
+ cradle.RingDensity, 0f, 0f, cradle.BottomLength,
+ cradle.LeftLength, cradle.RightLength,
+ cradle.Angle, cradle.Rotation, cradle.CornerRadius));
+ GUIUtility.ExitGUI();
+ }
+
+ row.y += LayoutLineHeight + 3f;
+ var bottomLength = EditorGUI.DelayedFloatField(row,
+ new GUIContent("Bottom Length",
+ "Length of the always-present straight bottom wire. Positive arms may "
+ + "raise the minimum enough to fit their rounded corners."),
+ cradle.BottomLength);
+
+ row.y += LayoutLineHeight + 3f;
+ var leftLength = EditorGUI.DelayedFloatField(row,
+ new GUIContent("Left Arm Length",
+ "Length from the left end of the bottom wire. Set to zero to omit it; "
+ + "positive values are clamped to fit the rounded corner."),
+ cradle.LeftLength);
+
+ row.y += LayoutLineHeight + 3f;
+ var rightLength = EditorGUI.DelayedFloatField(row,
+ new GUIContent("Right Arm Length",
+ "Length from the right end of the bottom wire. Set to zero to omit it; "
+ + "positive values are clamped to fit the rounded corner."),
+ cradle.RightLength);
+
+ row.y += LayoutLineHeight + 3f;
+ var angle = EditorGUI.Slider(row, new GUIContent("Arm Angle",
+ "Included angle between the left and right arm directions."),
+ cradle.Angle, 1f, 179f);
+
+ row.y += LayoutLineHeight + 3f;
+ var rotation = EditorGUI.Slider(row, new GUIContent("Rotation",
+ "Rotate the complete fixture around the spline tangent."),
+ cradle.Rotation, 0f, 360f);
+
+ row.y += LayoutLineHeight + 3f;
+ var cornerRadius = EditorGUI.FloatField(row, new GUIContent("Corner Radius",
+ "Requested centerline radius where each non-zero arm meets the bottom wire."),
+ cradle.CornerRadius);
+
+ if (EditorGUI.EndChangeCheck()) {
+ Edit(component, "Edit Wire Rail Cradle", () =>
+ component.SetCradleFixtureProperties(fixtureIndex, distance,
+ ringDensity, lateralOffset, verticalOffset, bottomLength,
+ leftLength, rightLength, angle, rotation,
+ cornerRadius));
+ }
+ }
+
+ private void DrawRingFixtureElement(Rect content, WireRailComponent component,
+ int fixtureIndex, WireRailRingFixture ring)
+ {
+ var row = new Rect(content.x, content.y - 2f, content.width, LayoutLineHeight);
+ EditorGUI.LabelField(row, $"Ring {fixtureIndex + 1}", EditorStyles.boldLabel);
+ var trashRect = new Rect(row.xMax - LayoutLineHeight, row.y, LayoutLineHeight,
+ LayoutLineHeight);
+ var duplicateRect = new Rect(trashRect.x - LayoutLineHeight - 2f, row.y,
+ LayoutLineHeight, LayoutLineHeight);
+ var duplicate = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Duplicate")) {
+ tooltip = "Duplicate this ring",
+ };
+ var trash = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Trash")) {
+ tooltip = "Remove this ring",
+ };
+ EditorGUIUtility.AddCursorRect(duplicateRect, MouseCursor.Link);
+ EditorGUIUtility.AddCursorRect(trashRect, MouseCursor.Link);
+ if (GUI.Button(duplicateRect, duplicate, GUIStyle.none)) {
+ Edit(component, "Duplicate Wire Rail Ring",
+ () => component.DuplicateRingFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+ if (GUI.Button(trashRect, trash, GUIStyle.none)) {
+ Edit(component, "Remove Wire Rail Ring",
+ () => component.RemoveFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+
+ EditorGUI.BeginChangeCheck();
+ row.y = content.y + LayoutLineHeight + 3f;
+ var distance = EditorGUI.Slider(row, new GUIContent("Position",
+ "Distance along the complete spline in VPX units."), ring.Distance,
+ 0f, math.max(0f, component.SplineLength));
+
+ row.y += LayoutLineHeight + 3f;
+ var scale = EditorGUI.Slider(row, new GUIContent("Scale",
+ "Multiplier for the automatically fitted ring radius."), ring.Scale,
+ FixtureScaleMinimum, FixtureScaleMaximum);
+
+ row.y += LayoutLineHeight + 3f;
+ var ringDensity = EditorGUI.IntSlider(row, new GUIContent("Ring Density",
+ "Number of tube segments around the complete ring."),
+ ring.RingDensity, 3, 128);
+
+ row.y += LayoutLineHeight + 4f;
+ var previewRect = new Rect(content.x, row.y, content.width,
+ WireRailRingPreviewEditor.Height);
+ _ringPreviewEditor.Draw(previewRect, component, fixtureIndex, ring);
+
+ row.y = previewRect.yMax + 4f;
+ var lateralOffset = ring.LateralOffset;
+ var verticalOffset = ring.VerticalOffset;
+ DrawFixtureOffsetRow(row, ref lateralOffset, ref verticalOffset,
+ out var resetOffset);
+ if (resetOffset) {
+ Edit(component, "Reset Wire Rail Ring Offset", () =>
+ component.SetRingFixtureProperties(fixtureIndex, ring.Distance,
+ ring.HasCutout, ring.CutoutStartAngle, ring.CutoutEndAngle,
+ ring.HasStraightSection, ring.StraightStartAngle,
+ ring.StraightEndAngle, 0f, 0f, ring.Scale,
+ ring.RingDensity));
+ GUIUtility.ExitGUI();
+ }
+
+ row.y += LayoutLineHeight + 3f;
+ var cutoutStart = ring.CutoutStartAngle;
+ var cutoutEnd = ring.CutoutEndAngle;
+ var hasCutout = DrawAngleRange(row, "Cutout",
+ "Remove the angular range from the ring.", ring.HasCutout,
+ ref cutoutStart, ref cutoutEnd);
+
+ row.y += LayoutLineHeight + 3f;
+ var straightStart = ring.StraightStartAngle;
+ var straightEnd = ring.StraightEndAngle;
+ var hasStraightSection = DrawAngleRange(row, "Straight Line",
+ "Replace the angular range with a straight chord.", ring.HasStraightSection,
+ ref straightStart, ref straightEnd);
+
+ var propertiesChanged = EditorGUI.EndChangeCheck();
+ // Last row of the panel; DrawFixtureElement puts the solder line above it.
+ row.y = content.yMax - LayoutLineHeight;
+ var hasOtherRing = HasOtherRingFixture(component, fixtureIndex);
+ var applyToAll = false;
+ using (new EditorGUI.DisabledScope(!hasOtherRing)) {
+ applyToAll = GUI.Button(row, new GUIContent("Apply to All",
+ "Copy every setting except Position from this ring to all other rings."));
+ }
+ if (applyToAll) {
+ Edit(component, "Apply Wire Rail Ring Settings to All", () => {
+ if (propertiesChanged) {
+ component.SetRingFixtureProperties(fixtureIndex, distance,
+ hasCutout, cutoutStart, cutoutEnd,
+ hasStraightSection, straightStart, straightEnd,
+ lateralOffset, verticalOffset, scale, ringDensity);
+ }
+ component.ApplyRingPropertiesToAll(fixtureIndex);
+ });
+ GUIUtility.ExitGUI();
+ }
+ if (propertiesChanged) {
+ Edit(component, "Edit Wire Rail Ring", () =>
+ component.SetRingFixtureProperties(fixtureIndex, distance,
+ hasCutout, cutoutStart, cutoutEnd,
+ hasStraightSection, straightStart, straightEnd,
+ lateralOffset, verticalOffset, scale, ringDensity));
+ }
+ }
+
+ private void DrawRungFixtureElement(Rect content, WireRailComponent component,
+ int fixtureIndex, WireRailRungFixture rung)
+ {
+ var row = new Rect(content.x, content.y - 2f, content.width, LayoutLineHeight);
+ EditorGUI.LabelField(row, $"Rung {fixtureIndex + 1}", EditorStyles.boldLabel);
+ var trashRect = new Rect(row.xMax - LayoutLineHeight, row.y, LayoutLineHeight,
+ LayoutLineHeight);
+ var duplicateRect = new Rect(trashRect.x - LayoutLineHeight - 2f, row.y,
+ LayoutLineHeight, LayoutLineHeight);
+ var duplicate = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Duplicate")) {
+ tooltip = "Duplicate this rung",
+ };
+ var trash = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Trash")) {
+ tooltip = "Remove this rung",
+ };
+ EditorGUIUtility.AddCursorRect(duplicateRect, MouseCursor.Link);
+ EditorGUIUtility.AddCursorRect(trashRect, MouseCursor.Link);
+ if (GUI.Button(duplicateRect, duplicate, GUIStyle.none)) {
+ Edit(component, "Duplicate Wire Rail Rung",
+ () => component.DuplicateRungFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+ if (GUI.Button(trashRect, trash, GUIStyle.none)) {
+ Edit(component, "Remove Wire Rail Rung",
+ () => component.RemoveFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+
+ EditorGUI.BeginChangeCheck();
+ row.y = content.y + LayoutLineHeight + 3f;
+ var distance = EditorGUI.Slider(row, new GUIContent("Position",
+ "Distance along the complete spline in VPX units."), rung.Distance,
+ 0f, math.max(0f, component.SplineLength));
+
+ row.y += LayoutLineHeight + 4f;
+ var previewRect = new Rect(content.x, row.y, content.width,
+ WireRailRungPreviewEditor.Height);
+ _rungPreviewEditor.Draw(previewRect, component, fixtureIndex, rung);
+
+ row.y = previewRect.yMax + 4f;
+ var lateralOffset = rung.LateralOffset;
+ var verticalOffset = rung.VerticalOffset;
+ DrawFixtureOffsetRow(row, ref lateralOffset, ref verticalOffset,
+ out var resetOffset);
+ if (resetOffset) {
+ Edit(component, "Reset Wire Rail Rung Offset", () =>
+ component.SetRungFixtureProperties(fixtureIndex, rung.Distance,
+ rung.Angle, 0f, 0f,
+ rung.LengthAdjustment));
+ GUIUtility.ExitGUI();
+ }
+
+ row.y += LayoutLineHeight + 3f;
+ var angle = EditorGUI.Slider(row, new GUIContent("Angle",
+ "Rotation around the spline tangent. 0° is horizontal along local X; "
+ + "90° is vertical along local Z."), rung.Angle, 0f, 360f);
+
+ row.y += LayoutLineHeight + 3f;
+ var lengthAdjustment = EditorGUI.FloatField(row, new GUIContent("Length",
+ "Signed VPX adjustment to the span between the bottom rails. Positive values "
+ + "extend the wire; negative values shorten it."), rung.LengthAdjustment);
+
+ if (EditorGUI.EndChangeCheck()) {
+ Edit(component, "Edit Wire Rail Rung", () =>
+ component.SetRungFixtureProperties(fixtureIndex, distance,
+ angle, lateralOffset, verticalOffset,
+ lengthAdjustment));
+ }
+ }
+
+ private void DrawStandFixtureElement(Rect content, WireRailComponent component,
+ int fixtureIndex, WireRailStandFixture leg)
+ {
+ var row = new Rect(content.x, content.y - 2f, content.width, LayoutLineHeight);
+ EditorGUI.LabelField(row, $"Stand {fixtureIndex + 1}", EditorStyles.boldLabel);
+ var trashRect = new Rect(row.xMax - LayoutLineHeight, row.y, LayoutLineHeight,
+ LayoutLineHeight);
+ var duplicateRect = new Rect(trashRect.x - LayoutLineHeight - 2f, row.y,
+ LayoutLineHeight, LayoutLineHeight);
+ var duplicate = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Duplicate")) {
+ tooltip = "Duplicate this stand",
+ };
+ var trash = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Trash")) {
+ tooltip = "Remove this stand",
+ };
+ EditorGUIUtility.AddCursorRect(duplicateRect, MouseCursor.Link);
+ EditorGUIUtility.AddCursorRect(trashRect, MouseCursor.Link);
+ if (GUI.Button(duplicateRect, duplicate, GUIStyle.none)) {
+ Edit(component, "Duplicate Wire Rail Stand",
+ () => component.DuplicateStandFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+ if (GUI.Button(trashRect, trash, GUIStyle.none)) {
+ Edit(component, "Remove Wire Rail Stand",
+ () => component.RemoveFixture(fixtureIndex));
+ SynchronizeOrder(_fixtureOrder, component.Fixtures.Count, true);
+ GUIUtility.ExitGUI();
+ }
+
+ EditorGUI.BeginChangeCheck();
+ row.y = content.y + LayoutLineHeight + 3f;
+ var distance = EditorGUI.Slider(row, new GUIContent("Position",
+ "Distance along the complete spline in VPX units."), leg.Distance,
+ 0f, math.max(0f, component.SplineLength));
+
+ row.y += LayoutLineHeight + 4f;
+ var previewRect = new Rect(content.x, row.y, content.width,
+ WireRailStandPreviewEditor.Height);
+ _standPreviewEditor.Draw(previewRect, component, fixtureIndex, leg);
+
+ row.y = previewRect.yMax + 4f;
+ EditorGUI.LabelField(row, "Rail Attachment", EditorStyles.boldLabel);
+
+ row.y += LayoutLineHeight + 3f;
+ var lateralOffset = leg.LateralOffset;
+ var verticalOffset = leg.VerticalOffset;
+ DrawFixtureOffsetRow(row, ref lateralOffset, ref verticalOffset,
+ out var resetOffset);
+ if (resetOffset) {
+ Edit(component, "Reset Wire Rail Stand Attachment Offset", () =>
+ component.SetStandFixtureProperties(fixtureIndex, leg.Distance, leg.LegSide,
+ leg.StartDirection, leg.StartLength, leg.FootPosition,
+ leg.FootRotation, leg.FootWidth, leg.FootLength,
+ leg.FootConnectionLength, 0f, 0f, leg.LengthAdjustment));
+ GUIUtility.ExitGUI();
+ }
+
+ row.y += LayoutLineHeight + 3f;
+ var lengthAdjustment = EditorGUI.FloatField(row, new GUIContent("Length",
+ "Signed VPX adjustment to the attachment span between the bottom rails. "
+ + "Positive values extend it; negative values shorten it."),
+ leg.LengthAdjustment);
+
+ row.y += LayoutLineHeight + 3f;
+ EditorGUI.LabelField(row, "Leg", EditorStyles.boldLabel);
+
+ row.y += LayoutLineHeight + 3f;
+ const float mirrorButtonWidth = 58f;
+ var sideRect = new Rect(row.x, row.y,
+ row.width - mirrorButtonWidth - 4f, row.height);
+ var mirrorRect = new Rect(sideRect.xMax + 4f, row.y,
+ mirrorButtonWidth, row.height);
+ var legSide = (WireRailStandSide)EditorGUI.EnumPopup(sideRect,
+ new GUIContent("Side", "End of the bottom-rail attachment where the leg begins."),
+ leg.LegSide);
+ if (GUI.Button(mirrorRect, new GUIContent("Mirror",
+ "Reflect the complete stand to the opposite route-local side."))) {
+ Edit(component, "Mirror Wire Rail Stand",
+ () => component.MirrorStandFixture(fixtureIndex));
+ GUIUtility.ExitGUI();
+ }
+
+ row.y += LayoutLineHeight + 3f;
+ var vector3FieldHeight = GetVector3FieldHeight();
+ row.height = vector3FieldHeight;
+ var startDirection = EditorGUI.Vector3Field(row,
+ new GUIContent("Start Vector", "Route-local XYZ direction followed by the leg before it bends toward the foot."),
+ leg.StartDirection);
+
+ row.y += vector3FieldHeight + 3f;
+ row.height = LayoutLineHeight;
+ var startLength = EditorGUI.FloatField(row,
+ new GUIContent("Start Length", "Distance in VPX units traveled along the start vector before connecting to the foot."),
+ leg.StartLength);
+
+ row.y += LayoutLineHeight + 3f;
+ EditorGUI.LabelField(row, "U-Hook Foot", EditorStyles.boldLabel);
+
+ row.y += LayoutLineHeight + 3f;
+ row.height = vector3FieldHeight;
+ var footPosition = EditorGUI.Vector3Field(row,
+ new GUIContent("Position", "Route-local XYZ translation of the foot pivot, relative to the leg attachment."),
+ leg.FootPosition);
+
+ row.y += vector3FieldHeight + 3f;
+ var footRotation = EditorGUI.Vector3Field(row,
+ new GUIContent("Rotation", "Route-local XYZ Euler rotation of the complete U-hook foot."),
+ leg.FootRotation);
+
+ row.y += vector3FieldHeight + 3f;
+ row.height = LayoutLineHeight;
+ var footClockwise = EditorGUI.Toggle(row,
+ new GUIContent("Clockwise",
+ "Reverse the U-hook winding direction around its bend."),
+ leg.FootClockwise);
+
+ row.y += LayoutLineHeight + 3f;
+ row.height = LayoutLineHeight;
+ var footWidth = EditorGUI.FloatField(row,
+ new GUIContent("Width", "Centerline width across the U-hook bend in VPX units."),
+ leg.FootWidth);
+
+ row.y += LayoutLineHeight + 3f;
+ var footLength = EditorGUI.FloatField(row,
+ new GUIContent("Arm Length", "Length of the free straight U-hook arm in VPX units."),
+ leg.FootLength);
+
+ row.y += LayoutLineHeight + 3f;
+ var footConnectionLength = EditorGUI.FloatField(row,
+ new GUIContent("Connected Arm Length", "Distance from the U-hook bend to the point where the leg's connection segment joins the hook."),
+ leg.FootConnectionLength);
+
+ if (EditorGUI.EndChangeCheck()) {
+ Edit(component, "Edit Wire Rail Stand", () =>
+ component.SetStandFixtureProperties(fixtureIndex, distance, legSide,
+ startDirection, startLength, footPosition, footRotation,
+ footWidth, footLength, footConnectionLength, lateralOffset,
+ verticalOffset, lengthAdjustment, footClockwise));
+ }
+ }
+
+ private static float GetVector3FieldHeight()
+ => EditorGUIUtility.wideMode ? LayoutLineHeight : LayoutLineHeight * 2f;
+
+ private static void DrawFixtureOffsetRow(Rect rect, ref float lateralOffset,
+ ref float verticalOffset, out bool reset)
+ {
+ const float resetWidth = 54f;
+ const float offsetLabelWidth = 42f;
+ const float axisLabelWidth = 14f;
+ const float spacing = 4f;
+ var numericFieldWidth = math.max(20f, (rect.width - offsetLabelWidth
+ - axisLabelWidth * 2f - resetWidth - spacing * 5f) * 0.5f);
+ var axisFieldWidth = axisLabelWidth + numericFieldWidth;
+ var x = rect.x;
+ EditorGUI.LabelField(new Rect(x, rect.y, offsetLabelWidth, LayoutLineHeight),
+ "Offset");
+ x += offsetLabelWidth + spacing;
+ var previousLabelWidth = EditorGUIUtility.labelWidth;
+ EditorGUIUtility.labelWidth = axisLabelWidth;
+ lateralOffset = EditorGUI.FloatField(new Rect(x, rect.y, axisFieldWidth,
+ LayoutLineHeight), new GUIContent("X", "Lateral offset in VPX units."),
+ lateralOffset);
+ x += axisFieldWidth + spacing;
+ verticalOffset = EditorGUI.FloatField(new Rect(x, rect.y, axisFieldWidth,
+ LayoutLineHeight), new GUIContent("Z", "Vertical offset in VPX units."),
+ verticalOffset);
+ EditorGUIUtility.labelWidth = previousLabelWidth;
+ reset = GUI.Button(new Rect(rect.xMax - resetWidth, rect.y, resetWidth,
+ LayoutLineHeight), "Reset");
+ }
+
+ private static bool DrawAngleRange(Rect rect, string label, string tooltip,
+ bool enabled, ref float start, ref float end)
+ {
+ const float toggleWidth = 102f;
+ enabled = EditorGUI.ToggleLeft(new Rect(rect.x, rect.y, toggleWidth,
+ LayoutLineHeight), new GUIContent(label, tooltip), enabled);
+ if (!enabled) {
+ return false;
+ }
+ DrawAnglePairControls(rect, toggleWidth, ref start, ref end);
+ return true;
+ }
+
+ private static void DrawAnglePairControls(Rect rect, float prefixWidth,
+ ref float start, ref float end)
+ {
+ const float fieldWidth = 42f;
+ const float alignButtonWidth = LayoutLineHeight;
+ const float alignIconSize = 16f;
+ const float spacing = 4f;
+ var startRect = new Rect(rect.x + prefixWidth + spacing, rect.y, fieldWidth,
+ LayoutLineHeight);
+ var alignRect = new Rect(rect.xMax - alignButtonWidth, rect.y, alignButtonWidth,
+ LayoutLineHeight);
+ var endRect = new Rect(alignRect.x - spacing - fieldWidth, rect.y, fieldWidth,
+ LayoutLineHeight);
+ var sliderRect = new Rect(startRect.xMax + spacing, rect.y,
+ math.max(10f, endRect.x - startRect.xMax - spacing * 2f), LayoutLineHeight);
+ start = math.clamp(EditorGUI.FloatField(startRect, new GUIContent(string.Empty,
+ "From angle in degrees."), start), 0f, 360f);
+ end = math.clamp(EditorGUI.FloatField(endRect, new GUIContent(string.Empty,
+ "To angle in degrees."), end), 0f, 360f);
+ if (start <= end) {
+ EditorGUI.MinMaxSlider(sliderRect, ref start, ref end, 0f, 360f);
+ } else {
+ var gapStart = end;
+ var gapEnd = start;
+ EditorGUI.MinMaxSlider(sliderRect, ref gapStart, ref gapEnd, 0f, 360f);
+ start = gapEnd;
+ end = gapStart;
+ }
+ EditorGUIUtility.AddCursorRect(alignRect, MouseCursor.Link);
+ var alignClicked = GUI.Button(alignRect, AlignAngleRangeContent,
+ EditorStyles.miniButton);
+ var iconRect = new Rect(alignRect.center.x - alignIconSize * 0.5f,
+ alignRect.center.y - alignIconSize * 0.5f, alignIconSize, alignIconSize);
+ GUI.DrawTexture(iconRect, Icons.Horizon(), ScaleMode.ScaleToFit, true);
+ if (alignClicked) {
+ var aligned = WireRailRingFixture.AlignAngleRangeHorizontally(start, end);
+ start = aligned.x;
+ end = aligned.y;
+ }
+ }
+
+ private static GUIContent AlignAngleRangeContent
+ => _alignAngleRangeContent ??= new GUIContent(string.Empty,
+ "Align both endpoints to the same vertical height.");
+
+ private static bool HasOtherRingFixture(WireRailComponent component,
+ int sourceFixtureIndex)
+ {
+ for (var fixtureIndex = 0; fixtureIndex < component.Fixtures.Count;
+ fixtureIndex++) {
+ if (fixtureIndex != sourceFixtureIndex
+ && component.Fixtures[fixtureIndex] is WireRailRingFixture) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void DrawRenderGeometrySettings(WireRailComponent component)
+ {
+ serializedObject.Update();
+ EditorGUI.BeginChangeCheck();
+ EditorGUI.BeginChangeCheck();
+ var railCount = EditorGUILayout.Popup(new GUIContent("Rails",
+ "Total number of rails available to every wire layout."),
+ math.clamp(component.RailCount, 1, RailCounts.Length) - 1, RailCounts) + 1;
+ var railCountChanged = EditorGUI.EndChangeCheck();
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_renderMaterial"),
+ new GUIContent("Material"));
+ EditorGUI.BeginChangeCheck();
+ var wireDiameter = EditorGUILayout.FloatField(new GUIContent("Wire Diameter",
+ "Global diameter for every rail wire and fixture wire."),
+ component.WireDiameter);
+ var wireDiameterChanged = EditorGUI.EndChangeCheck();
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_radialSegments"),
+ new GUIContent("Tube Sides"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_wireCapBevelSize"),
+ new GUIContent("Wire Cap Bevel",
+ "One-segment bevel size on every exposed wire end. Each wire clamps the size "
+ + "to half its diameter."));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_renderSamplesPerSegment"),
+ new GUIContent("Minimum Samples Per Layout Span",
+ "Base longitudinal detail. Sharper wire bends receive extra rings automatically."));
+
+ var settingsChanged = EditorGUI.EndChangeCheck();
+ serializedObject.ApplyModifiedProperties();
+ if (railCountChanged) {
+ Edit(component, "Change Wire Rail Count",
+ () => component.SetRailCount(railCount));
+ } else if (wireDiameterChanged) {
+ Edit(component, "Change Wire Rail Diameter",
+ () => component.SetWireDiameter(math.max(0.1f, wireDiameter)));
+ } else if (settingsChanged) {
+ component.RebuildRenderGeometry();
+ SceneView.RepaintAll();
+ }
+
+ var renderMesh = component.RenderMesh;
+ if (renderMesh) {
+ EditorGUILayout.LabelField("Generated", $"{renderMesh.vertexCount} vertices");
+ }
+ if (GUILayout.Button("Rebuild Render Geometry")) {
+ component.RebuildRenderGeometry();
+ SceneView.RepaintAll();
+ }
+ }
+
+ private void DrawBallChannelColliderSettings(WireRailComponent component)
+ {
+ serializedObject.Update();
+ EditorGUI.BeginChangeCheck();
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_referenceBallDiameter"),
+ new GUIContent("Ball Diameter",
+ "Diameter of the reference ball used to fit the collision channel. It changes "
+ + "only the collider, not the visible wires or the game's ball."));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_colliderSamplesPerSegment"),
+ new GUIContent("Curvature Detail",
+ "Controls adaptive collider tessellation. Curves receive more rows while "
+ + "straight spans remain sparse."));
+ var colliderGeometryChanged = EditorGUI.EndChangeCheck();
+ EditorGUI.BeginChangeCheck();
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_showColliderPreview"),
+ new GUIContent("Show Collider Preview"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_physicsMaterial"),
+ new GUIContent("Physics Material"));
+ EditorGUILayout.PropertyField(
+ serializedObject.FindProperty("_terminalPhysicsMaterial"),
+ new GUIContent("Terminal Impact Material",
+ "Optional physics material used only by Hairpin terminal arcs. It takes precedence even when Overwrite Physics is enabled."));
+ var overwritePhysics = serializedObject.FindProperty("_overwritePhysics");
+ EditorGUILayout.PropertyField(overwritePhysics, new GUIContent("Overwrite Physics",
+ "Use the inline values for the channel and Hairpin leads. A Terminal Impact Material remains a deliberate terminal-only override."));
+ if (overwritePhysics.boolValue) {
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_elasticity"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_elasticityFalloff"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_friction"));
+ EditorGUILayout.PropertyField(serializedObject.FindProperty("_scatter"));
+ }
+ var otherSettingsChanged = EditorGUI.EndChangeCheck();
+ serializedObject.ApplyModifiedProperties();
+ if (colliderGeometryChanged) {
+ component.InvalidateColliderGeometry();
+ }
+ if (colliderGeometryChanged || otherSettingsChanged) {
+ SceneView.RepaintAll();
+ }
+
+ var colliderMesh = component.ColliderMesh;
+ if (colliderMesh && colliderMesh.subMeshCount > 0) {
+ var triangleCount = 0UL;
+ for (var submeshIndex = 0; submeshIndex < colliderMesh.subMeshCount;
+ submeshIndex++) {
+ triangleCount += colliderMesh.GetIndexCount(submeshIndex) / 3;
+ }
+ EditorGUILayout.LabelField("Generated", $"{triangleCount} triangles");
+ }
+ if (GUILayout.Button("Rebuild Collider")) {
+ component.RebuildColliderMesh();
+ SceneView.RepaintAll();
+ }
+ }
+
+ private float GetLayoutElementHeight(WireRailComponent component, int layoutIndex)
+ {
+ if (layoutIndex < 0 || layoutIndex >= component.Segments.Count) {
+ return LayoutLineHeight;
+ }
+ var layout = component.Segments[layoutIndex];
+ var height = LayoutPadding * 2f + LayoutLineHeight * 2f + 6f
+ + WireRailCrossSectionEditor.Height;
+ if (component.RailCount == 3) {
+ height += LayoutLineHeight + 3f;
+ }
+ var connectionHeight = GetConnectionHeight(component, layoutIndex);
+ return height + (connectionHeight > 0f ? connectionHeight + 5f : 0f);
+ }
+
+ private void DrawLayoutElement(Rect rect, WireRailComponent component, int layoutIndex)
+ {
+ rect.y -= 1f;
+ rect.height -= 1f;
+ var content = new Rect(rect.x + LayoutPadding, rect.y + LayoutPadding,
+ rect.width - LayoutPadding * 2f - ElementRightInset, rect.height - LayoutPadding * 2f);
+ var layout = component.Segments[layoutIndex];
+ var row = new Rect(content.x, content.y - 2f, content.width, LayoutLineHeight);
+ var displayIndex = component.GetLayoutDisplayIndex(layoutIndex);
+ EditorGUI.LabelField(row, $"Layout {displayIndex + 1}", EditorStyles.boldLabel);
+ var trashRect = new Rect(row.xMax - LayoutLineHeight, row.y, LayoutLineHeight,
+ LayoutLineHeight);
+ var duplicateRect = new Rect(trashRect.x - LayoutLineHeight - 2f, row.y,
+ LayoutLineHeight, LayoutLineHeight);
+ var duplicate = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Duplicate")) {
+ tooltip = "Duplicate this wire layout between physical neighbors",
+ };
+ var trash = new GUIContent(EditorGUIUtility.IconContent("TreeEditor.Trash")) {
+ tooltip = "Remove this wire layout",
+ };
+ var canRemoveLayout = component.Segments.Count > 1;
+ EditorGUIUtility.AddCursorRect(duplicateRect, MouseCursor.Link);
+ if (canRemoveLayout) {
+ EditorGUIUtility.AddCursorRect(trashRect, MouseCursor.Link);
+ }
+ if (GUI.Button(duplicateRect, duplicate, GUIStyle.none)) {
+ AddOrDuplicateLayout(component, layoutIndex);
+ GUIUtility.ExitGUI();
+ }
+ using (new EditorGUI.DisabledScope(!canRemoveLayout)) {
+ if (GUI.Button(trashRect, trash, GUIStyle.none)) {
+ var removedDisplayIndex = component.GetLayoutDisplayIndex(layoutIndex);
+ var previousSelectedLayout = _layoutSelectionBeforePointerDown;
+ Edit(component, "Remove Wire Rail Layout",
+ () => component.RemoveLayout(layoutIndex));
+ SynchronizeLayoutOrder(_layoutOrder, component, true);
+ if (previousSelectedLayout < 0) {
+ DeselectLayout();
+ } else if (previousSelectedLayout == layoutIndex) {
+ var selectedDisplayIndex = math.min(removedDisplayIndex,
+ _layoutOrder.Count - 1);
+ SelectLayout(component, _layoutOrder[selectedDisplayIndex]);
+ } else {
+ var selectedLayoutIndex = previousSelectedLayout > layoutIndex
+ ? previousSelectedLayout - 1 : previousSelectedLayout;
+ SelectLayout(component, selectedLayoutIndex);
+ }
+ GUIUtility.ExitGUI();
+ }
+ }
+
+ row.y = content.y + LayoutLineHeight + 3f;
+ var positionRect = new Rect(row.x, row.y, row.width, LayoutLineHeight);
+ float position;
+ using (new EditorGUI.DisabledScope(layoutIndex == 0)) {
+ var previousLabelWidth = EditorGUIUtility.labelWidth;
+ EditorGUIUtility.labelWidth = 52f;
+ var positionTooltip = layoutIndex == 0
+ ? "The route's physical starting layout is anchored at 0 VPX."
+ : "Distance along the complete spline in VPX units.";
+ position = EditorGUI.DelayedFloatField(positionRect,
+ new GUIContent("Position", positionTooltip), layout.Distance);
+ EditorGUIUtility.labelWidth = previousLabelWidth;
+ }
+ if (!Mathf.Approximately(position, layout.Distance)) {
+ var wasSelected = WireRailLayoutEditorSelection.IsSelected(component, layoutIndex);
+ var newLayoutIndex = layoutIndex;
+ Edit(component, "Move Wire Rail Layout",
+ () => newLayoutIndex = component.SetLayoutDistance(layoutIndex, position));
+ if (newLayoutIndex != layoutIndex) {
+ // The layout moved past a neighbor and the physical list was re-sorted;
+ // the display list is remapped by the component, so only the scene
+ // selection, which is keyed by physical index, needs to follow.
+ SynchronizeLayoutOrder(_layoutOrder, component, true);
+ if (wasSelected) {
+ WireRailLayoutEditorSelection.Select(component, newLayoutIndex);
+ }
+ GUIUtility.ExitGUI();
+ }
+ layout = component.Segments[layoutIndex];
+ }
+
+ if (component.RailCount == 3) {
+ row.y += LayoutLineHeight + 3f;
+ var sideLabelRect = new Rect(row.x, row.y, 70f, LayoutLineHeight);
+ EditorGUI.LabelField(sideLabelRect, new GUIContent("Third Rail",
+ "Choose whether the raised third rail is on the left or right."));
+ var sideRect = new Rect(sideLabelRect.xMax, row.y,
+ row.xMax - sideLabelRect.xMax, LayoutLineHeight);
+ var side = (WireRailThirdRailSide)GUI.Toolbar(sideRect,
+ (int)layout.ThirdRailSide, ThirdRailSides);
+ if (side != layout.ThirdRailSide) {
+ Edit(component, "Change Third Wire Rail Side",
+ () => component.SetThirdRailSide(layoutIndex, side));
+ }
+ }
+
+ var crossSectionY = row.yMax + 4f;
+ var crossSectionRect = new Rect(content.x, crossSectionY, content.width,
+ WireRailCrossSectionEditor.Height);
+ _crossSectionEditor.Draw(crossSectionRect, component, layoutIndex);
+ var connectionHeight = GetConnectionHeight(component, layoutIndex);
+ if (connectionHeight > 0f) {
+ DrawConnection(new Rect(content.x, crossSectionRect.yMax + 5f,
+ content.width, connectionHeight), component, layoutIndex);
+ }
+ }
+
+ private static float GetConnectionHeight(WireRailComponent component, int layoutIndex)
+ {
+ var nextLayoutIndex = component.GetNextSegmentIndex(layoutIndex);
+ if (nextLayoutIndex < 0) {
+ return 0f;
+ }
+ var layout = component.Segments[layoutIndex];
+ var connection = layout.ConnectionToNext;
+ var wireCount = component.RailCount;
+ var overriddenWireCount = 0;
+ for (var wireIndex = 0; wireIndex < wireCount; wireIndex++) {
+ if (layout.IsRailActive(wireIndex)
+ && component.Segments[nextLayoutIndex].IsRailActive(wireIndex)
+ && connection.IsWireOverridden(wireIndex)) {
+ overriddenWireCount++;
+ }
+ }
+ var blendInfoHeight = TransitionUsesBlending(component, layoutIndex)
+ ? GetTransitionBlendInfoHeight() + 3f : 0f;
+ return LayoutPadding * 2f + LayoutLineHeight * 2f + 3f + blendInfoHeight
+ + overriddenWireCount * (LayoutLineHeight + 3f);
+ }
+
+ private static void DrawConnection(Rect rect, WireRailComponent component,
+ int layoutIndex)
+ {
+ GUI.Box(rect, GUIContent.none, EditorStyles.helpBox);
+ var nextLayoutIndex = component.GetNextSegmentIndex(layoutIndex);
+ var layout = component.Segments[layoutIndex];
+ var nextLayout = component.Segments[nextLayoutIndex];
+ var connection = layout.ConnectionToNext;
+ var wireCount = component.RailCount;
+ var row = new Rect(rect.x + LayoutPadding, rect.y + LayoutPadding,
+ rect.width - LayoutPadding * 2f, LayoutLineHeight);
+ var nextDisplayIndex = component.GetLayoutDisplayIndex(nextLayoutIndex);
+ EditorGUI.LabelField(row, $"Transition to Layout {nextDisplayIndex + 1}",
+ EditorStyles.boldLabel);
+
+ row.y += LayoutLineHeight + 3f;
+ if (TransitionUsesBlending(component, layoutIndex, BlendedWireIndices)) {
+ var label = BlendedWireIndices.Count == 1 ? "Wire" : "Wires";
+ var blendInfoHeight = GetTransitionBlendInfoHeight();
+ EditorGUI.HelpBox(new Rect(row.x, row.y, row.width,
+ blendInfoHeight),
+ $"Blending {label} {string.Join(", ", BlendedWireIndices)}"
+ + TransitionBlendMessageSuffix, MessageType.Info);
+ row.y += blendInfoHeight + 3f;
+ }
+ const float overrideButtonWidth = 24f;
+ var overrideButtonsWidth = overrideButtonWidth * wireCount;
+ EditorGUI.LabelField(new Rect(row.x, row.y,
+ math.max(0f, row.width - overrideButtonsWidth - 4f), LayoutLineHeight),
+ new GUIContent("Override Wires:",
+ "Choose which wires need non-default continuity or transition curves."));
+ var overrideButtonX = row.xMax - overrideButtonsWidth;
+ for (var wireIndex = 0; wireIndex < wireCount; wireIndex++) {
+ var canTransition = layout.IsRailActive(wireIndex)
+ && nextLayout.IsRailActive(wireIndex);
+ var style = wireCount == 1 ? EditorStyles.miniButton
+ : wireIndex == 0 ? EditorStyles.miniButtonLeft
+ : wireIndex == wireCount - 1 ? EditorStyles.miniButtonRight
+ : EditorStyles.miniButtonMid;
+ var overrideRect = new Rect(overrideButtonX + wireIndex * overrideButtonWidth,
+ row.y, overrideButtonWidth, LayoutLineHeight);
+ var overridden = connection.IsWireOverridden(wireIndex);
+ bool toggledOverride;
+ using (new EditorGUI.DisabledScope(!canTransition)) {
+ toggledOverride = GUI.Toggle(overrideRect, overridden,
+ new GUIContent((wireIndex + 1).ToString(), canTransition
+ ? "Override this wire's transition."
+ : "This wire is inactive in one or both layouts."), style);
+ }
+ if (toggledOverride != overridden) {
+ var capturedWireIndex = wireIndex;
+ Edit(component, "Change Wire Rail Transition Override",
+ () => component.SetWireTransitionOverride(layoutIndex,
+ capturedWireIndex, toggledOverride));
+ GUI.changed = true;
+ return;
+ }
+ }
+
+ const float wireLabelWidth = 54f;
+ const float continuousWidth = 88f;
+ const float spacing = 4f;
+ for (var wireIndex = 0; wireIndex < wireCount; wireIndex++) {
+ if (!layout.IsRailActive(wireIndex) || !nextLayout.IsRailActive(wireIndex)
+ || !connection.IsWireOverridden(wireIndex)) {
+ continue;
+ }
+ row.y += LayoutLineHeight + 3f;
+ EditorGUI.LabelField(new Rect(row.x, row.y, wireLabelWidth, LayoutLineHeight),
+ $"Wire {wireIndex + 1}", EditorStyles.boldLabel);
+ var continuous = connection.IsWireContinuous(wireIndex);
+ var continuousRect = new Rect(row.x + wireLabelWidth, row.y,
+ continuousWidth, LayoutLineHeight);
+ var toggled = EditorGUI.ToggleLeft(continuousRect, new GUIContent("Continuous",
+ "Join this wire to the matching wire in the next layout."), continuous);
+ if (toggled != continuous) {
+ var capturedWireIndex = wireIndex;
+ Edit(component, "Change Wire Rail Continuity",
+ () => component.SetWireContinuous(layoutIndex,
+ capturedWireIndex, toggled));
+ connection = component.Segments[layoutIndex].ConnectionToNext;
+ }
+ var curveRect = new Rect(continuousRect.xMax + spacing, row.y,
+ math.max(20f, row.xMax - continuousRect.xMax - spacing), LayoutLineHeight);
+ using (new EditorGUI.DisabledScope(!toggled)) {
+ EditorGUI.BeginChangeCheck();
+ var curve = EditorGUI.CurveField(curveRect, new GUIContent(string.Empty,
+ "Transition curve for this wire."),
+ CloneCurve(connection.GetWireCurve(wireIndex)), TransitionCurveColor,
+ new Rect(0f, 0f, 1f, 1f));
+ if (EditorGUI.EndChangeCheck()) {
+ var capturedWireIndex = wireIndex;
+ Edit(component, "Change Wire Rail Transition Curve",
+ () => component.SetWireTransitionCurve(layoutIndex,
+ capturedWireIndex, curve));
+ connection = component.Segments[layoutIndex].ConnectionToNext;
+ }
+ }
+ }
+ }
+
+ private static bool TransitionUsesBlending(WireRailComponent component,
+ int layoutIndex, List blendedWires = null)
+ {
+ blendedWires?.Clear();
+ var nextLayoutIndex = component.GetNextSegmentIndex(layoutIndex);
+ if (nextLayoutIndex < 0) {
+ return false;
+ }
+ var layout = component.Segments[layoutIndex];
+ var nextLayout = component.Segments[nextLayoutIndex];
+ var connection = layout.ConnectionToNext;
+ for (var wireIndex = 0; wireIndex < component.RailCount; wireIndex++) {
+ if (!layout.IsRailActive(wireIndex) || !nextLayout.IsRailActive(wireIndex)
+ || !connection.IsWireContinuous(wireIndex)) {
+ continue;
+ }
+ var offsetDelta = (float2)layout.GetRailOffset(wireIndex)
+ - (float2)nextLayout.GetRailOffset(wireIndex);
+ if (math.lengthsq(offsetDelta) > 1e-8f
+ || math.abs(layout.GetWireDiameter(wireIndex)
+ - nextLayout.GetWireDiameter(wireIndex)) > 1e-4f) {
+ if (blendedWires == null) {
+ return true;
+ }
+ blendedWires.Add(wireIndex + 1);
+ }
+ }
+ return blendedWires != null && blendedWires.Count > 0;
+ }
+
+ private static float GetTransitionBlendInfoHeight()
+ {
+ // Reserve the HelpBox info icon as well as the reorderable-list insets.
+ var availableWidth = math.max(60f,
+ EditorGUIUtility.currentViewWidth - 140f);
+ return math.ceil(EditorStyles.helpBox.CalcHeight(
+ TransitionBlendHeightContent, availableWidth));
+ }
+
+ private static AnimationCurve CloneCurve(AnimationCurve source)
+ {
+ if (source == null) {
+ return AnimationCurve.Linear(0f, 0f, 1f, 1f);
+ }
+ return new AnimationCurve(source.keys) {
+ preWrapMode = source.preWrapMode,
+ postWrapMode = source.postWrapMode,
+ };
+ }
+
+ private int GetSelectedLayoutIndex(WireRailComponent component)
+ {
+ var selectedDisplayIndex = _layoutOrderList?.index ?? -1;
+ if (selectedDisplayIndex < 0 || selectedDisplayIndex >= _layoutOrder.Count) {
+ return -1;
+ }
+ var layoutIndex = _layoutOrder[selectedDisplayIndex];
+ return layoutIndex >= 0 && layoutIndex < component.Segments.Count
+ ? layoutIndex : -1;
+ }
+
+ private void AddOrDuplicateLayout(WireRailComponent component, int sourceLayoutIndex)
+ {
+ var distance = component.GetSuggestedLayoutDistance(sourceLayoutIndex);
+ var newLayoutIndex = -1;
+ if (sourceLayoutIndex >= 0) {
+ Edit(component, "Duplicate Wire Rail Layout",
+ () => newLayoutIndex = component.DuplicateLayout(sourceLayoutIndex, distance));
+ } else {
+ Edit(component, "Add Wire Rail Layout",
+ () => newLayoutIndex = component.AddLayout(distance));
+ }
+ SynchronizeLayoutOrder(_layoutOrder, component, true);
+ if (sourceLayoutIndex >= 0) {
+ SelectLayout(component, newLayoutIndex);
+ } else {
+ DeselectLayout();
+ }
+ }
+
+ private void SelectLayout(WireRailComponent component, int layoutIndex)
+ {
+ if (layoutIndex < 0 || layoutIndex >= component.Segments.Count) {
+ return;
+ }
+ WireRailLayoutEditorSelection.Select(component, layoutIndex);
+ _layoutOrderList.index = component.GetLayoutDisplayIndex(layoutIndex);
+ Repaint();
+ SceneView.RepaintAll();
+ }
+
+ private void DeselectLayout()
+ {
+ _layoutOrderList.index = -1;
+ _layoutSelectionBeforePointerDown = -1;
+ WireRailLayoutEditorSelection.Clear();
+ Repaint();
+ SceneView.RepaintAll();
+ }
+
+ private static void Edit(WireRailComponent component, string undoName, Action edit)
+ {
+ Undo.RecordObject(component, undoName);
+ edit();
+ EditorUtility.SetDirty(component);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(component);
+ SceneView.RepaintAll();
+ }
+
+ private static void CenterPivot(WireRailComponent component)
+ {
+ var container = component.SplineContainer;
+ if (!container) {
+ return;
+ }
+ const string undoName = "Center Wire Rail Pivot";
+ Undo.RecordObjects(new UnityEngine.Object[] { component.transform, container },
+ undoName);
+ if (!component.CenterPivot()) {
+ return;
+ }
+ EditorUtility.SetDirty(component.transform);
+ EditorUtility.SetDirty(container);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(component.transform);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(container);
+ SceneView.RepaintAll();
+ }
+
+ internal static bool TryGetGradeSplineRange(SplineContainer container,
+ out int startKnotIndex, out int endKnotIndex, out int selectedKnotCount)
+ {
+ startKnotIndex = 0;
+ endKnotIndex = container && container.Spline != null
+ ? container.Spline.Count - 1 : -1;
+ selectedKnotCount = 0;
+ var spline = container ? container.Spline : null;
+ if (spline == null || spline.Closed || spline.Count < 2) {
+ return false;
+ }
+
+ var splineInfo = new SplineInfo(container, 0);
+ SplineSelection.GetElements(splineInfo, SelectedGradeKnots);
+ SplineSelection.GetElements(splineInfo, SelectedGradeTangents);
+ SelectedGradeKnotIndices.Clear();
+ for (var index = 0; index < SelectedGradeKnots.Count; index++) {
+ AddSelectedGradeKnotIndex(SelectedGradeKnots[index].KnotIndex);
+ }
+ for (var index = 0; index < SelectedGradeTangents.Count; index++) {
+ AddSelectedGradeKnotIndex(SelectedGradeTangents[index].KnotIndex);
+ }
+ SelectedGradeKnotIndices.Sort();
+ selectedKnotCount = SelectedGradeKnotIndices.Count;
+ if (selectedKnotCount == 0) {
+ return true;
+ }
+ if (selectedKnotCount != 2) {
+ return false;
+ }
+
+ startKnotIndex = SelectedGradeKnotIndices[0];
+ endKnotIndex = SelectedGradeKnotIndices[1];
+ return true;
+ }
+
+ private static void AddSelectedGradeKnotIndex(int knotIndex)
+ {
+ if (!SelectedGradeKnotIndices.Contains(knotIndex)) {
+ SelectedGradeKnotIndices.Add(knotIndex);
+ }
+ }
+
+ internal static bool GradeSplineHeights(WireRailComponent component,
+ int startKnotIndex, int endKnotIndex)
+ {
+ var container = component ? component.SplineContainer : null;
+ var spline = container ? container.Spline : null;
+ if (spline == null || spline.Closed || spline.Count < 2
+ || startKnotIndex < 0 || endKnotIndex >= spline.Count
+ || startKnotIndex >= endKnotIndex) {
+ return false;
+ }
+
+ var rangeKnotCount = endKnotIndex - startKnotIndex + 1;
+ var knotDistances = new float[rangeKnotCount];
+ for (var segmentIndex = startKnotIndex; segmentIndex < endKnotIndex;
+ segmentIndex++) {
+ var rangeIndex = segmentIndex - startKnotIndex;
+ knotDistances[rangeIndex + 1] = knotDistances[rangeIndex]
+ + CalculatePlanarLength(spline.GetCurve(segmentIndex));
+ }
+ var totalDistance = knotDistances[^1];
+ if (totalDistance <= 1e-5f) {
+ return false;
+ }
+
+ var originalKnots = new BezierKnot[rangeKnotCount];
+ var knots = new BezierKnot[rangeKnotCount];
+ var tangentModes = new TangentMode[rangeKnotCount];
+ var startHeight = spline[startKnotIndex].Position.z;
+ var endHeight = spline[endKnotIndex].Position.z;
+ var grade = (endHeight - startHeight) / totalDistance;
+ for (var knotIndex = startKnotIndex; knotIndex <= endKnotIndex; knotIndex++) {
+ var rangeIndex = knotIndex - startKnotIndex;
+ var knot = spline[knotIndex];
+ originalKnots[rangeIndex] = knot;
+ var ratio = knotDistances[rangeIndex] / totalDistance;
+ knot.Position.z = math.lerp(startHeight, endHeight, ratio);
+ tangentModes[rangeIndex] = spline.GetTangentMode(knotIndex);
+ if (tangentModes[rangeIndex] != TangentMode.Linear) {
+ if (knotIndex > startKnotIndex || startKnotIndex == 0) {
+ knot.TangentIn = GradeTangent(knot.TangentIn, knot.Rotation, grade,
+ false);
+ }
+ if (knotIndex < endKnotIndex || endKnotIndex == spline.Count - 1) {
+ knot.TangentOut = GradeTangent(knot.TangentOut, knot.Rotation, grade,
+ true);
+ }
+ }
+ knots[rangeIndex] = knot;
+ }
+
+ const string undoName = "Grade Wire Rail Heights";
+ Undo.RecordObjects(new UnityEngine.Object[] { component, container }, undoName);
+ for (var knotIndex = startKnotIndex; knotIndex <= endKnotIndex; knotIndex++) {
+ var rangeIndex = knotIndex - startKnotIndex;
+ var mainTangent = knotIndex == endKnotIndex
+ ? BezierTangent.In : BezierTangent.Out;
+ var hasUngradedTangent = (knotIndex == startKnotIndex && startKnotIndex > 0)
+ || (knotIndex == endKnotIndex && endKnotIndex < spline.Count - 1);
+ var tangentMode = tangentModes[rangeIndex];
+ var requiresBoundaryBreak = hasUngradedTangent
+ && RequiresBoundaryBreak(originalKnots, knots, rangeIndex,
+ tangentMode);
+ if (tangentMode == TangentMode.AutoSmooth) {
+ if (requiresBoundaryBreak) {
+ spline.SetTangentModeNoNotify(knotIndex, TangentMode.Broken,
+ mainTangent);
+ } else if (!hasUngradedTangent) {
+ spline.SetTangentModeNoNotify(knotIndex, TangentMode.Continuous,
+ mainTangent);
+ }
+ } else if (requiresBoundaryBreak && (tangentMode == TangentMode.Continuous
+ || tangentMode == TangentMode.Mirrored)) {
+ spline.SetTangentModeNoNotify(knotIndex, TangentMode.Broken,
+ mainTangent);
+ }
+ if (knotIndex == endKnotIndex) {
+ spline.SetKnot(knotIndex, knots[rangeIndex], mainTangent);
+ } else {
+ spline.SetKnotNoNotify(knotIndex, knots[rangeIndex], mainTangent);
+ }
+ }
+
+ EditorUtility.SetDirty(container);
+ EditorUtility.SetDirty(component);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(container);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(component);
+ SceneView.RepaintAll();
+ return true;
+ }
+
+ private static bool RequiresBoundaryBreak(IReadOnlyList originalKnots,
+ IReadOnlyList gradedKnots, int rangeIndex, TangentMode tangentMode)
+ {
+ var original = originalKnots[rangeIndex];
+ var graded = gradedKnots[rangeIndex];
+ if (!Approximately(original.Position, graded.Position)
+ || !Approximately(original.TangentIn, graded.TangentIn)
+ || !Approximately(original.TangentOut, graded.TangentOut)) {
+ return true;
+ }
+ if (tangentMode != TangentMode.AutoSmooth) {
+ return false;
+ }
+ var insideRangeIndex = rangeIndex == 0 ? 1 : rangeIndex - 1;
+ return !Approximately(originalKnots[insideRangeIndex].Position,
+ gradedKnots[insideRangeIndex].Position);
+
+ static bool Approximately(float3 left, float3 right)
+ => math.distancesq(left, right) <= 1e-10f;
+ }
+
+ private static float CalculatePlanarLength(BezierCurve curve)
+ {
+ curve.P0.z = 0f;
+ curve.P1.z = 0f;
+ curve.P2.z = 0f;
+ curve.P3.z = 0f;
+ return CurveUtility.CalculateLength(curve, PlanarSplineLengthResolution);
+ }
+
+ private static float3 GradeTangent(float3 tangent, quaternion rotation,
+ float grade, bool outgoing)
+ {
+ var splineTangent = math.rotate(rotation, tangent);
+ var horizontalLength = math.length(splineTangent.xy);
+ splineTangent.z = (outgoing ? grade : -grade) * horizontalLength;
+ return math.rotate(math.inverse(rotation), splineTangent);
+ }
+
+ internal static void EditSpline(SplineContainer container)
+ {
+ if (!container) {
+ return;
+ }
+ _pendingSplineEdit = container;
+ Selection.activeGameObject = container.gameObject;
+ EditorApplication.update -= ActivateSplineTool;
+ EditorApplication.update += ActivateSplineTool;
+ }
+
+ private static void ActivateSplineTool()
+ {
+ EditorApplication.update -= ActivateSplineTool;
+ var container = _pendingSplineEdit;
+ _pendingSplineEdit = null;
+ if (!container || Selection.activeGameObject != container.gameObject) {
+ return;
+ }
+
+ ActiveEditorTracker.sharedTracker.ForceRebuild();
+ ToolManager.SetActiveContext();
+ ToolManager.SetActiveTool();
+ SceneView.RepaintAll();
+ }
+ }
+
+ internal static class WireRailLayoutEditorSelection
+ {
+ private static WireRailComponent _component;
+ private static int _layoutIndex = -1;
+
+ internal static void Select(WireRailComponent component, int layoutIndex)
+ {
+ _component = component;
+ _layoutIndex = component ? layoutIndex : -1;
+ }
+
+ internal static bool IsSelected(WireRailComponent component, int layoutIndex)
+ => component && component == _component
+ && layoutIndex == _layoutIndex;
+
+ internal static void Clear()
+ {
+ _component = null;
+ _layoutIndex = -1;
+ SceneView.RepaintAll();
+ }
+ }
+
+ internal static class WireRailFixtureEditorSelection
+ {
+ private static WireRailComponent _component;
+ private static int _fixtureIndex = -1;
+
+ internal static void Select(WireRailComponent component, int fixtureIndex)
+ {
+ _component = component;
+ _fixtureIndex = component ? fixtureIndex : -1;
+ }
+
+ internal static bool IsSelected(WireRailComponent component, int fixtureIndex)
+ => component && component == _component && fixtureIndex == _fixtureIndex;
+
+ internal static int GetSelectedIndex(WireRailComponent component)
+ => component && component == _component ? _fixtureIndex : -1;
+
+ internal static void Clear()
+ {
+ _component = null;
+ _fixtureIndex = -1;
+ SceneView.RepaintAll();
+ }
+ }
+
+ [InitializeOnLoad]
+ internal static class WireRailScenePreview
+ {
+ private const int SamplesPerSegment = 24;
+ private static readonly ProfilerMarker ScenePreviewMarker =
+ new("WireRail.ScenePreview");
+ private static readonly Color[] RailColors = {
+ new(0.05f, 0.75f, 1f, 0.95f),
+ new(1f, 0.55f, 0.05f, 0.95f),
+ new(0.45f, 1f, 0.2f, 0.95f),
+ new(1f, 0.2f, 0.65f, 0.95f),
+ new(0.65f, 0.35f, 1f, 0.95f),
+ };
+ private static readonly List SegmentPreviews = new();
+ private static readonly List FixturePreviews = new();
+ private static WireRailComponent _cachedComponent;
+ private static int _cachedRenderGeometryVersion = -1;
+ private static Matrix4x4 _cachedLocalToWorld;
+ private static Mesh _cachedColliderMesh;
+ private static int _cachedColliderGeometryVersion = -1;
+ private static Matrix4x4 _cachedColliderLocalToWorld;
+ private static Vector3[] _cachedColliderFaces = Array.Empty();
+ private static Vector3[] _cachedColliderEdges = Array.Empty();
+
+ private sealed class SegmentPreview
+ {
+ public Vector3[][] RailPoints;
+ public Vector3[] SpinePoints;
+ public Vector3 LabelPosition;
+ public int ActiveRailCount;
+ public int CachedDisplayIndex = -1;
+ public readonly GUIContent Label = new();
+ public readonly GUIContent SelectedLabel = new();
+ }
+
+ private sealed class FixturePreview
+ {
+ public Vector3[] Points;
+ public GUIContent Label;
+ public int FixtureIndex = -1;
+ }
+
+ static WireRailScenePreview()
+ {
+ SceneView.duringSceneGui += OnSceneGUI;
+ SplineSelection.changed += SceneView.RepaintAll;
+ }
+
+ private static void OnSceneGUI(SceneView _)
+ {
+ var selected = Selection.activeGameObject;
+ var component = selected ? selected.GetComponent() : null;
+ component ??= selected ? selected.GetComponentInParent() : null;
+ if (!component) {
+ return;
+ }
+
+ var container = component.SplineContainer;
+ if (!container) {
+ return;
+ }
+ DrawEditPanel(component, container);
+ var spline = container.Spline;
+ if (spline == null || spline.Count < 2) {
+ return;
+ }
+ using (ScenePreviewMarker.Auto()) {
+ EnsurePreviewCache(component, container, spline);
+ DrawLayoutHandles(component, container, spline);
+ DrawFixtureHandles(component, container, spline);
+ if (Event.current.type != EventType.Repaint) {
+ return;
+ }
+ DrawSegmentPreviews(component, container);
+ DrawSelectedSpanOverlay(component, container);
+ DrawFixturePreviews(component);
+ DrawSelectedFixtureOverlay(component, container);
+
+ if (component.ShowColliderPreview) {
+ DrawColliderPreview(component, component.ColliderMesh, container.transform);
+ }
+ }
+ }
+
+ private static void EnsurePreviewCache(WireRailComponent component,
+ SplineContainer container, Spline spline)
+ {
+ var localToWorld = container.transform.localToWorldMatrix;
+ if (_cachedComponent == component
+ && _cachedRenderGeometryVersion == component.RenderGeometryVersion
+ && _cachedLocalToWorld == localToWorld) {
+ return;
+ }
+
+ _cachedComponent = component;
+ _cachedRenderGeometryVersion = component.RenderGeometryVersion;
+ _cachedLocalToWorld = localToWorld;
+ SegmentPreviews.Clear();
+ FixturePreviews.Clear();
+ var evaluationContext = new WireRailPathEvaluationContext();
+ for (var segmentIndex = 0; segmentIndex < component.Segments.Count;
+ segmentIndex++) {
+ var segment = component.Segments[segmentIndex];
+ var preview = new SegmentPreview {
+ RailPoints = new Vector3[segment.RailCount][],
+ SpinePoints = new Vector3[SamplesPerSegment + 1],
+ ActiveRailCount = CountActiveRails(segment),
+ };
+ for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) {
+ if (!segment.IsRailActive(railIndex)) {
+ continue;
+ }
+ var points = new Vector3[SamplesPerSegment + 1];
+ preview.RailPoints[railIndex] = points;
+ for (var sampleIndex = 0; sampleIndex <= SamplesPerSegment; sampleIndex++) {
+ var curveT = sampleIndex / (float)SamplesPerSegment;
+ points[sampleIndex] = WireRailSplineGeometry.TryEvaluateRailPosition(spline,
+ component.Segments, evaluationContext, segmentIndex, railIndex, curveT,
+ out var position)
+ ? localToWorld.MultiplyPoint3x4((Vector3)position)
+ : container.transform.position;
+ }
+ }
+ for (var sampleIndex = 0; sampleIndex <= SamplesPerSegment; sampleIndex++) {
+ var curveT = sampleIndex / (float)SamplesPerSegment;
+ preview.SpinePoints[sampleIndex] = WireRailSplineGeometry
+ .TryEvaluateLayoutPosition(spline, component.Segments, evaluationContext,
+ segmentIndex, curveT, out var position)
+ ? localToWorld.MultiplyPoint3x4((Vector3)position)
+ : container.transform.position;
+ }
+ preview.LabelPosition = preview.SpinePoints[0];
+ SegmentPreviews.Add(preview);
+ }
+ BuildFixturePreviews(component, spline, localToWorld);
+ }
+
+ private static readonly Color SelectionColor = new(1f, 0.6f, 0.1f, 1f);
+ private static readonly Color SelectionGlowColor = new(1f, 0.6f, 0.1f, 0.28f);
+ private static readonly Color OutlineColor = new(0.02f, 0.02f, 0.02f, 0.95f);
+ private static readonly Color DimmedOutlineColor = new(0.02f, 0.02f, 0.02f, 0.35f);
+ private const float DimmedAlpha = 0.28f;
+ private static GUIStyle _selectedLabelStyle;
+ private static Texture2D _selectedLabelBackground;
+ private static readonly List<(float angle, Vector3 point)> FrameScratch = new();
+
+ private static GUIStyle SelectedLabelStyle {
+ get {
+ if (_selectedLabelStyle != null && _selectedLabelBackground) {
+ return _selectedLabelStyle;
+ }
+ _selectedLabelBackground = new Texture2D(1, 1, TextureFormat.RGBA32, false) {
+ hideFlags = HideFlags.HideAndDontSave,
+ };
+ _selectedLabelBackground.SetPixel(0, 0, new Color(0.05f, 0.05f, 0.05f, 0.85f));
+ _selectedLabelBackground.Apply();
+ _selectedLabelStyle = new GUIStyle(EditorStyles.boldLabel) {
+ fontSize = 13,
+ padding = new RectOffset(7, 7, 3, 3),
+ normal = {
+ textColor = SelectionColor,
+ background = _selectedLabelBackground,
+ },
+ };
+ return _selectedLabelStyle;
+ }
+ }
+
+ private static void DrawSegmentPreviews(WireRailComponent component,
+ SplineContainer container)
+ {
+ var editing = Selection.activeGameObject == container.gameObject
+ && ToolManager.activeContextType == typeof(SplineToolContext);
+ var selectedIndex = GetSelectedLayoutIndex(component);
+
+ // Everything that is not the selected layout steps back, so the selection
+ // reads from contrast rather than from a couple of extra pixels of width.
+ for (var segmentIndex = 0; segmentIndex < SegmentPreviews.Count; segmentIndex++) {
+ var preview = SegmentPreviews[segmentIndex];
+ var selectedLayout = segmentIndex == selectedIndex;
+ var dimmed = selectedIndex >= 0 && !selectedLayout;
+
+ if (selectedLayout) {
+ DrawSelectionGlow(preview);
+ }
+
+ for (var railIndex = 0; railIndex < preview.RailPoints.Length; railIndex++) {
+ var points = preview.RailPoints[railIndex];
+ if (points == null) {
+ continue;
+ }
+ var railColor = RailColors[railIndex % RailColors.Length];
+ if (selectedLayout) {
+ var previousRailZTest = Handles.zTest;
+ Handles.zTest = CompareFunction.Always;
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(8f, points);
+ Handles.color = railColor;
+ Handles.DrawAAPolyLine(5f, points);
+ Handles.zTest = previousRailZTest;
+ } else {
+ if (dimmed) {
+ railColor.a = DimmedAlpha;
+ }
+ Handles.color = railColor;
+ Handles.DrawAAPolyLine(dimmed ? 2f : 3f, points);
+ }
+ }
+
+ var previousZTest = Handles.zTest;
+ Handles.zTest = CompareFunction.Always;
+ Handles.color = dimmed ? DimmedOutlineColor : OutlineColor;
+ Handles.DrawAAPolyLine(selectedLayout ? 13f : editing ? 11f : dimmed ? 6f : 8f,
+ preview.SpinePoints);
+ var spineColor = selectedLayout
+ ? SelectionColor
+ : editing
+ ? new Color(1f, 0.78f, 0.05f, 1f)
+ : new Color(0.9f, 0.95f, 1f, 1f);
+ if (dimmed) {
+ spineColor.a = DimmedAlpha;
+ }
+ Handles.color = spineColor;
+ Handles.DrawAAPolyLine(selectedLayout ? 6f : editing ? 5f : dimmed ? 3f : 4f,
+ preview.SpinePoints);
+ Handles.zTest = previousZTest;
+
+ if (selectedLayout) {
+ DrawSelectionFrames(preview);
+ }
+
+ Handles.color = Color.white;
+ var displayIndex = GetDisplayIndex(component.LayoutDisplayOrder, segmentIndex);
+ if (preview.CachedDisplayIndex != displayIndex) {
+ preview.CachedDisplayIndex = displayIndex;
+ preview.Label.text = $"Layout {displayIndex + 1}: {preview.ActiveRailCount}/"
+ + $"{component.RailCount} rails";
+ preview.SelectedLabel.text = "▶ " + preview.Label.text;
+ }
+ // Arrows sit at the start of the selected span and of the next one; the
+ // labels there would only cover them up.
+ var hasHandle = selectedIndex >= 0
+ && (segmentIndex == selectedIndex || segmentIndex == selectedIndex + 1);
+ if (!hasHandle) {
+ Handles.Label(preview.LabelPosition, preview.Label,
+ dimmed ? EditorStyles.miniBoldLabel : EditorStyles.boldLabel);
+ }
+ }
+ }
+
+ private static int GetSelectedLayoutIndex(WireRailComponent component)
+ {
+ for (var segmentIndex = 0; segmentIndex < SegmentPreviews.Count; segmentIndex++) {
+ if (WireRailLayoutEditorSelection.IsSelected(component, segmentIndex)) {
+ return segmentIndex;
+ }
+ }
+ return -1;
+ }
+
+ ///
+ /// Move handles at the start and the end of the selected span. Dragging one slides
+ /// that layout along the route; the end handle belongs to the next layout.
+ ///
+ private static void DrawLayoutHandles(WireRailComponent component,
+ SplineContainer container, Spline spline)
+ {
+ var selectedIndex = GetSelectedLayoutIndex(component);
+ if (selectedIndex < 0 || selectedIndex >= component.Segments.Count) {
+ return;
+ }
+ // Layout 1 is pinned to the route start and has nothing to drag.
+ if (selectedIndex > 0) {
+ DrawLayoutHandle(component, container, spline, selectedIndex);
+ }
+ var nextIndex = selectedIndex + 1;
+ if (nextIndex < component.Segments.Count && nextIndex < SegmentPreviews.Count) {
+ DrawLayoutHandle(component, container, spline, nextIndex);
+ }
+ }
+
+ private static void DrawLayoutHandle(WireRailComponent component,
+ SplineContainer container, Spline spline, int layoutIndex)
+ {
+ var spine = SegmentPreviews[layoutIndex].SpinePoints;
+ var tangent = spine.Length > 1 ? spine[1] - spine[0] : Vector3.forward;
+ var current = component.Segments[layoutIndex].Distance;
+ DrawRouteDistanceHandle(container, spline, spine[0], tangent,
+ $"{current:0} units", distance => {
+ var selectedIndex = GetSelectedLayoutIndex(component);
+ var selectedLayout = selectedIndex >= 0
+ && selectedIndex < component.Segments.Count
+ ? component.Segments[selectedIndex] : null;
+ Undo.RecordObject(component, "Move Wire Rail Layout");
+ component.SetLayoutDistance(layoutIndex, distance);
+ EditorUtility.SetDirty(component);
+ // Dragging a layout past its neighbor re-sorts the physical list; keep
+ // the selection on the same layout.
+ if (selectedLayout == null) {
+ return;
+ }
+ for (var index = 0; index < component.Segments.Count; index++) {
+ if (component.Segments[index] == selectedLayout) {
+ if (index != selectedIndex) {
+ WireRailLayoutEditorSelection.Select(component, index);
+ }
+ break;
+ }
+ }
+ });
+ }
+
+ ///
+ /// One arrow for the selected fixture. Supports slide along the route; hairpins and
+ /// elbows set how far from their endpoint they sit. Rail trims have no single
+ /// position and get no handle.
+ ///
+ private static void DrawFixtureHandles(WireRailComponent component,
+ SplineContainer container, Spline spline)
+ {
+ var fixtureIndex = WireRailFixtureEditorSelection.GetSelectedIndex(component);
+ if (fixtureIndex < 0 || fixtureIndex >= component.Fixtures.Count) {
+ return;
+ }
+ var fixture = component.Fixtures[fixtureIndex];
+ var splineLength = component.SplineLength;
+ float routeDistance;
+ string hoverLabel;
+ Action apply;
+ switch (fixture) {
+ case WireRailHairpinFixture hairpin:
+ routeDistance = hairpin.Endpoint == WireRailEndpoint.Start
+ ? hairpin.RailOffset : splineLength - hairpin.RailOffset;
+ hoverLabel = $"offset {hairpin.RailOffset:0} units";
+ apply = distance => {
+ Undo.RecordObject(component, "Move Wire Rail Hairpin");
+ component.SetHairpinFixtureOffset(fixtureIndex,
+ hairpin.Endpoint == WireRailEndpoint.Start
+ ? distance : splineLength - distance);
+ EditorUtility.SetDirty(component);
+ };
+ break;
+ case WireRailElbowFixture elbow:
+ routeDistance = elbow.Endpoint == WireRailEndpoint.Start
+ ? elbow.Offset : splineLength - elbow.Offset;
+ hoverLabel = $"offset {elbow.Offset:0} units";
+ apply = distance => {
+ Undo.RecordObject(component, "Move Wire Rail Elbow");
+ component.SetElbowFixtureOffset(fixtureIndex,
+ elbow.Endpoint == WireRailEndpoint.Start
+ ? distance : splineLength - distance);
+ EditorUtility.SetDirty(component);
+ };
+ break;
+ case WireRailTrimFixture:
+ return;
+ default:
+ routeDistance = fixture.Distance;
+ hoverLabel = $"{fixture.Distance:0} units";
+ apply = distance => {
+ Undo.RecordObject(component, "Move Wire Rail Fixture");
+ component.SetFixtureDistance(fixtureIndex, distance);
+ EditorUtility.SetDirty(component);
+ };
+ break;
+ }
+ if (!TryEvaluateRoutePoint(container, spline, routeDistance, out var position,
+ out var tangent)) {
+ return;
+ }
+ DrawRouteDistanceHandle(container, spline, position, tangent, hoverLabel, apply);
+ }
+
+ private static bool TryEvaluateRoutePoint(SplineContainer container, Spline spline,
+ float distance, out Vector3 position, out Vector3 tangent)
+ {
+ position = default;
+ tangent = Vector3.forward;
+ if (spline == null || spline.Count < 2) {
+ return false;
+ }
+ var length = spline.GetLength();
+ var t = length > 1e-5f
+ ? spline.ConvertIndexUnit(math.clamp(distance, 0f, length),
+ PathIndexUnit.Distance, PathIndexUnit.Normalized)
+ : 0f;
+ if (!spline.Evaluate(t, out var localPosition, out var localTangent, out _)) {
+ return false;
+ }
+ var localToWorld = container.transform.localToWorldMatrix;
+ position = localToWorld.MultiplyPoint3x4((Vector3)localPosition);
+ tangent = localToWorld.MultiplyVector((Vector3)localTangent);
+ return true;
+ }
+
+ ///
+ /// A double-headed arrow at a point on the route. Dragging it snaps to the nearest
+ /// point on the spline and hands that route distance to .
+ ///
+ private static void DrawRouteDistanceHandle(SplineContainer container, Spline spline,
+ Vector3 position, Vector3 tangent, string hoverLabel, Action applyDistance)
+ {
+ if (tangent.sqrMagnitude < 1e-10f) {
+ tangent = Vector3.forward;
+ }
+ tangent.Normalize();
+ var size = HandleUtility.GetHandleSize(position) * 0.5f;
+ var controlId = GUIUtility.GetControlID(FocusType.Passive);
+ var hovered = HandleUtility.nearestControl == controlId
+ || GUIUtility.hotControl == controlId;
+ var previousColor = Handles.color;
+ var previousZTest = Handles.zTest;
+ Handles.zTest = CompareFunction.Always;
+ if (Event.current.type == EventType.Repaint) {
+ // Repaint only: a cap drawn during Layout would register as a pickable
+ // control and steal the click from the move handle below.
+ Handles.color = hovered ? Color.white : OutlineColor;
+ DoubleArrowCap(tangent, 0, position, Quaternion.identity, size * 1.25f,
+ EventType.Repaint);
+ }
+ Handles.color = hovered ? new Color(1f, 0.8f, 0.35f, 1f) : SelectionColor;
+ EditorGUI.BeginChangeCheck();
+ var dragged = Handles.FreeMoveHandle(controlId, position, size, Vector3.zero,
+ (id, capPosition, rotation, capSize, eventType) =>
+ DoubleArrowCap(tangent, id, capPosition, rotation, capSize, eventType));
+ if (EditorGUI.EndChangeCheck()) {
+ var local = (float3)container.transform.InverseTransformPoint(dragged);
+ SplineUtility.GetNearestPoint(spline, local, out _, out var t);
+ var distance = spline.ConvertIndexUnit(t, PathIndexUnit.Normalized,
+ PathIndexUnit.Distance);
+ applyDistance(distance);
+ }
+ if (hovered) {
+ Handles.Label(position, " " + hoverLabel, SelectedLabelStyle);
+ }
+ Handles.color = previousColor;
+ Handles.zTest = previousZTest;
+ }
+
+ ///
+ /// A double-headed arrow along the route: two cones on a short bar. Picking treats
+ /// the whole bar as the control, not only its centerline.
+ ///
+ private static void DoubleArrowCap(Vector3 tangent, int controlId, Vector3 position,
+ Quaternion _, float size, EventType eventType)
+ {
+ var halfBar = size * 0.5f;
+ var start = position - tangent * halfBar;
+ var end = position + tangent * halfBar;
+ switch (eventType) {
+ case EventType.Layout:
+ case EventType.MouseMove:
+ // The arrow is roughly 10px thick on screen; anywhere on it counts as a hit.
+ HandleUtility.AddControl(controlId,
+ math.max(0f, HandleUtility.DistanceToLine(start, end) - 10f));
+ break;
+ case EventType.Repaint:
+ var coneSize = size * 0.7f;
+ Handles.ConeHandleCap(0, end, Quaternion.LookRotation(tangent), coneSize,
+ EventType.Repaint);
+ Handles.ConeHandleCap(0, start, Quaternion.LookRotation(-tangent), coneSize,
+ EventType.Repaint);
+ // Bar thickness in pixels, scaling with the cap size so the outline pass
+ // (drawn slightly larger) stays visible around the colored pass.
+ Handles.DrawAAPolyLine(size / HandleUtility.GetHandleSize(position) * 24f,
+ start, end);
+ break;
+ }
+ }
+
+ private static readonly Color SpanOverlayColor = new(1f, 0.6f, 0.1f, 0.35f);
+ // Push the overlay this far toward the camera so it wins the depth test against the
+ // mesh it copies instead of z-fighting with it. World units, i.e. meters.
+ private const float SpanOverlayDepthBias = 0.00025f;
+ private static Material _spanOverlayMaterial;
+ private static readonly MeshRangeOverlay SpanOverlay = new();
+ private static readonly MeshRangeOverlay FixtureOverlay = new();
+
+ private static Material SpanOverlayMaterial {
+ get {
+ if (_spanOverlayMaterial) {
+ return _spanOverlayMaterial;
+ }
+ _spanOverlayMaterial = new Material(Shader.Find("Hidden/Internal-Colored")) {
+ hideFlags = HideFlags.HideAndDontSave,
+ };
+ _spanOverlayMaterial.SetInt("_SrcBlend", (int)BlendMode.SrcAlpha);
+ _spanOverlayMaterial.SetInt("_DstBlend", (int)BlendMode.OneMinusSrcAlpha);
+ _spanOverlayMaterial.SetInt("_Cull", (int)CullMode.Back);
+ _spanOverlayMaterial.SetInt("_ZWrite", 0);
+ _spanOverlayMaterial.SetInt("_ZTest", (int)CompareFunction.LessEqual);
+ return _spanOverlayMaterial;
+ }
+ }
+
+ ///
+ /// Tints the rendered tubes of the selected span in the selection color, so the
+ /// selection shows on the actual geometry and not only on the centerlines.
+ ///
+ private static void DrawSelectedSpanOverlay(WireRailComponent component,
+ SplineContainer container)
+ {
+ var selectedIndex = GetSelectedLayoutIndex(component);
+ var ranges = component.RenderSegmentIndexRanges;
+ if (selectedIndex >= 0 && selectedIndex < ranges.Count) {
+ SpanOverlay.Draw(component, container, ranges[selectedIndex]);
+ }
+ }
+
+ private static void DrawSelectedFixtureOverlay(WireRailComponent component,
+ SplineContainer container)
+ {
+ var fixtureIndex = WireRailFixtureEditorSelection.GetSelectedIndex(component);
+ var ranges = component.RenderFixtureIndexRanges;
+ if (fixtureIndex >= 0 && fixtureIndex < ranges.Count) {
+ FixtureOverlay.Draw(component, container, ranges[fixtureIndex]);
+ }
+ }
+
+ ///
+ /// Redraws one index range of the render mesh with the selection tint. Caches the
+ /// world-space triangles per geometry version, range and transform.
+ ///
+ private sealed class MeshRangeOverlay
+ {
+ private WireRailComponent _component;
+ private int _version = -1;
+ private int2 _range;
+ private Matrix4x4 _localToWorld;
+ private Vector3[] _triangles = Array.Empty();
+
+ public void Draw(WireRailComponent component, SplineContainer container, int2 range)
+ {
+ var mesh = component.RenderMesh;
+ if (!mesh || range.y < 3) {
+ return;
+ }
+ var localToWorld = container.transform.localToWorldMatrix;
+ // Keyed on the mesh generation: the geometry version already advances when a
+ // rebuild is requested, while the rebuild itself is deferred in the editor.
+ if (_component != component || _version != component.RenderMeshVersion
+ || !_range.Equals(range) || _localToWorld != localToWorld) {
+ _component = component;
+ _version = component.RenderMeshVersion;
+ _range = range;
+ _localToWorld = localToWorld;
+ var vertices = mesh.vertices;
+ var indices = mesh.triangles;
+ var end = math.min(indices.Length, range.x + range.y);
+ var triangles = new Vector3[math.max(0, end - range.x)];
+ for (var i = range.x; i < end; i++) {
+ triangles[i - range.x] = localToWorld.MultiplyPoint3x4(vertices[indices[i]]);
+ }
+ _triangles = triangles;
+ }
+ if (_triangles.Length < 3) {
+ return;
+ }
+ var camera = Camera.current;
+ var cameraPosition = camera ? camera.transform.position : Vector3.zero;
+ SpanOverlayMaterial.SetPass(0);
+ GL.PushMatrix();
+ GL.MultMatrix(Matrix4x4.identity);
+ GL.Begin(GL.TRIANGLES);
+ GL.Color(SpanOverlayColor);
+ foreach (var vertex in _triangles) {
+ var toCamera = cameraPosition - vertex;
+ var length = toCamera.magnitude;
+ GL.Vertex(length > 1e-6f
+ ? vertex + toCamera * (SpanOverlayDepthBias / length)
+ : vertex);
+ }
+ GL.End();
+ GL.PopMatrix();
+ }
+ }
+
+ ///
+ /// A wide, translucent band under the selected span. Drawn on top of everything so the
+ /// highlighted stretch is visible even where the rail runs behind other geometry.
+ ///
+ private static void DrawSelectionGlow(SegmentPreview preview)
+ {
+ var previousZTest = Handles.zTest;
+ Handles.zTest = CompareFunction.Always;
+ Handles.color = SelectionGlowColor;
+ Handles.DrawAAPolyLine(26f, preview.SpinePoints);
+ for (var railIndex = 0; railIndex < preview.RailPoints.Length; railIndex++) {
+ var points = preview.RailPoints[railIndex];
+ if (points != null) {
+ Handles.DrawAAPolyLine(16f, points);
+ }
+ }
+ Handles.zTest = previousZTest;
+ }
+
+ ///
+ /// Outlines the cross-section at the start and the end of the selected span, so the
+ /// author sees exactly which stretch of the route the layout governs.
+ ///
+ private static void DrawSelectionFrames(SegmentPreview preview)
+ {
+ var lastSample = preview.SpinePoints.Length - 1;
+ if (lastSample < 1) {
+ return;
+ }
+ DrawSelectionFrame(preview, 0,
+ preview.SpinePoints[1] - preview.SpinePoints[0]);
+ DrawSelectionFrame(preview, lastSample,
+ preview.SpinePoints[lastSample] - preview.SpinePoints[lastSample - 1]);
+ }
+
+ private static void DrawSelectionFrame(SegmentPreview preview, int sampleIndex,
+ Vector3 tangent)
+ {
+ var center = preview.SpinePoints[sampleIndex];
+ if (tangent.sqrMagnitude < 1e-10f) {
+ return;
+ }
+ tangent.Normalize();
+ var reference = math.abs(Vector3.Dot(tangent, Vector3.up)) < 0.9f
+ ? Vector3.up : Vector3.right;
+ var axisX = Vector3.Normalize(Vector3.Cross(reference, tangent));
+ var axisY = Vector3.Cross(tangent, axisX);
+
+ FrameScratch.Clear();
+ for (var railIndex = 0; railIndex < preview.RailPoints.Length; railIndex++) {
+ var points = preview.RailPoints[railIndex];
+ if (points == null || sampleIndex >= points.Length) {
+ continue;
+ }
+ var offset = points[sampleIndex] - center;
+ var angle = math.atan2(Vector3.Dot(offset, axisY), Vector3.Dot(offset, axisX));
+ FrameScratch.Add((angle, points[sampleIndex]));
+ }
+ if (FrameScratch.Count == 0) {
+ return;
+ }
+ FrameScratch.Sort((a, b) => a.angle.CompareTo(b.angle));
+
+ var previousZTest = Handles.zTest;
+ Handles.zTest = CompareFunction.Always;
+ var camera = Camera.current;
+ var discNormal = camera ? -camera.transform.forward : tangent;
+ if (FrameScratch.Count >= 2) {
+ var loop = new Vector3[FrameScratch.Count + 1];
+ for (var i = 0; i < FrameScratch.Count; i++) {
+ loop[i] = FrameScratch[i].point;
+ }
+ loop[FrameScratch.Count] = FrameScratch[0].point;
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(7f, loop);
+ Handles.color = SelectionColor;
+ Handles.DrawAAPolyLine(4f, loop);
+ }
+ foreach (var (_, point) in FrameScratch) {
+ var radius = HandleUtility.GetHandleSize(point) * 0.028f;
+ Handles.color = OutlineColor;
+ Handles.DrawSolidDisc(point, discNormal, radius * 1.6f);
+ Handles.color = Color.white;
+ Handles.DrawSolidDisc(point, discNormal, radius);
+ }
+ Handles.zTest = previousZTest;
+ }
+
+ private static void BuildFixturePreviews(WireRailComponent component, Spline spline,
+ Matrix4x4 localToWorld)
+ {
+ for (var fixtureIndex = 0; fixtureIndex < component.Fixtures.Count; fixtureIndex++) {
+ if (component.Fixtures[fixtureIndex] is WireRailCradleFixture cradle) {
+ if (WireRailSplineGeometry.TryEvaluateCradle(spline, component.Segments,
+ cradle, out var points)) {
+ AddFixturePreview(points, $"Cradle {fixtureIndex + 1}", fixtureIndex);
+ }
+ continue;
+ }
+ if (component.Fixtures[fixtureIndex] is WireRailRungFixture rung) {
+ if (WireRailSplineGeometry.TryEvaluateRung(spline, component.Segments,
+ rung, out var start, out var end)) {
+ FixturePreviews.Add(new FixturePreview {
+ Points = new[] {
+ localToWorld.MultiplyPoint3x4((Vector3)start),
+ localToWorld.MultiplyPoint3x4((Vector3)end),
+ },
+ Label = new GUIContent($"Rung {fixtureIndex + 1}"),
+ FixtureIndex = fixtureIndex,
+ });
+ }
+ continue;
+ }
+ if (component.Fixtures[fixtureIndex] is WireRailStandFixture leg) {
+ if (WireRailSplineGeometry.TryEvaluateStand(spline, component.Segments, leg,
+ out var points)) {
+ AddFixturePreview(points, $"Stand {fixtureIndex + 1}", fixtureIndex);
+ }
+ continue;
+ }
+ if (component.Fixtures[fixtureIndex] is WireRailHairpinFixture hairpin) {
+ if (WireRailSplineGeometry.TryEvaluateHairpin(spline, component.Segments,
+ hairpin, out var points)) {
+ AddFixturePreview(points, $"Hairpin {fixtureIndex + 1}", fixtureIndex);
+ }
+ continue;
+ }
+ if (component.Fixtures[fixtureIndex] is WireRailElbowFixture elbow) {
+ if (WireRailSplineGeometry.TryEvaluateElbow(spline, component.Segments,
+ elbow, out var firstRailPoints, out var secondRailPoints)) {
+ AddFixturePreview(firstRailPoints, $"Elbow {fixtureIndex + 1}", fixtureIndex);
+ AddFixturePreview(secondRailPoints, string.Empty, fixtureIndex);
+ }
+ continue;
+ }
+ if (component.Fixtures[fixtureIndex] is not WireRailRingFixture ring
+ || !ring.TryGetVisibleArc(out var startAngle, out var sweepAngle, out _)
+ || !WireRailSplineGeometry.TryEvaluateRing(spline, component.Segments,
+ ring, out var center, out _, out var right, out var up,
+ out var radius)) {
+ continue;
+ }
+ var previewSegments = math.max(2,
+ (int)math.ceil(ring.RingDensity * sweepAngle / (math.PI * 2f)));
+ var ringPoints = new Vector3[previewSegments + 1];
+ for (var pointIndex = 0; pointIndex <= previewSegments; pointIndex++) {
+ var angle = startAngle + sweepAngle * pointIndex / previewSegments;
+ var centerlineOffset = ring.EvaluateCenterlineOffset(angle, radius);
+ ringPoints[pointIndex] = localToWorld.MultiplyPoint3x4(
+ (Vector3)(center + right * centerlineOffset.x + up * centerlineOffset.y));
+ }
+ FixturePreviews.Add(new FixturePreview {
+ Points = ringPoints,
+ Label = new GUIContent($"Ring {fixtureIndex + 1}"),
+ FixtureIndex = fixtureIndex,
+ });
+ }
+
+ void AddFixturePreview(IReadOnlyList sourcePoints, string label,
+ int previewFixtureIndex)
+ {
+ if (sourcePoints == null || sourcePoints.Count < 2) {
+ return;
+ }
+ var points = new Vector3[sourcePoints.Count];
+ for (var pointIndex = 0; pointIndex < sourcePoints.Count; pointIndex++) {
+ points[pointIndex] = localToWorld.MultiplyPoint3x4(
+ (Vector3)sourcePoints[pointIndex]);
+ }
+ FixturePreviews.Add(new FixturePreview {
+ Points = points,
+ Label = new GUIContent(label),
+ FixtureIndex = previewFixtureIndex,
+ });
+ }
+ }
+
+ private static readonly Color FixtureColor = new(1f, 0.82f, 0.1f, 1f);
+
+ private static void DrawFixturePreviews(WireRailComponent component)
+ {
+ var selectedFixture = WireRailFixtureEditorSelection.GetSelectedIndex(component);
+ var previousColor = Handles.color;
+ var previousZTest = Handles.zTest;
+ Handles.zTest = CompareFunction.Always;
+ foreach (var preview in FixturePreviews) {
+ var selected = selectedFixture >= 0 && preview.FixtureIndex == selectedFixture;
+ var dimmed = selectedFixture >= 0 && !selected;
+ if (selected) {
+ Handles.color = SelectionGlowColor;
+ Handles.DrawAAPolyLine(16f, preview.Points);
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(8f, preview.Points);
+ Handles.color = SelectionColor;
+ Handles.DrawAAPolyLine(5f, preview.Points);
+ } else {
+ var color = FixtureColor;
+ if (dimmed) {
+ color.a = DimmedAlpha;
+ }
+ Handles.color = color;
+ Handles.DrawAAPolyLine(dimmed ? 3f : 4f, preview.Points);
+ }
+ if (preview.Label.text.Length > 0) {
+ Handles.Label(preview.Points[0], preview.Label,
+ dimmed ? EditorStyles.miniBoldLabel : EditorStyles.boldLabel);
+ }
+ }
+ Handles.color = previousColor;
+ Handles.zTest = previousZTest;
+ }
+
+ private static int CountActiveRails(WireRailSegment segment)
+ {
+ var count = 0;
+ for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) {
+ if (segment.IsRailActive(railIndex)) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private static int GetDisplayIndex(IReadOnlyList displayOrder, int layoutIndex)
+ {
+ for (var displayIndex = 0; displayIndex < displayOrder.Count; displayIndex++) {
+ if (displayOrder[displayIndex] == layoutIndex) {
+ return displayIndex;
+ }
+ }
+ return layoutIndex;
+ }
+
+ private const float PanelWidth = 276f;
+ private const float PanelHeightIdle = 98f;
+ private const float PanelHeightEditing = 206f;
+ private static GUIStyle _panelTitleStyle;
+ private static GUIStyle _panelStatusDotStyle;
+ private static GUIStyle _panelHintKeyStyle;
+
+ private static GUIStyle PanelTitleStyle => _panelTitleStyle ??= new GUIStyle(EditorStyles.boldLabel) {
+ fontSize = 13,
+ alignment = TextAnchor.MiddleLeft,
+ };
+
+ private static GUIStyle PanelStatusDotStyle => _panelStatusDotStyle ??= new GUIStyle(EditorStyles.boldLabel) {
+ normal = { textColor = new Color(0.35f, 0.85f, 0.4f) },
+ alignment = TextAnchor.MiddleLeft,
+ };
+
+ private static GUIStyle PanelHintKeyStyle => _panelHintKeyStyle ??= new GUIStyle(EditorStyles.miniBoldLabel) {
+ alignment = TextAnchor.MiddleLeft,
+ };
+
+ private static void DrawEditPanel(WireRailComponent component,
+ SplineContainer container)
+ {
+ var editing = Selection.activeGameObject == container.gameObject
+ && ToolManager.activeContextType == typeof(SplineToolContext);
+ var spline = container.Spline;
+
+ Handles.BeginGUI();
+ GUILayout.BeginArea(new Rect(55f, 42f, PanelWidth,
+ editing ? PanelHeightEditing : PanelHeightIdle), GUIContent.none, GUI.skin.window);
+
+ // Title: icon, name of the thing, name of the object.
+ using (new GUILayout.HorizontalScope()) {
+ var iconRect = GUILayoutUtility.GetRect(18f, 18f, GUILayout.Width(18f));
+ iconRect.y += 1f;
+ GUI.DrawTexture(iconRect, Icons.RampWires(IconSize.Small), ScaleMode.ScaleToFit, true);
+ GUILayout.Space(4f);
+ GUILayout.Label("Wire Rail", PanelTitleStyle, GUILayout.Height(20f));
+ GUILayout.FlexibleSpace();
+ GUILayout.Label(component.name, EditorStyles.miniLabel, GUILayout.Height(20f));
+ }
+
+ // One line that says what this rail is made of.
+ var layoutCount = component.Layouts.Count;
+ var fixtureCount = component.Fixtures.Count;
+ var summary = $"{component.RailCount} {(component.RailCount == 1 ? "rail" : "rails")}"
+ + $" · {layoutCount} {(layoutCount == 1 ? "layout" : "layouts")}"
+ + $" · {fixtureCount} {(fixtureCount == 1 ? "fixture" : "fixtures")}"
+ + $" · {component.SplineLength:0} units"
+ + (spline != null && spline.Closed ? " · closed" : string.Empty);
+ GUILayout.Label(summary, EditorStyles.miniLabel);
+
+ GUILayout.Space(3f);
+ var separator = GUILayoutUtility.GetRect(1f, 1f, GUILayout.ExpandWidth(true));
+ EditorGUI.DrawRect(separator, new Color(0f, 0f, 0f, 0.35f));
+ GUILayout.Space(5f);
+
+ using (new GUILayout.HorizontalScope()) {
+ if (editing) {
+ GUILayout.Label("●", PanelStatusDotStyle, GUILayout.Width(14f), GUILayout.Height(24f));
+ GUILayout.Label("Editing spline", EditorStyles.boldLabel, GUILayout.Height(24f));
+ GUILayout.FlexibleSpace();
+ if (GUILayout.Button(new GUIContent("Done",
+ "Leave spline editing and return to the Wire Rail."),
+ GUILayout.Width(64f), GUILayout.Height(24f))) {
+ FinishSplineEdit(component);
+ }
+ } else {
+ var editIcon = EditorGUIUtility.IconContent("d_editicon.sml").image;
+ if (GUILayout.Button(new GUIContent(" Edit Spline", editIcon,
+ "Move knots, add knots with a double-click, shape tangents."),
+ GUILayout.Height(24f))) {
+ WireRailInspector.EditSpline(container);
+ }
+ GUILayout.Space(4f);
+ EditorGUI.BeginChangeCheck();
+ var showCollider = GUILayout.Toggle(component.ShowColliderPreview,
+ new GUIContent(" Collider", Icons.Physics(),
+ "Show the generated ball channel collider."),
+ GUI.skin.button, GUILayout.Width(88f), GUILayout.Height(24f));
+ if (EditorGUI.EndChangeCheck()) {
+ Undo.RecordObject(component, "Toggle Wire Rail Collider Preview");
+ component.SetShowColliderPreview(showCollider);
+ }
+ }
+ }
+
+ if (editing) {
+ GUILayout.Space(4f);
+ var hasGradeRange = WireRailInspector.TryGetGradeSplineRange(container,
+ out var startKnotIndex, out var endKnotIndex, out var selectedKnotCount);
+ var label = selectedKnotCount == 2
+ ? " Grade Between Selected Knots" : " Grade Heights First → Last";
+ using (new EditorGUI.DisabledScope(!hasGradeRange)) {
+ if (GUILayout.Button(new GUIContent(label, Icons.Horizon(),
+ "Select no knots to grade the complete route, or exactly two knots "
+ + "to grade only the interval between them."),
+ GUILayout.Height(22f))) {
+ if (!WireRailInspector.GradeSplineHeights(component, startKnotIndex,
+ endKnotIndex)) {
+ Debug.LogWarning(
+ "Cannot grade a Wire Rail spline without horizontal length.",
+ component);
+ }
+ }
+ }
+ GUILayout.Space(6f);
+ DrawPanelHint("Click knot", "position gizmo");
+ DrawPanelHint("Double-click line", "add a knot");
+ DrawPanelHint("Double-click knot", "remove it");
+ }
+
+ GUILayout.EndArea();
+ Handles.EndGUI();
+ }
+
+ private static void DrawPanelHint(string key, string action)
+ {
+ using (new GUILayout.HorizontalScope()) {
+ GUILayout.Label(key, PanelHintKeyStyle, GUILayout.Width(118f), GUILayout.Height(15f));
+ GUILayout.Label(action, EditorStyles.miniLabel, GUILayout.Height(15f));
+ }
+ }
+
+ ///
+ /// Leaves the spline tool context and hands the selection back to the Wire Rail
+ /// itself, so the author lands on the component inspector rather than the child.
+ ///
+ private static void FinishSplineEdit(WireRailComponent component)
+ {
+ ToolManager.SetActiveContext();
+ Selection.activeGameObject = component.gameObject;
+ SceneView.RepaintAll();
+ }
+
+ private static void DrawColliderPreview(WireRailComponent component, Mesh mesh,
+ Transform meshTransform)
+ {
+ if (!mesh || mesh.vertexCount == 0) {
+ return;
+ }
+ EnsureColliderPreviewCache(component, mesh, meshTransform.localToWorldMatrix);
+ var previousColor = Handles.color;
+ var previousZTest = Handles.zTest;
+ Handles.zTest = CompareFunction.LessEqual;
+ Handles.color = new Color32(0, 255, 75, 128);
+ for (var i = 0; i < _cachedColliderFaces.Length; i += 3) {
+ Handles.DrawAAConvexPolygon(
+ _cachedColliderFaces[i],
+ _cachedColliderFaces[i + 1],
+ _cachedColliderFaces[i + 2]);
+ }
+ Handles.color = new Color(0f, 1f, 75f / 255f, 0.9f);
+ for (var i = 0; i < _cachedColliderEdges.Length; i += 2) {
+ Handles.DrawLine(_cachedColliderEdges[i], _cachedColliderEdges[i + 1], 2f);
+ }
+ Handles.color = previousColor;
+ Handles.zTest = previousZTest;
+ }
+
+ private static void EnsureColliderPreviewCache(WireRailComponent component, Mesh mesh,
+ Matrix4x4 localToWorld)
+ {
+ if (_cachedColliderMesh == mesh
+ && _cachedColliderGeometryVersion == component.ColliderGeometryVersion
+ && _cachedColliderLocalToWorld == localToWorld) {
+ return;
+ }
+ _cachedColliderMesh = mesh;
+ _cachedColliderGeometryVersion = component.ColliderGeometryVersion;
+ _cachedColliderLocalToWorld = localToWorld;
+ var vertices = mesh.vertices;
+ var indices = mesh.triangles;
+ var edgeKeys = new HashSet();
+ var faceKeys = new HashSet<(int, int, int)>();
+ var faces = new List(indices.Length / 2);
+ var edges = new List(indices.Length);
+ for (var index = 0; index < indices.Length; index += 3) {
+ var first = indices[index];
+ var second = indices[index + 1];
+ var third = indices[index + 2];
+ var sortedFirst = first;
+ var sortedSecond = second;
+ var sortedThird = third;
+ if (sortedFirst > sortedSecond) {
+ (sortedFirst, sortedSecond) = (sortedSecond, sortedFirst);
+ }
+ if (sortedSecond > sortedThird) {
+ (sortedSecond, sortedThird) = (sortedThird, sortedSecond);
+ }
+ if (sortedFirst > sortedSecond) {
+ (sortedFirst, sortedSecond) = (sortedSecond, sortedFirst);
+ }
+ // The physics mesh is two-sided. Cache each geometric triangle once,
+ // independently of winding or emission order.
+ if (faceKeys.Add((sortedFirst, sortedSecond, sortedThird))) {
+ faces.Add(localToWorld.MultiplyPoint3x4(vertices[first]));
+ faces.Add(localToWorld.MultiplyPoint3x4(vertices[second]));
+ faces.Add(localToWorld.MultiplyPoint3x4(vertices[third]));
+ }
+ AddEdge(first, second);
+ AddEdge(second, third);
+ AddEdge(third, first);
+ }
+ _cachedColliderFaces = faces.ToArray();
+ _cachedColliderEdges = edges.ToArray();
+
+ void AddEdge(int first, int second)
+ {
+ var min = (uint)math.min(first, second);
+ var max = (uint)math.max(first, second);
+ var key = ((ulong)min << 32) | max;
+ if (edgeKeys.Add(key)) {
+ edges.Add(localToWorld.MultiplyPoint3x4(vertices[first]));
+ edges.Add(localToWorld.MultiplyPoint3x4(vertices[second]));
+ }
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailInspector.cs.meta
new file mode 100644
index 000000000..6b0f3e841
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailInspector.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 506c0bb66cb2449591cfcf23344b9a6d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailSplineHandles.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailSplineHandles.cs
new file mode 100644
index 000000000..cb89e1018
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailSplineHandles.cs
@@ -0,0 +1,585 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Collections.Generic;
+using Unity.Mathematics;
+using UnityEditor;
+using UnityEditor.EditorTools;
+using UnityEditor.Splines;
+using UnityEngine;
+using UnityEngine.Rendering;
+using UnityEngine.Splines;
+
+namespace VisualPinball.Unity.Editor
+{
+ ///
+ /// Adds VPE's double-click knot workflow to the native spline editing tools.
+ ///
+ [InitializeOnLoad]
+ internal static class WireRailSplineHandles
+ {
+ private const float KnotPickDistance = 14f;
+ private const float CurvePickDistance = 10f;
+ private const float HandleHoverDistance = 15f;
+ private const float KnotRadius = 0.085f;
+ private const float TangentRadius = 0.065f;
+ private const int CurveSamples = 48;
+ private const int KnotControlHint = 0x57524b00;
+ private const int TangentInControlHint = 0x57525400;
+ private const int TangentOutControlHint = 0x57525500;
+ private static readonly Color KnotColor = new(0.08f, 0.76f, 1f, 1f);
+ private static readonly Color SelectedColor = new(1f, 0.72f, 0.06f, 1f);
+ private static readonly Color HoverColor = new(1f, 0.95f, 0.58f, 1f);
+ private static readonly Color TangentColor = new(0.08f, 0.84f, 1f, 1f);
+ private static readonly Color OutlineColor = new(0.015f, 0.02f, 0.025f, 0.95f);
+ private static readonly List SelectedKnots = new();
+ private static readonly List SelectedTangents = new();
+ private static bool _overriding;
+
+ static WireRailSplineHandles()
+ {
+ // Run before the native handles can claim the second mouse-down on a knot.
+ SceneView.beforeSceneGui += OnSceneGUI;
+ SceneView.duringSceneGui += DrawHandleVisuals;
+ ToolManager.activeContextChanged += OnEditorStateChanged;
+ ToolManager.activeToolChanged += OnEditorStateChanged;
+ Selection.selectionChanged += OnEditorStateChanged;
+ }
+
+ private static void OnEditorStateChanged() => SyncOverride(out _, out _, out _);
+
+ private static void OnSceneGUI(SceneView sceneView)
+ {
+ if (!SyncOverride(out var component, out var container, out var spline)) {
+ return;
+ }
+
+ var evt = Event.current;
+ if (evt.type != EventType.MouseDown || evt.button != 0 || evt.clickCount != 2
+ || evt.alt || evt.control || evt.command) {
+ return;
+ }
+
+ var localToWorld = container.transform.localToWorldMatrix;
+ if (TryPickKnot(spline, localToWorld, evt.mousePosition, out var knotIndex)) {
+ if (!RemoveKnot(component, knotIndex)) {
+ var minimum = spline.Closed ? 3 : 2;
+ sceneView.ShowNotification(new GUIContent(
+ $"A wire rail spline needs at least {minimum} knots."));
+ }
+ evt.Use();
+ return;
+ }
+ if (TryPickCurve(spline, localToWorld, evt.mousePosition,
+ out var segmentIndex, out var curveT)) {
+ InsertKnot(component, segmentIndex, curveT);
+ evt.Use();
+ }
+ }
+
+ private static bool TryGetActiveSpline(out WireRailComponent component,
+ out SplineContainer container, out Spline spline)
+ {
+ component = null;
+ container = null;
+ spline = null;
+ if (ToolManager.activeContextType != typeof(SplineToolContext)
+ || !IsTransformTool(ToolManager.activeToolType)) {
+ return false;
+ }
+
+ var selected = Selection.activeGameObject;
+ container = selected ? selected.GetComponent() : null;
+ component = selected ? selected.GetComponentInParent() : null;
+ if (!component || !container || component.SplineContainer != container) {
+ return false;
+ }
+ spline = container.Spline;
+ return spline != null && spline.Count >= 2;
+ }
+
+ private static void DrawHandleVisuals(SceneView sceneView)
+ {
+ if (!SyncOverride(out var component, out var container, out var spline)) {
+ return;
+ }
+
+ var splineInfo = new SplineInfo(container, 0);
+ CollectSelection(splineInfo);
+ using (new SplineHandles.SplineHandleScope()) {
+ SplineHandles.DoSegmentsHandles(splineInfo);
+ }
+ DrawHandleControls(component, spline, splineInfo);
+
+ if (Event.current.type != EventType.Repaint) {
+ return;
+ }
+ CollectSelection(splineInfo);
+ DrawVisualOverlay(sceneView, spline, splineInfo);
+ }
+
+ private static void DrawHandleControls(WireRailComponent component, Spline spline,
+ SplineInfo splineInfo)
+ {
+ var canMoveKnots = ToolManager.activeToolType == typeof(SplineMoveTool);
+ for (var knotIndex = 0; knotIndex < spline.Count; knotIndex++) {
+ var knot = new SelectableKnot(splineInfo, knotIndex);
+ var controlId = GUIUtility.GetControlID(KnotControlHint + knotIndex,
+ FocusType.Passive);
+ var position = ToVector3(knot.Position);
+ var size = HandleUtility.GetHandleSize(position) * KnotRadius * 1.35f;
+ if (canMoveKnots) {
+ var moved = DoFreeMoveControl(controlId, position, size, knot);
+ if ((moved - position).sqrMagnitude > 1e-10f) {
+ MoveKnot(component, knot,
+ new float3(moved.x, moved.y, moved.z));
+ }
+ } else {
+ DoSelectionControl(controlId, position, size, knot);
+ }
+ }
+
+ for (var knotIndex = 0; knotIndex < spline.Count; knotIndex++) {
+ var knot = new SelectableKnot(splineInfo, knotIndex);
+ if (!IsKnotSelected(knotIndex, knot)
+ || !HasEditableTangents(spline.GetTangentMode(knotIndex))) {
+ continue;
+ }
+ if (spline.Closed || knotIndex != 0) {
+ DoTangentControl(component, knot.TangentIn,
+ TangentInControlHint + knotIndex);
+ }
+ if (spline.Closed || knotIndex + 1 != spline.Count) {
+ DoTangentControl(component, knot.TangentOut,
+ TangentOutControlHint + knotIndex);
+ }
+ }
+ }
+
+ private static void DoTangentControl(WireRailComponent component,
+ SelectableTangent tangent, int controlHint)
+ {
+ var controlId = GUIUtility.GetControlID(controlHint, FocusType.Passive);
+ var position = ToVector3(tangent.Position);
+ var size = HandleUtility.GetHandleSize(position) * TangentRadius * 1.5f;
+ var moved = DoFreeMoveControl(controlId, position, size, tangent);
+ if ((moved - position).sqrMagnitude <= 1e-10f) {
+ return;
+ }
+
+ RecordUndo(component, "Move Wire Rail Tangent");
+ ApplyTangentPosition(tangent, new float3(moved.x, moved.y, moved.z));
+ Apply(component);
+ }
+
+ private static Vector3 DoFreeMoveControl(int controlId, Vector3 position,
+ float size, T element)
+ where T : struct, ISelectableElement
+ {
+ var evt = Event.current;
+ if (evt.GetTypeForControl(controlId) == EventType.MouseDown && evt.button == 0
+ && !evt.alt && HandleUtility.nearestControl == controlId) {
+ SelectElement(element, evt);
+ }
+ return Handles.FreeMoveHandle(controlId, position, size, Vector3.zero,
+ InvisibleGripCap);
+ }
+
+ private static void DoSelectionControl(int controlId, Vector3 position,
+ float size, T element) where T : struct, ISelectableElement
+ {
+ var evt = Event.current;
+ switch (evt.GetTypeForControl(controlId)) {
+ case EventType.Layout:
+ case EventType.MouseMove:
+ InvisibleGripCap(controlId, position, Quaternion.identity, size,
+ evt.type);
+ break;
+ case EventType.MouseDown:
+ if (evt.button == 0 && !evt.alt
+ && HandleUtility.nearestControl == controlId) {
+ GUIUtility.hotControl = controlId;
+ SelectElement(element, evt);
+ evt.Use();
+ }
+ break;
+ case EventType.MouseUp:
+ if (evt.button == 0 && GUIUtility.hotControl == controlId) {
+ GUIUtility.hotControl = 0;
+ evt.Use();
+ }
+ break;
+ }
+ }
+
+ private static void SelectElement(T element, Event evt)
+ where T : struct, ISelectableElement
+ {
+ if (evt.control || evt.command) {
+ if (SplineSelection.Contains(element)) {
+ SplineSelection.Remove(element);
+ } else {
+ SplineSelection.Add(element);
+ SplineSelection.SetActive(element);
+ }
+ } else if (evt.shift) {
+ SplineSelection.Add(element);
+ SplineSelection.SetActive(element);
+ } else {
+ SplineSelection.Set(element);
+ }
+ }
+
+ private static void InvisibleGripCap(int controlId, Vector3 position,
+ Quaternion rotation, float size, EventType eventType)
+ {
+ if ((eventType == EventType.Layout || eventType == EventType.MouseMove)
+ && !Tools.viewToolActive && Tools.current != Tool.View && !Event.current.alt) {
+ HandleUtility.AddControl(controlId,
+ HandleUtility.DistanceToCircle(position, size));
+ }
+ }
+
+ private static void ApplyTangentPosition(SelectableTangent tangent,
+ float3 targetPosition)
+ {
+ var knot = tangent.Owner;
+ if (knot.Mode == TangentMode.Broken) {
+ tangent.Position = targetPosition;
+ return;
+ }
+
+ var splineTrs = knot.SplineInfo.LocalToWorld;
+ var splineTrsInv = math.inverse(splineTrs);
+ var splinePosition = splineTrs.c3.xyz;
+ var splineRotation = new quaternion(splineTrs);
+ var unscaledTarget = splinePosition + math.rotate(splineRotation,
+ math.transform(splineTrsInv, targetPosition));
+ var unscaledKnot = splinePosition + math.rotate(splineRotation,
+ math.transform(splineTrsInv, knot.Position));
+ var knotRotationInv = math.inverse(knot.Rotation);
+ var forward = (tangent.TangentIndex == (int)BezierTangent.In ? -1f : 1f)
+ * math.normalizesafe(unscaledTarget - unscaledKnot);
+ var up = math.mul(knot.Rotation, math.up());
+ var targetRotation = quaternion.LookRotationSafe(forward, up);
+ var rotationDelta = math.mul(targetRotation, knotRotationInv);
+ var targetLocalDirection = math.rotate(knotRotationInv,
+ unscaledTarget - unscaledKnot);
+ var magnitudeDelta = math.length(targetLocalDirection)
+ - math.length(tangent.LocalDirection);
+
+ knot.Rotation = math.mul(rotationDelta, knot.Rotation);
+ var localDirection = tangent.LocalDirection;
+ var fallbackDirection = tangent.TangentIndex == (int)BezierTangent.In
+ ? new float3(0f, 0f, -1f) : new float3(0f, 0f, 1f);
+ var direction = math.normalizesafe(localDirection, fallbackDirection);
+ tangent.LocalDirection = direction
+ * math.max(0f, math.length(localDirection) + magnitudeDelta);
+ }
+
+ private static void MoveKnot(WireRailComponent component, SelectableKnot knot,
+ float3 targetPosition)
+ {
+ RecordUndo(component, "Move Wire Rail Knot");
+ knot.Position = targetPosition;
+ Apply(component);
+ }
+
+ private static void CollectSelection(SplineInfo splineInfo)
+ {
+ SelectedKnots.Clear();
+ SelectedTangents.Clear();
+ SplineSelection.GetElements(splineInfo, SelectedKnots);
+ SplineSelection.GetElements(splineInfo, SelectedTangents);
+ }
+
+ private static void DrawVisualOverlay(SceneView sceneView, Spline spline,
+ SplineInfo splineInfo)
+ {
+ var mouse = Event.current.mousePosition;
+ var camera = sceneView ? sceneView.camera : null;
+ var normal = camera ? camera.transform.forward : Vector3.up;
+ var previousColor = Handles.color;
+ var previousZTest = Handles.zTest;
+ Handles.zTest = CompareFunction.Always;
+ string tooltip = null;
+
+ for (var knotIndex = 0; knotIndex < spline.Count; knotIndex++) {
+ var knot = new SelectableKnot(splineInfo, knotIndex);
+ if (!IsKnotSelected(knotIndex, knot)
+ || !HasEditableTangents(spline.GetTangentMode(knotIndex))) {
+ continue;
+ }
+ if (spline.Closed || knotIndex != 0) {
+ DrawTangentVisual(knot.Position, knot.TangentIn, "Incoming tangent",
+ normal, mouse, ref tooltip);
+ }
+ if (spline.Closed || knotIndex + 1 != spline.Count) {
+ DrawTangentVisual(knot.Position, knot.TangentOut, "Outgoing tangent",
+ normal, mouse, ref tooltip);
+ }
+ }
+
+ for (var knotIndex = 0; knotIndex < spline.Count; knotIndex++) {
+ var knot = new SelectableKnot(splineInfo, knotIndex);
+ var position = ToVector3(knot.Position);
+ var hovered = IsHovered(position, mouse);
+ var selected = IsKnotSelected(knotIndex, knot);
+ var radius = HandleUtility.GetHandleSize(position) * KnotRadius;
+ DrawGrip(position, normal, radius, selected, hovered, KnotColor);
+ if (hovered) {
+ tooltip = $"Knot {knotIndex + 1} · double-click to remove";
+ }
+ }
+
+ Handles.zTest = previousZTest;
+ Handles.color = previousColor;
+ if (!string.IsNullOrEmpty(tooltip)) {
+ DrawTooltip(mouse, tooltip);
+ }
+ }
+
+ private static bool SyncOverride(out WireRailComponent component,
+ out SplineContainer container, out Spline spline)
+ {
+ var shouldOverride = TryGetActiveSpline(out component, out container, out spline);
+ if (shouldOverride) {
+ SplineToolContext.useCustomSplineHandles = true;
+ } else if (_overriding && !ToolOwnsHandles(ToolManager.activeToolType)
+ && !SelectionUsesDragPointHandles()) {
+ SplineToolContext.useCustomSplineHandles = false;
+ }
+ if (shouldOverride != _overriding) {
+ _overriding = shouldOverride;
+ SceneView.RepaintAll();
+ }
+ return shouldOverride;
+ }
+
+ private static bool SelectionUsesDragPointHandles()
+ {
+ foreach (var gameObject in Selection.gameObjects) {
+ if (gameObject.TryGetComponent(out _)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static bool ToolOwnsHandles(Type tool)
+ => tool != null && typeof(SplineTool).IsAssignableFrom(tool)
+ && !IsTransformTool(tool);
+
+ private static bool IsKnotSelected(int knotIndex, SelectableKnot knot)
+ {
+ if (SelectedKnots.Contains(knot)) {
+ return true;
+ }
+ for (var i = 0; i < SelectedTangents.Count; i++) {
+ if (SelectedTangents[i].KnotIndex == knotIndex) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static bool HasEditableTangents(TangentMode mode)
+ => mode != TangentMode.Linear && mode != TangentMode.AutoSmooth;
+
+ private static void DrawTangentVisual(float3 knotPosition,
+ SelectableTangent tangent, string label, Vector3 normal, Vector2 mouse,
+ ref string tooltip)
+ {
+ var start = ToVector3(knotPosition);
+ var end = ToVector3(tangent.Position);
+ if ((end - start).sqrMagnitude <= 1e-8f) {
+ return;
+ }
+
+ var selected = SelectedTangents.Contains(tangent);
+ var hovered = IsHovered(end, mouse);
+ Handles.color = OutlineColor;
+ Handles.DrawAAPolyLine(7f, start, end);
+ Handles.color = selected ? SelectedColor : TangentColor;
+ Handles.DrawAAPolyLine(selected ? 4f : 3.5f, start, end);
+ var radius = HandleUtility.GetHandleSize(end) * TangentRadius;
+ DrawGrip(end, normal, radius, selected, hovered, TangentColor);
+ if (hovered) {
+ tooltip = $"{label} · drag to shape the curve";
+ }
+ }
+
+ private static void DrawGrip(Vector3 position, Vector3 normal, float radius,
+ bool selected, bool hovered, Color idleColor)
+ {
+ Handles.color = OutlineColor;
+ Handles.DrawSolidDisc(position, normal, radius * 1.35f);
+ Handles.color = hovered ? HoverColor : selected ? SelectedColor : idleColor;
+ Handles.DrawSolidDisc(position, normal, radius);
+ Handles.color = Color.white;
+ Handles.DrawWireDisc(position, normal, radius, 2f);
+ Handles.color = OutlineColor;
+ Handles.DrawSolidDisc(position, normal, radius * 0.22f);
+ }
+
+ private static bool IsHovered(Vector3 worldPosition, Vector2 mouse)
+ => Vector2.Distance(HandleUtility.WorldToGUIPoint(worldPosition), mouse)
+ <= HandleHoverDistance;
+
+ private static void DrawTooltip(Vector2 mouse, string text)
+ {
+ Handles.BeginGUI();
+ var content = new GUIContent(text);
+ var size = EditorStyles.helpBox.CalcSize(content);
+ GUI.Label(new Rect(mouse.x + 16f, mouse.y + 18f, size.x + 10f,
+ size.y + 6f), content, EditorStyles.helpBox);
+ Handles.EndGUI();
+ }
+
+ private static bool IsTransformTool(Type tool)
+ => tool == typeof(SplineMoveTool) || tool == typeof(SplineRotateTool)
+ || tool == typeof(SplineScaleTool);
+
+ private static bool TryPickKnot(Spline spline, Matrix4x4 localToWorld,
+ Vector2 mouse, out int knotIndex)
+ {
+ knotIndex = -1;
+ var closest = KnotPickDistance;
+ for (var candidate = 0; candidate < spline.Count; candidate++) {
+ var world = localToWorld.MultiplyPoint3x4(ToVector3(
+ spline[candidate].Position));
+ var distance = Vector2.Distance(HandleUtility.WorldToGUIPoint(world), mouse);
+ if (distance >= closest) {
+ continue;
+ }
+ closest = distance;
+ knotIndex = candidate;
+ }
+ return knotIndex >= 0;
+ }
+
+ private static bool TryPickCurve(Spline spline, Matrix4x4 localToWorld,
+ Vector2 mouse, out int segmentIndex, out float curveT)
+ {
+ segmentIndex = -1;
+ curveT = 0f;
+ var closest = CurvePickDistance;
+ var segmentCount = spline.Closed ? spline.Count : spline.Count - 1;
+ for (var candidateSegment = 0; candidateSegment < segmentCount;
+ candidateSegment++) {
+ var curve = spline.GetCurve(candidateSegment);
+ var previousT = 0f;
+ var previous = ToGuiPoint(localToWorld, curve.P0);
+ for (var sample = 1; sample <= CurveSamples; sample++) {
+ var currentT = sample / (float)CurveSamples;
+ var current = ToGuiPoint(localToWorld,
+ CurveUtility.EvaluatePosition(curve, currentT));
+ var line = current - previous;
+ var amount = line.sqrMagnitude <= 1e-8f ? 0f
+ : math.saturate(Vector2.Dot(mouse - previous, line) / line.sqrMagnitude);
+ var distance = Vector2.Distance(mouse, previous + line * amount);
+ if (distance < closest) {
+ closest = distance;
+ segmentIndex = candidateSegment;
+ curveT = math.lerp(previousT, currentT, amount);
+ }
+ previous = current;
+ previousT = currentT;
+ }
+ }
+ return segmentIndex >= 0;
+ }
+
+ private static Vector2 ToGuiPoint(Matrix4x4 localToWorld, float3 local)
+ => HandleUtility.WorldToGUIPoint(localToWorld.MultiplyPoint3x4(ToVector3(local)));
+
+ private static Vector3 ToVector3(float3 value) => new(value.x, value.y, value.z);
+
+ private static bool InsertKnot(WireRailComponent component, int segmentIndex,
+ float curveT)
+ {
+ var container = component.SplineContainer;
+ var spline = container.Spline;
+ var segmentCount = spline.Closed ? spline.Count : spline.Count - 1;
+ if (segmentIndex < 0 || segmentIndex >= segmentCount) {
+ return false;
+ }
+
+ curveT = math.saturate(curveT);
+ var curve = spline.GetCurve(segmentIndex);
+ var position = CurveUtility.EvaluatePosition(curve, curveT);
+ var knotT = segmentIndex + curveT;
+ var normalizedT = SplineUtility.GetNormalizedInterpolation(spline, knotT,
+ PathIndexUnit.Knot);
+ spline.Evaluate(normalizedT, out _, out var tangent, out var up);
+ tangent = math.normalizesafe(tangent, new float3(0f, 1f, 0f));
+ up -= tangent * math.dot(up, tangent);
+ up = math.normalizesafe(up, new float3(0f, 0f, 1f));
+ var rotation = quaternion.LookRotationSafe(tangent, up);
+ // On a closed route the last curve runs from the last knot back to the first.
+ // Appending keeps knot 0, and with it distance zero, where the author put it;
+ // inserting at index 0 would move every layout and fixture along the route.
+ var knotIndex = segmentIndex + 1;
+
+ RecordUndo(component, "Add Wire Rail Knot");
+ spline.Insert(knotIndex, new BezierKnot(position) { Rotation = rotation },
+ TangentMode.AutoSmooth);
+ Apply(component);
+ SplineSelection.Set(new SelectableKnot(new SplineInfo(container, 0),
+ knotIndex));
+ return true;
+ }
+
+ private static bool RemoveKnot(WireRailComponent component, int knotIndex)
+ {
+ var container = component.SplineContainer;
+ var spline = container.Spline;
+ if (knotIndex < 0 || knotIndex >= spline.Count) {
+ return false;
+ }
+ var minimum = spline.Closed ? 3 : 2;
+ if (spline.Count <= minimum) {
+ return false;
+ }
+
+ RecordUndo(component, "Remove Wire Rail Knot");
+ spline.RemoveAt(knotIndex);
+ Apply(component);
+ SplineSelection.Set(new SelectableKnot(new SplineInfo(container, 0),
+ math.min(knotIndex, spline.Count - 1)));
+ return true;
+ }
+
+ private static void RecordUndo(WireRailComponent component, string name)
+ {
+ // Record in the same GUI event as the mutation. Unity's spline tools follow
+ // this pattern because a delta record made only on MouseDown is discarded
+ // before the first MouseDrag changes the nested spline data.
+ Undo.RecordObjects(
+ new UnityEngine.Object[] { component, component.SplineContainer }, name);
+ }
+
+ private static void Apply(WireRailComponent component)
+ {
+ EditorUtility.SetDirty(component.SplineContainer);
+ EditorUtility.SetDirty(component);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(component.SplineContainer);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(component);
+ SceneView.RepaintAll();
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailSplineHandles.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailSplineHandles.cs.meta
new file mode 100644
index 000000000..c1063d264
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/WireRail/WireRailSplineHandles.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8fde20e4a0d0482f8528483e567bd54e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs
index e9c05ef23..8e66cda1a 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs
@@ -247,14 +247,14 @@ void CountChanges(Spline changedSpline, int _, SplineModification __)
}
[Test]
- public void ShouldRemoveDuplicateGeneratedSplinesDeterministically()
+ public void ShouldRemoveAnEmptyDuplicateGeneratedSplineDeterministically()
{
var go = new GameObject("Rubber");
try {
var rubber = go.AddComponent();
rubber.DragPoints = CreateDragPoints();
var original = rubber.DragPointSpline;
- DragPointSplineComponent.Create(rubber, CreateDragPoints());
+ DragPointSplineComponent.Create(rubber, new DragPointData[0]);
ClearSplineReference(rubber);
LogAssert.Expect(LogType.Warning,
"Removing generated spline child 'Spline' from 'Rubber' because it duplicates another drag-point spline.");
@@ -268,6 +268,30 @@ public void ShouldRemoveDuplicateGeneratedSplinesDeterministically()
}
}
+ [Test]
+ public void ShouldKeepADuplicateGeneratedSplineThatStillHoldsKnots()
+ {
+ var go = new GameObject("Rubber");
+ try {
+ var rubber = go.AddComponent();
+ rubber.DragPoints = CreateDragPoints();
+ var original = rubber.DragPointSpline;
+ var duplicate = DragPointSplineComponent.Create(rubber, CreateDragPoints());
+ ClearSplineReference(rubber);
+ LogAssert.Expect(LogType.Warning,
+ "Keeping generated spline child 'Spline' of 'Rubber' even though it duplicates another drag-point spline, because it still holds 4 knots.");
+
+ Assert.That(rubber.DragPointSpline, Is.SameAs(original));
+ Assert.That(duplicate, Is.Not.Null, "knots are the only copy of the geometry and must never be destroyed");
+ Assert.That(duplicate.gameObject.activeSelf, Is.True);
+ Assert.That(go.GetComponentsInChildren(true),
+ Has.Length.EqualTo(2));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
[Test]
public void ShouldRemoveInvalidGeneratedChildrenWithoutSkippingTheSpline()
{
@@ -362,6 +386,32 @@ private static Vector3[] GetWorldPositions(SplineContainer container)
return positions;
}
+ [Test]
+ public void ShouldLockTheGeneratedSplineTransform()
+ {
+ var go = new GameObject("Rubber");
+ try {
+ var rubber = go.AddComponent();
+ rubber.DragPoints = CreateDragPoints();
+ var spline = rubber.DragPointSpline;
+
+ Assert.That(spline.transform.hideFlags & HideFlags.NotEditable,
+ Is.EqualTo(HideFlags.NotEditable),
+ "the generated child carries the VPX conversion and must be locked");
+
+ spline.transform.hideFlags = HideFlags.None;
+ ClearSplineReference(rubber);
+
+ Assert.That(rubber.DragPointSpline, Is.SameAs(spline));
+ Assert.That(spline.transform.hideFlags & HideFlags.NotEditable,
+ Is.EqualTo(HideFlags.NotEditable),
+ "an existing child is locked again when it is resolved");
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
private static void ClearSplineReference(RubberComponent rubber)
{
typeof(RubberComponent)
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DeviceCoilTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DeviceCoilTests.cs
new file mode 100644
index 000000000..35801667d
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DeviceCoilTests.cs
@@ -0,0 +1,54 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System.Collections.Generic;
+using NUnit.Framework;
+
+namespace VisualPinball.Unity.Test
+{
+ public class DeviceCoilTests
+ {
+ [Test]
+ public void ShouldPublishStatusForASimulationThreadDispatchedTransition()
+ {
+ var mainThreadEnables = 0;
+ var simulationThreadEnables = 0;
+ var statusEvents = new List();
+ var coil = new DeviceCoil(null,
+ onEnable: () => mainThreadEnables++,
+ onEnableSimulationThread: () => simulationThreadEnables++);
+ coil.CoilStatusChanged += (_, args) => statusEvents.Add(args.IsEnergized);
+
+ // native poll on the simulation thread, then the managed mirror of the same press
+ coil.OnCoilSimulationThread(1f);
+ coil.PublishSimulationThreadDispatchedState(true);
+
+ Assert.That(simulationThreadEnables, Is.EqualTo(1));
+ Assert.That(mainThreadEnables, Is.Zero, "the physics effect belongs to the simulation thread");
+ Assert.That(coil.IsEnabled, Is.True);
+ Assert.That(statusEvents, Is.EqualTo(new[] { true }),
+ "listeners such as coil sounds and animations still see the transition");
+
+ // the managed mirror may also arrive first; it must not fire physics twice
+ coil.PublishSimulationThreadDispatchedState(false);
+ coil.OnCoilSimulationThread(0f);
+ coil.OnCoil(true);
+
+ Assert.That(mainThreadEnables, Is.Zero, "a later main-thread call keeps its physics suppressed");
+ Assert.That(statusEvents, Is.EqualTo(new[] { true, false, true }));
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DeviceCoilTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DeviceCoilTests.cs.meta
new file mode 100644
index 000000000..a75e8d3ea
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/DeviceCoilTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 632a6dd05cc84a54a684ae4dd8c9bca3
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs
index 374909282..fa0f350b5 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs
@@ -15,6 +15,7 @@
// along with this program. If not, see .
using System;
+using System.Linq;
using System.Text;
using NativeTrees;
using NUnit.Framework;
@@ -27,6 +28,36 @@ namespace VisualPinball.Unity.Test
{
public class MagnetPhysicsTests
{
+ [Test]
+ public void ComponentExposesHitAndBallHeldSwitches()
+ {
+ var gameObject = new GameObject("Magnet Switch Test");
+ try {
+ var component = gameObject.AddComponent();
+ var switches = component.AvailableSwitches.ToArray();
+
+ Assert.That(switches.Select(item => item.Id), Is.EqualTo(new[] {
+ MagnetComponent.BallHeldSwitchItem,
+ MagnetComponent.HitSwitchItem
+ }));
+ Assert.That(switches.Select(item => item.IsPulseSwitch), Is.EqualTo(new[] { false, true }));
+ } finally {
+ UnityEngine.Object.DestroyImmediate(gameObject);
+ }
+ }
+
+ [Test]
+ public void PulseDeviceSwitchMarksWireDestinationAsPulseSource()
+ {
+ var deviceSwitch = new DeviceSwitch(MagnetComponent.HitSwitchItem, true,
+ SwitchDefault.NormallyOpen, null, null);
+ var wireConfig = new WireDestConfig(new WireMapping());
+
+ deviceSwitch.AddWireDest(wireConfig);
+
+ Assert.That(wireConfig.IsPulseSource, Is.True);
+ }
+
[Test]
public void ComponentDistancesAreAuthoredDirectlyInVpxUnits()
{
@@ -80,6 +111,7 @@ public void CylindricalDampingRoundTripsAndLegacyPackagesUseTheDefault()
var component = gameObject.AddComponent();
component.CylindricalDamping = 0.35f;
component.GenerateCylinderCollider = true;
+ component.HitThreshold = 4.5f;
var bytes = component.Pack();
component.CylindricalDamping = 2f;
@@ -87,12 +119,15 @@ public void CylindricalDampingRoundTripsAndLegacyPackagesUseTheDefault()
Assert.That(component.CylindricalDamping, Is.EqualTo(0.35f));
Assert.That(component.GenerateCylinderCollider, Is.True);
+ Assert.That(component.HitThreshold, Is.EqualTo(4.5f));
component.CylindricalDamping = 0f;
component.GenerateCylinderCollider = true;
+ component.HitThreshold = 0f;
component.Unpack(Encoding.UTF8.GetBytes("{}"));
Assert.That(component.CylindricalDamping, Is.EqualTo(MagnetComponent.DefaultCylindricalDamping));
Assert.That(component.GenerateCylinderCollider, Is.False);
+ Assert.That(component.HitThreshold, Is.EqualTo(MagnetComponent.DefaultHitThreshold));
} finally {
UnityEngine.Object.DestroyImmediate(gameObject);
}
@@ -109,6 +144,7 @@ public void GeneratedCylinderColliderUsesAuthoredVpxDimensions()
component.MagnetType = MagnetType.Cylindrical;
component.CylinderRadius = 25.16466f;
component.CylinderHeight = 49.92385f;
+ component.HitThreshold = 3.5f;
var collidable = (ICollidableComponent)component;
Assert.That(collidable.IsCollidable, Is.True,
@@ -128,6 +164,8 @@ public void GeneratedCylinderColliderUsesAuthoredVpxDimensions()
Assert.That(collider.ZLow, Is.Zero);
Assert.That(collider.ZHigh, Is.EqualTo(component.CylinderHeight));
Assert.That(collider.Header.ItemId, Is.EqualTo(component.ItemId));
+ Assert.That(collider.Header.FireEvents, Is.True);
+ Assert.That(collider.Header.Threshold, Is.EqualTo(component.HitThreshold));
Assert.That(matrix.GetScale(), Is.EqualTo(new float3(1f)),
"visual transform scale must not resize dimensions authored directly in VPX units");
} finally {
@@ -137,6 +175,44 @@ public void GeneratedCylinderColliderUsesAuthoredVpxDimensions()
}
}
+ [Test]
+ public void GeneratedCylinderColliderFiresHitOnlyAboveThreshold()
+ {
+ var collider = new CircleCollider(float2.zero, 10f, 0f, 20f, new ColliderInfo {
+ ItemId = 1,
+ ItemType = VisualPinball.Engine.VPT.ItemType.Primitive,
+ HitThreshold = 5f,
+ FireEvents = true
+ });
+ var state = new PhysicsState();
+ var events = new NativeQueue(Allocator.Temp);
+ try {
+ var writer = events.AsParallelWriter();
+ var collision = new CollisionEventData {
+ HitNormal = new float3(1f, 0f, 0f),
+ HitDistance = 0f
+ };
+ var ball = new BallState {
+ Id = 1,
+ Position = new float3(11f, 0f, 10f),
+ EventPosition = new float3(100f, 0f, 0f),
+ Velocity = new float3(-4f, 0f, 0f),
+ Radius = 1f,
+ Mass = 1f
+ };
+
+ collider.Collide(ref ball, ref writer, in collision, ref state);
+ Assert.That(events.Count, Is.Zero);
+
+ ball.EventPosition = new float3(100f, 0f, 0f);
+ ball.Velocity = new float3(-6f, 0f, 0f);
+ collider.Collide(ref ball, ref writer, in collision, ref state);
+ Assert.That(events.Count, Is.EqualTo(1));
+ } finally {
+ events.Dispose();
+ }
+ }
+
[Test]
public void CylindricalGrabUsesAutomaticContactTolerance()
{
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail.meta
new file mode 100644
index 000000000..f763775ee
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 176608c5ed0c4197bce472a68f5c9966
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden.meta
new file mode 100644
index 000000000..091eba1c6
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 965efd0a6a684051a1b1c75d4457346c
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-spline-v1.json b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-spline-v1.json
new file mode 100644
index 000000000..e5a64bc79
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-spline-v1.json
@@ -0,0 +1,78 @@
+{
+ "Version": 1,
+ "Closed": false,
+ "Knots": [
+ {
+ "Position": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "TangentIn": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": -50.0
+ },
+ "TangentOut": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 50.0
+ },
+ "Rotation": {
+ "X": 0.0,
+ "Y": 0.707106769,
+ "Z": 0.707106769,
+ "W": 0.0
+ },
+ "TangentMode": 0
+ },
+ {
+ "Position": {
+ "X": 0.0,
+ "Y": 500.0,
+ "Z": 0.0
+ },
+ "TangentIn": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": -157.49437
+ },
+ "TangentOut": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 144.264069
+ },
+ "Rotation": {
+ "X": 0.0501050055,
+ "Y": 0.6880194,
+ "Z": 0.7220484,
+ "W": 0.05258318
+ },
+ "TangentMode": 0
+ },
+ {
+ "Position": {
+ "X": 120.0,
+ "Y": 900.0,
+ "Z": 40.0
+ },
+ "TangentIn": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": -41.9523544
+ },
+ "TangentOut": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 41.9523544
+ },
+ "Rotation": {
+ "X": -0.479418963,
+ "Y": 0.4716825,
+ "Z": 0.34554565,
+ "W": 0.6544245
+ },
+ "TangentMode": 0
+ }
+ ]
+}
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-spline-v1.json.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-spline-v1.json.meta
new file mode 100644
index 000000000..c75db9fe8
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-spline-v1.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 03ad48a3835b4471b97148c8cdf1cb62
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-v1.json b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-v1.json
new file mode 100644
index 000000000..adf890d61
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-v1.json
@@ -0,0 +1,871 @@
+{
+ "Version": 1,
+ "RailCount": 5,
+ "WireDiameter": 7.5,
+ "WireCapBevelSize": 0.5,
+ "RadialSegments": 10,
+ "RenderSamplesPerSegment": 16,
+ "ReferenceBallDiameter": 50.0,
+ "ColliderSamplesPerSegment": 8,
+ "ShowColliderPreview": true,
+ "OverwritePhysics": true,
+ "Elasticity": 0.3,
+ "ElasticityFalloff": 0.5,
+ "Friction": 0.15,
+ "Scatter": 0.0,
+ "Layouts": [
+ {
+ "Distance": 0.0,
+ "ThirdRailSide": 1,
+ "RailOffsets": [
+ {
+ "X": -15.0,
+ "Y": 0.0
+ },
+ {
+ "X": 15.0,
+ "Y": 0.0
+ },
+ {
+ "X": -30.0,
+ "Y": 30.0
+ },
+ {
+ "X": 30.0,
+ "Y": 30.0
+ },
+ {
+ "X": 0.0,
+ "Y": 60.0
+ }
+ ],
+ "ActiveRails": [
+ true,
+ true,
+ true,
+ true,
+ true
+ ],
+ "WireDiameters": [
+ 7.5,
+ 7.5,
+ 7.5,
+ 7.5,
+ 7.5
+ ],
+ "Transitions": [
+ {
+ "Overridden": false,
+ "Continuous": true,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 1.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 1.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ },
+ {
+ "Overridden": true,
+ "Continuous": true,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 0.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ },
+ {
+ "Overridden": true,
+ "Continuous": false,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 1.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 1.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ },
+ {
+ "Overridden": false,
+ "Continuous": true,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 1.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 1.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ },
+ {
+ "Overridden": false,
+ "Continuous": true,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 1.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 1.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ }
+ ]
+ },
+ {
+ "Distance": 200.0,
+ "ThirdRailSide": 1,
+ "RailOffsets": [
+ {
+ "X": -21.0,
+ "Y": 3.0
+ },
+ {
+ "X": 15.0,
+ "Y": 0.0
+ },
+ {
+ "X": -30.0,
+ "Y": 30.0
+ },
+ {
+ "X": 30.0,
+ "Y": 30.0
+ },
+ {
+ "X": 0.0,
+ "Y": 60.0
+ }
+ ],
+ "ActiveRails": [
+ true,
+ true,
+ true,
+ true,
+ false
+ ],
+ "WireDiameters": [
+ 7.5,
+ 7.5,
+ 7.5,
+ 7.5,
+ 7.5
+ ],
+ "Transitions": [
+ {
+ "Overridden": false,
+ "Continuous": true,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 1.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 1.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ },
+ {
+ "Overridden": false,
+ "Continuous": true,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 1.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 1.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ },
+ {
+ "Overridden": false,
+ "Continuous": true,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 1.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 1.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ },
+ {
+ "Overridden": false,
+ "Continuous": true,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 1.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 1.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ },
+ {
+ "Overridden": false,
+ "Continuous": true,
+ "Curve": {
+ "keys": [
+ {
+ "time": 0.0,
+ "value": 0.0,
+ "inTangent": 0.0,
+ "outTangent": 1.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ },
+ {
+ "time": 1.0,
+ "value": 1.0,
+ "inTangent": 1.0,
+ "outTangent": 0.0,
+ "inWeight": 0.0,
+ "outWeight": 0.0,
+ "weightedMode": 0,
+ "tangentMode": 0
+ }
+ ],
+ "length": 2,
+ "preWrapMode": 8,
+ "postWrapMode": 8
+ }
+ }
+ ]
+ },
+ {
+ "Distance": 400.0,
+ "ThirdRailSide": 1,
+ "RailOffsets": [
+ {
+ "X": -15.0,
+ "Y": 0.0
+ },
+ {
+ "X": 15.0,
+ "Y": 0.0
+ },
+ {
+ "X": -30.0,
+ "Y": 30.0
+ },
+ {
+ "X": 30.0,
+ "Y": 30.0
+ },
+ {
+ "X": 0.0,
+ "Y": 60.0
+ }
+ ],
+ "ActiveRails": [
+ true,
+ true,
+ true,
+ true,
+ true
+ ],
+ "WireDiameters": [
+ 7.5,
+ 7.5,
+ 7.5,
+ 7.5,
+ 7.5
+ ],
+ "Transitions": []
+ }
+ ],
+ "LayoutDisplayOrder": [
+ 0,
+ 1,
+ 2
+ ],
+ "Fixtures": [
+ {
+ "Kind": "Ring",
+ "Distance": 120.0,
+ "SolderThreshold": 2.0,
+ "SolderSize": 1.0,
+ "Enabled": true,
+ "Diameter": 7.5,
+ "HasCutout": true,
+ "CutoutStartAngle": 30.0,
+ "CutoutEndAngle": 90.0,
+ "HasStraightSection": true,
+ "StraightStartAngle": 200.0,
+ "StraightEndAngle": 300.0,
+ "Scale": 1.4,
+ "RingDensity": 24,
+ "LateralOffset": 2.0,
+ "VerticalOffset": -3.0,
+ "Angle": 0.0,
+ "Rotation": 0.0,
+ "LengthAdjustment": 0.0,
+ "Endpoint": 0,
+ "FirstRailIndex": 0,
+ "SecondRailIndex": 0,
+ "RailOffsets": null,
+ "BottomLength": 0.0,
+ "LeftLength": 0.0,
+ "RightLength": 0.0,
+ "CornerRadius": 0.0,
+ "LoopDiameter": 0.0,
+ "LeadLength": 0.0,
+ "TangentLength": 0.0,
+ "RailOffset": 0.0,
+ "Offset": 0.0,
+ "DropLength": 0.0,
+ "ZAngle": 0.0,
+ "LegSide": 0,
+ "StartDirection": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "StartLength": 0.0,
+ "FootPosition": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootRotation": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootClockwise": false,
+ "FootWidth": 0.0,
+ "FootLength": 0.0,
+ "FootConnectionLength": 0.0
+ },
+ {
+ "Kind": "Cradle",
+ "Distance": 150.0,
+ "SolderThreshold": 2.0,
+ "SolderSize": 1.0,
+ "Enabled": true,
+ "Diameter": 7.5,
+ "HasCutout": false,
+ "CutoutStartAngle": 0.0,
+ "CutoutEndAngle": 0.0,
+ "HasStraightSection": false,
+ "StraightStartAngle": 0.0,
+ "StraightEndAngle": 0.0,
+ "Scale": 0.0,
+ "RingDensity": 32,
+ "LateralOffset": 0.0,
+ "VerticalOffset": 0.0,
+ "Angle": 53.1301,
+ "Rotation": 0.0,
+ "LengthAdjustment": 0.0,
+ "Endpoint": 0,
+ "FirstRailIndex": 0,
+ "SecondRailIndex": 0,
+ "RailOffsets": null,
+ "BottomLength": 8.0,
+ "LeftLength": 85.0,
+ "RightLength": 85.0,
+ "CornerRadius": 8.0,
+ "LoopDiameter": 0.0,
+ "LeadLength": 0.0,
+ "TangentLength": 0.0,
+ "RailOffset": 0.0,
+ "Offset": 0.0,
+ "DropLength": 0.0,
+ "ZAngle": 0.0,
+ "LegSide": 0,
+ "StartDirection": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "StartLength": 0.0,
+ "FootPosition": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootRotation": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootClockwise": false,
+ "FootWidth": 0.0,
+ "FootLength": 0.0,
+ "FootConnectionLength": 0.0
+ },
+ {
+ "Kind": "Rung",
+ "Distance": 180.0,
+ "SolderThreshold": 2.0,
+ "SolderSize": 1.5,
+ "Enabled": true,
+ "Diameter": 7.5,
+ "HasCutout": false,
+ "CutoutStartAngle": 0.0,
+ "CutoutEndAngle": 0.0,
+ "HasStraightSection": false,
+ "StraightStartAngle": 0.0,
+ "StraightEndAngle": 0.0,
+ "Scale": 0.0,
+ "RingDensity": 0,
+ "LateralOffset": 0.0,
+ "VerticalOffset": 0.0,
+ "Angle": 0.0,
+ "Rotation": 0.0,
+ "LengthAdjustment": 0.0,
+ "Endpoint": 0,
+ "FirstRailIndex": 0,
+ "SecondRailIndex": 1,
+ "RailOffsets": null,
+ "BottomLength": 0.0,
+ "LeftLength": 0.0,
+ "RightLength": 0.0,
+ "CornerRadius": 0.0,
+ "LoopDiameter": 0.0,
+ "LeadLength": 0.0,
+ "TangentLength": 0.0,
+ "RailOffset": 0.0,
+ "Offset": 0.0,
+ "DropLength": 0.0,
+ "ZAngle": 0.0,
+ "LegSide": 0,
+ "StartDirection": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "StartLength": 0.0,
+ "FootPosition": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootRotation": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootClockwise": false,
+ "FootWidth": 0.0,
+ "FootLength": 0.0,
+ "FootConnectionLength": 0.0
+ },
+ {
+ "Kind": "Stand",
+ "Distance": 220.0,
+ "SolderThreshold": 2.0,
+ "SolderSize": 1.0,
+ "Enabled": false,
+ "Diameter": 7.5,
+ "HasCutout": false,
+ "CutoutStartAngle": 0.0,
+ "CutoutEndAngle": 0.0,
+ "HasStraightSection": false,
+ "StraightStartAngle": 0.0,
+ "StraightEndAngle": 0.0,
+ "Scale": 0.0,
+ "RingDensity": 0,
+ "LateralOffset": 0.0,
+ "VerticalOffset": 0.0,
+ "Angle": 0.0,
+ "Rotation": 0.0,
+ "LengthAdjustment": 0.0,
+ "Endpoint": 0,
+ "FirstRailIndex": 0,
+ "SecondRailIndex": 0,
+ "RailOffsets": null,
+ "BottomLength": 0.0,
+ "LeftLength": 0.0,
+ "RightLength": 0.0,
+ "CornerRadius": 0.0,
+ "LoopDiameter": 0.0,
+ "LeadLength": 0.0,
+ "TangentLength": 0.0,
+ "RailOffset": 0.0,
+ "Offset": 0.0,
+ "DropLength": 0.0,
+ "ZAngle": 0.0,
+ "LegSide": 1,
+ "StartDirection": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": -1.0
+ },
+ "StartLength": 40.0,
+ "FootPosition": {
+ "X": 15.0,
+ "Y": -22.5,
+ "Z": -80.0
+ },
+ "FootRotation": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootClockwise": false,
+ "FootWidth": 30.0,
+ "FootLength": 30.0,
+ "FootConnectionLength": 30.0
+ },
+ {
+ "Kind": "Hairpin",
+ "Distance": 920.8729,
+ "SolderThreshold": 2.0,
+ "SolderSize": 1.0,
+ "Enabled": true,
+ "Diameter": 7.5,
+ "HasCutout": false,
+ "CutoutStartAngle": 0.0,
+ "CutoutEndAngle": 0.0,
+ "HasStraightSection": false,
+ "StraightStartAngle": 0.0,
+ "StraightEndAngle": 0.0,
+ "Scale": 0.0,
+ "RingDensity": 24,
+ "LateralOffset": 0.0,
+ "VerticalOffset": 0.0,
+ "Angle": 0.0,
+ "Rotation": 0.0,
+ "LengthAdjustment": 0.0,
+ "Endpoint": 1,
+ "FirstRailIndex": 0,
+ "SecondRailIndex": 1,
+ "RailOffsets": null,
+ "BottomLength": 0.0,
+ "LeftLength": 0.0,
+ "RightLength": 0.0,
+ "CornerRadius": 0.0,
+ "LoopDiameter": 60.0,
+ "LeadLength": 40.0,
+ "TangentLength": 15.0,
+ "RailOffset": 12.0,
+ "Offset": 0.0,
+ "DropLength": 0.0,
+ "ZAngle": 0.0,
+ "LegSide": 0,
+ "StartDirection": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "StartLength": 0.0,
+ "FootPosition": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootRotation": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootClockwise": false,
+ "FootWidth": 0.0,
+ "FootLength": 0.0,
+ "FootConnectionLength": 0.0
+ },
+ {
+ "Kind": "Elbow",
+ "Distance": 0.0,
+ "SolderThreshold": 2.0,
+ "SolderSize": 1.0,
+ "Enabled": true,
+ "Diameter": 7.5,
+ "HasCutout": false,
+ "CutoutStartAngle": 0.0,
+ "CutoutEndAngle": 0.0,
+ "HasStraightSection": false,
+ "StraightStartAngle": 0.0,
+ "StraightEndAngle": 0.0,
+ "Scale": 0.0,
+ "RingDensity": 0,
+ "LateralOffset": 0.0,
+ "VerticalOffset": 0.0,
+ "Angle": 0.0,
+ "Rotation": 0.0,
+ "LengthAdjustment": 0.0,
+ "Endpoint": 0,
+ "FirstRailIndex": 0,
+ "SecondRailIndex": 1,
+ "RailOffsets": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "BottomLength": 0.0,
+ "LeftLength": 0.0,
+ "RightLength": 0.0,
+ "CornerRadius": 0.0,
+ "LoopDiameter": 0.0,
+ "LeadLength": 0.0,
+ "TangentLength": 0.0,
+ "RailOffset": 0.0,
+ "Offset": 40.0,
+ "DropLength": 80.0,
+ "ZAngle": 0.0,
+ "LegSide": 0,
+ "StartDirection": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "StartLength": 0.0,
+ "FootPosition": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootRotation": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootClockwise": false,
+ "FootWidth": 0.0,
+ "FootLength": 0.0,
+ "FootConnectionLength": 0.0
+ },
+ {
+ "Kind": "RailTrim",
+ "Distance": 920.8729,
+ "SolderThreshold": 2.0,
+ "SolderSize": 1.0,
+ "Enabled": true,
+ "Diameter": 0.0,
+ "HasCutout": false,
+ "CutoutStartAngle": 0.0,
+ "CutoutEndAngle": 0.0,
+ "HasStraightSection": false,
+ "StraightStartAngle": 0.0,
+ "StraightEndAngle": 0.0,
+ "Scale": 0.0,
+ "RingDensity": 0,
+ "LateralOffset": 0.0,
+ "VerticalOffset": 0.0,
+ "Angle": 0.0,
+ "Rotation": 0.0,
+ "LengthAdjustment": 0.0,
+ "Endpoint": 1,
+ "FirstRailIndex": 0,
+ "SecondRailIndex": 0,
+ "RailOffsets": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 25.0,
+ 0.0
+ ],
+ "BottomLength": 0.0,
+ "LeftLength": 0.0,
+ "RightLength": 0.0,
+ "CornerRadius": 0.0,
+ "LoopDiameter": 0.0,
+ "LeadLength": 0.0,
+ "TangentLength": 0.0,
+ "RailOffset": 0.0,
+ "Offset": 0.0,
+ "DropLength": 0.0,
+ "ZAngle": 0.0,
+ "LegSide": 0,
+ "StartDirection": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "StartLength": 0.0,
+ "FootPosition": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootRotation": {
+ "X": 0.0,
+ "Y": 0.0,
+ "Z": 0.0
+ },
+ "FootClockwise": false,
+ "FootWidth": 0.0,
+ "FootLength": 0.0,
+ "FootConnectionLength": 0.0
+ }
+ ]
+}
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-v1.json.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-v1.json.meta
new file mode 100644
index 000000000..4cdb18dbf
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/wire-rail-v1.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: bfe97456fc9949dca3a68e922beb1b94
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailComponentTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailComponentTests.cs
new file mode 100644
index 000000000..387691400
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailComponentTests.cs
@@ -0,0 +1,4591 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using NUnit.Framework;
+using Unity.Collections;
+using UnityEditor;
+using Unity.Mathematics;
+using UnityEngine;
+using UnityEngine.Splines;
+
+namespace VisualPinball.Unity.Test
+{
+ public class WireRailComponentTests
+ {
+ [TearDown]
+ public void TearDown()
+ {
+ Undo.ClearAll();
+ }
+
+ [Test]
+ public void ShouldCreateAThreeDimensionalVpxSpline()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var container = component.SplineContainer;
+
+ Assert.That(container.transform.parent, Is.EqualTo(go.transform));
+ Assert.That(container.Spline.Count, Is.EqualTo(2));
+ Assert.That(container.Spline[0].Position, Is.EqualTo(float3.zero));
+ Assert.That(container.Spline[1].Position,
+ Is.EqualTo(new float3(0f, 500f, 0f)));
+ Assert.That(container.transform.localScale,
+ Is.EqualTo(Physics.ScaleInvVector));
+
+ var knot = container.Spline[1];
+ knot.Position = new float3(125f, 500f, 75f);
+ container.Spline.SetKnot(1, knot);
+ Assert.That(container.Spline[1].Position,
+ Is.EqualTo(new float3(125f, 500f, 75f)));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldCreateFourRailsOnTheDefaultAuthoringGrid()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+
+ Assert.That(component.Segments, Has.Count.EqualTo(1));
+ AssertOffsets(component.Segments[0],
+ new Vector2(-15f, 0f),
+ new Vector2(15f, 0f),
+ new Vector2(-30f, 30f),
+ new Vector2(30f, 30f));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOnlyRecreateAMissingSplineChildExplicitly()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ Object.DestroyImmediate(component.SplineContainer.gameObject);
+
+ Assert.That(component.SplineContainer, Is.Null);
+ Assert.That(go.transform.childCount, Is.Zero);
+
+ var recreated = component.EnsureSplineContainerExists();
+ Assert.That(recreated, Is.Not.Null);
+ Assert.That(recreated.transform.parent, Is.SameAs(go.transform));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldMarkAndLockTheGeneratedSplineChild()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var child = component.SplineContainer.gameObject;
+
+ Assert.That(child.GetComponent(), Is.Not.Null);
+ Assert.That(child.transform.hideFlags & HideFlags.NotEditable,
+ Is.EqualTo(HideFlags.NotEditable),
+ "the child transform carries the VPX conversion and must be locked");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldResolveARenamedSplineChildThroughItsMarker()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var container = component.SplineContainer;
+ container.gameObject.name = "Renamed by an author";
+ ClearSplineContainerReference(component);
+
+ Assert.That(component.SplineContainer, Is.SameAs(container));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldHardenALegacySplineChildFoundByName()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var container = component.SplineContainer;
+ var child = container.gameObject;
+ Object.DestroyImmediate(child.GetComponent());
+ child.transform.hideFlags = HideFlags.None;
+ ClearSplineContainerReference(component);
+
+ Assert.That(component.SplineContainer, Is.SameAs(container),
+ "a child without a marker is still found by its legacy name");
+ Assert.That(child.GetComponent(), Is.Not.Null);
+ Assert.That(child.transform.hideFlags & HideFlags.NotEditable,
+ Is.EqualTo(HideFlags.NotEditable));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ private static void ClearSplineContainerReference(WireRailComponent component)
+ {
+ typeof(WireRailComponent)
+ .GetField("_splineContainer", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .SetValue(component, null);
+ }
+
+ [Test]
+ public void ShouldCreateContinuousLinearConnectionsByDefault()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SplineContainer.Spline.Add(
+ new BezierKnot(new float3(0f, 1000f, 0f)), TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+
+ var connection = component.Segments[0].ConnectionToNext;
+ Assert.That(connection.WireCount, Is.EqualTo(4));
+ for (var wireIndex = 0; wireIndex < connection.WireCount; wireIndex++) {
+ Assert.That(connection.IsWireOverridden(wireIndex), Is.False);
+ Assert.That(connection.IsWireContinuous(wireIndex), Is.True);
+ Assert.That(connection.GetWireCurve(wireIndex).Evaluate(0.5f),
+ Is.EqualTo(0.5f).Within(0.001f));
+ }
+ Assert.That(component.Segments[1].ConnectionToNext.WireCount, Is.Zero);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldTrackAndResetExplicitTransitionOverrides()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SplineContainer.Spline.Add(
+ new BezierKnot(new float3(0f, 1000f, 0f)), TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+
+ component.SetWireTransitionOverride(0, 2, true);
+ var connection = component.Segments[0].ConnectionToNext;
+ Assert.That(connection.IsWireOverridden(2), Is.True);
+ Assert.That(connection.IsWireContinuous(2), Is.True);
+ Assert.That(connection.GetWireCurve(2).Evaluate(0.25f),
+ Is.EqualTo(0.25f).Within(0.001f));
+
+ component.SetWireContinuous(0, 2, false);
+ connection = component.Segments[0].ConnectionToNext;
+ Assert.That(connection.IsWireOverridden(2), Is.True);
+ Assert.That(connection.IsWireContinuous(2), Is.False);
+
+ component.SetWireTransitionOverride(0, 2, false);
+ connection = component.Segments[0].ConnectionToNext;
+ Assert.That(connection.IsWireOverridden(2), Is.False);
+ Assert.That(connection.IsWireContinuous(2), Is.True);
+ Assert.That(connection.GetWireCurve(2).Evaluate(0.25f),
+ Is.EqualTo(0.25f).Within(0.001f));
+
+ component.SetWireTransitionCurve(0, 2, new AnimationCurve(
+ new Keyframe(0f, 0f), new Keyframe(0.5f, 0.2f),
+ new Keyframe(1f, 1f)));
+ connection = component.Segments[0].ConnectionToNext;
+ Assert.That(connection.IsWireOverridden(2), Is.True);
+ Assert.That(connection.GetWireCurve(2).Evaluate(0.5f),
+ Is.EqualTo(0.2f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldUseLayoutPositionsAsTransitionEndpoints()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ spline.Add(new BezierKnot(new float3(0f, 1000f, 0f)),
+ TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetRailCount(1);
+ component.SetRailOffset(0, 0, new Vector2(0f, 0f));
+ component.SetRailOffset(1, 0, new Vector2(40f, 0f));
+
+ var firstStart = WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 0, 0, 0f);
+ var firstMiddle = WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 0, 0, 0.5f);
+ var firstEnd = WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 0, 0, 1f);
+ var secondStart = WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 1, 0, 0f);
+ var secondEnd = WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 1, 0, 1f);
+
+ Assert.That(firstStart.x, Is.EqualTo(0f).Within(0.001f));
+ Assert.That(firstMiddle.x, Is.EqualTo(20f).Within(0.001f));
+ Assert.That(firstEnd.x, Is.EqualTo(40f).Within(0.001f));
+ Assert.That(secondStart.x, Is.EqualTo(40f).Within(0.001f));
+ Assert.That(secondEnd.x, Is.EqualTo(40f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldTransitionEveryContinuousWireToTheNextLayout()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ spline.Add(new BezierKnot(new float3(0f, 1000f, 0f)),
+ TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetRailCount(2);
+ component.SetRailOffset(0, 0, new Vector2(-20f, 0f));
+ component.SetRailOffset(0, 1, new Vector2(20f, 0f));
+ component.SetRailOffset(1, 0, new Vector2(-40f, 0f));
+ component.SetRailOffset(1, 1, new Vector2(60f, 0f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 0, 0, 1f).x, Is.EqualTo(-40f).Within(0.001f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 1, 0, 0f).x, Is.EqualTo(-40f).Within(0.001f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 0, 1, 1f).x, Is.EqualTo(60f).Within(0.001f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 1, 1, 0f).x, Is.EqualTo(60f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldShapeTheSpanBetweenLayoutsWithTheWireCurve()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ spline.Add(new BezierKnot(new float3(0f, 1000f, 0f)),
+ TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetRailCount(1);
+ component.SetRailOffset(0, 0, Vector2.zero);
+ component.SetRailOffset(1, 0, new Vector2(40f, 0f));
+ component.SetWireTransitionCurve(0, 0, new AnimationCurve(
+ new Keyframe(0f, 0f), new Keyframe(0.5f, 0.25f),
+ new Keyframe(1f, 1f)));
+
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 0, 0, 0.5f).x, Is.EqualTo(10f).Within(0.001f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 1, 0, 0.5f).x, Is.EqualTo(40f).Within(0.001f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 0, 0, 1f).x, Is.EqualTo(40f).Within(0.001f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 1, 0, 0f).x, Is.EqualTo(40f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOnlyBlendTheSelectedWireIndices()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ spline.Add(new BezierKnot(new float3(0f, 1000f, 0f)),
+ TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetRailCount(2);
+ component.SetRailOffset(0, 0, new Vector2(-20f, 0f));
+ component.SetRailOffset(0, 1, new Vector2(20f, 0f));
+ component.SetRailOffset(1, 0, new Vector2(-40f, 0f));
+ component.SetRailOffset(1, 1, new Vector2(40f, 0f));
+ component.SetWireContinuous(0, 1, false);
+
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 0, 0, 1f).x, Is.EqualTo(-40f).Within(0.001f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 1, 0, 0f).x, Is.EqualTo(-40f).Within(0.001f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 0, 1, 1f).x, Is.EqualTo(20f).Within(0.001f));
+ Assert.That(WireRailSplineGeometry.EvaluateRailOffset(spline,
+ component.Segments, 1, 1, 0f).x, Is.EqualTo(40f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldRemoveInternalCapsFromAContinuousWire()
+ {
+ const int radialSegments = 10;
+ const int trianglesPerRingPair = radialSegments * 2;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetWireCapBevelSize(0f);
+ var spline = component.SplineContainer.Spline;
+ spline.Add(
+ new BezierKnot(new float3(0f, 1000f, 0f)), TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetRailCount(1);
+ component.SetRailOffset(0, 0, new Vector2(0f, 0f));
+ component.SetRailOffset(1, 0, new Vector2(40f, 0f));
+ component.SetWireContinuous(0, 0, false);
+ var disconnectedTriangleCount = component.RenderMesh.triangles.Length / 3;
+ var disconnectedBodyTriangleCount = 0;
+ for (var segmentIndex = 0; segmentIndex < 2; segmentIndex++) {
+ disconnectedBodyTriangleCount += (WireRailRenderMeshGenerator
+ .BuildSampleParameters(spline, component.Segments, segmentIndex, 0, 16)
+ .Count - 1) * trianglesPerRingPair;
+ }
+
+ component.SetWireContinuous(0, 0, true);
+
+ var continuousTriangleCount = component.RenderMesh.triangles.Length / 3;
+ var continuousBodyTriangleCount = 0;
+ for (var segmentIndex = 0; segmentIndex < 2; segmentIndex++) {
+ continuousBodyTriangleCount += (WireRailRenderMeshGenerator
+ .BuildSampleParameters(spline, component.Segments, segmentIndex, 0, 16)
+ .Count - 1) * trianglesPerRingPair;
+ }
+
+ Assert.That(disconnectedTriangleCount - disconnectedBodyTriangleCount,
+ Is.EqualTo(radialSegments * 4), "all four open ends should be capped");
+ Assert.That(continuousTriangleCount - continuousBodyTriangleCount,
+ Is.EqualTo(radialSegments * 2), "only the two outer ends should be capped");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOrientRenderRingsPerpendicularToTheWireCenterline()
+ {
+ const int radialSegments = 10;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetWireCapBevelSize(0f);
+ component.SplineContainer.Spline.Add(
+ new BezierKnot(new float3(0f, 1000f, 0f)) {
+ Rotation = quaternion.RotateX(math.PI * 0.5f),
+ }, TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetRailCount(1);
+ component.SetRailOffset(0, 0, Vector2.zero);
+ component.SetRailOffset(1, 0, new Vector2(500f, 0f));
+
+ var vertices = component.RenderMesh.vertices;
+ var samples = WireRailRenderMeshGenerator.BuildSampleParameters(
+ component.SplineContainer.Spline, component.Segments, 0, 0, 16);
+ var sampleIndex = samples.FindIndex(value => Mathf.Approximately(value, 0.5f));
+ var ringStart = sampleIndex * radialSegments;
+ var center = AverageRing(vertices, ringStart, radialSegments);
+ var tangentStep = math.min(0.5f - samples[sampleIndex - 1],
+ samples[sampleIndex + 1] - 0.5f);
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailFrame(
+ component.SplineContainer.Spline, component.Segments, 0, 0, 0.5f,
+ tangentStep, out var frame), Is.True);
+
+ AssertRingPerpendicular(vertices, ringStart, radialSegments, center,
+ frame.Tangent);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldUseTheSameWireFrameAndTangentOnBothSidesOfAConnectedKnot()
+ {
+ const int radialSegments = 10;
+ const int capVertexCount = radialSegments + 1;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetWireCapBevelSize(0f);
+ component.SplineContainer.Spline.Add(
+ new BezierKnot(new float3(0f, 1000f, 0f)) {
+ Rotation = quaternion.RotateX(math.PI * 0.5f),
+ }, TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetRailCount(1);
+ component.SetRailOffset(0, 0, Vector2.zero);
+ component.SetRailOffset(1, 0, new Vector2(500f, 0f));
+
+ var vertices = component.RenderMesh.vertices;
+ var firstSamples = WireRailRenderMeshGenerator.BuildSampleParameters(
+ component.SplineContainer.Spline, component.Segments, 0, 0, 16);
+ var secondSamples = WireRailRenderMeshGenerator.BuildSampleParameters(
+ component.SplineContainer.Spline, component.Segments, 1, 0, 16);
+ var firstEndRing = (firstSamples.Count - 1) * radialSegments;
+ var secondStartRing = firstSamples.Count * radialSegments + capVertexCount;
+ var firstCenter = AverageRing(vertices, firstEndRing, radialSegments);
+ var secondCenter = AverageRing(vertices, secondStartRing, radialSegments);
+ var tangentStep = math.min(1f - firstSamples[^2], secondSamples[1]);
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailFrame(
+ component.SplineContainer.Spline, component.Segments, 0, 0, 1f,
+ tangentStep, out var frame), Is.True);
+
+ Assert.That(Vector3.Distance(firstCenter, secondCenter), Is.LessThan(0.001f));
+ AssertRingPerpendicular(vertices, firstEndRing, radialSegments, firstCenter,
+ frame.Tangent);
+ AssertRingPerpendicular(vertices, secondStartRing, radialSegments, secondCenter,
+ frame.Tangent);
+ var firstRadial = math.normalize((float3)(vertices[firstEndRing] - firstCenter));
+ var secondRadial = math.normalize((float3)(vertices[secondStartRing] - secondCenter));
+ Assert.That(math.dot(firstRadial, secondRadial), Is.GreaterThan(0.9999f));
+
+ const float curveStep = 1f / 1024f;
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailPosition(
+ component.SplineContainer.Spline, component.Segments, 0, 0,
+ 1f - curveStep, out var beforeKnot), Is.True);
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailPosition(
+ component.SplineContainer.Spline, component.Segments, 0, 0,
+ 1f, out var atKnot), Is.True);
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailPosition(
+ component.SplineContainer.Spline, component.Segments, 1, 0,
+ curveStep, out var afterKnot), Is.True);
+ var incoming = math.normalizesafe(atKnot - beforeKnot, frame.Tangent);
+ var outgoing = math.normalizesafe(afterKnot - atKnot, frame.Tangent);
+ Assert.That(math.dot(incoming, outgoing), Is.GreaterThan(0.9999f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepAnUnchangingFourWireLayoutRigidAndParallel()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ var middle = spline[1];
+ middle.Position = new float3(-400f, 300f, 0f);
+ spline.SetKnot(1, middle);
+ spline.Add(new BezierKnot(new float3(0f, 700f, 0f)) {
+ Rotation = quaternion.RotateX(math.PI * 0.5f),
+ }, TangentMode.AutoSmooth);
+
+ for (var segmentIndex = 0; segmentIndex < component.Segments.Count;
+ segmentIndex++) {
+ for (var sampleIndex = 0; sampleIndex <= 32; sampleIndex++) {
+ var curveT = sampleIndex / 32f;
+ Assert.That(WireRailSplineGeometry.TryEvaluateLayout(spline,
+ component.Segments, segmentIndex,
+ curveT, out var mainFrame), Is.True);
+ var referenceTangent = float3.zero;
+ for (var railIndex = 0; railIndex < 4; railIndex++) {
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailPosition(spline,
+ component.Segments, segmentIndex, railIndex, curveT,
+ out var position), Is.True);
+ var expected = mainFrame.TransformOffset(
+ (float2)component.Segments[segmentIndex].GetRailOffset(railIndex));
+ Assert.That(math.distance(position, expected), Is.LessThan(0.001f),
+ $"wire {railIndex + 1} drifted at segment "
+ + $"{segmentIndex + 1}, t={curveT}");
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailFrame(spline,
+ component.Segments, segmentIndex, railIndex, curveT, 1f / 128f,
+ out var railFrame), Is.True);
+ if (railIndex == 0) {
+ referenceTangent = railFrame.Tangent;
+ } else {
+ Assert.That(math.dot(referenceTangent, railFrame.Tangent),
+ Is.GreaterThan(0.9999f), $"wire {railIndex + 1} was not "
+ + $"parallel at segment {segmentIndex + 1}, t={curveT}");
+ }
+ }
+ }
+ }
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldPlaceRingFixturesByDistanceAcrossTheWholeSpline()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ spline.Add(new BezierKnot(new float3(0f, 1000f, 0f)) {
+ Rotation = quaternion.RotateX(math.PI * 0.5f),
+ }, TangentMode.AutoSmooth);
+ var fixtureIndex = component.AddRingFixture(750f);
+ var ring = (WireRailRingFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateRingProfile(spline,
+ component.Segments, ring, out var profile), Is.True);
+ Assert.That(math.distance(profile.Frame.Position, new float3(0f, 750f, 0f)),
+ Is.LessThan(0.1f));
+ Assert.That(math.abs(math.dot(profile.GetCenterlinePosition(0f) - profile.Center,
+ profile.Frame.Tangent)), Is.LessThan(0.001f),
+ "the ring should be perpendicular to the spline");
+ Assert.That(component.Fixtures.Count, Is.EqualTo(1));
+ Assert.That(component.Segments.Count, Is.EqualTo(1));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldFitTheDefaultRungArmsToTheBottomAndMiddleRails()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var railTriangleCount = component.RenderMesh.triangles.Length / 3;
+ var fixtureIndex = component.AddCradleFixture(250f);
+ var cradle = (WireRailCradleFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var profile), Is.True);
+ Assert.That(cradle.Angle,
+ Is.EqualTo(WireRailCradleFixture.DefaultAngle).Within(0.001f));
+ Assert.That(cradle.BottomLength,
+ Is.EqualTo(WireRailCradleFixture.DefaultBottomLength));
+ Assert.That(cradle.LeftLength,
+ Is.EqualTo(WireRailCradleFixture.DefaultLeftLength));
+ Assert.That(cradle.RightLength,
+ Is.EqualTo(WireRailCradleFixture.DefaultRightLength));
+ Assert.That(profile.RailOffsets, Has.Count.EqualTo(4));
+
+ var centerline = profile.CenterlinePoints.Select(ToOffset).ToArray();
+ var leftDirection = math.normalize(centerline[1] - centerline[0]);
+ var rightDirection = math.normalize(centerline[^1] - centerline[^2]);
+ AssertTouches(0, centerline[0], leftDirection);
+ AssertTouches(2, centerline[0], leftDirection);
+ AssertTouches(1, centerline[^1], rightDirection);
+ AssertTouches(3, centerline[^1], rightDirection);
+ var leftUp = -leftDirection;
+ var includedAngle = math.degrees(math.acos(math.clamp(
+ math.dot(leftUp, rightDirection), -1f, 1f)));
+ Assert.That(includedAngle, Is.EqualTo(cradle.Angle).Within(0.01f));
+ Assert.That(component.RenderMesh.triangles.Length / 3,
+ Is.GreaterThan(railTriangleCount));
+
+ float2 ToOffset(float3 point)
+ {
+ var relative = point - profile.Frame.Position;
+ return new float2(math.dot(relative, profile.Frame.Right),
+ math.dot(relative, profile.Frame.Up));
+ }
+
+ void AssertTouches(int railIndex, float2 linePoint, float2 direction)
+ {
+ var relative = profile.RailOffsets[railIndex] - linePoint;
+ var distance = math.abs(direction.x * relative.y
+ - direction.y * relative.x);
+ Assert.That(distance, Is.EqualTo(profile.RailRadii[railIndex]
+ + cradle.Diameter * 0.5f).Within(0.01f));
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldAlwaysGenerateTheBottomWireAndRoundItsArmCorners()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddCradleFixture(250f);
+ var cradle = (WireRailCradleFixture)component.Fixtures[fixtureIndex];
+ component.SetCradleFixtureProperties(fixtureIndex, cradle.Distance,
+ 64, 0f, 0f, 24f, cradle.LeftLength, cradle.RightLength,
+ cradle.Angle, 0f, 12f);
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var rounded), Is.True);
+ var roundedOffsets = rounded.CenterlinePoints.Select(point =>
+ ToOffset(rounded, point)).ToArray();
+ Assert.That(roundedOffsets.Zip(roundedOffsets.Skip(1),
+ (left, right) => math.abs(left.y - rounded.OriginOffset.y) < 0.001f
+ && math.abs(right.y - rounded.OriginOffset.y) < 0.001f
+ && math.abs(left.x - right.x) > 0.1f).Any(matches => matches),
+ Is.True, "the rounded corners should retain a straight bottom span");
+ Assert.That(CalculateMaximumTurn(roundedOffsets),
+ Is.LessThanOrEqualTo(360f / 64f + 0.1f));
+
+ component.SetCradleFixtureProperties(fixtureIndex, cradle.Distance,
+ 3, 0f, 0f, cradle.BottomLength, cradle.LeftLength,
+ cradle.RightLength, cradle.Angle, 0f, 12f);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var lowDensity), Is.True);
+ var lowDensityOffsets = lowDensity.CenterlinePoints.Select(point =>
+ ToOffset(lowDensity, point)).ToArray();
+ Assert.That(CalculateMaximumTurn(lowDensityOffsets),
+ Is.LessThanOrEqualTo(15.1f),
+ "low density must not introduce a visible miter waist");
+
+ static float2 ToOffset(WireRailCradleProfile profile, float3 point)
+ {
+ var relative = point - profile.Frame.Position;
+ return new float2(math.dot(relative, profile.Frame.Right),
+ math.dot(relative, profile.Frame.Up));
+ }
+ static float CalculateMaximumTurn(IReadOnlyList points)
+ {
+ var maximumTurn = 0f;
+ for (var pointIndex = 1; pointIndex < points.Count - 1; pointIndex++) {
+ var incoming = math.normalize(points[pointIndex]
+ - points[pointIndex - 1]);
+ var outgoing = math.normalize(points[pointIndex + 1]
+ - points[pointIndex]);
+ maximumTurn = math.max(maximumTurn, math.degrees(math.acos(
+ math.clamp(math.dot(incoming, outgoing), -1f, 1f))));
+ }
+ return maximumTurn;
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOffsetRotateResizeAndDuplicateARungWithArms()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddCradleFixture(250f);
+ var cradle = (WireRailCradleFixture)component.Fixtures[fixtureIndex];
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var original), Is.True);
+
+ component.SetCradleFixtureProperties(fixtureIndex, 175f, 48,
+ 6f, -4f, 24f, 70f, 95f, 60f, 30f, 10f);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var changed), Is.True);
+ Assert.That(math.distance(changed.OriginOffset,
+ original.OriginOffset + new float2(6f, -4f)), Is.LessThan(0.001f));
+ var endpoints = new[] {
+ ToOffset(changed, changed.CenterlinePoints[0]),
+ ToOffset(changed, changed.CenterlinePoints[^1]),
+ };
+ var leftBottom = changed.OriginOffset + Rotate(new float2(-12f, 0f), 30f);
+ var rightBottom = changed.OriginOffset + Rotate(new float2(12f, 0f), 30f);
+ Assert.That(math.distance(endpoints[0], leftBottom),
+ Is.EqualTo(70f).Within(0.001f));
+ Assert.That(math.distance(endpoints[1], rightBottom),
+ Is.EqualTo(95f).Within(0.001f));
+ var expectedLeftDirection = Rotate(new float2(-0.5f,
+ math.cos(math.radians(30f))), 30f);
+ Assert.That(math.dot(math.normalize(endpoints[0] - leftBottom),
+ expectedLeftDirection), Is.EqualTo(1f).Within(0.001f));
+
+ var duplicateIndex = component.DuplicateCradleFixture(fixtureIndex);
+ var duplicate = (WireRailCradleFixture)component.Fixtures[duplicateIndex];
+ Assert.That(duplicate, Is.Not.SameAs(cradle));
+ Assert.That(duplicate.Distance, Is.EqualTo(cradle.Distance));
+ Assert.That(duplicate.RingDensity, Is.EqualTo(cradle.RingDensity));
+ Assert.That(duplicate.LateralOffset, Is.EqualTo(cradle.LateralOffset));
+ Assert.That(duplicate.VerticalOffset, Is.EqualTo(cradle.VerticalOffset));
+ Assert.That(duplicate.BottomLength, Is.EqualTo(cradle.BottomLength));
+ Assert.That(duplicate.LeftLength, Is.EqualTo(cradle.LeftLength));
+ Assert.That(duplicate.RightLength, Is.EqualTo(cradle.RightLength));
+ Assert.That(duplicate.Angle, Is.EqualTo(cradle.Angle));
+ Assert.That(duplicate.Rotation, Is.EqualTo(cradle.Rotation));
+ Assert.That(duplicate.CornerRadius, Is.EqualTo(cradle.CornerRadius));
+
+ static float2 ToOffset(WireRailCradleProfile profile, float3 point)
+ {
+ var relative = point - profile.Frame.Position;
+ return new float2(math.dot(relative, profile.Frame.Right),
+ math.dot(relative, profile.Frame.Up));
+ }
+ static float2 Rotate(float2 point, float degrees)
+ {
+ var direction = new float2(math.cos(math.radians(degrees)),
+ math.sin(math.radians(degrees)));
+ return new float2(point.x * direction.x - point.y * direction.y,
+ point.x * direction.y + point.y * direction.x);
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldClampRungArmSettingsAndShareTheWireDiameter()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddCradleFixture(250f);
+ component.SetCradleFixtureProperties(fixtureIndex, 250f, 1000,
+ 0f, 0f, -1000f, -5f, -10f, 500f, 500f, 0.1f);
+ var cradle = (WireRailCradleFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(cradle.RingDensity, Is.EqualTo(128));
+ Assert.That(cradle.BottomLength, Is.EqualTo(0.1f));
+ Assert.That(cradle.LeftLength, Is.Zero);
+ Assert.That(cradle.RightLength, Is.Zero);
+ Assert.That(cradle.Angle, Is.EqualTo(179f));
+ Assert.That(cradle.Rotation, Is.EqualTo(360f));
+ Assert.That(cradle.CornerRadius,
+ Is.EqualTo(component.WireDiameter * 0.5f));
+
+ component.SetCradleFixtureProperties(fixtureIndex, 250f,
+ cradle.RingDensity, 0f, 0f, 0.1f, 0.1f, 0f,
+ WireRailCradleFixture.DefaultAngle, 0f, cradle.CornerRadius);
+ Assert.That(cradle.BottomLength, Is.GreaterThan(0.1f));
+ Assert.That(cradle.LeftLength, Is.GreaterThan(0.1f));
+ Assert.That(cradle.RightLength, Is.Zero);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle, out _),
+ Is.True);
+
+ component.SetWireDiameter(12f);
+ Assert.That(cradle.Diameter, Is.EqualTo(12f));
+ Assert.That(cradle.CornerRadius, Is.GreaterThanOrEqualTo(6f));
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle, out _),
+ Is.True);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOmitZeroLengthArmsWithoutOmittingTheBottomWire()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddCradleFixture(250f);
+ var cradle = (WireRailCradleFixture)component.Fixtures[fixtureIndex];
+
+ component.SetCradleFixtureProperties(fixtureIndex, cradle.Distance,
+ cradle.RingDensity, 0f, 0f, 40f, 0f, 0f, 60f,
+ 0f, cradle.CornerRadius);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var bottomOnly), Is.True);
+ Assert.That(bottomOnly.CenterlinePoints.Count, Is.EqualTo(2));
+ Assert.That(math.distance(bottomOnly.CenterlinePoints[0],
+ bottomOnly.CenterlinePoints[1]), Is.EqualTo(40f).Within(0.001f));
+
+ component.SetCradleFixtureProperties(fixtureIndex, cradle.Distance,
+ cradle.RingDensity, 0f, 0f, 40f, 0f, 50f, 60f,
+ 0f, cradle.CornerRadius);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var rightArmOnly), Is.True);
+ Assert.That(rightArmOnly.CenterlinePoints.Count, Is.GreaterThan(2));
+ var firstOffset = ToOffset(rightArmOnly,
+ rightArmOnly.CenterlinePoints[0]);
+ var lastOffset = ToOffset(rightArmOnly,
+ rightArmOnly.CenterlinePoints[^1]);
+ Assert.That(math.distance(firstOffset,
+ rightArmOnly.OriginOffset + new float2(-20f, 0f)), Is.LessThan(0.001f));
+ Assert.That(math.distance(lastOffset,
+ rightArmOnly.OriginOffset + new float2(20f, 0f)),
+ Is.EqualTo(50f).Within(0.001f));
+
+ static float2 ToOffset(WireRailCradleProfile profile, float3 point)
+ {
+ var relative = point - profile.Frame.Position;
+ return new float2(math.dot(relative, profile.Frame.Right),
+ math.dot(relative, profile.Frame.Up));
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepRungArmsAvailableWithFewerThanFourActiveRails()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(3);
+ var fixtureIndex = component.AddCradleFixture(250f);
+ var cradle = (WireRailCradleFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var threeRailProfile), Is.True);
+ Assert.That(threeRailProfile.RailOffsets, Has.Count.EqualTo(3));
+
+ component.SetRailsActive(0, new[] { 2 }, false);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var twoRailProfile), Is.True);
+ Assert.That(twoRailProfile.RailOffsets, Has.Count.EqualTo(2));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void ShouldFallBackBelowTheEnvelopeForAmbiguousFourRailLayouts(
+ bool swapBottomRails)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ if (swapBottomRails) {
+ component.SetRailOffset(0, 0, new Vector2(15f, 0f));
+ component.SetRailOffset(0, 1, new Vector2(-15f, 0f));
+ } else {
+ component.SetRailOffset(0, 2, new Vector2(-10f, 30f));
+ component.SetRailOffset(0, 3, new Vector2(10f, 30f));
+ }
+ var fixtureIndex = component.AddCradleFixture(250f);
+ var cradle = (WireRailCradleFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(
+ component.SplineContainer.Spline, component.Segments, cradle,
+ out var profile), Is.True);
+ var segment = component.Segments[0];
+ var minimumHeight = Enumerable.Range(0, segment.RailCount)
+ .Where(segment.IsRailActive)
+ .Min(railIndex => segment.GetRailOffset(railIndex).y
+ - segment.GetWireDiameter(railIndex) * 0.5f);
+ Assert.That(profile.OriginOffset.y, Is.LessThan(minimumHeight),
+ "invalid outer-tangent intersections must use the below-envelope fallback");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldConnectTheTwoBottomRailsWithARungByDefault()
+ {
+ const int radialSegments = 10;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var railTriangleCount = component.RenderMesh.triangles.Length / 3;
+ var fixtureIndex = component.AddRungFixture(250f);
+ var rung = (WireRailRungFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateRungProfile(
+ component.SplineContainer.Spline, component.Segments, rung,
+ out var profile), Is.True);
+ Assert.That(rung.StartRailIndex, Is.EqualTo(0));
+ Assert.That(rung.EndRailIndex, Is.EqualTo(1));
+ Assert.That(rung.Angle, Is.EqualTo(0f));
+ var expectedStart = profile.StartRailOffset
+ + new float2(profile.StartRailRadius, 0f);
+ var expectedEnd = profile.EndRailOffset
+ - new float2(profile.EndRailRadius, 0f);
+ Assert.That(math.distance(profile.StartOffset, expectedStart),
+ Is.LessThan(0.001f));
+ Assert.That(math.distance(profile.EndOffset, expectedEnd),
+ Is.LessThan(0.001f));
+ Assert.That(math.abs(math.dot(profile.End - profile.Start,
+ profile.Frame.Tangent)), Is.LessThan(0.001f));
+ var touches = new List();
+ WireRailSolderMeshGenerator.CollectTouches(component.SplineContainer.Spline,
+ component.Segments, component.Fixtures, rung, touches);
+ Assert.That(component.RenderMesh.triangles.Length / 3 - railTriangleCount,
+ Is.EqualTo(radialSegments * 8 + touches.Count
+ * WireRailSolderMeshGenerator.TrianglesPerBlob),
+ "the rung should have one tube span, two beveled caps, and its solder joins");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOffsetAngleAndResizeARung()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddRungFixture(250f);
+ component.SetRungFixtureProperties(fixtureIndex, 250f,
+ 90f, 6f, -3f, 12f);
+ var rung = (WireRailRungFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateRungProfile(
+ component.SplineContainer.Spline, component.Segments, rung,
+ out var profile), Is.True);
+ var railDirection = math.normalize(profile.EndRailOffset
+ - profile.StartRailOffset);
+ var attachmentStart = profile.StartRailOffset
+ + railDirection * profile.StartRailRadius;
+ var attachmentEnd = profile.EndRailOffset
+ - railDirection * profile.EndRailRadius;
+ var direction = new float2(0f, 1f);
+ var expectedLength = math.distance(attachmentStart, attachmentEnd) + 12f;
+ var bottomCenter = (attachmentStart + attachmentEnd) * 0.5f;
+ var envelopeMinimum = new float2(float.PositiveInfinity);
+ var envelopeMaximum = new float2(float.NegativeInfinity);
+ var segment = component.Segments[0];
+ for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) {
+ if (!segment.IsRailActive(railIndex)) {
+ continue;
+ }
+ var railOffset = (float2)segment.GetRailOffset(railIndex);
+ var railRadius = segment.GetWireDiameter(railIndex) * 0.5f;
+ envelopeMinimum = math.min(envelopeMinimum, railOffset - railRadius);
+ envelopeMaximum = math.max(envelopeMaximum, railOffset + railRadius);
+ }
+ var expectedRotationOrigin = (envelopeMinimum + envelopeMaximum) * 0.5f;
+ Assert.That(math.distance(profile.RotationOriginOffset,
+ expectedRotationOrigin), Is.LessThan(0.001f));
+ var relativeBottomCenter = bottomCenter - profile.RotationOriginOffset;
+ var expectedCenter = profile.RotationOriginOffset
+ + new float2(-relativeBottomCenter.y, relativeBottomCenter.x)
+ + new float2(6f, -3f);
+ Assert.That(math.distance(profile.StartOffset,
+ expectedCenter - direction * expectedLength * 0.5f),
+ Is.LessThan(0.001f));
+ Assert.That(math.distance(profile.EndOffset,
+ expectedCenter + direction * expectedLength * 0.5f),
+ Is.LessThan(0.001f));
+ Assert.That(math.distance(profile.StartOffset, profile.EndOffset),
+ Is.EqualTo(expectedLength).Within(0.001f));
+ Assert.That(math.dot(math.normalize(profile.EndOffset - profile.StartOffset),
+ direction), Is.EqualTo(1f).Within(0.001f));
+ Assert.That(math.distance(profile.RotationOriginOffset, bottomCenter),
+ Is.GreaterThan(0.001f), "the default four-rail envelope should rotate "
+ + "around a point above the bottom rails");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepRungAngleRelativeToTheSpline()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailOffset(0, 0, new Vector2(-19f, -8f));
+ component.SetRailOffset(0, 1, new Vector2(19f, 8f));
+ var fixtureIndex = component.AddRungFixture(250f);
+ var rung = (WireRailRungFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateRungProfile(
+ component.SplineContainer.Spline, component.Segments, rung,
+ out var profile), Is.True);
+ Assert.That(math.dot(math.normalize(profile.EndOffset - profile.StartOffset),
+ new float2(1f, 0f)), Is.EqualTo(1f).Within(0.001f),
+ "angle zero must stay horizontal even when the bottom rails are stepped");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOmitDegenerateRungBodyAtMaximumBevel()
+ {
+ const int radialSegments = 10;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ component.SetWireCapBevelSize(2f);
+ var railTriangleCount = component.RenderMesh.triangles.Length / 3;
+ var fixtureIndex = component.AddRungFixture(250f);
+ component.SetRungFixtureProperties(fixtureIndex, 250f,
+ 0f, 0f, 0f, -1000f);
+
+ var fixtureTriangleCount = component.RenderMesh.triangles.Length / 3
+ - railTriangleCount;
+ Assert.That(fixtureTriangleCount, Is.EqualTo(radialSegments * 6),
+ "the two beveled caps should meet without a zero-length tube body");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldHideARungWhenEitherBottomRailIsInactive()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var fixtureIndex = component.AddRungFixture(250f);
+ var rung = (WireRailRungFixture)component.Fixtures[fixtureIndex];
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateRungProfile(
+ component.SplineContainer.Spline, component.Segments, rung, out _),
+ Is.True);
+
+ component.SetRailsActive(0, new[] { 1 }, false);
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateRungProfile(
+ component.SplineContainer.Spline, component.Segments, rung, out _),
+ Is.False);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldCreateAConnectedLegAndUHookFootByDefault()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var railTriangleCount = component.RenderMesh.triangles.Length / 3;
+ var fixtureIndex = component.AddStandFixture(250f);
+ var leg = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ component.SplineContainer.Spline, component.Segments, leg,
+ out var profile), Is.True);
+ Assert.That(leg.LegSide, Is.EqualTo(WireRailStandSide.Right));
+ Assert.That(leg.LateralOffset, Is.Zero);
+ Assert.That(leg.VerticalOffset, Is.Zero);
+ Assert.That(leg.LengthAdjustment, Is.Zero);
+ Assert.That(math.distance(profile.LegPoints[0],
+ profile.AttachmentProfile.End), Is.LessThan(0.001f),
+ "the vertical part of the L should begin at the right end by default");
+ Assert.That(math.distance(profile.CombinedPath[0],
+ profile.AttachmentProfile.Start), Is.LessThan(0.001f));
+ Assert.That(profile.CombinedPath.Any(point => math.distance(
+ point, profile.LegPoints[0]) < 0.001f), Is.False,
+ "the attachment-to-leg corner should be replaced by a rounded bend");
+ Assert.That(math.distance(profile.LegPoints[0], profile.LegPoints[1]),
+ Is.EqualTo(WireRailStandFixture.DefaultStartLength).Within(0.001f));
+ Assert.That(math.dot(math.normalize(profile.LegPoints[1]
+ - profile.LegPoints[0]), -profile.AttachmentProfile.Frame.Up),
+ Is.EqualTo(1f).Within(0.001f));
+ Assert.That(math.distance(profile.LegPoints[^1], profile.FootPoints[0]),
+ Is.LessThan(0.001f), "the leg must meet the open end of the U-hook");
+ Assert.That(leg.FootConnectionLength,
+ Is.EqualTo(WireRailStandFixture.DefaultFootConnectionLength));
+ Assert.That(math.distance(profile.FootPoints[0], profile.FootPoints[1]),
+ Is.EqualTo(WireRailStandFixture.DefaultFootConnectionLength)
+ .Within(0.001f));
+ Assert.That(profile.FootPoints.Count,
+ Is.EqualTo(WireRailStandFixture.FootBendSegments + 3));
+ Assert.That(component.RenderMesh.triangles.Length / 3,
+ Is.GreaterThan(railTriangleCount));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOffsetAndResizeTheLegRailAttachment()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddStandFixture(250f);
+ var leg = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ component.SplineContainer.Spline, component.Segments, leg,
+ out var original), Is.True);
+
+ component.SetStandFixtureProperties(fixtureIndex, leg.Distance, leg.LegSide,
+ leg.StartDirection, leg.StartLength, leg.FootPosition, leg.FootRotation,
+ leg.FootWidth, leg.FootLength, leg.FootConnectionLength,
+ 6f, -3f, 12f);
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ component.SplineContainer.Spline, component.Segments, leg,
+ out var adjusted), Is.True);
+ Assert.That(leg.LateralOffset, Is.EqualTo(6f));
+ Assert.That(leg.VerticalOffset, Is.EqualTo(-3f));
+ Assert.That(leg.LengthAdjustment, Is.EqualTo(12f));
+ var originalCenter = (original.AttachmentProfile.Start
+ + original.AttachmentProfile.End) * 0.5f;
+ var adjustedCenter = (adjusted.AttachmentProfile.Start
+ + adjusted.AttachmentProfile.End) * 0.5f;
+ Assert.That(math.distance(adjustedCenter, originalCenter
+ + original.AttachmentProfile.Frame.Right * 6f
+ - original.AttachmentProfile.Frame.Up * 3f), Is.LessThan(0.001f));
+ Assert.That(math.distance(adjusted.AttachmentProfile.Start,
+ adjusted.AttachmentProfile.End), Is.EqualTo(math.distance(
+ original.AttachmentProfile.Start, original.AttachmentProfile.End)
+ + 12f).Within(0.001f));
+ Assert.That(math.distance(adjusted.LegPoints[0],
+ adjusted.AttachmentProfile.End), Is.LessThan(0.001f));
+ var legDelta = adjusted.LegPoints[0] - original.LegPoints[0];
+ for (var pointIndex = 0; pointIndex < original.FootPoints.Count;
+ pointIndex++) {
+ Assert.That(math.distance(adjusted.FootPoints[pointIndex],
+ original.FootPoints[pointIndex] + legDelta), Is.LessThan(0.001f));
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldRoundLegJointsAndKeepTubeRingsAtTheWireRadius()
+ {
+ const int radialSegments = 8;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddStandFixture(250f);
+ var leg = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ component.SplineContainer.Spline, component.Segments, leg,
+ out var profile), Is.True);
+
+ foreach (var sharpCorner in new[] {
+ profile.LegPoints[0], profile.LegPoints[^1],
+ }) {
+ Assert.That(profile.CombinedPath.Any(point => math.distance(
+ point, sharpCorner) < 0.001f), Is.False,
+ "each sharp leg joint should be replaced by a rounded bend");
+ }
+ var maximumTurn = 0f;
+ for (var pointIndex = 1; pointIndex < profile.CombinedPath.Count - 1;
+ pointIndex++) {
+ var incoming = math.normalize(profile.CombinedPath[pointIndex]
+ - profile.CombinedPath[pointIndex - 1]);
+ var outgoing = math.normalize(profile.CombinedPath[pointIndex + 1]
+ - profile.CombinedPath[pointIndex]);
+ maximumTurn = math.max(maximumTurn, math.degrees(math.acos(
+ math.clamp(math.dot(incoming, outgoing), -1f, 1f))));
+ }
+ Assert.That(maximumTurn, Is.LessThanOrEqualTo(15.1f));
+
+ var vertices = new List();
+ var normals = new List();
+ var uvs = new List();
+ var indices = new List();
+ WireRailFixtureMeshGenerator.AppendPolylineTube(profile.CombinedPath,
+ profile.AttachmentProfile.Frame, leg.Diameter, 0f, radialSegments,
+ vertices, normals, uvs, indices);
+ var expectedRadius = leg.Diameter * 0.5f;
+ for (var pointIndex = 0; pointIndex < profile.CombinedPath.Count;
+ pointIndex++) {
+ for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) {
+ var vertex = (float3)vertices[pointIndex * radialSegments + radialIndex];
+ Assert.That(math.distance(vertex, profile.CombinedPath[pointIndex]),
+ Is.EqualTo(expectedRadius).Within(0.001f));
+ }
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldRejectAReversingPolylineTube()
+ {
+ var vertices = new List();
+ var normals = new List();
+ var uvs = new List();
+ var indices = new List();
+ var path = new[] {
+ new float3(0f, 0f, 0f),
+ new float3(10f, 0f, 0f),
+ new float3(0f, 0f, 0f),
+ };
+
+ WireRailFixtureMeshGenerator.AppendPolylineTube(path,
+ new WireRailPathFrame(float3.zero, new float3(0f, 1f, 0f),
+ new float3(1f, 0f, 0f), new float3(0f, 0f, 1f)),
+ 6f, 0f, 8, vertices, normals, uvs, indices);
+
+ Assert.That(vertices, Is.Empty);
+ Assert.That(normals, Is.Empty);
+ Assert.That(uvs, Is.Empty);
+ Assert.That(indices, Is.Empty);
+ }
+
+ [TestCase(0f)]
+ [TestCase(0.02f)]
+ public void ShouldRejectLegsThatFoldBackThroughTheirAttachment(float vertical)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddStandFixture(250f);
+ var leg = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+ component.SetStandFixtureProperties(fixtureIndex, leg.Distance,
+ WireRailStandSide.Right, new Vector3(-1f, 0f, vertical),
+ leg.StartLength, leg.FootPosition, leg.FootRotation, leg.FootWidth,
+ leg.FootLength, leg.FootConnectionLength, leg.LateralOffset,
+ leg.VerticalOffset, leg.LengthAdjustment);
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ component.SplineContainer.Spline, component.Segments,
+ (WireRailStandFixture)component.Fixtures[fixtureIndex], out _), Is.False);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldPoseTheFootInThreeDimensionsAndKeepTheLegConnected()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddStandFixture(250f);
+ component.SetStandFixtureProperties(fixtureIndex, 250f,
+ WireRailStandSide.Left, new Vector3(0f, 1f, 0f), 25f,
+ new Vector3(10f, 20f, -50f), new Vector3(90f, 0f, 30f),
+ 40f, 25f, 12f);
+ var leg = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ component.SplineContainer.Spline, component.Segments, leg,
+ out var profile), Is.True);
+ Assert.That(math.distance(profile.LegPoints[0],
+ profile.AttachmentProfile.Start), Is.LessThan(0.001f));
+ Assert.That(math.dot(math.normalize(profile.LegPoints[1]
+ - profile.LegPoints[0]), profile.AttachmentProfile.Frame.Tangent),
+ Is.EqualTo(1f).Within(0.001f));
+ Assert.That(math.distance(profile.LegPoints[0], profile.LegPoints[1]),
+ Is.EqualTo(25f).Within(0.001f));
+ Assert.That(math.distance(profile.LegPoints[^1], profile.FootPoints[0]),
+ Is.LessThan(0.001f));
+ Assert.That(math.distance(profile.FootPoints[0], profile.FootPoints[1]),
+ Is.EqualTo(12f).Within(0.001f));
+ Assert.That(math.distance(profile.FootPoints[^2], profile.FootPoints[^1]),
+ Is.EqualTo(25f).Within(0.001f));
+ const float footRadius = 20f;
+ const float footArcCenterY = -25f * 0.5f + footRadius * 0.5f;
+ var expectedLocalFootStart = new float3(10f, 20f, -50f)
+ + math.mul(quaternion.EulerXYZ(math.radians(new float3(90f, 0f, 30f))),
+ new float3(-footRadius, footArcCenterY + 12f, 0f));
+ var expectedFootStart = profile.LegPoints[0]
+ + profile.AttachmentProfile.Frame.Right * expectedLocalFootStart.x
+ + profile.AttachmentProfile.Frame.Tangent * expectedLocalFootStart.y
+ + profile.AttachmentProfile.Frame.Up * expectedLocalFootStart.z;
+ Assert.That(math.distance(profile.FootPoints[0], expectedFootStart),
+ Is.LessThan(0.001f));
+ var footNormal = math.normalize(math.cross(
+ profile.FootPoints[1] - profile.FootPoints[0],
+ profile.FootPoints[2] - profile.FootPoints[1]));
+ Assert.That(math.abs(math.dot(footNormal,
+ profile.AttachmentProfile.Frame.Up)), Is.LessThan(0.99f),
+ "the authored foot rotation should tilt its plane out of the default orientation");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(27.9f, 61f, -269.2f)]
+ [TestCase(19f, 56.9f, -269.2f)]
+ [TestCase(27.9f, 56.9f, -82f)]
+ public void ShouldKeepAStandVisibleAcrossPracticalFootAdjustments(float armLength,
+ float connectedArmLength, float zRotation)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var railVertexCount = component.RenderMesh.vertexCount;
+ var fixtureIndex = component.AddStandFixture(250f);
+ var stand = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+ component.SetStandFixtureProperties(fixtureIndex, stand.Distance,
+ WireRailStandSide.Right, new Vector3(1f, 0f, 0f), 17.36f,
+ new Vector3(69.8f, 10.5f, -4.81f),
+ new Vector3(0f, 0f, zRotation), 18.66f,
+ armLength, connectedArmLength, -5.44f, -6.03f, 0f);
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ component.SplineContainer.Spline, component.Segments, stand,
+ out var profile), Is.True);
+ Assert.That(profile.CombinedPath.Count, Is.GreaterThan(1));
+ Assert.That(component.RenderMesh.vertexCount, Is.GreaterThan(railVertexCount));
+
+ var vertices = new List();
+ var normals = new List();
+ var uvs = new List();
+ var indices = new List();
+ WireRailFixtureMeshGenerator.AppendPolylineTube(profile.CombinedPath,
+ profile.AttachmentProfile.Frame, stand.Diameter, 0f, 8,
+ vertices, normals, uvs, indices, true);
+ Assert.That(vertices, Is.Not.Empty);
+ Assert.That(indices, Is.Not.Empty);
+ var expectedRadius = stand.Diameter * 0.5f;
+ for (var pointIndex = 0; pointIndex < profile.CombinedPath.Count;
+ pointIndex++) {
+ for (var radialIndex = 0; radialIndex < 8; radialIndex++) {
+ Assert.That(math.distance((float3)vertices[pointIndex * 8 + radialIndex],
+ profile.CombinedPath[pointIndex]),
+ Is.EqualTo(expectedRadius).Within(0.001f));
+ }
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldHideALegAndFootWhenEitherBottomRailIsInactive()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var fixtureIndex = component.AddStandFixture(250f);
+ var leg = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ component.SplineContainer.Spline, component.Segments, leg, out _),
+ Is.True);
+
+ component.SetRailsActive(0, new[] { 0 }, false);
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ component.SplineContainer.Spline, component.Segments, leg, out _),
+ Is.False);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldAttachAHairpinToTwoRailsAtTheSelectedEndpoint()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var fixtureIndex = component.AddHairpinFixture();
+ var hairpin = (WireRailHairpinFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(hairpin.Endpoint, Is.EqualTo(WireRailEndpoint.End));
+ Assert.That(hairpin.Distance, Is.EqualTo(component.SplineLength));
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(
+ component.SplineContainer.Spline, component.Segments, hairpin,
+ out var endProfile), Is.True);
+ Assert.That(math.distance(endProfile.CenterlinePoints[0],
+ endProfile.FirstLeadPoints[0]), Is.LessThan(0.0001f));
+ Assert.That(math.distance(endProfile.CenterlinePoints[^1],
+ endProfile.SecondLeadPoints[^1]), Is.LessThan(0.0001f));
+ Assert.That(endProfile.CenterlinePoints.Max(point => point.y),
+ Is.GreaterThan(component.SplineLength));
+
+ component.SetHairpinFixtureProperties(fixtureIndex, WireRailEndpoint.Start,
+ 0, 1, hairpin.LoopDiameter, hairpin.LeadLength,
+ hairpin.TangentLength, hairpin.RingDensity, 0f, 0f);
+ Assert.That(hairpin.Distance, Is.Zero);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(
+ component.SplineContainer.Spline, component.Segments, hairpin,
+ out var startProfile), Is.True);
+ Assert.That(startProfile.CenterlinePoints.Min(point => point.y), Is.LessThan(0f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(WireRailEndpoint.Start)]
+ [TestCase(WireRailEndpoint.End)]
+ public void ShouldUseFlatRailCapsWhereAHairpinAttaches(WireRailEndpoint endpoint)
+ {
+ const int radialSegments = 10;
+ const float capBevelSize = 2f;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ component.SetWireCapBevelSize(capBevelSize);
+ component.AddHairpinFixture(endpoint);
+
+ var spline = component.SplineContainer.Spline;
+ var samples = WireRailRenderMeshGenerator.BuildSampleParameters(
+ spline, component.Segments, 0, 0, 16);
+ var attachedAtStart = endpoint == WireRailEndpoint.Start;
+ var attachedRingIndex = attachedAtStart ? 0 : samples.Count - 1;
+ var exposedRingIndex = attachedAtStart ? samples.Count - 1 : 0;
+ var attachedCurveT = samples[attachedRingIndex];
+ var exposedCurveT = samples[exposedRingIndex];
+ var attachedStep = attachedAtStart
+ ? samples[1] - samples[0]
+ : samples[^1] - samples[^2];
+ var exposedStep = attachedAtStart
+ ? samples[^1] - samples[^2]
+ : samples[1] - samples[0];
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailFrame(spline,
+ component.Segments, 0, 0, attachedCurveT, attachedStep,
+ out var attachedFrame), Is.True);
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailFrame(spline,
+ component.Segments, 0, 0, exposedCurveT, exposedStep,
+ out var exposedFrame), Is.True);
+
+ var vertices = component.RenderMesh.vertices;
+ var attachedCenter = RingCenter(vertices,
+ attachedRingIndex * radialSegments, radialSegments);
+ var exposedCenter = RingCenter(vertices,
+ exposedRingIndex * radialSegments, radialSegments);
+ Assert.That(math.distance(attachedCenter, attachedFrame.Position),
+ Is.LessThan(0.001f), "the fitting joint must not inset the rail cap");
+ Assert.That(math.distance(exposedCenter, exposedFrame.Position),
+ Is.EqualTo(capBevelSize).Within(0.001f),
+ "the opposite exposed rail end should retain its cap bevel");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+
+ static float3 RingCenter(IReadOnlyList vertices, int start,
+ int count)
+ {
+ var center = float3.zero;
+ for (var index = 0; index < count; index++) {
+ center += (float3)vertices[start + index];
+ }
+ return center / count;
+ }
+ }
+
+ [TestCase(WireRailEndpoint.Start)]
+ [TestCase(WireRailEndpoint.End)]
+ public void ShouldShortenConnectedRailsToKeepAHairpinAttachedWhenOffset(
+ WireRailEndpoint endpoint)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var fixtureIndex = component.AddHairpinFixture(endpoint);
+ var hairpin = (WireRailHairpinFixture)component.Fixtures[fixtureIndex];
+
+ // Baseline: the loop sits at the endpoint with no offset.
+ component.SetHairpinFixtureProperties(fixtureIndex, endpoint,
+ hairpin.FirstRailIndex, hairpin.SecondRailIndex, hairpin.LoopDiameter,
+ hairpin.LeadLength, hairpin.TangentLength, hairpin.RingDensity,
+ 0f, hairpin.Rotation);
+ var baselineVertexCount = component.RenderMesh.vertexCount;
+ var baselineExtent = MaxComponent(component.RenderMesh.bounds.size);
+
+ // The offset moves the loop back along the rails; the two connected rails
+ // shorten to follow it so it stays attached.
+ const float offset = 20f;
+ component.SetHairpinFixtureProperties(fixtureIndex, endpoint,
+ hairpin.FirstRailIndex, hairpin.SecondRailIndex, hairpin.LoopDiameter,
+ hairpin.LeadLength, hairpin.TangentLength, hairpin.RingDensity,
+ offset, hairpin.Rotation);
+
+ Assert.That(
+ ((WireRailHairpinFixture)component.Fixtures[fixtureIndex]).RailOffset,
+ Is.EqualTo(offset));
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(
+ component.SplineContainer.Spline, component.Segments, hairpin, out _),
+ Is.True, "the loop must still generate when offset");
+ Assert.That(component.RenderMesh.vertexCount, Is.EqualTo(baselineVertexCount),
+ "the loop stays connected, so no detach caps are added");
+ Assert.That(baselineExtent - MaxComponent(component.RenderMesh.bounds.size),
+ Is.EqualTo(offset).Within(2f),
+ "the connected rails shorten by the offset, pulling the loop in with them");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+
+ static float MaxComponent(Vector3 v) => math.max(v.x, math.max(v.y, v.z));
+ }
+
+ [Test]
+ public void ShouldOmitAHairpinThatConflictsWithARailTrim()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var trimIndex = component.AddRailTrimFixture(WireRailEndpoint.Start);
+ component.SetRailTrimFixtureProperties(trimIndex, WireRailEndpoint.Start,
+ new[] { 30f, 0f });
+ var renderVertexCount = component.RenderMesh.vertexCount;
+ var colliderVertexCount = component.ColliderMesh.vertexCount;
+ var hairpinIndex = component.AddHairpinFixture(WireRailEndpoint.Start);
+ var hairpin = (WireRailHairpinFixture)component.Fixtures[hairpinIndex];
+
+ Assert.That(component.HasRailTrimConflict(hairpin.Endpoint,
+ hairpin.FirstRailIndex, hairpin.SecondRailIndex), Is.True);
+ Assert.That(component.RenderMesh.vertexCount, Is.EqualTo(renderVertexCount),
+ "a conflicting loop must not leave floating render geometry");
+ Assert.That(component.ColliderMesh.vertexCount,
+ Is.EqualTo(colliderVertexCount),
+ "a conflicting loop must not leave a floating collider");
+ Assert.That(component.ColliderMesh.subMeshCount, Is.EqualTo(1));
+
+ component.SetRailTrimFixtureProperties(trimIndex, WireRailEndpoint.Start,
+ new[] { 0f, 0f });
+ Assert.That(component.HasRailTrimConflict(hairpin.Endpoint,
+ hairpin.FirstRailIndex, hairpin.SecondRailIndex), Is.False);
+ Assert.That(component.RenderMesh.vertexCount, Is.GreaterThan(renderVertexCount));
+ Assert.That(component.ColliderMesh.subMeshCount, Is.EqualTo(2));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(WireRailEndpoint.Start)]
+ [TestCase(WireRailEndpoint.End)]
+ public void ShouldTrimEachRailIndependentlyAtAnEndpoint(WireRailEndpoint endpoint)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ component.SetWireCapBevelSize(0f);
+ var fixtureIndex = component.AddRailTrimFixture(endpoint);
+ component.SetRailTrimFixtureProperties(fixtureIndex, endpoint,
+ new[] { 50f, 100f });
+
+ var vertices = component.RenderMesh.vertices;
+ var firstRail = vertices.Where(vertex => vertex.x < 0f).ToArray();
+ var secondRail = vertices.Where(vertex => vertex.x > 0f).ToArray();
+ Assert.That(firstRail, Is.Not.Empty);
+ Assert.That(secondRail, Is.Not.Empty);
+ if (endpoint == WireRailEndpoint.Start) {
+ Assert.That(firstRail.Min(vertex => vertex.y),
+ Is.EqualTo(50f).Within(0.25f));
+ Assert.That(secondRail.Min(vertex => vertex.y),
+ Is.EqualTo(100f).Within(0.25f));
+ } else {
+ Assert.That(firstRail.Max(vertex => vertex.y),
+ Is.EqualTo(450f).Within(0.25f));
+ Assert.That(secondRail.Max(vertex => vertex.y),
+ Is.EqualTo(400f).Within(0.25f));
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(WireRailEndpoint.Start, 75f)]
+ [TestCase(WireRailEndpoint.End, 425f)]
+ public void ShouldTrimTheColliderToTheRemainingRailChannel(
+ WireRailEndpoint endpoint, float expectedBoundary)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddRailTrimFixture(endpoint);
+ component.SetRailTrimFixtureProperties(fixtureIndex, endpoint,
+ new[] { 75f, 75f, 75f, 75f });
+
+ var collider = component.ColliderMesh;
+ Assert.That(collider, Is.Not.Null, component.GenerationError);
+ var actualBoundary = endpoint == WireRailEndpoint.Start
+ ? collider.bounds.min.y : collider.bounds.max.y;
+ Assert.That(actualBoundary, Is.EqualTo(expectedBoundary).Within(0.25f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepTheLargestRailTrimRegardlessOfFixtureOrder()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ component.SetWireCapBevelSize(0f);
+ var firstIndex = component.AddRailTrimFixture(WireRailEndpoint.Start);
+ component.SetRailTrimFixtureProperties(firstIndex, WireRailEndpoint.Start,
+ new[] { 80f, 0f });
+ var secondIndex = component.AddRailTrimFixture(WireRailEndpoint.Start);
+ component.SetRailTrimFixtureProperties(secondIndex, WireRailEndpoint.Start,
+ new[] { 30f, 0f });
+ component.MoveFixture(secondIndex, firstIndex);
+
+ var firstRail = component.RenderMesh.vertices
+ .Where(vertex => vertex.x < 0f).ToArray();
+ Assert.That(firstRail.Min(vertex => vertex.y),
+ Is.EqualTo(80f).Within(0.25f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldResizeRailTrimOffsetsWithTheComponentRailCount()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddRailTrimFixture(WireRailEndpoint.Start);
+ component.SetRailTrimFixtureProperties(fixtureIndex, WireRailEndpoint.Start,
+ new[] { 10f, 20f, 30f, 40f });
+ component.SetRailCount(6);
+ component.SynchronizeSegments();
+
+ var railTrim = (WireRailTrimFixture)component.Fixtures[fixtureIndex];
+ Assert.That(railTrim.RailOffsets,
+ Is.EqualTo(new[] { 10f, 20f, 30f, 40f, 0f, 0f }));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldMirrorAStandWithoutLosingItsGeometry()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddStandFixture(250f);
+ component.SetStandFixtureProperties(fixtureIndex, 250f,
+ WireRailStandSide.Right, new Vector3(1f, 0f, 0f), 19.5f,
+ new Vector3(42.59f, 9.1f, 0.4f), new Vector3(0f, 0f, 90f),
+ 17.6f, 19.2f, 0f, -7.9f, -6.7f);
+ var spline = component.SplineContainer.Spline;
+ var stand = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ spline, component.Segments, stand, out var original), Is.True);
+
+ component.MirrorStandFixture(fixtureIndex);
+
+ stand = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+ Assert.That(stand.LegSide, Is.EqualTo(WireRailStandSide.Left));
+ Assert.That(stand.FootClockwise, Is.True);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ spline, component.Segments, stand, out var mirrored), Is.True);
+ Assert.That(mirrored.CombinedPath, Has.Count.EqualTo(original.CombinedPath.Count));
+ var maximumDeviation = 0f;
+ for (var pointIndex = 0; pointIndex < original.CombinedPath.Count; pointIndex++) {
+ var expected = original.CombinedPath[pointIndex];
+ expected.x = -expected.x;
+ maximumDeviation = math.max(maximumDeviation,
+ math.distance(mirrored.CombinedPath[pointIndex], expected));
+ }
+ Assert.That(maximumDeviation, Is.LessThan(0.05f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldReverseAStandFootToClockwise()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddStandFixture(250f);
+ var spline = component.SplineContainer.Spline;
+ var stand = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ spline, component.Segments, stand, out var counterClockwise), Is.True);
+
+ component.SetStandFixtureProperties(fixtureIndex, stand.Distance,
+ stand.LegSide, stand.StartDirection, stand.StartLength,
+ stand.FootPosition, stand.FootRotation, stand.FootWidth,
+ stand.FootLength, stand.FootConnectionLength, stand.LateralOffset,
+ stand.VerticalOffset, stand.LengthAdjustment, true);
+
+ stand = (WireRailStandFixture)component.Fixtures[fixtureIndex];
+ Assert.That(stand.FootClockwise, Is.True);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateStandProfile(
+ spline, component.Segments, stand, out var clockwise), Is.True);
+ var pivot = clockwise.LegPoints[0]
+ + clockwise.AttachmentProfile.Frame.Right * stand.FootPosition.x
+ + clockwise.AttachmentProfile.Frame.Tangent * stand.FootPosition.y
+ + clockwise.AttachmentProfile.Frame.Up * stand.FootPosition.z;
+ var counterClockwiseStart = math.dot(counterClockwise.FootPoints[0] - pivot,
+ clockwise.AttachmentProfile.Frame.Right);
+ var counterClockwiseEnd = math.dot(counterClockwise.FootPoints[^1] - pivot,
+ clockwise.AttachmentProfile.Frame.Right);
+ var clockwiseStart = math.dot(clockwise.FootPoints[0] - pivot,
+ clockwise.AttachmentProfile.Frame.Right);
+ var clockwiseEnd = math.dot(clockwise.FootPoints[^1] - pivot,
+ clockwise.AttachmentProfile.Frame.Right);
+ Assert.That(counterClockwiseStart, Is.LessThan(counterClockwiseEnd));
+ Assert.That(clockwiseStart, Is.GreaterThan(clockwiseEnd));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(WireRailEndpoint.Start, 100f)]
+ [TestCase(WireRailEndpoint.End, 400f)]
+ public void ShouldCapARailTrimAtAnExactLayoutBoundary(
+ WireRailEndpoint endpoint, float boundaryDistance)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(1);
+ component.SetWireCapBevelSize(0f);
+ component.AddLayout(boundaryDistance);
+ var offset = endpoint == WireRailEndpoint.Start
+ ? boundaryDistance : component.SplineLength - boundaryDistance;
+ var fixtureIndex = component.AddRailTrimFixture(endpoint);
+ component.SetRailTrimFixtureProperties(fixtureIndex, endpoint,
+ new[] { offset });
+ Assert.That(WireRailSplineGeometry.TryEvaluateDistance(
+ component.SplineContainer.Spline, boundaryDistance, out var frame), Is.True);
+ var expectedNormal = endpoint == WireRailEndpoint.Start
+ ? -frame.Tangent : frame.Tangent;
+ var vertices = component.RenderMesh.vertices;
+ var normals = component.RenderMesh.normals;
+ var capVertexCount = 0;
+ for (var vertexIndex = 0; vertexIndex < vertices.Length; vertexIndex++) {
+ var fromBoundary = (float3)vertices[vertexIndex] - frame.Position;
+ if (math.abs(math.dot(fromBoundary, frame.Tangent)) < 0.01f
+ && math.dot((float3)normals[vertexIndex], expectedNormal) > 0.99f) {
+ capVertexCount++;
+ }
+ }
+ Assert.That(capVertexCount, Is.GreaterThan(0));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldFlareHairpinLeadsToTheAuthoredLoopDiameter()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ component.SetRailOffset(0, 0, new Vector2(-15f, 0f));
+ component.SetRailOffset(0, 1, new Vector2(15f, 0f));
+ var fixtureIndex = component.AddHairpinFixture();
+ component.SetHairpinFixtureProperties(fixtureIndex, WireRailEndpoint.End,
+ 0, 1, 100f, 45f, 18f, 32, 0f, 0f);
+ var hairpin = (WireRailHairpinFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(
+ component.SplineContainer.Spline, component.Segments, hairpin,
+ out var profile), Is.True);
+ Assert.That(math.distance(profile.TerminalPoints[0],
+ profile.TerminalPoints[^1]), Is.EqualTo(100f).Within(0.001f));
+ Assert.That(math.distance(profile.FirstLeadPoints[0],
+ profile.TerminalPoints[0]), Is.GreaterThan(20f));
+ Assert.That(profile.TerminalPoints.Count, Is.EqualTo(17));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldNotTrimRailsForAHairpinItCannotGenerate()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddHairpinFixture();
+ component.SetHairpinFixtureOffset(fixtureIndex, 100f);
+ var hairpin = (WireRailHairpinFixture)component.Fixtures[fixtureIndex];
+ var spline = component.SplineContainer.Spline;
+ var startOffsets = new float[component.RailCount];
+ var endOffsets = new float[component.RailCount];
+
+ WireRailEndpointTrimUtility.Collect(spline, component.Segments,
+ component.Fixtures, startOffsets, endOffsets);
+ Assert.That(endOffsets[0], Is.EqualTo(100f).Within(0.001f));
+ Assert.That(endOffsets[1], Is.EqualTo(100f).Within(0.001f));
+
+ component.SetRailsActive(0, new[] { 0 }, false);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(spline,
+ component.Segments, hairpin, out _), Is.False);
+ WireRailEndpointTrimUtility.Collect(spline, component.Segments,
+ component.Fixtures, startOffsets, endOffsets);
+ Assert.That(endOffsets[1], Is.Zero,
+ "a hairpin that is not generated must not shorten its surviving rail");
+ Assert.That(endOffsets[0], Is.Zero);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOmitEndFittingsWhoseOffsetConsumesTheRoute()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var hairpinIndex = component.AddHairpinFixture();
+ component.SetHairpinFixtureOffset(hairpinIndex, component.SplineLength);
+ var elbowIndex = component.AddElbowFixture(WireRailEndpoint.Start);
+ component.SetElbowFixtureOffset(elbowIndex, component.SplineLength);
+ var spline = component.SplineContainer.Spline;
+ var hairpin = (WireRailHairpinFixture)component.Fixtures[hairpinIndex];
+ var elbow = (WireRailElbowFixture)component.Fixtures[elbowIndex];
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(spline,
+ component.Segments, hairpin, out _), Is.False,
+ "no rail is left for the loop to attach to");
+ Assert.That(WireRailEndpointTrimUtility.IsElbowGeneratable(component.Segments,
+ elbow, component.SplineLength), Is.False);
+
+ var startOffsets = new float[component.RailCount];
+ var endOffsets = new float[component.RailCount];
+ WireRailEndpointTrimUtility.Collect(spline, component.Segments,
+ component.Fixtures, startOffsets, endOffsets);
+ Assert.That(startOffsets, Is.All.Zero, "an omitted elbow trims nothing");
+ Assert.That(endOffsets, Is.All.Zero, "an omitted hairpin trims nothing");
+ Assert.That(component.RenderMesh.vertexCount, Is.GreaterThan(0),
+ "the rails themselves are still rendered");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldNotLetAnOmittedElbowSuppressAValidHairpin()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var hairpinIndex = component.AddHairpinFixture();
+ var elbowIndex = component.AddElbowFixture();
+ var hairpin = (WireRailHairpinFixture)component.Fixtures[hairpinIndex];
+ var elbow = (WireRailElbowFixture)component.Fixtures[elbowIndex];
+ Assert.That(hairpin.Endpoint, Is.EqualTo(elbow.Endpoint));
+ Assert.That(component.HasRailTrimConflict(hairpin.Endpoint,
+ hairpin.FirstRailIndex, hairpin.SecondRailIndex, hairpin), Is.True,
+ "a generated elbow on the same rails conflicts with the hairpin");
+
+ component.SetElbowFixtureOffset(elbowIndex, component.SplineLength);
+
+ Assert.That(component.HasRailTrimConflict(hairpin.Endpoint,
+ hairpin.FirstRailIndex, hairpin.SecondRailIndex, hairpin), Is.False,
+ "an elbow that is not generated cannot conflict with anything");
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(
+ component.SplineContainer.Spline, component.Segments, hairpin, out _), Is.True);
+ component.RebuildRenderGeometry();
+ Assert.That(component.RenderFixtureIndexRanges[hairpinIndex].y, Is.GreaterThan(0),
+ "the hairpin is rendered");
+ Assert.That(component.RenderFixtureIndexRanges[elbowIndex].y, Is.Zero,
+ "the omitted elbow is not");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldHideAHairpinWhenAnAttachedRailIsInactive()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var fixtureIndex = component.AddHairpinFixture();
+ var hairpin = (WireRailHairpinFixture)component.Fixtures[fixtureIndex];
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(
+ component.SplineContainer.Spline, component.Segments, hairpin, out _),
+ Is.True);
+
+ component.SetRailsActive(0, new[] { 1 }, false);
+
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(
+ component.SplineContainer.Spline, component.Segments, hairpin, out _),
+ Is.False);
+
+ component.SetRailsActive(0, new[] { 1 }, true);
+ component.SplineContainer.Spline.Closed = true;
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(
+ component.SplineContainer.Spline, component.Segments, hairpin, out _),
+ Is.False, "a closed spline has no endpoint for an end fitting");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldPutTheHairpinTerminalArcInASeparateColliderSubmesh()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var originalChannelIndexCount = component.ColliderMesh.GetIndexCount(0);
+ var originalVertexCount = component.ColliderMesh.vertexCount;
+ var fixtureIndex = component.AddHairpinFixture();
+ var hairpin = (WireRailHairpinFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(component.ColliderMesh.subMeshCount, Is.EqualTo(2));
+ Assert.That(component.ColliderMesh.GetIndexCount(0),
+ Is.EqualTo(originalChannelIndexCount + 192),
+ "four box faces over eight approach spans should use the ordinary material");
+ Assert.That(component.ColliderMesh.GetIndexCount(1), Is.EqualTo(288),
+ "the terminal semicircle should use twelve coarse box spans");
+ Assert.That(component.ColliderMesh.vertexCount,
+ Is.EqualTo(originalVertexCount + 84),
+ "the complete fitting should use 21 four-corner box rings");
+ Assert.That(component.ColliderMesh.GetIndices(0)
+ .Intersect(component.ColliderMesh.GetIndices(1)).Count(), Is.EqualTo(8),
+ "both material sections should share the two four-corner seam rings");
+
+ component.SetHairpinFixtureProperties(fixtureIndex, hairpin.Endpoint,
+ hairpin.FirstRailIndex, hairpin.SecondRailIndex, hairpin.LoopDiameter,
+ hairpin.LeadLength, hairpin.TangentLength, 128,
+ hairpin.RailOffset, hairpin.Rotation);
+ Assert.That(component.ColliderMesh.GetIndexCount(0),
+ Is.EqualTo(originalChannelIndexCount + 192));
+ Assert.That(component.ColliderMesh.GetIndexCount(1), Is.EqualTo(288),
+ "render ring density must not increase collider density");
+ Assert.That(component.ColliderMesh.vertexCount,
+ Is.EqualTo(originalVertexCount + 84));
+
+ component.RemoveFixture(0);
+ Assert.That(component.ColliderMesh.subMeshCount, Is.EqualTo(1));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldUseTheTerminalImpactMaterialOnlyForHairpinArcTriangles()
+ {
+ var go = new GameObject("Wire Rail");
+ var terminalMaterial = ScriptableObject.CreateInstance();
+ var transforms = new NativeParallelHashMap(0, Allocator.Temp);
+ var colliders = new ColliderReference(ref transforms, Allocator.Temp);
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ component.AddHairpinFixture();
+ terminalMaterial.Elasticity = 0.91f;
+ terminalMaterial.ElasticityFalloff = 0.13f;
+ terminalMaterial.Friction = 0.27f;
+ terminalMaterial.ScatterAngle = 0.42f;
+ Assert.That(component.PhysicsOverwrite, Is.True,
+ "the terminal material is intentionally independent of the main overwrite mode");
+ component.TerminalPhysicsMaterialReference = terminalMaterial;
+ var expectedTerminalTriangleCount =
+ (int)component.ColliderMesh.GetIndexCount(1) / 3;
+
+ ((ICollidableComponent)component).GetColliders(null, null, ref colliders,
+ float4x4.identity, 0f);
+
+ var triangleColliders = colliders.ToArray().OfType().ToArray();
+ var terminalColliderCount = triangleColliders.Count(collider =>
+ math.abs(collider.Header.Material.Elasticity - 0.91f) < 0.0001f);
+ Assert.That(terminalColliderCount, Is.EqualTo(expectedTerminalTriangleCount));
+ Assert.That(triangleColliders.Any(collider =>
+ math.abs(collider.Header.Material.Elasticity - 0.3f) < 0.0001f), Is.True,
+ "the channel and approach leads should retain the ordinary material");
+ } finally {
+ colliders.Dispose();
+ transforms.Dispose();
+ Object.DestroyImmediate(terminalMaterial);
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldDuplicateEveryHairpinSettingAndSynchronizeItsDiameter()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var sourceIndex = component.AddHairpinFixture(WireRailEndpoint.Start);
+ component.SetHairpinFixtureProperties(sourceIndex, WireRailEndpoint.Start,
+ 2, 3, 92f, 37f, 11f, 40, 6f, 27f);
+ var duplicateIndex = component.DuplicateHairpinFixture(sourceIndex);
+ component.SetWireDiameter(12f);
+ var source = (WireRailHairpinFixture)component.Fixtures[sourceIndex];
+ var duplicate = (WireRailHairpinFixture)component.Fixtures[duplicateIndex];
+
+ Assert.That(duplicateIndex, Is.EqualTo(sourceIndex + 1));
+ Assert.That(duplicate, Is.Not.SameAs(source));
+ Assert.That(duplicate.Endpoint, Is.EqualTo(source.Endpoint));
+ Assert.That(duplicate.Distance, Is.Zero);
+ Assert.That(duplicate.FirstRailIndex, Is.EqualTo(source.FirstRailIndex));
+ Assert.That(duplicate.SecondRailIndex, Is.EqualTo(source.SecondRailIndex));
+ Assert.That(duplicate.LoopDiameter, Is.EqualTo(source.LoopDiameter));
+ Assert.That(duplicate.LeadLength, Is.EqualTo(source.LeadLength));
+ Assert.That(duplicate.TangentLength, Is.EqualTo(source.TangentLength));
+ Assert.That(duplicate.RingDensity, Is.EqualTo(source.RingDensity));
+ Assert.That(duplicate.RailOffset, Is.EqualTo(source.RailOffset));
+ Assert.That(duplicate.Rotation, Is.EqualTo(source.Rotation));
+ Assert.That(source.Diameter, Is.EqualTo(12f));
+ Assert.That(duplicate.Diameter, Is.EqualTo(12f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(WireRailEndpoint.Start, -1f)]
+ [TestCase(WireRailEndpoint.End, 1f)]
+ public void ShouldBuildTwoParallelRoundedElbowRails(
+ WireRailEndpoint endpoint, float endpointDirection)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var fixtureIndex = component.AddElbowFixture(endpoint);
+ var elbow = (WireRailElbowFixture)component.Fixtures[fixtureIndex];
+
+ // Zero offset drops straight at the endpoint: no straight run, the rounded
+ // bend begins at the attachment and ends the elbow length below it.
+ component.SetElbowFixtureProperties(fixtureIndex, endpoint, 0, 1,
+ 0f, 90f, 30f, new[] { 0f, 0f });
+ Assert.That(elbow.Offset, Is.Zero);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateElbowProfile(
+ component.SplineContainer.Spline, component.Segments, elbow,
+ out var zeroProfile), Is.True);
+ var outward = math.normalizesafe(math.mul(quaternion.AxisAngle(
+ zeroProfile.Frame.Up, math.radians(30f)),
+ zeroProfile.Frame.Tangent * endpointDirection));
+ var attachAtEndpoint = zeroProfile.FirstRailPoints[0];
+ Assert.That(math.distance(zeroProfile.FirstRailPoints[^1],
+ attachAtEndpoint + outward * elbow.Diameter
+ - zeroProfile.Frame.Up * 90f),
+ Is.LessThan(0.001f),
+ "the drop rounds outward by the bend radius, then drops straight down");
+
+ // A positive offset shortens the rails: the attachment slides inward along the
+ // spline by the offset and the same rounded elbow follows it, still no straight run.
+ component.SetElbowFixtureProperties(fixtureIndex, endpoint, 0, 1,
+ 45f, 90f, 30f, new[] { 0f, 0f });
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateElbowProfile(
+ component.SplineContainer.Spline, component.Segments, elbow,
+ out var profile), Is.True);
+ Assert.That(profile.FirstRailPoints.Count, Is.GreaterThan(3));
+ Assert.That(profile.SecondRailPoints.Count,
+ Is.EqualTo(profile.FirstRailPoints.Count));
+ var inward = math.normalizesafe(
+ profile.Frame.Tangent * -endpointDirection);
+ var attachmentShift = profile.FirstRailPoints[0] - attachAtEndpoint;
+ Assert.That(math.length(attachmentShift), Is.EqualTo(45f).Within(1f),
+ "a positive offset moves the drop inward from the endpoint by the offset");
+ Assert.That(math.dot(math.normalizesafe(attachmentShift), inward),
+ Is.GreaterThan(0.99f),
+ "the drop must move inward along the rails, not sideways");
+ Assert.That(math.distance(profile.FirstRailPoints[^1],
+ profile.FirstRailPoints[0] + outward * elbow.Diameter
+ - profile.Frame.Up * 90f),
+ Is.LessThan(0.01f),
+ "the rounded drop still starts at the (shifted) attachment, no straight run");
+ for (var pointIndex = 0; pointIndex < profile.FirstRailPoints.Count;
+ pointIndex++) {
+ Assert.That(math.distance(
+ profile.SecondRailPoints[pointIndex]
+ - profile.FirstRailPoints[pointIndex],
+ profile.SecondRailPoints[0] - profile.FirstRailPoints[0]),
+ Is.LessThan(0.001f));
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldChooseTheActiveRailPairForAStartElbow()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(6);
+ component.SetWireCapBevelSize(2f);
+ component.AddLayout(component.SplineLength * 0.5f);
+ var endpointLayoutIndex = component.AddLayout(0f);
+ component.SetRailsActive(endpointLayoutIndex,
+ new[] { 0, 1, 2, 3 }, false);
+
+ var fixtureIndex = component.AddElbowFixture(WireRailEndpoint.Start);
+ var elbow = (WireRailElbowFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(elbow.FirstRailIndex, Is.EqualTo(4));
+ Assert.That(elbow.SecondRailIndex, Is.EqualTo(5));
+ Assert.That(component.AreEndpointRailsActive(WireRailEndpoint.Start,
+ elbow.FirstRailIndex, elbow.SecondRailIndex), Is.True);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateElbowProfile(
+ component.SplineContainer.Spline, component.Segments, elbow,
+ out var profile),
+ Is.True);
+ var attachedRingCenter = float3.zero;
+ var renderVertices = component.RenderMesh.vertices;
+ const int radialSegments = 10;
+ for (var vertexIndex = 0; vertexIndex < radialSegments; vertexIndex++) {
+ attachedRingCenter += (float3)renderVertices[vertexIndex];
+ }
+ attachedRingCenter /= radialSegments;
+ Assert.That(math.distance(attachedRingCenter,
+ profile.FirstRailPoints[0]), Is.LessThan(0.001f),
+ "a duplicate-distance start layout must not leave a cap bevel inside the fitting");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldDeferLegacyElbowPairMigrationUntilTwoRailsAreActive()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(6);
+ component.SetRailsActive(0, new[] { 0, 1, 2, 3, 5 }, false);
+ var elbow = new WireRailElbowFixture();
+ JsonUtility.FromJsonOverwrite(
+ "{\"_endpoint\":0,\"_firstRailIndex\":0,\"_secondRailIndex\":1,"
+ + "\"_railPairInitialized\":false}", elbow);
+ var fixturesField = typeof(WireRailComponent).GetField("_fixtures",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.That(fixturesField, Is.Not.Null);
+ fixturesField.SetValue(component, new List { elbow });
+
+ component.SynchronizeSegments();
+
+ Assert.That(elbow.RailPairInitialized, Is.False);
+ Assert.That(elbow.FirstRailIndex, Is.EqualTo(0));
+ Assert.That(elbow.SecondRailIndex, Is.EqualTo(1));
+
+ component.SetRailsActive(0, new[] { 5 }, true);
+ component.SynchronizeSegments();
+
+ Assert.That(elbow.RailPairInitialized, Is.True);
+ Assert.That(elbow.FirstRailIndex, Is.EqualTo(4));
+ Assert.That(elbow.SecondRailIndex, Is.EqualTo(5));
+ Assert.That(elbow.EnsureRailPairInitialized(0, 1), Is.False);
+ Assert.That(elbow.FirstRailIndex, Is.EqualTo(4));
+ Assert.That(elbow.SecondRailIndex, Is.EqualTo(5));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldChooseANewActiveRailPairWhenTheElbowEndpointChanges()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(6);
+ var fixtureIndex = component.AddElbowFixture(WireRailEndpoint.End);
+ var elbow = (WireRailElbowFixture)component.Fixtures[fixtureIndex];
+ Assert.That(elbow.FirstRailIndex, Is.EqualTo(0));
+ Assert.That(elbow.SecondRailIndex, Is.EqualTo(1));
+ component.SetRailsActive(0, new[] { 0, 1, 2, 3 }, false);
+
+ component.SetElbowFixtureProperties(fixtureIndex, WireRailEndpoint.Start,
+ elbow.FirstRailIndex, elbow.SecondRailIndex,
+ elbow.Offset, elbow.DropLength, elbow.ZAngle,
+ elbow.RailOffsets);
+ elbow = (WireRailElbowFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(elbow.FirstRailIndex, Is.EqualTo(4));
+ Assert.That(elbow.SecondRailIndex, Is.EqualTo(5));
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateElbowProfile(
+ component.SplineContainer.Spline, component.Segments, elbow, out _),
+ Is.True);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldApplyElbowCutoffsOnlyToTheOtherRails()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(4);
+ var fixtureIndex = component.AddElbowFixture(WireRailEndpoint.End);
+ component.SetElbowFixtureProperties(fixtureIndex, WireRailEndpoint.End,
+ 1, 3, 40f, 80f, 0f, new[] { 25f, 50f, 75f, 100f });
+ var elbow = (WireRailElbowFixture)component.Fixtures[fixtureIndex];
+ var startOffsets = new float[WireRailEndpointTrimUtility.MaximumRailCount];
+ var endOffsets = new float[WireRailEndpointTrimUtility.MaximumRailCount];
+
+ WireRailEndpointTrimUtility.Collect(component.SplineContainer.Spline,
+ component.Segments, component.Fixtures, startOffsets, endOffsets);
+
+ // The per-rail cutoffs still apply only to the other rails (the attached pair
+ // is forced to zero), but the offset trims the two attached rails by 40.
+ Assert.That(elbow.RailOffsets, Is.EqualTo(new[] { 25f, 0f, 75f, 0f }));
+ Assert.That(startOffsets, Is.All.Zero);
+ Assert.That(endOffsets.Take(4), Is.EqualTo(new[] { 25f, 40f, 75f, 40f }));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(WireRailEndpoint.Start)]
+ [TestCase(WireRailEndpoint.End)]
+ public void ShouldExtendTheTwoBottomFacesDownForAnElbow(
+ WireRailEndpoint endpoint)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var baselineVertices = component.ColliderMesh.vertices;
+ var baselineIndices = component.ColliderMesh.GetIndices(0);
+
+ const float dropLength = 30f;
+ var fixtureIndex = component.AddElbowFixture(endpoint);
+ component.SetElbowFixtureProperties(fixtureIndex, endpoint, 0, 1,
+ 0f, dropLength, 0f, new[] { 0f, 0f });
+ AssertElbowExtendsTwoBottomFaces(component, endpoint, dropLength,
+ baselineVertices, baselineIndices);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ // A Elbow leaves the whole normal channel untouched and appends exactly two vertical
+ // faces: the two floor faces the ball rests on, each extended straight down (along
+ // -Up) by the elbow length, top-aligned with the channel floor.
+ private static void AssertElbowExtendsTwoBottomFaces(WireRailComponent component,
+ WireRailEndpoint endpoint, float dropLength, Vector3[] baselineVertices,
+ int[] baselineIndices)
+ {
+ var collider = component.ColliderMesh;
+ var vertices = collider.vertices;
+ var indices = collider.GetIndices(0);
+
+ // the channel prefix must be byte-for-byte the no-fixture collider
+ Assert.That(vertices.Length, Is.EqualTo(baselineVertices.Length + 8),
+ "an Elbow must append exactly two four-vertex faces");
+ Assert.That(indices.Length, Is.EqualTo(baselineIndices.Length + 24),
+ "an Elbow must append exactly two two-sided quads");
+ for (var i = 0; i < baselineVertices.Length; i++) {
+ Assert.That(math.distance((float3)vertices[i],
+ (float3)baselineVertices[i]), Is.LessThan(0.0001f),
+ "an Elbow must not change the existing channel collider");
+ }
+ for (var i = 0; i < baselineIndices.Length; i++) {
+ Assert.That(indices[i], Is.EqualTo(baselineIndices[i]));
+ }
+
+ var spline = component.SplineContainer.Spline;
+ var endpointDistance = endpoint == WireRailEndpoint.Start
+ ? 0f : component.SplineLength;
+ Assert.That(WireRailSplineGeometry.TryEvaluateDistance(spline, endpointDistance,
+ out var frame), Is.True);
+ var down = math.normalizesafe(-frame.Up);
+
+ var faceTops = new System.Collections.Generic.List();
+ for (var face = 0; face < 2; face++) {
+ var b = baselineVertices.Length + face * 4;
+ var top0 = (float3)vertices[b];
+ var bottom0 = (float3)vertices[b + 1];
+ var top1 = (float3)vertices[b + 2];
+ var bottom1 = (float3)vertices[b + 3];
+ AssertVerticalElbow(top0, bottom0);
+ AssertVerticalElbow(top1, bottom1);
+ AssertTopOnChannel(top0);
+ AssertTopOnChannel(top1);
+ faceTops.Add(top0);
+ faceTops.Add(top1);
+ }
+
+ // the two faces form a V: they share exactly one top vertex, the resting point
+ var shared = 0;
+ for (var a = 0; a < 2; a++) {
+ for (var c = 2; c < 4; c++) {
+ if (math.distance(faceTops[a], faceTops[c]) < 0.001f) {
+ shared++;
+ }
+ }
+ }
+ Assert.That(shared, Is.EqualTo(1),
+ "the two drop faces must meet at the single lowest channel point");
+
+ void AssertVerticalElbow(float3 top, float3 bottom)
+ {
+ var extrusion = bottom - top;
+ Assert.That(math.length(extrusion), Is.EqualTo(dropLength).Within(0.001f),
+ "each drop face must be exactly the drop length tall");
+ Assert.That(math.dot(math.normalizesafe(extrusion), down),
+ Is.GreaterThan(0.9999f), "each drop face must hang straight down");
+ }
+
+ void AssertTopOnChannel(float3 top)
+ => Assert.That(baselineVertices.Any(v =>
+ math.distance((float3)v, top) < 0.001f), Is.True,
+ "each drop face must be top-aligned with the channel floor");
+ }
+
+ [TestCase(WireRailEndpoint.Start)]
+ [TestCase(WireRailEndpoint.End)]
+ public void ShouldExtendOnlyTheTwoBottomFacesForASixRailElbow(
+ WireRailEndpoint endpoint)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(6);
+ var baselineVertices = component.ColliderMesh.vertices;
+ var baselineIndices = component.ColliderMesh.GetIndices(0);
+
+ // A six-rail channel has several up-facing floor faces, but only the two the
+ // ball rests on may be extended, and the channel itself must not be trimmed
+ // (these railOffsets would trim rails 2 and 3 if the Elbow affected the channel).
+ const float dropLength = 30f;
+ var railOffsets = new[] { 0f, 0f, 50f, 50f, 0f, 0f };
+ var fixtureIndex = component.AddElbowFixture(endpoint);
+ component.SetElbowFixtureProperties(fixtureIndex, endpoint, 0, 1,
+ 0f, dropLength, 0f, railOffsets);
+ AssertElbowExtendsTwoBottomFaces(component, endpoint, dropLength,
+ baselineVertices, baselineIndices);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(WireRailEndpoint.Start)]
+ [TestCase(WireRailEndpoint.End)]
+ public void ShouldMoveTheElbowInwardWithAPositiveOffset(WireRailEndpoint endpoint)
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var fixtureIndex = component.AddElbowFixture(endpoint);
+
+ component.SetElbowFixtureProperties(fixtureIndex, endpoint, 0, 1,
+ 0f, 30f, 0f, new[] { 0f, 0f });
+ var tops0 = ElbowFaceTops(component);
+ var channelExtent0 = ChannelExtentAlongElbow(component, endpoint);
+
+ const float offset = 100f;
+ component.SetElbowFixtureProperties(fixtureIndex, endpoint, 0, 1,
+ offset, 30f, 0f, new[] { 0f, 0f });
+ var topsOffset = ElbowFaceTops(component);
+ var channelExtentOffset = ChannelExtentAlongElbow(component, endpoint);
+
+ Assert.That(topsOffset.Length, Is.EqualTo(tops0.Length),
+ "the offset must keep exactly the two drop faces");
+ var spline = component.SplineContainer.Spline;
+ var endpointDistance = endpoint == WireRailEndpoint.Start
+ ? 0f : component.SplineLength;
+ Assert.That(WireRailSplineGeometry.TryEvaluateDistance(spline,
+ endpointDistance, out var frame), Is.True);
+ var inward = math.normalizesafe((endpoint == WireRailEndpoint.Start
+ ? 1f : -1f) * frame.Tangent);
+ for (var i = 0; i < tops0.Length; i++) {
+ var shift = topsOffset[i] - tops0[i];
+ Assert.That(math.length(shift), Is.EqualTo(offset).Within(1f),
+ "the drop faces must move inward by the offset");
+ Assert.That(math.dot(math.normalizesafe(shift), inward),
+ Is.GreaterThan(0.99f),
+ "the drop faces must move inward along the rails, not sideways");
+ }
+ Assert.That(channelExtent0 - channelExtentOffset,
+ Is.EqualTo(offset).Within(1f),
+ "the channel must be scaled to end at the shifted drop point");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+
+ static float3[] ElbowFaceTops(WireRailComponent component)
+ {
+ var v = component.ColliderMesh.vertices;
+ return new[] {
+ (float3)v[v.Length - 8], (float3)v[v.Length - 6],
+ (float3)v[v.Length - 4], (float3)v[v.Length - 2],
+ };
+ }
+
+ // How far the channel (everything but the eight elbow-face vertices) reaches toward
+ // the elbow endpoint, so a positive offset must reduce it by that offset.
+ static float ChannelExtentAlongElbow(WireRailComponent component,
+ WireRailEndpoint endpoint)
+ {
+ var spline = component.SplineContainer.Spline;
+ var endpointDistance = endpoint == WireRailEndpoint.Start
+ ? 0f : component.SplineLength;
+ WireRailSplineGeometry.TryEvaluateDistance(spline, endpointDistance,
+ out var frame);
+ var toward = (endpoint == WireRailEndpoint.Start ? -1f : 1f)
+ * frame.Tangent;
+ var v = component.ColliderMesh.vertices;
+ var extent = float.NegativeInfinity;
+ for (var i = 0; i < v.Length - 8; i++) {
+ extent = math.max(extent, math.dot((float3)v[i], toward));
+ }
+ return extent;
+ }
+ }
+
+ [Test]
+ public void ShouldOmitAnElbowWhenAnAttachedRailIsTrimmed()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(2);
+ var trimIndex = component.AddRailTrimFixture(WireRailEndpoint.End);
+ component.SetRailTrimFixtureProperties(trimIndex, WireRailEndpoint.End,
+ new[] { 30f, 0f });
+ var renderVertexCount = component.RenderMesh.vertexCount;
+ var colliderVertexCount = component.ColliderMesh.vertexCount;
+ var fixtureIndex = component.AddElbowFixture(WireRailEndpoint.End);
+ var elbow = (WireRailElbowFixture)component.Fixtures[fixtureIndex];
+
+ Assert.That(component.HasRailTrimConflict(elbow.Endpoint,
+ elbow.FirstRailIndex, elbow.SecondRailIndex), Is.True);
+ Assert.That(component.RenderMesh.vertexCount, Is.EqualTo(renderVertexCount));
+ Assert.That(component.ColliderMesh.vertexCount,
+ Is.EqualTo(colliderVertexCount));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldDuplicateEveryElbowSettingAndResizeItsCutoffs()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(4);
+ var sourceIndex = component.AddElbowFixture(WireRailEndpoint.Start);
+ component.SetElbowFixtureProperties(sourceIndex, WireRailEndpoint.Start,
+ 1, 2, 55f, 95f, -32f, new[] { 20f, 0f, 0f, 65f });
+ var duplicateIndex = component.DuplicateElbowFixture(sourceIndex);
+ component.SetWireDiameter(12f);
+ component.SetRailCount(6);
+ component.SynchronizeSegments();
+ var source = (WireRailElbowFixture)component.Fixtures[sourceIndex];
+ var duplicate = (WireRailElbowFixture)component.Fixtures[duplicateIndex];
+
+ Assert.That(duplicateIndex, Is.EqualTo(sourceIndex + 1));
+ Assert.That(duplicate.Endpoint, Is.EqualTo(source.Endpoint));
+ Assert.That(duplicate.FirstRailIndex, Is.EqualTo(source.FirstRailIndex));
+ Assert.That(duplicate.SecondRailIndex, Is.EqualTo(source.SecondRailIndex));
+ Assert.That(duplicate.Offset, Is.EqualTo(55f));
+ Assert.That(duplicate.DropLength, Is.EqualTo(95f));
+ Assert.That(duplicate.ZAngle, Is.EqualTo(-32f));
+ Assert.That(duplicate.RailOffsets,
+ Is.EqualTo(new[] { 20f, 0f, 0f, 65f, 0f, 0f }));
+ Assert.That(source.Diameter, Is.EqualTo(12f));
+ Assert.That(duplicate.Diameter, Is.EqualTo(12f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldMigrateLegacyRingRadiusOffsetToScale()
+ {
+ var ring = new WireRailRingFixture();
+ JsonUtility.FromJsonOverwrite(
+ "{\"_diameter\":8,\"_scale\":1,\"_radiusOffset\":10,\"_scaleInitialized\":false}",
+ ring);
+
+ Assert.That(ring.EnsureScaleInitialized(50f), Is.True);
+ Assert.That(ring.Scale, Is.EqualTo(1.2f).Within(0.001f));
+ Assert.That(ring.EnsureScaleInitialized(50f), Is.False);
+ }
+
+ [Test]
+ public void ShouldKeepRingScaleWhenMovingAcrossDifferentWireLayouts()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SplineContainer.Spline.Add(
+ new BezierKnot(new float3(0f, 1000f, 0f)), TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetRailCount(2);
+ component.SetRailOffset(0, 0, new Vector2(-20f, 0f));
+ component.SetRailOffset(0, 1, new Vector2(20f, 0f));
+ component.SetRailOffset(1, 0, new Vector2(-40f, 0f));
+ component.SetRailOffset(1, 1, new Vector2(40f, 0f));
+ var fixtureIndex = component.AddRingFixture(250f);
+ component.SetRingFixtureProperties(fixtureIndex, 250f,
+ false, 0f, 0f, scale: 1.5f);
+
+ Assert.That(component.TryGetRingCrossSection(fixtureIndex,
+ out var firstCrossSection), Is.True);
+ component.SetRingFixtureProperties(fixtureIndex, 750f,
+ false, 0f, 0f, scale: 1.5f);
+ Assert.That(component.TryGetRingCrossSection(fixtureIndex,
+ out var secondCrossSection), Is.True);
+
+ Assert.That(math.abs(secondCrossSection.BaseRadius
+ - firstCrossSection.BaseRadius), Is.GreaterThan(0.001f));
+ Assert.That(firstCrossSection.Radius / firstCrossSection.BaseRadius,
+ Is.EqualTo(1.5f).Within(0.001f));
+ Assert.That(secondCrossSection.Radius / secondCrossSection.BaseRadius,
+ Is.EqualTo(1.5f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldExposeRingCrossSectionForInspectorScale()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddRingFixture(250f);
+
+ Assert.That(component.TryGetRingCrossSection(fixtureIndex,
+ out var defaultCrossSection), Is.True);
+ Assert.That(defaultCrossSection.Radius,
+ Is.EqualTo(defaultCrossSection.BaseRadius).Within(0.001f));
+
+ component.SetRingFixtureProperties(fixtureIndex, 250f,
+ false, 0f, 0f, scale: 1.5f);
+
+ Assert.That(component.TryGetRingCrossSection(fixtureIndex,
+ out var scaledCrossSection), Is.True);
+ Assert.That(scaledCrossSection.BaseRadius,
+ Is.EqualTo(defaultCrossSection.BaseRadius).Within(0.001f));
+ Assert.That(scaledCrossSection.Radius,
+ Is.EqualTo(defaultCrossSection.BaseRadius * 1.5f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldGenerateFullAndCutoutRingGeometry()
+ {
+ const int radialSegments = 10;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var railTriangleCount = component.RenderMesh.triangles.Length / 3;
+ var fixtureIndex = component.AddRingFixture(250f);
+ var ring = (WireRailRingFixture)component.Fixtures[fixtureIndex];
+ var touches = new List();
+ WireRailSolderMeshGenerator.CollectTouches(component.SplineContainer.Spline,
+ component.Segments, component.Fixtures, ring, touches);
+ var fullRingTriangleCount = component.RenderMesh.triangles.Length / 3
+ - railTriangleCount;
+ Assert.That(fullRingTriangleCount,
+ Is.EqualTo(32 * radialSegments * 2 + touches.Count
+ * WireRailSolderMeshGenerator.TrianglesPerBlob));
+
+ component.SetRingFixtureProperties(fixtureIndex, 250f,
+ true, 0f, 90f);
+ touches.Clear();
+ WireRailSolderMeshGenerator.CollectTouches(component.SplineContainer.Spline,
+ component.Segments, component.Fixtures, ring, touches);
+ var cutoutRingTriangleCount = component.RenderMesh.triangles.Length / 3
+ - railTriangleCount;
+ Assert.That(cutoutRingTriangleCount,
+ Is.EqualTo(24 * radialSegments * 2 + radialSegments * 6
+ + touches.Count * WireRailSolderMeshGenerator.TrianglesPerBlob),
+ "a 90-degree cutout should leave a capped three-quarter ring");
+
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldUseTheAuthoredRingRingDensity()
+ {
+ const int radialSegments = 10;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var railTriangleCount = component.RenderMesh.triangles.Length / 3;
+ var fixtureIndex = component.AddRingFixture(250f);
+
+ component.SetRingFixtureProperties(fixtureIndex, 250f,
+ false, 0f, 0f, ringDensity: 12);
+
+ var ring = (WireRailRingFixture)component.Fixtures[fixtureIndex];
+ var touches = new List();
+ WireRailSolderMeshGenerator.CollectTouches(component.SplineContainer.Spline,
+ component.Segments, component.Fixtures, ring, touches);
+ Assert.That(ring.RingDensity, Is.EqualTo(12));
+ Assert.That(component.RenderMesh.triangles.Length / 3 - railTriangleCount,
+ Is.EqualTo(12 * radialSegments * 2 + touches.Count
+ * WireRailSolderMeshGenerator.TrianglesPerBlob));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldBevelEveryExposedRailAndFixtureCap()
+ {
+ const int radialSegments = 10;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetWireCapBevelSize(0f);
+ component.SetRailCount(2);
+ var fixtureIndex = component.AddRingFixture(250f);
+ component.SetRingFixtureProperties(fixtureIndex, 250f,
+ true, 0f, 90f);
+ var flatVertices = component.RenderMesh.vertexCount;
+ var flatTriangles = component.RenderMesh.triangles.Length / 3;
+
+ component.SetWireCapBevelSize(2f);
+
+ const int exposedCaps = 6; // Four rail ends plus two ring cutout ends.
+ Assert.That(component.RenderMesh.vertexCount - flatVertices,
+ Is.EqualTo(exposedCaps * radialSegments * 2));
+ Assert.That(component.RenderMesh.triangles.Length / 3 - flatTriangles,
+ Is.EqualTo(exposedCaps * radialSegments * 2));
+ Assert.That(component.WireCapBevelSize, Is.EqualTo(2f));
+
+ var samples = WireRailRenderMeshGenerator.BuildSampleParameters(
+ component.SplineContainer.Spline, component.Segments, 0, 0, 16);
+ var firstBevelVertex = samples.Count * radialSegments;
+ Assert.That(Vector3.Distance(component.RenderMesh.vertices[0],
+ component.RenderMesh.vertices[firstBevelVertex]), Is.LessThan(0.001f));
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailFrame(
+ component.SplineContainer.Spline, component.Segments, 0, 0, 0f,
+ samples[1], out var frame), Is.True);
+ var capNormal = -frame.Tangent;
+ var bevelNormal = (float3)component.RenderMesh.normals[firstBevelVertex];
+ Assert.That(math.dot(bevelNormal, capNormal),
+ Is.EqualTo(math.sqrt(0.5f)).Within(0.001f));
+ var flatCapCenter = firstBevelVertex + radialSegments * 2;
+ Assert.That(math.dot((float3)component.RenderMesh.normals[flatCapCenter],
+ capNormal), Is.EqualTo(1f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldOffsetAndReplacePartOfARingWithAStraightChord()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var fixtureIndex = component.AddRingFixture(250f);
+ var ring = (WireRailRingFixture)component.Fixtures[fixtureIndex];
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateRingProfile(
+ component.SplineContainer.Spline, component.Segments, ring,
+ out var original), Is.True);
+
+ component.SetRingFixtureProperties(fixtureIndex, 250f,
+ false, 60f, 120f,
+ true, 210f, 330f, 12f, -7f, 1.25f);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateRingProfile(
+ component.SplineContainer.Spline, component.Segments, ring,
+ out var changed), Is.True);
+ Assert.That(math.distance(changed.Center, original.Center
+ + original.Frame.Right * 12f - original.Frame.Up * 7f),
+ Is.LessThan(0.001f));
+ Assert.That(changed.Radius, Is.EqualTo(original.Radius * 1.25f).Within(0.001f));
+
+ var start = ring.EvaluateCenterlineOffset(math.radians(210f), changed.Radius);
+ var middle = ring.EvaluateCenterlineOffset(math.radians(270f), changed.Radius);
+ var end = ring.EvaluateCenterlineOffset(math.radians(330f), changed.Radius);
+ Assert.That(math.abs((middle.x - start.x) * (end.y - start.y)
+ - (middle.y - start.y) * (end.x - start.x)), Is.LessThan(0.001f));
+ Assert.That(middle.y, Is.EqualTo(-changed.Radius * 0.5f).Within(0.001f));
+
+ component.SetRingFixtureProperties(fixtureIndex, 250f,
+ false, 60f, 120f,
+ true, 210f, 330f, 12f, -7f, 0.75f);
+ Assert.That(WireRailFixtureMeshGenerator.TryEvaluateRingProfile(
+ component.SplineContainer.Spline, component.Segments, ring,
+ out var shrunk), Is.True);
+ Assert.That(shrunk.Radius, Is.EqualTo(original.Radius * 0.75f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(40f, 100f, 60f, 120f)]
+ [TestCase(210f, 300f, 225f, 315f)]
+ [TestCase(10f, 300f, 305f, 235f)]
+ public void ShouldAlignRingAngleRangesWithoutChangingTheirSweep(float start,
+ float end, float expectedStart, float expectedEnd)
+ {
+ var aligned = WireRailRingFixture.AlignAngleRangeHorizontally(start, end);
+
+ Assert.That(aligned.x, Is.EqualTo(expectedStart).Within(0.001f));
+ Assert.That(aligned.y, Is.EqualTo(expectedEnd).Within(0.001f));
+ var sourceSweep = math.fmod(end - start + 360f, 360f);
+ var alignedSweep = math.fmod(aligned.y - aligned.x + 360f, 360f);
+ Assert.That(alignedSweep, Is.EqualTo(sourceSweep).Within(0.001f));
+ Assert.That(math.sin(math.radians(aligned.x)),
+ Is.EqualTo(math.sin(math.radians(aligned.y))).Within(0.001f));
+ }
+
+ [Test]
+ public void ShouldApplyRingPropertiesToAllWithoutChangingPositions()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddRingFixture(100f);
+ component.AddRingFixture(250f);
+ component.AddRingFixture(400f);
+ component.SetRingFixtureProperties(1, 250f,
+ true, 35f, 145f, true, 215f, 325f, 7f, -11f, 1.35f, 48);
+ component.SetRingFixtureProperties(0, 100f,
+ false, 60f, 120f, false, 210f, 330f, 0f, 0f, 0.8f);
+ component.SetRingFixtureProperties(2, 400f,
+ false, 50f, 130f, false, 220f, 320f, -3f, 5f, 1.8f);
+
+ component.ApplyRingPropertiesToAll(1);
+
+ Assert.That(component.Fixtures.Select(fixture => fixture.Distance),
+ Is.EqualTo(new[] { 100f, 250f, 400f }));
+ foreach (var ring in component.Fixtures.Cast()) {
+ Assert.That(ring.HasCutout, Is.True);
+ Assert.That(ring.CutoutStartAngle, Is.EqualTo(35f));
+ Assert.That(ring.CutoutEndAngle, Is.EqualTo(145f));
+ Assert.That(ring.HasStraightSection, Is.True);
+ Assert.That(ring.StraightStartAngle, Is.EqualTo(215f));
+ Assert.That(ring.StraightEndAngle, Is.EqualTo(325f));
+ Assert.That(ring.LateralOffset, Is.EqualTo(7f));
+ Assert.That(ring.VerticalOffset, Is.EqualTo(-11f));
+ Assert.That(ring.Scale, Is.EqualTo(1.35f));
+ Assert.That(ring.RingDensity, Is.EqualTo(48));
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldDuplicateEveryRingSetting()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var sourceIndex = component.AddRingFixture(175f);
+ component.SetRingFixtureProperties(sourceIndex, 175f,
+ true, 35f, 125f, true, 205f, 315f, 6f, -9f, 1.4f, 24);
+ var duplicateIndex = component.DuplicateRingFixture(sourceIndex);
+ var source = (WireRailRingFixture)component.Fixtures[sourceIndex];
+ var duplicate = (WireRailRingFixture)component.Fixtures[duplicateIndex];
+
+ Assert.That(duplicateIndex, Is.EqualTo(sourceIndex + 1));
+ Assert.That(component.Fixtures, Has.Count.EqualTo(2));
+ Assert.That(duplicate, Is.Not.SameAs(source));
+ Assert.That(duplicate.Distance, Is.EqualTo(source.Distance));
+ Assert.That(duplicate.Diameter, Is.EqualTo(source.Diameter));
+ Assert.That(duplicate.HasCutout, Is.EqualTo(source.HasCutout));
+ Assert.That(duplicate.CutoutStartAngle, Is.EqualTo(source.CutoutStartAngle));
+ Assert.That(duplicate.CutoutEndAngle, Is.EqualTo(source.CutoutEndAngle));
+ Assert.That(duplicate.HasStraightSection, Is.EqualTo(source.HasStraightSection));
+ Assert.That(duplicate.StraightStartAngle, Is.EqualTo(source.StraightStartAngle));
+ Assert.That(duplicate.StraightEndAngle, Is.EqualTo(source.StraightEndAngle));
+ Assert.That(duplicate.LateralOffset, Is.EqualTo(source.LateralOffset));
+ Assert.That(duplicate.VerticalOffset, Is.EqualTo(source.VerticalOffset));
+ Assert.That(duplicate.Scale, Is.EqualTo(source.Scale));
+ Assert.That(duplicate.RingDensity, Is.EqualTo(source.RingDensity));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldDuplicateEveryRungSetting()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var sourceIndex = component.AddRungFixture(175f);
+ component.SetRungFixtureProperties(sourceIndex, 175f,
+ 25f, 6f, -9f, 14f);
+ var duplicateIndex = component.DuplicateRungFixture(sourceIndex);
+ var source = (WireRailRungFixture)component.Fixtures[sourceIndex];
+ var duplicate = (WireRailRungFixture)component.Fixtures[duplicateIndex];
+
+ Assert.That(duplicateIndex, Is.EqualTo(sourceIndex + 1));
+ Assert.That(duplicate, Is.Not.SameAs(source));
+ Assert.That(duplicate.Distance, Is.EqualTo(source.Distance));
+ Assert.That(duplicate.Diameter, Is.EqualTo(source.Diameter));
+ Assert.That(duplicate.StartRailIndex, Is.EqualTo(source.StartRailIndex));
+ Assert.That(duplicate.EndRailIndex, Is.EqualTo(source.EndRailIndex));
+ Assert.That(duplicate.Angle, Is.EqualTo(source.Angle));
+ Assert.That(duplicate.LateralOffset, Is.EqualTo(source.LateralOffset));
+ Assert.That(duplicate.VerticalOffset, Is.EqualTo(source.VerticalOffset));
+ Assert.That(duplicate.LengthAdjustment,
+ Is.EqualTo(source.LengthAdjustment));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldDuplicateEveryLegAndFootSetting()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var sourceIndex = component.AddStandFixture(175f);
+ component.SetStandFixtureProperties(sourceIndex, 175f,
+ WireRailStandSide.Left, new Vector3(1f, 2f, -3f), 42f,
+ new Vector3(4f, 5f, -60f), new Vector3(15f, 25f, 35f),
+ 38f, 27f, 19f, 6f, -9f, 14f);
+ var duplicateIndex = component.DuplicateStandFixture(sourceIndex);
+ var source = (WireRailStandFixture)component.Fixtures[sourceIndex];
+ var duplicate = (WireRailStandFixture)component.Fixtures[duplicateIndex];
+
+ Assert.That(duplicateIndex, Is.EqualTo(sourceIndex + 1));
+ Assert.That(duplicate, Is.Not.SameAs(source));
+ Assert.That(duplicate.Distance, Is.EqualTo(source.Distance));
+ Assert.That(duplicate.Diameter, Is.EqualTo(source.Diameter));
+ Assert.That(duplicate.LegSide, Is.EqualTo(source.LegSide));
+ Assert.That(duplicate.LateralOffset, Is.EqualTo(source.LateralOffset));
+ Assert.That(duplicate.VerticalOffset, Is.EqualTo(source.VerticalOffset));
+ Assert.That(duplicate.LengthAdjustment,
+ Is.EqualTo(source.LengthAdjustment));
+ Assert.That(Vector3.Distance(duplicate.StartDirection, source.StartDirection),
+ Is.LessThan(0.0001f));
+ Assert.That(duplicate.StartLength, Is.EqualTo(source.StartLength));
+ Assert.That(duplicate.FootPosition, Is.EqualTo(source.FootPosition));
+ Assert.That(duplicate.FootRotation, Is.EqualTo(source.FootRotation));
+ Assert.That(duplicate.FootWidth, Is.EqualTo(source.FootWidth));
+ Assert.That(duplicate.FootLength, Is.EqualTo(source.FootLength));
+ Assert.That(duplicate.FootConnectionLength,
+ Is.EqualTo(source.FootConnectionLength));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldReorderFixturesWithoutChangingTheirPositions()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddRingFixture(100f);
+ component.AddRingFixture(300f);
+ component.SetRingFixtureProperties(0, 100f, false, 0f, 0f);
+ component.SetRingFixtureProperties(1, 300f, false, 0f, 0f);
+
+ component.MoveFixture(1, 0);
+
+ Assert.That(component.Fixtures[0].Distance, Is.EqualTo(300f).Within(0.001f));
+ Assert.That(component.Fixtures[1].Distance, Is.EqualTo(100f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldUseOneRailCountAcrossEveryLayout()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(250f);
+ component.SetRailCount(5);
+
+ Assert.That(component.RailCount, Is.EqualTo(5));
+ Assert.That(component.Segments.Select(layout => layout.RailCount),
+ Is.EqualTo(new[] { 5, 5 }));
+ Assert.That(component.Segments.Select(CountActiveRails),
+ Is.EqualTo(new[] { 5, 5 }));
+ Assert.That(component.Segments[0].ConnectionToNext.WireCount, Is.EqualTo(5));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldGenerateOnlyRailsEnabledForTheLayoutSpan()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var allRailTriangles = component.RenderMesh.triangles.Length;
+ component.SetRailCount(6);
+
+ component.SetRailsActive(0, new[] { 0, 1, 2, 3 }, false);
+
+ Assert.That(component.RailCount, Is.EqualTo(6));
+ Assert.That(CountActiveRails(component.Segments[0]), Is.EqualTo(2));
+ Assert.That(component.RenderMesh.triangles.Length, Is.LessThan(allRailTriangles));
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(0));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldIgnoreAnInactiveLayoutOffsetWhenTheRailStartsLater()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(250f);
+ component.SetRailCount(1);
+ component.SetRailOffset(0, 0, new Vector2(300f, 0f));
+ component.SetRailOffset(1, 0, new Vector2(20f, 0f));
+ component.SetRailsActive(0, new[] { 0 }, false);
+ var spline = component.SplineContainer.Spline;
+
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailPosition(spline,
+ component.Segments, 1, 0, 0f, out var before), Is.True);
+ component.SetRailOffset(0, 0, new Vector2(-300f, 100f));
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailPosition(spline,
+ component.Segments, 1, 0, 0f, out var after), Is.True);
+ Assert.That(WireRailSplineGeometry.TryEvaluateLayout(spline,
+ component.Segments, 1, 0f, out var frame), Is.True);
+
+ Assert.That(math.distance(before, after), Is.LessThan(0.001f));
+ Assert.That(math.distance(after,
+ frame.TransformOffset(new float2(20f, 0f))), Is.LessThan(0.001f));
+ Assert.That(WireRailSplineGeometry.IsContinuousAtStart(spline,
+ component.Segments, 1, 0), Is.False);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldMigrateLegacyLayoutCountsToInactiveRails()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var twoRailLayout = new WireRailSegment();
+ twoRailLayout.ResizeRailCount(2, 8f, true, true);
+ var fiveRailLayout = new WireRailSegment();
+ fiveRailLayout.ResizeRailCount(5, 8f, true, true);
+ var flags = BindingFlags.Instance | BindingFlags.NonPublic;
+ typeof(WireRailComponent).GetField("_segments", flags)?.SetValue(component,
+ new List { twoRailLayout, fiveRailLayout });
+ typeof(WireRailComponent).GetField("_railCountInitialized", flags)
+ ?.SetValue(component, false);
+
+ component.SynchronizeSegments();
+
+ Assert.That(component.RailCount, Is.EqualTo(5));
+ Assert.That(component.Segments.Select(layout => layout.RailCount),
+ Is.EqualTo(new[] { 5, 5 }));
+ Assert.That(component.Segments.Select(CountActiveRails),
+ Is.EqualTo(new[] { 2, 5 }));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldPlaceWireLayoutsIndependentlyFromSplineKnots()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var knotCount = component.SplineContainer.Spline.Count;
+ component.AddLayout(125f);
+ component.AddLayout(375f);
+ component.SetLayoutDistance(1, 200f);
+ component.SetRailCount(2);
+
+ Assert.That(component.SplineContainer.Spline.Count, Is.EqualTo(knotCount));
+ Assert.That(component.Segments, Has.Count.EqualTo(3));
+ Assert.That(component.Segments[0].Distance, Is.Zero);
+ Assert.That(component.Segments[1].Distance, Is.EqualTo(200f).Within(0.001f));
+ Assert.That(component.Segments[2].Distance, Is.EqualTo(375f).Within(0.001f));
+ Assert.That(component.Segments[1].RailCount, Is.EqualTo(2));
+
+ component.RemoveLayout(1);
+ Assert.That(component.Segments, Has.Count.EqualTo(2));
+ Assert.That(component.SplineContainer.Spline.Count, Is.EqualTo(knotCount));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldReorderLayoutNamesWithoutChangingPhysicalGeometry()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(200f);
+ component.AddLayout(400f);
+ component.SetRailCount(3);
+ component.SetRailsActive(0, new[] { 1, 2 }, false);
+ component.SetRailsActive(1, new[] { 2 }, false);
+
+ component.MoveLayout(2, 0);
+
+ Assert.That(component.Segments.Select(layout => layout.Distance),
+ Is.EqualTo(new[] { 0f, 200f, 400f }));
+ Assert.That(component.Segments.Select(CountActiveRails),
+ Is.EqualTo(new[] { 1, 2, 3 }));
+ Assert.That(component.LayoutDisplayOrder, Is.EqualTo(new[] { 2, 0, 1 }));
+ Assert.That(component.Segments[0].ConnectionToNext.WireCount, Is.EqualTo(3));
+ Assert.That(component.Segments[1].ConnectionToNext.WireCount, Is.EqualTo(3));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldSuggestLayoutPositionsFromPhysicalNeighbors()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ Assert.That(component.GetSuggestedLayoutDistance(),
+ Is.EqualTo(component.SplineLength * 0.5f).Within(0.001f));
+
+ component.AddLayout(200f);
+ component.AddLayout(400f);
+ component.MoveLayout(2, 0);
+
+ Assert.That(component.GetSuggestedLayoutDistance(),
+ Is.EqualTo(300f).Within(0.001f));
+ Assert.That(component.GetSuggestedLayoutDistance(0),
+ Is.EqualTo(100f).Within(0.001f));
+ Assert.That(component.GetSuggestedLayoutDistance(1),
+ Is.EqualTo(300f).Within(0.001f));
+ Assert.That(component.GetSuggestedLayoutDistance(2),
+ Is.EqualTo(450f).Within(0.001f),
+ "the last layout duplicates halfway to the end of the route");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldAddAnUnselectedLayoutLastInDisplayOrder()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(200f);
+ component.AddLayout(400f);
+
+ var layoutIndex = component.AddLayout(component.GetSuggestedLayoutDistance());
+
+ Assert.That(layoutIndex, Is.EqualTo(2));
+ Assert.That(component.Segments.Select(layout => layout.Distance),
+ Is.EqualTo(new[] { 0f, 200f, 300f, 400f }));
+ Assert.That(component.LayoutDisplayOrder, Is.EqualTo(new[] { 0, 1, 3, 2 }));
+ Assert.That(component.GetLayoutDisplayIndex(layoutIndex), Is.EqualTo(3));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldDuplicateASelectedLayoutBetweenPhysicalNeighbors()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(200f);
+ component.AddLayout(400f);
+ component.SetRailCount(3);
+ component.SetRailOffset(1, 0, new Vector2(42f, 17f));
+ component.SetRailsActive(1, new[] { 2 }, false);
+ component.SetWireTransitionOverride(1, 0, true);
+ component.SetWireContinuous(1, 0, false);
+
+ var layoutIndex = component.DuplicateLayout(1,
+ component.GetSuggestedLayoutDistance(1));
+
+ Assert.That(layoutIndex, Is.EqualTo(2));
+ Assert.That(component.Segments.Select(layout => layout.Distance),
+ Is.EqualTo(new[] { 0f, 200f, 300f, 400f }));
+ Assert.That(component.Segments[layoutIndex].GetRailOffset(0),
+ Is.EqualTo(new Vector2(42f, 17f)));
+ Assert.That(component.Segments[layoutIndex].IsRailActive(2), Is.False);
+ Assert.That(component.Segments[layoutIndex].ConnectionToNext
+ .IsWireOverridden(0), Is.True);
+ Assert.That(component.Segments[layoutIndex].ConnectionToNext
+ .IsWireContinuous(0), Is.False);
+ Assert.That(component.Segments[1].ConnectionToNext
+ .IsWireOverridden(0), Is.False);
+ Assert.That(component.LayoutDisplayOrder, Is.EqualTo(new[] { 0, 1, 2, 3 }));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldDuplicateTheLastPhysicalLayoutAfterItsDisplayEntry()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(200f);
+ component.AddLayout(400f);
+ component.SetRailOffset(2, 0, new Vector2(-25f, 35f));
+ component.MoveLayout(2, 0);
+
+ var layoutIndex = component.DuplicateLayout(2,
+ component.GetSuggestedLayoutDistance(2));
+
+ Assert.That(layoutIndex, Is.EqualTo(3));
+ Assert.That(component.Segments.Select(layout => layout.Distance),
+ Is.EqualTo(new[] { 0f, 200f, 400f, 450f }));
+ Assert.That(component.Segments[layoutIndex].GetRailOffset(0),
+ Is.EqualTo(new Vector2(-25f, 35f)));
+ Assert.That(component.LayoutDisplayOrder, Is.EqualTo(new[] { 2, 3, 0, 1 }));
+ Assert.That(component.GetLayoutDisplayIndex(layoutIndex), Is.EqualTo(1));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldReorderLayoutsWhenAPositionMovesPastANeighbor()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(200f);
+ component.AddLayout(400f);
+ component.SetRailOffset(1, 0, new Vector2(42f, 17f));
+ var moved = component.Segments[1];
+
+ var newIndex = component.SetLayoutDistance(1, 450f);
+
+ Assert.That(newIndex, Is.EqualTo(2));
+ Assert.That(component.Segments[2], Is.SameAs(moved));
+ Assert.That(component.Segments.Select(layout => layout.Distance),
+ Is.EqualTo(new[] { 0f, 400f, 450f }));
+ Assert.That(component.Segments[2].GetRailOffset(0),
+ Is.EqualTo(new Vector2(42f, 17f)));
+ Assert.That(component.LayoutDisplayOrder, Is.EqualTo(new[] { 0, 2, 1 }),
+ "Layout 2 keeps its name while it moves to the end of the route");
+ Assert.That(component.GetLayoutDisplayIndex(2), Is.EqualTo(1));
+
+ Assert.That(component.SetLayoutDistance(2, 100f), Is.EqualTo(1));
+ Assert.That(component.Segments.Select(layout => layout.Distance),
+ Is.EqualTo(new[] { 0f, 100f, 400f }));
+ Assert.That(component.LayoutDisplayOrder, Is.EqualTo(new[] { 0, 1, 2 }));
+
+ Assert.That(component.SetLayoutDistance(0, 300f), Is.EqualTo(0));
+ Assert.That(component.Segments[0].Distance, Is.Zero,
+ "the first layout stays pinned to the route start");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldMoveSupportsAlongTheRouteAndRecordTheirMeshRanges()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var ringIndex = component.AddRingFixture(100f);
+ var rungIndex = component.AddRungFixture(200f);
+ component.SetFixtureEnabled(rungIndex, false);
+
+ component.SetFixtureDistance(ringIndex, 350f);
+ Assert.That(component.Fixtures[ringIndex].Distance,
+ Is.EqualTo(350f).Within(0.001f));
+ component.SetFixtureDistance(ringIndex, 9999f);
+ Assert.That(component.Fixtures[ringIndex].Distance,
+ Is.EqualTo(component.SplineLength).Within(0.001f),
+ "a fixture cannot leave the route");
+
+ component.RebuildRenderGeometry();
+ var ranges = component.RenderFixtureIndexRanges;
+ Assert.That(ranges, Has.Count.EqualTo(component.Fixtures.Count));
+ Assert.That(ranges[ringIndex].y, Is.GreaterThan(0),
+ "an enabled ring has triangles in the render mesh");
+ Assert.That(ranges[ringIndex].x + ranges[ringIndex].y,
+ Is.LessThanOrEqualTo(component.RenderMesh.triangles.Length));
+ Assert.That(ranges[rungIndex].y, Is.Zero,
+ "a disabled fixture has no triangles in the render mesh");
+
+ var hairpinIndex = component.AddHairpinFixture();
+ Assert.That(() => component.SetFixtureDistance(hairpinIndex, 10f),
+ Throws.ArgumentException, "end fittings have no route position");
+ component.SetHairpinFixtureOffset(hairpinIndex, 25f);
+ Assert.That(((WireRailHairpinFixture)component.Fixtures[hairpinIndex]).RailOffset,
+ Is.EqualTo(25f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldPreserveDisplayOrderWhenRemovingAPhysicalLayout()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(200f);
+ component.AddLayout(400f);
+ component.MoveLayout(2, 0);
+
+ component.RemoveLayout(1);
+
+ Assert.That(component.Segments.Select(layout => layout.Distance),
+ Is.EqualTo(new[] { 0f, 400f }));
+ Assert.That(component.LayoutDisplayOrder, Is.EqualTo(new[] { 1, 0 }));
+ Assert.That(component.GetLayoutDisplayIndex(1), Is.Zero);
+ Assert.That(component.GetLayoutDisplayIndex(0), Is.EqualTo(1));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldRepairLegacyAndInvalidLayoutDisplayOrder()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(200f);
+ component.AddLayout(400f);
+ var flags = BindingFlags.Instance | BindingFlags.NonPublic;
+ var displayOrderField = typeof(WireRailComponent)
+ .GetField("_layoutDisplayOrder", flags);
+
+ displayOrderField?.SetValue(component, new List());
+ Assert.That(component.SynchronizeSegments(), Is.True);
+ Assert.That(component.LayoutDisplayOrder, Is.EqualTo(new[] { 0, 1, 2 }));
+
+ displayOrderField?.SetValue(component, new List { 0, 0, 2 });
+ Assert.That(component.SynchronizeSegments(), Is.True);
+ Assert.That(component.LayoutDisplayOrder, Is.EqualTo(new[] { 0, 1, 2 }));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepSynchronizationIdempotentOnAZeroLengthSpline()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(component.SplineLength * 0.5f);
+ var spline = component.SplineContainer.Spline;
+ var end = spline[1];
+ end.Position = spline[0].Position;
+ spline.SetKnot(1, end);
+ component.SynchronizeSegments();
+ var renderVersion = component.RenderGeometryVersion;
+ var colliderVersion = component.ColliderGeometryVersion;
+
+ Assert.That(component.SynchronizeSegments(), Is.False);
+ Assert.That(component.RenderGeometryVersion, Is.EqualTo(renderVersion));
+ Assert.That(component.ColliderGeometryVersion, Is.EqualTo(colliderVersion));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepFixturesIndependentFromSegmentChanges()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddRingFixture(100f);
+ component.AddRingFixture(400f);
+ component.SplineContainer.Spline.Add(
+ new BezierKnot(new float3(0f, 1000f, 0f)), TangentMode.AutoSmooth);
+
+ Assert.That(component.Segments.Count, Is.EqualTo(1));
+ Assert.That(component.Fixtures.Count, Is.EqualTo(2));
+ Assert.That(component.Fixtures[0].Distance, Is.EqualTo(100f).Within(0.001f));
+ Assert.That(component.Fixtures[1].Distance, Is.EqualTo(400f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldAdaptAndKeepTheLastRingsOnAForwardWirePath()
+ {
+ const int radialSegments = 10;
+ const int capVertexCount = radialSegments + 1;
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetWireCapBevelSize(0f);
+ var spline = component.SplineContainer.Spline;
+ spline.Add(new BezierKnot(new float3(400f, 650f, 200f)) {
+ Rotation = quaternion.RotateX(math.PI * 0.5f),
+ }, TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetRailCount(1);
+ component.SetRailOffset(0, 0, Vector2.zero);
+ component.SetRailOffset(1, 0, new Vector2(500f, 0f));
+ component.SetWireTransitionCurve(0, 0, new AnimationCurve(
+ new Keyframe(0f, 0f, 0f, 0f),
+ new Keyframe(1f, 1f, 2f, 2f)));
+
+ var firstSamples = WireRailRenderMeshGenerator.BuildSampleParameters(
+ spline, component.Segments, 0, 0, 16);
+ var secondSamples = WireRailRenderMeshGenerator.BuildSampleParameters(
+ spline, component.Segments, 1, 0, 16);
+ var secondTubeStart = firstSamples.Count * radialSegments + capVertexCount;
+ var lastRingStart = secondTubeStart
+ + (secondSamples.Count - 1) * radialSegments;
+ var vertices = component.RenderMesh.vertices;
+ var center = AverageRing(vertices, lastRingStart, radialSegments);
+ var tangentStep = 1f - secondSamples[^2];
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailFrame(spline,
+ component.Segments, 1, 0, 1f, tangentStep, out var frame), Is.True);
+
+ AssertRingPerpendicular(vertices, lastRingStart, radialSegments, center,
+ frame.Tangent);
+ Assert.That(secondSamples.Count, Is.GreaterThan(17),
+ "curved wire spans should receive additional render rings");
+
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailPosition(spline,
+ component.Segments, 1, 0, 0f, out var previousPosition), Is.True);
+ for (var sampleIndex = 1; sampleIndex <= 128; sampleIndex++) {
+ var curveT = sampleIndex / 128f;
+ Assert.That(WireRailSplineGeometry.TryEvaluateRailPosition(spline,
+ component.Segments, 1, 0, curveT, out var position), Is.True);
+ Assert.That(WireRailSplineGeometry.TryEvaluateLayout(spline,
+ component.Segments, 1, curveT,
+ out var mainFrame), Is.True);
+ var direction = math.normalizesafe(position - previousPosition,
+ mainFrame.Tangent);
+ Assert.That(math.dot(direction, mainFrame.Tangent), Is.GreaterThan(0f),
+ $"wire path reversed at t={curveT}");
+ previousPosition = position;
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldBlendTheBallChannelThroughAContinuousConnection()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SplineContainer.Spline.Add(
+ new BezierKnot(new float3(0f, 1000f, 0f)) {
+ Rotation = quaternion.RotateX(math.PI * 0.5f),
+ }, TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ var secondOffsets = new Vector2[4];
+ var indices = new int[4];
+ for (var wireIndex = 0; wireIndex < 4; wireIndex++) {
+ indices[wireIndex] = wireIndex;
+ secondOffsets[wireIndex] = component.Segments[1].GetRailOffset(wireIndex)
+ + new Vector2(40f, 0f);
+ }
+ component.SetWireProperties(1, indices, secondOffsets);
+ var transitionCurve = new AnimationCurve(
+ new Keyframe(0f, 0f), new Keyframe(0.5f, 0.25f),
+ new Keyframe(1f, 1f));
+ for (var wireIndex = 0; wireIndex < 4; wireIndex++) {
+ component.SetWireTransitionCurve(0, wireIndex, transitionCurve);
+ }
+ Assert.That(WireRailChannelProfile.TryCreate(
+ WireRailLayout.CreateDefaultOffsets(4), 4f, 25f,
+ out var profile, out var error), Is.True, error);
+ var rowSize = profile.Vertices.Count;
+ var vertices = component.ColliderMesh.vertices;
+ var rowCount = vertices.Length / rowSize;
+ var secondStartRow = -1;
+ for (var rowIndex = 1; rowIndex < rowCount; rowIndex++) {
+ if (RowsMatch(vertices, (rowIndex - 1) * rowSize,
+ rowIndex * rowSize, rowSize)) {
+ secondStartRow = rowIndex;
+ break;
+ }
+ }
+ Assert.That(secondStartRow, Is.GreaterThan(1));
+ var firstStartX = AverageRowX(vertices, 0, rowSize);
+ var firstEndX = AverageRowX(vertices,
+ (secondStartRow - 1) * rowSize, rowSize);
+ var secondStartX = AverageRowX(vertices,
+ secondStartRow * rowSize, rowSize);
+ var secondEndX = AverageRowX(vertices,
+ (rowCount - 1) * rowSize, rowSize);
+ var hasAuthoredMiddle = false;
+ for (var rowIndex = 1; rowIndex < secondStartRow - 1; rowIndex++) {
+ var rowX = AverageRowX(vertices, rowIndex * rowSize, rowSize);
+ if (math.abs(rowX - firstStartX - 10f) < 0.01f) {
+ hasAuthoredMiddle = true;
+ break;
+ }
+ }
+
+ Assert.That(hasAuthoredMiddle, Is.True);
+ Assert.That(firstEndX - firstStartX, Is.EqualTo(40f).Within(0.01f));
+ Assert.That(secondStartX, Is.EqualTo(firstEndX).Within(0.01f));
+ Assert.That(secondEndX - firstStartX, Is.EqualTo(40f).Within(0.01f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepLayoutConnectionsWhenAKnotSplitsTheRoute()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ spline.Add(new BezierKnot(new float3(0f, 1000f, 0f)),
+ TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+ component.SetWireTransitionCurve(0, 0, new AnimationCurve(
+ new Keyframe(0f, 0f), new Keyframe(0.5f, 0.2f),
+ new Keyframe(1f, 1f)));
+
+ spline.Insert(1, new BezierKnot(new float3(0f, 250f, 0f)),
+ TangentMode.AutoSmooth);
+
+ Assert.That(component.Segments[0].ConnectionToNext.IsWireContinuous(0),
+ Is.True);
+ Assert.That(component.Segments[0].ConnectionToNext.GetWireCurve(0)
+ .Evaluate(0.5f), Is.EqualTo(0.2f).Within(0.001f));
+ Assert.That(component.Segments, Has.Count.EqualTo(2));
+
+ spline.RemoveAt(1);
+
+ Assert.That(component.Segments[0].ConnectionToNext.IsWireContinuous(0),
+ Is.True);
+ Assert.That(component.Segments[0].ConnectionToNext.GetWireCurve(0)
+ .Evaluate(0.5f), Is.EqualTo(0.2f).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldApplyUsefulLayoutsFromOneThroughFiveRails()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+
+ component.SetRailCount(1);
+ AssertOffsets(component.Segments[0], Vector2.zero);
+
+ component.SetRailCount(2);
+ AssertOffsets(component.Segments[0],
+ new Vector2(-15f, 0f), new Vector2(15f, 0f));
+
+ component.SetRailCount(3);
+ AssertOffsets(component.Segments[0],
+ new Vector2(-15f, 0f), new Vector2(15f, 0f),
+ new Vector2(30f, 30f));
+ component.SetThirdRailSide(0, WireRailThirdRailSide.Left);
+ Assert.That(component.Segments[0].GetRailOffset(2),
+ Is.EqualTo(new Vector2(-30f, 30f)));
+
+ component.SetRailCount(4);
+ AssertOffsets(component.Segments[0],
+ new Vector2(-15f, 0f), new Vector2(15f, 0f),
+ new Vector2(-30f, 30f), new Vector2(30f, 30f));
+
+ component.SetRailCount(5);
+ Assert.That(component.Segments[0].GetRailOffset(4),
+ Is.EqualTo(new Vector2(0f, 60f)));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldDistributeAdditionalRailsAcrossTheTop()
+ {
+ var offsets = WireRailLayout.CreateDefaultOffsets(7);
+
+ Assert.That(offsets, Has.Length.EqualTo(7));
+ Assert.That(offsets[4], Is.EqualTo(new Vector2(-15f, 60f)));
+ Assert.That(offsets[5], Is.EqualTo(new Vector2(0f, 60f)));
+ Assert.That(offsets[6], Is.EqualTo(new Vector2(15f, 60f)));
+ }
+
+ [Test]
+ public void ShouldEditSelectedWirePositionsTogether()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetWireProperties(0, new[] { 0, 2 },
+ new[] { new Vector2(-22f, 1f), new Vector2(-20f, 46f) });
+
+ Assert.That(component.Segments[0].GetRailOffset(0),
+ Is.EqualTo(new Vector2(-22f, 1f)));
+ Assert.That(component.Segments[0].GetWireDiameter(0), Is.EqualTo(6.5f));
+ Assert.That(component.Segments[0].GetWireDiameter(1), Is.EqualTo(6.5f));
+ Assert.That(component.Segments[0].GetWireDiameter(2), Is.EqualTo(6.5f));
+ Assert.That(component.RenderMesh.bounds.min.x,
+ Is.EqualTo(-25.25f).Within(0.05f));
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(0));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldApplySelectedWirePositionsToEveryLayout()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(250f);
+ component.AddLayout(500f);
+ component.SetRailCount(4);
+ component.SetRailOffset(0, 0, new Vector2(-31f, 2f));
+ component.SetRailOffset(0, 2, new Vector2(-29f, 49f));
+ component.SetRailOffset(1, 0, new Vector2(-20f, 5f));
+ component.SetRailOffset(1, 1, new Vector2(24f, 6f));
+ component.SetRailOffset(1, 2, new Vector2(-18f, 45f));
+ component.SetRailOffset(2, 0, new Vector2(-16f, 8f));
+ component.SetRailOffset(2, 2, new Vector2(-15f, 42f));
+ component.SetRailsActive(2, new[] { 0 }, false);
+
+ component.ApplyWirePositionsToAllLayouts(0, new[] { 0, 2 });
+
+ foreach (var layout in component.Layouts) {
+ Assert.That(layout.GetRailOffset(0),
+ Is.EqualTo(new Vector2(-31f, 2f)));
+ Assert.That(layout.GetRailOffset(2),
+ Is.EqualTo(new Vector2(-29f, 49f)));
+ }
+ Assert.That(component.Layouts[1].GetRailOffset(1),
+ Is.EqualTo(new Vector2(24f, 6f)));
+ Assert.That(component.Layouts[2].IsRailActive(0), Is.False);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldCenterThePivotWithoutMovingTheSpline()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var container = component.SplineContainer;
+ var spline = container.Spline;
+ spline.Insert(1, new BezierKnot(new float3(100f, 180f, 35f)),
+ TangentMode.AutoSmooth);
+ go.transform.position = new Vector3(17f, 29f, -11f);
+ go.transform.rotation = Quaternion.Euler(13f, 27f, 9f);
+ go.transform.localScale = new Vector3(1.25f, 0.8f, 1.6f);
+ var worldKnotPositions = Enumerable.Range(0, spline.Count)
+ .Select(index => container.transform.TransformPoint((Vector3)spline[index].Position))
+ .ToArray();
+ Assert.That(WireRailSplineGeometry.TryEvaluateLayoutPosition(spline,
+ component.Layouts, 0, 0.5f, out var midpoint), Is.True);
+ var expectedPivot = container.transform.TransformPoint((Vector3)midpoint);
+ var originalRotation = go.transform.rotation;
+ var originalScale = go.transform.localScale;
+
+ Assert.That(component.CenterPivot(), Is.True);
+
+ Assert.That(Vector3.Distance(go.transform.position, expectedPivot),
+ Is.LessThan(0.0001f));
+ Assert.That(Quaternion.Angle(go.transform.rotation, originalRotation),
+ Is.LessThan(0.0001f));
+ Assert.That(go.transform.localScale, Is.EqualTo(originalScale));
+ for (var knotIndex = 0; knotIndex < spline.Count; knotIndex++) {
+ var worldPosition = container.transform.TransformPoint(
+ (Vector3)spline[knotIndex].Position);
+ Assert.That(Vector3.Distance(worldPosition, worldKnotPositions[knotIndex]),
+ Is.LessThan(0.0001f));
+ }
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldApplyOneDiameterToEveryWireAndFixture()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.AddLayout(250f);
+ component.SetRailCount(5);
+ component.AddRingFixture(125f);
+ component.AddRungFixture(375f);
+ component.AddStandFixture(250f);
+
+ component.SetWireDiameter(12f);
+
+ foreach (var layout in component.Layouts) {
+ for (var wireIndex = 0; wireIndex < layout.RailCount; wireIndex++) {
+ Assert.That(layout.GetWireDiameter(wireIndex), Is.EqualTo(12f));
+ }
+ }
+ Assert.That(component.WireDiameter, Is.EqualTo(12f));
+ Assert.That(((WireRailRingFixture)component.Fixtures[0]).Diameter,
+ Is.EqualTo(12f));
+ Assert.That(((WireRailRungFixture)component.Fixtures[1]).Diameter,
+ Is.EqualTo(12f));
+ Assert.That(((WireRailStandFixture)component.Fixtures[2]).Diameter,
+ Is.EqualTo(12f));
+ Assert.That(component.RenderMesh.bounds.size.x, Is.GreaterThan(0f));
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(0));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepLayoutActivationAndPositionsIndependent()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SplineContainer.Spline.Add(
+ new BezierKnot(new float3(50f, 750f, 100f)), TangentMode.AutoSmooth);
+ AddMidpointLayout(component);
+
+ component.SetRailCount(5);
+ component.SetRailsActive(0, new[] { 2, 3, 4 }, false);
+ component.SetRailOffset(1, 4, new Vector2(3f, 61f));
+
+ Assert.That(component.RailCount, Is.EqualTo(5));
+ Assert.That(component.Segments[0].RailCount, Is.EqualTo(5));
+ Assert.That(component.Segments[1].RailCount, Is.EqualTo(5));
+ Assert.That(CountActiveRails(component.Segments[0]), Is.EqualTo(2));
+ Assert.That(CountActiveRails(component.Segments[1]), Is.EqualTo(5));
+ Assert.That(component.Segments[1].GetRailOffset(4),
+ Is.EqualTo(new Vector2(3f, 61f)));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldNotCreateALayoutWhenAKnotSplitsTheRoute()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(3);
+ component.SetRailOffset(0, 2, new Vector2(23f, 48f));
+ component.SetWireDiameter(11f);
+
+ component.SplineContainer.Spline.Insert(1,
+ new BezierKnot(new float3(0f, 250f, 25f)), TangentMode.AutoSmooth);
+
+ Assert.That(component.Segments, Has.Count.EqualTo(1));
+ Assert.That(component.Segments[0].GetRailOffset(2),
+ Is.EqualTo(new Vector2(23f, 48f)));
+ Assert.That(component.Segments[0].GetWireDiameter(2), Is.EqualTo(11f));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepDistanceLayoutsWhenInsertingAndRemovingAKnot()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ spline.Add(new BezierKnot(new float3(100f, 1000f, 100f)),
+ TangentMode.AutoSmooth);
+ component.AddLayout(600f);
+ component.SetRailCount(5);
+ component.SetRailsActive(0, new[] { 2, 3, 4 }, false);
+
+ spline.Insert(1, new BezierKnot(new float3(25f, 250f, 20f)),
+ TangentMode.AutoSmooth);
+
+ Assert.That(component.Segments, Has.Count.EqualTo(2));
+ Assert.That(component.Segments[0].RailCount, Is.EqualTo(5));
+ Assert.That(component.Segments[1].RailCount, Is.EqualTo(5));
+ Assert.That(CountActiveRails(component.Segments[0]), Is.EqualTo(2));
+ Assert.That(CountActiveRails(component.Segments[1]), Is.EqualTo(5));
+ Assert.That(component.Segments[1].Distance, Is.EqualTo(600f).Within(0.001f));
+
+ spline.RemoveAt(1);
+
+ Assert.That(component.Segments, Has.Count.EqualTo(2));
+ Assert.That(component.Segments[0].RailCount, Is.EqualTo(5));
+ Assert.That(component.Segments[1].RailCount, Is.EqualTo(5));
+ Assert.That(CountActiveRails(component.Segments[0]), Is.EqualTo(2));
+ Assert.That(CountActiveRails(component.Segments[1]), Is.EqualTo(5));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepLayoutsWhenTheSplineClosesAndOpens()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ spline.Add(new BezierKnot(new float3(100f, 600f, 50f)),
+ TangentMode.AutoSmooth);
+
+ Assert.That(component.Segments, Has.Count.EqualTo(1));
+ spline.Closed = true;
+ Assert.That(component.Segments, Has.Count.EqualTo(1));
+ spline.Closed = false;
+ Assert.That(component.Segments, Has.Count.EqualTo(1));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldGenerateVisibleDecagonalWireTubesWithTheAuthoringDefaults()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var mesh = component.RenderMesh;
+
+ Assert.That(mesh, Is.Not.Null);
+ Assert.That(mesh.vertexCount, Is.GreaterThan(0));
+ Assert.That(mesh.normals, Has.Length.EqualTo(mesh.vertexCount));
+ Assert.That(component.WireDiameter, Is.EqualTo(6.5f));
+ Assert.That(component.WireCapBevelSize, Is.EqualTo(0.5f));
+ Assert.That(mesh.bounds.min.x, Is.EqualTo(-33.25f).Within(0.05f));
+ Assert.That(mesh.bounds.max.x, Is.EqualTo(33.25f).Within(0.05f));
+ Assert.That(mesh.hideFlags & HideFlags.DontSaveInEditor,
+ Is.EqualTo(HideFlags.DontSaveInEditor));
+ Assert.That(mesh.hideFlags & HideFlags.DontSaveInBuild,
+ Is.EqualTo(HideFlags.DontSaveInBuild));
+ Assert.That(component.ColliderMesh.hideFlags & HideFlags.DontSaveInEditor,
+ Is.EqualTo(HideFlags.DontSaveInEditor));
+ Assert.That(component.ColliderMesh.hideFlags & HideFlags.DontSaveInBuild,
+ Is.EqualTo(HideFlags.DontSaveInBuild));
+ var serializedComponent = new SerializedObject(component);
+ Assert.That(serializedComponent.FindProperty("_radialSegments").intValue,
+ Is.EqualTo(10));
+ Assert.That(serializedComponent.FindProperty("_renderMesh"), Is.Null);
+ Assert.That(serializedComponent.FindProperty("_colliderMesh"), Is.Null);
+ Assert.That(component.SplineContainer.GetComponent().sharedMesh,
+ Is.SameAs(mesh));
+ Assert.That(component.SplineContainer.GetComponent(), Is.Not.Null);
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepTheLegacyReferenceDiameterForUninitializedSegments()
+ {
+ var segment = new WireRailSegment();
+
+ Assert.That(WireRailLayout.DefaultWireDiameter, Is.EqualTo(6.5f));
+ Assert.That(WireRailLayout.ReferenceWireDiameter, Is.EqualTo(8f));
+ Assert.That(segment.GetWireDiameter(0), Is.EqualTo(8f),
+ "a segment without owning-component context must retain the legacy fallback");
+ }
+
+ [Test]
+ public void ShouldReleaseAndRegenerateDerivedMeshesWhenDisabled()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var renderMesh = component.RenderMesh;
+ var colliderMesh = component.ColliderMesh;
+
+ component.enabled = false;
+
+ Assert.That(renderMesh == null, Is.True);
+ Assert.That(colliderMesh == null, Is.True);
+ Assert.That(component.RenderMesh, Is.Null);
+ Assert.That(component.ColliderMesh, Is.Null);
+
+ component.enabled = true;
+ Assert.That(component.RenderMesh, Is.Not.Null);
+ Assert.That(component.ColliderMesh, Is.Not.Null, component.GenerationError);
+ Assert.That(component.RenderMesh, Is.Not.SameAs(renderMesh));
+ Assert.That(component.ColliderMesh, Is.Not.SameAs(colliderMesh));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldGenerateTheColliderOnlyWhenItIsConsumed()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var colliderField = typeof(WireRailComponent).GetField("_colliderMesh",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ var dirtyField = typeof(WireRailComponent).GetField("_colliderGeometryDirty",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ Assert.That(component.RenderMesh, Is.Not.Null);
+ Assert.That(colliderField, Is.Not.Null);
+ Assert.That(dirtyField, Is.Not.Null);
+ Assert.That(colliderField.GetValue(component), Is.Null);
+ Assert.That(dirtyField.GetValue(component), Is.True);
+
+ var collider = component.ColliderMesh;
+
+ Assert.That(collider, Is.Not.Null);
+ Assert.That(colliderField.GetValue(component), Is.SameAs(collider));
+ Assert.That(dirtyField.GetValue(component), Is.False);
+
+ component.InvalidateColliderGeometry();
+ Assert.That(colliderField.GetValue(component), Is.SameAs(collider));
+ Assert.That(dirtyField.GetValue(component), Is.True);
+ Assert.That(component.ColliderMesh, Is.SameAs(collider));
+ Assert.That(dirtyField.GetValue(component), Is.False);
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepStressRailMeshesStableAcrossCachedRebuilds()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ component.SetRailCount(6);
+ spline.Insert(1, new BezierKnot(new float3(120f, 160f, 35f)),
+ TangentMode.AutoSmooth);
+ spline.Insert(2, new BezierKnot(new float3(-90f, 330f, 80f)),
+ TangentMode.AutoSmooth);
+ component.AddLayout(component.SplineLength * 0.33f);
+ component.AddLayout(component.SplineLength * 0.66f);
+ component.AddRingFixture(component.SplineLength * 0.25f);
+ component.AddRungFixture(component.SplineLength * 0.5f);
+ component.AddCradleFixture(component.SplineLength * 0.75f);
+
+ var renderSignature = ComputeMeshSignature(component.RenderMesh);
+ var colliderSignature = ComputeMeshSignature(component.ColliderMesh);
+ Assert.That(renderSignature, Is.EqualTo(17935112580949234758UL));
+ Assert.That(colliderSignature, Is.EqualTo(5994109088990827143UL));
+
+ component.RebuildGeneratedMeshes();
+
+ Assert.That(ComputeMeshSignature(component.RenderMesh),
+ Is.EqualTo(renderSignature));
+ Assert.That(ComputeMeshSignature(component.ColliderMesh),
+ Is.EqualTo(colliderSignature));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldCoalesceInspectorRebuildRequests()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var initialGenerationCount = component.RenderMeshGenerationCount;
+ component.DeferEditorRebuildsForTesting = true;
+ component.SetRailOffset(0, 0, new Vector2(-20f, 2f));
+ component.SetRailOffset(0, 0, new Vector2(-22f, 3f));
+ component.SetRailOffset(0, 0, new Vector2(-24f, 4f));
+ component.DeferEditorRebuildsForTesting = false;
+
+ Assert.That(component.RenderMeshGenerationCount,
+ Is.EqualTo(initialGenerationCount));
+ component.FlushDeferredEditorRebuildForTesting();
+ Assert.That(component.RenderMeshGenerationCount,
+ Is.EqualTo(initialGenerationCount + 1));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [TestCase(2, 3, false)]
+ [TestCase(3, 5, false)]
+ [TestCase(4, 7, false)]
+ [TestCase(5, 8, true)]
+ [TestCase(6, 8, true)]
+ [TestCase(9, 8, true)]
+ public void ShouldCreateOneSelectivelyOpenBallChannel(int railCount,
+ int expectedFacets, bool expectedClosed)
+ {
+ var offsets = WireRailLayout.CreateDefaultOffsets(railCount);
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, 4f, 25f,
+ out var profile, out var error), Is.True, error);
+ Assert.That(profile.Spans, Has.Count.EqualTo(expectedFacets));
+ Assert.That(profile.IsClosed, Is.EqualTo(expectedClosed));
+ Assert.That(profile.Spans.Count, Is.LessThanOrEqualTo(8));
+ }
+
+ [Test]
+ public void ShouldOpenTheTopWhenTheUpperRailGapCanPassTheBall()
+ {
+ var offsets = WireRailLayout.CreateDefaultOffsets(6);
+ offsets[4] = new Vector2(-55f, 60f);
+ offsets[5] = new Vector2(55f, 60f);
+ var wireRadii = Enumerable.Repeat(3.25f, offsets.Length).ToArray();
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, wireRadii, 25f,
+ out var profile, out var error), Is.True, error);
+ Assert.That(profile.IsClosed, Is.False);
+ Assert.That(profile.Spans.Any(span =>
+ (span.StartVertex == 0 && span.EndVertex == profile.Vertices.Count - 1)
+ || (span.EndVertex == 0
+ && span.StartVertex == profile.Vertices.Count - 1)), Is.False,
+ "the passable upper gap must not receive a roof facet");
+ Assert.That(profile.Vertices, Has.Count.EqualTo(offsets.Length),
+ "the open channel must have one inward contact vertex per rail");
+ Assert.That(profile.Spans, Has.Count.EqualTo(profile.Vertices.Count - 1));
+ Assert.That(MatchesRailContact(profile.Vertices[0], 4), Is.True,
+ "the left rim must terminate at the left upper rail contact");
+ Assert.That(MatchesRailContact(profile.Vertices[^1], 5), Is.True,
+ "the right rim must terminate at the right upper rail contact");
+ Assert.That(profile.Vertices.Max(vertex => vertex.y), Is.LessThan(60f),
+ "open side facets must not extrapolate above the authored upper rails");
+ for (var vertexIndex = 0; vertexIndex < profile.Vertices.Count; vertexIndex++) {
+ Assert.That(Enumerable.Range(0, offsets.Length)
+ .Any(railIndex => MatchesRailContact(profile.Vertices[vertexIndex],
+ railIndex)), Is.True,
+ $"profile vertex {vertexIndex} must be an actual rail contact, not a chamfer notch");
+ }
+
+ bool MatchesRailContact(float2 vertex, int railIndex)
+ {
+ var railCenter = (float2)offsets[railIndex];
+ var normal = math.normalizesafe(profile.RestingBallCenter - railCenter);
+ return math.distance(vertex, railCenter + normal * wireRadii[railIndex])
+ < 0.01f;
+ }
+ }
+
+ [Test]
+ public void ShouldKeepAuthoredBlockingRailsWhenTestingADecimatedTopOpening()
+ {
+ var offsets = WireRailLayout.CreateDefaultOffsets(9);
+ offsets[4] = new Vector2(-45f, 60f);
+ offsets[5] = new Vector2(-40f, 60f);
+ offsets[6] = new Vector2(0f, 60f);
+ offsets[7] = new Vector2(40f, 60f);
+ offsets[8] = new Vector2(45f, 60f);
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, 4f, 25f,
+ out var profile, out var error), Is.True, error);
+ Assert.That(profile.IsClosed, Is.True,
+ "an upper rail omitted from the eight-facet profile still blocks the exit");
+ }
+
+ [Test]
+ public void ShouldCloseTheTopWhenTheClearGapCannotPassTheBall()
+ {
+ var offsets = WireRailLayout.CreateDefaultOffsets(6);
+ offsets[4] = new Vector2(-29f, 60f);
+ offsets[5] = new Vector2(29f, 60f);
+ var wireRadii = Enumerable.Repeat(4f, offsets.Length).ToArray();
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, wireRadii, 25f,
+ out var profile, out var error), Is.True, error);
+ Assert.That(profile.IsClosed, Is.True,
+ "a 58-unit center spacing leaves exactly 50 units between 4-unit wires");
+ }
+
+ [Test]
+ public void ShouldOpenEitherSideOfAnOverheadRailWhenTheBallCanPass()
+ {
+ var offsets = WireRailLayout.CreateDefaultOffsets(5);
+ offsets[2] = new Vector2(-90f, 30f);
+ offsets[4] = new Vector2(-1f, 60f);
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, 4f, 25f,
+ out var profile, out var error), Is.True, error);
+ Assert.That(profile.IsClosed, Is.False,
+ "both gaps flanking a rail above the ball must be considered within its angular radius");
+ Assert.That(MatchesRailContact(profile.Vertices[0], 2), Is.True,
+ "one open rim must terminate at the rail on the passable side");
+ Assert.That(MatchesRailContact(profile.Vertices[^1], 4), Is.True,
+ "the other open rim must terminate at the overhead rail");
+
+ bool MatchesRailContact(float2 vertex, int railIndex)
+ {
+ var railCenter = (float2)offsets[railIndex];
+ var normal = math.normalizesafe(profile.RestingBallCenter - railCenter);
+ return math.distance(vertex, railCenter + normal * 4f) < 0.01f;
+ }
+ }
+
+ [Test]
+ public void ShouldCheckTheOuterFlanksOfEveryRailCoveringTheTopDirection()
+ {
+ var offsets = WireRailLayout.CreateDefaultOffsets(6);
+ offsets[2] = new Vector2(-90f, 30f);
+ offsets[4] = new Vector2(-4f, 60f);
+ offsets[5] = new Vector2(4f, 60f);
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, 4f, 25f,
+ out var profile, out var error), Is.True, error);
+ Assert.That(profile.IsClosed, Is.False);
+ Assert.That(profile.TopOpening.FirstRailIndex, Is.EqualTo(4));
+ Assert.That(profile.TopOpening.SecondRailIndex, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void ShouldKeepTheSameOpeningPairWhenTheOppositeFlankBecomesWider()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(5);
+ component.SetRailOffset(0, 2, new Vector2(-91f, 30f));
+ component.SetRailOffset(0, 3, new Vector2(90f, 30f));
+ var nextLayout = component.AddLayout(component.SplineLength);
+ component.SetRailOffset(nextLayout, 2, new Vector2(-90f, 30f));
+ component.SetRailOffset(nextLayout, 3, new Vector2(91f, 30f));
+
+ Assert.That(component.ColliderMesh, Is.Not.Null, component.GenerationError);
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(0));
+ Assert.That(component.GenerationError, Is.Null);
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepTheOpeningPairWhenAnUpperRailCrossesDeadCenter()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(5);
+ component.SetRailOffset(0, 2, new Vector2(-90f, 30f));
+ component.SetRailOffset(0, 3, new Vector2(90f, 30f));
+ component.SetRailOffset(0, 4, new Vector2(-1f, 60f));
+ var nextLayout = component.AddLayout(component.SplineLength);
+ component.SetRailOffset(nextLayout, 4, new Vector2(1f, 60f));
+
+ Assert.That(component.ColliderMesh, Is.Not.Null, component.GenerationError);
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(0));
+ Assert.That(component.GenerationError, Is.Null);
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepTheTopOpenAcrossAClosingLayoutTransition()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(6);
+ component.SetRailOffset(0, 4, new Vector2(-55f, 60f));
+ component.SetRailOffset(0, 5, new Vector2(55f, 60f));
+ var nextLayout = component.AddLayout(100f);
+ component.SetRailOffset(nextLayout, 4, new Vector2(-15f, 60f));
+ component.SetRailOffset(nextLayout, 5, new Vector2(15f, 60f));
+
+ Assert.That(component.ColliderMesh, Is.Not.Null);
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(0));
+ Assert.That(component.GenerationError, Is.Null);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldKeepTheSameRailPairAtAForcedTopOpening()
+ {
+ var closedOffsets = WireRailLayout.CreateDefaultOffsets(5);
+ var openOffsets = WireRailLayout.CreateDefaultOffsets(5);
+ openOffsets[2] = new Vector2(-90f, 30f);
+ var wireRadii = Enumerable.Repeat(4f, 5).ToArray();
+
+ Assert.That(WireRailChannelProfile.TryCreate(openOffsets, wireRadii, 25f,
+ new Vector2(0f, 30f), out var openProfile, out var openError),
+ Is.True, openError);
+ Assert.That(openProfile.IsClosed, Is.False);
+ Assert.That(openProfile.TopOpening.IsValid, Is.True);
+ Assert.That(WireRailChannelProfile.TryCreate(closedOffsets, wireRadii, 25f,
+ new Vector2(0f, 30f), openProfile.TopOpening, false,
+ out var forcedProfile, out _, out var forcedError), Is.True, forcedError);
+ Assert.That(forcedProfile.TopOpening.FirstRailIndex,
+ Is.EqualTo(openProfile.TopOpening.FirstRailIndex));
+ Assert.That(forcedProfile.TopOpening.SecondRailIndex,
+ Is.EqualTo(openProfile.TopOpening.SecondRailIndex));
+ Assert.That(MatchesRailContact(forcedProfile.Vertices[0],
+ forcedProfile.TopOpening.SecondRailIndex), Is.True);
+ Assert.That(MatchesRailContact(forcedProfile.Vertices[^1],
+ forcedProfile.TopOpening.FirstRailIndex), Is.True);
+
+ bool MatchesRailContact(float2 vertex, int railIndex)
+ {
+ var center = (float2)closedOffsets[railIndex];
+ var normal = math.normalizesafe(forcedProfile.RestingBallCenter - center);
+ return math.distance(vertex, center + normal * wireRadii[railIndex]) < 0.01f;
+ }
+ }
+
+ [Test]
+ public void ShouldUseAClosedProfileWhenARailMovesThroughTheForcedOpening()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(6);
+ component.SetRailOffset(0, 4, new Vector2(-55f, 60f));
+ component.SetRailOffset(0, 5, new Vector2(55f, 60f));
+ var nextLayout = component.AddLayout(component.SplineLength);
+ component.SetRailOffset(nextLayout, 3, new Vector2(0f, 75f));
+ component.SetRailOffset(nextLayout, 4, new Vector2(-55f, 60f));
+ component.SetRailOffset(nextLayout, 5, new Vector2(55f, 60f));
+
+ Assert.That(component.ColliderMesh, Is.Not.Null, component.GenerationError);
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(0));
+ Assert.That(component.GenerationError, Is.Null);
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldUseAClosedProfileWhenARailCrossesTheOpeningBetweenProbes()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(6);
+ component.SetRailOffset(0, 4, new Vector2(-55f, 60f));
+ component.SetRailOffset(0, 5, new Vector2(55f, 60f));
+ var nextLayout = component.AddLayout(component.SplineLength);
+ component.SetRailOffset(nextLayout, 3, new Vector2(-60f, 30f));
+ component.SetWireTransitionCurve(0, 3, new AnimationCurve(
+ new Keyframe(0f, 0f), new Keyframe(0.125f, 1f / 3f),
+ new Keyframe(0.25f, 0f), new Keyframe(0.9f, 0f),
+ new Keyframe(1f, 1f)));
+ var spline = component.SplineContainer.Spline;
+ spline.Insert(1, new BezierKnot(new float3(500f, 250f, 0f)),
+ TangentMode.Linear);
+ component.SetLayoutDistance(nextLayout, component.SplineLength);
+
+ Assert.That(component.ColliderMesh, Is.Not.Null, component.GenerationError);
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(0));
+ Assert.That(component.ColliderTopologyRetryCount, Is.EqualTo(1));
+ Assert.That(component.GenerationError, Is.Null);
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldReportWhenTheTopOpeningMovesToAnotherRailPair()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(5);
+ component.SetRailOffset(0, 2, new Vector2(-90f, 30f));
+ var nextLayout = component.AddLayout(component.SplineLength);
+ component.SetRailOffset(nextLayout, 2, new Vector2(-30f, 30f));
+ component.SetRailOffset(nextLayout, 3, new Vector2(90f, 30f));
+
+ Assert.That(component.ColliderMesh, Is.Null);
+ Assert.That(component.GenerationError, Is.Not.Null);
+ Assert.That(component.GenerationError.Contains(
+ "top opening moves between different rail pairs"), Is.True);
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldRetryOpenWhenAdaptiveSamplingFindsANarrowTransitionGap()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SetRailCount(6);
+ component.SetRailOffset(0, 4, new Vector2(-10f, 60f));
+ component.SetRailOffset(0, 5, new Vector2(45f, 60f));
+ var nextLayout = component.AddLayout(component.SplineLength);
+ component.SetRailOffset(nextLayout, 4, new Vector2(-45f, 60f));
+ component.SetRailOffset(nextLayout, 5, new Vector2(10f, 60f));
+ component.SetWireTransitionCurve(0, 4, new AnimationCurve(
+ new Keyframe(0f, 0f), new Keyframe(0.125f, 1f),
+ new Keyframe(0.25f, 0f), new Keyframe(0.9f, 0f),
+ new Keyframe(1f, 1f)));
+ component.SetWireTransitionCurve(0, 5, new AnimationCurve(
+ new Keyframe(0f, 0f), new Keyframe(0.9f, 0f),
+ new Keyframe(1f, 1f)));
+ var spline = component.SplineContainer.Spline;
+ spline.Insert(1, new BezierKnot(new float3(500f, 250f, 0f)),
+ TangentMode.Linear);
+ component.SetLayoutDistance(nextLayout, component.SplineLength);
+
+ Assert.That(component.ColliderMesh, Is.Not.Null, component.GenerationError);
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(0));
+ Assert.That(component.ColliderTopologyRetryCount, Is.EqualTo(1),
+ $"collider vertices: {component.ColliderMesh.vertexCount}; "
+ + $"layout distance: {component.Segments[nextLayout].Distance}; "
+ + $"spline length: {component.SplineLength}");
+ Assert.That(component.GenerationError, Is.Null);
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldFitTheChannelToDifferentWireDiameters()
+ {
+ var offsets = WireRailLayout.CreateDefaultOffsets(2);
+ var radii = new[] { 3f, 6f };
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, radii, 25f,
+ out var profile, out var error), Is.True, error);
+ Assert.That(math.distance(profile.RestingBallCenter, (float2)offsets[0]),
+ Is.EqualTo(28f).Within(0.01f));
+ Assert.That(math.distance(profile.RestingBallCenter, (float2)offsets[1]),
+ Is.EqualTo(31f).Within(0.01f));
+ }
+
+ [Test]
+ public void ShouldOpenAChannelDownwardWhenOnlyTheTopRailsAreActive()
+ {
+ var offsets = WireRailLayout.CreateDefaultOffsets(6);
+ var topOffsets = new[] { offsets[4], offsets[5] };
+ var wireRadii = new[] { 4f, 4f };
+ var envelopeCenter = new Vector2(0f, 30f);
+
+ Assert.That(WireRailChannelProfile.TryCreate(topOffsets, wireRadii, 25f,
+ envelopeCenter, out var profile, out var error), Is.True, error);
+ Assert.That(profile.RestingBallCenter.y, Is.LessThan(topOffsets[0].y));
+ Assert.That(profile.IsClosed, Is.False);
+ }
+
+ [Test]
+ public void ShouldKeepATwoRailChannelAboveReversedSupports()
+ {
+ var offsets = new[] { new Vector2(15f, 0f), new Vector2(-15f, 0f) };
+ var wireRadii = new[] { 4f, 4f };
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, wireRadii, 25f,
+ Vector2.zero, out var profile, out var error), Is.True, error);
+ Assert.That(profile.RestingBallCenter.y, Is.GreaterThan(0f));
+ }
+
+ [Test]
+ public void ShouldKeepASingleRailChannelHorizontal()
+ {
+ var offsets = new[] { new Vector2(-15f, 0f) };
+ var wireRadii = new[] { 4f };
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, wireRadii, 25f,
+ new Vector2(0f, 15f), out var profile, out var error), Is.True, error);
+ Assert.That(profile.RestingBallCenter.x, Is.EqualTo(-15f).Within(0.001f));
+ Assert.That(profile.Vertices[0].y,
+ Is.EqualTo(profile.Vertices[1].y).Within(0.001f));
+ }
+
+ [Test]
+ public void ShouldNotSelfIntersectAChannelBelowThreeUpperRails()
+ {
+ var allOffsets = WireRailLayout.CreateDefaultOffsets(7);
+ var offsets = new[] { allOffsets[4], allOffsets[5], allOffsets[6] };
+ var wireRadii = new[] { 4f, 4f, 4f };
+
+ Assert.That(WireRailChannelProfile.TryCreate(offsets, wireRadii, 25f,
+ new Vector2(0f, 30f), out var profile, out var error), Is.True, error);
+ Assert.That(profile.RestingBallCenter.y, Is.LessThan(offsets[0].y));
+ for (var firstSpanIndex = 0; firstSpanIndex < profile.Spans.Count;
+ firstSpanIndex++) {
+ var firstSpan = profile.Spans[firstSpanIndex];
+ for (var secondSpanIndex = firstSpanIndex + 2;
+ secondSpanIndex < profile.Spans.Count; secondSpanIndex++) {
+ var secondSpan = profile.Spans[secondSpanIndex];
+ Assert.That(SegmentsProperlyIntersect(
+ profile.Vertices[firstSpan.StartVertex],
+ profile.Vertices[firstSpan.EndVertex],
+ profile.Vertices[secondSpan.StartVertex],
+ profile.Vertices[secondSpan.EndVertex]), Is.False);
+ }
+ }
+ }
+
+ [Test]
+ public void ShouldGenerateAChannelColliderInsteadOfPerWireTubes()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+
+ Assert.That(component.ColliderMesh, Is.Not.Null);
+ Assert.That(component.ColliderMesh.triangles, Has.Length.EqualTo(28 * 3));
+ Assert.That(component.ColliderMesh.normals, Is.Empty);
+ Assert.That(component.ColliderMesh.uv, Is.Empty);
+ Assert.That(component.RenderMesh.vertexCount,
+ Is.GreaterThan(component.ColliderMesh.vertexCount));
+
+ component.SetRailCount(2);
+ Assert.That(component.ColliderMesh.triangles, Has.Length.EqualTo(12 * 3));
+ Assert.That(component, Is.InstanceOf());
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldTessellateTheColliderFromCurvatureInsteadOfKnotCount()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ var straightVertexCount = component.ColliderMesh.vertexCount;
+
+ spline.Insert(1, new BezierKnot(new float3(0f, 250f, 0f)) {
+ Rotation = spline[0].Rotation,
+ },
+ TangentMode.Linear);
+ Assert.That(component.ColliderMesh.vertexCount, Is.EqualTo(straightVertexCount),
+ "a collinear knot must not force extra collider rows");
+
+ var middle = spline[1];
+ middle.Position = new float3(150f, 250f, 0f);
+ spline.SetKnot(1, middle);
+ Assert.That(component.ColliderMesh.vertexCount, Is.GreaterThan(straightVertexCount),
+ "a bend must receive adaptive collider rows");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldRebuildGeometryWhenTheSplineMoves()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ var spline = component.SplineContainer.Spline;
+ var end = spline[1];
+ end.Position = new float3(150f, 500f, 100f);
+
+ spline.SetKnot(1, end);
+
+ Assert.That(component.RenderMesh.bounds.max.x, Is.GreaterThan(140f));
+ Assert.That(component.ColliderMesh.bounds.max.x, Is.GreaterThan(130f));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldExpandTheChannelMeshIntoVpePhysicsColliders()
+ {
+ var go = new GameObject("Wire Rail");
+ var transforms = new NativeParallelHashMap(0, Allocator.Temp);
+ var colliders = new ColliderReference(ref transforms, Allocator.Temp);
+ try {
+ var component = go.AddComponent();
+ var collidable = (ICollidableComponent)component;
+
+ collidable.GetColliders(null, null, ref colliders, float4x4.identity, 0f);
+
+ Assert.That(collidable.IsCollidable, Is.True);
+ Assert.That(colliders.Count, Is.EqualTo(52));
+ for (var i = 0; i < colliders.Count; i++) {
+ Assert.That(colliders[i], Is.Not.InstanceOf(),
+ "the channel must not expand into per-wire tube colliders");
+ }
+ }
+ finally {
+ colliders.Dispose();
+ transforms.Dispose();
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldTransformCollidersFromTheSplineChildSpace()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var component = go.AddComponent();
+ component.SplineContainer.transform.localPosition =
+ new float3(57f, 728f, 124f).TranslateToWorld();
+
+ var matrix = component.GetLocalToPlayfieldMatrixInVpx(float4x4.identity);
+ var transformed = matrix.MultiplyPoint(new float3(10f, 20f, 30f));
+
+ Assert.That(transformed.x, Is.EqualTo(67f).Within(0.001f));
+ Assert.That(transformed.y, Is.EqualTo(748f).Within(0.001f));
+ Assert.That(transformed.z, Is.EqualTo(154f).Within(0.001f));
+ }
+ finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ private static void AddMidpointLayout(WireRailComponent component)
+ {
+ component.AddLayout(component.SplineLength * 0.5f);
+ }
+
+ private static ulong ComputeMeshSignature(Mesh mesh)
+ {
+ unchecked {
+ const ulong offsetBasis = 14695981039346656037UL;
+ const ulong prime = 1099511628211UL;
+ var hash = offsetBasis;
+ Mix((uint)mesh.vertexCount);
+ foreach (var vertex in mesh.vertices) {
+ MixQuantized(vertex.x);
+ MixQuantized(vertex.y);
+ MixQuantized(vertex.z);
+ }
+ var indices = mesh.triangles;
+ Mix((uint)indices.Length);
+ foreach (var index in indices) {
+ Mix((uint)index);
+ }
+ return hash;
+
+ void Mix(uint value)
+ {
+ hash ^= value;
+ hash *= prime;
+ }
+
+ void MixQuantized(float value)
+ {
+ var quantized = (int)math.round(value * 10000f);
+ Mix((uint)quantized);
+ }
+ }
+ }
+
+ private static void AssertOffsets(WireRailSegment segment,
+ params Vector2[] expected)
+ {
+ Assert.That(segment.RailCount, Is.EqualTo(expected.Length));
+ for (var i = 0; i < expected.Length; i++) {
+ Assert.That(segment.GetRailOffset(i), Is.EqualTo(expected[i]));
+ }
+ }
+
+ private static int CountActiveRails(WireRailSegment segment)
+ => Enumerable.Range(0, segment.RailCount).Count(segment.IsRailActive);
+
+ private static float AverageRowX(Vector3[] vertices, int start, int count)
+ {
+ var sum = 0f;
+ for (var index = start; index < start + count; index++) {
+ sum += vertices[index].x;
+ }
+ return sum / count;
+ }
+
+ private static int FindRowAtRouteY(Vector3[] vertices, int rowSize, float routeY)
+ {
+ for (var rowStart = 0; rowStart <= vertices.Length - rowSize;
+ rowStart += rowSize) {
+ if (math.abs(AverageRowY(vertices, rowStart, rowSize) - routeY) <= 0.001f) {
+ return rowStart;
+ }
+ }
+ return -1;
+ }
+
+ private static float AverageRowY(Vector3[] vertices, int start, int count)
+ {
+ var sum = 0f;
+ for (var index = start; index < start + count; index++) {
+ sum += vertices[index].y;
+ }
+ return sum / count;
+ }
+
+ private static float CrossSectionExtent(Vector3[] vertices, int start, int count)
+ {
+ var minimum = new float2(float.PositiveInfinity);
+ var maximum = new float2(float.NegativeInfinity);
+ for (var index = start; index < start + count; index++) {
+ var point = new float2(vertices[index].x, vertices[index].z);
+ minimum = math.min(minimum, point);
+ maximum = math.max(maximum, point);
+ }
+ return math.csum(maximum - minimum);
+ }
+
+ private static bool RowsMatch(Vector3[] vertices, int firstStart, int secondStart,
+ int count)
+ {
+ for (var index = 0; index < count; index++) {
+ if ((vertices[firstStart + index] - vertices[secondStart + index]).sqrMagnitude
+ > 1e-6f) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static bool SegmentsProperlyIntersect(float2 firstStart, float2 firstEnd,
+ float2 secondStart, float2 secondEnd)
+ {
+ var firstDirection = firstEnd - firstStart;
+ var secondDirection = secondEnd - secondStart;
+ var firstSideA = Cross(firstDirection, secondStart - firstStart);
+ var firstSideB = Cross(firstDirection, secondEnd - firstStart);
+ var secondSideA = Cross(secondDirection, firstStart - secondStart);
+ var secondSideB = Cross(secondDirection, firstEnd - secondStart);
+ return firstSideA * firstSideB < -1e-6f
+ && secondSideA * secondSideB < -1e-6f;
+
+ static float Cross(float2 first, float2 second)
+ => first.x * second.y - first.y * second.x;
+ }
+
+ private static Vector3 AverageRing(Vector3[] vertices, int start, int count)
+ {
+ var sum = Vector3.zero;
+ for (var index = start; index < start + count; index++) {
+ sum += vertices[index];
+ }
+ return sum / count;
+ }
+
+ private static void AssertRingPerpendicular(Vector3[] vertices, int start,
+ int count, Vector3 center, float3 tangent)
+ {
+ for (var index = start; index < start + count; index++) {
+ var radial = (float3)(vertices[index] - center);
+ Assert.That(math.abs(math.dot(radial, tangent)), Is.LessThan(0.001f));
+ }
+ }
+
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailComponentTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailComponentTests.cs.meta
new file mode 100644
index 000000000..2c444aaee
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailComponentTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a181feec5613479b99493e92d86a149a
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailPackagingTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailPackagingTests.cs
new file mode 100644
index 000000000..8bb4d6bc5
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailPackagingTests.cs
@@ -0,0 +1,321 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Collections;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using Unity.Mathematics;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Splines;
+using UnityEngine.TestTools;
+using VisualPinball.Unity.Editor;
+using Object = UnityEngine.Object;
+
+namespace VisualPinball.Unity.Test
+{
+ public class WireRailPackagingTests
+ {
+ [TearDown]
+ public void TearDown()
+ {
+ Undo.ClearAll();
+ }
+
+ [Test]
+ public void ShouldRoundTripEverySettingThroughThePackables()
+ {
+ var sourceGo = new GameObject("Wire Rail");
+ var targetGo = new GameObject("Wire Rail Restored");
+ var reversedGo = new GameObject("Wire Rail Restored (reversed order)");
+ try {
+ var source = Author(sourceGo.AddComponent());
+ var railBytes = source.Pack();
+ var splineBytes = source.SplineContainer
+ .GetComponent().Pack();
+
+ // spline first, then the rail data
+ var target = targetGo.AddComponent();
+ target.SplineContainer.GetComponent().Unpack(splineBytes);
+ target.Unpack(railBytes);
+ AssertRestored(source, target);
+
+ // rail data first, then the spline: the rebuild must wait for the route
+ var reversed = reversedGo.AddComponent();
+ reversed.Unpack(railBytes);
+ reversed.SplineContainer.GetComponent().Unpack(splineBytes);
+ AssertRestored(source, reversed);
+ } finally {
+ Object.DestroyImmediate(sourceGo);
+ Object.DestroyImmediate(targetGo);
+ Object.DestroyImmediate(reversedGo);
+ }
+ }
+
+ // Frozen payloads as written by the version 1 format, for the rail that Author()
+ // builds. When the format evolves, keep these files untouched and add the new version
+ // alongside: they prove that tables packaged by earlier builds still load into the
+ // same rail.
+ private const string GoldenFolder =
+ "Packages/org.visualpinball.engine.unity/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/Golden/";
+
+ [Test]
+ public void ShouldLoadVersion1Payloads()
+ {
+ var go = new GameObject("Wire Rail");
+ try {
+ var rail = go.AddComponent();
+ rail.SplineContainer.GetComponent()
+ .Unpack(LoadGolden("wire-rail-spline-v1.json"));
+ rail.Unpack(LoadGolden("wire-rail-v1.json"));
+ AssertRestored(null, rail);
+
+ // Version 1 carried no tension and was restored through Spline.Add, which gives
+ // AutoSmooth knots the Catmull-Rom tension. That is the curve those tables were
+ // authored and played with, so it is what a version 1 payload must still produce.
+ var spline = rail.SplineContainer.Spline;
+ var legacy = new Spline();
+ for (var i = 0; i < spline.Count; i++) {
+ Assert.That(spline.GetAutoSmoothTension(i),
+ Is.EqualTo(SplineUtility.CatmullRomTension).Within(1e-5f), $"knot {i}");
+ legacy.Add(spline[i], spline.GetTangentMode(i));
+ }
+ legacy.Closed = spline.Closed;
+ var maxDeviation = 0f;
+ for (var i = 0; i <= 100; i++) {
+ var t = i / 100f;
+ maxDeviation = math.max(maxDeviation, math.distance(
+ SplineUtility.EvaluatePosition(spline, t),
+ SplineUtility.EvaluatePosition(legacy, t)));
+ }
+ Assert.That(maxDeviation, Is.LessThan(0.001f),
+ "a version 1 route must follow the curve the old loader produced");
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ private static byte[] LoadGolden(string fileName)
+ {
+ var asset = AssetDatabase.LoadAssetAtPath(GoldenFolder + fileName);
+ Assert.That(asset, Is.Not.Null, $"golden payload {fileName} is missing");
+ return asset.bytes;
+ }
+
+ [Test]
+ public void ShouldPreserveAutoSmoothTensionThroughThePackables()
+ {
+ var sourceGo = new GameObject("Wire Rail");
+ var targetGo = new GameObject("Wire Rail Restored");
+ try {
+ var source = sourceGo.AddComponent();
+ var sourceSpline = source.SplineContainer.Spline;
+ sourceSpline.Add(new BezierKnot(new float3(300f, 800f, 100f)), TangentMode.AutoSmooth);
+ sourceSpline.SetAutoSmoothTension(1, 0.8f);
+ var splineBytes = source.SplineContainer.GetComponent().Pack();
+
+ var target = targetGo.AddComponent();
+ target.SplineContainer.GetComponent().Unpack(splineBytes);
+ var spline = target.SplineContainer.Spline;
+
+ Assert.That(spline.GetAutoSmoothTension(1), Is.EqualTo(0.8f).Within(1e-4f));
+ var maxDeviation = 0f;
+ for (var i = 0; i <= 100; i++) {
+ var t = i / 100f;
+ maxDeviation = math.max(maxDeviation, math.distance(
+ SplineUtility.EvaluatePosition(spline, t),
+ SplineUtility.EvaluatePosition(sourceSpline, t)));
+ }
+ Assert.That(maxDeviation, Is.LessThan(0.01f),
+ "the restored route must follow the authored curve, not a re-tensioned one");
+ } finally {
+ Object.DestroyImmediate(sourceGo);
+ Object.DestroyImmediate(targetGo);
+ }
+ }
+
+ [UnityTest]
+ public IEnumerator ShouldSurviveAPackageRoundTripAndCollide()
+ {
+ var firstPackage = Path.Combine(Path.GetTempPath(),
+ $"vpe-wire-rail-{Guid.NewGuid():N}.vpe");
+ var secondPackage = Path.Combine(Path.GetTempPath(),
+ $"vpe-wire-rail-{Guid.NewGuid():N}.vpe");
+ GameObject source = null;
+ GameObject imported = null;
+ try {
+ source = new GameObject("Table");
+ source.AddComponent();
+ var railGo = new GameObject("Wire Rail");
+ railGo.transform.SetParent(source.transform, false);
+ var sourceRail = Author(railGo.AddComponent