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 + +Ring parameters + +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 + +Rung parameters + +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 + +Cradle parameters + +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 + +Stand parameters + +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 blobs where a ring meets the rails + +- **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 + +Hairpin parameters + +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 + +Elbow parameters + +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 + +Rail trim parameters + +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 + +Render Geometry section + +- **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 + +Ball Channel Collider section + +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. + +
+ +![Collider preview](collider-preview.png) +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. +--- + +![An example wire rail](overview-example.png) + +# 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 + +The Wire Rail button in the toolbox + + +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 layout panel](layout-panel.png) +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. + +![A transition](layout-transition.png) +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. + +![Editing the spline](scene-editing.png) +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. + +![Scene view preview](scene-preview.png) + +- 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()); + Assert.That(sourceRail.RenderMesh, Is.Not.Null); + Assert.That(((ICollidableComponent)sourceRail).IsCollidable, Is.True); + + new PackageWriter(source).WritePackageSync(firstPackage); + + var importTask = new RuntimePackageReader(firstPackage).ImportIntoScene(); + while (!importTask.IsCompleted) { + yield return null; + } + if (importTask.IsFaulted) { + throw importTask.Exception!.GetBaseException(); + } + imported = importTask.Result; + + var rails = imported.GetComponentsInChildren(true); + Assert.That(rails, Has.Length.EqualTo(1)); + var rail = rails[0]; + AssertRestored(sourceRail, rail); + Assert.That(rail.transform.Cast() + .Count(child => child.GetComponent()), Is.EqualTo(1), + "exactly one spline child, no duplicate created on import"); + Assert.That(rail.transform.childCount, Is.EqualTo(1)); + Assert.That(rail.SplineContainer.GetComponent().sharedMaterial, + Is.Not.Null, "the imported material stays on the generated mesh"); + Assert.That(((ICollidableComponent)rail).IsCollidable, Is.True, + "a restored wire rail must produce its ball channel collider"); + Assert.That(rail.ColliderMesh.vertexCount, Is.GreaterThan(0)); + + // and out again, unchanged + new PackageWriter(imported).WritePackageSync(secondPackage); + } finally { + if (source) { + Object.DestroyImmediate(source); + } + if (imported) { + Object.DestroyImmediate(imported); + } + File.Delete(firstPackage); + File.Delete(secondPackage); + } + } + + /// + /// A rail that exercises every packed field: a curved three-knot route, three + /// layouts with custom offsets, an inactive wire, transition overrides, every fixture + /// kind, and non-default settings. + /// + private static WireRailComponent Author(WireRailComponent rail) + { + rail.SplineContainer.Spline.Add(new BezierKnot(new float3(120f, 900f, 40f)), + TangentMode.AutoSmooth); + rail.SetRailCount(5); + rail.SetWireDiameter(7.5f); + rail.SetShowColliderPreview(true); + rail.AddLayout(200f); + rail.AddLayout(400f); + rail.SetRailOffset(1, 0, new Vector2(-21f, 3f)); + rail.SetRailsActive(1, new[] { 4 }, false); + rail.SetWireTransitionOverride(0, 2, true); + rail.SetWireContinuous(0, 2, false); + rail.SetWireTransitionCurve(0, 1, AnimationCurve.EaseInOut(0f, 0f, 1f, 1f)); + + var ring = rail.AddRingFixture(120f); + rail.SetRingFixtureProperties(ring, 120f, true, 30f, 90f, true, 200f, 300f, + 2f, -3f, 1.4f, 24); + rail.AddCradleFixture(150f); + var rung = rail.AddRungFixture(180f); + rail.SetFixtureSolderSize(rung, 1.5f); + var stand = rail.AddStandFixture(220f); + rail.SetFixtureEnabled(stand, false); + var hairpin = rail.AddHairpinFixture(); + rail.SetHairpinFixtureOffset(hairpin, 12f); + rail.AddElbowFixture(WireRailEndpoint.Start); + var trim = rail.AddRailTrimFixture(); + rail.SetRailTrimFixtureProperties(trim, WireRailEndpoint.End, + new[] { 0f, 0f, 0f, 25f, 0f }); + rail.RebuildRenderGeometry(); + return rail; + } + + /// + /// Checks a restored rail against what built. With a source the + /// route and mesh are compared exactly; without one (golden payloads) against the + /// known authored values. + /// + private static void AssertRestored(WireRailComponent source, WireRailComponent rail) + { + Assert.That(rail.SplineContainer, Is.Not.Null); + var spline = rail.SplineContainer.Spline; + if (source != null) { + var sourceSpline = source.SplineContainer.Spline; + Assert.That(spline.Count, Is.EqualTo(sourceSpline.Count)); + Assert.That(spline.Closed, Is.EqualTo(sourceSpline.Closed)); + for (var i = 0; i < spline.Count; i++) { + Assert.That(math.distance(spline[i].Position, sourceSpline[i].Position), + Is.LessThan(0.001f), $"knot {i} position"); + Assert.That(spline.GetTangentMode(i), Is.EqualTo(sourceSpline.GetTangentMode(i))); + } + Assert.That(rail.SplineLength, Is.EqualTo(source.SplineLength).Within(0.01f)); + } else { + Assert.That(spline.Count, Is.EqualTo(3)); + Assert.That(spline.Closed, Is.False); + Assert.That(math.distance(spline[2].Position, new float3(120f, 900f, 40f)), + Is.LessThan(0.001f)); + Assert.That(rail.SplineLength, Is.GreaterThan(900f)); + } + + Assert.That(rail.RailCount, Is.EqualTo(5)); + Assert.That(rail.WireDiameter, Is.EqualTo(7.5f).Within(0.001f)); + Assert.That(rail.ShowColliderPreview, Is.True); + Assert.That(rail.Segments.Select(layout => layout.Distance), + Is.EqualTo(new[] { 0f, 200f, 400f }).Within(0.001f)); + Assert.That(rail.Segments[1].GetRailOffset(0), Is.EqualTo(new Vector2(-21f, 3f))); + Assert.That(rail.Segments[1].IsRailActive(4), Is.False); + Assert.That(rail.Segments[1].IsRailActive(0), Is.True); + var connection = rail.Segments[0].ConnectionToNext; + Assert.That(connection.IsWireOverridden(2), Is.True); + Assert.That(connection.IsWireContinuous(2), Is.False); + Assert.That(connection.IsWireOverridden(1), Is.True); + Assert.That(connection.GetWireCurve(1).keys.Length, + Is.EqualTo(AnimationCurve.EaseInOut(0f, 0f, 1f, 1f).keys.Length)); + Assert.That(rail.LayoutDisplayOrder, Is.EqualTo(new[] { 0, 1, 2 })); + + Assert.That(rail.Fixtures.Select(fixture => fixture.GetType()), Is.EqualTo(new[] { + typeof(WireRailRingFixture), typeof(WireRailCradleFixture), + typeof(WireRailRungFixture), typeof(WireRailStandFixture), + typeof(WireRailHairpinFixture), typeof(WireRailElbowFixture), + typeof(WireRailTrimFixture), + })); + var ring = (WireRailRingFixture)rail.Fixtures[0]; + Assert.That(ring.Distance, Is.EqualTo(120f).Within(0.001f)); + Assert.That(ring.HasCutout, Is.True); + Assert.That(ring.CutoutEndAngle, Is.EqualTo(90f).Within(0.001f)); + Assert.That(ring.HasStraightSection, Is.True); + Assert.That(ring.Scale, Is.EqualTo(1.4f).Within(0.001f)); + Assert.That(ring.RingDensity, Is.EqualTo(24)); + Assert.That(ring.LateralOffset, Is.EqualTo(2f).Within(0.001f)); + Assert.That(rail.Fixtures[2].SolderSize, Is.EqualTo(1.5f).Within(0.001f)); + Assert.That(rail.Fixtures[3].Enabled, Is.False); + Assert.That(((WireRailHairpinFixture)rail.Fixtures[4]).RailOffset, + Is.EqualTo(12f).Within(0.001f)); + Assert.That(((WireRailElbowFixture)rail.Fixtures[5]).Endpoint, + Is.EqualTo(WireRailEndpoint.Start)); + Assert.That(((WireRailTrimFixture)rail.Fixtures[6]).RailOffsets[3], + Is.EqualTo(25f).Within(0.001f)); + + Assert.That(rail.RenderMesh, Is.Not.Null, "geometry is rebuilt once both halves are restored"); + if (source != null) { + Assert.That(rail.RenderMesh.vertexCount, Is.EqualTo(source.RenderMesh.vertexCount)); + } else { + Assert.That(rail.RenderMesh.vertexCount, Is.GreaterThan(0)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailPackagingTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailPackagingTests.cs.meta new file mode 100644 index 000000000..4707f2517 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailPackagingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 65c10aecd7314768b887e6625595752a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSolderMeshGeneratorTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSolderMeshGeneratorTests.cs new file mode 100644 index 000000000..4c5df565b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSolderMeshGeneratorTests.cs @@ -0,0 +1,176 @@ +// 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; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity.Test +{ + public class WireRailSolderMeshGeneratorTests + { + [Test] + public void ShouldUseTheFixtureThresholdAsASurfaceGap() + { + var firstStart = new float3(-5f, 0f, 0f); + var firstEnd = new float3(5f, 0f, 0f); + var secondStart = new float3(0f, 2.5f, -5f); + var secondEnd = new float3(0f, 2.5f, 5f); + + Assert.That(WireRailWireTouchDetector.TryFindTouch(firstStart, firstEnd, 1f, + secondStart, secondEnd, 1f, 0.49f, out _), Is.False); + Assert.That(WireRailWireTouchDetector.TryFindTouch(firstStart, firstEnd, 1f, + secondStart, secondEnd, 1f, 0.5f, out var touch), Is.True); + Assert.That(touch.SurfaceDistance, Is.EqualTo(0.5f).Within(0.0001f)); + Assert.That(math.dot(touch.FirstTangent, touch.SecondTangent), + Is.EqualTo(0f).Within(0.0001f)); + } + + [Test] + public void ShouldCreateAStableRandomizedLowPolySolderBlob() + { + var touch = new WireRailTouch(new float3(0f), new float3(0f, 2f, 0f), + new float3(1f, 0f, 0f), new float3(0f, 0f, 1f), 1f, 1f, 0f); + var first = Generate(123u); + var repeated = Generate(123u); + var changed = Generate(124u); + var doubled = Generate(123u, 2f); + + Assert.That(first.Indices, + Has.Count.EqualTo(WireRailSolderMeshGenerator.TrianglesPerBlob * 3)); + Assert.That(first.Vertices, + Has.Count.EqualTo(WireRailSolderMeshGenerator.TrianglesPerBlob * 3)); + Assert.That(first.Normals, Has.Count.EqualTo(first.Vertices.Count)); + Assert.That(first.Uvs, Has.Count.EqualTo(first.Vertices.Count)); + Assert.That(Vector3.Dot(first.Normals[0], first.Normals[1]), + Is.LessThan(0.999f), + "solder faces should use per-vertex normals for smooth shading"); + Assert.That(repeated.Vertices, Is.EqualTo(first.Vertices), + "the same join must not shimmer between rebuilds"); + Assert.That(changed.Vertices, Is.Not.EqualTo(first.Vertices), + "different seeds should not produce cloned solder blobs"); + for (var vertexIndex = 0; vertexIndex < first.Vertices.Count; vertexIndex++) { + var expected = (Vector3)touch.Position + + (first.Vertices[vertexIndex] - (Vector3)touch.Position) * 2f; + Assert.That(Vector3.Distance(doubled.Vertices[vertexIndex], expected), + Is.LessThan(0.0001f), + "solder size must uniformly scale the blob around the wire touch"); + } + + (List Vertices, List Normals, + List Uvs, List Indices) Generate(uint seed, + float solderSize = WireRailFixture.DefaultSolderSize) + { + var vertices = new List(); + var normals = new List(); + var uvs = new List(); + var indices = new List(); + WireRailSolderMeshGenerator.AppendTouch(touch, seed, solderSize, + vertices, normals, uvs, indices); + return (vertices, normals, uvs, indices); + } + } + + [Test] + public void ShouldSolderADefaultRingToAllFourRails() + { + const int radialSegments = 10; + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + component.SetRailOffset(0, 0, new Vector2(-30f, 0f)); + component.SetRailOffset(0, 1, new Vector2(30f, 0f)); + var railTriangleCount = component.RenderMesh.triangles.Length / 3; + var fixtureIndex = component.AddRingFixture(250f); + var defaultVertices = component.RenderMesh.vertices; + var defaultTriangleCount = component.RenderMesh.triangles.Length; + var ring = (WireRailRingFixture)component.Fixtures[fixtureIndex]; + var touches = new List(); + + WireRailSolderMeshGenerator.CollectTouches(component.SplineContainer.Spline, + component.Segments, component.Fixtures, ring, touches); + + Assert.That(touches, Has.Count.EqualTo(4)); + var ringTriangles = WireRailRingFixture.DefaultRingDensity + * radialSegments * 2; + Assert.That(component.RenderMesh.triangles.Length / 3 - railTriangleCount, + Is.EqualTo(ringTriangles + touches.Count + * WireRailSolderMeshGenerator.TrianglesPerBlob)); + + component.SetFixtureSolderSize(fixtureIndex, 2f); + Assert.That(component.RenderMesh.triangles.Length, + Is.EqualTo(defaultTriangleCount), + "changing solder size must preserve the generated mesh topology"); + Assert.That(component.RenderMesh.vertices, Is.Not.EqualTo(defaultVertices), + "the component render path must pass the authored solder size to the generator"); + } finally { + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ShouldNotSolderContinuousEndpointFixtures() + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + component.SetRailCount(2); + component.AddElbowFixture(WireRailEndpoint.Start); + component.AddHairpinFixture(WireRailEndpoint.End); + var touches = new List(); + foreach (var fixture in component.Fixtures) { + touches.Clear(); + WireRailSolderMeshGenerator.CollectTouches( + component.SplineContainer.Spline, component.Segments, + component.Fixtures, fixture, touches); + Assert.That(touches, Is.Empty, + $"{fixture.GetType().Name} continues the rail wire and needs no solder"); + } + } finally { + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ShouldStoreAndDuplicateTheSolderSettingsPerFixture() + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + var fixtureIndex = component.AddRingFixture(250f); + component.SetFixtureSolderThreshold(fixtureIndex, 3.5f); + component.SetFixtureSolderSize(fixtureIndex, 2.25f); + var duplicateIndex = component.DuplicateRingFixture(fixtureIndex); + + Assert.That(component.Fixtures[fixtureIndex].SolderThreshold, + Is.EqualTo(3.5f)); + Assert.That(component.Fixtures[duplicateIndex].SolderThreshold, + Is.EqualTo(3.5f)); + Assert.That(component.Fixtures[fixtureIndex].SolderSize, + Is.EqualTo(2.25f)); + Assert.That(component.Fixtures[duplicateIndex].SolderSize, + Is.EqualTo(2.25f)); + Assert.Throws(() => + component.SetFixtureSolderThreshold(fixtureIndex, -0.1f)); + Assert.Throws(() => + component.SetFixtureSolderSize(fixtureIndex, 0f)); + } finally { + Object.DestroyImmediate(gameObject); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSolderMeshGeneratorTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSolderMeshGeneratorTests.cs.meta new file mode 100644 index 000000000..7d741e9f3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSolderMeshGeneratorTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f05cc9834b4145f0a8ba79df771f3ce0 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSplineHandlesTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSplineHandlesTests.cs new file mode 100644 index 000000000..a1dd9ec60 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSplineHandlesTests.cs @@ -0,0 +1,646 @@ +// 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; +using System.Reflection; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEditor; +using UnityEditor.EditorTools; +using UnityEngine; +using UnityEngine.Splines; +using UnityEngine.TestTools; +using VisualPinball.Unity.Editor; + +namespace VisualPinball.Unity.Test +{ + public class WireRailSplineHandlesTests + { + [Test] + public void ShouldCreateWireRailFromTheGameObjectMenu() + { + var parent = new GameObject("Wire Rail Menu Parent"); + try { + var create = typeof(WireRailInspector).GetMethod("CreateWireRailGameObject", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(create, Is.Not.Null); + var menuItem = create.GetCustomAttribute(); + Assert.That(menuItem, Is.Not.Null); + Assert.That(menuItem.menuItem, Is.EqualTo("GameObject/Pinball/Wire Rail")); + + create.Invoke(null, new object[] { new MenuCommand(parent) }); + var created = Selection.activeGameObject; + Assert.That(created, Is.Not.Null); + Assert.That(created.name, Is.EqualTo("Wire Rail")); + Assert.That(created.transform.parent, Is.SameAs(parent.transform)); + Assert.That(created.transform.localPosition, Is.EqualTo(Vector3.zero)); + Assert.That(created.GetComponent(), Is.Not.Null); + + Undo.PerformUndo(); + Assert.That(created == null, Is.True); + } finally { + Selection.activeGameObject = null; + Undo.ClearAll(); + Object.DestroyImmediate(parent); + } + } + + [UnityTest] + public IEnumerator ShouldSelectTheGeneratedSplineWhenEditingStarts() + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + var editSpline = typeof(WireRailInspector).GetMethod("EditSpline", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(editSpline, Is.Not.Null); + + editSpline.Invoke(null, new object[] { component.SplineContainer }); + yield return null; + + Assert.That(Selection.activeGameObject, + Is.SameAs(component.SplineContainer.gameObject)); + } finally { + ToolManager.SetActiveContext(); + Selection.activeGameObject = null; + Object.DestroyImmediate(gameObject); + } + } + + [UnityTest] + public IEnumerator ShouldKeepTheSplineMoveGizmoVisibleWhileEditingKnots() + { + var gameObject = new GameObject("Wire Rail"); + var toolsWereHidden = Tools.hidden; + try { + Tools.hidden = false; + var component = gameObject.AddComponent(); + Selection.activeGameObject = component.SplineContainer.gameObject; + ActiveEditorTracker.sharedTracker.ForceRebuild(); + yield return null; + + var splineEditorAssembly = Assembly.Load("Unity.Splines.Editor"); + var contextType = splineEditorAssembly.GetType( + "UnityEditor.Splines.SplineToolContext"); + var moveToolType = splineEditorAssembly.GetType( + "UnityEditor.Splines.SplineMoveTool"); + Assert.That(contextType, Is.Not.Null); + Assert.That(moveToolType, Is.Not.Null); + Assert.That(Selection.activeGameObject, + Is.SameAs(component.SplineContainer.gameObject)); + ToolManager.SetActiveContext(contextType); + ToolManager.SetActiveTool(moveToolType); + yield return null; + + Assert.That(ToolManager.activeToolType, Is.EqualTo(moveToolType)); + Assert.That(Tools.hidden, Is.False, + "The spline move tool needs to draw its standard axis handles."); + } finally { + ToolManager.SetActiveContext(); + Selection.activeGameObject = null; + Tools.hidden = toolsWereHidden; + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ShouldUndoAndRedoACompleteWireDragAsOneOperation() + { + var gameObject = new GameObject("Wire Rail"); + try { + Undo.ClearAll(); + var component = gameObject.AddComponent(); + var initialOffset = component.Segments[0].GetRailOffset(0); + var intermediateOffset = initialOffset + new Vector2(5f, 3f); + var finalOffset = initialOffset + new Vector2(12f, 8f); + var editorType = typeof(WireRailInspector).Assembly.GetType( + "VisualPinball.Unity.Editor.WireRailCrossSectionEditor"); + var beginDragUndo = editorType?.GetMethod("BeginDragUndo", + BindingFlags.Static | BindingFlags.NonPublic); + var endDragUndo = editorType?.GetMethod("EndDragUndo", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(beginDragUndo, Is.Not.Null); + Assert.That(endDragUndo, Is.Not.Null); + + var undoGroup = (int)beginDragUndo.Invoke(null, new object[] { component }); + component.SetWireProperties(0, new[] { 0 }, + new[] { intermediateOffset }); + component.SetWireProperties(0, new[] { 0 }, new[] { finalOffset }); + endDragUndo.Invoke(null, new object[] { undoGroup }); + + Assert.That(component.Segments[0].GetRailOffset(0), + Is.EqualTo(finalOffset)); + Undo.PerformUndo(); + Assert.That(component.Segments[0].GetRailOffset(0), + Is.EqualTo(initialOffset)); + Undo.PerformRedo(); + Assert.That(component.Segments[0].GetRailOffset(0), + Is.EqualTo(finalOffset)); + } finally { + Undo.ClearAll(); + Object.DestroyImmediate(gameObject); + } + } + + [UnityTest] + public IEnumerator ShouldUndoAndRedoADirectSplineKnotDrag() + { + var gameObject = new GameObject("Wire Rail"); + try { + Undo.ClearAll(); + var component = gameObject.AddComponent(); + // Creating the hidden spline child registers its own Undo operation. A direct + // knot drag starts from an already-authored object, so isolate that gesture. + Undo.ClearAll(); + var container = component.SplineContainer; + var handles = typeof(WireRailInspector).Assembly.GetType( + "VisualPinball.Unity.Editor.WireRailSplineHandles"); + var moveKnot = handles?.GetMethod("MoveKnot", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(moveKnot, Is.Not.Null); + + var splineEditorAssembly = Assembly.Load("Unity.Splines.Editor"); + var knotType = splineEditorAssembly.GetType( + "UnityEditor.Splines.SelectableKnot"); + Assert.That(knotType, Is.Not.Null); + var selectableKnot = System.Activator.CreateInstance(knotType, + new object[] { new SplineInfo(container, 0), 0 }); + var positionProperty = knotType.GetProperty("Position"); + Assert.That(positionProperty, Is.Not.Null); + var initialPosition = (float3)positionProperty.GetValue(selectableKnot); + var finalPosition = initialPosition + new float3(17f, 23f, 11f); + + moveKnot.Invoke(null, + new[] { (object)component, selectableKnot, finalPosition }); + + Assert.That(math.distance((float3)positionProperty.GetValue(selectableKnot), + finalPosition), + Is.LessThan(0.001f)); + yield return null; + Undo.PerformUndo(); + yield return null; + Assert.That(math.distance( + (float3)positionProperty.GetValue(selectableKnot), initialPosition), + Is.LessThan(0.001f)); + Undo.PerformRedo(); + yield return null; + Assert.That(math.distance( + (float3)positionProperty.GetValue(selectableKnot), finalPosition), + Is.LessThan(0.001f)); + } finally { + Undo.ClearAll(); + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ShouldGradeKnotHeightsByHorizontalDistanceAndSupportUndo() + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + var container = component.SplineContainer; + container.Spline = new Spline(new[] { + new float3(0f, 0f, 10f), + new float3(100f, 0f, -40f), + new float3(100f, 300f, 400f), + new float3(500f, 300f, 110f), + }, TangentMode.Linear); + Undo.ClearAll(); + + Assert.That(GradeSplineHeights(component, 0, 3), Is.True); + Assert.That(container.Spline[0].Position.z, Is.EqualTo(10f).Within(0.001f)); + Assert.That(container.Spline[1].Position.z, Is.EqualTo(22.5f).Within(0.001f)); + Assert.That(container.Spline[2].Position.z, Is.EqualTo(60f).Within(0.001f)); + Assert.That(container.Spline[3].Position.z, Is.EqualTo(110f).Within(0.001f)); + + Undo.PerformUndo(); + Assert.That(container.Spline[1].Position.z, Is.EqualTo(-40f).Within(0.001f)); + Assert.That(container.Spline[2].Position.z, Is.EqualTo(400f).Within(0.001f)); + Undo.PerformRedo(); + Assert.That(container.Spline[1].Position.z, Is.EqualTo(22.5f).Within(0.001f)); + Assert.That(container.Spline[2].Position.z, Is.EqualTo(60f).Within(0.001f)); + } finally { + Undo.ClearAll(); + Object.DestroyImmediate(gameObject); + } + } + + [TestCase(TangentMode.AutoSmooth, TangentMode.Continuous)] + [TestCase(TangentMode.Broken, TangentMode.Broken)] + [TestCase(TangentMode.Continuous, TangentMode.Continuous)] + [TestCase(TangentMode.Mirrored, TangentMode.Mirrored)] + public void ShouldGradeBezierTangentsWithoutChangingThePlanView( + TangentMode initialMode, TangentMode expectedMode) + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + var container = component.SplineContainer; + var rotation = quaternion.EulerXYZ(math.radians(new float3(23f, -17f, 31f))); + var inverseRotation = math.inverse(rotation); + var spline = new Spline(); + spline.Add(new BezierKnot(new float3(0f, 0f, 5f), + math.rotate(inverseRotation, new float3(-40f, -20f, 0f)), + math.rotate(inverseRotation, new float3(90f, 25f, 0f)), rotation), + TangentMode.Broken); + spline.Add(new BezierKnot(new float3(180f, 130f, -70f), + math.rotate(inverseRotation, new float3(-65f, -55f, 0f)), + math.rotate(inverseRotation, new float3(55f, 85f, 0f)), rotation), + TangentMode.Broken); + spline.Add(new BezierKnot(new float3(360f, 310f, 125f), + math.rotate(inverseRotation, new float3(-80f, -35f, 0f)), + math.rotate(inverseRotation, new float3(30f, 20f, 0f)), rotation), + TangentMode.Broken); + for (var knotIndex = 0; knotIndex < spline.Count; knotIndex++) { + var mainTangent = knotIndex == spline.Count - 1 + ? BezierTangent.In : BezierTangent.Out; + spline.SetTangentMode(knotIndex, initialMode, mainTangent); + } + container.Spline = spline; + var originalCurves = new[] { spline.GetCurve(0), spline.GetCurve(1) }; + var planarLength = CalculatePlanarLength(originalCurves[0]) + + CalculatePlanarLength(originalCurves[1]); + var expectedGrade = 120f / planarLength; + + Assert.That(GradeSplineHeights(component, 0, 2), Is.True); + + for (var knotIndex = 0; knotIndex < spline.Count; knotIndex++) { + Assert.That(spline.GetTangentMode(knotIndex), Is.EqualTo(expectedMode)); + } + for (var segmentIndex = 0; segmentIndex < originalCurves.Length; + segmentIndex++) { + var original = originalCurves[segmentIndex]; + var graded = spline.GetCurve(segmentIndex); + AssertPlanarPoint(graded.P0, original.P0); + AssertPlanarPoint(graded.P1, original.P1); + AssertPlanarPoint(graded.P2, original.P2); + AssertPlanarPoint(graded.P3, original.P3); + var outgoing = graded.P1 - graded.P0; + var incoming = graded.P2 - graded.P3; + Assert.That(outgoing.z, + Is.EqualTo(expectedGrade * math.length(outgoing.xy)).Within(0.001f)); + Assert.That(incoming.z, + Is.EqualTo(-expectedGrade * math.length(incoming.xy)).Within(0.001f)); + } + } finally { + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ShouldOfferHeightGradingForZeroOrTwoSelectedKnotsOnly() + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + var container = component.SplineContainer; + container.Spline = new Spline(new[] { + new float3(0f, 0f, 0f), + new float3(100f, 0f, 0f), + new float3(200f, 0f, 0f), + new float3(300f, 0f, 0f), + }, TangentMode.Linear); + ClearSplineSelection(); + Assert.That(TryGetGradeSplineRange(container, out var startKnot, + out var endKnot, out var selectedCount), Is.True); + Assert.That(selectedCount, Is.Zero); + Assert.That(startKnot, Is.Zero); + Assert.That(endKnot, Is.EqualTo(3)); + + SetSplineSelection(container, 3); + Assert.That(TryGetGradeSplineRange(container, out _, out _, + out selectedCount), Is.False); + Assert.That(selectedCount, Is.EqualTo(1)); + + AddSplineSelection(container, 1); + Assert.That(TryGetGradeSplineRange(container, out startKnot, + out endKnot, out selectedCount), Is.True); + Assert.That(selectedCount, Is.EqualTo(2)); + Assert.That(startKnot, Is.EqualTo(1)); + Assert.That(endKnot, Is.EqualTo(3)); + + AddSplineSelection(container, 0); + Assert.That(TryGetGradeSplineRange(container, out _, out _, + out selectedCount), Is.False); + Assert.That(selectedCount, Is.EqualTo(3)); + } finally { + ClearSplineSelection(); + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ShouldGradeOnlyBetweenTwoSelectedKnots() + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + var container = component.SplineContainer; + var spline = new Spline(); + var positions = new[] { + new float3(0f, 0f, -100f), + new float3(100f, 0f, 10f), + new float3(200f, 0f, 900f), + new float3(500f, 0f, 110f), + new float3(700f, 0f, 500f), + }; + for (var knotIndex = 0; knotIndex < positions.Length; knotIndex++) { + spline.Add(new BezierKnot(positions[knotIndex], + new float3(-30f, 0f, 0f), new float3(30f, 0f, 0f)), + TangentMode.Broken); + } + spline.SetTangentMode(1, TangentMode.Continuous, BezierTangent.Out); + spline.SetTangentMode(2, TangentMode.AutoSmooth, BezierTangent.Out); + spline.SetTangentMode(3, TangentMode.Mirrored, BezierTangent.In); + container.Spline = spline; + var precedingCurve = spline.GetCurve(0); + var followingCurve = spline.GetCurve(3); + SetSplineSelection(container, 3); + AddSplineSelection(container, 1); + + Assert.That(TryGetGradeSplineRange(container, out var startKnot, + out var endKnot, out var selectedCount), Is.True); + Assert.That(selectedCount, Is.EqualTo(2)); + Assert.That(GradeSplineHeights(component, startKnot, endKnot), Is.True); + + Assert.That(spline[0].Position.z, Is.EqualTo(-100f).Within(0.001f)); + Assert.That(spline[1].Position.z, Is.EqualTo(10f).Within(0.001f)); + Assert.That(spline[2].Position.z, Is.EqualTo(35f).Within(0.001f)); + Assert.That(spline[3].Position.z, Is.EqualTo(110f).Within(0.001f)); + Assert.That(spline[4].Position.z, Is.EqualTo(500f).Within(0.001f)); + AssertCurve(spline.GetCurve(0), precedingCurve); + AssertCurve(spline.GetCurve(3), followingCurve); + Assert.That(spline.GetTangentMode(1), Is.EqualTo(TangentMode.Broken)); + Assert.That(spline.GetTangentMode(2), Is.EqualTo(TangentMode.Continuous)); + Assert.That(spline.GetTangentMode(3), Is.EqualTo(TangentMode.Broken)); + } finally { + ClearSplineSelection(); + Object.DestroyImmediate(gameObject); + } + } + + [TestCase(TangentMode.AutoSmooth)] + [TestCase(TangentMode.Continuous)] + [TestCase(TangentMode.Mirrored)] + public void ShouldKeepBoundaryTangentModesWhenIntervalGradeIsANoOp( + TangentMode boundaryMode) + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + var spline = new Spline(new[] { + new float3(0f, 0f, 20f), + new float3(100f, 0f, 20f), + new float3(200f, 0f, 20f), + new float3(300f, 0f, 20f), + new float3(400f, 0f, 20f), + }, TangentMode.AutoSmooth); + spline.SetTangentMode(1, boundaryMode, BezierTangent.Out); + spline.SetTangentMode(3, boundaryMode, BezierTangent.In); + component.SplineContainer.Spline = spline; + var precedingCurve = spline.GetCurve(0); + var followingCurve = spline.GetCurve(3); + + Assert.That(GradeSplineHeights(component, 1, 3), Is.True); + + Assert.That(spline.GetTangentMode(1), Is.EqualTo(boundaryMode)); + Assert.That(spline.GetTangentMode(3), Is.EqualTo(boundaryMode)); + AssertCurve(spline.GetCurve(0), precedingCurve); + AssertCurve(spline.GetCurve(3), followingCurve); + } finally { + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ShouldAddAndRemoveKnotsWithoutRemovingTheMinimumRoute() + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + component.SetRailCount(3); + var handles = typeof(WireRailInspector).Assembly.GetType( + "VisualPinball.Unity.Editor.WireRailSplineHandles"); + var insert = handles?.GetMethod("InsertKnot", + BindingFlags.Static | BindingFlags.NonPublic); + var remove = handles?.GetMethod("RemoveKnot", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(insert, Is.Not.Null); + Assert.That(remove, Is.Not.Null); + + Assert.That(insert.Invoke(null, new object[] { component, 0, 0.5f }), + Is.True); + Assert.That(component.SplineContainer.Spline.Count, Is.EqualTo(3)); + Assert.That(component.Segments, Has.Count.EqualTo(1)); + Assert.That(component.Segments[0].RailCount, Is.EqualTo(3)); + + Assert.That(remove.Invoke(null, new object[] { component, 1 }), Is.True); + Assert.That(component.SplineContainer.Spline.Count, Is.EqualTo(2)); + Assert.That(component.Segments, Has.Count.EqualTo(1)); + Assert.That(remove.Invoke(null, new object[] { component, 0 }), Is.False); + Assert.That(component.SplineContainer.Spline.Count, Is.EqualTo(2)); + } finally { + Undo.ClearAll(); + Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void ShouldAppendAKnotOnTheClosingCurveWithoutMovingTheOrigin() + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + var spline = component.SplineContainer.Spline; + spline.Add(new BezierKnot(new float3(400f, 500f, 0f)), TangentMode.AutoSmooth); + spline.Add(new BezierKnot(new float3(400f, 0f, 0f)), TangentMode.AutoSmooth); + spline.Closed = true; + component.AddLayout(200f); + var origin = spline[0].Position; + var handles = typeof(WireRailInspector).Assembly.GetType( + "VisualPinball.Unity.Editor.WireRailSplineHandles"); + var insert = handles?.GetMethod("InsertKnot", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(insert, Is.Not.Null); + + Assert.That(insert.Invoke(null, new object[] { component, 3, 0.5f }), Is.True); + + Assert.That(spline.Count, Is.EqualTo(5)); + Assert.That(math.distance(spline[0].Position, origin), Is.LessThan(0.001f), + "the route origin, and with it distance zero, stays where it was"); + Assert.That(spline[4].Position.x, Is.EqualTo(200f).Within(1f), + "the new knot sits on the closing curve, appended after the last knot"); + Assert.That(component.Segments[1].Distance, Is.EqualTo(200f).Within(0.001f)); + } finally { + Undo.ClearAll(); + Object.DestroyImmediate(gameObject); + } + } + + [TestCase(TangentMode.AutoSmooth, false)] + [TestCase(TangentMode.Linear, false)] + [TestCase(TangentMode.Broken, true)] + [TestCase(TangentMode.Continuous, true)] + [TestCase(TangentMode.Mirrored, true)] + public void ShouldOnlyDrawTangentGripsForEditableBezierModes(TangentMode mode, + bool expected) + { + var handles = typeof(WireRailInspector).Assembly.GetType( + "VisualPinball.Unity.Editor.WireRailSplineHandles"); + var hasEditableTangents = handles?.GetMethod("HasEditableTangents", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(hasEditableTangents, Is.Not.Null); + Assert.That(hasEditableTangents.Invoke(null, new object[] { mode }), + Is.EqualTo(expected)); + } + + [TestCase(TangentMode.Broken)] + [TestCase(TangentMode.Continuous)] + [TestCase(TangentMode.Mirrored)] + public void ShouldMoveBezierTangentsDirectlyToTheDraggedPosition(TangentMode mode) + { + var gameObject = new GameObject("Wire Rail"); + try { + var component = gameObject.AddComponent(); + var container = component.SplineContainer; + container.transform.localScale = new Vector3(1.6f, 0.7f, 1.25f); + var spline = container.Spline; + spline.SetTangentMode(0, mode, BezierTangent.Out); + var splineEditorAssembly = Assembly.Load("Unity.Splines.Editor"); + var knotType = splineEditorAssembly.GetType( + "UnityEditor.Splines.SelectableKnot"); + var splineInfo = new SplineInfo(container, 0); + var knot = System.Activator.CreateInstance(knotType, + new object[] { splineInfo, 0 }); + var tangent = knotType.GetProperty("TangentOut")?.GetValue(knot); + var knotPosition = (float3)knotType.GetProperty("Position")?.GetValue(knot); + var target = knotPosition + new float3(23f, 31f, 17f); + + var handles = typeof(WireRailInspector).Assembly.GetType( + "VisualPinball.Unity.Editor.WireRailSplineHandles"); + var applyTangentPosition = handles?.GetMethod("ApplyTangentPosition", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(applyTangentPosition, Is.Not.Null); + applyTangentPosition.Invoke(null, new object[] { tangent, target }); + + var tangentType = tangent.GetType(); + var position = (float3)tangentType.GetProperty("Position")?.GetValue(tangent); + Assert.That(math.distance(position, target), Is.LessThan(0.01f)); + if (mode == TangentMode.Mirrored) { + var localDirection = (float3)tangentType.GetProperty("LocalDirection") + ?.GetValue(tangent); + var opposite = tangentType.GetProperty("OppositeTangent")?.GetValue(tangent); + var oppositeDirection = (float3)tangentType.GetProperty("LocalDirection") + ?.GetValue(opposite); + Assert.That(math.length(localDirection), + Is.EqualTo(math.length(oppositeDirection)) + .Within(0.001f)); + } + } finally { + Object.DestroyImmediate(gameObject); + } + } + + 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, 64); + } + + private static bool GradeSplineHeights(WireRailComponent component, + int startKnotIndex, int endKnotIndex) + { + var gradeSplineHeights = typeof(WireRailInspector).GetMethod( + "GradeSplineHeights", BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(gradeSplineHeights, Is.Not.Null); + return (bool)gradeSplineHeights.Invoke(null, + new object[] { component, startKnotIndex, endKnotIndex }); + } + + private static bool TryGetGradeSplineRange(SplineContainer container, + out int startKnotIndex, out int endKnotIndex, out int selectedKnotCount) + { + var getGradeSplineRange = typeof(WireRailInspector).GetMethod( + "TryGetGradeSplineRange", BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(getGradeSplineRange, Is.Not.Null); + var arguments = new object[] { container, 0, 0, 0 }; + var result = (bool)getGradeSplineRange.Invoke(null, arguments); + startKnotIndex = (int)arguments[1]; + endKnotIndex = (int)arguments[2]; + selectedKnotCount = (int)arguments[3]; + return result; + } + + private static void ClearSplineSelection() + { + var selectionType = Assembly.Load("Unity.Splines.Editor").GetType( + "UnityEditor.Splines.SplineSelection"); + var clear = selectionType?.GetMethod("Clear", BindingFlags.Static + | BindingFlags.Public); + Assert.That(clear, Is.Not.Null); + clear.Invoke(null, null); + } + + private static void SetSplineSelection(SplineContainer container, int knotIndex) + => ChangeSplineSelection("Set", container, knotIndex); + + private static void AddSplineSelection(SplineContainer container, int knotIndex) + => ChangeSplineSelection("Add", container, knotIndex); + + private static void ChangeSplineSelection(string methodName, + SplineContainer container, int knotIndex) + { + var splineEditorAssembly = Assembly.Load("Unity.Splines.Editor"); + var knotType = splineEditorAssembly.GetType( + "UnityEditor.Splines.SelectableKnot"); + var selectionType = splineEditorAssembly.GetType( + "UnityEditor.Splines.SplineSelection"); + Assert.That(knotType, Is.Not.Null); + Assert.That(selectionType, Is.Not.Null); + var knot = System.Activator.CreateInstance(knotType, + new object[] { new SplineInfo(container, 0), knotIndex }); + MethodInfo selectionMethod = null; + foreach (var method in selectionType.GetMethods(BindingFlags.Static + | BindingFlags.Public)) { + if (method.Name == methodName && method.IsGenericMethodDefinition + && method.GetParameters().Length == 1) { + selectionMethod = method.MakeGenericMethod(knotType); + break; + } + } + Assert.That(selectionMethod, Is.Not.Null); + selectionMethod.Invoke(null, new[] { knot }); + } + + private static void AssertPlanarPoint(float3 actual, float3 expected) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(0.001f)); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(0.001f)); + } + + private static void AssertCurve(BezierCurve actual, BezierCurve expected) + { + Assert.That(math.distance(actual.P0, expected.P0), Is.LessThan(0.001f)); + Assert.That(math.distance(actual.P1, expected.P1), Is.LessThan(0.001f)); + Assert.That(math.distance(actual.P2, expected.P2), Is.LessThan(0.001f)); + Assert.That(math.distance(actual.P3, expected.P3), Is.LessThan(0.001f)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSplineHandlesTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSplineHandlesTests.cs.meta new file mode 100644 index 000000000..11499d9cb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailSplineHandlesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5150aa64e86e4a9a8ebf5934be09b907 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs index 482033b1d..a4ec371d4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs @@ -73,6 +73,7 @@ public static DragPointSplineComponent Create(IDragPointSplineOwner owner, splineObject.transform.localPosition = Vector3.zero; splineObject.transform.localRotation = ((Matrix4x4)Physics.VpxToWorld).rotation; splineObject.transform.localScale = Physics.ScaleInvVector; + LockTransform(splineObject); splineObject.AddComponent(); var container = splineObject.AddComponent(); @@ -211,6 +212,7 @@ private static DragPointSplineComponent ResolveExisting(IDragPointSplineOwner ow private static void EnsureExportMarker(GameObject splineObject) { + LockTransform(splineObject); if (!splineObject.TryGetComponent(out _)) { #if UNITY_EDITOR if (!Application.isPlaying) { @@ -223,6 +225,23 @@ private static void EnsureExportMarker(GameObject splineObject) } } + /// + /// The generated child's transform carries the VPX-to-world conversion; lock it in the + /// inspector so it cannot be edited by hand. Idempotent. + /// + private static void LockTransform(GameObject splineObject) + { + if ((splineObject.transform.hideFlags & HideFlags.NotEditable) != 0) { + return; + } + splineObject.transform.hideFlags |= HideFlags.NotEditable; +#if UNITY_EDITOR + if (!Application.isPlaying) { + EditorUtility.SetDirty(splineObject.transform); + } +#endif + } + private static void RemoveGeneratedChild(IDragPointSplineOwner owner, GameObject generatedChild, string reason) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs index ca0e7f83b..380bd7795 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceCoil.cs @@ -135,6 +135,22 @@ public void OnCoilSimulationThread(float value) public void OnChange(bool enabled) => OnCoil(enabled); + /// + /// Publishes a transition the simulation thread has already dispatched (or is about to) + /// to the main-thread listeners: the enabled state, and + /// the editor UI. Sounds and animations key off those, while the physics callbacks stay + /// with the simulation thread. + /// + public void PublishSimulationThreadDispatchedState(bool enabled) + { + _simThreadActive = true; + Interlocked.Exchange(ref _isEnabled, enabled ? 1 : 0); + CoilStatusChanged?.Invoke(this, new NoIdCoilEventArgs(enabled)); +#if UNITY_EDITOR + RefreshUI(); +#endif + } + /// /// Reset simulation-thread state. Call when the simulation thread /// is torn down or the gamelogic engine stops, so that a subsequent @@ -149,7 +165,7 @@ internal void ResetSimulationState() #if UNITY_EDITOR private void RefreshUI() { - if (!_player.UpdateDuringGameplay) { + if (_player == null || !_player.UpdateDuringGameplay) { return; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceSwitch.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceSwitch.cs index 31e4e14e7..42bb88f13 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceSwitch.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/DeviceSwitch.cs @@ -60,7 +60,7 @@ public DeviceSwitch(string name, bool isPulseSwitch, SwitchDefault switchDefault IApiSwitchStatus IApiSwitch.AddSwitchDest(SwitchConfig switchConfig, IApiSwitchStatus switchStatus) => _switchHandler.AddSwitchDest(switchConfig.WithPulse(_isPulseSwitch).WithDefault(_switchDefault), switchStatus); - public void AddWireDest(WireDestConfig wireConfig) => _switchHandler.AddWireDest(wireConfig); + public void AddWireDest(WireDestConfig wireConfig) => _switchHandler.AddWireDest(wireConfig.WithPulse(_isPulseSwitch)); void IApiSwitch.RemoveWireDest(string destId) => _switchHandler.RemoveWireDest(destId); /// diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs index 953bef7d4..375b9fe34 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -71,7 +71,7 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r case ColliderType.Circle: { ref var circleCollider = ref colliders.Circle(colliderId); - circleCollider.Collide(ref ball, in ball.CollisionEvent, ref state); + circleCollider.Collide(ref ball, ref state.EventQueue, in ball.CollisionEvent, ref state); break; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 5f9446464..64c74998d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -168,6 +168,19 @@ private void EnsureSimulationThreadComponent() public IApiWireDeviceDest WireDevice(IWireableComponent c) => _wirePlayer.WireDevice(c); internal void HandleWireSwitchChange(WireDestConfig wireConfig, bool isEnabled) => _wirePlayer.HandleSwitchChange(wireConfig, isEnabled); + /// + /// Registers a hardware-rule wire coil so the simulation thread fires it when the input action fires + /// (low-latency flippers under native input). Returns true when the coil is now on that path. + /// + internal bool RegisterInputActionWireCoil(string inputActionHint, ISimulationThreadCoil coil) + => SimulationThreadComponent != null + && SimulationThreadComponent.RegisterInputActionWireCoil(inputActionHint, coil); + + /// Removes a hardware-rule wire coil registered for simulation-thread dispatch. + internal void UnregisterInputActionWireCoil(string inputActionHint, ISimulationThreadCoil coil) + => SimulationThreadComponent?.UnregisterInputActionWireCoil(inputActionHint, coil); + + internal void DispatchSwitch(string switchId, bool isClosed) { if (SimulationThreadComponent != null && SimulationThreadComponent.EnqueueSwitchFromMainThread(switchId, isClosed)) { @@ -330,6 +343,9 @@ public void Register(TApi api, MonoBehaviour component) where TApi : IApi } if (api is IApiMagnetEvents magnetEvents) { _magnets[itemId] = magnetEvents; + if (api is IApiHittable magnetHittable) { + _hittables[itemId] = magnetHittable; + } } if (api is IApiSpinnable spinnable) { _spinnables[itemId] = spinnable; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs index 0e2e54228..296a7a3c2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs @@ -20,6 +20,7 @@ using NLog; using UnityEngine; using UnityEngine.InputSystem; +using VisualPinball.Unity.Simulation; using Logger = NLog.Logger; namespace VisualPinball.Unity @@ -138,7 +139,11 @@ internal void AddWire(WireMapping wireMapping, bool isHardwareRule = false) if (!_keyWireAssignments.ContainsKey(inputAction)) { _keyWireAssignments[inputAction] = new List(); } - _keyWireAssignments[inputAction].Add(SetupWireDestConfig(wireMapping, isHardwareRule)); + var destConfig = SetupWireDestConfig(wireMapping, isHardwareRule); + _keyWireAssignments[inputAction].Add(destConfig); + if (isHardwareRule) { + RegisterSimulationThreadWireCoil(inputAction, destConfig); + } break; } @@ -237,6 +242,9 @@ internal void RemoveWire(WireMapping wireMapping) var assignment = _keyWireAssignments[inputAction] .FirstOrDefault(a => a.IsHardwareRule && a.Device == wireMapping.DestinationDevice && a.DeviceItem == wireMapping.DestinationDeviceItem); _keyWireAssignments[inputAction].Remove(assignment); + if (assignment != null) { + UnregisterSimulationThreadWireCoil(inputAction, assignment); + } break; } @@ -249,6 +257,61 @@ internal void RemoveWire(WireMapping wireMapping) } } + // Hardware-rule wires driven by an input action (a P-ROC flipper, say) must energize on the + // simulation thread: native input polling dispatches switches but not wires, and the managed + // Input System wire path (HandleKeyInput) is inactive while native polling owns the keys. When the + // wire's coil supports the simulation-thread path, hand it to the simulation thread so it fires in + // the same tick as the key press. Coils without that path stay on the (main-thread) wire path. + // Destinations the simulation thread fires itself. While native polling owns the keys, + // the managed path must not fire them a second time. + private readonly HashSet _simulationThreadWireDests = new(); + + private void RegisterSimulationThreadWireCoil(string inputAction, WireDestConfig destConfig) + { + if (TryGetSimulationThreadCoil(destConfig, out var coil) + && _player.RegisterInputActionWireCoil(inputAction, coil)) { + _simulationThreadWireDests.Add(destConfig); + } + } + + private void UnregisterSimulationThreadWireCoil(string inputAction, WireDestConfig destConfig) + { + _simulationThreadWireDests.Remove(destConfig); + if (!TryGetSimulationThreadCoil(destConfig, out var coil)) { + return; + } + // The same rule installed twice yields two assignments sharing one native + // registration. Keep that registration while another assignment on this action + // still routes to the coil, or the survivor would suppress its managed fallback + // with nothing firing on the simulation thread. + if (_keyWireAssignments.TryGetValue(inputAction, out var remaining) + && remaining.Any(other => other != destConfig + && _simulationThreadWireDests.Contains(other) + && TryGetSimulationThreadCoil(other, out var otherCoil) + && ReferenceEquals(otherCoil, coil))) { + return; + } + _player.UnregisterInputActionWireCoil(inputAction, coil); + } + + private bool IsDispatchedOnSimulationThread(WireDestConfig wireConfig) + => _simulationThreadWireDests.Contains(wireConfig) + && NativeInputManager.TryGetExistingInstance()?.IsPolling == true; + + private bool TryGetSimulationThreadCoil(WireDestConfig destConfig, out ISimulationThreadCoil coil) + { + coil = null; + if (destConfig?.Device == null || !_wireDevices.ContainsKey(destConfig.Device)) { + return false; + } + if (_wireDevices[destConfig.Device].Wire(destConfig.DeviceItem) is DeviceCoil deviceCoil + && deviceCoil.SupportsSimulationThreadDispatch) { + coil = deviceCoil; + return true; + } + return false; + } + #endregion #region Runtime @@ -276,6 +339,18 @@ internal void DispatchInputAction(string actionName, bool isEnabled) if (wireConfig.Device == null || !_wireDevices.ContainsKey(wireConfig.Device)) { continue; } + if (IsDispatchedOnSimulationThread(wireConfig)) { + // The native poller fires this coil on the simulation thread in the same + // tick as the key. Publish the state to the main-thread listeners (coil + // sounds, animations, editor UI) without the physics callbacks. Without + // native polling the managed path below remains the fallback. + if (TryGetSimulationThreadCoil(wireConfig, out var simulationCoil) + && simulationCoil is DeviceCoil deviceCoil) { + deviceCoil.PublishSimulationThreadDispatchedState(isEnabled); + } + WireStatuses[wireConfig.Id] = (isEnabled, 0); + continue; + } var device = _wireDevices[wireConfig.Device]; var wire = device.Wire(wireConfig.DeviceItem); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs index 75e5a4f2e..c3cd5ba0c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using Unity.Collections; using Unity.Mathematics; using VisualPinball.Engine.Common; using VisualPinball.Engine.VPT; @@ -231,9 +232,17 @@ public float HitTestBasicRadius(ref CollisionEventData collEvent, ref InsideOfs #endregion - public void Collide(ref BallState ball, in CollisionEventData collEvent, ref PhysicsState state) + public void Collide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, + in CollisionEventData collEvent, ref PhysicsState state) { + var impactSpeed = -math.dot(collEvent.HitNormal, ball.Velocity); BallCollider.Collide3DWall(ref ball, in Header.Material, in collEvent, in collEvent.HitNormal, ref state); + + // Circle is shared by non-hittable bracket and rigid-body colliders. The magnet deliberately + // identifies its generated cylinder as a primitive so only that circle uses primitive hit events. + if (Header.FireEvents && impactSpeed >= Header.Threshold && Header.IsPrimitive) { + Collider.FireHitEvent(ref ball, ref hitEvents, in Header); + } } #region Transformation diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 077849294..da4937a55 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -82,6 +82,15 @@ public class SimulationThread : IDisposable private readonly bool[] _actionInvertsPressed; private readonly bool[] _actionToggleOnPress; private readonly bool[] _actionSwitchStates; + + // Hardware-rule wires that connect an input action to a simulation-thread-capable coil (e.g. a + // P-ROC flipper: "Left Flipper" -> flipper main coil). The native input poller dispatches these + // on the simulation thread so the coil energizes in the same tick as the key, instead of relying + // on the managed Input System callback (which the native path bypasses). Guarded by its own lock: + // registration happens on the main thread when rules are (de)installed, dispatch on the sim thread + // only on an actual input transition, so contention is negligible. + private readonly Dictionary> _actionWireCoils = new(); + private readonly object _actionWireCoilLock = new object(); private volatile bool _inputMappingsBuilt; // Statistics @@ -519,6 +528,11 @@ private void ProcessInputEvents() continue; } + // Fire any hardware-rule wire coils bound to this action on the simulation thread, so a + // P-ROC flipper energizes within the same tick as the key press. This is the wire path the + // managed Input System would otherwise own, which native polling bypasses. + DispatchInputActionWires(actionIndex, isPressed); + InputLatencyTracker.RecordInputPolled((NativeInputApi.InputAction)actionIndex, isPressed, evt.TimestampUsec); if (actionIndex == (int)NativeInputApi.InputAction.Tilt @@ -763,7 +777,63 @@ private void LogMapping(NativeInputApi.InputAction action, string name) Logger.Info($"{LogPrefix} [SimulationThread] Mapping: {name}={mapped}"); } - private static bool TryMapInputActionHint(string inputActionHint, out NativeInputApi.InputAction action) + /// + /// Registers a hardware-rule wire coil so the simulation-thread input loop energizes it when the + /// given input action fires. Only coils that expose the simulation-thread dispatch path are + /// eligible; others must keep to the main-thread wire path. Called on the main thread. + /// + internal void RegisterInputActionWireCoil(string inputActionHint, ISimulationThreadCoil coil) + { + if (coil == null || !TryMapInputActionHint(inputActionHint, out var action)) { + return; + } + var actionIndex = (int)action; + lock (_actionWireCoilLock) { + if (!_actionWireCoils.TryGetValue(actionIndex, out var list)) { + list = new List(2); + _actionWireCoils[actionIndex] = list; + } + if (!list.Contains(coil)) { + list.Add(coil); + } + } + } + + /// Removes a previously registered hardware-rule wire coil. Called on the main thread. + internal void UnregisterInputActionWireCoil(string inputActionHint, ISimulationThreadCoil coil) + { + if (coil == null || !TryMapInputActionHint(inputActionHint, out var action)) { + return; + } + lock (_actionWireCoilLock) { + if (_actionWireCoils.TryGetValue((int)action, out var list)) { + list.Remove(coil); + } + } + } + + /// Drops all registered hardware-rule wire coils, e.g. when the gamelogic engine restarts. + internal void ClearInputActionWireCoils() + { + lock (_actionWireCoilLock) { + _actionWireCoils.Clear(); + } + } + + private void DispatchInputActionWires(int actionIndex, bool enabled) + { + lock (_actionWireCoilLock) { + if (!_actionWireCoils.TryGetValue(actionIndex, out var list) || list.Count == 0) { + return; + } + var value = enabled ? 1f : 0f; + for (var i = 0; i < list.Count; i++) { + list[i].OnCoilSimulationThread(value); + } + } + } + + internal static bool TryMapInputActionHint(string inputActionHint, out NativeInputApi.InputAction action) { // Keep this allocation-free and fast: match against known InputConstants strings. if (inputActionHint == InputConstants.ActionLeftFlipper) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index f92326a46..703574884 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -250,6 +250,8 @@ public static SimulationThreadComponent EnsureForTable(GameObject tableRoot) private PhysicsEngine _physicsEngine; private IGamelogicEngine _gamelogicEngine; private SimulationThread _simulationThread; + // Hardware-rule wire coils by input action, kept here so they outlive any one thread. + private readonly Dictionary> _inputActionWireCoils = new(); private NativeInputManager _inputManager; private bool _started = false; @@ -380,6 +382,7 @@ public void StartSimulation() ? new Action((coilId, value) => player.DispatchCoilSimulationThread(coilId, value)) : null); ConfigureTiltBobRouting(); + ApplyInputActionWireCoils(_simulationThread); _simulationThread.SyncClockFromMainThread(_physicsEngine.CurrentSimulationClockUsec, _physicsEngine.CurrentSimulationClockScale); // Provide the triple-buffered SimulationState to PhysicsEngine so @@ -740,6 +743,55 @@ internal bool EnqueueSwitchFromMainThread(string switchId, bool isClosed) return _simulationThread.EnqueueExternalSwitch(switchId, isClosed); } + /// + /// Registers a hardware-rule wire coil for simulation-thread dispatch. See + /// . The registration is kept at + /// component scope and applied to every simulation thread this component creates, so a + /// rule installed before or retained across a stop/start + /// cycle still reaches native-input dispatch. + /// + /// True when the input action is one the native poller can dispatch. + internal bool RegisterInputActionWireCoil(string inputActionHint, ISimulationThreadCoil coil) + { + if (coil == null || string.IsNullOrEmpty(inputActionHint) + || !SimulationThread.TryMapInputActionHint(inputActionHint, out _)) { + return false; + } + if (!_inputActionWireCoils.TryGetValue(inputActionHint, out var coils)) { + coils = new List(2); + _inputActionWireCoils[inputActionHint] = coils; + } + if (!coils.Contains(coil)) { + coils.Add(coil); + } + _simulationThread?.RegisterInputActionWireCoil(inputActionHint, coil); + return true; + } + + /// Removes a hardware-rule wire coil registered for simulation-thread dispatch. + internal void UnregisterInputActionWireCoil(string inputActionHint, ISimulationThreadCoil coil) + { + if (coil == null || string.IsNullOrEmpty(inputActionHint)) { + return; + } + if (_inputActionWireCoils.TryGetValue(inputActionHint, out var coils)) { + coils.Remove(coil); + if (coils.Count == 0) { + _inputActionWireCoils.Remove(inputActionHint); + } + } + _simulationThread?.UnregisterInputActionWireCoil(inputActionHint, coil); + } + + private void ApplyInputActionWireCoils(SimulationThread simulationThread) + { + foreach (var (inputActionHint, coils) in _inputActionWireCoils) { + foreach (var coil in coils) { + simulationThread.RegisterInputActionWireCoil(inputActionHint, coil); + } + } + } + #endregion #region Private Methods diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs index 47e7457fb..fa0743c51 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetApi.cs @@ -22,7 +22,7 @@ namespace VisualPinball.Unity { - public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiWireDeviceDest, IApiMagnetEvents + public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiWireDeviceDest, IApiMagnetEvents, IApiHittable { private readonly MagnetComponent _component; private readonly Player _player; @@ -30,6 +30,7 @@ public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiWireDeviceD private readonly int _itemId; private DeviceCoil _magnetCoil; + private DeviceSwitch _hitSwitch; private DeviceSwitch _ballHeldSwitch; // written by the simulation thread's coil dispatch, read by the main thread private volatile bool _isEnabled; @@ -40,6 +41,7 @@ public class MagnetApi : IApi, IApiCoilDevice, IApiSwitchDevice, IApiWireDeviceD public event EventHandler BallExited; public event EventHandler BallGrabbed; public event EventHandler BallReleased; + public event EventHandler Hit; internal MagnetApi(GameObject go, Player player, PhysicsEngine physicsEngine) { @@ -50,6 +52,7 @@ internal MagnetApi(GameObject go, Player player, PhysicsEngine physicsEngine) _isEnabled = _component.IsEnabledOnStart; _magnetCoil = new DeviceCoil(_player, onValue: OnCoilValue, onValueSimulationThread: OnCoilValueSimulationThread); + _hitSwitch = new DeviceSwitch(MagnetComponent.HitSwitchItem, true, SwitchDefault.NormallyOpen, _player, _physicsEngine); _ballHeldSwitch = new DeviceSwitch(MagnetComponent.BallHeldSwitchItem, false, SwitchDefault.NormallyOpen, _player, _physicsEngine); } @@ -145,10 +148,17 @@ private IApiSwitch Switch(string deviceItem) { return deviceItem switch { MagnetComponent.BallHeldSwitchItem => _ballHeldSwitch, - _ => throw new ArgumentException($"Unknown magnet switch \"{deviceItem}\". Valid name is \"{MagnetComponent.BallHeldSwitchItem}\".") + MagnetComponent.HitSwitchItem => _hitSwitch, + _ => throw new ArgumentException($"Unknown magnet switch \"{deviceItem}\". Valid names are \"{MagnetComponent.BallHeldSwitchItem}\" and \"{MagnetComponent.HitSwitchItem}\".") }; } + void IApiHittable.OnHit(int ballId, bool _) + { + Hit?.Invoke(this, new HitEventArgs(ballId)); + _hitSwitch.SetSwitch(true); + } + /// /// Sets the normalized electrical command in [0..1] (e.g. Iron Man's ROM /// pulses the magnet coils). Physical profiles integrate this command into diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index b425a207e..063785656 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -31,7 +31,9 @@ namespace VisualPinball.Unity public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDeviceComponent, IPackable, ICollidableComponent { public const string MagnetCoilItem = "magnet_coil"; + public const string HitSwitchItem = "hit_switch"; public const string BallHeldSwitchItem = "ball_held"; + public const float DefaultHitThreshold = 2f; public const float DefaultPlanarDamping = 0.985f; public const float DefaultCoilRiseTimeMs = 20f; public const float DefaultCoilFallTimeMs = 20f; @@ -117,6 +119,10 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic [Tooltip("Draw play-mode force vectors and a green/red runtime coil-status gizmo.")] public bool DrawDebugForces; + [Min(0f)] + [Tooltip("Minimum normal ball impact speed against the generated cylindrical collider required to pulse the Hit switch.")] + public float HitThreshold = DefaultHitThreshold; + [SerializeField, HideInInspector] private float _colliderElasticity = DefaultColliderElasticity; [SerializeField, HideInInspector] private float _colliderElasticityFalloff = DefaultColliderElasticityFalloff; [SerializeField, HideInInspector] private float _colliderFriction = DefaultColliderFriction; @@ -141,6 +147,10 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac public IEnumerable AvailableSwitches => new[] { new GamelogicEngineSwitch(BallHeldSwitchItem) { Description = "Ball Held" + }, + new GamelogicEngineSwitch(HitSwitchItem) { + Description = "Hit", + IsPulseSwitch = true } }; @@ -186,6 +196,7 @@ private void OnValidate() CylinderRadius = math.max(0f, CylinderRadius); CylinderHeight = math.max(0f, CylinderHeight); CylindricalDamping = math.max(0f, CylindricalDamping); + HitThreshold = math.max(0f, HitThreshold); SyncPhysicsState(); } @@ -318,7 +329,8 @@ void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngin ItemId = ItemId, ItemType = ItemType.Primitive, Material = material, - FireEvents = false + HitThreshold = HitThreshold, + FireEvents = true }; colliders.Add(new CircleCollider(float2.zero, CylinderRadius, 0f, CylinderHeight, info), translateWithinPlayfieldMatrix); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs index a3de217b0..8c3b75c27 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs @@ -18,7 +18,7 @@ namespace VisualPinball.Unity { public struct MagnetPackable { - private const int CurrentVersion = 2; + private const int CurrentVersion = 3; public int Version; public float Radius; @@ -38,6 +38,7 @@ public struct MagnetPackable public bool IsEnabledOnStart; public bool IsKinematic; public bool DrawDebugForces; + public float HitThreshold; public static byte[] Pack(MagnetComponent comp) { @@ -60,6 +61,7 @@ public static byte[] Pack(MagnetComponent comp) IsEnabledOnStart = comp.IsEnabledOnStart, IsKinematic = comp.IsKinematic, DrawDebugForces = comp.DrawDebugForces, + HitThreshold = comp.HitThreshold, }); } @@ -83,6 +85,7 @@ public static void Unpack(byte[] bytes, MagnetComponent comp) comp.IsEnabledOnStart = data.IsEnabledOnStart; comp.IsKinematic = data.IsKinematic; comp.DrawDebugForces = data.DrawDebugForces; + comp.HitThreshold = data.Version >= 3 ? data.HitThreshold : MagnetComponent.DefaultHitThreshold; } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail.meta new file mode 100644 index 000000000..8a4d1b4ef --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: df6e9a7e46a04a9ab8e4ca3d3c37dcf0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailComponent.cs new file mode 100644 index 000000000..a0b194c1b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailComponent.cs @@ -0,0 +1,3963 @@ +// 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.Collections; +using Unity.Mathematics; +using Unity.Profiling; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Scripting.APIUpdating; +using UnityEngine.Serialization; +using UnityEngine.Splines; +using VisualPinball.Engine.VPT; +using Material = UnityEngine.Material; +using Mesh = UnityEngine.Mesh; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace VisualPinball.Unity +{ + public enum WireRailThirdRailSide + { + Left, + Right, + } + + public enum WireRailStandSide + { + Left, + Right, + } + + public enum WireRailEndpoint + { + Start, + End, + } + + /// + /// Creates useful starting positions for wire-rail centerlines. All values are in VPX units + /// and describe the X/Z cross-section around a route whose initial direction is +Y. + /// + public static class WireRailLayout + { + public const float ReferenceBallDiameter = 50f; + public const float ReferenceWireDiameter = 8f; + public const float DefaultWireDiameter = 6.5f; + public const float BottomRailSpacing = 30f; + public const float MiddleRailSpacing = 60f; + public const float MiddleRailHeight = 30f; + public const float TopRailSpacing = 30f; + public const float TopRailHeight = 60f; + + public static Vector2[] CreateDefaultOffsets(int railCount, + WireRailThirdRailSide thirdRailSide = WireRailThirdRailSide.Right) + { + if (railCount < 1) { + throw new ArgumentOutOfRangeException(nameof(railCount), railCount, + "A wire-rail segment needs at least one rail."); + } + + if (railCount == 1) { + return new[] { Vector2.zero }; + } + + var bottomHalfSpacing = BottomRailSpacing * 0.5f; + var middleHalfSpacing = MiddleRailSpacing * 0.5f; + var topHalfSpacing = TopRailSpacing * 0.5f; + var offsets = new List(railCount) { + new(-bottomHalfSpacing, 0f), + new(bottomHalfSpacing, 0f), + }; + + if (railCount == 3) { + offsets.Add(new Vector2( + thirdRailSide == WireRailThirdRailSide.Left + ? -middleHalfSpacing : middleHalfSpacing, + MiddleRailHeight)); + return offsets.ToArray(); + } + + if (railCount >= 4) { + offsets.Add(new Vector2(-middleHalfSpacing, MiddleRailHeight)); + offsets.Add(new Vector2(middleHalfSpacing, MiddleRailHeight)); + } + + var topRailCount = railCount - 4; + for (var i = 0; i < topRailCount; i++) { + var x = topRailCount == 1 + ? 0f + : math.lerp(-topHalfSpacing, topHalfSpacing, + i / (float)(topRailCount - 1)); + offsets.Add(new Vector2(x, TopRailHeight)); + } + return offsets.ToArray(); + } + } + + [Serializable] + public sealed class WireRailTransition + { + internal void Restore(bool overridden, bool continuous, AnimationCurve curve) + { + _overridden = overridden; + _continuous = continuous; + _curve = curve ?? AnimationCurve.Linear(0f, 0f, 1f, 1f); + } + + [SerializeField] private bool _overridden; + [SerializeField] private bool _continuous = true; + [SerializeField] private AnimationCurve _curve = AnimationCurve.Linear(0f, 0f, 1f, 1f); + + public bool Overridden => _overridden; + public bool Continuous => _continuous; + public AnimationCurve Curve => _curve; + + internal bool EnsureInitialized() + { + var changed = false; + if (_curve == null || _curve.length == 0) { + _curve = AnimationCurve.Linear(0f, 0f, 1f, 1f); + changed = true; + } + if (!_overridden && (!_continuous || !IsLinear(_curve))) { + _overridden = true; + changed = true; + } + return changed; + } + + internal void SetOverridden(bool overridden) + { + _overridden = overridden; + if (overridden) { + return; + } + _continuous = true; + _curve = AnimationCurve.Linear(0f, 0f, 1f, 1f); + } + + internal void SetContinuous(bool continuous) + { + _overridden = true; + _continuous = continuous; + } + + internal void SetCurve(AnimationCurve curve) + { + _overridden = true; + _curve = CloneCurve(curve); + EnsureInitialized(); + } + + internal float Evaluate(float curveT) + { + if (curveT <= 0f) { + return 0f; + } + if (curveT >= 1f) { + return 1f; + } + return _curve == null || _curve.length == 0 + ? curveT + : math.saturate(_curve.Evaluate(curveT)); + } + + internal WireRailTransition Clone() + => new() { + _overridden = _overridden, + _continuous = _continuous, + _curve = CloneCurve(_curve), + }; + + internal static WireRailTransition FromLegacy(bool continuous) + => new() { + _overridden = !continuous, + _continuous = continuous, + }; + + private static bool IsLinear(AnimationCurve curve) + { + const int sampleCount = 16; + for (var sampleIndex = 0; sampleIndex <= sampleCount; sampleIndex++) { + var t = sampleIndex / (float)sampleCount; + if (!Mathf.Approximately(curve.Evaluate(t), t)) { + return false; + } + } + return true; + } + + private static AnimationCurve CloneCurve(AnimationCurve source) + { + if (source == null || source.length == 0) { + return AnimationCurve.Linear(0f, 0f, 1f, 1f); + } + return new AnimationCurve(source.keys) { + preWrapMode = source.preWrapMode, + postWrapMode = source.postWrapMode, + }; + } + } + + [Serializable] + public sealed class WireRailConnection + { + internal void Restore(List wires) + { + _wires = wires ?? new List(); + _legacyContinuousWires = null; + } + + [SerializeField] private List _wires = new(); + [SerializeField, HideInInspector, FormerlySerializedAs("_continuousWires")] + private List _legacyContinuousWires; + + public int WireCount => _wires?.Count ?? 0; + + public bool IsWireOverridden(int wireIndex) + => GetWire(wireIndex).Overridden; + + public bool IsWireContinuous(int wireIndex) + => GetWire(wireIndex).Continuous; + + public AnimationCurve GetWireCurve(int wireIndex) + => GetWire(wireIndex).Curve; + + internal bool EnsureInitialized(int wireCount) + { + if (wireCount < 0) { + throw new ArgumentOutOfRangeException(nameof(wireCount)); + } + + var changed = false; + _wires ??= new List(); + if (_wires.Count == 0 && _legacyContinuousWires is { Count: > 0 }) { + foreach (var continuous in _legacyContinuousWires) { + _wires.Add(WireRailTransition.FromLegacy(continuous)); + } + _legacyContinuousWires = null; + changed = true; + } + while (_wires.Count < wireCount) { + _wires.Add(new WireRailTransition()); + changed = true; + } + if (_wires.Count > wireCount) { + _wires.RemoveRange(wireCount, _wires.Count - wireCount); + changed = true; + } + for (var wireIndex = 0; wireIndex < _wires.Count; wireIndex++) { + if (_wires[wireIndex] == null) { + _wires[wireIndex] = new WireRailTransition(); + changed = true; + } + changed |= _wires[wireIndex].EnsureInitialized(); + } + return changed; + } + + internal void SetWireOverridden(int wireIndex, bool overridden) + => GetWire(wireIndex).SetOverridden(overridden); + + internal void SetWireContinuous(int wireIndex, bool continuous) + => GetWire(wireIndex).SetContinuous(continuous); + + internal void SetWireCurve(int wireIndex, AnimationCurve curve) + => GetWire(wireIndex).SetCurve(curve); + + internal float EvaluateWireTransition(int wireIndex, float curveT) + => GetWire(wireIndex).Evaluate(curveT); + + internal WireRailConnection Clone() + { + var clone = new WireRailConnection { + _wires = new List(), + }; + if (_wires != null) { + foreach (var wire in _wires) { + clone._wires.Add(wire?.Clone() ?? new WireRailTransition()); + } + } + return clone; + } + + private WireRailTransition GetWire(int wireIndex) + { + if (wireIndex < 0 || wireIndex >= WireCount) { + throw new ArgumentOutOfRangeException(nameof(wireIndex)); + } + return _wires[wireIndex]; + } + } + + [Serializable] + public sealed class WireRailSegment + { + internal void Restore(float distance, WireRailThirdRailSide thirdRailSide, + List railOffsets, List activeRails, List wireDiameters, + WireRailConnection connectionToNext) + { + _distance = math.max(0f, distance); + _thirdRailSide = thirdRailSide; + _railOffsets = railOffsets ?? new List(); + _activeRails = activeRails ?? new List(); + _wireDiameters = wireDiameters ?? new List(); + _connectionToNext = connectionToNext ?? new WireRailConnection(); + } + + [SerializeField, Min(0f)] private float _distance; + [SerializeField] private WireRailThirdRailSide _thirdRailSide = WireRailThirdRailSide.Right; + [SerializeField] private List _railOffsets = new( + WireRailLayout.CreateDefaultOffsets(4)); + [SerializeField] private List _activeRails = new(); + [SerializeField] private List _wireDiameters = new(); + [SerializeField] private WireRailConnection _connectionToNext = new(); + + public float Distance => _distance; + public WireRailThirdRailSide ThirdRailSide => _thirdRailSide; + public int RailCount => _railOffsets?.Count ?? 0; + public IReadOnlyList RailOffsets => _railOffsets; + public IReadOnlyList WireDiameters => _wireDiameters; + public WireRailConnection ConnectionToNext => _connectionToNext; + + internal void SetDistance(float distance, float splineLength) + { + _distance = math.clamp(distance, 0f, math.max(0f, splineLength)); + } + + public Vector2 GetRailOffset(int railIndex) + { + EnsureInitialized(WireRailLayout.ReferenceWireDiameter); + return _railOffsets[railIndex]; + } + + public float GetWireDiameter(int railIndex) + { + EnsureInitialized(WireRailLayout.ReferenceWireDiameter); + return _wireDiameters[railIndex]; + } + + public bool IsRailActive(int railIndex) + { + EnsureInitialized(WireRailLayout.ReferenceWireDiameter); + return _activeRails[railIndex]; + } + + internal bool ResizeRailCount(int railCount, float defaultWireDiameter, + bool activateAddedRails, bool updateRecommendedLayout) + { + EnsureInitialized(defaultWireDiameter); + if (railCount < 1) { + throw new ArgumentOutOfRangeException(nameof(railCount)); + } + if (railCount == RailCount) { + return false; + } + + var previousDefaults = WireRailLayout.CreateDefaultOffsets(RailCount, + _thirdRailSide); + var usesRecommendedLayout = updateRecommendedLayout + && _railOffsets.SequenceEqual(previousDefaults); + var nextDefaults = WireRailLayout.CreateDefaultOffsets(railCount, _thirdRailSide); + var retainedCount = math.min(RailCount, railCount); + if (usesRecommendedLayout) { + for (var railIndex = 0; railIndex < retainedCount; railIndex++) { + _railOffsets[railIndex] = nextDefaults[railIndex]; + } + } + while (_railOffsets.Count < railCount) { + var railIndex = _railOffsets.Count; + _railOffsets.Add(nextDefaults[railIndex]); + _wireDiameters.Add(math.max(0.1f, defaultWireDiameter)); + _activeRails.Add(activateAddedRails); + } + if (_railOffsets.Count > railCount) { + _railOffsets.RemoveRange(railCount, _railOffsets.Count - railCount); + _wireDiameters.RemoveRange(railCount, _wireDiameters.Count - railCount); + _activeRails.RemoveRange(railCount, _activeRails.Count - railCount); + } + return true; + } + + internal void SetThirdRailSide(WireRailThirdRailSide side) + { + if (_thirdRailSide == side) { + return; + } + _thirdRailSide = side; + if (RailCount == 3) { + ResetLayout(); + } + } + + internal void SetRailOffset(int railIndex, Vector2 offset) + { + EnsureInitialized(WireRailLayout.ReferenceWireDiameter); + _railOffsets[railIndex] = offset; + } + + internal void SetRailActive(int railIndex, bool active) + { + EnsureInitialized(WireRailLayout.ReferenceWireDiameter); + _activeRails[railIndex] = active; + } + + internal bool SetAllWireDiameters(float diameter) + { + diameter = math.max(0.1f, diameter); + var changed = false; + for (var railIndex = 0; railIndex < _wireDiameters.Count; railIndex++) { + if (Mathf.Approximately(_wireDiameters[railIndex], diameter)) { + continue; + } + _wireDiameters[railIndex] = diameter; + changed = true; + } + return changed; + } + + internal void ResetLayout() + { + EnsureInitialized(WireRailLayout.ReferenceWireDiameter); + _railOffsets = new List( + WireRailLayout.CreateDefaultOffsets(_railOffsets.Count, _thirdRailSide)); + } + + internal bool EnsureInitialized(float defaultWireDiameter) + { + var changed = false; + if (_connectionToNext == null) { + _connectionToNext = new WireRailConnection(); + changed = true; + } + if (_railOffsets == null || _railOffsets.Count == 0) { + _railOffsets = new List( + WireRailLayout.CreateDefaultOffsets(4, _thirdRailSide)); + changed = true; + } + _wireDiameters ??= new List(); + _activeRails ??= new List(); + while (_wireDiameters.Count < _railOffsets.Count) { + _wireDiameters.Add(math.max(0.1f, defaultWireDiameter)); + changed = true; + } + if (_wireDiameters.Count > _railOffsets.Count) { + _wireDiameters.RemoveRange(_railOffsets.Count, + _wireDiameters.Count - _railOffsets.Count); + changed = true; + } + while (_activeRails.Count < _railOffsets.Count) { + _activeRails.Add(true); + changed = true; + } + if (_activeRails.Count > _railOffsets.Count) { + _activeRails.RemoveRange(_railOffsets.Count, + _activeRails.Count - _railOffsets.Count); + changed = true; + } + for (var i = 0; i < _wireDiameters.Count; i++) { + var clamped = math.max(0.1f, _wireDiameters[i]); + if (!Mathf.Approximately(clamped, _wireDiameters[i])) { + _wireDiameters[i] = clamped; + changed = true; + } + } + return changed; + } + + internal WireRailSegment Clone(float defaultWireDiameter) + { + EnsureInitialized(defaultWireDiameter); + return new WireRailSegment { + _distance = _distance, + _thirdRailSide = _thirdRailSide, + _railOffsets = new List(_railOffsets), + _activeRails = new List(_activeRails), + _wireDiameters = new List(_wireDiameters), + _connectionToNext = _connectionToNext.Clone(), + }; + } + + internal bool EnsureConnectionInitialized(int wireCount) + { + var changed = false; + if (_connectionToNext == null) { + _connectionToNext = new WireRailConnection(); + changed = true; + } + return _connectionToNext.EnsureInitialized(wireCount) || changed; + } + + internal void ResetConnection() + { + _connectionToNext = new WireRailConnection(); + } + + internal void CopyConnectionFrom(WireRailSegment source) + { + _connectionToNext = source?._connectionToNext?.Clone() + ?? new WireRailConnection(); + } + } + + [Serializable] + public abstract class WireRailFixture + { + internal void RestoreCommon(float distance, float solderThreshold, float solderSize, + bool enabled) + { + _distance = math.max(0f, distance); + _solderThreshold = math.max(0f, solderThreshold); + _solderSize = math.max(0.01f, solderSize); + _enabled = enabled; + } + + public const float DefaultSolderThreshold = WireRailLayout.ReferenceWireDiameter * 0.25f; + public const float DefaultSolderSize = 1f; + + [SerializeField, Min(0f)] private float _distance; + [SerializeField, Min(0f)] private float _solderThreshold = DefaultSolderThreshold; + [SerializeField, Min(0.01f)] private float _solderSize = DefaultSolderSize; + // Serialize-reference fixtures run field initializers on load, so pre-existing + // fixtures deserialize as enabled. Disabling only hides the render mesh; colliders + // are unaffected. + [SerializeField] private bool _enabled = true; + + public float Distance => _distance; + public float SolderThreshold => _solderThreshold; + public float SolderSize => _solderSize; + public bool Enabled => _enabled; + + internal void SetEnabled(bool enabled) + { + _enabled = enabled; + } + + internal bool EnsureInitialized(float splineLength) + { + var changed = false; + var clampedDistance = math.clamp(_distance, 0f, math.max(0f, splineLength)); + if (!Mathf.Approximately(_distance, clampedDistance)) { + _distance = clampedDistance; + changed = true; + } + var solderThreshold = math.max(0f, _solderThreshold); + if (!Mathf.Approximately(_solderThreshold, solderThreshold)) { + _solderThreshold = solderThreshold; + changed = true; + } + var solderSize = _solderSize <= 0f + ? DefaultSolderSize : math.max(0.01f, _solderSize); + if (!Mathf.Approximately(_solderSize, solderSize)) { + _solderSize = solderSize; + changed = true; + } + return changed; + } + + internal void SetDistance(float distance, float splineLength) + { + _distance = math.clamp(distance, 0f, math.max(0f, splineLength)); + } + + internal void SetSolderThreshold(float solderThreshold) + { + _solderThreshold = math.max(0f, solderThreshold); + } + + internal void SetSolderSize(float solderSize) + { + _solderSize = math.max(0.01f, solderSize); + } + } + + [Serializable] + [MovedFrom(true, sourceClassName: "WireRailBraceFixture")] + public sealed class WireRailRingFixture : WireRailFixture + { + internal void Restore(float diameter, bool hasCutout, float cutoutStartAngle, + float cutoutEndAngle, bool hasStraightSection, float straightStartAngle, + float straightEndAngle, float lateralOffset, float verticalOffset, float scale, + int ringDensity) + { + _diameter = math.max(0.1f, diameter); + _hasCutout = hasCutout; + _cutoutStartAngle = cutoutStartAngle; + _cutoutEndAngle = cutoutEndAngle; + _hasStraightSection = hasStraightSection; + _straightStartAngle = straightStartAngle; + _straightEndAngle = straightEndAngle; + _lateralOffset = lateralOffset; + _verticalOffset = verticalOffset; + _scale = math.max(0.1f, scale); + _ringDensity = math.clamp(ringDensity, 3, 128); + _radiusOffset = 0f; + _scaleInitialized = true; + } + + public const int DefaultRingDensity = 32; + public const float DefaultCutoutStartAngle = 60f; + public const float DefaultCutoutEndAngle = 120f; + public const float DefaultStraightStartAngle = 210f; + public const float DefaultStraightEndAngle = 330f; + + [SerializeField, Min(0.1f)] private float _diameter = WireRailLayout.ReferenceWireDiameter; + [SerializeField] private bool _hasCutout; + [SerializeField, Range(0f, 360f)] private float _cutoutStartAngle = DefaultCutoutStartAngle; + [SerializeField, Range(0f, 360f)] private float _cutoutEndAngle = DefaultCutoutEndAngle; + [SerializeField] private bool _hasStraightSection; + [SerializeField, Range(0f, 360f)] private float _straightStartAngle = DefaultStraightStartAngle; + [SerializeField, Range(0f, 360f)] private float _straightEndAngle = DefaultStraightEndAngle; + [SerializeField] private float _lateralOffset; + [SerializeField] private float _verticalOffset; + [SerializeField, Min(0.1f)] private float _scale = 1f; + [SerializeField, Range(3, 128)] private int _ringDensity = DefaultRingDensity; + [SerializeField, HideInInspector] private float _radiusOffset; + [SerializeField, HideInInspector] private bool _scaleInitialized; + + public float Diameter => _diameter; + public bool HasCutout => _hasCutout; + public float CutoutStartAngle => _cutoutStartAngle; + public float CutoutEndAngle => _cutoutEndAngle; + public bool HasStraightSection => _hasStraightSection; + public float StraightStartAngle => _straightStartAngle; + public float StraightEndAngle => _straightEndAngle; + public float LateralOffset => _lateralOffset; + public float VerticalOffset => _verticalOffset; + public float Scale => _scale; + public int RingDensity => _ringDensity; + internal bool ScaleInitialized => _scaleInitialized; + + public static Vector2 AlignAngleRangeHorizontally(float startAngle, float endAngle) + { + startAngle = math.clamp(startAngle, 0f, 360f); + endAngle = math.clamp(endAngle, 0f, 360f); + var rawSweep = endAngle - startAngle; + if (math.abs(rawSweep) >= 359.999f) { + return new Vector2(startAngle, endAngle); + } + var sweep = math.fmod(rawSweep + 360f, 360f); + if (sweep <= 0.001f) { + return new Vector2(startAngle, endAngle); + } + var halfSweep = sweep * 0.5f; + var center = Mathf.Repeat(startAngle + halfSweep, 360f); + var alignedCenter = math.abs(Mathf.DeltaAngle(center, 90f)) + <= math.abs(Mathf.DeltaAngle(center, 270f)) ? 90f : 270f; + var rawStart = alignedCenter - halfSweep; + var rawEnd = alignedCenter + halfSweep; + var alignedStart = Mathf.Repeat(rawStart, 360f); + var alignedEnd = Mathf.Repeat(rawEnd, 360f); + if (Mathf.Approximately(alignedEnd, 0f) && rawEnd > 0f) { + alignedEnd = 360f; + } + return new Vector2(alignedStart, alignedEnd); + } + + internal bool EnsureRingInitialized(float splineLength) + { + var changed = EnsureInitialized(splineLength); + var diameter = math.max(0.1f, _diameter); + var startAngle = math.clamp(_cutoutStartAngle, 0f, 360f); + var endAngle = math.clamp(_cutoutEndAngle, 0f, 360f); + var straightStartAngle = math.clamp(_straightStartAngle, 0f, 360f); + var straightEndAngle = math.clamp(_straightEndAngle, 0f, 360f); + var scale = math.max(0.1f, _scale); + var ringDensity = math.clamp(_ringDensity <= 0 ? DefaultRingDensity : _ringDensity, + 3, 128); + if (!Mathf.Approximately(_diameter, diameter)) { + _diameter = diameter; + changed = true; + } + if (!Mathf.Approximately(_cutoutStartAngle, startAngle)) { + _cutoutStartAngle = startAngle; + changed = true; + } + if (!Mathf.Approximately(_cutoutEndAngle, endAngle)) { + _cutoutEndAngle = endAngle; + changed = true; + } + if (!Mathf.Approximately(_straightStartAngle, straightStartAngle)) { + _straightStartAngle = straightStartAngle; + changed = true; + } + if (!Mathf.Approximately(_straightEndAngle, straightEndAngle)) { + _straightEndAngle = straightEndAngle; + changed = true; + } + if (!Mathf.Approximately(_scale, scale)) { + _scale = scale; + changed = true; + } + if (_ringDensity != ringDensity) { + _ringDensity = ringDensity; + changed = true; + } + return changed; + } + + internal bool EnsureScaleInitialized(float baseRadius) + { + if (_scaleInitialized) { + return false; + } + var migratedRadius = math.max(_diameter * 0.5f, baseRadius + _radiusOffset); + _scale = baseRadius > 1e-5f + ? math.max(0.1f, migratedRadius / baseRadius) : 1f; + _radiusOffset = 0f; + _scaleInitialized = true; + return true; + } + + internal bool SetDiameter(float diameter) + { + diameter = math.max(0.1f, diameter); + if (Mathf.Approximately(_diameter, diameter)) { + return false; + } + _diameter = diameter; + return true; + } + + internal void SetProperties(float distance, float splineLength, float diameter, + bool hasCutout, float cutoutStartAngle, float cutoutEndAngle, + bool hasStraightSection, float straightStartAngle, float straightEndAngle, + float lateralOffset, float verticalOffset, float scale, + int ringDensity = DefaultRingDensity) + { + SetDistance(distance, splineLength); + _diameter = math.max(0.1f, diameter); + _hasCutout = hasCutout; + _cutoutStartAngle = math.clamp(cutoutStartAngle, 0f, 360f); + _cutoutEndAngle = math.clamp(cutoutEndAngle, 0f, 360f); + _hasStraightSection = hasStraightSection; + _straightStartAngle = math.clamp(straightStartAngle, 0f, 360f); + _straightEndAngle = math.clamp(straightEndAngle, 0f, 360f); + _lateralOffset = lateralOffset; + _verticalOffset = verticalOffset; + _scale = math.max(0.1f, scale); + _ringDensity = math.clamp(ringDensity, 3, 128); + _radiusOffset = 0f; + _scaleInitialized = true; + } + + public bool TryGetStraightSection(out float startAngle, out float sweepAngle) + { + startAngle = math.radians(_straightStartAngle); + var rawSweep = _straightEndAngle - _straightStartAngle; + var sweepDegrees = math.abs(rawSweep) >= 359.999f + ? 360f + : math.fmod(rawSweep + 360f, 360f); + sweepAngle = math.radians(sweepDegrees); + return _hasStraightSection && sweepDegrees > 0.001f && sweepDegrees < 359.999f; + } + + public float2 EvaluateCenterlineOffset(float angle, float radius) + { + var circular = new float2(math.cos(angle), math.sin(angle)) * radius; + if (!TryGetStraightSection(out var startAngle, out var sweepAngle)) { + return circular; + } + var progress = math.fmod(angle - startAngle + math.PI * 4f, math.PI * 2f); + if (progress > sweepAngle) { + return circular; + } + var start = new float2(math.cos(startAngle), math.sin(startAngle)) * radius; + var endAngle = startAngle + sweepAngle; + var end = new float2(math.cos(endAngle), math.sin(endAngle)) * radius; + return math.lerp(start, end, progress / sweepAngle); + } + + public float2 EvaluateCenterlineTangent(float angle) + { + var circular = new float2(-math.sin(angle), math.cos(angle)); + if (!TryGetStraightSection(out var startAngle, out var sweepAngle)) { + return circular; + } + var progress = math.fmod(angle - startAngle + math.PI * 4f, math.PI * 2f); + if (progress > sweepAngle) { + return circular; + } + var start = new float2(math.cos(startAngle), math.sin(startAngle)); + var endAngle = startAngle + sweepAngle; + var end = new float2(math.cos(endAngle), math.sin(endAngle)); + return math.normalizesafe(end - start, circular); + } + + public bool TryGetVisibleArc(out float startAngle, out float sweepAngle, + out bool closed) + { + startAngle = 0f; + sweepAngle = math.PI * 2f; + closed = true; + if (!_hasCutout) { + return true; + } + + var rawCutoutSweep = _cutoutEndAngle - _cutoutStartAngle; + var cutoutSweep = math.abs(rawCutoutSweep) >= 359.999f + ? 360f + : math.fmod(rawCutoutSweep + 360f, 360f); + if (cutoutSweep <= 0.001f) { + return true; + } + + var visibleSweep = 360f - cutoutSweep; + if (visibleSweep <= 0.001f) { + return false; + } + startAngle = math.radians(_cutoutEndAngle); + sweepAngle = math.radians(visibleSweep); + closed = false; + return true; + } + } + + /// + /// A bottom rung with independently optional rounded arms. The historic type name is + /// retained so existing managed-reference fixture data remains loadable. + /// + [Serializable] + [MovedFrom(true, sourceClassName: "WireRailVBraceFixture")] + public sealed class WireRailCradleFixture : WireRailFixture + { + internal void Restore(float diameter, int ringDensity, float lateralOffset, + float verticalOffset, float bottomLength, float leftLength, float rightLength, + float angle, float rotation, float cornerRadius) + { + _diameter = math.max(0.1f, diameter); + _ringDensity = math.clamp(ringDensity, 3, 128); + _lateralOffset = lateralOffset; + _verticalOffset = verticalOffset; + _bottomLength = math.max(0.1f, bottomLength); + _leftLength = math.max(0f, leftLength); + _rightLength = math.max(0f, rightLength); + _angle = math.clamp(angle, 1f, 179f); + _rotation = rotation; + _cornerRadius = math.max(0.1f, cornerRadius); + } + + internal const float MaximumCornerSpanFraction = 0.45f; + + public const int DefaultRingDensity = 32; + public const float DefaultBottomLength = 8f; + public const float DefaultLeftLength = 85f; + public const float DefaultRightLength = 85f; + public const float DefaultAngle = 53.130102f; + public const float DefaultRotation = 0f; + public const float DefaultCornerRadius = WireRailLayout.ReferenceWireDiameter; + + [SerializeField, Min(0.1f)] private float _diameter = WireRailLayout.ReferenceWireDiameter; + [SerializeField, Range(3, 128)] private int _ringDensity = DefaultRingDensity; + [SerializeField] private float _lateralOffset; + [SerializeField] private float _verticalOffset; + [SerializeField, Min(0.1f), FormerlySerializedAs("_straightHeight")] + private float _bottomLength = DefaultBottomLength; + [SerializeField, Min(0f)] private float _leftLength = DefaultLeftLength; + [SerializeField, Min(0f)] private float _rightLength = DefaultRightLength; + [SerializeField, Range(1f, 179f)] private float _angle = DefaultAngle; + [SerializeField, Range(0f, 360f)] private float _rotation = DefaultRotation; + [SerializeField, Min(0.1f)] private float _cornerRadius = DefaultCornerRadius; + + public float Diameter => _diameter; + public int RingDensity => _ringDensity; + public float LateralOffset => _lateralOffset; + public float VerticalOffset => _verticalOffset; + public float BottomLength => _bottomLength; + public float LeftLength => _leftLength; + public float RightLength => _rightLength; + public float Angle => _angle; + public float Rotation => _rotation; + public float CornerRadius => _cornerRadius; + + internal bool EnsureCradleInitialized(float splineLength) + { + var changed = EnsureInitialized(splineLength); + var diameter = math.max(0.1f, _diameter); + var ringDensity = math.clamp(_ringDensity <= 0 ? DefaultRingDensity : _ringDensity, + 3, 128); + var leftLength = math.max(0f, _leftLength); + var rightLength = math.max(0f, _rightLength); + var angle = math.clamp(_angle, 1f, 179f); + var minimumRoundedSpan = GetMinimumRoundedSpan(diameter, angle); + leftLength = ClampOptionalArmLength(leftLength, minimumRoundedSpan); + rightLength = ClampOptionalArmLength(rightLength, minimumRoundedSpan); + var bottomLength = math.max(0.1f, _bottomLength); + if (leftLength > 0f || rightLength > 0f) { + bottomLength = math.max(bottomLength, minimumRoundedSpan); + } + var rotation = math.clamp(_rotation, 0f, 360f); + var cornerRadius = math.max(diameter * 0.5f, _cornerRadius); + changed |= SetValue(ref _diameter, diameter); + if (_ringDensity != ringDensity) { + _ringDensity = ringDensity; + changed = true; + } + changed |= SetValue(ref _bottomLength, bottomLength); + changed |= SetValue(ref _leftLength, leftLength); + changed |= SetValue(ref _rightLength, rightLength); + changed |= SetValue(ref _angle, angle); + changed |= SetValue(ref _rotation, rotation); + changed |= SetValue(ref _cornerRadius, cornerRadius); + return changed; + + static bool SetValue(ref float destination, float value) + { + if (Mathf.Approximately(destination, value)) { + return false; + } + destination = value; + return true; + } + } + + internal bool SetDiameter(float diameter) + { + diameter = math.max(0.1f, diameter); + var changed = !Mathf.Approximately(_diameter, diameter); + _diameter = diameter; + var cornerRadius = math.max(_cornerRadius, diameter * 0.5f); + if (!Mathf.Approximately(_cornerRadius, cornerRadius)) { + _cornerRadius = cornerRadius; + changed = true; + } + var minimumRoundedSpan = GetMinimumRoundedSpan(diameter, _angle); + var leftLength = ClampOptionalArmLength(_leftLength, minimumRoundedSpan); + var rightLength = ClampOptionalArmLength(_rightLength, minimumRoundedSpan); + var bottomLength = _bottomLength; + if (leftLength > 0f || rightLength > 0f) { + bottomLength = math.max(bottomLength, minimumRoundedSpan); + } + changed |= SetValue(ref _bottomLength, bottomLength); + changed |= SetValue(ref _leftLength, leftLength); + changed |= SetValue(ref _rightLength, rightLength); + return changed; + + static bool SetValue(ref float destination, float value) + { + if (Mathf.Approximately(destination, value)) { + return false; + } + destination = value; + return true; + } + } + + internal void SetProperties(float distance, float splineLength, float diameter, + int ringDensity, float lateralOffset, float verticalOffset, + float bottomLength, float leftLength, float rightLength, float angle, + float rotation, float cornerRadius) + { + SetDistance(distance, splineLength); + _diameter = math.max(0.1f, diameter); + _ringDensity = math.clamp(ringDensity, 3, 128); + _lateralOffset = lateralOffset; + _verticalOffset = verticalOffset; + _angle = math.clamp(angle, 1f, 179f); + var minimumRoundedSpan = GetMinimumRoundedSpan(_diameter, _angle); + _leftLength = ClampOptionalArmLength(leftLength, minimumRoundedSpan); + _rightLength = ClampOptionalArmLength(rightLength, minimumRoundedSpan); + _bottomLength = math.max(0.1f, bottomLength); + if (_leftLength > 0f || _rightLength > 0f) { + _bottomLength = math.max(_bottomLength, minimumRoundedSpan); + } + _rotation = math.clamp(rotation, 0f, 360f); + _cornerRadius = math.max(_diameter * 0.5f, cornerRadius); + } + + private static float ClampOptionalArmLength(float length, float minimumLength) + => length <= 0f ? 0f : math.max(length, minimumLength); + + private static float GetMinimumRoundedSpan(float diameter, float angle) + { + var cornerAngle = math.radians((180f - angle) * 0.5f); + var tangentScale = math.tan(cornerAngle * 0.5f); + return diameter * 0.5f * tangentScale / MaximumCornerSpanFraction; + } + } + + [Serializable] + [MovedFrom(true, sourceClassName: "WireRailCrossWireFixture")] + public sealed class WireRailRungFixture : WireRailFixture + { + internal void Restore(float diameter, int startRailIndex, int endRailIndex, float angle, + float lateralOffset, float verticalOffset, float lengthAdjustment) + { + _diameter = math.max(0.1f, diameter); + _startRailIndex = math.max(0, startRailIndex); + _endRailIndex = math.max(0, endRailIndex); + _angle = angle; + _lateralOffset = lateralOffset; + _verticalOffset = verticalOffset; + _lengthAdjustment = lengthAdjustment; + } + + public const float DefaultAngle = 0f; + + [SerializeField, Min(0.1f)] private float _diameter = WireRailLayout.ReferenceWireDiameter; + [SerializeField, HideInInspector, Min(0)] private int _startRailIndex; + [SerializeField, HideInInspector, Min(0)] private int _endRailIndex = 1; + [SerializeField, Range(0f, 360f)] + private float _angle = DefaultAngle; + [SerializeField] private float _lateralOffset; + [SerializeField] private float _verticalOffset; + [SerializeField] private float _lengthAdjustment; + + public float Diameter => _diameter; + public int StartRailIndex => _startRailIndex; + public int EndRailIndex => _endRailIndex; + public float Angle => _angle; + public float LateralOffset => _lateralOffset; + public float VerticalOffset => _verticalOffset; + public float LengthAdjustment => _lengthAdjustment; + + internal bool EnsureRungInitialized(float splineLength) + { + var changed = EnsureInitialized(splineLength); + var diameter = math.max(0.1f, _diameter); + var startRailIndex = math.max(0, _startRailIndex); + var endRailIndex = math.max(0, _endRailIndex); + var angle = math.clamp(_angle, 0f, 360f); + if (!Mathf.Approximately(_diameter, diameter)) { + _diameter = diameter; + changed = true; + } + if (_startRailIndex != startRailIndex) { + _startRailIndex = startRailIndex; + changed = true; + } + if (_endRailIndex != endRailIndex) { + _endRailIndex = endRailIndex; + changed = true; + } + if (!Mathf.Approximately(_angle, angle)) { + _angle = angle; + changed = true; + } + return changed; + } + + internal bool SetDiameter(float diameter) + { + diameter = math.max(0.1f, diameter); + if (Mathf.Approximately(_diameter, diameter)) { + return false; + } + _diameter = diameter; + return true; + } + + internal void SetProperties(float distance, float splineLength, float diameter, + int startRailIndex, int endRailIndex, float angle, + float lateralOffset, float verticalOffset, float lengthAdjustment) + { + SetDistance(distance, splineLength); + _diameter = math.max(0.1f, diameter); + _startRailIndex = math.max(0, startRailIndex); + _endRailIndex = math.max(0, endRailIndex); + _angle = math.clamp(angle, 0f, 360f); + _lateralOffset = lateralOffset; + _verticalOffset = verticalOffset; + _lengthAdjustment = lengthAdjustment; + } + } + + /// + /// An endpoint-only fitting that joins two rails with outward leads and a terminal + /// semicircle. The terminal arc is emitted into a separate collider submesh. + /// + [Serializable] + [MovedFrom(true, sourceClassName: "WireRailDropLoopFixture")] + public sealed class WireRailHairpinFixture : WireRailFixture + { + internal void Restore(float diameter, WireRailEndpoint endpoint, int firstRailIndex, + int secondRailIndex, float loopDiameter, float leadLength, float tangentLength, + int ringDensity, float railOffset, float rotation) + { + _diameter = math.max(0.1f, diameter); + _endpoint = endpoint; + _firstRailIndex = math.max(0, firstRailIndex); + _secondRailIndex = math.max(0, secondRailIndex); + _loopDiameter = math.max(0.1f, loopDiameter); + _leadLength = math.max(0f, leadLength); + _tangentLength = math.max(0f, tangentLength); + _ringDensity = math.clamp(ringDensity, 4, 128); + _railOffset = math.max(0f, railOffset); + _rotation = rotation; + } + + public const float DefaultLoopDiameter = 60f; + public const float DefaultLeadLength = 40f; + public const float DefaultTangentLength = 15f; + public const int DefaultRingDensity = 24; + + [SerializeField, Min(0.1f)] private float _diameter = WireRailLayout.ReferenceWireDiameter; + [SerializeField] private WireRailEndpoint _endpoint = WireRailEndpoint.End; + [SerializeField, Min(0)] private int _firstRailIndex; + [SerializeField, Min(0)] private int _secondRailIndex = 1; + [SerializeField, Min(0.1f)] private float _loopDiameter = DefaultLoopDiameter; + [SerializeField, Min(0f)] private float _leadLength = DefaultLeadLength; + [SerializeField, Min(0f)] private float _tangentLength = DefaultTangentLength; + [SerializeField, Range(4, 128)] private int _ringDensity = DefaultRingDensity; + [SerializeField] private float _railOffset; + [SerializeField, Range(0f, 360f)] private float _rotation; + + public float Diameter => _diameter; + public WireRailEndpoint Endpoint => _endpoint; + public int FirstRailIndex => _firstRailIndex; + public int SecondRailIndex => _secondRailIndex; + public float LoopDiameter => _loopDiameter; + public float LeadLength => _leadLength; + public float TangentLength => _tangentLength; + public int RingDensity => _ringDensity; + public float RailOffset => _railOffset; + public float Rotation => _rotation; + + internal bool EnsureHairpinInitialized(float splineLength) + { + var changed = EnsureInitialized(splineLength); + var endpoint = _endpoint == WireRailEndpoint.Start + ? WireRailEndpoint.Start : WireRailEndpoint.End; + if (_endpoint != endpoint) { + _endpoint = endpoint; + changed = true; + } + var endpointDistance = _endpoint == WireRailEndpoint.Start ? 0f : splineLength; + if (!Mathf.Approximately(Distance, endpointDistance)) { + SetDistance(endpointDistance, splineLength); + changed = true; + } + changed |= SetValue(ref _diameter, math.max(0.1f, _diameter)); + var firstRailIndex = math.max(0, _firstRailIndex); + var secondRailIndex = math.max(0, _secondRailIndex); + if (_firstRailIndex != firstRailIndex) { + _firstRailIndex = firstRailIndex; + changed = true; + } + if (_secondRailIndex != secondRailIndex) { + _secondRailIndex = secondRailIndex; + changed = true; + } + changed |= SetValue(ref _loopDiameter, math.max(0.1f, _loopDiameter)); + changed |= SetValue(ref _leadLength, math.max(0f, _leadLength)); + changed |= SetValue(ref _tangentLength, math.max(0f, _tangentLength)); + changed |= SetValue(ref _railOffset, + math.clamp(_railOffset, 0f, math.max(0f, splineLength))); + var ringDensity = math.clamp(_ringDensity <= 0 ? DefaultRingDensity : _ringDensity, + 4, 128); + if (_ringDensity != ringDensity) { + _ringDensity = ringDensity; + changed = true; + } + changed |= SetValue(ref _rotation, math.clamp(_rotation, 0f, 360f)); + return changed; + + static bool SetValue(ref float destination, float value) + { + if (Mathf.Approximately(destination, value)) { + return false; + } + destination = value; + return true; + } + } + + internal bool SetDiameter(float diameter) + { + diameter = math.max(0.1f, diameter); + if (Mathf.Approximately(_diameter, diameter)) { + return false; + } + _diameter = diameter; + return true; + } + + internal void SetProperties(float splineLength, float diameter, + WireRailEndpoint endpoint, int firstRailIndex, int secondRailIndex, + float loopDiameter, float leadLength, float tangentLength, int ringDensity, + float railOffset, float rotation) + { + _endpoint = endpoint == WireRailEndpoint.Start + ? WireRailEndpoint.Start : WireRailEndpoint.End; + SetDistance(endpoint == WireRailEndpoint.Start ? 0f : splineLength, splineLength); + _diameter = math.max(0.1f, diameter); + _firstRailIndex = math.max(0, firstRailIndex); + _secondRailIndex = math.max(0, secondRailIndex); + _loopDiameter = math.max(0.1f, loopDiameter); + _leadLength = math.max(0f, leadLength); + _tangentLength = math.max(0f, tangentLength); + _ringDensity = math.clamp(ringDensity, 4, 128); + _railOffset = math.clamp(railOffset, 0f, math.max(0f, splineLength)); + _rotation = math.clamp(rotation, 0f, 360f); + } + } + + /// + /// An endpoint-only fitting that continues two selected rails past the spline and + /// then bends them vertically down. The remaining rails can be shortened independently. + /// + [Serializable] + [MovedFrom(true, sourceClassName: "WireRailDropFixture")] + public sealed class WireRailElbowFixture : WireRailFixture + { + internal void Restore(float diameter, WireRailEndpoint endpoint, int firstRailIndex, + int secondRailIndex, float offset, float dropLength, float zAngle, + List railOffsets) + { + _diameter = math.max(0.1f, diameter); + _endpoint = endpoint; + _firstRailIndex = math.max(0, firstRailIndex); + _secondRailIndex = math.max(0, secondRailIndex); + _offset = math.max(0f, offset); + _dropLength = math.max(0.1f, dropLength); + _zAngle = zAngle; + _railOffsets = railOffsets ?? new List(); + _railPairInitialized = true; + } + + public const float DefaultOffset = 40f; + public const float DefaultDropLength = 80f; + + [SerializeField, Min(0.1f)] private float _diameter = WireRailLayout.ReferenceWireDiameter; + [SerializeField] private WireRailEndpoint _endpoint = WireRailEndpoint.End; + [SerializeField, Min(0)] private int _firstRailIndex; + [SerializeField, Min(0)] private int _secondRailIndex = 1; + // Moves the elbow inward from the spline endpoint, shortening the two rails. Zero drops + // exactly at the endpoint. + [FormerlySerializedAs("_distanceToHole")] + [SerializeField, Min(0f)] private float _offset = DefaultOffset; + [SerializeField, Min(0.1f)] private float _dropLength = DefaultDropLength; + [SerializeField] private float _zAngle; + [SerializeField] private List _railOffsets = new(); + [SerializeField, HideInInspector] private bool _railPairInitialized; + + public float Diameter => _diameter; + public WireRailEndpoint Endpoint => _endpoint; + public int FirstRailIndex => _firstRailIndex; + public int SecondRailIndex => _secondRailIndex; + public float Offset => _offset; + public float DropLength => _dropLength; + public float ZAngle => _zAngle; + public int RailCount => _railOffsets?.Count ?? 0; + public IReadOnlyList RailOffsets => _railOffsets; + internal bool RailPairInitialized => _railPairInitialized; + + public bool IsAttachedRail(int railIndex) + => railIndex == _firstRailIndex || railIndex == _secondRailIndex; + + public float GetRailOffset(int railIndex) + { + if (_railOffsets == null || railIndex < 0 || railIndex >= _railOffsets.Count) { + throw new ArgumentOutOfRangeException(nameof(railIndex)); + } + return _railOffsets[railIndex]; + } + + internal bool EnsureElbowInitialized(float splineLength, int railCount) + { + var changed = EnsureInitialized(splineLength); + var endpoint = _endpoint == WireRailEndpoint.Start + ? WireRailEndpoint.Start : WireRailEndpoint.End; + if (_endpoint != endpoint) { + _endpoint = endpoint; + changed = true; + } + var endpointDistance = _endpoint == WireRailEndpoint.Start ? 0f : splineLength; + if (!Mathf.Approximately(Distance, endpointDistance)) { + SetDistance(endpointDistance, splineLength); + changed = true; + } + changed |= SetValue(ref _diameter, math.max(0.1f, _diameter)); + var maximumRailIndex = math.max(0, railCount - 1); + var firstRailIndex = math.clamp(_firstRailIndex, 0, maximumRailIndex); + var secondRailIndex = math.clamp(_secondRailIndex, 0, maximumRailIndex); + if (_firstRailIndex != firstRailIndex) { + _firstRailIndex = firstRailIndex; + changed = true; + } + if (_secondRailIndex != secondRailIndex) { + _secondRailIndex = secondRailIndex; + changed = true; + } + changed |= SetValue(ref _offset, + math.clamp(_offset, 0f, math.max(0f, splineLength))); + changed |= SetValue(ref _dropLength, math.max(0.1f, _dropLength)); + _railOffsets ??= new List(); + while (_railOffsets.Count < railCount) { + _railOffsets.Add(0f); + changed = true; + } + if (_railOffsets.Count > railCount) { + _railOffsets.RemoveRange(railCount, _railOffsets.Count - railCount); + changed = true; + } + for (var railIndex = 0; railIndex < _railOffsets.Count; railIndex++) { + var offset = IsAttachedRail(railIndex) ? 0f : math.clamp( + _railOffsets[railIndex], 0f, math.max(0f, splineLength)); + if (Mathf.Approximately(_railOffsets[railIndex], offset)) { + continue; + } + _railOffsets[railIndex] = offset; + changed = true; + } + return changed; + + static bool SetValue(ref float destination, float value) + { + if (Mathf.Approximately(destination, value)) { + return false; + } + destination = value; + return true; + } + } + + internal bool SetDiameter(float diameter) + { + diameter = math.max(0.1f, diameter); + if (Mathf.Approximately(_diameter, diameter)) { + return false; + } + _diameter = diameter; + return true; + } + + internal bool EnsureRailPairInitialized(int firstRailIndex, int secondRailIndex) + { + if (_railPairInitialized) { + return false; + } + _firstRailIndex = math.max(0, firstRailIndex); + _secondRailIndex = math.max(0, secondRailIndex); + if (_railOffsets != null) { + if (_firstRailIndex < _railOffsets.Count) { + _railOffsets[_firstRailIndex] = 0f; + } + if (_secondRailIndex < _railOffsets.Count) { + _railOffsets[_secondRailIndex] = 0f; + } + } + _railPairInitialized = true; + return true; + } + + internal void SetProperties(float splineLength, int railCount, float diameter, + WireRailEndpoint endpoint, int firstRailIndex, int secondRailIndex, + float offset, float dropLength, float zAngle, + IReadOnlyList railOffsets) + { + if (railOffsets == null) { + throw new ArgumentNullException(nameof(railOffsets)); + } + _endpoint = endpoint == WireRailEndpoint.Start + ? WireRailEndpoint.Start : WireRailEndpoint.End; + SetDistance(_endpoint == WireRailEndpoint.Start ? 0f : splineLength, splineLength); + _diameter = math.max(0.1f, diameter); + var maximumRailIndex = math.max(0, railCount - 1); + _firstRailIndex = math.clamp(firstRailIndex, 0, maximumRailIndex); + _secondRailIndex = math.clamp(secondRailIndex, 0, maximumRailIndex); + _offset = math.clamp(offset, 0f, math.max(0f, splineLength)); + _dropLength = math.max(0.1f, dropLength); + _zAngle = zAngle; + _railPairInitialized = true; + // Snapshot the incoming cutoffs first: callers may pass this fixture's own + // RailOffsets list, which Clear() below would otherwise empty before we read it. + var incoming = new float[railCount]; + for (var railIndex = 0; railIndex < railCount; railIndex++) { + incoming[railIndex] = railIndex < railOffsets.Count ? railOffsets[railIndex] : 0f; + } + _railOffsets ??= new List(); + _railOffsets.Clear(); + for (var railIndex = 0; railIndex < railCount; railIndex++) { + _railOffsets.Add(IsAttachedRail(railIndex) + ? 0f : math.clamp(incoming[railIndex], 0f, math.max(0f, splineLength))); + } + } + } + + /// + /// An endpoint-only fitting that moves each rail's visible and collidable start or end + /// independently inward along the complete route. + /// + [Serializable] + public sealed class WireRailTrimFixture : WireRailFixture + { + internal void Restore(WireRailEndpoint endpoint, List railOffsets) + { + _endpoint = endpoint; + _railOffsets = railOffsets ?? new List(); + } + + [SerializeField] private WireRailEndpoint _endpoint = WireRailEndpoint.End; + [SerializeField] private List _railOffsets = new(); + + public WireRailEndpoint Endpoint => _endpoint; + public int RailCount => _railOffsets?.Count ?? 0; + public IReadOnlyList RailOffsets => _railOffsets; + + public float GetRailOffset(int railIndex) + { + if (_railOffsets == null || railIndex < 0 || railIndex >= _railOffsets.Count) { + throw new ArgumentOutOfRangeException(nameof(railIndex)); + } + return _railOffsets[railIndex]; + } + + internal bool EnsureRailTrimInitialized(float splineLength, int railCount) + { + var changed = EnsureInitialized(splineLength); + var endpoint = _endpoint == WireRailEndpoint.Start + ? WireRailEndpoint.Start : WireRailEndpoint.End; + if (_endpoint != endpoint) { + _endpoint = endpoint; + changed = true; + } + var endpointDistance = _endpoint == WireRailEndpoint.Start ? 0f : splineLength; + if (!Mathf.Approximately(Distance, endpointDistance)) { + SetDistance(endpointDistance, splineLength); + changed = true; + } + _railOffsets ??= new List(); + while (_railOffsets.Count < railCount) { + _railOffsets.Add(0f); + changed = true; + } + if (_railOffsets.Count > railCount) { + _railOffsets.RemoveRange(railCount, _railOffsets.Count - railCount); + changed = true; + } + for (var railIndex = 0; railIndex < _railOffsets.Count; railIndex++) { + var offset = math.clamp(_railOffsets[railIndex], 0f, + math.max(0f, splineLength)); + if (Mathf.Approximately(_railOffsets[railIndex], offset)) { + continue; + } + _railOffsets[railIndex] = offset; + changed = true; + } + return changed; + } + + internal void SetProperties(float splineLength, int railCount, + WireRailEndpoint endpoint, IReadOnlyList railOffsets) + { + if (railOffsets == null) { + throw new ArgumentNullException(nameof(railOffsets)); + } + _endpoint = endpoint == WireRailEndpoint.Start + ? WireRailEndpoint.Start : WireRailEndpoint.End; + SetDistance(_endpoint == WireRailEndpoint.Start ? 0f : splineLength, splineLength); + _railOffsets ??= new List(); + _railOffsets.Clear(); + for (var railIndex = 0; railIndex < railCount; railIndex++) { + var offset = railIndex < railOffsets.Count ? railOffsets[railIndex] : 0f; + _railOffsets.Add(math.clamp(offset, 0f, math.max(0f, splineLength))); + } + } + } + + [Serializable] + [MovedFrom(true, sourceClassName: "WireRailLegFixture")] + public sealed class WireRailStandFixture : WireRailFixture + { + internal void Restore(float diameter, WireRailStandSide legSide, float lateralOffset, + float verticalOffset, float lengthAdjustment, Vector3 startDirection, + float startLength, Vector3 footPosition, Vector3 footRotation, bool footClockwise, + float footWidth, float footLength, float footConnectionLength) + { + _diameter = math.max(0.1f, diameter); + _legSide = legSide; + _lateralOffset = lateralOffset; + _verticalOffset = verticalOffset; + _lengthAdjustment = lengthAdjustment; + _startDirection = startDirection; + _startLength = math.max(0f, startLength); + _footPosition = footPosition; + _footRotation = footRotation; + _footClockwise = footClockwise; + _footWidth = math.max(0.1f, footWidth); + _footLength = math.max(0f, footLength); + _footConnectionLength = math.max(0f, footConnectionLength); + } + + public const float DefaultStartLength = 40f; + public const float DefaultFootWidth = 30f; + public const float DefaultFootLength = 30f; + public const float DefaultFootConnectionLength = 30f; + public const int FootBendSegments = 12; + + public static readonly Vector3 DefaultStartDirection = new(0f, 0f, -1f); + public static readonly Vector3 DefaultFootPosition = new(15f, -22.5f, -80f); + + [SerializeField, Min(0.1f)] private float _diameter = WireRailLayout.ReferenceWireDiameter; + [SerializeField] private WireRailStandSide _legSide = WireRailStandSide.Right; + [SerializeField] private float _lateralOffset; + [SerializeField] private float _verticalOffset; + [SerializeField] private float _lengthAdjustment; + [SerializeField] private Vector3 _startDirection = new(0f, 0f, -1f); + [SerializeField, Min(0f)] private float _startLength = DefaultStartLength; + [SerializeField] private Vector3 _footPosition = new(15f, -22.5f, -80f); + [SerializeField] private Vector3 _footRotation; + [SerializeField] private bool _footClockwise; + [SerializeField, Min(0.1f)] private float _footWidth = DefaultFootWidth; + [SerializeField, Min(0f)] private float _footLength = DefaultFootLength; + [SerializeField, Min(0f)] private float _footConnectionLength = DefaultFootConnectionLength; + + public float Diameter => _diameter; + public WireRailStandSide LegSide => _legSide; + public float LateralOffset => _lateralOffset; + public float VerticalOffset => _verticalOffset; + public float LengthAdjustment => _lengthAdjustment; + public Vector3 StartDirection => _startDirection; + public float StartLength => _startLength; + public Vector3 FootPosition => _footPosition; + public Vector3 FootRotation => _footRotation; + public bool FootClockwise => _footClockwise; + public float FootWidth => _footWidth; + public float FootLength => _footLength; + public float FootConnectionLength => _footConnectionLength; + + internal bool EnsureStandInitialized(float splineLength) + { + var changed = EnsureInitialized(splineLength); + var diameter = math.max(0.1f, _diameter); + var startDirection = _startDirection.sqrMagnitude > 1e-8f + ? _startDirection.normalized : DefaultStartDirection; + var startLength = math.max(0f, _startLength); + var footWidth = math.max(0.1f, _footWidth); + var footLength = math.max(0f, _footLength); + var footConnectionLength = math.max(0f, _footConnectionLength); + if (!Mathf.Approximately(_diameter, diameter)) { + _diameter = diameter; + changed = true; + } + if ((_startDirection - startDirection).sqrMagnitude > 1e-8f) { + _startDirection = startDirection; + changed = true; + } + if (!Mathf.Approximately(_startLength, startLength)) { + _startLength = startLength; + changed = true; + } + if (!Mathf.Approximately(_footWidth, footWidth)) { + _footWidth = footWidth; + changed = true; + } + if (!Mathf.Approximately(_footLength, footLength)) { + _footLength = footLength; + changed = true; + } + if (!Mathf.Approximately(_footConnectionLength, footConnectionLength)) { + _footConnectionLength = footConnectionLength; + changed = true; + } + return changed; + } + + internal bool SetDiameter(float diameter) + { + diameter = math.max(0.1f, diameter); + if (Mathf.Approximately(_diameter, diameter)) { + return false; + } + _diameter = diameter; + return true; + } + + internal void SetProperties(float distance, float splineLength, float diameter, + WireRailStandSide legSide, Vector3 startDirection, float startLength, + Vector3 footPosition, Vector3 footRotation, float footWidth, float footLength, + float footConnectionLength, float lateralOffset = 0f, + float verticalOffset = 0f, float lengthAdjustment = 0f, + bool footClockwise = false) + { + SetDistance(distance, splineLength); + _diameter = math.max(0.1f, diameter); + _legSide = legSide; + _lateralOffset = lateralOffset; + _verticalOffset = verticalOffset; + _lengthAdjustment = lengthAdjustment; + _startDirection = startDirection.sqrMagnitude > 1e-8f + ? startDirection.normalized : DefaultStartDirection; + _startLength = math.max(0f, startLength); + _footPosition = footPosition; + _footRotation = footRotation; + _footClockwise = footClockwise; + _footWidth = math.max(0.1f, footWidth); + _footLength = math.max(0f, footLength); + _footConnectionLength = math.max(0f, footConnectionLength); + } + } + + public readonly struct WireRailRingCrossSection + { + public readonly Vector2 CenterOffset; + public readonly float BaseRadius; + public readonly float Radius; + // The wires the ring wraps around, in the route-local (lateral, vertical) plane. + public readonly Vector2[] RailOffsets; + public readonly float[] RailRadii; + + internal WireRailRingCrossSection(float2 centerOffset, float baseRadius, + float radius, float2[] railOffsets, float[] railRadii) + { + CenterOffset = new Vector2(centerOffset.x, centerOffset.y); + BaseRadius = baseRadius; + Radius = radius; + var count = railOffsets?.Length ?? 0; + RailOffsets = new Vector2[count]; + RailRadii = new float[count]; + for (var i = 0; i < count; i++) { + RailOffsets[i] = new Vector2(railOffsets[i].x, railOffsets[i].y); + RailRadii[i] = railRadii[i]; + } + } + } + + public readonly struct WireRailRungCrossSection + { + public readonly Vector2 StartRailOffset; + public readonly Vector2 EndRailOffset; + public readonly float StartRailRadius; + public readonly float EndRailRadius; + public readonly Vector2 StartOffset; + public readonly Vector2 EndOffset; + + internal WireRailRungCrossSection(float2 startRailOffset, + float2 endRailOffset, float startRailRadius, float endRailRadius, + float2 startOffset, float2 endOffset) + { + StartRailOffset = new Vector2(startRailOffset.x, startRailOffset.y); + EndRailOffset = new Vector2(endRailOffset.x, endRailOffset.y); + StartRailRadius = startRailRadius; + EndRailRadius = endRailRadius; + StartOffset = new Vector2(startOffset.x, startOffset.y); + EndOffset = new Vector2(endOffset.x, endOffset.y); + } + } + + public readonly struct WireRailStandPreview + { + public readonly Vector3 StartRailOffset; + public readonly Vector3 EndRailOffset; + public readonly float StartRailRadius; + public readonly float EndRailRadius; + public readonly IReadOnlyList CenterlinePoints; + + internal WireRailStandPreview(Vector3 startRailOffset, Vector3 endRailOffset, + float startRailRadius, float endRailRadius, + IReadOnlyList centerlinePoints) + { + StartRailOffset = startRailOffset; + EndRailOffset = endRailOffset; + StartRailRadius = startRailRadius; + EndRailRadius = endRailRadius; + CenterlinePoints = centerlinePoints; + } + } + + public readonly struct WireRailCradlePreview + { + public readonly IReadOnlyList RailOffsets; + public readonly IReadOnlyList RailRadii; + public readonly IReadOnlyList CenterlinePoints; + + internal WireRailCradlePreview(IReadOnlyList railOffsets, + IReadOnlyList railRadii, IReadOnlyList centerlinePoints) + { + RailOffsets = railOffsets; + RailRadii = railRadii; + CenterlinePoints = centerlinePoints; + } + } + + public readonly struct WireRailElbowPreview + { + public readonly IReadOnlyList FirstRailPoints; + public readonly IReadOnlyList SecondRailPoints; + public readonly float FirstRailRadius; + public readonly float SecondRailRadius; + + internal WireRailElbowPreview(IReadOnlyList firstRailPoints, + IReadOnlyList secondRailPoints, float firstRailRadius, + float secondRailRadius) + { + FirstRailPoints = firstRailPoints; + SecondRailPoints = secondRailPoints; + FirstRailRadius = firstRailRadius; + SecondRailRadius = secondRailRadius; + } + } + + public readonly struct WireRailHairpinPreview + { + public readonly IReadOnlyList CenterlinePoints; + public readonly float Radius; + + internal WireRailHairpinPreview(IReadOnlyList centerlinePoints, + float radius) + { + CenterlinePoints = centerlinePoints; + Radius = radius; + } + } + + /// + /// Native Unity spline authoring with independently positioned wire layouts and fixtures. + /// The spline helper stores raw VPX coordinates; its transform converts them into Unity space. + /// + [ExecuteAlways] + [DisallowMultipleComponent] + [AddComponentMenu("Pinball/Game Item/Wire Rail")] + [PackAs("WireRail")] + public class WireRailComponent : MonoBehaviour, ICollidableComponent, IPackable + { + private const string SplineObjectName = "Wire Rail Spline"; + private static readonly ProfilerMarker SynchronizeSegmentsMarker = + new("WireRail.SynchronizeSegments"); + private static readonly ProfilerMarker RenderMeshMarker = + new("WireRail.RenderMesh"); + private static readonly ProfilerMarker ColliderMeshMarker = + new("WireRail.ColliderMesh"); + private static Material _builtinDefaultMaterial; + + [SerializeField] private SplineContainer _splineContainer; + [SerializeField] private List _segments = new(); + [SerializeField, HideInInspector] private List _layoutDisplayOrder = new(); + [SerializeReference] private List _fixtures = new(); + // Reusable buffer holding only the enabled fixtures, fed to the render generator. + // Disabled fixtures are hidden from rendering but left untouched for colliders. + private readonly List _enabledFixtures = new(); + [SerializeField, Range(1, 6)] private int _railCount = 4; + [SerializeField, HideInInspector] private bool _railCountInitialized; + [SerializeField, Min(0.1f)] private float _wireDiameter = WireRailLayout.DefaultWireDiameter; + [SerializeField, Min(0f), FormerlySerializedAs("_braceCapBevelSize")] + private float _wireCapBevelSize = 0.5f; + [SerializeField, Range(6, 16)] private int _radialSegments = 10; + [SerializeField, Range(2, 64)] private int _renderSamplesPerSegment = 16; + [SerializeField] private Material _renderMaterial; + [SerializeField, Min(1f)] private float _referenceBallDiameter = WireRailLayout.ReferenceBallDiameter; + [SerializeField, Range(2, 32)] private int _colliderSamplesPerSegment = 8; + [SerializeField] private bool _showColliderPreview; + [SerializeReference] private PhysicsMaterialAsset _physicsMaterial; + [SerializeField] private PhysicsMaterialAsset _terminalPhysicsMaterial; + [SerializeField] private bool _overwritePhysics = true; + [SerializeField, Min(0f)] private float _elasticity = 0.3f; + [SerializeField, Min(0f)] private float _elasticityFalloff = 0.5f; + [SerializeField, Min(0f)] private float _friction = 0.15f; + [SerializeField, Range(-90f, 90f)] private float _scatter; + [NonSerialized] private Mesh _renderMesh; + [NonSerialized] private Mesh _colliderMesh; + [NonSerialized] private Vector3[] _colliderEdgeVertices = Array.Empty(); + [NonSerialized] private int _colliderTopologyRetryCount; + [NonSerialized] private bool _rebuildingGeneratedMeshes; + [NonSerialized] private bool _collidersDirty = true; + [NonSerialized] private bool _colliderGeometryDirty = true; + [NonSerialized] private int _renderGeometryVersion; + [NonSerialized] private int _colliderGeometryVersion; + [NonSerialized] private int _renderMeshGenerationCount; + [NonSerialized] private readonly List _renderSegmentIndexRanges = new(); + [NonSerialized] private readonly List _enabledFixtureIndexRanges = new(); + [NonSerialized] private readonly List _renderFixtureIndexRanges = new(); + [NonSerialized] private string _generationError; +#if UNITY_EDITOR + [NonSerialized] private bool _editorRebuildScheduled; + [NonSerialized] private bool _editorRebuildNeedsInvalidation; + [NonSerialized] internal bool DeferEditorRebuildsForTesting; +#endif + + public SplineContainer SplineContainer => GetSplineContainerWithoutCreating(); + public IReadOnlyList Segments => _segments; + public IReadOnlyList Layouts => _segments; + public IReadOnlyList LayoutDisplayOrder => _layoutDisplayOrder; + public IReadOnlyList Fixtures => _fixtures; + public int RailCount => _railCount; + public float WireDiameter => _wireDiameter; + public float WireCapBevelSize => _wireCapBevelSize; + public int RadialSegments => _radialSegments; + public int RenderSamplesPerSegment => _renderSamplesPerSegment; + public float ReferenceBallDiameter => _referenceBallDiameter; + public int ColliderSamplesPerSegment => _colliderSamplesPerSegment; + public float SplineLength { + get { + var container = GetSplineContainerWithoutCreating(); + return container && container.Spline != null ? container.Spline.GetLength() : 0f; + } + } + public string GenerationError => _generationError; + public bool ShowColliderPreview => _showColliderPreview; + public Mesh RenderMesh => _renderMesh; + + /// + /// Index range (start, count) of each layout span's rail tubes in . + /// + public IReadOnlyList RenderSegmentIndexRanges => _renderSegmentIndexRanges; + + /// + /// Index range (start, count) of each fixture in , one entry + /// per entry of . Disabled or omitted fixtures have an empty range. + /// + public IReadOnlyList RenderFixtureIndexRanges => _renderFixtureIndexRanges; + public Mesh ColliderMesh { + get { + if (isActiveAndEnabled) { + EnsureColliderMesh(); + } + return _colliderMesh; + } + } + public int ColliderTopologyRetryCount => _colliderTopologyRetryCount; + public int RenderGeometryVersion => _renderGeometryVersion; + public int ColliderGeometryVersion => _colliderGeometryVersion; + public bool ColliderGeometryDirty => _colliderGeometryDirty; + internal int RenderMeshGenerationCount => _renderMeshGenerationCount; + + /// + /// Increments every time is actually regenerated. Unlike + /// , which advances when a rebuild is requested, + /// this only moves once the mesh data has changed. + /// + public int RenderMeshVersion => _renderMeshGenerationCount; + + #region Packaging + + [NonSerialized] private bool _packedDataRestored; + + public byte[] Pack() => WireRailPackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) + => WireRailReferencesPackable.Pack(this, files); + + public void Unpack(byte[] bytes) => WireRailPackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] bytes, Transform root, PackagedRefs refs, + PackagedFiles files) + => WireRailReferencesPackable.Unpack(bytes, this, files); + + /// + /// Takes over the authored data from a package. Nothing is synchronized here: the + /// route may not have been restored yet, and synchronizing against a missing or + /// default spline would clamp every distance. Geometry follows once both halves are in. + /// + internal void RestoreFromPackage(WireRailPackable data, List layouts, + List displayOrder, List fixtures) + { + _railCount = math.clamp(data.RailCount, 1, 6); + _railCountInitialized = true; + _wireDiameter = math.max(0.1f, data.WireDiameter); + _wireCapBevelSize = math.max(0f, data.WireCapBevelSize); + _radialSegments = math.clamp(data.RadialSegments, 6, 16); + _renderSamplesPerSegment = math.clamp(data.RenderSamplesPerSegment, 2, 64); + _referenceBallDiameter = math.max(1f, data.ReferenceBallDiameter); + _colliderSamplesPerSegment = math.clamp(data.ColliderSamplesPerSegment, 2, 32); + _showColliderPreview = data.ShowColliderPreview; + _overwritePhysics = data.OverwritePhysics; + _elasticity = math.max(0f, data.Elasticity); + _elasticityFalloff = math.max(0f, data.ElasticityFalloff); + _friction = math.max(0f, data.Friction); + _scatter = math.clamp(data.Scatter, -90f, 90f); + _segments = layouts ?? new List(); + _layoutDisplayOrder = displayOrder ?? new List(); + _fixtures = fixtures ?? new List(); + _packedDataRestored = true; + TryFinishPackageRestore(); + } + + /// + /// Called by the spline child once its route has been restored from a package. + /// + internal void OnSplineRestored() => TryFinishPackageRestore(); + + private void TryFinishPackageRestore() + { + if (!_packedDataRestored) { + return; + } + var container = GetSplineContainerWithoutCreating(); + if (!container || !container.TryGetComponent(out var marker) + || !marker.SplineRestored) { + return; + } + _packedDataRestored = false; + InvalidateGeneratedGeometry(); + SynchronizeSegments(); + RebuildGeneratedMeshes(); + } + + #endregion + + private void Reset() + { + EnsureSplineContainer(); + SynchronizeSegments(); + RebuildGeneratedMeshes(); + } + + private void OnEnable() + { + Subscribe(); + + var container = GetSplineContainerWithoutCreating(); + if (container) { + HardenSplineChild(container.gameObject); + SynchronizeSegments(); + RebuildGeneratedMeshes(); + } + GetComponentInParent()?.EnableCollider(ItemId); + } + + private void Subscribe() + { + Spline.Changed -= OnSplineChanged; + Spline.Changed += OnSplineChanged; + UnityEngine.Splines.SplineContainer.SplineAdded -= OnSplineCollectionChanged; + UnityEngine.Splines.SplineContainer.SplineAdded += OnSplineCollectionChanged; + UnityEngine.Splines.SplineContainer.SplineRemoved -= OnSplineCollectionChanged; + UnityEngine.Splines.SplineContainer.SplineRemoved += OnSplineCollectionChanged; +#if UNITY_EDITOR + Undo.undoRedoPerformed -= OnUndoRedo; + Undo.undoRedoPerformed += OnUndoRedo; +#endif + } + + private void OnDisable() + { + Spline.Changed -= OnSplineChanged; + UnityEngine.Splines.SplineContainer.SplineAdded -= OnSplineCollectionChanged; + UnityEngine.Splines.SplineContainer.SplineRemoved -= OnSplineCollectionChanged; +#if UNITY_EDITOR + Undo.undoRedoPerformed -= OnUndoRedo; + if (_editorRebuildScheduled) { + EditorApplication.delayCall -= RebuildAfterValidation; + _editorRebuildScheduled = false; + _editorRebuildNeedsInvalidation = false; + } +#endif + GetComponentInParent()?.DisableCollider(ItemId); + DestroyGeneratedMeshes(); + } + + private void OnValidate() + { + _railCount = math.clamp(_railCount, 1, 6); + _wireDiameter = math.max(0.1f, _wireDiameter); + _wireCapBevelSize = math.max(0f, _wireCapBevelSize); + _radialSegments = math.clamp(_radialSegments, 6, 16); + _renderSamplesPerSegment = math.clamp(_renderSamplesPerSegment, 2, 64); + _referenceBallDiameter = math.max(1f, _referenceBallDiameter); + _colliderSamplesPerSegment = math.clamp(_colliderSamplesPerSegment, 2, 32); + SynchronizeFixtures(); + if (!GetSplineContainerWithoutCreating()) { + return; + } +#if UNITY_EDITOR + ScheduleEditorRebuild(true); +#endif + } + +#if UNITY_EDITOR + private void RebuildAfterValidation() + { + _editorRebuildScheduled = false; + if (!this || !GetSplineContainerWithoutCreating()) { + return; + } + if (_editorRebuildNeedsInvalidation) { + InvalidateGeneratedGeometry(); + } + _editorRebuildNeedsInvalidation = false; + RebuildGeneratedMeshesImmediately(); + SceneView.RepaintAll(); + } + + private void ScheduleEditorRebuild(bool needsInvalidation) + { + _editorRebuildNeedsInvalidation |= needsInvalidation; + if (_editorRebuildScheduled) { + return; + } + _editorRebuildScheduled = true; + EditorApplication.delayCall += RebuildAfterValidation; + } + + internal void FlushDeferredEditorRebuildForTesting() + { + if (!_editorRebuildScheduled) { + return; + } + EditorApplication.delayCall -= RebuildAfterValidation; + RebuildAfterValidation(); + } + + private void OnUndoRedo() + { + if (!this || !GetSplineContainerWithoutCreating()) { + return; + } + RebuildGeneratedMeshes(); + SceneView.RepaintAll(); + } +#endif + + public void SetRailCount(int railCount) + { + if (railCount < 1 || railCount > 6) { + throw new ArgumentOutOfRangeException(nameof(railCount), railCount, + "A wire rail supports between one and six rails."); + } + SynchronizeSegments(); + if (_railCount == railCount) { + return; + } + _railCount = railCount; + _railCountInitialized = true; + foreach (var layout in _segments) { + layout.ResizeRailCount(railCount, _wireDiameter, true, true); + } + SynchronizeSegmentConnections(); + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public void SetRailsActive(int segmentIndex, IReadOnlyList railIndices, + bool active) + { + if (railIndices == null) { + throw new ArgumentNullException(nameof(railIndices)); + } + var segment = GetSegment(segmentIndex); + foreach (var railIndex in railIndices) { + if (railIndex < 0 || railIndex >= segment.RailCount) { + throw new ArgumentOutOfRangeException(nameof(railIndices), railIndex, + $"Layout {GetLayoutDisplayIndex(segmentIndex) + 1} has " + + $"{segment.RailCount} wire(s)."); + } + } + foreach (var railIndex in railIndices) { + segment.SetRailActive(railIndex, active); + } + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public void SetWireContinuous(int segmentIndex, int wireIndex, bool continuous) + { + SynchronizeSegments(); + var nextSegmentIndex = GetNextSegmentIndex(segmentIndex); + if (nextSegmentIndex < 0) { + throw new InvalidOperationException( + $"Segment {segmentIndex + 1} has no following segment."); + } + var wireCount = _railCount; + var connection = _segments[segmentIndex].ConnectionToNext; + connection.EnsureInitialized(wireCount); + connection.SetWireContinuous(wireIndex, continuous); + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public void SetWireTransitionOverride(int segmentIndex, int wireIndex, + bool overridden) + { + SynchronizeSegments(); + var nextSegmentIndex = GetNextSegmentIndex(segmentIndex); + if (nextSegmentIndex < 0) { + throw new InvalidOperationException( + $"Segment {segmentIndex + 1} has no following segment."); + } + var wireCount = _railCount; + var connection = _segments[segmentIndex].ConnectionToNext; + connection.EnsureInitialized(wireCount); + connection.SetWireOverridden(wireIndex, overridden); + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public void SetWireTransitionCurve(int segmentIndex, int wireIndex, + AnimationCurve curve) + { + SynchronizeSegments(); + var nextSegmentIndex = GetNextSegmentIndex(segmentIndex); + if (nextSegmentIndex < 0) { + throw new InvalidOperationException( + $"Segment {segmentIndex + 1} has no following segment."); + } + var wireCount = _railCount; + var connection = _segments[segmentIndex].ConnectionToNext; + connection.EnsureInitialized(wireCount); + connection.SetWireCurve(wireIndex, curve); + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public int GetNextSegmentIndex(int segmentIndex) + { + if (_segments == null || segmentIndex < 0 || segmentIndex >= _segments.Count) { + throw new ArgumentOutOfRangeException(nameof(segmentIndex)); + } + if (segmentIndex + 1 < _segments.Count) { + return segmentIndex + 1; + } + var container = GetSplineContainerWithoutCreating(); + return container && container.Spline != null && container.Spline.Closed + && _segments.Count > 1 ? 0 : -1; + } + + public void SetThirdRailSide(int segmentIndex, WireRailThirdRailSide side) + { + GetSegment(segmentIndex).SetThirdRailSide(side); + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public void SetRailOffset(int segmentIndex, int railIndex, Vector2 offset) + { + GetSegment(segmentIndex).SetRailOffset(railIndex, offset); + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public void SetWireDiameter(float diameter) + { + if (diameter <= 0f) { + throw new ArgumentOutOfRangeException(nameof(diameter), diameter, + "Wire diameter must be positive."); + } + _wireDiameter = diameter; + SynchronizeSegments(); + foreach (var segment in _segments) { + segment.SetAllWireDiameters(diameter); + } + SynchronizeFixtures(); + foreach (var ring in _fixtures.OfType()) { + ring.SetDiameter(diameter); + } + foreach (var cradle in _fixtures.OfType()) { + cradle.SetDiameter(diameter); + } + foreach (var rung in _fixtures.OfType()) { + rung.SetDiameter(diameter); + } + foreach (var leg in _fixtures.OfType()) { + leg.SetDiameter(diameter); + } + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public void SetWireProperties(int segmentIndex, IReadOnlyList railIndices, + IReadOnlyList offsets) + { + if (railIndices == null || offsets == null + || railIndices.Count != offsets.Count) { + throw new ArgumentException("Wire indices and offsets must have matching counts."); + } + var segment = GetSegment(segmentIndex); + for (var i = 0; i < railIndices.Count; i++) { + if (railIndices[i] < 0 || railIndices[i] >= segment.RailCount) { + throw new ArgumentOutOfRangeException(nameof(railIndices), railIndices[i], + $"Segment {segmentIndex + 1} has {segment.RailCount} wire(s)."); + } + } + for (var i = 0; i < railIndices.Count; i++) { + segment.SetRailOffset(railIndices[i], offsets[i]); + } + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public void ApplyWirePositionsToAllLayouts(int sourceSegmentIndex, + IReadOnlyList railIndices) + { + if (railIndices == null) { + throw new ArgumentNullException(nameof(railIndices)); + } + var sourceSegment = GetSegment(sourceSegmentIndex); + if (railIndices.Count == 0) { + return; + } + var offsets = new Vector2[railIndices.Count]; + for (var i = 0; i < railIndices.Count; i++) { + var railIndex = railIndices[i]; + if (railIndex < 0 || railIndex >= sourceSegment.RailCount) { + throw new ArgumentOutOfRangeException(nameof(railIndices), railIndex, + $"Layout {GetLayoutDisplayIndex(sourceSegmentIndex) + 1} has " + + $"{sourceSegment.RailCount} wire(s)."); + } + offsets[i] = sourceSegment.GetRailOffset(railIndex); + } + foreach (var segment in _segments) { + for (var i = 0; i < railIndices.Count; i++) { + segment.SetRailOffset(railIndices[i], offsets[i]); + } + } + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public bool CenterPivot() + { + var container = GetSplineContainerWithoutCreating(); + var spline = container ? container.Spline : null; + if (spline == null || spline.Count < 2 + || !WireRailSplineGeometry.TryEvaluateDistance(spline, + spline.GetLength() * 0.5f, out var midpoint)) { + return false; + } + + var midpointWorld = container.transform.TransformPoint((Vector3)midpoint.Position); + var worldDelta = midpointWorld - transform.position; + if (worldDelta.sqrMagnitude <= 1e-12f) { + return false; + } + var splineDelta = (float3)container.transform.InverseTransformVector(worldDelta); + transform.position = midpointWorld; + for (var knotIndex = 0; knotIndex < spline.Count - 1; knotIndex++) { + var knot = spline[knotIndex]; + knot.Position -= splineDelta; + spline.SetKnotNoNotify(knotIndex, knot); + } + var lastKnotIndex = spline.Count - 1; + var lastKnot = spline[lastKnotIndex]; + lastKnot.Position -= splineDelta; + spline.SetKnot(lastKnotIndex, lastKnot); + _collidersDirty = true; + return true; + } + + public void ResetSegmentLayout(int segmentIndex) + { + GetSegment(segmentIndex).ResetLayout(); + RebuildGeneratedMeshes(); + MarkDirty(); + } + + public int AddRingFixture(float distance) + { + _fixtures ??= new List(); + var ring = new WireRailRingFixture(); + ring.SetProperties(distance, SplineLength, _wireDiameter, false, + WireRailRingFixture.DefaultCutoutStartAngle, + WireRailRingFixture.DefaultCutoutEndAngle, false, + WireRailRingFixture.DefaultStraightStartAngle, + WireRailRingFixture.DefaultStraightEndAngle, 0f, 0f, 1f, + WireRailRingFixture.DefaultRingDensity); + _fixtures.Add(ring); + RebuildRenderGeometry(); + MarkDirty(); + return _fixtures.Count - 1; + } + + public int AddRungFixture(float distance) + { + _fixtures ??= new List(); + var rung = new WireRailRungFixture(); + rung.SetProperties(distance, SplineLength, _wireDiameter, 0, 1, + WireRailRungFixture.DefaultAngle, 0f, 0f, 0f); + _fixtures.Add(rung); + RebuildRenderGeometry(); + MarkDirty(); + return _fixtures.Count - 1; + } + + public int AddCradleFixture(float distance) + { + _fixtures ??= new List(); + var cradle = new WireRailCradleFixture(); + cradle.SetProperties(distance, SplineLength, _wireDiameter, + WireRailCradleFixture.DefaultRingDensity, 0f, 0f, + WireRailCradleFixture.DefaultBottomLength, + WireRailCradleFixture.DefaultLeftLength, + WireRailCradleFixture.DefaultRightLength, + WireRailCradleFixture.DefaultAngle, + WireRailCradleFixture.DefaultRotation, + WireRailCradleFixture.DefaultCornerRadius); + _fixtures.Add(cradle); + RebuildRenderGeometry(); + MarkDirty(); + return _fixtures.Count - 1; + } + + public int AddStandFixture(float distance) + { + _fixtures ??= new List(); + var leg = new WireRailStandFixture(); + leg.SetProperties(distance, SplineLength, _wireDiameter, + WireRailStandSide.Right, WireRailStandFixture.DefaultStartDirection, + WireRailStandFixture.DefaultStartLength, + WireRailStandFixture.DefaultFootPosition, Vector3.zero, + WireRailStandFixture.DefaultFootWidth, WireRailStandFixture.DefaultFootLength, + WireRailStandFixture.DefaultFootConnectionLength); + _fixtures.Add(leg); + RebuildRenderGeometry(); + MarkDirty(); + return _fixtures.Count - 1; + } + + public int AddHairpinFixture(WireRailEndpoint endpoint = WireRailEndpoint.End) + { + _fixtures ??= new List(); + var hairpin = new WireRailHairpinFixture(); + hairpin.SetProperties(SplineLength, _wireDiameter, endpoint, 0, 1, + WireRailHairpinFixture.DefaultLoopDiameter, + WireRailHairpinFixture.DefaultLeadLength, + WireRailHairpinFixture.DefaultTangentLength, + WireRailHairpinFixture.DefaultRingDensity, 0f, 0f); + _fixtures.Add(hairpin); + InvalidateColliderGeometry(); + RebuildRenderGeometry(); + MarkDirty(); + return _fixtures.Count - 1; + } + + public int AddElbowFixture(WireRailEndpoint endpoint = WireRailEndpoint.End) + { + _fixtures ??= new List(); + TryGetDefaultEndpointRailPair(endpoint, out var firstRailIndex, + out var secondRailIndex); + var elbow = new WireRailElbowFixture(); + elbow.SetProperties(SplineLength, _railCount, _wireDiameter, endpoint, + firstRailIndex, secondRailIndex, + WireRailElbowFixture.DefaultOffset, + WireRailElbowFixture.DefaultDropLength, 0f, Array.Empty()); + _fixtures.Add(elbow); + InvalidateColliderGeometry(); + RebuildRenderGeometry(); + MarkDirty(); + return _fixtures.Count - 1; + } + + public int AddRailTrimFixture(WireRailEndpoint endpoint = WireRailEndpoint.End) + { + _fixtures ??= new List(); + var railTrim = new WireRailTrimFixture(); + railTrim.SetProperties(SplineLength, _railCount, endpoint, + Array.Empty()); + _fixtures.Add(railTrim); + InvalidateColliderGeometry(); + RebuildRenderGeometry(); + MarkDirty(); + return _fixtures.Count - 1; + } + + public bool TryGetRingCrossSection(int fixtureIndex, + out WireRailRingCrossSection crossSection) + { + crossSection = default; + if (_fixtures == null || fixtureIndex < 0 || fixtureIndex >= _fixtures.Count + || _fixtures[fixtureIndex] is not WireRailRingFixture ring + || !_splineContainer || _splineContainer.Spline == null + || !WireRailFixtureMeshGenerator.TryEvaluateRingProfile( + _splineContainer.Spline, _segments, ring, out var profile)) { + return false; + } + crossSection = new WireRailRingCrossSection(profile.CenterOffset, + profile.BaseRadius, profile.Radius, profile.RailOffsets, profile.RailRadii); + return true; + } + + public bool TryGetRungCrossSection(int fixtureIndex, + out WireRailRungCrossSection crossSection) + { + crossSection = default; + if (_fixtures == null || fixtureIndex < 0 || fixtureIndex >= _fixtures.Count + || _fixtures[fixtureIndex] is not WireRailRungFixture rung + || !_splineContainer || _splineContainer.Spline == null + || !WireRailFixtureMeshGenerator.TryEvaluateRungProfile( + _splineContainer.Spline, _segments, rung, out var profile)) { + return false; + } + crossSection = new WireRailRungCrossSection(profile.StartRailOffset, + profile.EndRailOffset, profile.StartRailRadius, profile.EndRailRadius, + profile.StartOffset, profile.EndOffset); + return true; + } + + public bool TryGetStandPreview(int fixtureIndex, out WireRailStandPreview preview) + { + preview = default; + if (_fixtures == null || fixtureIndex < 0 || fixtureIndex >= _fixtures.Count + || _fixtures[fixtureIndex] is not WireRailStandFixture leg + || !_splineContainer || _splineContainer.Spline == null + || !WireRailFixtureMeshGenerator.TryEvaluateStandProfile( + _splineContainer.Spline, _segments, leg, out var profile)) { + return false; + } + preview = new WireRailStandPreview( + ToLocalOffset(profile.AttachmentProfile.StartRailOffset), + ToLocalOffset(profile.AttachmentProfile.EndRailOffset), + profile.AttachmentProfile.StartRailRadius, + profile.AttachmentProfile.EndRailRadius, + profile.CombinedPath.Select(point => ToLocalPosition(point)).ToArray()); + return true; + + Vector3 ToLocalOffset(float2 offset) => new(offset.x, 0f, offset.y); + Vector3 ToLocalPosition(float3 position) + { + var relative = position - profile.AttachmentProfile.Frame.Position; + return new Vector3( + math.dot(relative, profile.AttachmentProfile.Frame.Right), + math.dot(relative, profile.AttachmentProfile.Frame.Tangent), + math.dot(relative, profile.AttachmentProfile.Frame.Up)); + } + } + + public bool TryGetCradlePreview(int fixtureIndex, out WireRailCradlePreview preview) + { + preview = default; + if (_fixtures == null || fixtureIndex < 0 || fixtureIndex >= _fixtures.Count + || _fixtures[fixtureIndex] is not WireRailCradleFixture cradle + || !_splineContainer || _splineContainer.Spline == null + || !WireRailFixtureMeshGenerator.TryEvaluateCradleProfile( + _splineContainer.Spline, _segments, cradle, out var profile)) { + return false; + } + preview = new WireRailCradlePreview( + profile.RailOffsets.Select(offset => new Vector2(offset.x, offset.y)).ToArray(), + profile.RailRadii.ToArray(), + profile.CenterlinePoints.Select(point => { + var relative = point - profile.Frame.Position; + return new Vector2(math.dot(relative, profile.Frame.Right), + math.dot(relative, profile.Frame.Up)); + }).ToArray()); + return true; + } + + public bool TryGetElbowPreview(int fixtureIndex, out WireRailElbowPreview preview) + { + preview = default; + if (_fixtures == null || fixtureIndex < 0 || fixtureIndex >= _fixtures.Count + || _fixtures[fixtureIndex] is not WireRailElbowFixture elbow + || !_splineContainer || _splineContainer.Spline == null + || !WireRailFixtureMeshGenerator.TryEvaluateElbowProfile( + _splineContainer.Spline, _segments, elbow, out var profile)) { + return false; + } + // Show a stretch of the rails leading into the elbow for context. This is preview-only + // and prepended here; the generated mesh still uses the profile paths untouched. + var desiredLead = math.max(30f, elbow.DropLength * 1.5f); + WireRailFixtureMeshGenerator.TryEvaluateElbowIncomingLeads(_splineContainer.Spline, + _segments, elbow, desiredLead, out var firstLead, out var secondLead); + preview = new WireRailElbowPreview( + BuildRailPath(firstLead, profile.FirstRailPoints), + BuildRailPath(secondLead, profile.SecondRailPoints), + profile.FirstRailRadius, profile.SecondRailRadius); + return true; + + Vector3[] BuildRailPath(IReadOnlyList lead, IReadOnlyList railPoints) + { + var points = new List( + (lead?.Count ?? 0) + railPoints.Count); + if (lead != null) { + foreach (var point in lead) { + points.Add(ToLocalPosition(point)); + } + } + foreach (var point in railPoints) { + points.Add(ToLocalPosition(point)); + } + return points.ToArray(); + } + + Vector3 ToLocalPosition(float3 position) + { + var relative = position - profile.Frame.Position; + return new Vector3( + math.dot(relative, profile.Frame.Right), + math.dot(relative, profile.Frame.Tangent), + math.dot(relative, profile.Frame.Up)); + } + } + + public bool TryGetHairpinPreview(int fixtureIndex, + out WireRailHairpinPreview preview) + { + preview = default; + if (_fixtures == null || fixtureIndex < 0 || fixtureIndex >= _fixtures.Count + || _fixtures[fixtureIndex] is not WireRailHairpinFixture hairpin + || !_splineContainer || _splineContainer.Spline == null + || !WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile( + _splineContainer.Spline, _segments, hairpin, out var profile)) { + return false; + } + preview = new WireRailHairpinPreview( + profile.CenterlinePoints.Select(point => ToLocalPosition(point)).ToArray(), + hairpin.Diameter * 0.5f); + return true; + + Vector3 ToLocalPosition(float3 position) + { + var relative = position - profile.Frame.Position; + return new Vector3( + math.dot(relative, profile.Frame.Right), + math.dot(relative, profile.Frame.Tangent), + math.dot(relative, profile.Frame.Up)); + } + } + + public void RemoveFixture(int fixtureIndex) + { + var affectsCollider = GetFixture(fixtureIndex) is WireRailHairpinFixture + or WireRailElbowFixture or WireRailTrimFixture; + _fixtures.RemoveAt(fixtureIndex); + if (affectsCollider) { + InvalidateColliderGeometry(); + } + RebuildRenderGeometry(); + MarkDirty(); + } + + public int DuplicateRingFixture(int fixtureIndex) + { + if (GetFixture(fixtureIndex) is not WireRailRingFixture source) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a ring.", + nameof(fixtureIndex)); + } + var duplicate = new WireRailRingFixture(); + duplicate.SetProperties(source.Distance, SplineLength, _wireDiameter, + source.HasCutout, source.CutoutStartAngle, source.CutoutEndAngle, + source.HasStraightSection, source.StraightStartAngle, + source.StraightEndAngle, source.LateralOffset, source.VerticalOffset, + source.Scale, source.RingDensity); + duplicate.SetSolderThreshold(source.SolderThreshold); + duplicate.SetSolderSize(source.SolderSize); + var duplicateIndex = fixtureIndex + 1; + _fixtures.Insert(duplicateIndex, duplicate); + RebuildRenderGeometry(); + MarkDirty(); + return duplicateIndex; + } + + public int DuplicateRungFixture(int fixtureIndex) + { + if (GetFixture(fixtureIndex) is not WireRailRungFixture source) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a rung.", + nameof(fixtureIndex)); + } + var duplicate = new WireRailRungFixture(); + duplicate.SetProperties(source.Distance, SplineLength, _wireDiameter, + source.StartRailIndex, source.EndRailIndex, + source.Angle, source.LateralOffset, + source.VerticalOffset, source.LengthAdjustment); + duplicate.SetSolderThreshold(source.SolderThreshold); + duplicate.SetSolderSize(source.SolderSize); + var duplicateIndex = fixtureIndex + 1; + _fixtures.Insert(duplicateIndex, duplicate); + RebuildRenderGeometry(); + MarkDirty(); + return duplicateIndex; + } + + public int DuplicateCradleFixture(int fixtureIndex) + { + if (GetFixture(fixtureIndex) is not WireRailCradleFixture source) { + throw new ArgumentException( + $"Fixture {fixtureIndex + 1} is not a cradle.", + nameof(fixtureIndex)); + } + var duplicate = new WireRailCradleFixture(); + duplicate.SetProperties(source.Distance, SplineLength, _wireDiameter, + source.RingDensity, source.LateralOffset, source.VerticalOffset, + source.BottomLength, source.LeftLength, source.RightLength, + source.Angle, source.Rotation, source.CornerRadius); + duplicate.SetSolderThreshold(source.SolderThreshold); + duplicate.SetSolderSize(source.SolderSize); + var duplicateIndex = fixtureIndex + 1; + _fixtures.Insert(duplicateIndex, duplicate); + RebuildRenderGeometry(); + MarkDirty(); + return duplicateIndex; + } + + public int DuplicateStandFixture(int fixtureIndex) + { + if (GetFixture(fixtureIndex) is not WireRailStandFixture source) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a stand.", + nameof(fixtureIndex)); + } + var duplicate = new WireRailStandFixture(); + duplicate.SetProperties(source.Distance, SplineLength, _wireDiameter, + source.LegSide, source.StartDirection, source.StartLength, + source.FootPosition, source.FootRotation, source.FootWidth, + source.FootLength, source.FootConnectionLength, source.LateralOffset, + source.VerticalOffset, source.LengthAdjustment, source.FootClockwise); + duplicate.SetSolderThreshold(source.SolderThreshold); + duplicate.SetSolderSize(source.SolderSize); + var duplicateIndex = fixtureIndex + 1; + _fixtures.Insert(duplicateIndex, duplicate); + RebuildRenderGeometry(); + MarkDirty(); + return duplicateIndex; + } + + public int DuplicateHairpinFixture(int fixtureIndex) + { + if (GetFixture(fixtureIndex) is not WireRailHairpinFixture source) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a hairpin.", + nameof(fixtureIndex)); + } + var duplicate = new WireRailHairpinFixture(); + duplicate.SetProperties(SplineLength, _wireDiameter, source.Endpoint, + source.FirstRailIndex, source.SecondRailIndex, source.LoopDiameter, + source.LeadLength, source.TangentLength, source.RingDensity, + source.RailOffset, source.Rotation); + duplicate.SetSolderThreshold(source.SolderThreshold); + duplicate.SetSolderSize(source.SolderSize); + var duplicateIndex = fixtureIndex + 1; + _fixtures.Insert(duplicateIndex, duplicate); + InvalidateColliderGeometry(); + RebuildRenderGeometry(); + MarkDirty(); + return duplicateIndex; + } + + public int DuplicateElbowFixture(int fixtureIndex) + { + if (GetFixture(fixtureIndex) is not WireRailElbowFixture source) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not an elbow.", + nameof(fixtureIndex)); + } + var duplicate = new WireRailElbowFixture(); + duplicate.SetProperties(SplineLength, _railCount, _wireDiameter, source.Endpoint, + source.FirstRailIndex, source.SecondRailIndex, source.Offset, + source.DropLength, source.ZAngle, source.RailOffsets); + duplicate.SetSolderThreshold(source.SolderThreshold); + duplicate.SetSolderSize(source.SolderSize); + var duplicateIndex = fixtureIndex + 1; + _fixtures.Insert(duplicateIndex, duplicate); + InvalidateColliderGeometry(); + RebuildRenderGeometry(); + MarkDirty(); + return duplicateIndex; + } + + public int DuplicateRailTrimFixture(int fixtureIndex) + { + if (GetFixture(fixtureIndex) is not WireRailTrimFixture source) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a rail trim.", + nameof(fixtureIndex)); + } + var duplicate = new WireRailTrimFixture(); + duplicate.SetProperties(SplineLength, _railCount, source.Endpoint, + source.RailOffsets); + var duplicateIndex = fixtureIndex + 1; + _fixtures.Insert(duplicateIndex, duplicate); + InvalidateColliderGeometry(); + RebuildRenderGeometry(); + MarkDirty(); + return duplicateIndex; + } + + public void SetRingFixtureProperties(int fixtureIndex, float distance, + bool hasCutout, float cutoutStartAngle, float cutoutEndAngle, + bool hasStraightSection = false, float straightStartAngle = + WireRailRingFixture.DefaultStraightStartAngle, float straightEndAngle = + WireRailRingFixture.DefaultStraightEndAngle, float lateralOffset = 0f, + float verticalOffset = 0f, float scale = 1f, + int ringDensity = 0) + { + if (GetFixture(fixtureIndex) is not WireRailRingFixture ring) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a ring.", + nameof(fixtureIndex)); + } + var resolvedRingDensity = ringDensity >= 3 + ? ringDensity + : (ring.RingDensity >= 3 + ? ring.RingDensity : WireRailRingFixture.DefaultRingDensity); + ring.SetProperties(distance, SplineLength, _wireDiameter, hasCutout, + cutoutStartAngle, cutoutEndAngle, hasStraightSection, straightStartAngle, + straightEndAngle, lateralOffset, verticalOffset, scale, + resolvedRingDensity); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void SetRungFixtureProperties(int fixtureIndex, float distance, + float angle, float lateralOffset = 0f, + float verticalOffset = 0f, float lengthAdjustment = 0f) + { + if (GetFixture(fixtureIndex) is not WireRailRungFixture rung) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a rung.", + nameof(fixtureIndex)); + } + rung.SetProperties(distance, SplineLength, _wireDiameter, + rung.StartRailIndex, rung.EndRailIndex, angle, + lateralOffset, verticalOffset, lengthAdjustment); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void SetCradleFixtureProperties(int fixtureIndex, float distance, + int ringDensity, float lateralOffset, float verticalOffset, + float bottomLength, float leftLength, float rightLength, float angle, + float rotation, float cornerRadius) + { + if (GetFixture(fixtureIndex) is not WireRailCradleFixture cradle) { + throw new ArgumentException( + $"Fixture {fixtureIndex + 1} is not a cradle.", + nameof(fixtureIndex)); + } + cradle.SetProperties(distance, SplineLength, _wireDiameter, ringDensity, + lateralOffset, verticalOffset, bottomLength, leftLength, rightLength, + angle, rotation, cornerRadius); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void SetStandFixtureProperties(int fixtureIndex, float distance, + WireRailStandSide legSide, Vector3 startDirection, float startLength, + Vector3 footPosition, Vector3 footRotation, float footWidth, float footLength, + float footConnectionLength, float lateralOffset = 0f, + float verticalOffset = 0f, float lengthAdjustment = 0f, + bool? footClockwise = null) + { + if (GetFixture(fixtureIndex) is not WireRailStandFixture leg) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a stand.", + nameof(fixtureIndex)); + } + leg.SetProperties(distance, SplineLength, _wireDiameter, legSide, + startDirection, startLength, footPosition, footRotation, + footWidth, footLength, footConnectionLength, lateralOffset, + verticalOffset, lengthAdjustment, footClockwise ?? leg.FootClockwise); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void MirrorStandFixture(int fixtureIndex) + { + if (GetFixture(fixtureIndex) is not WireRailStandFixture leg) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a stand.", + nameof(fixtureIndex)); + } + var mirroredRotation = leg.FootRotation; + mirroredRotation.y = -mirroredRotation.y; + mirroredRotation.z = -mirroredRotation.z; + var startDirection = leg.StartDirection; + startDirection.x = -startDirection.x; + var footPosition = leg.FootPosition; + footPosition.x = -footPosition.x; + leg.SetProperties(leg.Distance, SplineLength, _wireDiameter, + leg.LegSide == WireRailStandSide.Left + ? WireRailStandSide.Right : WireRailStandSide.Left, + startDirection, leg.StartLength, footPosition, + mirroredRotation, leg.FootWidth, leg.FootLength, + leg.FootConnectionLength, -leg.LateralOffset, leg.VerticalOffset, + leg.LengthAdjustment, !leg.FootClockwise); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void SetHairpinFixtureProperties(int fixtureIndex, + WireRailEndpoint endpoint, int firstRailIndex, int secondRailIndex, + float loopDiameter, float leadLength, float tangentLength, int ringDensity, + float railOffset, float rotation) + { + if (GetFixture(fixtureIndex) is not WireRailHairpinFixture hairpin) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a hairpin.", + nameof(fixtureIndex)); + } + hairpin.SetProperties(SplineLength, _wireDiameter, endpoint, + firstRailIndex, secondRailIndex, loopDiameter, leadLength, tangentLength, + ringDensity, railOffset, rotation); + InvalidateColliderGeometry(); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void SetElbowFixtureProperties(int fixtureIndex, + WireRailEndpoint endpoint, int firstRailIndex, int secondRailIndex, + float offset, float dropLength, float zAngle, + IReadOnlyList railOffsets) + { + if (GetFixture(fixtureIndex) is not WireRailElbowFixture elbow) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not an elbow.", + nameof(fixtureIndex)); + } + if (elbow.Endpoint != endpoint + && !AreEndpointRailsActive(endpoint, firstRailIndex, secondRailIndex)) { + TryGetDefaultEndpointRailPair(endpoint, out firstRailIndex, + out secondRailIndex); + } + elbow.SetProperties(SplineLength, _railCount, _wireDiameter, endpoint, + firstRailIndex, secondRailIndex, offset, dropLength, zAngle, + railOffsets); + InvalidateColliderGeometry(); + RebuildRenderGeometry(); + MarkDirty(); + } + + public bool AreEndpointRailsActive(WireRailEndpoint endpoint, + int firstRailIndex, int secondRailIndex, float attachmentOffset = 0f) + { + if (_segments == null || _segments.Count == 0 + || firstRailIndex == secondRailIndex) { + return false; + } + // A Drop's rails attach at its offset inward from the endpoint, which may fall in a + // different layout, so validate there rather than at the spline endpoint. + int segmentIndex; + if (attachmentOffset > 1e-5f) { + var splineLength = SplineLength; + var attachmentDistance = math.clamp(endpoint == WireRailEndpoint.Start + ? attachmentOffset : splineLength - attachmentOffset, 0f, splineLength); + segmentIndex = WireRailSplineGeometry.GetLayoutIndexAtDistance(_segments, + attachmentDistance, splineLength); + } else { + segmentIndex = GetEndpointSegmentIndex(endpoint); + } + if (segmentIndex < 0) { + return false; + } + var segment = _segments[segmentIndex]; + return firstRailIndex >= 0 && secondRailIndex >= 0 + && firstRailIndex < segment.RailCount + && secondRailIndex < segment.RailCount + && segment.IsRailActive(firstRailIndex) + && segment.IsRailActive(secondRailIndex); + } + + private bool TryGetDefaultEndpointRailPair(WireRailEndpoint endpoint, + out int firstRailIndex, out int secondRailIndex) + { + SynchronizeSegments(); + return TryGetDefaultEndpointRailPairFromSegments(endpoint, + out firstRailIndex, out secondRailIndex); + } + + private bool TryGetDefaultEndpointRailPairFromSegments(WireRailEndpoint endpoint, + out int firstRailIndex, out int secondRailIndex) + { + firstRailIndex = 0; + secondRailIndex = math.min(1, math.max(0, _railCount - 1)); + if (_segments == null || _segments.Count == 0) { + return false; + } + var segmentIndex = GetEndpointSegmentIndex(endpoint); + if (segmentIndex < 0) { + return false; + } + var segment = _segments[segmentIndex]; + firstRailIndex = -1; + for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) { + if (!segment.IsRailActive(railIndex)) { + continue; + } + if (firstRailIndex < 0) { + firstRailIndex = railIndex; + continue; + } + secondRailIndex = railIndex; + return true; + } + firstRailIndex = 0; + secondRailIndex = math.min(1, math.max(0, _railCount - 1)); + return false; + } + + private int GetEndpointSegmentIndex(WireRailEndpoint endpoint) + { + if (_segments == null || _segments.Count == 0) { + return -1; + } + var splineLength = SplineLength; + return WireRailSplineGeometry.GetLayoutIndexAtDistance(_segments, + endpoint == WireRailEndpoint.Start ? 0f : splineLength, splineLength); + } + + public bool HasRailTrimConflict(WireRailEndpoint endpoint, + int firstRailIndex, int secondRailIndex, + WireRailFixture requestingFixture = null) + { + SynchronizeFixtures(); + return WireRailEndpointTrimUtility.HasRailTrimConflict(_fixtures, _segments, + SplineLength, endpoint, firstRailIndex, secondRailIndex, requestingFixture); + } + + public void SetRailTrimFixtureProperties(int fixtureIndex, + WireRailEndpoint endpoint, IReadOnlyList railOffsets) + { + if (GetFixture(fixtureIndex) is not WireRailTrimFixture railTrim) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a rail trim.", + nameof(fixtureIndex)); + } + railTrim.SetProperties(SplineLength, _railCount, endpoint, railOffsets); + InvalidateColliderGeometry(); + RebuildRenderGeometry(); + MarkDirty(); + } + + /// + /// Moves a support fixture along the route. End fittings are attached to an endpoint + /// and have no route position of their own. + /// + public void SetFixtureDistance(int fixtureIndex, float distance) + { + var fixture = GetFixture(fixtureIndex); + if (fixture is WireRailHairpinFixture or WireRailElbowFixture or WireRailTrimFixture) { + throw new ArgumentException( + $"Fixture {fixtureIndex + 1} is an end fitting and has no route position.", + nameof(fixtureIndex)); + } + fixture.SetDistance(distance, SplineLength); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void SetHairpinFixtureOffset(int fixtureIndex, float railOffset) + { + if (GetFixture(fixtureIndex) is not WireRailHairpinFixture hairpin) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not a hairpin.", + nameof(fixtureIndex)); + } + SetHairpinFixtureProperties(fixtureIndex, hairpin.Endpoint, hairpin.FirstRailIndex, + hairpin.SecondRailIndex, hairpin.LoopDiameter, hairpin.LeadLength, + hairpin.TangentLength, hairpin.RingDensity, railOffset, hairpin.Rotation); + } + + public void SetElbowFixtureOffset(int fixtureIndex, float offset) + { + if (GetFixture(fixtureIndex) is not WireRailElbowFixture elbow) { + throw new ArgumentException($"Fixture {fixtureIndex + 1} is not an elbow.", + nameof(fixtureIndex)); + } + SetElbowFixtureProperties(fixtureIndex, elbow.Endpoint, elbow.FirstRailIndex, + elbow.SecondRailIndex, offset, elbow.DropLength, elbow.ZAngle, elbow.RailOffsets); + } + + public void SetFixtureEnabled(int fixtureIndex, bool enabled) + { + var fixture = GetFixture(fixtureIndex); + if (fixture.Enabled == enabled) { + return; + } + fixture.SetEnabled(enabled); + // Disabling only hides the fixture's render mesh; the collider is left as-is. + RebuildRenderGeometry(); + MarkDirty(); + } + + public void SetFixtureSolderThreshold(int fixtureIndex, float solderThreshold) + { + if (solderThreshold < 0f) { + throw new ArgumentOutOfRangeException(nameof(solderThreshold), solderThreshold, + "Solder threshold cannot be negative."); + } + var fixture = GetFixture(fixtureIndex); + if (Mathf.Approximately(fixture.SolderThreshold, solderThreshold)) { + return; + } + fixture.SetSolderThreshold(solderThreshold); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void SetFixtureSolderSize(int fixtureIndex, float solderSize) + { + if (solderSize <= 0f) { + throw new ArgumentOutOfRangeException(nameof(solderSize), solderSize, + "Solder size must be positive."); + } + var fixture = GetFixture(fixtureIndex); + if (Mathf.Approximately(fixture.SolderSize, solderSize)) { + return; + } + fixture.SetSolderSize(solderSize); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void ApplyRingPropertiesToAll(int sourceFixtureIndex) + { + if (GetFixture(sourceFixtureIndex) is not WireRailRingFixture source) { + throw new ArgumentException($"Fixture {sourceFixtureIndex + 1} is not a ring.", + nameof(sourceFixtureIndex)); + } + for (var fixtureIndex = 0; fixtureIndex < _fixtures.Count; fixtureIndex++) { + if (fixtureIndex == sourceFixtureIndex + || _fixtures[fixtureIndex] is not WireRailRingFixture target) { + continue; + } + target.SetProperties(target.Distance, SplineLength, source.Diameter, + source.HasCutout, source.CutoutStartAngle, source.CutoutEndAngle, + source.HasStraightSection, source.StraightStartAngle, + source.StraightEndAngle, source.LateralOffset, source.VerticalOffset, + source.Scale, source.RingDensity); + target.SetSolderThreshold(source.SolderThreshold); + target.SetSolderSize(source.SolderSize); + } + RebuildRenderGeometry(); + MarkDirty(); + } + + public void SetWireCapBevelSize(float size) + { + _wireCapBevelSize = math.max(0f, size); + RebuildRenderGeometry(); + MarkDirty(); + } + + public void MoveFixture(int fromIndex, int toIndex) + { + SynchronizeFixtures(); + if (fromIndex < 0 || fromIndex >= _fixtures.Count) { + throw new ArgumentOutOfRangeException(nameof(fromIndex)); + } + if (toIndex < 0 || toIndex >= _fixtures.Count) { + throw new ArgumentOutOfRangeException(nameof(toIndex)); + } + if (fromIndex == toIndex) { + return; + } + var fixture = _fixtures[fromIndex]; + _fixtures.RemoveAt(fromIndex); + _fixtures.Insert(toIndex, fixture); + RebuildRenderGeometry(); + MarkDirty(); + } + + private WireRailFixture GetFixture(int fixtureIndex) + { + SynchronizeFixtures(); + if (fixtureIndex < 0 || fixtureIndex >= _fixtures.Count) { + throw new ArgumentOutOfRangeException(nameof(fixtureIndex), fixtureIndex, + $"The wire rail has {_fixtures.Count} fixture(s)."); + } + return _fixtures[fixtureIndex]; + } + + private bool SynchronizeFixtures() + { + _fixtures ??= new List(); + var changed = false; + var spline = GetSplineContainerWithoutCreating()?.Spline; + for (var fixtureIndex = _fixtures.Count - 1; fixtureIndex >= 0; fixtureIndex--) { + var fixture = _fixtures[fixtureIndex]; + if (fixture == null) { + _fixtures.RemoveAt(fixtureIndex); + changed = true; + continue; + } + if (fixture is WireRailRingFixture ring) { + changed |= ring.EnsureRingInitialized(SplineLength); + changed |= ring.SetDiameter(_wireDiameter); + if (!ring.ScaleInitialized && spline != null && _segments != null + && _segments.Count > 0 + && WireRailFixtureMeshGenerator.TryEvaluateRingProfile(spline, + _segments, ring, out var profile)) { + changed |= ring.EnsureScaleInitialized(profile.BaseRadius); + } + } else if (fixture is WireRailRungFixture rung) { + changed |= rung.EnsureRungInitialized(SplineLength); + changed |= rung.SetDiameter(_wireDiameter); + } else if (fixture is WireRailCradleFixture cradle) { + changed |= cradle.SetDiameter(_wireDiameter); + changed |= cradle.EnsureCradleInitialized(SplineLength); + } else if (fixture is WireRailStandFixture leg) { + changed |= leg.EnsureStandInitialized(SplineLength); + changed |= leg.SetDiameter(_wireDiameter); + } else if (fixture is WireRailHairpinFixture hairpin) { + changed |= hairpin.EnsureHairpinInitialized(SplineLength); + changed |= hairpin.SetDiameter(_wireDiameter); + } else if (fixture is WireRailElbowFixture elbow) { + changed |= elbow.EnsureElbowInitialized(SplineLength, _railCount); + changed |= elbow.SetDiameter(_wireDiameter); + if (!elbow.RailPairInitialized) { + var firstRailIndex = elbow.FirstRailIndex; + var secondRailIndex = elbow.SecondRailIndex; + var pairResolved = AreEndpointRailsActive(elbow.Endpoint, + firstRailIndex, secondRailIndex); + if (!pairResolved) { + pairResolved = TryGetDefaultEndpointRailPairFromSegments( + elbow.Endpoint, out firstRailIndex, out secondRailIndex); + } + if (pairResolved) { + changed |= elbow.EnsureRailPairInitialized(firstRailIndex, + secondRailIndex); + } + } + } else if (fixture is WireRailTrimFixture railTrim) { + changed |= railTrim.EnsureRailTrimInitialized(SplineLength, _railCount); + } else { + changed |= fixture.EnsureInitialized(SplineLength); + } + } + return changed; + } + + public bool SynchronizeSegments() + { + _segments ??= new List(); + var changed = false; + var migratingRailCount = !_railCountInitialized; + for (var i = 0; i < _segments.Count; i++) { + if (_segments[i] == null) { + _segments[i] = new WireRailSegment(); + changed = true; + } else if (_segments[i].RailCount == 0) { + changed = true; + } + changed |= _segments[i].EnsureInitialized(_wireDiameter); + changed |= _segments[i].SetAllWireDiameters(_wireDiameter); + } + + if (_segments.Count == 0 && GetSplineSegmentCount() > 0) { + _segments.Add(new WireRailSegment()); + changed |= _segments[0].EnsureInitialized(_wireDiameter); + changed = true; + } + if (migratingRailCount) { + _railCount = math.clamp(_segments.Count > 0 + ? _segments.Max(segment => segment.RailCount) : 4, 1, 6); + _railCountInitialized = true; + changed = true; + } else { + var clampedRailCount = math.clamp(_railCount, 1, 6); + if (_railCount != clampedRailCount) { + _railCount = clampedRailCount; + changed = true; + } + } + foreach (var layout in _segments) { + changed |= layout.ResizeRailCount(_railCount, _wireDiameter, + !migratingRailCount, false); + } + var splineLength = SplineLength; + // Older scenes stored one layout per spline curve and therefore had no explicit + // distances. Preserve their shape once by placing those layouts at the old curve + // boundaries; from then on the list is completely independent from spline knots. + if (splineLength > 0f && _segments.Count > 1 && _segments.All(segment => + Mathf.Approximately(segment.Distance, 0f))) { + var spline = GetSplineContainerWithoutCreating()?.Spline; + var distance = 0f; + for (var layoutIndex = 0; layoutIndex < _segments.Count; layoutIndex++) { + _segments[layoutIndex].SetDistance(distance, splineLength); + if (spline != null && layoutIndex < GetSplineSegmentCount()) { + distance += spline.GetCurveLength(layoutIndex); + } + } + changed = true; + } + if (_segments.Count > 0) { + _segments[0].SetDistance(0f, splineLength); + for (var layoutIndex = 1; layoutIndex < _segments.Count; layoutIndex++) { + var clampedDistance = math.clamp(_segments[layoutIndex].Distance, 0f, + splineLength); + if (!Mathf.Approximately(_segments[layoutIndex].Distance, clampedDistance)) { + _segments[layoutIndex].SetDistance(clampedDistance, splineLength); + changed = true; + } + } + } + changed |= SynchronizeLayoutDisplayOrder(); + changed |= SortLayoutsByDistance(); + changed |= SynchronizeSegmentConnections(); + changed |= SynchronizeFixtures(); + + if (changed) { + // Scene previews are version-keyed. Any synchronization mutation must + // participate in the same invalidation contract as an explicit edit. + InvalidateGeneratedGeometry(); + MarkDirty(); + } + return changed; + } + + public int AddLayout(float distance) + { + SynchronizeSegments(); + if (_segments.Count == 0) { + throw new InvalidOperationException("A wire layout needs a valid spline."); + } + distance = math.clamp(distance, 0f, SplineLength); + var insertIndex = GetLayoutInsertIndex(distance); + var source = _segments[insertIndex - 1]; + var layout = source.Clone(_wireDiameter); + layout.SetDistance(distance, SplineLength); + source.ResetConnection(); + _segments.Insert(insertIndex, layout); + InsertLayoutDisplayIndex(insertIndex, _layoutDisplayOrder.Count); + SynchronizeSegmentConnections(); + RebuildGeneratedMeshes(); + MarkDirty(); + return insertIndex; + } + + public int DuplicateLayout(int sourceLayoutIndex, float distance) + { + SynchronizeSegments(); + if (sourceLayoutIndex < 0 || sourceLayoutIndex >= _segments.Count) { + throw new ArgumentOutOfRangeException(nameof(sourceLayoutIndex)); + } + distance = math.clamp(distance, 0f, SplineLength); + var sourceDisplayIndex = _layoutDisplayOrder.IndexOf(sourceLayoutIndex); + var insertIndex = GetLayoutInsertIndex(distance); + var source = _segments[sourceLayoutIndex]; + var layout = source.Clone(_wireDiameter); + layout.SetDistance(distance, SplineLength); + if (sourceLayoutIndex == insertIndex - 1) { + source.ResetConnection(); + } else { + // The physically last layout is duplicated before itself. Its predecessor + // keeps the authored transition into the identical copy, while the copy + // receives a default transition into the original last layout. + layout.ResetConnection(); + } + _segments.Insert(insertIndex, layout); + InsertLayoutDisplayIndex(insertIndex, sourceDisplayIndex + 1); + SynchronizeSegmentConnections(); + RebuildGeneratedMeshes(); + MarkDirty(); + return insertIndex; + } + + public float GetSuggestedLayoutDistance(int sourceLayoutIndex = -1) + { + SynchronizeSegments(); + if (sourceLayoutIndex < -1 || sourceLayoutIndex >= _segments.Count) { + throw new ArgumentOutOfRangeException(nameof(sourceLayoutIndex)); + } + if (sourceLayoutIndex >= 0) { + // Halfway to the next layout, or halfway to the end of the route when the + // source is the last one, so a duplicate always lands in the source's span. + var nextDistance = sourceLayoutIndex + 1 < _segments.Count + ? _segments[sourceLayoutIndex + 1].Distance + : SplineLength; + return (_segments[sourceLayoutIndex].Distance + nextDistance) * 0.5f; + } + if (_segments.Count > 1) { + return (_segments[^2].Distance + _segments[^1].Distance) * 0.5f; + } + return _segments.Count == 1 + ? (_segments[0].Distance + SplineLength) * 0.5f + : 0f; + } + + public int GetLayoutDisplayIndex(int layoutIndex) + { + if (_segments == null || layoutIndex < 0 || layoutIndex >= _segments.Count) { + throw new ArgumentOutOfRangeException(nameof(layoutIndex)); + } + var displayIndex = _layoutDisplayOrder?.IndexOf(layoutIndex) ?? -1; + return displayIndex >= 0 ? displayIndex : layoutIndex; + } + + public void RemoveLayout(int layoutIndex) + { + SynchronizeSegments(); + if (_segments.Count <= 1) { + throw new InvalidOperationException("A wire rail needs at least one layout."); + } + if (layoutIndex < 0 || layoutIndex >= _segments.Count) { + throw new ArgumentOutOfRangeException(nameof(layoutIndex)); + } + var previousIndex = layoutIndex - 1; + if (previousIndex >= 0) { + _segments[previousIndex].CopyConnectionFrom(_segments[layoutIndex]); + } + RemoveLayoutDisplayIndex(layoutIndex); + _segments.RemoveAt(layoutIndex); + if (_segments.Count > 0) { + _segments[0].SetDistance(0f, SplineLength); + } + SynchronizeSegmentConnections(); + RebuildGeneratedMeshes(); + MarkDirty(); + } + + /// + /// Moves a layout to any distance along the route. The physical order follows the + /// distances, so a layout moved past its neighbor swaps places with it. Layout 0 is + /// pinned to the route start. + /// + /// The layout's physical index after the move. + public int SetLayoutDistance(int layoutIndex, float distance) + { + var layout = GetSegment(layoutIndex); + distance = layoutIndex == 0 ? 0f : math.clamp(distance, 0f, SplineLength); + layout.SetDistance(distance, SplineLength); + SortLayoutsByDistance(); + SynchronizeSegmentConnections(); + RebuildGeneratedMeshes(); + MarkDirty(); + return _segments.IndexOf(layout); + } + + /// + /// Keeps the physical layout list ordered by distance, layout 0 first. Layouts at the + /// same distance keep their relative order. The display order is remapped so every + /// "Layout N" keeps pointing at the same layout. + /// + /// True when the physical order changed. + private bool SortLayoutsByDistance() + { + if (_segments.Count < 3) { + return false; + } + var sorted = true; + for (var layoutIndex = 2; layoutIndex < _segments.Count; layoutIndex++) { + if (_segments[layoutIndex].Distance < _segments[layoutIndex - 1].Distance) { + sorted = false; + break; + } + } + if (sorted) { + return false; + } + var oldOrder = new List(_segments.Count) { 0 }; + oldOrder.AddRange(Enumerable.Range(1, _segments.Count - 1) + .OrderBy(layoutIndex => _segments[layoutIndex].Distance)); + var newIndexOfOld = new int[_segments.Count]; + var reordered = new List(_segments.Count); + for (var newIndex = 0; newIndex < oldOrder.Count; newIndex++) { + newIndexOfOld[oldOrder[newIndex]] = newIndex; + reordered.Add(_segments[oldOrder[newIndex]]); + } + _segments.Clear(); + _segments.AddRange(reordered); + if (_layoutDisplayOrder != null) { + for (var displayIndex = 0; displayIndex < _layoutDisplayOrder.Count; displayIndex++) { + var oldIndex = _layoutDisplayOrder[displayIndex]; + if (oldIndex >= 0 && oldIndex < newIndexOfOld.Length) { + _layoutDisplayOrder[displayIndex] = newIndexOfOld[oldIndex]; + } + } + } + return true; + } + + public void MoveLayout(int fromIndex, int toIndex) + { + SynchronizeSegments(); + if (fromIndex < 0 || fromIndex >= _layoutDisplayOrder.Count) { + throw new ArgumentOutOfRangeException(nameof(fromIndex)); + } + if (toIndex < 0 || toIndex >= _layoutDisplayOrder.Count) { + throw new ArgumentOutOfRangeException(nameof(toIndex)); + } + if (fromIndex == toIndex) { + return; + } + var layoutIndex = _layoutDisplayOrder[fromIndex]; + _layoutDisplayOrder.RemoveAt(fromIndex); + _layoutDisplayOrder.Insert(toIndex, layoutIndex); + MarkDirty(); + } + + private int GetLayoutInsertIndex(float distance) + { + var insertIndex = 1; + while (insertIndex < _segments.Count + && _segments[insertIndex].Distance <= distance) { + insertIndex++; + } + return insertIndex; + } + + private bool SynchronizeLayoutDisplayOrder() + { + _layoutDisplayOrder ??= new List(); + var valid = _layoutDisplayOrder.Count == _segments.Count; + var seen = valid ? new bool[_segments.Count] : null; + if (valid) { + foreach (var layoutIndex in _layoutDisplayOrder) { + if (layoutIndex < 0 || layoutIndex >= _segments.Count + || seen[layoutIndex]) { + valid = false; + break; + } + seen[layoutIndex] = true; + } + } + if (valid) { + return false; + } + _layoutDisplayOrder.Clear(); + for (var layoutIndex = 0; layoutIndex < _segments.Count; layoutIndex++) { + _layoutDisplayOrder.Add(layoutIndex); + } + return true; + } + + private void InsertLayoutDisplayIndex(int layoutIndex, int displayIndex) + { + for (var index = 0; index < _layoutDisplayOrder.Count; index++) { + if (_layoutDisplayOrder[index] >= layoutIndex) { + _layoutDisplayOrder[index]++; + } + } + _layoutDisplayOrder.Insert(math.clamp(displayIndex, 0, + _layoutDisplayOrder.Count), layoutIndex); + } + + private void RemoveLayoutDisplayIndex(int layoutIndex) + { + _layoutDisplayOrder.Remove(layoutIndex); + for (var index = 0; index < _layoutDisplayOrder.Count; index++) { + if (_layoutDisplayOrder[index] > layoutIndex) { + _layoutDisplayOrder[index]--; + } + } + } + + private bool SynchronizeSegmentConnections() + { + if (_segments == null) { + return false; + } + var changed = false; + for (var segmentIndex = 0; segmentIndex < _segments.Count; segmentIndex++) { + var nextSegmentIndex = GetNextSegmentIndex(segmentIndex); + var wireCount = nextSegmentIndex < 0 ? 0 : _railCount; + changed |= _segments[segmentIndex].EnsureConnectionInitialized(wireCount); + } + return changed; + } + + private WireRailSegment GetSegment(int segmentIndex) + { + SynchronizeSegments(); + if (segmentIndex < 0 || segmentIndex >= _segments.Count) { + throw new ArgumentOutOfRangeException(nameof(segmentIndex), segmentIndex, + $"The spline has {_segments.Count} segment(s)."); + } + return _segments[segmentIndex]; + } + + private void OnSplineChanged(Spline spline, int knotIndex, + SplineModification modification) + { + var container = GetSplineContainerWithoutCreating(); + if (!container || !ReferenceEquals(spline, container.Spline)) { + return; + } + + RebuildGeneratedMeshes(); + MarkDirty(); + } + + private void OnSplineCollectionChanged(SplineContainer container, int _) + { + if (container == GetSplineContainerWithoutCreating()) { + RebuildGeneratedMeshes(); + } + } + + private int GetSplineSegmentCount() + { + var container = GetSplineContainerWithoutCreating(); + var spline = container ? container.Spline : null; + if (spline == null || spline.Count == 0) { + return 0; + } + return math.max(0, spline.Count - (spline.Closed ? 0 : 1)); + } + + public void RebuildGeneratedMeshes() + { + InvalidateGeneratedGeometry(); +#if UNITY_EDITOR + if (!Application.isPlaying + && (Event.current != null || DeferEditorRebuildsForTesting)) { + ScheduleEditorRebuild(false); + return; + } +#endif + RebuildGeneratedMeshesImmediately(); + } + + public void RebuildRenderGeometry() + { + InvalidateRenderGeometry(); +#if UNITY_EDITOR + if (!Application.isPlaying + && (Event.current != null || DeferEditorRebuildsForTesting)) { + ScheduleEditorRebuild(false); + return; + } +#endif + RebuildGeneratedMeshesImmediately(); + } + + private void RebuildGeneratedMeshesImmediately() + { + if (_rebuildingGeneratedMeshes) { + return; + } + _rebuildingGeneratedMeshes = true; + try { + var container = GetSplineContainerWithoutCreating(); + if (!container) { + _generationError = "The generated Wire Rail Spline child is missing."; + return; + } + using (SynchronizeSegmentsMarker.Auto()) { + SynchronizeSegments(); + } + using (RenderMeshMarker.Auto()) { + // Only enabled fixtures contribute to the render mesh (own geometry, rail + // trims, end fitting and solder). Colliders still use the full list. + _enabledFixtures.Clear(); + foreach (var fixture in _fixtures) { + if (fixture != null && fixture.Enabled) { + _enabledFixtures.Add(fixture); + } + } + _renderMesh = WireRailRenderMeshGenerator.Generate(container.Spline, _segments, + _enabledFixtures, _wireCapBevelSize, _renderSamplesPerSegment, _radialSegments, + _renderMesh, _renderSegmentIndexRanges, _enabledFixtureIndexRanges); + _renderMeshGenerationCount++; + // The generator only saw the enabled fixtures; spread its ranges back over + // the full list so callers can index them by fixture. + _renderFixtureIndexRanges.Clear(); + var enabledRangeIndex = 0; + foreach (var fixture in _fixtures) { + var range = int2.zero; + if (fixture != null && fixture.Enabled) { + if (enabledRangeIndex < _enabledFixtureIndexRanges.Count) { + range = _enabledFixtureIndexRanges[enabledRangeIndex]; + } + enabledRangeIndex++; + } + _renderFixtureIndexRanges.Add(range); + } + } + + var meshFilter = GetOrAddComponent(container.gameObject); + var hadRenderer = container.gameObject.TryGetComponent( + out var meshRenderer); + if (!hadRenderer) { + meshRenderer = container.gameObject.AddComponent(); + } + if (meshFilter.sharedMesh != _renderMesh) { + meshFilter.sharedMesh = _renderMesh; + } + AssignRenderMaterial(meshRenderer, hadRenderer); + } finally { + _rebuildingGeneratedMeshes = false; + } + } + + public void RebuildColliderMesh() + { + InvalidateColliderGeometry(); + EnsureColliderMesh(); + } + + public void SetShowColliderPreview(bool show) + { + if (_showColliderPreview == show) { + return; + } + _showColliderPreview = show; + MarkDirty(); + } + + public void InvalidateColliderGeometry() + { + _colliderGeometryDirty = true; + _collidersDirty = true; + _colliderTopologyRetryCount = 0; + _generationError = null; + unchecked { + _colliderGeometryVersion++; + } + } + + private void InvalidateGeneratedGeometry() + { + InvalidateColliderGeometry(); + InvalidateRenderGeometry(); + } + + private void InvalidateRenderGeometry() + { + unchecked { + _renderGeometryVersion++; + } + } + + private bool EnsureColliderMesh() + { + if (!_colliderGeometryDirty) { + return _colliderMesh && _colliderMesh.vertexCount > 0; + } + var container = GetSplineContainerWithoutCreating(); + if (!container || container.Spline == null) { + _generationError = "The generated Wire Rail Spline child is missing."; + return false; + } + using (SynchronizeSegmentsMarker.Auto()) { + SynchronizeSegments(); + } + using (ColliderMeshMarker.Auto()) { + if (!WireRailColliderMeshGenerator.TryGenerate(container.Spline, _segments, + _fixtures, _referenceBallDiameter, _colliderSamplesPerSegment, + _colliderMesh, out _colliderMesh, out _colliderEdgeVertices, + out _colliderTopologyRetryCount, + out _generationError)) { + if (_colliderMesh) { + _colliderMesh.Clear(false); + } else { + _colliderMesh = null; + } + _colliderEdgeVertices = Array.Empty(); + } + } + _colliderGeometryDirty = false; + return _colliderMesh && _colliderMesh.vertexCount > 0; + } + + public SplineContainer EnsureSplineContainerExists() + => EnsureSplineContainer(); + + private void AssignRenderMaterial(MeshRenderer renderer, bool adoptExisting) + { + var pipeline = GraphicsSettings.currentRenderPipeline; + var pipelineDefault = pipeline ? pipeline.defaultMaterial : GetBuiltinDefaultMaterial(); + if (adoptExisting && !_renderMaterial && renderer.sharedMaterial + && renderer.sharedMaterial != pipelineDefault) { + // The renderer arrived with a material of its own, e.g. restored from a + // package; that is the author's choice, not something to overwrite. + _renderMaterial = renderer.sharedMaterial; + } + var material = _renderMaterial ? _renderMaterial : pipelineDefault; + if (renderer.sharedMaterial != material) { + renderer.sharedMaterial = material; + } + } + + private static Material GetBuiltinDefaultMaterial() + { + if (!_builtinDefaultMaterial) { + _builtinDefaultMaterial = Resources.Load( + "Materials/Table Opaque (Builtin)"); + } + return _builtinDefaultMaterial; + } + + private static T GetOrAddComponent(GameObject gameObject) where T : Component + { + var component = gameObject.GetComponent(); + return component ? component : gameObject.AddComponent(); + } + + private SplineContainer EnsureSplineContainer() + { + var existing = GetSplineContainerWithoutCreating(); + if (existing) { + return existing; + } + + // A child that already carries the marker or the legacy name but no container yet + // (a package import restores the container in the child's own unpack step) is + // adopted rather than shadowed by a second child. + var splineObject = FindSplineChildWithoutContainer(); + var created = !splineObject; + if (created) { + splineObject = new GameObject(SplineObjectName); + splineObject.transform.SetParent(transform, false); + splineObject.transform.localPosition = Vector3.zero; + splineObject.transform.localRotation = ((Matrix4x4)Physics.VpxToWorld).rotation; + splineObject.transform.localScale = Physics.ScaleInvVector; + } + HardenSplineChild(splineObject); + _splineContainer = splineObject.AddComponent(); + _splineContainer.Spline = CreateDefaultSpline(); + +#if UNITY_EDITOR + if (!Application.isPlaying) { + if (created && !Undo.isProcessing) { + Undo.RegisterCreatedObjectUndo(splineObject, "Create Wire Rail Spline"); + } + EditorUtility.SetDirty(this); + } +#endif + return _splineContainer; + } + + private GameObject FindSplineChildWithoutContainer() + { + for (var i = 0; i < transform.childCount; i++) { + var candidate = transform.GetChild(i); + if (candidate.TryGetComponent(out _)) { + continue; + } + if (candidate.TryGetComponent(out _) + || candidate.name == SplineObjectName) { + return candidate.gameObject; + } + } + return null; + } + + private SplineContainer GetSplineContainerWithoutCreating() + { + if (_splineContainer && _splineContainer.transform.parent == transform) { + return _splineContainer; + } + + // Prefer the marked child; fall back to the legacy name for scenes saved before + // the marker existed, and harden whatever we find. + _splineContainer = null; + for (var i = 0; i < transform.childCount; i++) { + var candidate = transform.GetChild(i); + if (candidate.TryGetComponent(out _) + && candidate.TryGetComponent(out var marked)) { + _splineContainer = marked; + break; + } + } + if (!_splineContainer) { + var child = transform.Find(SplineObjectName); + _splineContainer = child ? child.GetComponent() : null; + } + if (_splineContainer) { + HardenSplineChild(_splineContainer.gameObject); + } + return _splineContainer; + } + + /// + /// Makes sure the spline child carries its marker and that its transform, which holds + /// the VPX-to-world conversion, is locked in the inspector. Idempotent. + /// + private static void HardenSplineChild(GameObject splineObject) + { + if (!splineObject.TryGetComponent(out _)) { +#if UNITY_EDITOR + if (!Application.isPlaying && !Undo.isProcessing) { + Undo.AddComponent(splineObject); + } else { + splineObject.AddComponent(); + } +#else + splineObject.AddComponent(); +#endif + } + if ((splineObject.transform.hideFlags & HideFlags.NotEditable) == 0) { + splineObject.transform.hideFlags |= HideFlags.NotEditable; +#if UNITY_EDITOR + if (!Application.isPlaying) { + EditorUtility.SetDirty(splineObject.transform); + } +#endif + } + } + + private static Spline CreateDefaultSpline() + { + var spline = new Spline(2, false); + var rotation = quaternion.RotateX(math.PI * 0.5f); + var start = new BezierKnot(new float3(0f, 0f, 0f)) { Rotation = rotation }; + var end = new BezierKnot(new float3(0f, 500f, 0f)) { Rotation = rotation }; + spline.Add(start, TangentMode.AutoSmooth); + spline.Add(end, TangentMode.AutoSmooth); + return spline; + } + + public int ItemId => UnityObjectId.Get(gameObject); + public bool IsKinematic => false; + + public bool CollidersDirty { + set => _collidersDirty = value; + } + + public float PhysicsElasticity { + get => _elasticity; + set { + _elasticity = value; + _collidersDirty = true; + } + } + + public float PhysicsElasticityFalloff { + get => _elasticityFalloff; + set { + _elasticityFalloff = value; + _collidersDirty = true; + } + } + + public float PhysicsFriction { + get => _friction; + set { + _friction = value; + _collidersDirty = true; + } + } + + public float PhysicsScatter { + get => _scatter; + set { + _scatter = value; + _collidersDirty = true; + } + } + + public bool PhysicsOverwrite { + get => _overwritePhysics; + set { + _overwritePhysics = value; + _collidersDirty = true; + } + } + + public PhysicsMaterialAsset PhysicsMaterialReference { + get => _physicsMaterial; + set { + _physicsMaterial = value; + _collidersDirty = true; + } + } + + public PhysicsMaterialAsset TerminalPhysicsMaterialReference { + get => _terminalPhysicsMaterial; + set { + _terminalPhysicsMaterial = value; + _collidersDirty = true; + } + } + + public float4x4 GetLocalToPlayfieldMatrixInVpx(float4x4 worldToPlayfield) + { + var container = GetSplineContainerWithoutCreating(); + if (!container) { + return Physics.GetLocalToPlayfieldMatrixInVpx(transform.localToWorldMatrix, + worldToPlayfield); + } + + // Collider vertices remain in VPX units, but are local to the spline child + // that also positions the generated render mesh. Convert that child space + // directly to playfield VPX space without applying VpxToWorld a second time. + return math.mul(Physics.WorldToVpx, math.mul(worldToPlayfield, + (float4x4)container.transform.localToWorldMatrix)); + } + + public void OnTransformationChanged(float4x4 currTransformationMatrix) + { + _collidersDirty = true; + } + + void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, + ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) + { + if (!EnsureColliderMesh()) { + return; + } + var material = !_overwritePhysics && _physicsMaterial + ? new PhysicsMaterialData { + Elasticity = _physicsMaterial.Elasticity, + ElasticityFalloff = _physicsMaterial.ElasticityFalloff, + Friction = _physicsMaterial.Friction, + ScatterAngleRad = _physicsMaterial.ScatterAngle, + } + : new PhysicsMaterialData { + Elasticity = _elasticity, + ElasticityFalloff = _elasticityFalloff, + Friction = _friction, + ScatterAngleRad = math.radians(_scatter), + }; + var info = new ColliderInfo { + Id = -1, + ItemId = ItemId, + ItemType = ItemType.MetalWireGuide, + Material = material, + HitThreshold = 0f, + FireEvents = false, + }; + using var vertices = new NativeArray(_colliderMesh.vertices, + Allocator.TempJob); + using var channelIndices = new NativeArray(_colliderMesh.GetIndices(0), + Allocator.TempJob); + ColliderUtils.GenerateCollidersFromMesh(in vertices, in channelIndices, + translateWithinPlayfieldMatrix, info, ref colliders, true); + if (_colliderMesh.subMeshCount > 1 && _colliderMesh.GetIndexCount(1) > 0) { + var terminalInfo = info; + if (_terminalPhysicsMaterial) { + terminalInfo.Material = new PhysicsMaterialData { + Elasticity = _terminalPhysicsMaterial.Elasticity, + ElasticityFalloff = _terminalPhysicsMaterial.ElasticityFalloff, + Friction = _terminalPhysicsMaterial.Friction, + ScatterAngleRad = _terminalPhysicsMaterial.ScatterAngle, + }; + } + using var terminalIndices = new NativeArray(_colliderMesh.GetIndices(1), + Allocator.TempJob); + ColliderUtils.GenerateCollidersFromMesh(in vertices, in terminalIndices, + translateWithinPlayfieldMatrix, terminalInfo, ref colliders, true); + } + + var points = new HashSet(); + for (var i = 0; i + 1 < _colliderEdgeVertices.Length; i += 2) { + var start = _colliderEdgeVertices[i]; + var end = _colliderEdgeVertices[i + 1]; + colliders.Add(new Line3DCollider(start, end, info), + translateWithinPlayfieldMatrix); + points.Add(start); + points.Add(end); + } + foreach (var point in points) { + colliders.Add(new PointCollider(point, info), translateWithinPlayfieldMatrix); + } + _collidersDirty = false; + } + + bool ICollidableComponent.IsCollidable + => isActiveAndEnabled && EnsureColliderMesh(); + + private void OnDestroy() + => DestroyGeneratedMeshes(); + + private void DestroyGeneratedMeshes() + { + DestroyGeneratedMesh(_renderMesh); + DestroyGeneratedMesh(_colliderMesh); + _renderMesh = null; + _colliderMesh = null; + _colliderEdgeVertices = Array.Empty(); + _collidersDirty = true; + _colliderGeometryDirty = true; + } + + private static void DestroyGeneratedMesh(Mesh mesh) + { + if (!mesh) { + return; + } + if (Application.isPlaying) { + Destroy(mesh); + } else { + DestroyImmediate(mesh); + } + } + + private void MarkDirty() + { +#if UNITY_EDITOR + if (!Application.isPlaying) { + EditorUtility.SetDirty(this); + PrefabUtility.RecordPrefabInstancePropertyModifications(this); + } +#endif + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailComponent.cs.meta new file mode 100644 index 000000000..019351a33 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cf835f77cc9f4d54b23aac239c37c4da +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 24ebcd9b33c055d4386f33f50c9a59a1, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailGeometryGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailGeometryGenerator.cs new file mode 100644 index 000000000..9d76fd679 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailGeometryGenerator.cs @@ -0,0 +1,4654 @@ +// 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 UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Splines; + +namespace VisualPinball.Unity +{ + internal readonly struct WireRailPathFrame + { + public readonly float3 Position; + public readonly float3 Tangent; + public readonly float3 Right; + public readonly float3 Up; + + public WireRailPathFrame(float3 position, float3 tangent, float3 right, float3 up) + { + Position = position; + Tangent = tangent; + Right = right; + Up = up; + } + + public float3 TransformOffset(float2 offset) + => Position + Right * offset.x + Up * offset.y; + + public float3 TransformOffsetVector(float2 offset) + => Right * offset.x + Up * offset.y; + } + + public sealed class WireRailPathEvaluationContext + { + internal readonly Dictionary BoundaryTangents = new(); + internal readonly Dictionary LayoutFrames = new(); + internal readonly Dictionary RailFrames = new(); + internal Spline Spline; + internal float SplineLength; + + internal void Prepare(Spline spline) + { + if (ReferenceEquals(Spline, spline)) { + return; + } + Reset(spline); + } + + internal void Reset(Spline spline) + { + Spline = spline; + SplineLength = spline?.GetLength() ?? 0f; + BoundaryTangents.Clear(); + LayoutFrames.Clear(); + RailFrames.Clear(); + } + } + + internal readonly struct WireRailFrameCacheKey : IEquatable + { + private readonly int _segmentIndex; + private readonly int _railIndex; + private readonly int _curveT; + private readonly int _tangentStep; + + public WireRailFrameCacheKey(int segmentIndex, int railIndex, float curveT, + float tangentStep) + { + _segmentIndex = segmentIndex; + _railIndex = railIndex; + _curveT = math.asint(curveT); + _tangentStep = math.asint(tangentStep); + } + + public bool Equals(WireRailFrameCacheKey other) + => _segmentIndex == other._segmentIndex && _railIndex == other._railIndex + && _curveT == other._curveT && _tangentStep == other._tangentStep; + + public override bool Equals(object obj) + => obj is WireRailFrameCacheKey other && Equals(other); + + public override int GetHashCode() + { + unchecked { + var hash = _segmentIndex; + hash = hash * 397 ^ _railIndex; + hash = hash * 397 ^ _curveT; + return hash * 397 ^ _tangentStep; + } + } + } + + internal readonly struct WireRailBoundaryTangent + { + public readonly float3 Incoming; + public readonly float3 Outgoing; + public readonly float3 Shared; + + public WireRailBoundaryTangent(float3 incoming, float3 outgoing, float3 shared) + { + Incoming = incoming; + Outgoing = outgoing; + Shared = shared; + } + } + + public static class WireRailSplineGeometry + { + private const float ConnectionTangentBlend = 0.5f; + private const float ConnectionTangentSampleStep = 1f / 1024f; + private static readonly ProfilerMarker RailFrameEvalMarker = + new("WireRail.RailFrameEval"); + + internal static bool TryEvaluate(Spline spline, int segmentIndex, float curveT, + out WireRailPathFrame frame) + { + frame = default; + if (spline == null || spline.Count < 2) { + return false; + } + var knotT = segmentIndex + math.saturate(curveT); + var normalizedT = SplineUtility.GetNormalizedInterpolation(spline, knotT, + PathIndexUnit.Knot); + if (!spline.Evaluate(normalizedT, out var position, out var tangent, out var up)) { + return false; + } + + 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 right = math.normalizesafe(math.cross(tangent, up), new float3(1f, 0f, 0f)); + up = math.normalizesafe(math.cross(right, tangent), new float3(0f, 0f, 1f)); + frame = new WireRailPathFrame(position, tangent, right, up); + return true; + } + + internal static bool TryEvaluateLayout(Spline spline, + IReadOnlyList layouts, int layoutIndex, float layoutT, + out WireRailPathFrame frame) + => TryEvaluateLayout(spline, layouts, null, layoutIndex, layoutT, out frame); + + internal static bool TryEvaluateLayout(Spline spline, + IReadOnlyList layouts, WireRailPathEvaluationContext context, + int layoutIndex, float layoutT, out WireRailPathFrame frame) + { + frame = default; + if (spline == null || layouts == null || layoutIndex < 0 + || layoutIndex >= layouts.Count || spline.Count < 2) { + return false; + } + layoutT = math.saturate(layoutT); + context?.Prepare(spline); + var cacheKey = ((long)layoutIndex << 32) | (uint)math.asint(layoutT); + if (context != null && context.LayoutFrames.TryGetValue(cacheKey, out frame)) { + return true; + } + var splineLength = context?.SplineLength ?? spline.GetLength(); + var startDistance = math.clamp(layouts[layoutIndex].Distance, 0f, splineLength); + var endDistance = layoutIndex + 1 < layouts.Count + ? math.clamp(layouts[layoutIndex + 1].Distance, startDistance, splineLength) + : splineLength; + if (!TryEvaluateDistance(spline, + math.lerp(startDistance, endDistance, layoutT), splineLength, out frame)) { + return false; + } + context?.LayoutFrames.Add(cacheKey, frame); + return true; + } + + public static bool TryEvaluateLayoutPosition(Spline spline, + IReadOnlyList layouts, int layoutIndex, float layoutT, + out float3 position) + => TryEvaluateLayoutPosition(spline, layouts, null, layoutIndex, layoutT, + out position); + + public static bool TryEvaluateLayoutPosition(Spline spline, + IReadOnlyList layouts, WireRailPathEvaluationContext context, + int layoutIndex, float layoutT, out float3 position) + { + position = default; + if (!TryEvaluateLayout(spline, layouts, context, layoutIndex, layoutT, + out var frame)) { + return false; + } + position = frame.Position; + return true; + } + + internal static bool TryEvaluateDistance(Spline spline, float distance, + out WireRailPathFrame frame) + => TryEvaluateDistance(spline, distance, spline?.GetLength() ?? 0f, out frame); + + private static bool TryEvaluateDistance(Spline spline, float distance, + float splineLength, out WireRailPathFrame frame) + { + frame = default; + if (spline == null || spline.Count < 2) { + return false; + } + var normalizedT = spline.ConvertIndexUnit( + math.clamp(distance, 0f, splineLength), PathIndexUnit.Distance, + PathIndexUnit.Normalized); + if (!spline.Evaluate(normalizedT, out var position, out var tangent, out var up)) { + return false; + } + 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 right = math.normalizesafe(math.cross(tangent, up), new float3(1f, 0f, 0f)); + up = math.normalizesafe(math.cross(right, tangent), new float3(0f, 0f, 1f)); + frame = new WireRailPathFrame(position, tangent, right, up); + return true; + } + + public static bool TryEvaluateRing(Spline spline, + IReadOnlyList segments, WireRailRingFixture ring, + out float3 center, out float3 tangent, out float3 right, out float3 up, + out float radius) + { + center = default; + tangent = default; + right = default; + up = default; + radius = 0f; + if (!WireRailFixtureMeshGenerator.TryEvaluateRingProfile(spline, segments, + ring, out var profile)) { + return false; + } + center = profile.Center; + tangent = profile.Frame.Tangent; + right = profile.Frame.Right; + up = profile.Frame.Up; + radius = profile.Radius; + return true; + } + + public static bool TryEvaluateRung(Spline spline, + IReadOnlyList segments, WireRailRungFixture rung, + out float3 start, out float3 end) + { + start = default; + end = default; + if (!WireRailFixtureMeshGenerator.TryEvaluateRungProfile(spline, + segments, rung, out var profile)) { + return false; + } + start = profile.Start; + end = profile.End; + return true; + } + + public static bool TryEvaluateStand(Spline spline, + IReadOnlyList segments, WireRailStandFixture leg, + out IReadOnlyList centerlinePoints) + { + centerlinePoints = Array.Empty(); + if (!WireRailFixtureMeshGenerator.TryEvaluateStandProfile(spline, segments, + leg, out var profile)) { + return false; + } + centerlinePoints = profile.CombinedPath; + return true; + } + + public static bool TryEvaluateCradle(Spline spline, + IReadOnlyList segments, WireRailCradleFixture cradle, + out IReadOnlyList centerlinePoints) + { + centerlinePoints = Array.Empty(); + if (!WireRailFixtureMeshGenerator.TryEvaluateCradleProfile(spline, segments, + cradle, out var profile)) { + return false; + } + centerlinePoints = profile.CenterlinePoints; + return true; + } + + public static bool TryEvaluateHairpin(Spline spline, + IReadOnlyList segments, WireRailHairpinFixture hairpin, + out IReadOnlyList centerlinePoints) + { + centerlinePoints = Array.Empty(); + if (!WireRailFixtureMeshGenerator.TryEvaluateHairpinProfile(spline, segments, + hairpin, out var profile)) { + return false; + } + centerlinePoints = profile.CenterlinePoints; + return true; + } + + public static bool TryEvaluateElbow(Spline spline, + IReadOnlyList segments, WireRailElbowFixture elbow, + out IReadOnlyList firstRailPoints, + out IReadOnlyList secondRailPoints) + { + firstRailPoints = Array.Empty(); + secondRailPoints = Array.Empty(); + if (!WireRailFixtureMeshGenerator.TryEvaluateElbowProfile(spline, segments, + elbow, out var profile)) { + return false; + } + firstRailPoints = profile.FirstRailPoints; + secondRailPoints = profile.SecondRailPoints; + return true; + } + + internal static int GetLayoutIndexAtDistance( + IReadOnlyList segments, float distance, float splineLength) + { + if (segments == null || segments.Count == 0) { + return -1; + } + var clampedDistance = math.clamp(distance, 0f, math.max(0f, splineLength)); + var segmentIndex = segments.Count - 1; + for (var layoutIndex = 1; layoutIndex < segments.Count; layoutIndex++) { + if (clampedDistance < segments[layoutIndex].Distance) { + segmentIndex = layoutIndex - 1; + break; + } + } + return segmentIndex; + } + + public static float2 EvaluateRailOffset(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex, + float curveT) + { + var current = (float2)segments[segmentIndex].GetRailOffset(railIndex); + var nextSegmentIndex = GetNextSegmentIndex(spline, segments, segmentIndex); + if (!IsContinuousBoundary(segments, segmentIndex, nextSegmentIndex, railIndex)) { + return current; + } + var next = (float2)segments[nextSegmentIndex].GetRailOffset(railIndex); + var transition = segments[segmentIndex].ConnectionToNext + .EvaluateWireTransition(railIndex, curveT); + return math.lerp(current, next, transition); + } + + public static float EvaluateWireDiameter(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex, + float curveT) + { + var current = segments[segmentIndex].GetWireDiameter(railIndex); + var nextSegmentIndex = GetNextSegmentIndex(spline, segments, segmentIndex); + if (!IsContinuousBoundary(segments, segmentIndex, nextSegmentIndex, railIndex)) { + return current; + } + var next = segments[nextSegmentIndex].GetWireDiameter(railIndex); + var transition = segments[segmentIndex].ConnectionToNext + .EvaluateWireTransition(railIndex, curveT); + return math.lerp(current, next, transition); + } + + public static bool IsContinuousAtStart(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex) + => IsContinuousBoundary(segments, + GetPreviousSegmentIndex(spline, segments, segmentIndex), segmentIndex, railIndex); + + public static bool IsContinuousAtEnd(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex) + => IsContinuousBoundary(segments, segmentIndex, + GetNextSegmentIndex(spline, segments, segmentIndex), railIndex); + + internal static bool IsRailConnectedAtStart(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex) + => IsRailConnectedBoundary(segments, + GetPreviousSegmentIndex(spline, segments, segmentIndex), segmentIndex, + railIndex); + + internal static bool IsRailConnectedAtEnd(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex) + => IsRailConnectedBoundary(segments, segmentIndex, + GetNextSegmentIndex(spline, segments, segmentIndex), railIndex); + + internal static bool TryEvaluateRailFrame(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex, + float curveT, float tangentStep, out WireRailPathFrame railFrame) + => TryEvaluateRailFrame(spline, segments, new WireRailPathEvaluationContext(), + segmentIndex, railIndex, curveT, tangentStep, out railFrame); + + internal static bool TryEvaluateRailFrame(Spline spline, + IReadOnlyList segments, WireRailPathEvaluationContext context, + int segmentIndex, int railIndex, float curveT, float tangentStep, + out WireRailPathFrame railFrame) + { + railFrame = default; + curveT = math.saturate(curveT); + tangentStep = math.clamp(tangentStep, 1e-5f, 0.5f); + context ??= new WireRailPathEvaluationContext(); + context.Prepare(spline); + var cacheKey = new WireRailFrameCacheKey(segmentIndex, railIndex, curveT, + tangentStep); + if (context.RailFrames.TryGetValue(cacheKey, out railFrame)) { + return true; + } + using (RailFrameEvalMarker.Auto()) { + if (!TryEvaluateRailCenter(spline, segments, context, segmentIndex, railIndex, + curveT, out var mainFrame, out var center)) { + return false; + } + + var before = center; + var after = center; + var referenceRight = mainFrame.Right; + var referenceUp = mainFrame.Up; + if (curveT <= 0f) { + var previousSegmentIndex = GetPreviousSegmentIndex(spline, segments, + segmentIndex); + var connected = IsRailConnectedBoundary(segments, previousSegmentIndex, + segmentIndex, railIndex); + if (connected) { + if (!TryEvaluateRailCenter(spline, segments, context, previousSegmentIndex, + railIndex, 1f - tangentStep, out _, out before) + || !TryEvaluateLayout(spline, segments, context, previousSegmentIndex, + 1f, out var previousMainFrame)) { + return false; + } + AverageReferenceAxes(previousMainFrame, mainFrame, + out referenceRight, out referenceUp); + } + if (!TryEvaluateRailCenter(spline, segments, context, segmentIndex, + railIndex, tangentStep, out _, out after)) { + return false; + } + } else if (curveT >= 1f) { + if (!TryEvaluateRailCenter(spline, segments, context, segmentIndex, + railIndex, 1f - tangentStep, out _, out before)) { + return false; + } + var nextSegmentIndex = GetNextSegmentIndex(spline, segments, segmentIndex); + var connected = IsRailConnectedBoundary(segments, segmentIndex, + nextSegmentIndex, railIndex); + if (connected) { + if (!TryEvaluateRailCenter(spline, segments, context, nextSegmentIndex, + railIndex, tangentStep, out _, out after) + || !TryEvaluateLayout(spline, segments, context, nextSegmentIndex, 0f, + out var nextMainFrame)) { + return false; + } + AverageReferenceAxes(mainFrame, nextMainFrame, + out referenceRight, out referenceUp); + } + } else { + if (!TryEvaluateRailCenter(spline, segments, context, segmentIndex, + railIndex, math.max(0f, curveT - tangentStep), out _, out before) + || !TryEvaluateRailCenter(spline, segments, context, segmentIndex, + railIndex, math.min(1f, curveT + tangentStep), out _, out after)) { + return false; + } + } + + var tangentVector = after - before; + var derivativeStep = math.min(tangentStep, 1f / 1024f); + var derivativeBefore = center; + var derivativeAfter = center; + if (curveT <= 0f) { + var previousSegmentIndex = GetPreviousSegmentIndex(spline, segments, + segmentIndex); + if (IsRailConnectedBoundary(segments, previousSegmentIndex, segmentIndex, + railIndex) + && !TryEvaluateRailCenter(spline, segments, context, + previousSegmentIndex, railIndex, 1f - derivativeStep, out _, + out derivativeBefore)) { + return false; + } + if (!TryEvaluateRailCenter(spline, segments, context, segmentIndex, + railIndex, derivativeStep, out _, out derivativeAfter)) { + return false; + } + } else if (curveT >= 1f) { + if (!TryEvaluateRailCenter(spline, segments, context, segmentIndex, + railIndex, 1f - derivativeStep, out _, out derivativeBefore)) { + return false; + } + var nextSegmentIndex = GetNextSegmentIndex(spline, segments, segmentIndex); + if (IsRailConnectedBoundary(segments, segmentIndex, nextSegmentIndex, + railIndex) + && !TryEvaluateRailCenter(spline, segments, context, nextSegmentIndex, + railIndex, derivativeStep, out _, out derivativeAfter)) { + return false; + } + } else if (!TryEvaluateRailCenter(spline, segments, context, segmentIndex, + railIndex, math.max(0f, curveT - derivativeStep), out _, + out derivativeBefore) + || !TryEvaluateRailCenter(spline, segments, context, segmentIndex, + railIndex, math.min(1f, curveT + derivativeStep), out _, + out derivativeAfter)) { + return false; + } + var derivative = derivativeAfter - derivativeBefore; + if (math.lengthsq(derivative) > 1e-12f + && math.dot(math.normalize(derivative), mainFrame.Tangent) > 0f) { + tangentVector = derivative; + } + var tangent = math.normalizesafe(tangentVector, mainFrame.Tangent); + var right = referenceRight - tangent * math.dot(referenceRight, tangent); + if (math.lengthsq(right) <= 1e-8f) { + right = math.cross(tangent, referenceUp); + } + right = math.normalizesafe(right, mainFrame.Right); + var up = math.normalizesafe(math.cross(right, tangent), mainFrame.Up); + railFrame = new WireRailPathFrame(center, tangent, right, up); + context.RailFrames.Add(cacheKey, railFrame); + return true; + } + } + + public static bool TryEvaluateRailPosition(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex, + float curveT, out float3 position) + => TryEvaluateRailPosition(spline, segments, new WireRailPathEvaluationContext(), + segmentIndex, railIndex, curveT, out position); + + public static bool TryEvaluateRailPosition(Spline spline, + IReadOnlyList segments, WireRailPathEvaluationContext context, + int segmentIndex, int railIndex, float curveT, out float3 position) + { + context ??= new WireRailPathEvaluationContext(); + return TryEvaluateRailCenter(spline, segments, context, segmentIndex, railIndex, + curveT, out _, out position); + } + + private static bool TryEvaluateRailCenter(Spline spline, + IReadOnlyList segments, WireRailPathEvaluationContext context, + int segmentIndex, int railIndex, float curveT, + out WireRailPathFrame mainFrame, out float3 center) + { + curveT = math.saturate(curveT); + if (!TryEvaluateRailCenterUnsmoothed(spline, segments, context, segmentIndex, railIndex, + curveT, out mainFrame, out center)) { + return false; + } + + var previousSegmentIndex = GetPreviousSegmentIndex(spline, segments, segmentIndex); + // A compact Hermite correction rotates both sides toward the same tangent without + // moving the knot or changing the unaffected half of either segment. + if (curveT > 0f && curveT < ConnectionTangentBlend + && RequiresRailTangentSmoothing(spline, segments, previousSegmentIndex, + segmentIndex, railIndex)) { + if (!TryEvaluateSharedBoundaryTangent(spline, segments, context, + previousSegmentIndex, + segmentIndex, railIndex, out _, out var outgoing, out var sharedTangent)) { + return false; + } + var u = curveT / ConnectionTangentBlend; + var influence = ConnectionTangentBlend * u * (1f - u) * (1f - u); + center += influence * (sharedTangent * math.length(outgoing) - outgoing); + } + + var nextSegmentIndex = GetNextSegmentIndex(spline, segments, segmentIndex); + if (curveT > 1f - ConnectionTangentBlend && curveT < 1f + && RequiresRailTangentSmoothing(spline, segments, segmentIndex, + nextSegmentIndex, railIndex)) { + if (!TryEvaluateSharedBoundaryTangent(spline, segments, context, segmentIndex, + nextSegmentIndex, railIndex, out var incoming, out _, out var sharedTangent)) { + return false; + } + var u = (curveT - (1f - ConnectionTangentBlend)) / ConnectionTangentBlend; + var influence = ConnectionTangentBlend * u * u * (u - 1f); + center += influence * (sharedTangent * math.length(incoming) - incoming); + } + return true; + } + + private static bool TryEvaluateRailCenterUnsmoothed(Spline spline, + IReadOnlyList segments, WireRailPathEvaluationContext context, + int segmentIndex, int railIndex, + float curveT, out WireRailPathFrame mainFrame, out float3 center) + { + center = default; + curveT = math.saturate(curveT); + if (!TryEvaluateLayout(spline, segments, context, segmentIndex, curveT, out mainFrame)) { + return false; + } + + var offset = EvaluateRailOffset(spline, segments, segmentIndex, railIndex, curveT); + if (HasConstantRailOffset(spline, segments, segmentIndex, railIndex)) { + center = mainFrame.TransformOffset(offset); + return true; + } + if (!TryEvaluateLayout(spline, segments, context, segmentIndex, 0f, out var startFrame) + || !TryEvaluateLayout(spline, segments, context, segmentIndex, 1f, out var endFrame)) { + return false; + } + var startOffset = EvaluateRailOffset(spline, segments, segmentIndex, railIndex, 0f); + var endOffset = EvaluateRailOffset(spline, segments, segmentIndex, railIndex, 1f); + var offsetDelta = endOffset - startOffset; + var offsetDeltaLengthSquared = math.lengthsq(offsetDelta); + // Rotating a large offset with every spline frame can fold the rail back when the + // bend radius becomes smaller than that offset. Interpolate the endpoint + // displacements instead, while preserving any authored motion off that line. + var transitionT = offsetDeltaLengthSquared > 1e-6f + ? math.saturate(math.dot(offset - startOffset, offsetDelta) + / offsetDeltaLengthSquared) + : curveT; + var interpolatedOffset = math.lerp(startOffset, endOffset, transitionT); + var residualOffset = offset - interpolatedOffset; + var startDisplacement = startFrame.TransformOffsetVector(startOffset); + var endDisplacement = endFrame.TransformOffsetVector(endOffset); + var displacement = math.lerp(startDisplacement, endDisplacement, transitionT) + + mainFrame.TransformOffsetVector(residualOffset); + center = mainFrame.Position + displacement; + return true; + } + + private static bool TryEvaluateSharedBoundaryTangent(Spline spline, + IReadOnlyList segments, WireRailPathEvaluationContext context, + int sourceSegmentIndex, + int nextSegmentIndex, int railIndex, out float3 incoming, out float3 outgoing, + out float3 sharedTangent) + { + var cacheKey = ((long)sourceSegmentIndex << 32) | (uint)railIndex; + if (context.BoundaryTangents.TryGetValue(cacheKey, out var cached)) { + incoming = cached.Incoming; + outgoing = cached.Outgoing; + sharedTangent = cached.Shared; + return true; + } + incoming = default; + outgoing = default; + sharedTangent = default; + if (!TryEvaluateRailCenterUnsmoothed(spline, segments, context, sourceSegmentIndex, + railIndex, 1f - ConnectionTangentSampleStep, out _, out var sourceBefore) + || !TryEvaluateRailCenterUnsmoothed(spline, segments, context, sourceSegmentIndex, + railIndex, 1f, out var sourceMainFrame, out var sourceEnd) + || !TryEvaluateRailCenterUnsmoothed(spline, segments, context, nextSegmentIndex, + railIndex, 0f, out var nextMainFrame, out var nextStart) + || !TryEvaluateRailCenterUnsmoothed(spline, segments, context, nextSegmentIndex, + railIndex, ConnectionTangentSampleStep, out _, out var nextAfter)) { + return false; + } + + incoming = (sourceEnd - sourceBefore) / ConnectionTangentSampleStep; + outgoing = (nextAfter - nextStart) / ConnectionTangentSampleStep; + var referenceTangent = math.normalizesafe( + sourceMainFrame.Tangent + nextMainFrame.Tangent, sourceMainFrame.Tangent); + var incomingDirection = math.normalizesafe(incoming, referenceTangent); + var outgoingDirection = math.normalizesafe(outgoing, referenceTangent); + if (math.dot(incomingDirection, referenceTangent) <= 0f) { + incomingDirection = referenceTangent; + } + if (math.dot(outgoingDirection, referenceTangent) <= 0f) { + outgoingDirection = referenceTangent; + } + sharedTangent = math.normalizesafe(incomingDirection + outgoingDirection, + referenceTangent); + context.BoundaryTangents.Add(cacheKey, + new WireRailBoundaryTangent(incoming, outgoing, sharedTangent)); + return true; + } + + private static void AverageReferenceAxes(WireRailPathFrame first, + WireRailPathFrame second, out float3 right, out float3 up) + { + right = math.normalizesafe(first.Right + second.Right, second.Right); + up = math.normalizesafe(first.Up + second.Up, second.Up); + } + + private static bool IsContinuousBoundary(IReadOnlyList segments, + int sourceSegmentIndex, int nextSegmentIndex, int railIndex) + => sourceSegmentIndex >= 0 && nextSegmentIndex >= 0 + && sourceSegmentIndex < segments.Count && nextSegmentIndex < segments.Count + && segments[sourceSegmentIndex].RailCount > railIndex + && segments[nextSegmentIndex].RailCount > railIndex + && segments[sourceSegmentIndex].IsRailActive(railIndex) + && segments[nextSegmentIndex].IsRailActive(railIndex) + && segments[sourceSegmentIndex].ConnectionToNext.IsWireContinuous(railIndex); + + private static bool RequiresRailTangentSmoothing(Spline spline, + IReadOnlyList segments, int sourceSegmentIndex, + int nextSegmentIndex, int railIndex) + => IsContinuousBoundary(segments, sourceSegmentIndex, nextSegmentIndex, railIndex) + && !(HasConstantRailOffset(spline, segments, sourceSegmentIndex, railIndex) + && HasConstantRailOffset(spline, segments, nextSegmentIndex, railIndex) + && RailOffsetsMatch(segments[sourceSegmentIndex], + segments[nextSegmentIndex], railIndex)); + + private static bool HasConstantRailOffset(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex) + { + var previousSegmentIndex = GetPreviousSegmentIndex(spline, segments, segmentIndex); + if (IsContinuousBoundary(segments, previousSegmentIndex, segmentIndex, railIndex) + && !RailOffsetsMatch(segments[previousSegmentIndex], segments[segmentIndex], + railIndex)) { + return false; + } + var nextSegmentIndex = GetNextSegmentIndex(spline, segments, segmentIndex); + return !IsContinuousBoundary(segments, segmentIndex, nextSegmentIndex, railIndex) + || RailOffsetsMatch(segments[segmentIndex], segments[nextSegmentIndex], railIndex); + } + + private static bool RailOffsetsMatch(WireRailSegment first, + WireRailSegment second, int railIndex) + { + var delta = (float2)first.GetRailOffset(railIndex) + - (float2)second.GetRailOffset(railIndex); + return math.lengthsq(delta) <= 1e-8f; + } + + private static bool IsRailConnectedBoundary( + IReadOnlyList segments, int sourceSegmentIndex, + int nextSegmentIndex, int railIndex) + { + if (sourceSegmentIndex < 0 || nextSegmentIndex < 0 + || sourceSegmentIndex >= segments.Count || nextSegmentIndex >= segments.Count + || segments[sourceSegmentIndex].RailCount <= railIndex + || segments[nextSegmentIndex].RailCount <= railIndex + || !segments[sourceSegmentIndex].IsRailActive(railIndex) + || !segments[nextSegmentIndex].IsRailActive(railIndex)) { + return false; + } + if (IsContinuousBoundary(segments, sourceSegmentIndex, nextSegmentIndex, + railIndex)) { + return true; + } + var sourceOffset = (float2)segments[sourceSegmentIndex].GetRailOffset(railIndex); + var nextOffset = (float2)segments[nextSegmentIndex].GetRailOffset(railIndex); + var sourceDiameter = segments[sourceSegmentIndex].GetWireDiameter(railIndex); + var nextDiameter = segments[nextSegmentIndex].GetWireDiameter(railIndex); + return math.distancesq(sourceOffset, nextOffset) <= 1e-6f + && math.abs(sourceDiameter - nextDiameter) <= 1e-4f; + } + + private static int GetPreviousSegmentIndex(Spline spline, + IReadOnlyList segments, int segmentIndex) + { + if (segmentIndex > 0) { + return segmentIndex - 1; + } + return spline != null && spline.Closed && segments.Count > 1 + ? segments.Count - 1 : -1; + } + + private static int GetNextSegmentIndex(Spline spline, + IReadOnlyList segments, int segmentIndex) + { + if (segmentIndex + 1 < segments.Count) { + return segmentIndex + 1; + } + return spline != null && spline.Closed && segments.Count > 1 ? 0 : -1; + } + } + + internal static class WireRailEndpointTrimUtility + { + public const int MaximumRailCount = 6; + private const float MinimumSpan = 1e-5f; + + // A elbow only contributes geometry and trimming when its two rails are a distinct, active + // pair at the actual attachment (the endpoint moved inward by the offset, which may cross + // a layout boundary). Every elbow path uses this so caps, trims, colliders, and validation + // stay consistent across boundaries. + internal static bool IsElbowGeneratable(IReadOnlyList segments, + WireRailElbowFixture elbow, float splineLength) + { + if (elbow == null || segments == null || segments.Count == 0 + || elbow.FirstRailIndex == elbow.SecondRailIndex + || elbow.FirstRailIndex < 0 || elbow.SecondRailIndex < 0) { + return false; + } + var railTrim = math.max(0f, elbow.Offset); + // Same as for the hairpin: no rail left means no elbow. + if (railTrim >= splineLength - MinimumSpan) { + return false; + } + var attachmentDistance = math.clamp(elbow.Endpoint == WireRailEndpoint.Start + ? railTrim : splineLength - railTrim, 0f, splineLength); + var segmentIndex = WireRailSplineGeometry.GetLayoutIndexAtDistance(segments, + attachmentDistance, splineLength); + if (segmentIndex < 0) { + return false; + } + var segment = segments[segmentIndex]; + return elbow.FirstRailIndex < segment.RailCount + && elbow.SecondRailIndex < segment.RailCount + && segment.IsRailActive(elbow.FirstRailIndex) + && segment.IsRailActive(elbow.SecondRailIndex); + } + + /// + /// Whether a hairpin can be generated at its offset attachment: both rails distinct, + /// in range and active in the layout at that point. Shared by trim collection and the + /// fitting generators so an omitted hairpin never shortens a rail. + /// + internal static bool IsHairpinGeneratable(IReadOnlyList segments, + WireRailHairpinFixture hairpin, float splineLength) + { + if (hairpin == null || segments == null || segments.Count == 0 + || hairpin.FirstRailIndex == hairpin.SecondRailIndex + || hairpin.FirstRailIndex < 0 || hairpin.SecondRailIndex < 0) { + return false; + } + var loopTrim = math.max(0f, hairpin.RailOffset); + // An offset that consumes the whole route leaves no rail to attach to; clamping it + // would put the loop at the opposite endpoint over rails that no longer exist. + if (loopTrim >= splineLength - MinimumSpan) { + return false; + } + var attachmentDistance = math.clamp(hairpin.Endpoint == WireRailEndpoint.Start + ? loopTrim : splineLength - loopTrim, 0f, splineLength); + var segmentIndex = WireRailSplineGeometry.GetLayoutIndexAtDistance(segments, + attachmentDistance, splineLength); + if (segmentIndex < 0) { + return false; + } + var segment = segments[segmentIndex]; + return hairpin.FirstRailIndex < segment.RailCount + && hairpin.SecondRailIndex < segment.RailCount + && segment.IsRailActive(hairpin.FirstRailIndex) + && segment.IsRailActive(hairpin.SecondRailIndex); + } + + public static void Collect(Spline spline, IReadOnlyList segments, + IReadOnlyList fixtures, + float[] startOffsets, float[] endOffsets, bool includeElbow = true) + { + Array.Clear(startOffsets, 0, startOffsets.Length); + Array.Clear(endOffsets, 0, endOffsets.Length); + if (spline == null || spline.Closed || fixtures == null) { + return; + } + var splineLength = spline.GetLength(); + foreach (var fixture in fixtures) { + if (fixture is WireRailTrimFixture railTrim) { + var destination = railTrim.Endpoint == WireRailEndpoint.Start + ? startOffsets : endOffsets; + var railCount = math.min(destination.Length, railTrim.RailCount); + for (var railIndex = 0; railIndex < railCount; railIndex++) { + destination[railIndex] = math.max(destination[railIndex], + railTrim.GetRailOffset(railIndex)); + } + } else if (includeElbow && fixture is WireRailElbowFixture elbow) { + // A non-generatable or conflicting elbow omits the whole fixture, so it then + // contributes nothing — neither the offset trim nor the other cutoffs. + if (!IsElbowGeneratable(segments, elbow, splineLength) + || HasRailTrimConflict(fixtures, segments, splineLength, elbow.Endpoint, + elbow.FirstRailIndex, elbow.SecondRailIndex, elbow)) { + continue; + } + var destination = elbow.Endpoint == WireRailEndpoint.Start + ? startOffsets : endOffsets; + var railCount = math.min(destination.Length, elbow.RailCount); + // The offset shortens the two attached rails so the elbow starts before the + // endpoint; the other rails keep their own cutoffs. + var attachedTrim = math.max(0f, elbow.Offset); + for (var railIndex = 0; railIndex < railCount; railIndex++) { + var trim = elbow.IsAttachedRail(railIndex) + ? attachedTrim + : elbow.GetRailOffset(railIndex); + destination[railIndex] = math.max(destination[railIndex], trim); + } + } else if (fixture is WireRailHairpinFixture hairpin) { + // The loop's offset shortens the two rails it connects to, so the loop + // attaches that far before the endpoint and the rails follow it, staying + // connected. A conflicting or invalid loop is not generated, so it trims + // nothing. Applies to both render and collider (unlike a Drop). + var loopTrim = math.max(0f, hairpin.RailOffset); + if (loopTrim <= MinimumSpan + || !IsHairpinGeneratable(segments, hairpin, splineLength) + || HasRailTrimConflict(fixtures, segments, splineLength, hairpin.Endpoint, + hairpin.FirstRailIndex, hairpin.SecondRailIndex, hairpin)) { + continue; + } + var destination = hairpin.Endpoint == WireRailEndpoint.Start + ? startOffsets : endOffsets; + ShortenLoopRail(hairpin.FirstRailIndex); + ShortenLoopRail(hairpin.SecondRailIndex); + + void ShortenLoopRail(int railIndex) + { + if (railIndex >= 0 && railIndex < destination.Length) { + destination[railIndex] = math.max(destination[railIndex], loopTrim); + } + } + } + } + } + + // When the caller passes the fixture it is evaluating as requestingFixture, that fixture + // is skipped and another Drop's positive Offset (which shortens its own attached rails) + // also counts as a conflict on a shared rail. Callers that cannot identify themselves + // (e.g. the inspector) pass null and get only the rail-cutoff conflicts. An elbow that + // is not generated at all (see IsElbowGeneratable) contributes no trims and therefore + // no conflicts either. + public static bool HasRailTrimConflict(IReadOnlyList fixtures, + IReadOnlyList segments, float splineLength, + WireRailEndpoint endpoint, int firstRailIndex, int secondRailIndex, + WireRailFixture requestingFixture = null) + { + if (fixtures == null || firstRailIndex < 0 || secondRailIndex < 0) { + return false; + } + foreach (var fixture in fixtures) { + if (ReferenceEquals(fixture, requestingFixture)) { + continue; + } + if (fixture is WireRailElbowFixture omittedElbow + && !IsElbowGeneratable(segments, omittedElbow, splineLength)) { + continue; + } + if (fixture is WireRailTrimFixture railTrim + && railTrim.Endpoint == endpoint) { + if (firstRailIndex < railTrim.RailCount + && railTrim.GetRailOffset(firstRailIndex) > MinimumSpan + || secondRailIndex < railTrim.RailCount + && railTrim.GetRailOffset(secondRailIndex) > MinimumSpan) { + return true; + } + } else if (fixture is WireRailElbowFixture elbow + && elbow.Endpoint == endpoint) { + // An invalid elbow (equal indices) is not generated, so its offset must not + // count as a conflict that would suppress an otherwise valid fitting. + if (requestingFixture != null && elbow.Offset > MinimumSpan + && elbow.FirstRailIndex != elbow.SecondRailIndex + && (elbow.IsAttachedRail(firstRailIndex) + || elbow.IsAttachedRail(secondRailIndex))) { + return true; + } + if (!elbow.IsAttachedRail(firstRailIndex) + && firstRailIndex < elbow.RailCount + && elbow.GetRailOffset(firstRailIndex) > MinimumSpan + || !elbow.IsAttachedRail(secondRailIndex) + && secondRailIndex < elbow.RailCount + && elbow.GetRailOffset(secondRailIndex) > MinimumSpan) { + return true; + } + } + } + return false; + } + + public static bool TryGetSegmentRange(IReadOnlyList segments, + int segmentIndex, float splineLength, float startOffset, float endOffset, + out float startT, out float endT, out bool trimmedStart, out bool trimmedEnd) + { + startT = 0f; + endT = 1f; + trimmedStart = false; + trimmedEnd = false; + if (segments == null || segmentIndex < 0 || segmentIndex >= segments.Count) { + return false; + } + GetSegmentDistances(segments, segmentIndex, splineLength, + out var segmentStart, out var segmentEnd); + var segmentLength = segmentEnd - segmentStart; + if (segmentLength <= MinimumSpan) { + return false; + } + var visibleStart = math.max(segmentStart, math.max(0f, startOffset)); + var trimmedRouteEnd = splineLength - math.max(0f, endOffset); + var visibleEnd = math.min(segmentEnd, trimmedRouteEnd); + if (visibleEnd - visibleStart <= MinimumSpan) { + return false; + } + startT = math.saturate((visibleStart - segmentStart) / segmentLength); + endT = math.saturate((visibleEnd - segmentStart) / segmentLength); + trimmedStart = startOffset > MinimumSpan + && math.abs(visibleStart - startOffset) <= MinimumSpan; + trimmedEnd = endOffset > MinimumSpan + && math.abs(visibleEnd - trimmedRouteEnd) <= MinimumSpan; + return endT - startT > MinimumSpan; + } + + public static void GetSegmentDistances(IReadOnlyList segments, + int segmentIndex, float splineLength, out float startDistance, + out float endDistance) + { + startDistance = math.clamp(segments[segmentIndex].Distance, 0f, splineLength); + endDistance = segmentIndex + 1 < segments.Count + ? math.clamp(segments[segmentIndex + 1].Distance, startDistance, splineLength) + : splineLength; + } + } + + // Records where an endpoint fitting's two mouth rings landed in the render buffer so the + // fitting tube can be welded to the rail tubes it attaches to (Drop and Hairpin). + internal struct WireRailFittingSeam + { + public WireRailEndpoint Endpoint; + public int FirstRailIndex; + public int SecondRailIndex; + public int FirstMouthRing; + public int SecondMouthRing; + } + + internal static class WireRailRenderMeshGenerator + { + private const float MaximumRingAngle = 0.08726646f; + private const int MaximumAdaptiveDepth = 3; + private static readonly float2[][] RadialDirections = BuildRadialDirections(); + [ThreadStatic] private static RenderBuffers _threadBuffers; + + private sealed class RenderBuffers + { + public readonly List Vertices = new(); + public readonly List Normals = new(); + public readonly List Uvs = new(); + public readonly List Indices = new(); + public readonly Dictionary PreviousFrames = new(); + public readonly List SampleParameters = new(); + public readonly HashSet FittedStartRails = new(); + public readonly HashSet FittedEndRails = new(); + public readonly float[] StartTrimOffsets = + new float[WireRailEndpointTrimUtility.MaximumRailCount]; + public readonly float[] EndTrimOffsets = + new float[WireRailEndpointTrimUtility.MaximumRailCount]; + // First and last (mouth) ring start-vertex index each rail contributed to the sweep, + // so hairpins can weld to them; -1 means the rail was not swept. + public readonly int[] FirstRingByRail = + new int[WireRailEndpointTrimUtility.MaximumRailCount]; + public readonly int[] LastRingByRail = + new int[WireRailEndpointTrimUtility.MaximumRailCount]; + // The frames of those mouth rings, so a fitting tube can seed its own rings with the + // rail's radial orientation and line up vertex-for-vertex instead of twisting. + public readonly WireRailPathFrame[] FirstFrameByRail = + new WireRailPathFrame[WireRailEndpointTrimUtility.MaximumRailCount]; + public readonly WireRailPathFrame[] LastFrameByRail = + new WireRailPathFrame[WireRailEndpointTrimUtility.MaximumRailCount]; + public readonly List FittingSeams = new(); + public readonly WireRailPathEvaluationContext EvaluationContext = new(); + + public void Clear() + { + Vertices.Clear(); + Normals.Clear(); + Uvs.Clear(); + Indices.Clear(); + PreviousFrames.Clear(); + SampleParameters.Clear(); + FittedStartRails.Clear(); + FittedEndRails.Clear(); + FittingSeams.Clear(); + for (var railIndex = 0; railIndex < FirstRingByRail.Length; railIndex++) { + FirstRingByRail[railIndex] = -1; + LastRingByRail[railIndex] = -1; + } + } + } + + public static Mesh Generate(Spline spline, IReadOnlyList segments, + IReadOnlyList fixtures, float wireCapBevelSize, + int samplesPerSegment, int radialSegments, Mesh target, + List segmentIndexRanges = null, List fixtureIndexRanges = null) + { + radialSegments = math.clamp(radialSegments, 6, 16); + segmentIndexRanges?.Clear(); + fixtureIndexRanges?.Clear(); + var buffers = _threadBuffers ??= new RenderBuffers(); + buffers.Clear(); + var vertices = buffers.Vertices; + var normals = buffers.Normals; + var uvs = buffers.Uvs; + var indices = buffers.Indices; + var previousFrames = buffers.PreviousFrames; + var evaluationContext = buffers.EvaluationContext; + evaluationContext.Reset(spline); + var splineLength = evaluationContext.SplineLength; + var startEndpointSegmentIndex = WireRailSplineGeometry + .GetLayoutIndexAtDistance(segments, 0f, splineLength); + var endEndpointSegmentIndex = WireRailSplineGeometry + .GetLayoutIndexAtDistance(segments, splineLength, splineLength); + WireRailEndpointTrimUtility.Collect(spline, segments, fixtures, + buffers.StartTrimOffsets, buffers.EndTrimOffsets); + CollectFittedRailEnds(spline, segments, fixtures, splineLength, + buffers.StartTrimOffsets, buffers.EndTrimOffsets, + buffers.FittedStartRails, buffers.FittedEndRails); + for (var segmentIndex = 0; segmentIndex < segments.Count; segmentIndex++) { + var segment = segments[segmentIndex]; + var segmentIndexStart = indices.Count; + for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) { + if (!segment.IsRailActive(railIndex)) { + previousFrames.Remove(railIndex); + continue; + } + if (!WireRailEndpointTrimUtility.TryGetSegmentRange(segments, segmentIndex, + splineLength, buffers.StartTrimOffsets[railIndex], + buffers.EndTrimOffsets[railIndex], out var startT, out var endT, + out var trimmedStart, out var trimmedEnd)) { + previousFrames.Remove(railIndex); + continue; + } + WireRailPathFrame? previousSegmentFrame = null; + if (!trimmedStart && startT <= 1e-5f && segmentIndex > 0 + && WireRailSplineGeometry.IsRailConnectedAtStart(spline, segments, + segmentIndex, railIndex) + && previousFrames.TryGetValue(railIndex, out var previousFrame)) { + previousSegmentFrame = previousFrame; + } + // Omit the fitted flat cap where the rail actually ends: at the endpoint + // segment when it reaches the endpoint, or at whichever segment holds its + // trimmed end when a Drop offset moved the attachment across a layout. + var omitStartCapBevel = buffers.FittedStartRails.Contains(railIndex) + && (trimmedStart || segmentIndex == startEndpointSegmentIndex); + var omitEndCapBevel = buffers.FittedEndRails.Contains(railIndex) + && (trimmedEnd || segmentIndex == endEndpointSegmentIndex); + if (AppendTube(spline, segments, evaluationContext, segmentIndex, railIndex, + startT, endT, samplesPerSegment, radialSegments, wireCapBevelSize, + trimmedStart, trimmedEnd, omitStartCapBevel, omitEndCapBevel, + previousSegmentFrame, + buffers.SampleParameters, + vertices, normals, uvs, indices, out var lastFrame, + out var firstTubeRing, out var lastTubeRing, out var firstTubeFrame)) { + previousFrames[railIndex] = lastFrame; + // Track the rail's mouth rings and their frames (the tube rings, not the + // caps that follow) so fittings can weld to and align with them. + if (railIndex < buffers.FirstRingByRail.Length) { + if (buffers.FirstRingByRail[railIndex] < 0) { + buffers.FirstRingByRail[railIndex] = firstTubeRing; + buffers.FirstFrameByRail[railIndex] = firstTubeFrame; + } + buffers.LastRingByRail[railIndex] = lastTubeRing; + buffers.LastFrameByRail[railIndex] = lastFrame; + } + } else { + previousFrames.Remove(railIndex); + } + } + // The rail tubes of one layout span are contiguous in the index buffer, which + // lets the editor tint a single span without touching the mesh. + segmentIndexRanges?.Add(new int2(segmentIndexStart, + indices.Count - segmentIndexStart)); + } + WireRailFixtureMeshGenerator.Append(spline, segments, fixtures, + wireCapBevelSize, radialSegments, vertices, normals, uvs, indices, + buffers.FittingSeams, buffers.FirstFrameByRail, buffers.LastFrameByRail, + fixtureIndexRanges); + WeldFittingSeams(buffers, radialSegments); + WireRailSolderMeshGenerator.Append(spline, segments, fixtures, + vertices, normals, uvs, indices); + + var mesh = target ? target : new Mesh(); + mesh.Clear(false); + mesh.name = "Wire Rail Render (Generated)"; + mesh.hideFlags = HideFlags.DontSaveInEditor | HideFlags.DontSaveInBuild; + mesh.indexFormat = IndexFormat.UInt32; + mesh.SetVertices(vertices); + mesh.SetNormals(normals); + mesh.SetUVs(0, uvs); + mesh.SetTriangles(indices, 0, false); + mesh.RecalculateBounds(); + return mesh; + } + + // Stitches each hairpin's mouth rings to the rail tubes it attaches to, so the two + // tubes read as one continuous surface instead of leaving a gap at the junction. + private static void WeldFittingSeams(RenderBuffers buffers, int radialSegments) + { + foreach (var seam in buffers.FittingSeams) { + var firstRailRing = seam.Endpoint == WireRailEndpoint.Start + ? RingByRail(buffers.FirstRingByRail, seam.FirstRailIndex) + : RingByRail(buffers.LastRingByRail, seam.FirstRailIndex); + var secondRailRing = seam.Endpoint == WireRailEndpoint.Start + ? RingByRail(buffers.FirstRingByRail, seam.SecondRailIndex) + : RingByRail(buffers.LastRingByRail, seam.SecondRailIndex); + BridgeRings(buffers.Vertices, buffers.Normals, buffers.Indices, firstRailRing, + seam.FirstMouthRing, radialSegments); + BridgeRings(buffers.Vertices, buffers.Normals, buffers.Indices, secondRailRing, + seam.SecondMouthRing, radialSegments); + } + + static int RingByRail(int[] rings, int railIndex) + => railIndex >= 0 && railIndex < rings.Length ? rings[railIndex] : -1; + } + + // Connects two radial rings (each `radialSegments` vertices) with a triangle band that + // reuses the existing ring vertices, so the seam is welded and its normals stay + // continuous. The rings are matched by the rotation that minimizes vertex distance so + // the band does not twist. + // Maps ring-A radial index i to the paired ring-B radial index for a given rotation and + // whether ring B is traversed in reverse (opposite winding). + private static int MapRadial(int i, int offset, bool reversed, int radialSegments) + => reversed + ? (offset - i + 2 * radialSegments) % radialSegments + : (i + offset) % radialSegments; + + private static void BridgeRings(List vertices, List normals, + List indices, int ringA, int ringB, int radialSegments) + { + if (ringA < 0 || ringB < 0 + || ringA + radialSegments > vertices.Count + || ringB + radialSegments > vertices.Count) { + return; + } + // Align the two rings before stitching. The loop's far mouth winds the opposite way + // around the tube (its tangent is reversed), so try both a rotation and a reflected + // (reversed) pairing and keep whichever matches the vertices best. + var centerA = Vector3.zero; + var centerB = Vector3.zero; + for (var i = 0; i < radialSegments; i++) { + centerA += vertices[ringA + i]; + centerB += vertices[ringB + i]; + } + var axisCenter = (centerA + centerB) / (radialSegments * 2f); + var bestOffset = 0; + var bestReversed = false; + var bestDistance = float.MaxValue; + for (var reversed = 0; reversed < 2; reversed++) { + for (var offset = 0; offset < radialSegments; offset++) { + var sum = 0f; + for (var i = 0; i < radialSegments; i++) { + var delta = vertices[ringA + i] + - vertices[ringB + MapRadial(i, offset, reversed == 1, radialSegments)]; + sum += delta.sqrMagnitude; + } + if (sum < bestDistance) { + bestDistance = sum; + bestOffset = offset; + bestReversed = reversed == 1; + } + } + } + // Share an averaged normal between each welded pair so the shading is continuous + // across the seam instead of jumping between the two tubes' normals. + for (var i = 0; i < radialSegments; i++) { + var b = ringB + MapRadial(i, bestOffset, bestReversed, radialSegments); + var averaged = (normals[ringA + i] + normals[b]).normalized; + if (averaged.sqrMagnitude > 1e-6f) { + normals[ringA + i] = averaged; + normals[b] = averaged; + } + } + for (var i = 0; i < radialSegments; i++) { + var iNext = (i + 1) % radialSegments; + var a0 = ringA + i; + var a1 = ringA + iNext; + var b0 = ringB + MapRadial(i, bestOffset, bestReversed, radialSegments); + var b1 = ringB + MapRadial(iNext, bestOffset, bestReversed, radialSegments); + // Orient each quad so its face points away from the tube axis. + var centroid = (vertices[a0] + vertices[a1] + vertices[b0] + vertices[b1]) + * 0.25f; + var normal = Vector3.Cross(vertices[b0] - vertices[a0], + vertices[b1] - vertices[a0]); + if (Vector3.Dot(normal, centroid - axisCenter) >= 0f) { + indices.Add(a0); + indices.Add(b0); + indices.Add(b1); + indices.Add(a0); + indices.Add(b1); + indices.Add(a1); + } else { + indices.Add(a0); + indices.Add(b1); + indices.Add(b0); + indices.Add(a0); + indices.Add(a1); + indices.Add(b1); + } + } + } + + private static void CollectFittedRailEnds(Spline spline, + IReadOnlyList segments, + IReadOnlyList fixtures, float splineLength, + IReadOnlyList startTrimOffsets, IReadOnlyList endTrimOffsets, + ISet fittedStartRails, + ISet fittedEndRails) + { + if (spline == null || spline.Closed || segments == null || segments.Count == 0 + || fixtures == null) { + return; + } + foreach (var fixture in fixtures) { + WireRailEndpoint endpoint; + int firstRailIndex; + int secondRailIndex; + // A Drop's rails join the fitting at its offset attachment, not the spline + // endpoint, so validate and pick the fitted segment there — the same point the + // elbow profile and usability checks use — so the flat cap lands on the same rail. + float attachmentDistance; + if (fixture is WireRailHairpinFixture hairpin) { + endpoint = hairpin.Endpoint; + firstRailIndex = hairpin.FirstRailIndex; + secondRailIndex = hairpin.SecondRailIndex; + // The offset shortens the connected rails, so the loop joins them that far + // before the endpoint; fit the cap there so it lands on the same rail. + var loopTrim = math.max(0f, hairpin.RailOffset); + attachmentDistance = math.clamp(endpoint == WireRailEndpoint.Start + ? loopTrim : splineLength - loopTrim, 0f, splineLength); + if (!WireRailEndpointTrimUtility.IsHairpinGeneratable(segments, hairpin, + splineLength)) { + continue; + } + } else if (fixture is WireRailElbowFixture elbow) { + endpoint = elbow.Endpoint; + firstRailIndex = elbow.FirstRailIndex; + secondRailIndex = elbow.SecondRailIndex; + var railTrim = math.max(0f, elbow.Offset); + attachmentDistance = math.clamp(endpoint == WireRailEndpoint.Start + ? railTrim : splineLength - railTrim, 0f, splineLength); + if (!WireRailEndpointTrimUtility.IsElbowGeneratable(segments, elbow, + splineLength)) { + continue; + } + } else { + continue; + } + if (WireRailEndpointTrimUtility.HasRailTrimConflict(fixtures, segments, + splineLength, endpoint, firstRailIndex, secondRailIndex, fixture)) { + continue; + } + var endpointSegmentIndex = WireRailSplineGeometry.GetLayoutIndexAtDistance( + segments, attachmentDistance, splineLength); + if (endpointSegmentIndex < 0) { + continue; + } + var endpointSegment = segments[endpointSegmentIndex]; + if (firstRailIndex == secondRailIndex + || firstRailIndex < 0 || secondRailIndex < 0 + || firstRailIndex >= endpointSegment.RailCount + || secondRailIndex >= endpointSegment.RailCount + || !endpointSegment.IsRailActive(firstRailIndex) + || !endpointSegment.IsRailActive(secondRailIndex)) { + continue; + } + var fittedRails = endpoint == WireRailEndpoint.Start + ? fittedStartRails : fittedEndRails; + // The attached rails connect to the fixture, so they get no end cap even when + // the Drop's offset trims them back: that trim is the elbow attachment, not a + // cut end. A conflicting trim from another fixture was already rejected above. + fittedRails.Add(firstRailIndex); + fittedRails.Add(secondRailIndex); + } + } + + private static bool AppendTube(Spline spline, IReadOnlyList segments, + WireRailPathEvaluationContext evaluationContext, + int segmentIndex, int railIndex, float startT, float endT, int samplesPerSegment, + int radialSegments, float capBevelSize, bool trimmedStart, bool trimmedEnd, + bool omitStartCapBevel, bool omitEndCapBevel, + WireRailPathFrame? previousSegmentFrame, + List sampleParameters, List vertices, List normals, + List uvs, List indices, + out WireRailPathFrame lastFrame, out int firstTubeRing, out int lastTubeRing, + out WireRailPathFrame firstTubeFrame) + { + var firstRing = vertices.Count; + // The tube rings occupy [firstRing, cap start); caps (if any) are appended after + // them, so report the ring range explicitly for callers that weld to the mouths. + firstTubeRing = firstRing; + lastTubeRing = firstRing; + WireRailPathFrame firstFrame = default; + firstTubeFrame = default; + lastFrame = default; + var firstRadius = 0f; + var lastRadius = 0f; + BuildSampleParameters(spline, segments, evaluationContext, segmentIndex, railIndex, + startT, endT, samplesPerSegment, sampleParameters); + // A fitted rail end joins a Drop/Hairpin tube that continues from the same point + // and radius, so leave it open (no cap) for the surfaces to blend; other ends keep + // their flat/beveled cap. + var capStart = !omitStartCapBevel && (trimmedStart || startT > 1e-5f + || !WireRailSplineGeometry.IsRailConnectedAtStart(spline, segments, + segmentIndex, railIndex)); + var capEnd = !omitEndCapBevel && (trimmedEnd || endT < 1f - 1e-5f + || !WireRailSplineGeometry.IsRailConnectedAtEnd(spline, segments, + segmentIndex, railIndex)); + var startCapBevelSize = omitStartCapBevel ? 0f : capBevelSize; + var endCapBevelSize = omitEndCapBevel ? 0f : capBevelSize; + for (var sampleIndex = 0; sampleIndex < sampleParameters.Count; sampleIndex++) { + var curveT = sampleParameters[sampleIndex]; + var tangentStep = sampleIndex == 0 + ? sampleParameters[1] - curveT + : sampleIndex == sampleParameters.Count - 1 + ? curveT - sampleParameters[sampleIndex - 1] + : math.min(curveT - sampleParameters[sampleIndex - 1], + sampleParameters[sampleIndex + 1] - curveT); + if (!WireRailSplineGeometry.TryEvaluateRailFrame(spline, segments, + evaluationContext, segmentIndex, railIndex, curveT, tangentStep, + out var frame)) { + return false; + } + if (sampleIndex == 0 && previousSegmentFrame.HasValue) { + var previous = previousSegmentFrame.Value; + frame = new WireRailPathFrame(frame.Position, frame.Tangent, + previous.Right, previous.Up); + } else if (sampleIndex > 0) { + var right = Transport(lastFrame.Right, lastFrame.Tangent, frame.Tangent); + var up = math.normalizesafe(math.cross(right, frame.Tangent), lastFrame.Up); + right = math.normalizesafe(math.cross(frame.Tangent, up), right); + frame = new WireRailPathFrame(frame.Position, frame.Tangent, right, up); + } + var radius = WireRailSplineGeometry.EvaluateWireDiameter(spline, segments, + segmentIndex, railIndex, curveT) * 0.5f; + if (sampleIndex == 0) { + firstFrame = frame; + firstRadius = radius; + } + lastFrame = frame; + lastRadius = radius; + var tubeFrame = frame; + var clampedBevel = sampleIndex == 0 + ? math.clamp(startCapBevelSize, 0f, radius) + : sampleIndex == sampleParameters.Count - 1 + ? math.clamp(endCapBevelSize, 0f, radius) + : 0f; + if (clampedBevel > 1e-5f && sampleIndex == 0 && capStart) { + tubeFrame = new WireRailPathFrame(frame.Position + frame.Tangent * clampedBevel, + frame.Tangent, frame.Right, frame.Up); + } else if (clampedBevel > 1e-5f + && sampleIndex == sampleParameters.Count - 1 && capEnd) { + tubeFrame = new WireRailPathFrame(frame.Position - frame.Tangent * clampedBevel, + frame.Tangent, frame.Right, frame.Up); + } + var radialDirections = RadialDirections[radialSegments]; + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var radialDirection = radialDirections[radialIndex]; + var radial = tubeFrame.Right * radialDirection.x + + tubeFrame.Up * radialDirection.y; + vertices.Add((Vector3)(tubeFrame.Position + radial * radius)); + normals.Add((Vector3)radial); + uvs.Add(new Vector2(curveT, radialIndex / (float)radialSegments)); + } + } + // All tube rings are in now; caps (if any) come after, so the mouth is here. + lastTubeRing = vertices.Count - radialSegments; + firstTubeFrame = firstFrame; + + for (var sampleIndex = 0; sampleIndex < sampleParameters.Count - 1; sampleIndex++) { + var current = firstRing + sampleIndex * radialSegments; + var next = current + radialSegments; + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var radialNext = (radialIndex + 1) % radialSegments; + var a = current + radialIndex; + var b = next + radialIndex; + var c = current + radialNext; + var d = next + radialNext; + indices.Add(a); + indices.Add(b); + indices.Add(d); + indices.Add(a); + indices.Add(d); + indices.Add(c); + } + } + + if (capStart) { + WireRailCapMeshGenerator.Append(firstFrame, firstRadius, startCapBevelSize, + radialSegments, true, + vertices, normals, uvs, indices); + } + if (capEnd) { + WireRailCapMeshGenerator.Append(lastFrame, lastRadius, endCapBevelSize, + radialSegments, false, + vertices, normals, uvs, indices); + } + return true; + } + + internal static List BuildSampleParameters(Spline spline, + IReadOnlyList segments, int segmentIndex, int railIndex, + int minimumSamples) + { + var parameters = new List(minimumSamples + 1); + BuildSampleParameters(spline, segments, new WireRailPathEvaluationContext(), + segmentIndex, railIndex, minimumSamples, parameters); + return parameters; + } + + private static void BuildSampleParameters(Spline spline, + IReadOnlyList segments, + WireRailPathEvaluationContext evaluationContext, + int segmentIndex, int railIndex, int minimumSamples, List parameters) + => BuildSampleParameters(spline, segments, evaluationContext, segmentIndex, + railIndex, 0f, 1f, minimumSamples, parameters); + + private static void BuildSampleParameters(Spline spline, + IReadOnlyList segments, + WireRailPathEvaluationContext evaluationContext, + int segmentIndex, int railIndex, float startT, float endT, + int minimumSamples, List parameters) + { + parameters.Clear(); + parameters.Add(startT); + for (var sampleIndex = 0; sampleIndex < minimumSamples; sampleIndex++) { + var start = math.lerp(startT, endT, + sampleIndex / (float)minimumSamples); + var end = math.lerp(startT, endT, + (sampleIndex + 1f) / minimumSamples); + SubdivideSampleInterval(spline, segments, evaluationContext, + segmentIndex, railIndex, start, end, 0, parameters); + } + } + + private static float2[][] BuildRadialDirections() + { + var directions = new float2[17][]; + for (var radialSegments = 0; radialSegments < directions.Length; radialSegments++) { + directions[radialSegments] = new float2[radialSegments]; + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var angle = math.PI * 2f * radialIndex / radialSegments; + directions[radialSegments][radialIndex] = new float2(math.cos(angle), + math.sin(angle)); + } + } + return directions; + } + + private static void SubdivideSampleInterval(Spline spline, + IReadOnlyList segments, + WireRailPathEvaluationContext evaluationContext, + int segmentIndex, int railIndex, + float start, float end, int depth, ICollection parameters) + { + var step = end - start; + var evaluationStep = math.min(step, 1f / 1024f); + if (depth < MaximumAdaptiveDepth + && WireRailSplineGeometry.TryEvaluateRailFrame(spline, segments, + evaluationContext, segmentIndex, railIndex, start, evaluationStep, + out var startFrame) + && WireRailSplineGeometry.TryEvaluateRailFrame(spline, segments, + evaluationContext, segmentIndex, railIndex, end, evaluationStep, + out var endFrame) + && WireRailSplineGeometry.TryEvaluateLayout(spline, segments, evaluationContext, + segmentIndex, start, out var startMainFrame) + && WireRailSplineGeometry.TryEvaluateLayout(spline, segments, evaluationContext, + segmentIndex, end, out var endMainFrame) + && math.dot(startFrame.Tangent, startMainFrame.Tangent) > 0f + && math.dot(endFrame.Tangent, endMainFrame.Tangent) > 0f + && math.acos(math.clamp(math.dot(startFrame.Tangent, endFrame.Tangent), + -1f, 1f)) > MaximumRingAngle) { + var middle = (start + end) * 0.5f; + SubdivideSampleInterval(spline, segments, evaluationContext, + segmentIndex, railIndex, start, middle, depth + 1, parameters); + SubdivideSampleInterval(spline, segments, evaluationContext, + segmentIndex, railIndex, middle, end, depth + 1, parameters); + return; + } + parameters.Add(end); + } + + private static float3 Transport(float3 direction, float3 fromTangent, + float3 toTangent) + { + var axis = math.cross(fromTangent, toTangent); + var sinAngle = math.length(axis); + var cosAngle = math.clamp(math.dot(fromTangent, toTangent), -1f, 1f); + float3 transported; + if (sinAngle > 1e-6f) { + axis /= sinAngle; + var rotation = quaternion.AxisAngle(axis, math.atan2(sinAngle, cosAngle)); + transported = math.mul(rotation, direction); + } else { + transported = direction; + } + var projected = transported - toTangent * math.dot(transported, toTangent); + return math.normalizesafe(projected, direction); + } + + } + + internal readonly struct WireRailRingProfile + { + public readonly WireRailPathFrame Frame; + public readonly float2 CenterOffset; + public readonly float BaseRadius; + public readonly float Radius; + // The wires the ring wraps around, in the frame's (Right, Up) plane. + public readonly float2[] RailOffsets; + public readonly float[] RailRadii; + + public WireRailRingProfile(WireRailPathFrame frame, float2 centerOffset, + float baseRadius, float radius, float2[] railOffsets, float[] railRadii) + { + Frame = frame; + CenterOffset = centerOffset; + BaseRadius = baseRadius; + Radius = radius; + RailOffsets = railOffsets; + RailRadii = railRadii; + } + + public float3 Center => Frame.TransformOffset(CenterOffset); + + public float3 GetCenterlinePosition(float angle) + => Frame.TransformOffset(CenterOffset + new float2(math.cos(angle), + math.sin(angle)) * Radius); + } + + internal readonly struct WireRailRungProfile + { + public readonly WireRailPathFrame Frame; + public readonly float2 StartRailOffset; + public readonly float2 EndRailOffset; + public readonly float StartRailRadius; + public readonly float EndRailRadius; + public readonly float2 RotationOriginOffset; + public readonly float2 StartOffset; + public readonly float2 EndOffset; + + public WireRailRungProfile(WireRailPathFrame frame, float2 startRailOffset, + float2 endRailOffset, float startRailRadius, float endRailRadius, + float2 rotationOriginOffset, float2 startOffset, float2 endOffset) + { + Frame = frame; + StartRailOffset = startRailOffset; + EndRailOffset = endRailOffset; + StartRailRadius = startRailRadius; + EndRailRadius = endRailRadius; + RotationOriginOffset = rotationOriginOffset; + StartOffset = startOffset; + EndOffset = endOffset; + } + + public float3 Start => Frame.TransformOffset(StartOffset); + public float3 End => Frame.TransformOffset(EndOffset); + } + + internal readonly struct WireRailCradleProfile + { + public readonly WireRailPathFrame Frame; + public readonly IReadOnlyList RailOffsets; + public readonly IReadOnlyList RailRadii; + public readonly float2 OriginOffset; + public readonly IReadOnlyList CenterlinePoints; + + public WireRailCradleProfile(WireRailPathFrame frame, + IReadOnlyList railOffsets, IReadOnlyList railRadii, + float2 originOffset, IReadOnlyList centerlinePoints) + { + Frame = frame; + RailOffsets = railOffsets; + RailRadii = railRadii; + OriginOffset = originOffset; + CenterlinePoints = centerlinePoints; + } + } + + internal readonly struct WireRailStandProfile + { + public readonly WireRailRungProfile AttachmentProfile; + public readonly IReadOnlyList LegPoints; + public readonly IReadOnlyList FootPoints; + public readonly IReadOnlyList CombinedPath; + + public WireRailStandProfile(WireRailRungProfile attachmentProfile, + IReadOnlyList legPoints, IReadOnlyList footPoints, + IReadOnlyList combinedPath) + { + AttachmentProfile = attachmentProfile; + LegPoints = legPoints; + FootPoints = footPoints; + CombinedPath = combinedPath; + } + } + + internal readonly struct WireRailHairpinProfile + { + public readonly WireRailPathFrame Frame; + public readonly IReadOnlyList FirstLeadPoints; + public readonly IReadOnlyList TerminalPoints; + public readonly IReadOnlyList SecondLeadPoints; + public readonly IReadOnlyList CenterlinePoints; + public readonly int TerminalStartSpan; + public readonly int TerminalEndSpan; + + public WireRailHairpinProfile(WireRailPathFrame frame, + IReadOnlyList firstLeadPoints, + IReadOnlyList terminalPoints, + IReadOnlyList secondLeadPoints, + IReadOnlyList centerlinePoints, int terminalStartSpan, + int terminalEndSpan) + { + Frame = frame; + FirstLeadPoints = firstLeadPoints; + TerminalPoints = terminalPoints; + SecondLeadPoints = secondLeadPoints; + CenterlinePoints = centerlinePoints; + TerminalStartSpan = terminalStartSpan; + TerminalEndSpan = terminalEndSpan; + } + } + + internal readonly struct WireRailElbowProfile + { + public readonly WireRailPathFrame Frame; + public readonly IReadOnlyList FirstRailPoints; + public readonly IReadOnlyList SecondRailPoints; + public readonly float FirstRailRadius; + public readonly float SecondRailRadius; + public readonly int BendStartPointIndex; + public readonly float3 FirstElbowLinePoint; + public readonly float3 SecondElbowLinePoint; + + public WireRailElbowProfile(WireRailPathFrame frame, + IReadOnlyList firstRailPoints, + IReadOnlyList secondRailPoints, + float firstRailRadius, float secondRailRadius, int bendStartPointIndex, + float3 firstElbowLinePoint, float3 secondElbowLinePoint) + { + Frame = frame; + FirstRailPoints = firstRailPoints; + SecondRailPoints = secondRailPoints; + FirstRailRadius = firstRailRadius; + SecondRailRadius = secondRailRadius; + BendStartPointIndex = bendStartPointIndex; + FirstElbowLinePoint = firstElbowLinePoint; + SecondElbowLinePoint = secondElbowLinePoint; + } + } + + internal static class WireRailFixtureMeshGenerator + { + private const float FullTurn = math.PI * 2f; + private const float LegCornerRadiusDiameterRatio = 1f; + private const float RoundedCornerMaxAngleStep = math.PI / 12f; + private const float LegCornerMaxSpanFraction = 0.45f; + private const int HairpinColliderLeadSegments = 4; + private const int HairpinColliderTerminalSegments = 12; + + public static void Append(Spline spline, IReadOnlyList segments, + IReadOnlyList fixtures, float wireCapBevelSize, + int radialSegments, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices, + ICollection fittingSeams = null, + IReadOnlyList firstFrameByRail = null, + IReadOnlyList lastFrameByRail = null, + List fixtureIndexRanges = null) + { + fixtureIndexRanges?.Clear(); + if (fixtures == null) { + return; + } + + // The mouth frame of the rail a fitting attaches to, so its tube can seed its rings + // with the rail's radial orientation and line up vertex-for-vertex. Falls back to the + // fitting's own frame when the rail wasn't swept. + WireRailPathFrame RailMouthFrame(WireRailEndpoint endpoint, int railIndex, + WireRailPathFrame fallback) + { + var frames = endpoint == WireRailEndpoint.Start + ? firstFrameByRail : lastFrameByRail; + if (frames == null || railIndex < 0 || railIndex >= frames.Count) { + return fallback; + } + var frame = frames[railIndex]; + return math.lengthsq(frame.Tangent) > 1e-6f ? frame : fallback; + } + foreach (var fixture in fixtures) { + var fixtureIndexStart = indices.Count; + if (fixture is WireRailRingFixture ring + && TryEvaluateRingProfile(spline, segments, ring, out var profile)) { + AppendRing(profile, ring, wireCapBevelSize, radialSegments, vertices, + normals, uvs, indices); + } else if (fixture is WireRailCradleFixture cradle + && TryEvaluateCradleProfile(spline, segments, cradle, + out var cradleProfile)) { + AppendCradle(cradleProfile, cradle, wireCapBevelSize, radialSegments, + vertices, normals, uvs, indices); + } else if (fixture is WireRailRungFixture rung + && TryEvaluateRungProfile(spline, segments, rung, + out var rungProfile)) { + AppendRung(rungProfile, rung, wireCapBevelSize, + radialSegments, vertices, normals, uvs, indices); + } else if (fixture is WireRailStandFixture leg + && TryEvaluateStandProfile(spline, segments, leg, out var legProfile)) { + AppendStand(legProfile, leg, wireCapBevelSize, radialSegments, + vertices, normals, uvs, indices); + } else if (fixture is WireRailHairpinFixture hairpin + && !WireRailEndpointTrimUtility.HasRailTrimConflict(fixtures, segments, + spline.GetLength(), hairpin.Endpoint, hairpin.FirstRailIndex, + hairpin.SecondRailIndex, hairpin) + && TryEvaluateHairpinProfile(spline, segments, hairpin, + out var hairpinProfile)) { + var loopFirstRing = vertices.Count; + // Seed the loop tube's rings from the first rail's mouth frame so that mouth + // lines up; the far mouth is still aligned by the weld's ring matching. + AppendHairpin(hairpinProfile, hairpin, wireCapBevelSize, + radialSegments, RailMouthFrame(hairpin.Endpoint, + hairpin.FirstRailIndex, hairpinProfile.Frame), + vertices, normals, uvs, indices); + // The loop is one tube: its first ring meets the first rail, its last ring + // the second rail. Record them so the render can weld the mouths. + if (fittingSeams != null + && vertices.Count - loopFirstRing >= radialSegments * 2) { + fittingSeams.Add(new WireRailFittingSeam { + Endpoint = hairpin.Endpoint, + FirstRailIndex = hairpin.FirstRailIndex, + SecondRailIndex = hairpin.SecondRailIndex, + FirstMouthRing = loopFirstRing, + SecondMouthRing = vertices.Count - radialSegments, + }); + } + } else if (fixture is WireRailElbowFixture elbow + && !WireRailEndpointTrimUtility.HasRailTrimConflict(fixtures, segments, + spline.GetLength(), elbow.Endpoint, elbow.FirstRailIndex, + elbow.SecondRailIndex, elbow) + && TryEvaluateElbowProfile(spline, segments, elbow, + out var dropProfile)) { + // Seed each elbow tube's rings from its rail's mouth frame so both align. + AppendElbow(dropProfile, elbow, wireCapBevelSize, + radialSegments, + RailMouthFrame(elbow.Endpoint, elbow.FirstRailIndex, dropProfile.Frame), + RailMouthFrame(elbow.Endpoint, elbow.SecondRailIndex, dropProfile.Frame), + vertices, normals, uvs, indices, + out var firstMouthRing, out var secondMouthRing); + // The elbow is two tubes; weld each tube's mouth ring to its rail. + if (fittingSeams != null && firstMouthRing >= 0 && secondMouthRing >= 0) { + fittingSeams.Add(new WireRailFittingSeam { + Endpoint = elbow.Endpoint, + FirstRailIndex = elbow.FirstRailIndex, + SecondRailIndex = elbow.SecondRailIndex, + FirstMouthRing = firstMouthRing, + SecondMouthRing = secondMouthRing, + }); + } + } + // One index range per fixture, in list order, so the editor can tint a single + // fixture. A fixture that produced no geometry gets an empty range. + fixtureIndexRanges?.Add(new int2(fixtureIndexStart, + indices.Count - fixtureIndexStart)); + } + } + + internal static bool TryEvaluateRingProfile(Spline spline, + IReadOnlyList segments, WireRailRingFixture ring, + out WireRailRingProfile profile) + { + profile = default; + if (ring == null || !TryGetSplineLocation(spline, segments, ring.Distance, + out var segmentIndex, out var curveT, out var frame)) { + return false; + } + var segment = segments[segmentIndex]; + var activeRailIndices = Enumerable.Range(0, segment.RailCount) + .Where(segment.IsRailActive).ToArray(); + if (activeRailIndices.Length == 0) { + return false; + } + + var railOffsets = new float2[activeRailIndices.Length]; + var railRadii = new float[activeRailIndices.Length]; + var minimum = new float2(float.PositiveInfinity); + var maximum = new float2(float.NegativeInfinity); + var evaluationContext = new WireRailPathEvaluationContext(); + for (var activeRailIndex = 0; activeRailIndex < activeRailIndices.Length; + activeRailIndex++) { + var railIndex = activeRailIndices[activeRailIndex]; + if (!WireRailSplineGeometry.TryEvaluateRailPosition(spline, segments, + evaluationContext, segmentIndex, railIndex, curveT, + out var railPosition)) { + return false; + } + var relative = railPosition - frame.Position; + var offset = new float2(math.dot(relative, frame.Right), + math.dot(relative, frame.Up)); + var radius = WireRailSplineGeometry.EvaluateWireDiameter(spline, segments, + segmentIndex, railIndex, curveT) * 0.5f; + railOffsets[activeRailIndex] = offset; + railRadii[activeRailIndex] = radius; + minimum = math.min(minimum, offset - radius); + maximum = math.max(maximum, offset + radius); + } + + var automaticCenterOffset = (minimum + maximum) * 0.5f; + var envelopeRadius = 0f; + for (var railIndex = 0; railIndex < railOffsets.Length; railIndex++) { + envelopeRadius = math.max(envelopeRadius, + math.distance(railOffsets[railIndex], automaticCenterOffset) + + railRadii[railIndex]); + } + var centerOffset = automaticCenterOffset + + new float2(ring.LateralOffset, ring.VerticalOffset); + var tubeRadius = ring.Diameter * 0.5f; + var baseRadius = envelopeRadius + tubeRadius; + profile = new WireRailRingProfile(frame, centerOffset, baseRadius, + math.max(tubeRadius, baseRadius * ring.Scale), railOffsets, railRadii); + return true; + } + + internal static bool TryEvaluateCradleProfile(Spline spline, + IReadOnlyList segments, WireRailCradleFixture cradle, + out WireRailCradleProfile profile) + { + profile = default; + if (cradle == null || !TryGetSplineLocation(spline, segments, cradle.Distance, + out var segmentIndex, out var curveT, out var frame)) { + return false; + } + var segment = segments[segmentIndex]; + var railOffsets = new List(segment.RailCount); + var railRadii = new List(segment.RailCount); + var offsetsByIndex = new float2[segment.RailCount]; + var radiiByIndex = new float[segment.RailCount]; + var activeByIndex = new bool[segment.RailCount]; + var minimum = new float2(float.PositiveInfinity); + var maximum = new float2(float.NegativeInfinity); + var evaluationContext = new WireRailPathEvaluationContext(); + for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) { + if (!segment.IsRailActive(railIndex)) { + continue; + } + if (!WireRailSplineGeometry.TryEvaluateRailPosition(spline, segments, + evaluationContext, segmentIndex, railIndex, curveT, + out var railPosition)) { + return false; + } + var relative = railPosition - frame.Position; + var offset = new float2(math.dot(relative, frame.Right), + math.dot(relative, frame.Up)); + var radius = WireRailSplineGeometry.EvaluateWireDiameter(spline, segments, + segmentIndex, railIndex, curveT) * 0.5f; + railOffsets.Add(offset); + railRadii.Add(radius); + offsetsByIndex[railIndex] = offset; + radiiByIndex[railIndex] = radius; + activeByIndex[railIndex] = true; + minimum = math.min(minimum, offset - radius); + maximum = math.max(maximum, offset + radius); + } + if (railOffsets.Count == 0) { + return false; + } + + var tubeRadius = cradle.Diameter * 0.5f; + var envelopeCenterX = (minimum.x + maximum.x) * 0.5f; + var theoreticalTip = TryCalculateDefaultCradleOrigin(offsetsByIndex, + radiiByIndex, activeByIndex, tubeRadius, envelopeCenterX, + out var fittedOrigin) && fittedOrigin.y < minimum.y - 1e-5f + ? fittedOrigin + : new float2(envelopeCenterX, + minimum.y - WireRailLayout.MiddleRailHeight - tubeRadius * 2f); + // Freeze the bottom-center anchor at the default fit so editing the authored + // bottom length or arm angle reshapes the fixture without moving it. + var defaultHalfAngle = math.radians(WireRailCradleFixture.DefaultAngle * 0.5f); + var defaultBottomRise = WireRailCradleFixture.DefaultBottomLength * 0.5f + / math.tan(defaultHalfAngle); + var originOffset = theoreticalTip + new float2(0f, defaultBottomRise) + + new float2(cradle.LateralOffset, cradle.VerticalOffset); + + var halfAngle = math.radians(cradle.Angle * 0.5f); + var leftDirection = new float2(-math.sin(halfAngle), math.cos(halfAngle)); + var rightDirection = new float2(math.sin(halfAngle), math.cos(halfAngle)); + var halfBottomLength = cradle.BottomLength * 0.5f; + var leftBottom = new float2(-halfBottomLength, 0f); + var rightBottom = new float2(halfBottomLength, 0f); + var rawPoints = new List(4); + if (cradle.LeftLength > 1e-5f) { + rawPoints.Add(leftBottom + leftDirection * cradle.LeftLength); + } + rawPoints.Add(leftBottom); + rawPoints.Add(rightBottom); + if (cradle.RightLength > 1e-5f) { + rawPoints.Add(rightBottom + rightDirection * cradle.RightLength); + } + + var rotation = math.radians(cradle.Rotation); + var rotationDirection = new float2(math.cos(rotation), math.sin(rotation)); + for (var pointIndex = 0; pointIndex < rawPoints.Count; pointIndex++) { + var point = rawPoints[pointIndex]; + rawPoints[pointIndex] = originOffset + new float2( + point.x * rotationDirection.x - point.y * rotationDirection.y, + point.x * rotationDirection.y + point.y * rotationDirection.x); + } + var roundedOffsets = BuildRoundedCradleOffsets(rawPoints, + cradle.CornerRadius, tubeRadius, cradle.RingDensity); + if (roundedOffsets == null || roundedOffsets.Count < 2) { + return false; + } + var centerlinePoints = roundedOffsets + .Select(frame.TransformOffset).ToArray(); + profile = new WireRailCradleProfile(frame, railOffsets, railRadii, + originOffset, centerlinePoints); + return true; + } + + private static bool TryCalculateDefaultCradleOrigin(IReadOnlyList offsets, + IReadOnlyList radii, IReadOnlyList active, float tubeRadius, + float envelopeCenterX, out float2 origin) + { + origin = default; + if (offsets.Count < 4 || radii.Count < 4 || active.Count < 4 + || !active[0] || !active[1] || !active[2] || !active[3]) { + return false; + } + var bottomLeftSide = math.sign(offsets[0].x - envelopeCenterX); + var bottomRightSide = math.sign(offsets[1].x - envelopeCenterX); + var upperLeftSide = math.sign(offsets[2].x - envelopeCenterX); + var upperRightSide = math.sign(offsets[3].x - envelopeCenterX); + if (bottomLeftSide == 0f || bottomRightSide == 0f + || bottomLeftSide != upperLeftSide || bottomRightSide != upperRightSide + || bottomLeftSide == bottomRightSide + || !TryBuildOuterTangent(offsets[0], offsets[2], radii[0] + tubeRadius, + radii[2] + tubeRadius, new float2(bottomLeftSide, 0f), + out var leftPoint, out var leftDirection) + || !TryBuildOuterTangent(offsets[1], offsets[3], radii[1] + tubeRadius, + radii[3] + tubeRadius, new float2(bottomRightSide, 0f), + out var rightPoint, out var rightDirection)) { + return false; + } + var denominator = Cross(leftDirection, rightDirection); + if (math.abs(denominator) <= 1e-5f) { + return false; + } + var distance = Cross(rightPoint - leftPoint, rightDirection) / denominator; + origin = leftPoint + leftDirection * distance; + return math.all(math.isfinite(origin)); + + static bool TryBuildOuterTangent(float2 bottom, float2 upper, + float bottomRadius, float upperRadius, float2 outward, + out float2 point, out float2 direction) + { + point = default; + direction = default; + var delta = upper - bottom; + var distance = math.length(delta); + if (distance <= 1e-5f) { + return false; + } + var along = delta / distance; + var normalAlong = (bottomRadius - upperRadius) / distance; + if (math.abs(normalAlong) >= 1f) { + return false; + } + var normalAcross = math.sqrt(1f - normalAlong * normalAlong); + var perpendicular = new float2(-along.y, along.x); + var firstNormal = along * normalAlong + perpendicular * normalAcross; + var secondNormal = along * normalAlong - perpendicular * normalAcross; + var normal = math.dot(firstNormal, outward) >= math.dot(secondNormal, outward) + ? firstNormal : secondNormal; + point = bottom + normal * bottomRadius; + direction = new float2(-normal.y, normal.x); + if (math.dot(direction, delta) < 0f) { + direction = -direction; + } + return true; + } + + static float Cross(float2 left, float2 right) + => left.x * right.y - left.y * right.x; + } + + private static List BuildRoundedCradleOffsets(IReadOnlyList points, + float desiredRadius, float minimumRadius, int ringDensity) + { + var rounded = new List(points.Count + ringDensity); + AddDistinct(rounded, points[0]); + for (var pointIndex = 1; pointIndex < points.Count - 1; pointIndex++) { + var previous = points[pointIndex - 1]; + var corner = points[pointIndex]; + var next = points[pointIndex + 1]; + var incoming = corner - previous; + var outgoing = next - corner; + var incomingLength = math.length(incoming); + var outgoingLength = math.length(outgoing); + if (incomingLength <= 1e-5f || outgoingLength <= 1e-5f) { + AddDistinct(rounded, corner); + continue; + } + var incomingDirection = incoming / incomingLength; + var outgoingDirection = outgoing / outgoingLength; + var dot = math.clamp(math.dot(incomingDirection, outgoingDirection), -1f, 1f); + var cross = incomingDirection.x * outgoingDirection.y + - incomingDirection.y * outgoingDirection.x; + var cornerAngle = math.acos(dot); + if (cornerAngle <= math.radians(0.5f) || math.abs(cross) <= 1e-6f) { + AddDistinct(rounded, corner); + continue; + } + var tangentScale = math.tan(cornerAngle * 0.5f); + var tangentDistance = math.min(math.max(0.05f, desiredRadius) + * tangentScale, math.min(incomingLength, outgoingLength) + * WireRailCradleFixture.MaximumCornerSpanFraction); + var radius = tangentDistance / tangentScale; + if (radius + 1e-5f < minimumRadius) { + return null; + } + var start = corner - incomingDirection * tangentDistance; + var end = corner + outgoingDirection * tangentDistance; + var turnSign = math.sign(cross); + var center = start + new float2(-incomingDirection.y, + incomingDirection.x) * radius * turnSign; + var startRadius = start - center; + var segmentCount = math.max(2, (int)math.ceil(math.max( + ringDensity * cornerAngle / FullTurn, + cornerAngle / RoundedCornerMaxAngleStep))); + AddDistinct(rounded, start); + for (var segmentIndex = 1; segmentIndex < segmentCount; segmentIndex++) { + var angle = cornerAngle * turnSign * segmentIndex / segmentCount; + var direction = new float2(math.cos(angle), math.sin(angle)); + AddDistinct(rounded, center + new float2( + startRadius.x * direction.x - startRadius.y * direction.y, + startRadius.x * direction.y + startRadius.y * direction.x)); + } + AddDistinct(rounded, end); + } + AddDistinct(rounded, points[^1]); + return rounded; + + static void AddDistinct(List target, float2 point) + { + if (target.Count == 0 || math.distancesq(target[^1], point) > 1e-10f) { + target.Add(point); + } + } + } + + internal static bool TryEvaluateRungProfile(Spline spline, + IReadOnlyList segments, WireRailRungFixture rung, + out WireRailRungProfile profile) + { + profile = default; + return rung != null && TryEvaluateRungProfile(spline, segments, + rung.Distance, rung.StartRailIndex, rung.EndRailIndex, + rung.Angle, rung.LateralOffset, rung.VerticalOffset, + rung.LengthAdjustment, out profile); + } + + private static bool TryEvaluateRungProfile(Spline spline, + IReadOnlyList segments, float distance, int startRailIndex, + int endRailIndex, float angleDegrees, float lateralOffset, + float verticalOffset, float lengthAdjustment, + out WireRailRungProfile profile) + { + profile = default; + if (!TryGetSplineLocation(spline, segments, distance, + out var segmentIndex, out var curveT, out var frame)) { + return false; + } + var segment = segments[segmentIndex]; + if (startRailIndex == endRailIndex || startRailIndex >= segment.RailCount + || endRailIndex >= segment.RailCount + || !segment.IsRailActive(startRailIndex) + || !segment.IsRailActive(endRailIndex)) { + return false; + } + + var evaluationContext = new WireRailPathEvaluationContext(); + var startRailOffset = default(float2); + var endRailOffset = default(float2); + var startRailRadius = 0f; + var endRailRadius = 0f; + var envelopeMinimum = new float2(float.PositiveInfinity); + var envelopeMaximum = new float2(float.NegativeInfinity); + for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) { + if (!segment.IsRailActive(railIndex)) { + continue; + } + if (!TryEvaluateRailOffset(railIndex, out var railOffset, + out var railRadius)) { + return false; + } + if (railIndex == startRailIndex) { + startRailOffset = railOffset; + startRailRadius = railRadius; + } + if (railIndex == endRailIndex) { + endRailOffset = railOffset; + endRailRadius = railRadius; + } + envelopeMinimum = math.min(envelopeMinimum, railOffset - railRadius); + envelopeMaximum = math.max(envelopeMaximum, railOffset + railRadius); + } + var railDirection = math.normalizesafe(endRailOffset - startRailOffset, + new float2(1f, 0f)); + var attachmentStart = startRailOffset + railDirection * startRailRadius; + var attachmentEnd = endRailOffset - railDirection * endRailRadius; + var rotationOriginOffset = (envelopeMinimum + envelopeMaximum) * 0.5f; + var angle = math.radians(angleDegrees); + var direction = new float2(math.cos(angle), math.sin(angle)); + var bottomCenter = (attachmentStart + attachmentEnd) * 0.5f; + var relativeBottomCenter = bottomCenter - rotationOriginOffset; + var rotatedBottomCenter = rotationOriginOffset + new float2( + relativeBottomCenter.x * direction.x + - relativeBottomCenter.y * direction.y, + relativeBottomCenter.x * direction.y + + relativeBottomCenter.y * direction.x); + var center = rotatedBottomCenter + + new float2(lateralOffset, verticalOffset); + var length = math.max(0.1f, math.distance(attachmentStart, attachmentEnd) + + lengthAdjustment); + var startOffset = center - direction * length * 0.5f; + var endOffset = center + direction * length * 0.5f; + profile = new WireRailRungProfile(frame, startRailOffset, endRailOffset, + startRailRadius, endRailRadius, rotationOriginOffset, startOffset, endOffset); + return true; + + bool TryEvaluateRailOffset(int railIndex, out float2 railOffset, + out float railRadius) + { + railOffset = default; + railRadius = 0f; + if (!WireRailSplineGeometry.TryEvaluateRailPosition(spline, segments, + evaluationContext, segmentIndex, railIndex, curveT, + out var railPosition)) { + return false; + } + var relative = railPosition - frame.Position; + railOffset = new float2(math.dot(relative, frame.Right), + math.dot(relative, frame.Up)); + railRadius = WireRailSplineGeometry.EvaluateWireDiameter(spline, segments, + segmentIndex, railIndex, curveT) * 0.5f; + return true; + } + } + + internal static bool TryEvaluateStandProfile(Spline spline, + IReadOnlyList segments, WireRailStandFixture leg, + out WireRailStandProfile profile) + { + profile = default; + if (leg == null || !TryEvaluateRungProfile(spline, segments, + leg.Distance, 0, 1, 0f, leg.LateralOffset, leg.VerticalOffset, + leg.LengthAdjustment, + out var attachmentProfile)) { + return false; + } + + var frame = attachmentProfile.Frame; + var legStart = leg.LegSide == WireRailStandSide.Left + ? attachmentProfile.Start : attachmentProfile.End; + var authoredDirection = (float3)leg.StartDirection; + var startDirection = math.normalizesafe( + frame.Right * authoredDirection.x + + frame.Tangent * authoredDirection.y + + frame.Up * authoredDirection.z, -frame.Up); + var elbow = legStart + startDirection * leg.StartLength; + + var footRotation = quaternion.EulerXYZ(math.radians((float3)leg.FootRotation)); + var footPosition = (float3)leg.FootPosition; + var localFootPoints = BuildUHookPoints(leg.FootWidth, leg.FootLength, + leg.FootConnectionLength, WireRailStandFixture.FootBendSegments, + leg.FootClockwise); + var footPoints = new float3[localFootPoints.Count]; + for (var pointIndex = 0; pointIndex < localFootPoints.Count; pointIndex++) { + var local = footPosition + math.mul(footRotation, localFootPoints[pointIndex]); + footPoints[pointIndex] = legStart + frame.Right * local.x + + frame.Tangent * local.y + frame.Up * local.z; + } + + var legPoints = new List { legStart }; + AddDistinct(legPoints, elbow); + AddDistinct(legPoints, footPoints[0]); + var oppositeAttachmentEnd = leg.LegSide == WireRailStandSide.Left + ? attachmentProfile.End : attachmentProfile.Start; + var combinedPath = new List(legPoints.Count + footPoints.Length + 1) { + oppositeAttachmentEnd, + }; + foreach (var legPoint in legPoints) { + AddDistinct(combinedPath, legPoint); + } + var lastLegCornerIndex = combinedPath.Count - 1; + for (var pointIndex = 1; pointIndex < footPoints.Length; pointIndex++) { + AddDistinct(combinedPath, footPoints[pointIndex]); + } + if (combinedPath.Count < 2) { + return false; + } + combinedPath = BuildRoundedLegPath(combinedPath, lastLegCornerIndex, + leg.Diameter * LegCornerRadiusDiameterRatio); + if (combinedPath == null) { + return false; + } + profile = new WireRailStandProfile(attachmentProfile, legPoints, + footPoints, combinedPath); + return true; + + static void AddDistinct(ICollection points, float3 point) + { + if (points is List list && list.Count > 0 + && math.distancesq(list[^1], point) <= 1e-10f) { + return; + } + points.Add(point); + } + } + + internal static bool TryEvaluateHairpinProfile(Spline spline, + IReadOnlyList segments, WireRailHairpinFixture hairpin, + out WireRailHairpinProfile profile) + { + if (hairpin == null) { + profile = default; + return false; + } + var leadSegments = math.max(2, + (int)math.ceil(hairpin.RingDensity * 0.25f)); + var terminalSegments = math.max(2, + (int)math.ceil(hairpin.RingDensity * 0.5f)); + return TryEvaluateHairpinProfile(spline, segments, hairpin, leadSegments, + terminalSegments, out profile); + } + + internal static bool TryEvaluateHairpinColliderProfile(Spline spline, + IReadOnlyList segments, WireRailHairpinFixture hairpin, + out WireRailHairpinProfile profile) + => TryEvaluateHairpinProfile(spline, segments, hairpin, + HairpinColliderLeadSegments, HairpinColliderTerminalSegments, out profile); + + private static bool TryEvaluateHairpinProfile(Spline spline, + IReadOnlyList segments, WireRailHairpinFixture hairpin, + int leadSegments, int terminalSegments, out WireRailHairpinProfile profile) + { + profile = default; + if (hairpin == null || spline == null || spline.Closed) { + return false; + } + // The offset shortens the connected rails, so the loop attaches this far before the + // endpoint; evaluate the frame and rails there so the loop meets the trimmed ends. + var splineLength = spline.GetLength(); + if (!WireRailEndpointTrimUtility.IsHairpinGeneratable(segments, hairpin, splineLength)) { + return false; + } + var railTrim = math.max(0f, hairpin.RailOffset); + var attachmentDistance = math.clamp( + hairpin.Endpoint == WireRailEndpoint.Start ? railTrim : splineLength - railTrim, + 0f, splineLength); + if (!TryGetSplineLocation(spline, segments, attachmentDistance, + out var segmentIndex, out var curveT, out var frame)) { + return false; + } + var segment = segments[segmentIndex]; + if (hairpin.FirstRailIndex == hairpin.SecondRailIndex + || hairpin.FirstRailIndex < 0 || hairpin.SecondRailIndex < 0 + || hairpin.FirstRailIndex >= segment.RailCount + || hairpin.SecondRailIndex >= segment.RailCount + || !segment.IsRailActive(hairpin.FirstRailIndex) + || !segment.IsRailActive(hairpin.SecondRailIndex)) { + return false; + } + + var evaluationContext = new WireRailPathEvaluationContext(); + if (!WireRailSplineGeometry.TryEvaluateRailPosition(spline, segments, + evaluationContext, segmentIndex, hairpin.FirstRailIndex, curveT, + out var firstRail) + || !WireRailSplineGeometry.TryEvaluateRailPosition(spline, segments, + evaluationContext, segmentIndex, hairpin.SecondRailIndex, curveT, + out var secondRail)) { + return false; + } + + var outward = hairpin.Endpoint == WireRailEndpoint.Start + ? -frame.Tangent : frame.Tangent; + // The rails are already trimmed to the attachment distance above, so the loop joins + // them right at their ends. + var firstAttachment = firstRail; + var secondAttachment = secondRail; + var pairAxis = math.normalizesafe(secondAttachment - firstAttachment, + frame.Right); + pairAxis = math.normalizesafe(math.mul(quaternion.AxisAngle(frame.Tangent, + math.radians(hairpin.Rotation)), pairAxis), frame.Right); + var midpoint = (firstAttachment + secondAttachment) * 0.5f; + var loopCenter = midpoint + outward * hairpin.LeadLength; + var loopRadius = hairpin.LoopDiameter * 0.5f; + var firstArc = loopCenter - pairAxis * loopRadius; + var secondArc = loopCenter + pairAxis * loopRadius; + if (math.distancesq(firstAttachment, firstArc) + > math.distancesq(firstAttachment, secondArc)) { + pairAxis = -pairAxis; + (firstArc, secondArc) = (secondArc, firstArc); + } + + var firstLead = BuildLead(firstAttachment, firstArc, outward, + hairpin.TangentLength, leadSegments); + var secondLeadFromRail = BuildLead(secondAttachment, secondArc, outward, + hairpin.TangentLength, leadSegments); + var secondLead = secondLeadFromRail.Reverse().ToArray(); + var terminal = new float3[terminalSegments + 1]; + for (var pointIndex = 0; pointIndex <= terminalSegments; pointIndex++) { + var angle = math.PI * pointIndex / terminalSegments; + terminal[pointIndex] = loopCenter - pairAxis * math.cos(angle) * loopRadius + + outward * math.sin(angle) * loopRadius; + } + var centerline = new List(firstLead.Length + terminal.Length + + secondLead.Length - 2); + foreach (var point in firstLead) { + AddDistinct(centerline, point); + } + var terminalStartSpan = centerline.Count - 1; + for (var pointIndex = 1; pointIndex < terminal.Length; pointIndex++) { + AddDistinct(centerline, terminal[pointIndex]); + } + var terminalEndSpan = centerline.Count - 1; + for (var pointIndex = 1; pointIndex < secondLead.Length; pointIndex++) { + AddDistinct(centerline, secondLead[pointIndex]); + } + profile = new WireRailHairpinProfile(frame, firstLead, terminal, + secondLead, centerline, terminalStartSpan, terminalEndSpan); + return true; + + static void AddDistinct(List points, float3 point) + { + if (points.Count == 0 || math.distancesq(points[^1], point) > 1e-10f) { + points.Add(point); + } + } + + static float3[] BuildLead(float3 attachment, float3 arcPoint, + float3 outwardDirection, float tangentLength, int segmentCount) + { + var maximumTangentLength = math.distance(attachment, arcPoint) * 0.49f; + var handleLength = math.min(math.max(0f, tangentLength), maximumTangentLength); + var control1 = attachment + outwardDirection * handleLength; + var control2 = arcPoint - outwardDirection * handleLength; + var points = new float3[segmentCount + 1]; + for (var pointIndex = 0; pointIndex <= segmentCount; pointIndex++) { + var t = pointIndex / (float)segmentCount; + var inverse = 1f - t; + points[pointIndex] = inverse * inverse * inverse * attachment + + 3f * inverse * inverse * t * control1 + + 3f * inverse * t * t * control2 + + t * t * t * arcPoint; + } + return points; + } + } + + internal static bool TryEvaluateElbowProfile(Spline spline, + IReadOnlyList segments, WireRailElbowFixture elbow, + out WireRailElbowProfile profile) + { + profile = default; + if (elbow == null || spline == null || spline.Closed) { + return false; + } + // The offset shortens the rails: the elbow attaches this far back from the endpoint. + var splineLength = spline.GetLength(); + if (!WireRailEndpointTrimUtility.IsElbowGeneratable(segments, elbow, splineLength)) { + return false; + } + var railTrim = math.max(0f, elbow.Offset); + var attachmentDistance = elbow.Endpoint == WireRailEndpoint.Start + ? railTrim : splineLength - railTrim; + if (!TryGetSplineLocation(spline, segments, attachmentDistance, + out var segmentIndex, out var curveT, out var frame)) { + return false; + } + var segment = segments[segmentIndex]; + if (elbow.FirstRailIndex == elbow.SecondRailIndex + || elbow.FirstRailIndex < 0 || elbow.SecondRailIndex < 0 + || elbow.FirstRailIndex >= segment.RailCount + || elbow.SecondRailIndex >= segment.RailCount + || !segment.IsRailActive(elbow.FirstRailIndex) + || !segment.IsRailActive(elbow.SecondRailIndex)) { + return false; + } + + var evaluationContext = new WireRailPathEvaluationContext(); + if (!WireRailSplineGeometry.TryEvaluateRailPosition(spline, segments, + evaluationContext, segmentIndex, elbow.FirstRailIndex, curveT, + out var firstAttachment) + || !WireRailSplineGeometry.TryEvaluateRailPosition(spline, segments, + evaluationContext, segmentIndex, elbow.SecondRailIndex, curveT, + out var secondAttachment)) { + return false; + } + + var outward = elbow.Endpoint == WireRailEndpoint.Start + ? -frame.Tangent : frame.Tangent; + outward = math.normalizesafe(math.mul( + quaternion.AxisAngle(frame.Up, math.radians(elbow.ZAngle)), outward), + outward); + var down = -frame.Up; + var bendRadius = math.min(math.max(0.05f, elbow.Diameter), + elbow.DropLength); + var firstRailPoints = BuildPath(firstAttachment); + var secondRailPoints = BuildPath(secondAttachment); + if (firstRailPoints == null || secondRailPoints == null + || firstRailPoints.Count != secondRailPoints.Count) { + return false; + } + var firstRailRadius = WireRailSplineGeometry.EvaluateWireDiameter(spline, + segments, segmentIndex, elbow.FirstRailIndex, curveT) * 0.5f; + var secondRailRadius = WireRailSplineGeometry.EvaluateWireDiameter(spline, + segments, segmentIndex, elbow.SecondRailIndex, curveT) * 0.5f; + const int bendStartPointIndex = 0; + var dropLineDistance = bendRadius; + profile = new WireRailElbowProfile(frame, firstRailPoints, secondRailPoints, + firstRailRadius, secondRailRadius, bendStartPointIndex, + firstAttachment + outward * dropLineDistance, + secondAttachment + outward * dropLineDistance); + return true; + + List BuildPath(float3 attachment) + { + var bendStart = attachment; + var bendCenter = bendStart + down * bendRadius; + var bendNormal = math.normalizesafe(math.cross(outward, down)); + if (math.lengthsq(bendNormal) <= 1e-8f) { + return null; + } + var bendSegments = math.max(2, + (int)math.ceil((math.PI * 0.5f) / RoundedCornerMaxAngleStep)); + var points = new List(bendSegments + 3); + AddDistinct(points, attachment); + AddDistinct(points, bendStart); + var startRadius = -down * bendRadius; + for (var bendIndex = 1; bendIndex <= bendSegments; bendIndex++) { + var angle = math.PI * 0.5f * bendIndex / bendSegments; + AddDistinct(points, bendCenter + math.mul( + quaternion.AxisAngle(bendNormal, angle), startRadius)); + } + var bendEnd = bendCenter + outward * bendRadius; + AddDistinct(points, bendEnd + + down * math.max(0f, elbow.DropLength - bendRadius)); + return points; + + static void AddDistinct(ICollection target, float3 point) + { + if (target is List list && list.Count > 0 + && math.distancesq(list[^1], point) <= 1e-10f) { + return; + } + target.Add(point); + } + } + } + + // Samples the two attached rails just before a elbow's attachment so a preview can show + // the rails leading into the elbow. Points are ordered from furthest-back toward the + // attachment (exclusive). Preview-only: this is not part of the generated mesh. + internal static bool TryEvaluateElbowIncomingLeads(Spline spline, + IReadOnlyList segments, WireRailElbowFixture elbow, float desiredLead, + out List firstLead, out List secondLead) + { + firstLead = null; + secondLead = null; + if (elbow == null || spline == null || spline.Closed || desiredLead <= 1e-3f) { + return false; + } + var splineLength = spline.GetLength(); + var railTrim = math.max(0f, elbow.Offset); + var attachmentDistance = elbow.Endpoint == WireRailEndpoint.Start + ? railTrim : splineLength - railTrim; + // Incoming rail runs toward the spline interior (away from the elbow's endpoint). + var inwardSign = elbow.Endpoint == WireRailEndpoint.Start ? 1f : -1f; + var available = elbow.Endpoint == WireRailEndpoint.Start + ? splineLength - attachmentDistance : attachmentDistance; + var lead = math.clamp(desiredLead, 0f, math.max(0f, available)); + if (lead <= 1e-3f) { + return false; + } + var sampleCount = math.clamp((int)math.ceil(lead / 5f), 4, 24); + var context = new WireRailPathEvaluationContext(); + firstLead = new List(sampleCount); + secondLead = new List(sampleCount); + for (var sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) { + var t = lead * (1f - (float)sampleIndex / sampleCount); + var distance = math.clamp(attachmentDistance + inwardSign * t, 0f, splineLength); + if (!TryGetSplineLocation(spline, segments, distance, + out var segmentIndex, out var curveT, out _)) { + continue; + } + var segment = segments[segmentIndex]; + if (elbow.FirstRailIndex >= 0 && elbow.FirstRailIndex < segment.RailCount + && segment.IsRailActive(elbow.FirstRailIndex) + && WireRailSplineGeometry.TryEvaluateRailPosition(spline, segments, context, + segmentIndex, elbow.FirstRailIndex, curveT, out var firstPos)) { + firstLead.Add(firstPos); + } + if (elbow.SecondRailIndex >= 0 && elbow.SecondRailIndex < segment.RailCount + && segment.IsRailActive(elbow.SecondRailIndex) + && WireRailSplineGeometry.TryEvaluateRailPosition(spline, segments, context, + segmentIndex, elbow.SecondRailIndex, curveT, out var secondPos)) { + secondLead.Add(secondPos); + } + } + return firstLead.Count > 0 || secondLead.Count > 0; + } + + private static List BuildRoundedLegPath(IReadOnlyList points, + int lastCornerIndex, float desiredRadius) + { + var rounded = new List(points.Count + math.max(0, lastCornerIndex) * 6); + AddDistinct(rounded, points[0]); + var finalRoundedCorner = math.min(lastCornerIndex, points.Count - 2); + for (var pointIndex = 1; pointIndex < points.Count - 1; pointIndex++) { + // A reversal at the rail attachment would run the leg back through the + // attachment wire. Downstream joints are authorable foot geometry: retain + // a sharp joint when no finite tangent fillet can represent the pose. + if (pointIndex == 1 && IsAttachmentFoldback(points[pointIndex - 1], + points[pointIndex], points[pointIndex + 1])) { + return null; + } + if (pointIndex > finalRoundedCorner || !TryAppendRoundedCorner( + points[pointIndex - 1], points[pointIndex], points[pointIndex + 1], + desiredRadius, rounded)) { + AddDistinct(rounded, points[pointIndex]); + } + } + AddDistinct(rounded, points[^1]); + return rounded; + + static bool IsAttachmentFoldback(float3 previous, float3 corner, float3 next) + { + var incoming = math.normalizesafe(corner - previous); + var outgoing = math.normalizesafe(next - corner); + return math.lengthsq(incoming) > 1e-8f + && math.lengthsq(outgoing) > 1e-8f + && math.dot(incoming, outgoing) <= -math.cos(math.radians(5f)); + } + + static bool TryAppendRoundedCorner(float3 previous, float3 corner, float3 next, + float desiredRadius, List target) + { + var incoming = corner - previous; + var outgoing = next - corner; + var incomingLength = math.length(incoming); + var outgoingLength = math.length(outgoing); + if (incomingLength <= 1e-5f || outgoingLength <= 1e-5f) { + return false; + } + var incomingDirection = incoming / incomingLength; + var outgoingDirection = outgoing / outgoingLength; + var cornerAngle = math.acos(math.clamp( + math.dot(incomingDirection, outgoingDirection), -1f, 1f)); + if (cornerAngle <= math.radians(0.5f) + || cornerAngle >= math.PI - math.radians(0.5f)) { + return false; + } + var tangentScale = math.tan(cornerAngle * 0.5f); + var tangentDistance = math.min( + math.max(0.05f, desiredRadius) * tangentScale, + math.min(incomingLength, outgoingLength) * LegCornerMaxSpanFraction); + if (tangentDistance <= 1e-5f || tangentScale <= 1e-5f) { + return false; + } + var radius = tangentDistance / tangentScale; + var bendNormal = math.normalizesafe( + math.cross(incomingDirection, outgoingDirection)); + if (math.lengthsq(bendNormal) <= 1e-8f) { + return false; + } + var start = corner - incomingDirection * tangentDistance; + var end = corner + outgoingDirection * tangentDistance; + var center = start + math.cross(bendNormal, incomingDirection) * radius; + var startRadius = start - center; + var segmentCount = math.max(2, + (int)math.ceil(cornerAngle / RoundedCornerMaxAngleStep)); + AddDistinct(target, start); + for (var segmentIndex = 1; segmentIndex < segmentCount; segmentIndex++) { + var angle = cornerAngle * segmentIndex / segmentCount; + AddDistinct(target, center + math.mul( + quaternion.AxisAngle(bendNormal, angle), startRadius)); + } + AddDistinct(target, end); + return true; + } + + static void AddDistinct(ICollection target, float3 point) + { + if (target is List list && list.Count > 0 + && math.distancesq(list[^1], point) <= 1e-10f) { + return; + } + target.Add(point); + } + } + + private static List BuildUHookPoints(float width, float armLength, + float connectionArmLength, int bendSegments, bool clockwise) + { + var radius = math.max(0.05f, width * 0.5f); + armLength = math.max(0f, armLength); + connectionArmLength = math.max(0f, connectionArmLength); + bendSegments = math.max(2, bendSegments); + var arcCenterY = -armLength * 0.5f + radius * 0.5f; + var openEndY = armLength * 0.5f + radius * 0.5f; + var points = new List(bendSegments + 3) { + new(-radius, arcCenterY + connectionArmLength, 0f), + new(-radius, arcCenterY, 0f), + }; + for (var segmentIndex = 1; segmentIndex <= bendSegments; segmentIndex++) { + var angle = math.PI + math.PI * segmentIndex / bendSegments; + points.Add(new float3(math.cos(angle) * radius, + arcCenterY + math.sin(angle) * radius, 0f)); + } + points.Add(new float3(radius, openEndY, 0f)); + if (clockwise) { + for (var pointIndex = 0; pointIndex < points.Count; pointIndex++) { + var point = points[pointIndex]; + point.x = -point.x; + points[pointIndex] = point; + } + } + return points; + } + + private static bool TryGetSplineLocation(Spline spline, + IReadOnlyList segments, float distance, + out int segmentIndex, out float curveT, out WireRailPathFrame frame) + { + segmentIndex = 0; + curveT = 0f; + frame = default; + if (spline == null || segments == null || segments.Count == 0 + || spline.Count < 2) { + return false; + } + + var length = spline.GetLength(); + var clampedDistance = math.clamp(distance, 0f, math.max(0f, length)); + segmentIndex = WireRailSplineGeometry.GetLayoutIndexAtDistance(segments, + clampedDistance, length); + var startDistance = segments[segmentIndex].Distance; + var endDistance = segmentIndex + 1 < segments.Count + ? segments[segmentIndex + 1].Distance + : length; + curveT = endDistance > startDistance + ? math.saturate((clampedDistance - startDistance) / (endDistance - startDistance)) + : 0f; + return WireRailSplineGeometry.TryEvaluateDistance(spline, clampedDistance, + out frame); + } + + private static void AppendRing(WireRailRingProfile profile, + WireRailRingFixture ring, float capBevelSize, int radialSegments, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices) + { + if (!ring.TryGetVisibleArc(out var startAngle, out var sweepAngle, + out var closed)) { + return; + } + var longitudinalSegments = math.max(2, + (int)math.ceil(ring.RingDensity * sweepAngle / FullTurn)); + var angles = BuildRingAngles(ring, startAngle, sweepAngle, closed, + longitudinalSegments); + var ringCount = angles.Count; + var firstRing = vertices.Count; + var firstFrame = default(WireRailPathFrame); + var lastFrame = default(WireRailPathFrame); + var tubeRadius = ring.Diameter * 0.5f; + var clampedBevel = math.clamp(capBevelSize, 0f, tubeRadius); + for (var ringIndex = 0; ringIndex < ringCount; ringIndex++) { + var angle = angles[ringIndex]; + var centerlineOffset = ring.EvaluateCenterlineOffset(angle, profile.Radius); + var tangentOffset = ring.EvaluateCenterlineTangent(angle); + var tangent = math.normalizesafe(profile.Frame.Right * tangentOffset.x + + profile.Frame.Up * tangentOffset.y, profile.Frame.Up); + var outwardOffset = math.normalizesafe( + new float2(tangentOffset.y, -tangentOffset.x), new float2(1f, 0f)); + if (math.dot(outwardOffset, centerlineOffset) < 0f) { + outwardOffset = -outwardOffset; + } + var outward = math.normalizesafe(profile.Frame.Right * outwardOffset.x + + profile.Frame.Up * outwardOffset.y, profile.Frame.Right); + var up = math.normalizesafe(math.cross(outward, tangent), + -profile.Frame.Tangent); + var capFrame = new WireRailPathFrame( + profile.Frame.TransformOffset(profile.CenterOffset + centerlineOffset), + tangent, outward, up); + if (ringIndex == 0) { + firstFrame = capFrame; + } + lastFrame = capFrame; + var tubeFrame = capFrame; + if (!closed && clampedBevel > 1e-5f && ringIndex == 0) { + tubeFrame = new WireRailPathFrame( + capFrame.Position + capFrame.Tangent * clampedBevel, + capFrame.Tangent, capFrame.Right, capFrame.Up); + } else if (!closed && clampedBevel > 1e-5f + && ringIndex == ringCount - 1) { + tubeFrame = new WireRailPathFrame( + capFrame.Position - capFrame.Tangent * clampedBevel, + capFrame.Tangent, capFrame.Right, capFrame.Up); + } + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var radialAngle = FullTurn * radialIndex / radialSegments; + var radial = tubeFrame.Right * math.cos(radialAngle) + + tubeFrame.Up * math.sin(radialAngle); + vertices.Add((Vector3)(tubeFrame.Position + radial * tubeRadius)); + normals.Add((Vector3)radial); + uvs.Add(new Vector2((angle - startAngle) / sweepAngle, + radialIndex / (float)radialSegments)); + } + } + + var ringPairCount = closed ? ringCount : ringCount - 1; + for (var ringIndex = 0; ringIndex < ringPairCount; ringIndex++) { + var current = firstRing + ringIndex * radialSegments; + var next = firstRing + ((ringIndex + 1) % ringCount) * radialSegments; + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var radialNext = (radialIndex + 1) % radialSegments; + var a = current + radialIndex; + var b = next + radialIndex; + var c = current + radialNext; + var d = next + radialNext; + indices.Add(a); + indices.Add(b); + indices.Add(d); + indices.Add(a); + indices.Add(d); + indices.Add(c); + } + } + + if (!closed) { + WireRailCapMeshGenerator.Append(firstFrame, tubeRadius, capBevelSize, + radialSegments, true, vertices, normals, uvs, indices); + WireRailCapMeshGenerator.Append(lastFrame, tubeRadius, capBevelSize, + radialSegments, false, vertices, normals, uvs, indices); + } + } + + private static void AppendRung(WireRailRungProfile profile, + WireRailRungFixture rung, float capBevelSize, int radialSegments, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices) + { + var start = profile.Start; + var end = profile.End; + var length = math.distance(start, end); + if (length <= 1e-5f) { + return; + } + var tangent = (end - start) / length; + var right = math.normalizesafe(profile.Frame.Tangent, + new float3(0f, 1f, 0f)); + var up = math.normalizesafe(math.cross(right, tangent), profile.Frame.Up); + var startFrame = new WireRailPathFrame(start, tangent, right, up); + var endFrame = new WireRailPathFrame(end, tangent, right, up); + var tubeRadius = rung.Diameter * 0.5f; + var bevel = math.min(math.clamp(capBevelSize, 0f, tubeRadius), length * 0.5f); + var bodyStart = start + tangent * bevel; + var bodyEnd = end - tangent * bevel; + if (math.distancesq(bodyStart, bodyEnd) > 1e-10f) { + var firstRing = vertices.Count; + for (var ringIndex = 0; ringIndex < 2; ringIndex++) { + var position = ringIndex == 0 ? bodyStart : bodyEnd; + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var radialAngle = FullTurn * radialIndex / radialSegments; + var radial = right * math.cos(radialAngle) + + up * math.sin(radialAngle); + vertices.Add((Vector3)(position + radial * tubeRadius)); + normals.Add((Vector3)radial); + uvs.Add(new Vector2(ringIndex, + radialIndex / (float)radialSegments)); + } + } + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var radialNext = (radialIndex + 1) % radialSegments; + var a = firstRing + radialIndex; + var b = firstRing + radialSegments + radialIndex; + var c = firstRing + radialNext; + var d = firstRing + radialSegments + radialNext; + indices.Add(a); + indices.Add(b); + indices.Add(d); + indices.Add(a); + indices.Add(d); + indices.Add(c); + } + } + WireRailCapMeshGenerator.Append(startFrame, tubeRadius, bevel, + radialSegments, true, vertices, normals, uvs, indices); + WireRailCapMeshGenerator.Append(endFrame, tubeRadius, bevel, + radialSegments, false, vertices, normals, uvs, indices); + } + + private static void AppendCradle(WireRailCradleProfile profile, + WireRailCradleFixture cradle, float capBevelSize, int radialSegments, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices) + { + AppendPolylineTube(profile.CenterlinePoints, profile.Frame, + cradle.Diameter, capBevelSize, radialSegments, + vertices, normals, uvs, indices); + } + + private static void AppendStand(WireRailStandProfile profile, + WireRailStandFixture leg, float capBevelSize, int radialSegments, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices) + { + AppendPolylineTube(profile.CombinedPath, profile.AttachmentProfile.Frame, + leg.Diameter, capBevelSize, radialSegments, + vertices, normals, uvs, indices, true); + } + + private static void AppendHairpin(WireRailHairpinProfile profile, + WireRailHairpinFixture hairpin, float capBevelSize, int radialSegments, + WireRailPathFrame referenceFrame, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices) + { + // The loop always meets the (trimmed) rail ends, so its mouths stay open. Seed the + // radial orientation from the first rail's mouth frame so that mouth lines up. + AppendPolylineTube(profile.CenterlinePoints, referenceFrame, + hairpin.Diameter, capBevelSize, radialSegments, + vertices, normals, uvs, indices, false, false, mouthFrame: referenceFrame); + } + + private static void AppendElbow(WireRailElbowProfile profile, + WireRailElbowFixture elbow, float capBevelSize, int radialSegments, + WireRailPathFrame firstRailFrame, WireRailPathFrame secondRailFrame, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices, + out int firstMouthRing, out int secondMouthRing) + { + // Each rail tube is open at the rail attachment (capStart:false) and capped at the + // elbow; that first ring is the mouth, reported so the render can weld it to the rail. + // The rail's mouth frame seeds the tube's radial orientation so the rings line up. + var firstStart = vertices.Count; + AppendPolylineTube(profile.FirstRailPoints, firstRailFrame, + elbow.Diameter, capBevelSize, radialSegments, + vertices, normals, uvs, indices, false, true, mouthFrame: firstRailFrame); + firstMouthRing = vertices.Count - firstStart >= radialSegments ? firstStart : -1; + var secondStart = vertices.Count; + AppendPolylineTube(profile.SecondRailPoints, secondRailFrame, + elbow.Diameter, capBevelSize, radialSegments, + vertices, normals, uvs, indices, false, true, mouthFrame: secondRailFrame); + secondMouthRing = vertices.Count - secondStart >= radialSegments ? secondStart : -1; + } + + internal static void AppendPolylineTube(IReadOnlyList sourcePoints, + WireRailPathFrame referenceFrame, float diameter, float capBevelSize, + int radialSegments, ICollection vertices, + ICollection normals, ICollection uvs, + ICollection indices, bool allowPathReversals = false) + => AppendPolylineTube(sourcePoints, referenceFrame, diameter, capBevelSize, + radialSegments, vertices, normals, uvs, indices, true, true, + allowPathReversals: allowPathReversals); + + internal static void AppendPolylineTube(IReadOnlyList sourcePoints, + WireRailPathFrame referenceFrame, float diameter, float capBevelSize, + int radialSegments, ICollection vertices, + ICollection normals, ICollection uvs, + ICollection indices, bool capStart, bool capEnd, + ICollection secondaryIndices = null, int secondaryStartSpan = 0, + int secondaryEndSpan = 0, bool allowPathReversals = false, + WireRailPathFrame? mouthFrame = null) + => AppendPolylineSweep(sourcePoints, referenceFrame, diameter, capBevelSize, + radialSegments, 0f, vertices, normals, uvs, indices, capStart, capEnd, + secondaryIndices, secondaryStartSpan, secondaryEndSpan, allowPathReversals, + mouthFrame); + + internal static void AppendPolylineBox(IReadOnlyList sourcePoints, + WireRailPathFrame referenceFrame, float width, + ICollection vertices, ICollection indices, + ICollection secondaryIndices = null, int secondaryStartSpan = 0, + int secondaryEndSpan = 0, bool closeEnds = false) + { + const int sideCount = 4; + const float cornerRotation = math.PI * 0.25f; + var firstRing = vertices.Count; + AppendPolylineSweep(sourcePoints, referenceFrame, width * math.sqrt(2f), 0f, + sideCount, cornerRotation, vertices, null, null, indices, false, false, + secondaryIndices, secondaryStartSpan, secondaryEndSpan, false); + if (!closeEnds || vertices.Count - firstRing < sideCount * 2) { + return; + } + var lastRing = vertices.Count - sideCount; + indices.Add(firstRing); + indices.Add(firstRing + 1); + indices.Add(firstRing + 2); + indices.Add(firstRing); + indices.Add(firstRing + 2); + indices.Add(firstRing + 3); + indices.Add(lastRing); + indices.Add(lastRing + 2); + indices.Add(lastRing + 1); + indices.Add(lastRing); + indices.Add(lastRing + 3); + indices.Add(lastRing + 2); + } + + private static void AppendPolylineSweep(IReadOnlyList sourcePoints, + WireRailPathFrame referenceFrame, float diameter, float capBevelSize, + int radialSegments, float radialRotation, ICollection vertices, + ICollection normals, ICollection uvs, + ICollection indices, bool capStart, bool capEnd, + ICollection secondaryIndices, int secondaryStartSpan, + int secondaryEndSpan, bool allowPathReversals, + WireRailPathFrame? mouthFrame = null) + { + if (sourcePoints == null || sourcePoints.Count < 2) { + return; + } + var points = new List(sourcePoints.Count); + for (var pointIndex = 0; pointIndex < sourcePoints.Count; pointIndex++) { + if (points.Count == 0 + || math.distancesq(points[^1], sourcePoints[pointIndex]) > 1e-10f) { + points.Add(sourcePoints[pointIndex]); + } + } + if (points.Count < 2) { + return; + } + if (!allowPathReversals) { + for (var pointIndex = 1; pointIndex < points.Count - 1; pointIndex++) { + if (!IsPathReversal(points[pointIndex - 1], points[pointIndex], + points[pointIndex + 1])) { + continue; + } + return; + } + } + + var tubeRadius = math.max(0.05f, diameter * 0.5f); + var firstSpan = math.distance(points[0], points[1]); + var lastSpan = math.distance(points[^2], points[^1]); + var startBevel = capStart + ? math.min(math.clamp(capBevelSize, 0f, tubeRadius), firstSpan * 0.5f) + : 0f; + var endBevel = capEnd + ? math.min(math.clamp(capBevelSize, 0f, tubeRadius), lastSpan * 0.5f) + : 0f; + var frames = new WireRailPathFrame[points.Count]; + for (var pointIndex = 0; pointIndex < points.Count; pointIndex++) { + var tangent = pointIndex == 0 + ? math.normalizesafe(points[1] - points[0], -referenceFrame.Up) + : pointIndex == points.Count - 1 + ? math.normalizesafe(points[^1] - points[^2], -referenceFrame.Up) + : EvaluateInteriorTangent(points, pointIndex, -referenceFrame.Up, + allowPathReversals); + float3 right; + float3 up; + if (pointIndex == 0 && mouthFrame.HasValue) { + // Adopt the attached rail's radial orientation verbatim so this ring's + // vertices coincide with the rail's mouth ring (welded seam). The tube then + // transports naturally from here. + right = mouthFrame.Value.Right; + up = mouthFrame.Value.Up; + frames[pointIndex] = new WireRailPathFrame(points[pointIndex], tangent, + right, up); + continue; + } + if (pointIndex == 0) { + right = Project(referenceFrame.Tangent, tangent); + if (math.lengthsq(right) <= 1e-8f) { + right = Project(referenceFrame.Right, tangent); + } + right = math.normalizesafe(right, referenceFrame.Right); + } else { + right = Transport(frames[pointIndex - 1].Right, + frames[pointIndex - 1].Tangent, tangent); + } + up = math.normalizesafe(math.cross(right, tangent), referenceFrame.Up); + right = math.normalizesafe(math.cross(tangent, up), right); + frames[pointIndex] = new WireRailPathFrame(points[pointIndex], tangent, + right, up); + } + + var firstRing = vertices.Count; + for (var pointIndex = 0; pointIndex < points.Count; pointIndex++) { + var frame = frames[pointIndex]; + var position = frame.Position; + if (pointIndex == 0) { + position += frame.Tangent * startBevel; + } else if (pointIndex == points.Count - 1) { + position -= frame.Tangent * endBevel; + } + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var angle = radialRotation + FullTurn * radialIndex / radialSegments; + var radial = frame.Right * math.cos(angle) + frame.Up * math.sin(angle); + vertices.Add((Vector3)(position + radial * tubeRadius)); + normals?.Add((Vector3)radial); + uvs?.Add(new Vector2(pointIndex / (float)(points.Count - 1), + radialIndex / (float)radialSegments)); + } + } + + for (var pointIndex = 0; pointIndex < points.Count - 1; pointIndex++) { + var spanIndices = secondaryIndices != null + && pointIndex >= secondaryStartSpan && pointIndex < secondaryEndSpan + ? secondaryIndices : indices; + var current = firstRing + pointIndex * radialSegments; + var next = current + radialSegments; + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var radialNext = (radialIndex + 1) % radialSegments; + var a = current + radialIndex; + var b = next + radialIndex; + var c = current + radialNext; + var d = next + radialNext; + spanIndices.Add(a); + spanIndices.Add(b); + spanIndices.Add(d); + spanIndices.Add(a); + spanIndices.Add(d); + spanIndices.Add(c); + } + } + + if (capStart) { + WireRailCapMeshGenerator.Append(frames[0], tubeRadius, startBevel, + radialSegments, true, vertices, normals, uvs, indices); + } + if (capEnd) { + WireRailCapMeshGenerator.Append(frames[^1], tubeRadius, endBevel, + radialSegments, false, vertices, normals, uvs, indices); + } + + static float3 Project(float3 direction, float3 tangent) + => direction - tangent * math.dot(direction, tangent); + + static float3 EvaluateInteriorTangent(IReadOnlyList path, + int pointIndex, float3 fallback, bool allowPathReversals) + { + var incoming = math.normalizesafe(path[pointIndex] - path[pointIndex - 1], + fallback); + var outgoing = math.normalizesafe(path[pointIndex + 1] - path[pointIndex], + incoming); + if (allowPathReversals + && math.dot(incoming, outgoing) <= -math.cos(math.radians(0.5f))) { + return incoming; + } + return math.normalizesafe(incoming + outgoing, incoming); + } + + static float3 Transport(float3 direction, float3 fromTangent, + float3 toTangent) + { + var axis = math.cross(fromTangent, toTangent); + var sinAngle = math.length(axis); + var cosAngle = math.clamp(math.dot(fromTangent, toTangent), -1f, 1f); + var transported = direction; + if (sinAngle > 1e-6f) { + axis /= sinAngle; + transported = math.mul(quaternion.AxisAngle(axis, + math.atan2(sinAngle, cosAngle)), direction); + } + return math.normalizesafe(Project(transported, toTangent), direction); + } + } + + private static List BuildRingAngles(WireRailRingFixture ring, + float startAngle, float sweepAngle, bool closed, int longitudinalSegments) + { + var angles = new List(longitudinalSegments + 3); + var baseRingCount = closed ? longitudinalSegments : longitudinalSegments + 1; + for (var ringIndex = 0; ringIndex < baseRingCount; ringIndex++) { + angles.Add(startAngle + sweepAngle * ringIndex / longitudinalSegments); + } + if (ring.TryGetStraightSection(out var straightStart, out var straightSweep)) { + AddBoundary(straightStart); + AddBoundary(straightStart + straightSweep); + } + angles.Sort(); + for (var angleIndex = angles.Count - 1; angleIndex > 0; angleIndex--) { + if (math.abs(angles[angleIndex] - angles[angleIndex - 1]) < 1e-5f) { + angles.RemoveAt(angleIndex); + } + } + return angles; + + void AddBoundary(float boundary) + { + for (var turn = -2; turn <= 2; turn++) { + var unwrapped = boundary + turn * FullTurn; + var atEnd = math.abs(unwrapped - (startAngle + sweepAngle)) < 1e-5f; + if (unwrapped >= startAngle - 1e-5f + && unwrapped <= startAngle + sweepAngle + 1e-5f + && (!closed || !atEnd)) { + angles.Add(unwrapped); + } + } + } + } + + private static bool IsPathReversal(float3 previous, float3 corner, float3 next) + { + var incoming = math.normalizesafe(corner - previous); + var outgoing = math.normalizesafe(next - corner); + return math.lengthsq(incoming) > 1e-8f && math.lengthsq(outgoing) > 1e-8f + && math.dot(incoming, outgoing) <= -math.cos(math.radians(0.5f)); + } + } + + internal static class WireRailCapMeshGenerator + { + private const float FullTurn = math.PI * 2f; + + public static void Append(WireRailPathFrame frame, float radius, + float bevelSize, int radialSegments, bool start, ICollection vertices, + ICollection normals, ICollection uvs, + ICollection indices) + { + var normal = start ? -frame.Tangent : frame.Tangent; + bevelSize = math.clamp(bevelSize, 0f, radius); + if (bevelSize > 1e-5f) { + AppendCapBevel(frame, normal, radius, bevelSize, radialSegments, start, + vertices, normals, uvs, indices); + radius -= bevelSize; + } + if (radius <= 1e-5f) { + return; + } + AppendFlatCap(frame, normal, radius, radialSegments, start, + vertices, normals, uvs, indices); + } + + private static void AppendCapBevel(WireRailPathFrame frame, float3 normal, + float radius, float bevelSize, int radialSegments, bool start, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices) + { + var bodyCenter = frame.Position - normal * bevelSize; + var capRadius = radius - bevelSize; + var outerRingStart = vertices.Count; + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var angle = FullTurn * radialIndex / radialSegments; + var radial = frame.Right * math.cos(angle) + frame.Up * math.sin(angle); + var bevelNormal = math.normalizesafe(radial + normal, radial); + vertices.Add((Vector3)(bodyCenter + radial * radius)); + normals.Add((Vector3)bevelNormal); + uvs.Add(new Vector2(0f, radialIndex / (float)radialSegments)); + } + if (capRadius <= 1e-5f) { + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var next = (radialIndex + 1) % radialSegments; + var middleAngle = FullTurn * (radialIndex + 0.5f) / radialSegments; + var middleRadial = frame.Right * math.cos(middleAngle) + + frame.Up * math.sin(middleAngle); + var tipIndex = vertices.Count; + vertices.Add((Vector3)frame.Position); + normals.Add((Vector3)math.normalizesafe(middleRadial + normal, normal)); + uvs.Add(new Vector2(1f, (radialIndex + 0.5f) / radialSegments)); + indices.Add(outerRingStart + radialIndex); + indices.Add(start ? outerRingStart + next : tipIndex); + indices.Add(start ? tipIndex : outerRingStart + next); + } + return; + } + var innerRingStart = vertices.Count; + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var angle = FullTurn * radialIndex / radialSegments; + var radial = frame.Right * math.cos(angle) + frame.Up * math.sin(angle); + var bevelNormal = math.normalizesafe(radial + normal, radial); + vertices.Add((Vector3)(frame.Position + radial * capRadius)); + normals.Add((Vector3)bevelNormal); + uvs.Add(new Vector2(1f, radialIndex / (float)radialSegments)); + } + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var next = (radialIndex + 1) % radialSegments; + var a = outerRingStart + radialIndex; + var b = innerRingStart + radialIndex; + var c = outerRingStart + next; + var d = innerRingStart + next; + if (start) { + indices.Add(a); + indices.Add(d); + indices.Add(b); + indices.Add(a); + indices.Add(c); + indices.Add(d); + } else { + indices.Add(a); + indices.Add(b); + indices.Add(d); + indices.Add(a); + indices.Add(d); + indices.Add(c); + } + } + } + + private static void AppendFlatCap(WireRailPathFrame frame, float3 normal, + float radius, int radialSegments, bool start, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices) + { + var centerIndex = vertices.Count; + vertices.Add((Vector3)frame.Position); + normals.Add((Vector3)normal); + uvs.Add(new Vector2(0.5f, 0.5f)); + var ringStart = vertices.Count; + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var angle = FullTurn * radialIndex / radialSegments; + var radial = frame.Right * math.cos(angle) + frame.Up * math.sin(angle); + vertices.Add((Vector3)(frame.Position + radial * radius)); + normals.Add((Vector3)normal); + uvs.Add(new Vector2(math.cos(angle) * 0.5f + 0.5f, + math.sin(angle) * 0.5f + 0.5f)); + } + for (var radialIndex = 0; radialIndex < radialSegments; radialIndex++) { + var next = (radialIndex + 1) % radialSegments; + indices.Add(centerIndex); + indices.Add(ringStart + (start ? radialIndex : next)); + indices.Add(ringStart + (start ? next : radialIndex)); + } + } + } + + internal readonly struct WireRailProfileSpan + { + public readonly int StartVertex; + public readonly int EndVertex; + + public WireRailProfileSpan(int startVertex, int endVertex) + { + StartVertex = startVertex; + EndVertex = endVertex; + } + } + + internal readonly struct WireRailTopOpening + { + public readonly int FirstRailIndex; + public readonly int SecondRailIndex; + + public bool IsValid => FirstRailIndex >= 0 && SecondRailIndex >= 0 + && FirstRailIndex != SecondRailIndex; + + public WireRailTopOpening(int firstRailIndex, int secondRailIndex) + { + FirstRailIndex = firstRailIndex; + SecondRailIndex = secondRailIndex; + } + } + + internal sealed class WireRailChannelProfile + { + private const int MaximumFacetCount = 8; + private const float FullTurn = math.PI * 2f; + + public readonly List Vertices = new(); + public readonly List Spans = new(); + public float2 RestingBallCenter { get; private set; } + public bool IsClosed { get; private set; } + public WireRailTopOpening TopOpening { get; private set; } + + public static bool TryCreate(IReadOnlyList offsets, float wireRadius, + float ballRadius, out WireRailChannelProfile profile, out string error) + { + var radii = offsets == null + ? Array.Empty() + : Enumerable.Repeat(wireRadius, offsets.Count).ToArray(); + return TryCreate(offsets, radii, ballRadius, out profile, out error); + } + + public static bool TryCreate(IReadOnlyList offsets, + IReadOnlyList wireRadii, float ballRadius, + out WireRailChannelProfile profile, out string error) + => TryCreateCore(offsets, wireRadii, ballRadius, ballRadius * 2f, null, null, false, + out profile, out _, out error); + + internal static bool TryCreate(IReadOnlyList offsets, + IReadOnlyList wireRadii, float ballRadius, Vector2 ballCenterHint, + out WireRailChannelProfile profile, out string error) + => TryCreateCore(offsets, wireRadii, ballRadius, ballRadius * 2f, + (float2)ballCenterHint, + null, false, out profile, out _, out error); + + internal static bool TryCreate(IReadOnlyList offsets, + IReadOnlyList wireRadii, float ballRadius, Vector2 ballCenterHint, + WireRailTopOpening? forcedTopOpening, bool forceClosed, + out WireRailChannelProfile profile, out bool forcedOpeningUnavailable, + out string error) + => TryCreate(offsets, wireRadii, ballRadius, ballRadius * 2f, ballCenterHint, + forcedTopOpening, forceClosed, out profile, out forcedOpeningUnavailable, + out error); + + internal static bool TryCreate(IReadOnlyList offsets, + IReadOnlyList wireRadii, float ballRadius, float topOpeningDiameter, + Vector2 ballCenterHint, + WireRailTopOpening? forcedTopOpening, bool forceClosed, + out WireRailChannelProfile profile, out bool forcedOpeningUnavailable, + out string error) + => TryCreateCore(offsets, wireRadii, ballRadius, topOpeningDiameter, + (float2)ballCenterHint, + forcedTopOpening, forceClosed, out profile, + out forcedOpeningUnavailable, out error); + + private static bool TryCreateCore(IReadOnlyList offsets, + IReadOnlyList wireRadii, float ballRadius, float topOpeningDiameter, + float2? ballCenterHint, + WireRailTopOpening? forcedTopOpening, bool forceClosed, + out WireRailChannelProfile profile, out bool forcedOpeningUnavailable, + out string error) + { + profile = null; + forcedOpeningUnavailable = false; + error = null; + if (offsets == null || offsets.Count == 0) { + error = "A collision channel needs at least one rail."; + return false; + } + if (wireRadii == null || wireRadii.Count != offsets.Count) { + error = "Every collision rail needs a matching wire radius."; + return false; + } + if (wireRadii.Any(radius => radius <= 0f) || ballRadius <= 0f + || topOpeningDiameter <= 0f) { + error = "Wire and reference-ball radii must be positive."; + return false; + } + + if (!TryGetRestingBallCenter(offsets, wireRadii, ballRadius, ballCenterHint, + out var ballCenter, out error)) { + return false; + } + var allSupportLines = new List(offsets.Count); + for (var railIndex = 0; railIndex < offsets.Count; railIndex++) { + var offset = (float2)offsets[railIndex]; + var normal = math.normalizesafe(ballCenter - offset); + if (math.lengthsq(normal) < 0.5f) { + error = "A rail lies on the reference ball center and has no contact direction."; + return false; + } + allSupportLines.Add(new FacetLine(offset + normal * wireRadii[railIndex], normal, + NormalizeAngle(math.atan2(normal.y, normal.x)), offset, + wireRadii[railIndex], railIndex)); + } + allSupportLines.Sort((first, second) => first.Angle.CompareTo(second.Angle)); + + var topOpeningInwardNormal = NormalizeAngle(-math.PI * 0.5f); + var topOpeningGapIndex = -1; + var hasPassableTopOpening = offsets.Count >= 5 + && TryGetPassableTopOpening(allSupportLines, topOpeningInwardNormal, + topOpeningDiameter, out topOpeningGapIndex); + var topOpening = hasPassableTopOpening + ? GetOpening(allSupportLines, topOpeningGapIndex) + : default; + if (forcedTopOpening.HasValue && !forceClosed) { + if (!forcedTopOpening.Value.IsValid + || FindOpeningGapIndex(allSupportLines, forcedTopOpening.Value) < 0) { + forcedOpeningUnavailable = true; + error = null; + return false; + } + topOpening = forcedTopOpening.Value; + } + var openAtTop = !forceClosed && offsets.Count >= 5 + && (forcedTopOpening.HasValue || hasPassableTopOpening); + var closed = offsets.Count >= 5 && !openAtTop; + var collisionIndices = SelectCollisionIndices(offsets.Count, + openAtTop ? topOpening : null); + var supportLines = new List(collisionIndices.Count); + var supportCenter = float2.zero; + foreach (var line in allSupportLines) { + if (!collisionIndices.Contains(line.RailIndex)) { + continue; + } + supportLines.Add(line); + supportCenter += line.RailCenter; + } + supportCenter /= supportLines.Count; + var openingDirection = math.normalizesafe(ballCenter - supportCenter, + new float2(0f, 1f)); + var ordered = closed + ? supportLines + : openAtTop + ? OrderAroundOpening(supportLines, + FindOpeningGapIndex(supportLines, topOpening)) + : OrderAroundOpening(supportLines, NormalizeAngle(math.atan2( + -openingDirection.y, -openingDirection.x))); + var lines = openAtTop + ? ordered + : AddChamfers(ordered, ballCenter, ballRadius, closed); + if (!TryBuildProfile(lines, ballCenter, ballRadius, closed, openAtTop, + out profile, out error)) { + return false; + } + profile.TopOpening = topOpening; + return true; + } + + private static List SelectCollisionIndices(int railCount, + WireRailTopOpening? topOpening) + { + if (railCount <= MaximumFacetCount) { + return Enumerable.Range(0, railCount).ToList(); + } + var selected = Enumerable.Range(0, 4).ToList(); + if (topOpening.HasValue) { + AddTopOpeningRail(topOpening.Value.FirstRailIndex); + AddTopOpeningRail(topOpening.Value.SecondRailIndex); + } + var availableTopRails = Enumerable.Range(4, railCount - 4) + .Where(index => !selected.Contains(index)).ToList(); + var remaining = MaximumFacetCount - selected.Count; + for (var i = 0; i < remaining; i++) { + var availableIndex = remaining == 1 + ? availableTopRails.Count / 2 + : (int)math.round(i * (availableTopRails.Count - 1f) + / (remaining - 1f)); + selected.Add(availableTopRails[availableIndex]); + } + selected.Sort(); + return selected; + + void AddTopOpeningRail(int railIndex) + { + if (railIndex >= 4 && !selected.Contains(railIndex)) { + selected.Add(railIndex); + } + } + } + + private static bool TryGetRestingBallCenter(IReadOnlyList offsets, + IReadOnlyList wireRadii, float ballRadius, float2? ballCenterHint, + out float2 center, out string error) + { + error = null; + if (offsets.Count == 1) { + var offset = (float2)offsets[0]; + var direction = new float2(0f, + ballCenterHint.HasValue && ballCenterHint.Value.y < offset.y ? -1f : 1f); + center = offset + direction * (wireRadii[0] + ballRadius); + return true; + } + + var first = (float2)offsets[0]; + var second = (float2)offsets[1]; + var delta = second - first; + var separation = math.length(delta); + if (separation <= 1e-5f) { + center = default; + error = "The two support rails have coincident centers."; + return false; + } + var firstRadius = wireRadii[0] + ballRadius; + var secondRadius = wireRadii[1] + ballRadius; + if (separation > firstRadius + secondRadius + || separation < math.abs(firstRadius - secondRadius)) { + center = default; + error = "The reference ball cannot contact both support rails."; + return false; + } + var distanceAlong = (firstRadius * firstRadius - secondRadius * secondRadius + + separation * separation) / (2f * separation); + var midpoint = first + delta * (distanceAlong / separation); + var perpendicular = math.normalize(new float2(-delta.y, delta.x)); + var height = math.sqrt(math.max(0f, + firstRadius * firstRadius - distanceAlong * distanceAlong)); + var firstCandidate = midpoint + perpendicular * height; + var secondCandidate = midpoint - perpendicular * height; + if (ballCenterHint.HasValue) { + var firstDistance = math.distancesq(firstCandidate, ballCenterHint.Value); + var secondDistance = math.distancesq(secondCandidate, ballCenterHint.Value); + center = math.abs(firstDistance - secondDistance) <= 1e-4f + ? (firstCandidate.y >= secondCandidate.y + ? firstCandidate : secondCandidate) + : (firstDistance < secondDistance ? firstCandidate : secondCandidate); + } else { + center = firstCandidate.y >= secondCandidate.y + ? firstCandidate : secondCandidate; + } + return true; + } + + private static List OrderAroundOpening(IReadOnlyList sorted, + float openingInwardNormal) + => OrderAroundOpening(sorted, FindGapIndex(sorted, openingInwardNormal)); + + private static WireRailTopOpening GetOpening(IReadOnlyList sorted, + int gapIndex) + => new(sorted[gapIndex].RailIndex, + sorted[(gapIndex + 1) % sorted.Count].RailIndex); + + private static int FindOpeningGapIndex(IReadOnlyList sorted, + WireRailTopOpening opening) + { + for (var gapIndex = 0; gapIndex < sorted.Count; gapIndex++) { + if (sorted[gapIndex].RailIndex == opening.FirstRailIndex + && sorted[(gapIndex + 1) % sorted.Count].RailIndex + == opening.SecondRailIndex) { + return gapIndex; + } + } + return -1; + } + + private static List OrderAroundOpening(IReadOnlyList sorted, + int gapIndex) + { + if (sorted.Count <= 1) { + return sorted.ToList(); + } + + var result = new List(sorted.Count); + var previousAngle = float.NegativeInfinity; + for (var ordinal = 1; ordinal <= sorted.Count; ordinal++) { + var source = sorted[(gapIndex + ordinal) % sorted.Count]; + var angle = source.Angle; + while (angle <= previousAngle) { + angle += FullTurn; + } + result.Add(new FacetLine(source.Point, source.Normal, angle, + source.RailCenter, source.RailRadius, source.RailIndex)); + previousAngle = angle; + } + return result; + } + + private static bool TryGetPassableTopOpening(IReadOnlyList sorted, + float topOpeningInwardNormal, float ballDiameter, out int gapIndex) + { + gapIndex = 0; + if (sorted.Count < 2) { + return true; + } + var containingGapIndex = FindGapIndex(sorted, topOpeningInwardNormal); + var containingNextIndex = (containingGapIndex + 1) % sorted.Count; + if (!CoversTopDirection(sorted[containingGapIndex]) + && !CoversTopDirection(sorted[containingNextIndex]) + && GetClearGap(sorted[containingGapIndex], sorted[containingNextIndex]) + > ballDiameter + 1e-5f) { + gapIndex = containingGapIndex; + return true; + } + var selectedFirstRailIndex = int.MaxValue; + var selectedSecondRailIndex = int.MaxValue; + var found = false; + for (var candidateGapIndex = 0; candidateGapIndex < sorted.Count; + candidateGapIndex++) { + var nextIndex = (candidateGapIndex + 1) % sorted.Count; + if (!CoversTopDirection(sorted[candidateGapIndex]) + && !CoversTopDirection(sorted[nextIndex])) { + continue; + } + var clearance = GetClearGap(sorted[candidateGapIndex], sorted[nextIndex]); + if (clearance <= ballDiameter + 1e-5f) { + continue; + } + var firstRailIndex = math.min(sorted[candidateGapIndex].RailIndex, + sorted[nextIndex].RailIndex); + var secondRailIndex = math.max(sorted[candidateGapIndex].RailIndex, + sorted[nextIndex].RailIndex); + if (found && (firstRailIndex > selectedFirstRailIndex + || firstRailIndex == selectedFirstRailIndex + && secondRailIndex >= selectedSecondRailIndex)) { + continue; + } + gapIndex = candidateGapIndex; + selectedFirstRailIndex = firstRailIndex; + selectedSecondRailIndex = secondRailIndex; + found = true; + } + return found; + + bool CoversTopDirection(FacetLine line) + { + var angularRadius = math.asin(math.saturate(line.RailRadius + / (line.RailRadius + ballDiameter * 0.5f))); + return AngularDistance(line.Angle, topOpeningInwardNormal) + <= angularRadius; + } + } + + private static float GetClearGap(FacetLine first, FacetLine second) + => math.distance(first.RailCenter, second.RailCenter) + - first.RailRadius - second.RailRadius; + + private static float AngularDistance(float first, float second) + => math.abs(math.atan2(math.sin(first - second), math.cos(first - second))); + + private static int FindGapIndex(IReadOnlyList sorted, + float targetAngle) + { + var gapIndex = sorted.Count - 1; + for (var i = 0; i < sorted.Count; i++) { + var start = sorted[i].Angle; + var end = sorted[(i + 1) % sorted.Count].Angle; + if (i == sorted.Count - 1) { + end += FullTurn; + } + var target = targetAngle; + if (target < start) { + target += FullTurn; + } + if (target >= start && target <= end) { + gapIndex = i; + break; + } + } + return gapIndex; + } + + private static List AddChamfers(IReadOnlyList supports, + float2 ballCenter, float ballRadius, bool closed) + { + var gapCount = closed ? supports.Count : math.max(0, supports.Count - 1); + var chamferCount = math.min(math.max(0, MaximumFacetCount - supports.Count), + gapCount); + var gaps = new List(gapCount); + for (var i = 0; i < gapCount; i++) { + var next = (i + 1) % supports.Count; + var endAngle = supports[next].Angle; + if (next == 0) { + endAngle += FullTurn; + } + gaps.Add(new Gap(i, endAngle - supports[i].Angle)); + } + var chamferGaps = gaps.OrderByDescending(gap => gap.Angle) + .ThenBy(gap => gap.Index).Take(chamferCount) + .Select(gap => gap.Index).ToHashSet(); + + var result = new List(supports.Count + chamferCount); + for (var i = 0; i < supports.Count; i++) { + result.Add(supports[i]); + if (!chamferGaps.Contains(i)) { + continue; + } + var next = supports[(i + 1) % supports.Count]; + var normal = math.normalizesafe(supports[i].Normal + next.Normal, + supports[i].Normal); + var angle = (supports[i].Angle + (next.Angle <= supports[i].Angle + ? next.Angle + FullTurn : next.Angle)) * 0.5f; + result.Add(new FacetLine(ballCenter - normal * ballRadius, normal, angle)); + } + return result; + } + + private static bool TryBuildProfile(IReadOnlyList lines, + float2 ballCenter, float ballRadius, bool closed, bool connectOpenRails, + out WireRailChannelProfile profile, out string error) + { + profile = new WireRailChannelProfile { + RestingBallCenter = ballCenter, + IsClosed = closed, + }; + error = null; + if (lines.Count == 1) { + var tangent = Perpendicular(lines[0].Normal); + var extension = math.max(5f, ballRadius * 0.2f); + profile.Vertices.Add(lines[0].Point - tangent * extension); + profile.Vertices.Add(lines[0].Point + tangent * extension); + profile.Spans.Add(new WireRailProfileSpan(0, 1)); + return true; + } + + if (closed) { + for (var i = 0; i < lines.Count; i++) { + if (!TryIntersect(lines[(i - 1 + lines.Count) % lines.Count], lines[i], + out var vertex)) { + error = "The generated collision profile contains parallel neighboring facets."; + profile = null; + return false; + } + profile.Vertices.Add(vertex); + } + for (var i = 0; i < lines.Count; i++) { + profile.Spans.Add(new WireRailProfileSpan(i, (i + 1) % lines.Count)); + } + return true; + } + + if (connectOpenRails) { + foreach (var line in lines) { + profile.Vertices.Add(line.Point); + } + for (var i = 0; i < lines.Count - 1; i++) { + profile.Spans.Add(new WireRailProfileSpan(i, i + 1)); + } + return true; + } + + var intersections = new List(lines.Count - 1); + for (var i = 1; i < lines.Count; i++) { + if (!TryIntersect(lines[i - 1], lines[i], out var intersection)) { + error = "The generated collision profile contains parallel neighboring facets."; + profile = null; + return false; + } + intersections.Add(intersection); + } + var rimExtension = math.max(5f, ballRadius * 0.2f); + var firstDirection = math.normalizesafe(intersections[0] - lines[0].Point, + Perpendicular(lines[0].Normal)); + profile.Vertices.Add(lines[0].Point - firstDirection * rimExtension); + profile.Vertices.AddRange(intersections); + var lastDirection = math.normalizesafe(lines[^1].Point - intersections[^1], + Perpendicular(lines[^1].Normal)); + profile.Vertices.Add(lines[^1].Point + lastDirection * rimExtension); + for (var i = 0; i < lines.Count; i++) { + profile.Spans.Add(new WireRailProfileSpan(i, i + 1)); + } + return true; + } + + private static bool TryIntersect(FacetLine first, FacetLine second, + out float2 intersection) + { + var determinant = Cross(first.Normal, second.Normal); + if (math.abs(determinant) < 1e-5f) { + intersection = default; + return false; + } + var firstDistance = math.dot(first.Normal, first.Point); + var secondDistance = math.dot(second.Normal, second.Point); + intersection = new float2( + (firstDistance * second.Normal.y - first.Normal.y * secondDistance) / determinant, + (first.Normal.x * secondDistance - firstDistance * second.Normal.x) / determinant); + return math.all(math.isfinite(intersection)); + } + + private static float NormalizeAngle(float angle) + { + while (angle < 0f) { + angle += FullTurn; + } + return angle; + } + + private static float2 Perpendicular(float2 value) => new(-value.y, value.x); + private static float Cross(float2 first, float2 second) + => first.x * second.y - first.y * second.x; + + private readonly struct FacetLine + { + public readonly float2 Point; + public readonly float2 Normal; + public readonly float Angle; + public readonly float2 RailCenter; + public readonly float RailRadius; + public readonly int RailIndex; + + public FacetLine(float2 point, float2 normal, float angle, + float2 railCenter = default, float railRadius = 0f, + int railIndex = -1) + { + Point = point; + Normal = normal; + Angle = angle; + RailCenter = railCenter; + RailRadius = railRadius; + RailIndex = railIndex; + } + } + + private readonly struct Gap + { + public readonly int Index; + public readonly float Angle; + + public Gap(int index, float angle) + { + Index = index; + Angle = angle; + } + } + } + + internal static class WireRailColliderMeshGenerator + { + private const int MaximumAdaptiveDepth = 10; + [ThreadStatic] private static ColliderBuffers _threadBuffers; + + private sealed class ColliderBuffers + { + public readonly List Vertices = new(); + public readonly List Indices = new(); + public readonly List TerminalIndices = new(); + public readonly List Edges = new(); + public readonly List ActiveRailIndices = new(6); + public readonly List Samples = new(); + public readonly List BoundaryParameters = new(16); + public readonly float[] StartTrimOffsets = + new float[WireRailEndpointTrimUtility.MaximumRailCount]; + public readonly float[] EndTrimOffsets = + new float[WireRailEndpointTrimUtility.MaximumRailCount]; + public readonly WireRailPathEvaluationContext EvaluationContext = new(); + + public void Clear() + { + Vertices.Clear(); + Indices.Clear(); + TerminalIndices.Clear(); + Edges.Clear(); + ActiveRailIndices.Clear(); + Samples.Clear(); + BoundaryParameters.Clear(); + } + } + + public static bool TryGenerate(Spline spline, IReadOnlyList segments, + IReadOnlyList fixtures, float ballDiameter, + int samplesPerSegment, Mesh target, + out Mesh mesh, out Vector3[] edgeVertices, out int topologyRetryCount, + out string error) + { + topologyRetryCount = 0; + var buffers = _threadBuffers ??= new ColliderBuffers(); + buffers.Clear(); + var vertices = buffers.Vertices; + var indices = buffers.Indices; + var terminalIndices = buffers.TerminalIndices; + var edges = buffers.Edges; + var ballRadius = ballDiameter * 0.5f; + var evaluationContext = buffers.EvaluationContext; + evaluationContext.Reset(spline); + // The Drop's per-rail cutoffs never touch the collider (only its render mesh), but + // its offset shortens the colliders of the two rails it connects to: they end that + // far short of the endpoint, where the vertical elbow faces then take over. The + // outer rails keep their full colliders. + WireRailEndpointTrimUtility.Collect(spline, segments, fixtures, + buffers.StartTrimOffsets, buffers.EndTrimOffsets, includeElbow: false); + // Drops exist only on open splines; skip their collider contributions otherwise. + if (spline != null && !spline.Closed) { + ShortenElbowAttachedRailColliders(segments, fixtures, + evaluationContext.SplineLength, buffers.StartTrimOffsets, + buffers.EndTrimOffsets); + } + + for (var segmentIndex = 0; segmentIndex < segments.Count; segmentIndex++) { + if (!HasActiveRails(segments[segmentIndex])) { + continue; + } + if (!AppendTrimmedSegment(spline, segments, evaluationContext, segmentIndex, + ballRadius, samplesPerSegment, buffers.StartTrimOffsets, + buffers.EndTrimOffsets, buffers.ActiveRailIndices, buffers.Samples, + buffers.BoundaryParameters, vertices, indices, edges, + out var segmentTopologyRetryCount, out error)) { + mesh = target; + edgeVertices = Array.Empty(); + return false; + } + topologyRetryCount += segmentTopologyRetryCount; + } + AppendHairpinColliders(spline, segments, fixtures, buffers); + AppendElbowColliders(spline, segments, fixtures, ballRadius, buffers); + + mesh = target ? target : new Mesh(); + mesh.Clear(false); + mesh.name = "Wire Rail Collider (Generated)"; + mesh.hideFlags = HideFlags.DontSaveInEditor | HideFlags.DontSaveInBuild; + mesh.indexFormat = IndexFormat.UInt32; + mesh.SetVertices(vertices); + mesh.subMeshCount = terminalIndices.Count > 0 ? 2 : 1; + mesh.SetTriangles(indices, 0, false); + if (terminalIndices.Count > 0) { + mesh.SetTriangles(terminalIndices, 1, false); + } + mesh.RecalculateBounds(); + edgeVertices = edges.ToArray(); + error = null; + return true; + } + + private static void AppendHairpinColliders(Spline spline, + IReadOnlyList segments, + IReadOnlyList fixtures, ColliderBuffers buffers) + { + if (fixtures == null) { + return; + } + foreach (var fixture in fixtures) { + if (fixture is not WireRailHairpinFixture hairpin + || WireRailEndpointTrimUtility.HasRailTrimConflict(fixtures, segments, + spline.GetLength(), hairpin.Endpoint, hairpin.FirstRailIndex, + hairpin.SecondRailIndex, hairpin) + || !WireRailFixtureMeshGenerator.TryEvaluateHairpinColliderProfile(spline, + segments, hairpin, out var profile)) { + continue; + } + WireRailFixtureMeshGenerator.AppendPolylineBox(profile.CenterlinePoints, + profile.Frame, hairpin.Diameter, + buffers.Vertices, buffers.Indices, + buffers.TerminalIndices, profile.TerminalStartSpan, + profile.TerminalEndSpan, + false); + } + } + + // Shortens the collider only for the two rails the Drop connects to: a positive offset + // trims those rails at the elbow's attachment so they end that far short of the spline + // end, matching the render. The outer rails keep their full colliders. + private static void ShortenElbowAttachedRailColliders( + IReadOnlyList segments, + IReadOnlyList fixtures, float splineLength, + float[] startOffsets, float[] endOffsets) + { + if (fixtures == null) { + return; + } + foreach (var fixture in fixtures) { + if (fixture is not WireRailElbowFixture elbow + || !WireRailEndpointTrimUtility.IsElbowGeneratable(segments, elbow, + splineLength) + || WireRailEndpointTrimUtility.HasRailTrimConflict(fixtures, segments, + splineLength, elbow.Endpoint, elbow.FirstRailIndex, + elbow.SecondRailIndex, elbow)) { + continue; + } + var trim = math.max(0f, elbow.Offset); + if (trim <= 1e-5f) { + continue; + } + var destination = elbow.Endpoint == WireRailEndpoint.Start + ? startOffsets : endOffsets; + Shorten(elbow.FirstRailIndex); + Shorten(elbow.SecondRailIndex); + + void Shorten(int railIndex) + { + if (railIndex >= 0 && railIndex < destination.Length) { + destination[railIndex] = math.max(destination[railIndex], trim); + } + } + } + } + + // After the channel is scaled to the elbow point, the two floor faces the ball rests on + // are extended straight down there for the elbow length, so the ball rolls to the + // shortened end and then falls once it clears the vertical section. + private static void AppendElbowColliders(Spline spline, + IReadOnlyList segments, + IReadOnlyList fixtures, float ballRadius, + ColliderBuffers buffers) + { + // Drops exist only on open splines. + if (fixtures == null || spline == null || spline.Closed) { + return; + } + var splineLength = buffers.EvaluationContext.SplineLength; + foreach (var fixture in fixtures) { + if (fixture is not WireRailElbowFixture elbow + || !WireRailEndpointTrimUtility.IsElbowGeneratable(segments, elbow, + splineLength) + || WireRailEndpointTrimUtility.HasRailTrimConflict(fixtures, segments, + spline.GetLength(), elbow.Endpoint, elbow.FirstRailIndex, + elbow.SecondRailIndex, elbow) + || !TryGetElbowEndpointProfile(spline, segments, elbow, ballRadius, + buffers, out var frame, out var profile)) { + continue; + } + // The channel floor can have several up-facing spans; the two the ball rests + // on are the pair meeting at the lowest point of the cross-section. Only those + // two get extended down, matching the V the ball rides into the elbow. + var lowestVertex = -1; + var lowestHeight = float.PositiveInfinity; + foreach (var span in profile.Spans) { + if (!IsElbowFloorSpan(profile, span)) { + continue; + } + UpdateLowest(span.StartVertex); + UpdateLowest(span.EndVertex); + } + if (lowestVertex < 0) { + continue; + } + + var down = -frame.Up; + var vertices = buffers.Vertices; + var indices = buffers.Indices; + foreach (var span in profile.Spans) { + if (!IsElbowFloorSpan(profile, span) + || (span.StartVertex != lowestVertex + && span.EndVertex != lowestVertex)) { + continue; + } + var top0 = frame.TransformOffset(profile.Vertices[span.StartVertex]); + var top1 = frame.TransformOffset(profile.Vertices[span.EndVertex]); + var baseIndex = vertices.Count; + vertices.Add((Vector3)top0); + vertices.Add((Vector3)(top0 + down * elbow.DropLength)); + vertices.Add((Vector3)top1); + vertices.Add((Vector3)(top1 + down * elbow.DropLength)); + AppendTwoSidedQuad(baseIndex, baseIndex + 1, baseIndex + 2, + baseIndex + 3, indices); + } + + void UpdateLowest(int vertexIndex) + { + if (profile.Vertices[vertexIndex].y < lowestHeight) { + lowestHeight = profile.Vertices[vertexIndex].y; + lowestVertex = vertexIndex; + } + } + } + } + + private static bool TryGetElbowEndpointProfile(Spline spline, + IReadOnlyList segments, WireRailElbowFixture elbow, + float ballRadius, ColliderBuffers buffers, + out WireRailPathFrame frame, + out WireRailChannelProfile profile) + { + frame = default; + profile = null; + var splineLength = buffers.EvaluationContext.SplineLength; + // The elbow point moves back into the rails by a positive offset (the channel is + // scaled to end there), so the faces sit on the shortened channel's floor. + var railTrim = math.max(0f, elbow.Offset); + var dropPointDistance = math.clamp(elbow.Endpoint == WireRailEndpoint.Start + ? railTrim : splineLength - railTrim, 0f, splineLength); + var topOpeningDiameter = ballRadius * 2f; + var segmentIndex = WireRailSplineGeometry.GetLayoutIndexAtDistance(segments, + dropPointDistance, splineLength); + if (segmentIndex < 0) { + return false; + } + var segment = segments[segmentIndex]; + var segmentStart = segment.Distance; + var segmentEnd = segmentIndex + 1 < segments.Count + ? segments[segmentIndex + 1].Distance : splineLength; + var curveT = segmentEnd > segmentStart + ? math.saturate((dropPointDistance - segmentStart) / (segmentEnd - segmentStart)) + : 0f; + if (!WireRailSplineGeometry.TryEvaluateLayout(spline, segments, segmentIndex, + curveT, out frame)) { + return false; + } + // Build the profile from the rails actually present at the elbow point: a rail is + // gone here if its own endpoint trim (including this elbow's offset on the attached + // rails, or another Rail Trim) removed it before this distance. Otherwise the faces + // would use a different cross-section than the rendered elbow and normal channel. + buffers.ActiveRailIndices.Clear(); + for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) { + if (segment.IsRailActive(railIndex) + && dropPointDistance >= buffers.StartTrimOffsets[railIndex] - 1e-4f + && dropPointDistance + <= splineLength - buffers.EndTrimOffsets[railIndex] + 1e-4f) { + buffers.ActiveRailIndices.Add(railIndex); + } + } + if (buffers.ActiveRailIndices.Count == 0 + || !TryCreateProfile(spline, segments, segmentIndex, curveT, + buffers.ActiveRailIndices, ballRadius, topOpeningDiameter, null, false, + out profile, out _, out _) + || profile.Spans.Count == 0) { + profile = null; + return false; + } + return true; + } + + // A floor span is one whose surface faces up toward the resting ball; those are the + // faces the ball actually sits on, so they are the ones the elbow extends downward. + private static bool IsElbowFloorSpan(WireRailChannelProfile profile, + WireRailProfileSpan span) + { + var start = profile.Vertices[span.StartVertex]; + var end = profile.Vertices[span.EndVertex]; + var edge = end - start; + if (math.lengthsq(edge) <= 1e-8f) { + return false; + } + var inwardNormal = math.normalizesafe(new float2(-edge.y, edge.x)); + var midpoint = (start + end) * 0.5f; + if (math.dot(inwardNormal, profile.RestingBallCenter - midpoint) < 0f) { + inwardNormal = -inwardNormal; + } + return inwardNormal.y > 1e-4f; + } + + private static bool TryCreateProfile(Spline spline, + IReadOnlyList segments, int segmentIndex, float curveT, + IReadOnlyList activeRailIndices, float ballRadius, float topOpeningDiameter, + WireRailTopOpening? forcedTopOpening, bool forceClosed, + out WireRailChannelProfile profile, out bool forcedOpeningUnavailable, + out string error) + { + var offsets = new Vector2[activeRailIndices.Count]; + var wireRadii = new float[activeRailIndices.Count]; + var allOffsets = new Vector2[segments[segmentIndex].RailCount]; + var allWireRadii = new float[segments[segmentIndex].RailCount]; + var envelopeMinimum = new float2(float.PositiveInfinity); + var envelopeMaximum = new float2(float.NegativeInfinity); + for (var railIndex = 0; railIndex < segments[segmentIndex].RailCount; + railIndex++) { + var offset = WireRailSplineGeometry.EvaluateRailOffset(spline, segments, + segmentIndex, railIndex, curveT); + var radius = WireRailSplineGeometry.EvaluateWireDiameter(spline, segments, + segmentIndex, railIndex, curveT) * 0.5f; + allOffsets[railIndex] = (Vector2)offset; + allWireRadii[railIndex] = radius; + envelopeMinimum = math.min(envelopeMinimum, offset - radius); + envelopeMaximum = math.max(envelopeMaximum, offset + radius); + } + for (var activeRailIndex = 0; activeRailIndex < activeRailIndices.Count; + activeRailIndex++) { + var railIndex = activeRailIndices[activeRailIndex]; + offsets[activeRailIndex] = allOffsets[railIndex]; + wireRadii[activeRailIndex] = allWireRadii[railIndex]; + } + var ballCenterHint = (Vector2)((envelopeMinimum + envelopeMaximum) * 0.5f); + return WireRailChannelProfile.TryCreate(offsets, wireRadii, ballRadius, + topOpeningDiameter, + ballCenterHint, forcedTopOpening, forceClosed, + out profile, out forcedOpeningUnavailable, out error); + } + + private static bool HasMatchingTopology(WireRailChannelProfile first, + WireRailChannelProfile second) + { + if (first.IsClosed != second.IsClosed + || first.Vertices.Count != second.Vertices.Count + || first.Spans.Count != second.Spans.Count) { + return false; + } + for (var spanIndex = 0; spanIndex < first.Spans.Count; spanIndex++) { + var firstSpan = first.Spans[spanIndex]; + var secondSpan = second.Spans[spanIndex]; + if (firstSpan.StartVertex != secondSpan.StartVertex + || firstSpan.EndVertex != secondSpan.EndVertex) { + return false; + } + } + return true; + } + + private static bool AppendTrimmedSegment(Spline spline, + IReadOnlyList segments, + WireRailPathEvaluationContext evaluationContext, int segmentIndex, float ballRadius, + int curvatureDetail, + IReadOnlyList startTrimOffsets, + IReadOnlyList endTrimOffsets, List activeRailIndices, + List samples, List boundaryParameters, + List vertices, List indices, List edges, + out int topologyRetryCount, out string error) + { + topologyRetryCount = 0; + error = null; + var splineLength = evaluationContext.SplineLength; + WireRailEndpointTrimUtility.GetSegmentDistances(segments, segmentIndex, + splineLength, out var segmentStart, out var segmentEnd); + var segmentLength = segmentEnd - segmentStart; + if (segmentLength <= 1e-5f) { + return true; + } + var segment = segments[segmentIndex]; + boundaryParameters.Clear(); + boundaryParameters.Add(0f); + boundaryParameters.Add(1f); + for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) { + if (!segment.IsRailActive(railIndex)) { + continue; + } + AddBoundary(startTrimOffsets[railIndex]); + AddBoundary(splineLength - endTrimOffsets[railIndex]); + } + boundaryParameters.Sort(); + for (var boundaryIndex = boundaryParameters.Count - 1; boundaryIndex > 0; + boundaryIndex--) { + if (boundaryParameters[boundaryIndex] + - boundaryParameters[boundaryIndex - 1] <= 1e-5f) { + boundaryParameters.RemoveAt(boundaryIndex); + } + } + + for (var intervalIndex = 0; intervalIndex < boundaryParameters.Count - 1; + intervalIndex++) { + var startT = boundaryParameters[intervalIndex]; + var endT = boundaryParameters[intervalIndex + 1]; + if (endT - startT <= 1e-5f) { + continue; + } + var midpointDistance = math.lerp(segmentStart, segmentEnd, + (startT + endT) * 0.5f); + activeRailIndices.Clear(); + var authoredActiveRailCount = 0; + for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) { + if (!segment.IsRailActive(railIndex)) { + continue; + } + authoredActiveRailCount++; + if (midpointDistance + 1e-5f >= startTrimOffsets[railIndex] + && midpointDistance - 1e-5f + <= splineLength - endTrimOffsets[railIndex]) { + activeRailIndices.Add(railIndex); + } + } + if (activeRailIndices.Count == 0) { + continue; + } + var trimmedRailSet = activeRailIndices.Count < authoredActiveRailCount; + var midpointT = (startT + endT) * 0.5f; + if (trimmedRailSet && !TryCreateProfile(spline, segments, segmentIndex, + midpointT, activeRailIndices, ballRadius, ballRadius * 2f, null, false, + out _, out _, out _)) { + continue; + } + if (!AppendSegmentRange(spline, segments, evaluationContext, segmentIndex, + startT, endT, ballRadius, curvatureDetail, activeRailIndices, + samples, vertices, indices, edges, out var topologyRetried, out error)) { + return false; + } + if (topologyRetried) { + topologyRetryCount++; + } + } + return true; + + void AddBoundary(float distance) + { + if (distance <= segmentStart + 1e-5f || distance >= segmentEnd - 1e-5f) { + return; + } + boundaryParameters.Add(math.saturate( + (distance - segmentStart) / segmentLength)); + } + } + + private static bool AppendSegmentRange(Spline spline, + IReadOnlyList segments, + WireRailPathEvaluationContext evaluationContext, int segmentIndex, + float startT, float endT, float ballRadius, + int curvatureDetail, List activeRailIndices, List samples, + List vertices, List indices, List edges, + out bool topologyRetried, out string error) + { + if (!TryBuildAdaptiveSamples(spline, segments, evaluationContext, segmentIndex, + startT, endT, activeRailIndices, ballRadius, curvatureDetail, samples, + out topologyRetried, out error)) { + return false; + } + var firstRow = vertices.Count; + foreach (var sample in samples) { + foreach (var offset in sample.Profile.Vertices) { + vertices.Add((Vector3)sample.Frame.TransformOffset(offset)); + } + } + + var referenceProfile = samples[0].Profile; + var rowSize = referenceProfile.Vertices.Count; + for (var sampleIndex = 0; sampleIndex < samples.Count - 1; sampleIndex++) { + var currentRow = firstRow + sampleIndex * rowSize; + var nextRow = currentRow + rowSize; + foreach (var span in referenceProfile.Spans) { + AppendTwoSidedQuad(currentRow + span.StartVertex, + nextRow + span.StartVertex, currentRow + span.EndVertex, + nextRow + span.EndVertex, indices); + } + for (var profileVertex = 0; profileVertex < rowSize; profileVertex++) { + edges.Add(GetVertex(vertices, currentRow + profileVertex)); + edges.Add(GetVertex(vertices, nextRow + profileVertex)); + } + } + error = null; + return true; + } + + private static bool TryBuildAdaptiveSamples(Spline spline, + IReadOnlyList segments, + WireRailPathEvaluationContext evaluationContext, int segmentIndex, + float startT, float endT, IReadOnlyList activeRailIndices, + float ballRadius, int curvatureDetail, + List samples, out bool topologyRetried, out string error) + { + topologyRetried = false; + samples.Clear(); + var evaluationError = default(string); + WireRailTopOpening? forcedTopOpening = null; + WireRailTopOpening? detectedTopOpening = null; + var forceClosed = false; + var openingPairMismatch = false; + var forcedOpeningUnavailable = false; + WireRailEndpointTrimUtility.GetSegmentDistances(segments, segmentIndex, + evaluationContext.SplineLength, out var segmentStart, out var segmentEnd); + if (!TryEvaluateSample(startT, out var start) + || !TryEvaluateSample(math.lerp(startT, endT, 0.25f), + out var probeFirstQuarter) + || !TryEvaluateSample(math.lerp(startT, endT, 0.5f), + out var probeMiddle) + || !TryEvaluateSample(math.lerp(startT, endT, 0.75f), + out var probeThirdQuarter) + || !TryEvaluateSample(endT, out var end)) { + error = evaluationError; + return false; + } + RememberTopOpening(start.Profile); + RememberTopOpening(probeFirstQuarter.Profile); + RememberTopOpening(probeMiddle.Profile); + RememberTopOpening(probeThirdQuarter.Profile); + RememberTopOpening(end.Profile); + forcedTopOpening = detectedTopOpening; + if (forcedTopOpening.HasValue + && (!TryPrepareSample(ref start) + || !TryPrepareSample(ref probeFirstQuarter) + || !TryPrepareSample(ref probeMiddle) + || !TryPrepareSample(ref probeThirdQuarter) + || !TryPrepareSample(ref end))) { + if (forcedOpeningUnavailable) { + topologyRetried = true; + return TryBuildClosed(out error); + } + error = evaluationError; + return false; + } + if (openingPairMismatch) { + error = OpeningMigrationError(); + return false; + } + if (!HasMatchingTopology(start.Profile, end.Profile)) { + error = TopologyError(); + return false; + } + samples.Add(start); + var topologyMismatch = false; + if (TryAppendInterval(start, end, probeFirstQuarter, probeMiddle, + probeThirdQuarter, 0, out error)) { + return true; + } + if (forcedOpeningUnavailable) { + topologyRetried = true; + return TryBuildClosed(out error); + } + if (forcedTopOpening.HasValue || forceClosed || !topologyMismatch + || !detectedTopOpening.HasValue) { + return false; + } + + // An authored transition curve can create a narrow opening between the fixed + // preflight probes. Once adaptive sampling discovers it, rebuild this span with + // a consistently open profile instead of dropping the complete collider. + forcedTopOpening = detectedTopOpening; + topologyRetried = true; + topologyMismatch = false; + samples.Clear(); + forcedOpeningUnavailable = false; + if (!TryPrepareSample(ref start) + || !TryPrepareSample(ref probeFirstQuarter) + || !TryPrepareSample(ref probeMiddle) + || !TryPrepareSample(ref probeThirdQuarter) + || !TryPrepareSample(ref end)) { + if (forcedOpeningUnavailable) { + return TryBuildClosed(out error); + } + error = evaluationError; + return false; + } + samples.Add(start); + var retried = TryAppendInterval(start, end, probeFirstQuarter, probeMiddle, + probeThirdQuarter, 0, out error); + return !retried && forcedOpeningUnavailable + ? TryBuildClosed(out error) + : retried; + + bool TryAppendInterval(ColliderSample intervalStart, ColliderSample intervalEnd, + ColliderSample? knownFirstQuarter, ColliderSample? knownMiddle, + ColliderSample? knownThirdQuarter, int depth, out string intervalError) + { + var interval = intervalEnd.CurveT - intervalStart.CurveT; + var firstQuarter = knownFirstQuarter.GetValueOrDefault(); + if (!knownFirstQuarter.HasValue + && !TryEvaluateSample(intervalStart.CurveT + interval * 0.25f, + out firstQuarter)) { + intervalError = evaluationError; + return false; + } + var middle = knownMiddle.GetValueOrDefault(); + if (!knownMiddle.HasValue + && !TryEvaluateSample(intervalStart.CurveT + interval * 0.5f, + out middle)) { + intervalError = evaluationError; + return false; + } + var thirdQuarter = knownThirdQuarter.GetValueOrDefault(); + if (!knownThirdQuarter.HasValue + && !TryEvaluateSample(intervalStart.CurveT + interval * 0.75f, + out thirdQuarter)) { + intervalError = evaluationError; + return false; + } + if (!HasMatchingTopology(start.Profile, firstQuarter.Profile) + || !HasMatchingTopology(start.Profile, middle.Profile) + || !HasMatchingTopology(start.Profile, thirdQuarter.Profile)) { + RememberTopOpening(firstQuarter.Profile); + RememberTopOpening(middle.Profile); + RememberTopOpening(thirdQuarter.Profile); + if (openingPairMismatch) { + intervalError = OpeningMigrationError(); + return false; + } + topologyMismatch = true; + intervalError = TopologyError(); + return false; + } + if (depth < MaximumAdaptiveDepth + && ShouldSubdivide(intervalStart, firstQuarter, middle, thirdQuarter, + intervalEnd, curvatureDetail)) { + if (!TryAppendInterval(intervalStart, middle, null, firstQuarter, null, + depth + 1, out intervalError)) { + return false; + } + return TryAppendInterval(middle, intervalEnd, null, thirdQuarter, null, + depth + 1, out intervalError); + } + samples.Add(intervalEnd); + intervalError = null; + return true; + } + + bool TryEvaluateSample(float curveT, out ColliderSample sample) + { + sample = default; + if (!WireRailSplineGeometry.TryEvaluateLayout(spline, segments, evaluationContext, + segmentIndex, curveT, out var frame)) { + evaluationError = $"Could not evaluate spline segment {segmentIndex + 1}."; + return false; + } + if (!TryCreateProfile(spline, segments, segmentIndex, curveT, + activeRailIndices, ballRadius, ballRadius * 2f, + forcedTopOpening, forceClosed, + out var profile, out var openingUnavailable, + out evaluationError)) { + forcedOpeningUnavailable |= openingUnavailable; + return false; + } + sample = new ColliderSample(curveT, frame, profile); + return true; + } + + bool TryBuildClosed(out string closedError) + { + forcedTopOpening = null; + detectedTopOpening = null; + forceClosed = true; + openingPairMismatch = false; + forcedOpeningUnavailable = false; + topologyMismatch = false; + samples.Clear(); + if (!TryEvaluateSample(startT, out start) + || !TryEvaluateSample(math.lerp(startT, endT, 0.25f), + out probeFirstQuarter) + || !TryEvaluateSample(math.lerp(startT, endT, 0.5f), + out probeMiddle) + || !TryEvaluateSample(math.lerp(startT, endT, 0.75f), + out probeThirdQuarter) + || !TryEvaluateSample(endT, out end)) { + closedError = evaluationError; + return false; + } + if (!HasMatchingTopology(start.Profile, end.Profile)) { + closedError = TopologyError(); + return false; + } + samples.Add(start); + return TryAppendInterval(start, end, probeFirstQuarter, probeMiddle, + probeThirdQuarter, 0, out closedError); + } + + bool TryPrepareSample(ref ColliderSample sample) + { + if (!forceClosed && !sample.Profile.IsClosed + && SameOpening(sample.Profile.TopOpening, + forcedTopOpening.GetValueOrDefault())) { + return true; + } + return TryEvaluateSample(sample.CurveT, out sample); + } + + void RememberTopOpening(WireRailChannelProfile profile) + { + if (profile.IsClosed || !profile.TopOpening.IsValid) { + return; + } + if (!detectedTopOpening.HasValue) { + detectedTopOpening = profile.TopOpening; + } else if (!SameOpening(detectedTopOpening.Value, profile.TopOpening)) { + openingPairMismatch = true; + } + } + + bool SameOpening(WireRailTopOpening first, WireRailTopOpening second) + => first.FirstRailIndex == second.FirstRailIndex + && first.SecondRailIndex == second.SecondRailIndex; + + string OpeningMigrationError() + => $"The top opening moves between different rail pairs while blending " + + $"segment {segmentIndex + 1}. Add a wire layout where the opening changes."; + + string TopologyError() + => $"The collider profile changes topology while blending segment " + + $"{segmentIndex + 1}. Adjust the segment connection or rail layout."; + } + + private static bool ShouldSubdivide(ColliderSample start, + ColliderSample firstQuarter, ColliderSample middle, ColliderSample thirdQuarter, + ColliderSample end, int curvatureDetail) + { + curvatureDetail = math.clamp(curvatureDetail, 2, 32); + var maximumAngle = math.radians(45f / curvatureDetail); + if (Angle(start.Frame.Tangent, firstQuarter.Frame.Tangent) > maximumAngle + || Angle(firstQuarter.Frame.Tangent, middle.Frame.Tangent) > maximumAngle + || Angle(middle.Frame.Tangent, thirdQuarter.Frame.Tangent) > maximumAngle + || Angle(thirdQuarter.Frame.Tangent, end.Frame.Tangent) > maximumAngle) { + return true; + } + + var maximumDeviation = 4f / curvatureDetail; + var maximumDeviationSquared = maximumDeviation * maximumDeviation; + return SampleDeviates(firstQuarter, 0.25f) + || SampleDeviates(middle, 0.5f) + || SampleDeviates(thirdQuarter, 0.75f); + + bool SampleDeviates(ColliderSample sample, float interpolation) + { + for (var vertexIndex = 0; vertexIndex < sample.Profile.Vertices.Count; + vertexIndex++) { + var startVertex = start.Frame.TransformOffset( + start.Profile.Vertices[vertexIndex]); + var sampleVertex = sample.Frame.TransformOffset( + sample.Profile.Vertices[vertexIndex]); + var endVertex = end.Frame.TransformOffset(end.Profile.Vertices[vertexIndex]); + if (math.distancesq(sampleVertex, + math.lerp(startVertex, endVertex, interpolation)) + > maximumDeviationSquared) { + return true; + } + } + return false; + } + } + + private static float Angle(float3 first, float3 second) + => math.acos(math.clamp(math.dot(first, second), -1f, 1f)); + + private readonly struct ColliderSample + { + public readonly float CurveT; + public readonly WireRailPathFrame Frame; + public readonly WireRailChannelProfile Profile; + + public ColliderSample(float curveT, WireRailPathFrame frame, + WireRailChannelProfile profile) + { + CurveT = curveT; + Frame = frame; + Profile = profile; + } + } + + private static bool HasActiveRails(WireRailSegment segment) + { + for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) { + if (segment.IsRailActive(railIndex)) { + return true; + } + } + return false; + } + + private static Vector3 GetVertex(List vertices, int index) + => vertices[index]; + + private static void AppendTwoSidedQuad(int a, int b, int c, int d, + List indices) + { + indices.Add(a); + indices.Add(b); + indices.Add(c); + indices.Add(b); + indices.Add(d); + indices.Add(c); + indices.Add(a); + indices.Add(c); + indices.Add(b); + indices.Add(b); + indices.Add(c); + indices.Add(d); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailGeometryGenerator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailGeometryGenerator.cs.meta new file mode 100644 index 000000000..871090252 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailGeometryGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 83bfbb49da784736bd470dad0ac357cf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailPackable.cs new file mode 100644 index 000000000..9d1b2c96c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailPackable.cs @@ -0,0 +1,514 @@ +// 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 UnityEngine; +using UnityEngine.Splines; + +namespace VisualPinball.Unity +{ + /// + /// Package payload of a : every authored value except the + /// route, which travels with the spline child as . + /// + /// Evolving the format: the JSON packer ignores unknown fields and leaves missing ones at + /// their default, so adding a field never breaks an old table by itself. It does read as + /// zero, though, which is rarely the authored default. Bump + /// and gate the new field on the version passed into the restore methods, falling back to + /// the fixture's Default constant. Never change a fixture Kind string; add an alias + /// in if a kind has to be renamed. Golden + /// payloads of every past version live in WireRailPackagingTests. + /// + public struct WireRailPackable + { + public const int CurrentVersion = 1; + + public int Version; + public int RailCount; + public float WireDiameter; + public float WireCapBevelSize; + public int RadialSegments; + public int RenderSamplesPerSegment; + public float ReferenceBallDiameter; + public int ColliderSamplesPerSegment; + public bool ShowColliderPreview; + public bool OverwritePhysics; + public float Elasticity; + public float ElasticityFalloff; + public float Friction; + public float Scatter; + public WireRailLayoutPackable[] Layouts; + public int[] LayoutDisplayOrder; + public WireRailFixturePackable[] Fixtures; + + public static byte[] Pack(WireRailComponent comp) + { + return PackageApi.Packer.Pack(new WireRailPackable { + Version = CurrentVersion, + RailCount = comp.RailCount, + WireDiameter = comp.WireDiameter, + WireCapBevelSize = comp.WireCapBevelSize, + RadialSegments = comp.RadialSegments, + RenderSamplesPerSegment = comp.RenderSamplesPerSegment, + ReferenceBallDiameter = comp.ReferenceBallDiameter, + ColliderSamplesPerSegment = comp.ColliderSamplesPerSegment, + ShowColliderPreview = comp.ShowColliderPreview, + OverwritePhysics = comp.PhysicsOverwrite, + Elasticity = comp.PhysicsElasticity, + ElasticityFalloff = comp.PhysicsElasticityFalloff, + Friction = comp.PhysicsFriction, + Scatter = comp.PhysicsScatter, + Layouts = comp.Segments.Select(WireRailLayoutPackable.From).ToArray(), + LayoutDisplayOrder = comp.LayoutDisplayOrder.ToArray(), + Fixtures = comp.Fixtures.Select(WireRailFixturePackable.From).ToArray(), + }); + } + + public static void Unpack(byte[] bytes, WireRailComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + if (data.Version > CurrentVersion) { + Debug.LogWarning($"Wire rail \"{comp.name}\" was packaged by a newer VPE " + + $"(format {data.Version}, this build reads {CurrentVersion}); loading what is understood."); + } + var layouts = (data.Layouts ?? Array.Empty()) + .Select(layout => layout.ToSegment(data.Version)).ToList(); + var fixtures = (data.Fixtures ?? Array.Empty()) + .Select(fixture => fixture.ToFixture(data.Version)) + .Where(fixture => fixture != null).ToList(); + comp.RestoreFromPackage(data, layouts, + (data.LayoutDisplayOrder ?? Array.Empty()).ToList(), fixtures); + } + } + + /// + /// The physics material assets of a wire rail, resolved through the package's asset table. + /// + public struct WireRailReferencesPackable + { + public int PhysicsMaterialRef; + public int TerminalPhysicsMaterialRef; + + public static byte[] Pack(WireRailComponent comp, PackagedFiles files) + { + return PackageApi.Packer.Pack(new WireRailReferencesPackable { + PhysicsMaterialRef = files.AddAsset(comp.PhysicsMaterialReference), + TerminalPhysicsMaterialRef = files.AddAsset(comp.TerminalPhysicsMaterialReference), + }); + } + + public static void Unpack(byte[] bytes, WireRailComponent comp, PackagedFiles files) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.PhysicsMaterialReference = files.GetAsset(data.PhysicsMaterialRef); + comp.TerminalPhysicsMaterialReference = + files.GetAsset(data.TerminalPhysicsMaterialRef); + } + } + + public struct WireRailLayoutPackable + { + public float Distance; + public int ThirdRailSide; + public PackableFloat2[] RailOffsets; + public bool[] ActiveRails; + public float[] WireDiameters; + public WireRailTransitionPackable[] Transitions; + + public static WireRailLayoutPackable From(WireRailSegment segment) + { + var railCount = segment.RailCount; + return new WireRailLayoutPackable { + Distance = segment.Distance, + ThirdRailSide = (int)segment.ThirdRailSide, + RailOffsets = Enumerable.Range(0, railCount) + .Select(i => { var o = segment.GetRailOffset(i); return new PackableFloat2(o.x, o.y); }) + .ToArray(), + ActiveRails = Enumerable.Range(0, railCount).Select(segment.IsRailActive).ToArray(), + WireDiameters = Enumerable.Range(0, railCount).Select(segment.GetWireDiameter).ToArray(), + Transitions = Enumerable.Range(0, segment.ConnectionToNext.WireCount) + .Select(i => WireRailTransitionPackable.From(segment.ConnectionToNext, i)) + .ToArray(), + }; + } + + /// Payload version, for gating fields added after version 1. + public WireRailSegment ToSegment(int version) + { + var offsets = (RailOffsets ?? Array.Empty()) + .Select(o => new Vector2(o.X, o.Y)).ToList(); + var active = (ActiveRails ?? Array.Empty()).ToList(); + var diameters = (WireDiameters ?? Array.Empty()).ToList(); + var wires = (Transitions ?? Array.Empty()) + .Select(t => t.ToTransition()).ToList(); + var connection = new WireRailConnection(); + connection.Restore(wires); + var segment = new WireRailSegment(); + segment.Restore(Distance, (WireRailThirdRailSide)ThirdRailSide, offsets, active, + diameters, connection); + return segment; + } + } + + public struct WireRailTransitionPackable + { + public bool Overridden; + public bool Continuous; + public AnimationCurve Curve; + + public static WireRailTransitionPackable From(WireRailConnection connection, int wireIndex) + => new() { + Overridden = connection.IsWireOverridden(wireIndex), + Continuous = connection.IsWireContinuous(wireIndex), + Curve = connection.GetWireCurve(wireIndex), + }; + + public WireRailTransition ToTransition() + { + var transition = new WireRailTransition(); + transition.Restore(Overridden, Continuous, + Curve ?? AnimationCurve.Linear(0f, 0f, 1f, 1f)); + return transition; + } + } + + /// + /// One flat record for every fixture kind. says which fields matter; + /// the rest stay at their defaults, which keeps the payload readable and forward-tolerant. + /// + public struct WireRailFixturePackable + { + public const string RingKind = "Ring"; + public const string CradleKind = "Cradle"; + public const string RungKind = "Rung"; + public const string StandKind = "Stand"; + public const string HairpinKind = "Hairpin"; + public const string ElbowKind = "Elbow"; + public const string RailTrimKind = "RailTrim"; + + public string Kind; + // common + public float Distance; + public float SolderThreshold; + public float SolderSize; + public bool Enabled; + public float Diameter; + // ring + public bool HasCutout; + public float CutoutStartAngle; + public float CutoutEndAngle; + public bool HasStraightSection; + public float StraightStartAngle; + public float StraightEndAngle; + public float Scale; + public int RingDensity; + // shared by several kinds + public float LateralOffset; + public float VerticalOffset; + public float Angle; + public float Rotation; + public float LengthAdjustment; + public int Endpoint; + public int FirstRailIndex; + public int SecondRailIndex; + public float[] RailOffsets; + // cradle + public float BottomLength; + public float LeftLength; + public float RightLength; + public float CornerRadius; + // hairpin + public float LoopDiameter; + public float LeadLength; + public float TangentLength; + public float RailOffset; + // elbow + public float Offset; + public float DropLength; + public float ZAngle; + // stand + public int LegSide; + public PackableFloat3 StartDirection; + public float StartLength; + public PackableFloat3 FootPosition; + public PackableFloat3 FootRotation; + public bool FootClockwise; + public float FootWidth; + public float FootLength; + public float FootConnectionLength; + + public static WireRailFixturePackable From(WireRailFixture fixture) + { + var data = new WireRailFixturePackable { + Distance = fixture.Distance, + SolderThreshold = fixture.SolderThreshold, + SolderSize = fixture.SolderSize, + Enabled = fixture.Enabled, + }; + switch (fixture) { + case WireRailRingFixture ring: + data.Kind = RingKind; + data.Diameter = ring.Diameter; + data.HasCutout = ring.HasCutout; + data.CutoutStartAngle = ring.CutoutStartAngle; + data.CutoutEndAngle = ring.CutoutEndAngle; + data.HasStraightSection = ring.HasStraightSection; + data.StraightStartAngle = ring.StraightStartAngle; + data.StraightEndAngle = ring.StraightEndAngle; + data.LateralOffset = ring.LateralOffset; + data.VerticalOffset = ring.VerticalOffset; + data.Scale = ring.Scale; + data.RingDensity = ring.RingDensity; + break; + case WireRailCradleFixture cradle: + data.Kind = CradleKind; + data.Diameter = cradle.Diameter; + data.RingDensity = cradle.RingDensity; + data.LateralOffset = cradle.LateralOffset; + data.VerticalOffset = cradle.VerticalOffset; + data.BottomLength = cradle.BottomLength; + data.LeftLength = cradle.LeftLength; + data.RightLength = cradle.RightLength; + data.Angle = cradle.Angle; + data.Rotation = cradle.Rotation; + data.CornerRadius = cradle.CornerRadius; + break; + case WireRailRungFixture rung: + data.Kind = RungKind; + data.Diameter = rung.Diameter; + data.FirstRailIndex = rung.StartRailIndex; + data.SecondRailIndex = rung.EndRailIndex; + data.Angle = rung.Angle; + data.LateralOffset = rung.LateralOffset; + data.VerticalOffset = rung.VerticalOffset; + data.LengthAdjustment = rung.LengthAdjustment; + break; + case WireRailStandFixture stand: + data.Kind = StandKind; + data.Diameter = stand.Diameter; + data.LegSide = (int)stand.LegSide; + data.LateralOffset = stand.LateralOffset; + data.VerticalOffset = stand.VerticalOffset; + data.LengthAdjustment = stand.LengthAdjustment; + data.StartDirection = ToPackable(stand.StartDirection); + data.StartLength = stand.StartLength; + data.FootPosition = ToPackable(stand.FootPosition); + data.FootRotation = ToPackable(stand.FootRotation); + data.FootClockwise = stand.FootClockwise; + data.FootWidth = stand.FootWidth; + data.FootLength = stand.FootLength; + data.FootConnectionLength = stand.FootConnectionLength; + break; + case WireRailHairpinFixture hairpin: + data.Kind = HairpinKind; + data.Diameter = hairpin.Diameter; + data.Endpoint = (int)hairpin.Endpoint; + data.FirstRailIndex = hairpin.FirstRailIndex; + data.SecondRailIndex = hairpin.SecondRailIndex; + data.LoopDiameter = hairpin.LoopDiameter; + data.LeadLength = hairpin.LeadLength; + data.TangentLength = hairpin.TangentLength; + data.RingDensity = hairpin.RingDensity; + data.RailOffset = hairpin.RailOffset; + data.Rotation = hairpin.Rotation; + break; + case WireRailElbowFixture elbow: + data.Kind = ElbowKind; + data.Diameter = elbow.Diameter; + data.Endpoint = (int)elbow.Endpoint; + data.FirstRailIndex = elbow.FirstRailIndex; + data.SecondRailIndex = elbow.SecondRailIndex; + data.Offset = elbow.Offset; + data.DropLength = elbow.DropLength; + data.ZAngle = elbow.ZAngle; + data.RailOffsets = elbow.RailOffsets.ToArray(); + break; + case WireRailTrimFixture trim: + data.Kind = RailTrimKind; + data.Endpoint = (int)trim.Endpoint; + data.RailOffsets = trim.RailOffsets.ToArray(); + break; + } + return data; + } + + /// Payload version, for gating fields added after version 1. + public WireRailFixture ToFixture(int version) + { + var railOffsets = (RailOffsets ?? Array.Empty()).ToList(); + WireRailFixture fixture; + switch (Kind) { + case RingKind: { + var ring = new WireRailRingFixture(); + ring.Restore(Diameter, HasCutout, CutoutStartAngle, CutoutEndAngle, + HasStraightSection, StraightStartAngle, StraightEndAngle, + LateralOffset, VerticalOffset, Scale, RingDensity); + fixture = ring; + break; + } + case CradleKind: { + var cradle = new WireRailCradleFixture(); + cradle.Restore(Diameter, RingDensity, LateralOffset, VerticalOffset, + BottomLength, LeftLength, RightLength, Angle, Rotation, CornerRadius); + fixture = cradle; + break; + } + case RungKind: { + var rung = new WireRailRungFixture(); + rung.Restore(Diameter, FirstRailIndex, SecondRailIndex, Angle, + LateralOffset, VerticalOffset, LengthAdjustment); + fixture = rung; + break; + } + case StandKind: { + var stand = new WireRailStandFixture(); + stand.Restore(Diameter, (WireRailStandSide)LegSide, LateralOffset, + VerticalOffset, LengthAdjustment, ToVector(StartDirection), StartLength, + ToVector(FootPosition), ToVector(FootRotation), FootClockwise, FootWidth, + FootLength, FootConnectionLength); + fixture = stand; + break; + } + case HairpinKind: { + var hairpin = new WireRailHairpinFixture(); + hairpin.Restore(Diameter, (WireRailEndpoint)Endpoint, FirstRailIndex, + SecondRailIndex, LoopDiameter, LeadLength, TangentLength, RingDensity, + RailOffset, Rotation); + fixture = hairpin; + break; + } + case ElbowKind: { + var elbow = new WireRailElbowFixture(); + elbow.Restore(Diameter, (WireRailEndpoint)Endpoint, FirstRailIndex, + SecondRailIndex, Offset, DropLength, ZAngle, railOffsets); + fixture = elbow; + break; + } + case RailTrimKind: { + var trim = new WireRailTrimFixture(); + trim.Restore((WireRailEndpoint)Endpoint, railOffsets); + fixture = trim; + break; + } + default: + // A kind this build does not know, most likely from a newer VPE. The rest of + // the rail still loads. + Debug.LogWarning($"Unknown wire rail fixture kind \"{Kind}\" in package, skipping."); + return null; + } + fixture.RestoreCommon(Distance, SolderThreshold, SolderSize, Enabled); + return fixture; + } + + private static PackableFloat3 ToPackable(Vector3 v) => new(v.x, v.y, v.z); + private static Vector3 ToVector(PackableFloat3 v) => new(v.X, v.Y, v.Z); + } + + /// + /// The authored route of a wire rail: the knots of the first spline in the child's + /// container plus its closed flag. Positions are in the child's local (VPX) space. + /// + public struct WireRailSplinePackable + { + // 2: per-knot automatic tangent tension. + private const int CurrentVersion = 2; + + public int Version; + public bool Closed; + public WireRailKnotPackable[] Knots; + + public static byte[] Pack(WireRailSplineComponent comp) + { + var container = comp.GetComponent(); + var spline = container ? container.Spline : null; + var knots = new List(); + if (spline != null) { + for (var i = 0; i < spline.Count; i++) { + var knot = spline[i]; + knots.Add(new WireRailKnotPackable { + Position = new PackableFloat3(knot.Position.x, knot.Position.y, knot.Position.z), + TangentIn = new PackableFloat3(knot.TangentIn.x, knot.TangentIn.y, knot.TangentIn.z), + TangentOut = new PackableFloat3(knot.TangentOut.x, knot.TangentOut.y, knot.TangentOut.z), + Rotation = new PackableFloat4(knot.Rotation.value.x, knot.Rotation.value.y, + knot.Rotation.value.z, knot.Rotation.value.w), + TangentMode = (int)spline.GetTangentMode(i), + Tension = spline.GetAutoSmoothTension(i), + }); + } + } + return PackageApi.Packer.Pack(new WireRailSplinePackable { + Version = CurrentVersion, + Closed = spline?.Closed ?? false, + Knots = knots.ToArray(), + }); + } + + public static void Unpack(byte[] bytes, WireRailSplineComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + var spline = new Spline(); + foreach (var knot in data.Knots ?? Array.Empty()) { + var rotation = new quaternion(knot.Rotation.X, knot.Rotation.Y, knot.Rotation.Z, + knot.Rotation.W); + if (math.lengthsq(rotation.value) < 1e-8f) { + rotation = quaternion.identity; + } + // Version 1 had no tension. Those rails were restored through Spline.Add, which + // assigns the Catmull-Rom tension, so that is what they were played with. + var tension = data.Version >= 2 ? knot.Tension : SplineUtility.CatmullRomTension; + // The tension-aware insert keeps AutoSmooth knots at their authored curvature; + // the plain Add would reset them to the default tension. + spline.Insert(spline.Count, new BezierKnot( + new float3(knot.Position.X, knot.Position.Y, knot.Position.Z), + new float3(knot.TangentIn.X, knot.TangentIn.Y, knot.TangentIn.Z), + new float3(knot.TangentOut.X, knot.TangentOut.Y, knot.TangentOut.Z), + rotation), + (TangentMode)knot.TangentMode, tension); + } + spline.Closed = data.Closed; + comp.RestoreSpline(spline); + } + } + + public struct WireRailKnotPackable + { + public PackableFloat3 Position; + public PackableFloat3 TangentIn; + public PackableFloat3 TangentOut; + public PackableFloat4 Rotation; + public int TangentMode; + public float Tension; + } + + public struct PackableFloat4 + { + public float X; + public float Y; + public float Z; + public float W; + + public PackableFloat4(float x, float y, float z, float w) + { + X = x; + Y = y; + Z = z; + W = w; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailPackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailPackable.cs.meta new file mode 100644 index 000000000..3be1ed98c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailPackable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 27d4de0da0ed4a2b9f64a4440748a26a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSolderMeshGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSolderMeshGenerator.cs new file mode 100644 index 000000000..d3faab59e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSolderMeshGenerator.cs @@ -0,0 +1,548 @@ +// 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 UnityEngine; +using UnityEngine.Splines; + +namespace VisualPinball.Unity +{ + internal readonly struct WireRailTouch + { + public readonly float3 FirstPoint; + public readonly float3 SecondPoint; + public readonly float3 FirstTangent; + public readonly float3 SecondTangent; + public readonly float FirstRadius; + public readonly float SecondRadius; + public readonly float SurfaceDistance; + + public WireRailTouch(float3 firstPoint, float3 secondPoint, + float3 firstTangent, float3 secondTangent, float firstRadius, + float secondRadius, float surfaceDistance) + { + FirstPoint = firstPoint; + SecondPoint = secondPoint; + FirstTangent = firstTangent; + SecondTangent = secondTangent; + FirstRadius = firstRadius; + SecondRadius = secondRadius; + SurfaceDistance = surfaceDistance; + } + + public float3 Position => (FirstPoint + SecondPoint) * 0.5f; + } + + internal static class WireRailWireTouchDetector + { + private const float DistanceEpsilon = 1e-4f; + + internal static bool TryFindTouch(float3 firstStart, float3 firstEnd, + float firstRadius, float3 secondStart, float3 secondEnd, + float secondRadius, float threshold, out WireRailTouch touch) + { + ClosestPointsBetweenSegments(firstStart, firstEnd, secondStart, secondEnd, + out var firstPoint, out var secondPoint); + var surfaceDistance = math.distance(firstPoint, secondPoint) + - math.max(0f, firstRadius) - math.max(0f, secondRadius); + if (surfaceDistance > math.max(0f, threshold) + DistanceEpsilon) { + touch = default; + return false; + } + touch = new WireRailTouch(firstPoint, secondPoint, + math.normalizesafe(firstEnd - firstStart, new float3(0f, 1f, 0f)), + math.normalizesafe(secondEnd - secondStart, new float3(1f, 0f, 0f)), + math.max(0f, firstRadius), math.max(0f, secondRadius), surfaceDistance); + return true; + } + + internal static bool TryFindTouch(float3 firstStart, float3 firstEnd, + float firstRadius, IReadOnlyList secondPoints, float secondRadius, + float threshold, out WireRailTouch touch) + { + touch = default; + if (secondPoints == null || secondPoints.Count < 2) { + return false; + } + var found = false; + for (var pointIndex = 0; pointIndex < secondPoints.Count - 1; pointIndex++) { + if (!TryFindTouch(firstStart, firstEnd, firstRadius, + secondPoints[pointIndex], secondPoints[pointIndex + 1], + secondRadius, threshold, out var candidate) + || found && candidate.SurfaceDistance >= touch.SurfaceDistance) { + continue; + } + touch = candidate; + found = true; + } + return found; + } + + private static void ClosestPointsBetweenSegments(float3 firstStart, + float3 firstEnd, float3 secondStart, float3 secondEnd, + out float3 firstPoint, out float3 secondPoint) + { + var firstDirection = firstEnd - firstStart; + var secondDirection = secondEnd - secondStart; + var delta = firstStart - secondStart; + var firstLengthSquared = math.lengthsq(firstDirection); + var secondLengthSquared = math.lengthsq(secondDirection); + var secondProjection = math.dot(secondDirection, delta); + float firstT; + float secondT; + if (firstLengthSquared <= 1e-10f && secondLengthSquared <= 1e-10f) { + firstPoint = firstStart; + secondPoint = secondStart; + return; + } + if (firstLengthSquared <= 1e-10f) { + firstT = 0f; + secondT = math.saturate(secondProjection / secondLengthSquared); + } else { + var firstProjection = math.dot(firstDirection, delta); + if (secondLengthSquared <= 1e-10f) { + secondT = 0f; + firstT = math.saturate(-firstProjection / firstLengthSquared); + } else { + var directionsDot = math.dot(firstDirection, secondDirection); + var denominator = firstLengthSquared * secondLengthSquared + - directionsDot * directionsDot; + firstT = denominator > 1e-10f + ? math.saturate((directionsDot * secondProjection + - firstProjection * secondLengthSquared) / denominator) + : 0f; + secondT = (directionsDot * firstT + secondProjection) + / secondLengthSquared; + if (secondT < 0f) { + secondT = 0f; + firstT = math.saturate(-firstProjection / firstLengthSquared); + } else if (secondT > 1f) { + secondT = 1f; + firstT = math.saturate((directionsDot - firstProjection) + / firstLengthSquared); + } + } + } + firstPoint = firstStart + firstDirection * firstT; + secondPoint = secondStart + secondDirection * secondT; + } + } + + internal static class WireRailSolderMeshGenerator + { + /// + /// Blob size at Solder Size 1. The proportions below were tuned at 1.0 and then + /// judged a touch heavy, so the default is scaled down here rather than retuned. + /// + private const float BaseSizeFactor = 0.8f; + + private const float FullTurn = math.PI * 2f; + private const int BlobRadialSegments = 6; + private const int BlobRingCount = 3; + private const int BlobVertexCount = BlobRadialSegments * BlobRingCount + 2; + internal const int TrianglesPerBlob = BlobRadialSegments * BlobRingCount * 2; + + [ThreadStatic] private static SolderBuffers _threadBuffers; + + private sealed class SolderBuffers + { + public readonly List FixturePaths = new(2); + public readonly List Touches = new(8); + public readonly float[] StartTrimOffsets = + new float[WireRailEndpointTrimUtility.MaximumRailCount]; + public readonly float[] EndTrimOffsets = + new float[WireRailEndpointTrimUtility.MaximumRailCount]; + public readonly float3[] BlobPositions = new float3[BlobVertexCount]; + public readonly Vector2[] BlobUvs = new Vector2[BlobVertexCount]; + } + + private readonly struct FixtureWirePath + { + public readonly IReadOnlyList Points; + public readonly float Radius; + + public FixtureWirePath(IReadOnlyList points, float radius) + { + Points = points; + Radius = radius; + } + } + + internal static void Append(Spline spline, + IReadOnlyList segments, + IReadOnlyList fixtures, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices) + { + if (spline == null || segments == null || segments.Count == 0 + || fixtures == null) { + return; + } + var buffers = _threadBuffers ??= new SolderBuffers(); + WireRailEndpointTrimUtility.Collect(spline, segments, fixtures, + buffers.StartTrimOffsets, buffers.EndTrimOffsets); + foreach (var fixture in fixtures) { + buffers.Touches.Clear(); + CollectTouches(spline, segments, fixtures, fixture, + buffers.StartTrimOffsets, buffers.EndTrimOffsets, + buffers.FixturePaths, buffers.Touches); + foreach (var touch in buffers.Touches) { + AppendTouch(touch, CalculateSeed(touch), fixture.SolderSize, + vertices, normals, uvs, indices); + } + } + } + + internal static void CollectTouches(Spline spline, + IReadOnlyList segments, + IReadOnlyList fixtures, WireRailFixture fixture, + ICollection touches) + { + if (touches == null) { + throw new ArgumentNullException(nameof(touches)); + } + var buffers = _threadBuffers ??= new SolderBuffers(); + WireRailEndpointTrimUtility.Collect(spline, segments, fixtures, + buffers.StartTrimOffsets, buffers.EndTrimOffsets); + CollectTouches(spline, segments, fixtures, fixture, + buffers.StartTrimOffsets, buffers.EndTrimOffsets, + buffers.FixturePaths, touches); + } + + private static void CollectTouches(Spline spline, + IReadOnlyList segments, + IReadOnlyList fixtures, WireRailFixture fixture, + IReadOnlyList startTrimOffsets, + IReadOnlyList endTrimOffsets, + List fixturePaths, ICollection touches) + { + fixturePaths.Clear(); + if (spline == null || segments == null || segments.Count == 0 + || fixture == null || fixture is WireRailTrimFixture + || fixture is WireRailHairpinFixture + || fixture is WireRailElbowFixture + || !TryBuildFixturePaths(spline, segments, fixture, fixturePaths) + || fixturePaths.Count == 0) { + return; + } + + var splineLength = spline.GetLength(); + var distance = math.clamp(fixture.Distance, 0f, math.max(0f, splineLength)); + var segmentIndex = WireRailSplineGeometry.GetLayoutIndexAtDistance(segments, + distance, splineLength); + if (segmentIndex < 0) { + return; + } + var segmentStart = segments[segmentIndex].Distance; + var segmentEnd = segmentIndex + 1 < segments.Count + ? segments[segmentIndex + 1].Distance : splineLength; + var curveT = segmentEnd > segmentStart + ? math.saturate((distance - segmentStart) / (segmentEnd - segmentStart)) + : 0f; + var maximumFixtureRadius = 0f; + foreach (var fixturePath in fixturePaths) { + maximumFixtureRadius = math.max(maximumFixtureRadius, fixturePath.Radius); + } + var atStart = !spline.Closed && distance <= 1e-4f; + var atEnd = !spline.Closed && distance >= splineLength - 1e-4f; + var evaluationContext = new WireRailPathEvaluationContext(); + var segment = segments[segmentIndex]; + for (var railIndex = 0; railIndex < segment.RailCount; railIndex++) { + // Skip a rail whose endpoint trim removed it at the fixture: the rail is absent + // anywhere before its trimmed start or after its trimmed end, not only exactly + // at the spline endpoints, so a fixture inside that gap must not solder to it. + if (!segment.IsRailActive(railIndex) + || distance < startTrimOffsets[railIndex] - 1e-4f + || distance > splineLength - endTrimOffsets[railIndex] + 1e-4f + || !WireRailSplineGeometry.TryEvaluateRailFrame(spline, segments, + evaluationContext, segmentIndex, railIndex, curveT, 1f / 128f, + out var railFrame)) { + continue; + } + var railRadius = WireRailSplineGeometry.EvaluateWireDiameter(spline, + segments, segmentIndex, railIndex, curveT) * 0.5f; + var railSpan = math.max(WireRailLayout.ReferenceWireDiameter, + railRadius + maximumFixtureRadius + fixture.SolderThreshold) * 2f; + var railStart = atStart + ? railFrame.Position + : railFrame.Position - railFrame.Tangent * railSpan; + var railEnd = atEnd + ? railFrame.Position + : railFrame.Position + railFrame.Tangent * railSpan; + foreach (var fixturePath in fixturePaths) { + if (WireRailWireTouchDetector.TryFindTouch(railStart, railEnd, + railRadius, fixturePath.Points, fixturePath.Radius, + fixture.SolderThreshold, out var touch)) { + touches.Add(touch); + } + } + } + } + + private static bool TryBuildFixturePaths(Spline spline, + IReadOnlyList segments, WireRailFixture fixture, + ICollection paths) + { + switch (fixture) { + case WireRailRingFixture ring when WireRailFixtureMeshGenerator + .TryEvaluateRingProfile(spline, segments, ring, out var ringProfile): { + var points = BuildRingPath(ringProfile, ring); + if (points.Count >= 2) { + paths.Add(new FixtureWirePath(points, ring.Diameter * 0.5f)); + } + break; + } + case WireRailCradleFixture cradle when WireRailFixtureMeshGenerator + .TryEvaluateCradleProfile(spline, segments, cradle, out var cradleProfile): + paths.Add(new FixtureWirePath(cradleProfile.CenterlinePoints, + cradle.Diameter * 0.5f)); + break; + case WireRailRungFixture rung when WireRailFixtureMeshGenerator + .TryEvaluateRungProfile(spline, segments, rung, + out var rungProfile): + paths.Add(new FixtureWirePath(new[] { + rungProfile.Start, + rungProfile.End, + }, rung.Diameter * 0.5f)); + break; + case WireRailStandFixture leg when WireRailFixtureMeshGenerator + .TryEvaluateStandProfile(spline, segments, leg, out var legProfile): + paths.Add(new FixtureWirePath(new[] { + legProfile.AttachmentProfile.Start, + legProfile.AttachmentProfile.End, + }, leg.Diameter * 0.5f)); + break; + default: + return false; + } + return paths.Count > 0; + } + + private static List BuildRingPath(WireRailRingProfile profile, + WireRailRingFixture ring) + { + var points = new List(); + if (!ring.TryGetVisibleArc(out var startAngle, out var sweepAngle, + out var closed)) { + return points; + } + var segmentCount = math.max(2, + (int)math.ceil(ring.RingDensity * sweepAngle / FullTurn)); + var angles = BuildRingAngles(ring, startAngle, sweepAngle, closed, + segmentCount); + foreach (var angle in angles) { + points.Add(profile.Frame.TransformOffset(profile.CenterOffset + + ring.EvaluateCenterlineOffset(angle, profile.Radius))); + } + if (closed && points.Count > 1) { + points.Add(points[0]); + } + return points; + } + + private static List BuildRingAngles(WireRailRingFixture ring, + float startAngle, float sweepAngle, bool closed, int segmentCount) + { + var angles = new List(segmentCount + 3); + var pointCount = closed ? segmentCount : segmentCount + 1; + for (var pointIndex = 0; pointIndex < pointCount; pointIndex++) { + angles.Add(startAngle + sweepAngle * pointIndex / segmentCount); + } + if (ring.TryGetStraightSection(out var straightStart, out var straightSweep)) { + AddBoundary(straightStart); + AddBoundary(straightStart + straightSweep); + } + angles.Sort(); + for (var angleIndex = angles.Count - 1; angleIndex > 0; angleIndex--) { + if (math.abs(angles[angleIndex] - angles[angleIndex - 1]) < 1e-5f) { + angles.RemoveAt(angleIndex); + } + } + return angles; + + void AddBoundary(float boundary) + { + for (var turn = -2; turn <= 2; turn++) { + var unwrapped = boundary + turn * FullTurn; + var atEnd = math.abs(unwrapped - (startAngle + sweepAngle)) < 1e-5f; + if (unwrapped >= startAngle - 1e-5f + && unwrapped <= startAngle + sweepAngle + 1e-5f + && (!closed || !atEnd)) { + angles.Add(unwrapped); + } + } + } + } + + internal static void AppendTouch(WireRailTouch touch, uint seed, float solderSize, + ICollection vertices, ICollection normals, + ICollection uvs, ICollection indices) + { + var center = touch.Position; + var firstAxis = math.normalizesafe(touch.FirstTangent, + new float3(0f, 1f, 0f)); + var secondAxis = touch.SecondTangent + - firstAxis * math.dot(touch.SecondTangent, firstAxis); + if (math.lengthsq(secondAxis) <= 1e-8f) { + var separation = touch.SecondPoint - touch.FirstPoint; + secondAxis = separation - firstAxis * math.dot(separation, firstAxis); + } + if (math.lengthsq(secondAxis) <= 1e-8f) { + var fallback = math.abs(firstAxis.z) < 0.9f + ? new float3(0f, 0f, 1f) : new float3(1f, 0f, 0f); + secondAxis = fallback - firstAxis * math.dot(fallback, firstAxis); + } + secondAxis = math.normalize(secondAxis); + var normalAxis = math.normalizesafe(math.cross(firstAxis, secondAxis), + new float3(1f, 0f, 0f)); + var separationAxis = touch.SecondPoint - touch.FirstPoint; + if (math.dot(normalAxis, separationAxis) < 0f) { + normalAxis = -normalAxis; + } + + var state = seed == 0 ? 0x9e3779b9u : seed; + var firstExtent = (touch.FirstRadius * 1.25f + + touch.SecondRadius * 0.35f) * RandomRange(ref state, 0.92f, 1.08f); + var secondExtent = (touch.SecondRadius * 1.25f + + touch.FirstRadius * 0.35f) * RandomRange(ref state, 0.92f, 1.08f); + var centerDistance = math.distance(touch.FirstPoint, touch.SecondPoint); + var normalExtent = math.max(math.max(touch.FirstRadius, touch.SecondRadius) * 0.75f, + centerDistance * 0.5f + math.min(touch.FirstRadius, touch.SecondRadius) * 0.25f) + * RandomRange(ref state, 0.92f, 1.08f); + solderSize = math.max(0.01f, solderSize) * BaseSizeFactor; + firstExtent *= solderSize; + secondExtent *= solderSize; + normalExtent *= solderSize; + + var buffers = _threadBuffers ??= new SolderBuffers(); + var positions = buffers.BlobPositions; + var blobUvs = buffers.BlobUvs; + positions[0] = center - normalAxis * normalExtent + * RandomRange(ref state, 0.9f, 1.02f); + blobUvs[0] = new Vector2(0.5f, 0f); + for (var ringIndex = 0; ringIndex < BlobRingCount; ringIndex++) { + var latitude = math.lerp(-math.PI * 0.25f, math.PI * 0.25f, + ringIndex / (float)(BlobRingCount - 1)); + var latitudeRadius = math.cos(latitude); + var latitudeHeight = math.sin(latitude); + var angleOffset = RandomRange(ref state, -0.14f, 0.14f); + for (var radialIndex = 0; radialIndex < BlobRadialSegments; radialIndex++) { + var angle = FullTurn * radialIndex / BlobRadialSegments + angleOffset + + RandomRange(ref state, -0.08f, 0.08f); + var radialVariation = RandomRange(ref state, 0.84f, 1.16f); + var normalVariation = RandomRange(ref state, 0.9f, 1.1f); + var radial = firstAxis * (math.cos(angle) * firstExtent) + + secondAxis * (math.sin(angle) * secondExtent); + var vertexIndex = 1 + ringIndex * BlobRadialSegments + radialIndex; + positions[vertexIndex] = center + radial * latitudeRadius + * radialVariation + normalAxis * normalExtent * latitudeHeight + * normalVariation; + blobUvs[vertexIndex] = new Vector2( + radialIndex / (float)BlobRadialSegments, + (ringIndex + 1f) / (BlobRingCount + 1f)); + } + } + var topPole = BlobVertexCount - 1; + positions[topPole] = center + normalAxis * normalExtent + * RandomRange(ref state, 0.9f, 1.02f); + blobUvs[topPole] = new Vector2(0.5f, 1f); + + for (var radialIndex = 0; radialIndex < BlobRadialSegments; radialIndex++) { + var next = (radialIndex + 1) % BlobRadialSegments; + AppendTriangle(0, 1 + next, 1 + radialIndex); + for (var ringIndex = 0; ringIndex < BlobRingCount - 1; ringIndex++) { + var firstRing = 1 + ringIndex * BlobRadialSegments; + var secondRing = firstRing + BlobRadialSegments; + AppendTriangle(firstRing + radialIndex, firstRing + next, + secondRing + radialIndex); + AppendTriangle(firstRing + next, secondRing + next, + secondRing + radialIndex); + } + var lastRing = 1 + (BlobRingCount - 1) * BlobRadialSegments; + AppendTriangle(topPole, lastRing + radialIndex, lastRing + next); + } + + void AppendTriangle(int firstIndex, int secondIndex, int thirdIndex) + { + var first = positions[firstIndex]; + var second = positions[secondIndex]; + var third = positions[thirdIndex]; + var faceNormal = math.cross(second - first, third - first); + if (math.dot(faceNormal, (first + second + third) / 3f - center) < 0f) { + (second, third) = (third, second); + (secondIndex, thirdIndex) = (thirdIndex, secondIndex); + } + var firstVertex = vertices.Count; + vertices.Add((Vector3)first); + vertices.Add((Vector3)second); + vertices.Add((Vector3)third); + normals.Add((Vector3)EvaluateSmoothNormal(first)); + normals.Add((Vector3)EvaluateSmoothNormal(second)); + normals.Add((Vector3)EvaluateSmoothNormal(third)); + uvs.Add(blobUvs[firstIndex]); + uvs.Add(blobUvs[secondIndex]); + uvs.Add(blobUvs[thirdIndex]); + indices.Add(firstVertex); + indices.Add(firstVertex + 1); + indices.Add(firstVertex + 2); + } + + float3 EvaluateSmoothNormal(float3 position) + { + var offset = position - center; + var firstExtentSquared = math.max(firstExtent * firstExtent, 1e-8f); + var secondExtentSquared = math.max(secondExtent * secondExtent, 1e-8f); + var normalExtentSquared = math.max(normalExtent * normalExtent, 1e-8f); + var ellipsoidNormal = firstAxis * (math.dot(offset, firstAxis) / firstExtentSquared) + + secondAxis * (math.dot(offset, secondAxis) / secondExtentSquared) + + normalAxis * (math.dot(offset, normalAxis) / normalExtentSquared); + return math.normalizesafe(ellipsoidNormal, + math.normalizesafe(offset, normalAxis)); + } + } + + private static uint CalculateSeed(WireRailTouch touch) + { + var seed = 0x811c9dc5u; + seed = Mix(seed, math.asuint(touch.Position.x)); + seed = Mix(seed, math.asuint(touch.Position.y)); + seed = Mix(seed, math.asuint(touch.Position.z)); + seed = Mix(seed, math.asuint(touch.FirstRadius)); + seed = Mix(seed, math.asuint(touch.SecondRadius)); + return seed; + } + + private static uint Mix(uint seed, uint value) + { + seed ^= value + 0x9e3779b9u + (seed << 6) + (seed >> 2); + seed ^= seed >> 16; + seed *= 0x7feb352du; + seed ^= seed >> 15; + return seed; + } + + private static float RandomRange(ref uint state, float minimum, float maximum) + { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + var unit = (state & 0x00ffffffu) / 16777215f; + return math.lerp(minimum, maximum, unit); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSolderMeshGenerator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSolderMeshGenerator.cs.meta new file mode 100644 index 000000000..71d7d899c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSolderMeshGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f2e6202562544f70ab7f93638686fae7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSplineComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSplineComponent.cs new file mode 100644 index 000000000..318469d75 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSplineComponent.cs @@ -0,0 +1,69 @@ +// 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 UnityEngine; +using UnityEngine.Splines; + +namespace VisualPinball.Unity +{ + /// + /// Marks the generated spline child of a . The Wire Rail + /// resolves its spline through this marker rather than by name, and locks the child's + /// transform because it carries the VPX-to-world conversion the generated geometry relies on. + /// + /// Unlike drag-point splines, this child holds the authored route itself, so it is also the + /// packable that carries the route through a .vpe package: the child stays in the package's + /// scene (its transform, mesh and material travel with the glTF), and this component + /// restores the on load. + /// + [AddComponentMenu("")] + [DisallowMultipleComponent] + [PackAs("WireRailSpline")] + public sealed class WireRailSplineComponent : MonoBehaviour, IPackable + { + /// + /// True once a package restored this child's route. The owning Wire Rail waits for + /// it before synchronizing layouts and fixtures against the route length. + /// + public bool SplineRestored { get; private set; } + + public byte[] Pack() => WireRailSplinePackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) + => Array.Empty(); + + public void Unpack(byte[] bytes) => WireRailSplinePackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] bytes, Transform root, PackagedRefs refs, + PackagedFiles files) + { + } + + internal void RestoreSpline(Spline spline) + { + if (!TryGetComponent(out var container)) { + container = gameObject.AddComponent(); + } + container.Spline = spline; + transform.hideFlags |= HideFlags.NotEditable; + SplineRestored = true; + // The hierarchy is inactive while a runtime package is being restored, so look + // through inactive parents as well. + GetComponentInParent(true)?.OnSplineRestored(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSplineComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSplineComponent.cs.meta new file mode 100644 index 000000000..caa8a236a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailSplineComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c09a3cdc96ad4119846d40b9029214a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: