Skip to content

Wire rails - #575

Merged
freezy merged 51 commits into
masterfrom
feat/wire-rail-spline-authoring
Sep 5, 2026
Merged

Wire rails#575
freezy merged 51 commits into
masterfrom
feat/wire-rail-spline-authoring

Conversation

@freezy

@freezy freezy commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Adds a Wire Rail component: habitrails, wire ramps and guides authored from a native Unity spline, with generated wire tubes, fixtures, and a ball channel collider fitted to the space the ball actually occupies.

How it works

  • Route: a Unity spline on a generated, locked child GameObject in VPX space. Knots are edited in the Scene view, with a grade action that sets a constant slope across the plan-view route.
  • Wire layouts: cross-sections placed by distance along the route (1 to 6 wires, per-wire activation and offsets), with continuous or curve-shaped transitions between them. Physical order follows position alone.
  • Fixtures: rings, rungs, cradles and stands along the route; hairpins, elbows and rail trims at the endpoints. Supports get solder blobs where they touch a wire.
  • Generated geometry: a render mesh of tubes, and a faceted ball channel collider registered with VPE physics (not per-wire tubes). The channel opens or closes its roof depending on whether the ball fits through the top wires.

Editor

  • Inspector with interactive cross-section editing, transition overrides, per-fixture previews, icon buttons for adding fixtures, and framed list panels.
  • Scene view: colored wire centerlines, layout and fixture labels, a titled panel with edit/grade/collider-preview actions, and highlighting of the selected layout or fixture with arrow handles that slide it along the route.
  • Toolbox button and thumbcam preset.

Packaging

Wire rails survive a .vpe round trip. The spline child packs the route and restores its container on its own imported node, the component packs settings, layouts and fixtures, physics materials go through the asset table. Covered by pack/unpack, export-then-runtime-import and frozen version 1 payload tests.

Documentation

New authoring guide under creators-guide/editor/wire-rails/ (overview, route, layouts, fixtures, generated geometry), with screenshots.

Also on this branch

  • Magnet: a hit_switch pulse for cylindrical magnets with a generated collider.
  • Input: hardware-rule wires dispatched on the simulation thread.
  • Drag-point splines: the generated child's transform is locked; a stale duplicate-spline test was updated to the current keep-knots rule.

Not yet

No .vpx import or export of wire rails, and no branching routes.

The Drop fixture (renamed from Hole Drop) continues two selected rails and
turns them vertically down. Its offset now moves the drop inward, shortening
the two attached rails, and the collider extends their two floor faces
straight down at the drop point while leaving the outer rails full length.
Fixtures can also solder to the rails they touch.

Includes hardening from review: consistent drop validation at the offset
attachment across trims, caps, colliders and inspector; conflict detection
that accounts for other drop offsets and ignores non-generatable drops;
offset clamped to the spline length; and open-spline guards.
Collider vertices are generated in the spline container's local space,
which also positions the render mesh. Building the playfield matrix
from the component transform ignored the child's offset, so colliders
drifted away from the visible rail whenever the spline container was
moved.

Derive the matrix from the container's localToWorldMatrix instead, and
skip the extra VpxToWorld conversion since the vertices are already in
VPX units. Falls back to the component transform when no container
exists yet.
P-ROC hardware rules become input-action wires (e.g. "Left Flipper" ->
flipper main coil). Those wires only fired through the managed Input
System (WirePlayer.HandleKeyInput), which is inactive while native input
polling owns the keys. Under native input the switch reached the game but
the coil never energized, so flippers did not move.

Register each hardware-rule wire whose coil supports the simulation-thread
dispatch path against its native input action, and fire it from the
simulation-thread input loop in the same tick as the key. This keeps the
low-latency path flippers need. Coils without a simulation-thread path are
unaffected and stay on the main-thread wire path.
Add a Wire Rail entry to the toolbox next to Ramp, using the ramp_wires
icon, that creates a WireRailComponent under the playfield.
Add a per-fixture "Enabled" toggle to every wire-rail fixture. Disabling
hides the fixture from the render mesh only (own geometry, rail trims, end
fitting and solder) by feeding only enabled fixtures to the render
generator; colliders keep using the full fixture list and are unaffected.
The toggle lives in the shared fixture header, so it applies to all types.

Add inspector preview panels for the Drop and Drop Loop fixtures. The Drop
preview also shows a stretch of the rails leading into the drop for
context, and the Drop Loop preview uses a top-down projection so its loop
is drawn in the plane it actually lies in.
The brace preview drew only the ring arc, unlike the cross-wire, V-brace
and leg previews which also show their rails. Carry the rail offsets and
radii the brace profile already computes through to the cross-section, and
draw the wrapped wire rails behind the ring so the preview shows what the
brace encircles.
Replace the drop loop's lateral/vertical offset with a single along-rail
offset. The offset shortens the two rails the loop connects to so the loop
attaches that far before the endpoint and the rails follow it, keeping it
attached; the outer rails are untouched. Clamped to non-negative.

Weld the loop to those rails so the junction reads as one continuous tube:
fitted rail ends are left open (no flat cap), and each loop mouth ring is
stitched to the rail's end ring with a triangle band that reuses the ring
vertices. The rings are matched by the rotation (and, for the far mouth,
the reversed winding) that best aligns them, and their normals are averaged
so shading stays continuous across the seam.

Also size the drop loop inspector warning box to its text so the conflict
message is no longer clipped.
…seam

Generalize the drop-loop seam weld to serve both endpoint fittings and wire
the Drop into it, so each of its two rail tubes is stitched to the rail it
attaches to instead of leaving an open ring.

Seed each fitting tube's rings from the attached rail's mouth frame so the
rings coincide with the rail's rings vertex-for-vertex; the tube edges then
run straight through the junction rather than twisting across a mismatched
band. The drop's two tubes and the loop's near mouth align exactly; the
loop's far mouth keeps the ring-matched bridge (one tube cannot seed both
ends).
The generated spline child carries the VPX-to-world rotation and inverse
scale that every drag-point consumer relies on. Mark its transform
NotEditable when it is created and whenever an existing child is resolved,
so it cannot be nudged by hand in the inspector.

Also bring the duplicate-spline test in line with the guard that never
destroys a child still holding knots: an empty duplicate is removed, a
duplicate with knots is kept and warned about.
Fixtures are now named after their shape:

  Brace        -> Ring      (WireRailRingFixture)
  Cross + Arms -> Cradle    (WireRailCradleFixture)
  Cross Wire   -> Rung      (WireRailRungFixture)
  Stand        -> Stand     (WireRailStandFixture, was WireRailLegFixture)
  Drop Loop    -> Hairpin   (WireRailHairpinFixture)
  Drop         -> Elbow     (WireRailElbowFixture)
  Rail Trim    -> Rail Trim

Each class carries a MovedFrom attribute for its previous name so existing
scenes migrate on load. The Elbow's attribute now points at
WireRailDropFixture; the pre-branch WireRailHoleDropFixture name no longer
migrates.

The spline child is resolved through a new WireRailSplineComponent marker
instead of by name, and its transform is locked in the inspector since it
holds the VPX-to-world conversion. Legacy children are found by name once
and hardened in place.

The Add buttons in the Fixtures section are fixed-size icon buttons that
wrap with the inspector width, grouped into supports and end fittings.

The Scene view panel gets a title with the wire rail icon and the object
name, a one-line summary of rails, layouts, fixtures and route length, a
collider preview toggle, and while editing a status line with a Done button
that returns to the Wire Rail, the grade action with the horizon icon, and
the shortcuts as a small table.
Replace the single reference page with five author-facing pages under
creators-guide/editor/wire-rails: an overview with the route, layouts,
fixtures and generated geometry model, then one page each for the route,
wire layouts, fixtures and generated geometry. The uid wire_rail_authoring
is kept on the overview so existing links resolve.

Screenshots referenced by the pages are not included yet.
With a layout selected, every other layout dims, the selected span gets a
translucent glow under its wires and spine, and its cross-section is outlined
at both ends of the span so the governed stretch is visible. The rendered
tubes of the span are tinted in the selection color; the render generator
now records each span's index range in the mesh so the editor can draw just
those triangles.

Double-headed arrow handles at the start of the selected span and at the
start of the next one slide those layouts along the route, snapping to the
nearest point on the spline through the same distance setter the inspector
uses. Labels under the arrows are hidden while they are shown.
A layout's position can now be any distance along the route; only the first
layout stays pinned to the start. The physical list is sorted by distance
whenever a position changes and on load, and the display order is remapped
so each "Layout N" keeps its layout. Previously a position was clamped
between its physical neighbors, which made the order limit where a layout
could go.

SetLayoutDistance returns the layout's new physical index so the inspector
and the scene-view arrows keep the selection on the moved layout.

Duplicating a layout always places the copy halfway to the next layout, or
halfway to the end of the route for the last one, instead of before it.
The start and exit tapers of the ball channel collider are gone: the
component's six widening fields and their setter, the widening struct and
the parameter threaded through collider sampling and the elbow endpoint
profile, the inspector controls, and the seven tests that pinned them. The
channel radius is the reference ball radius everywhere.
Solder Size 1 now produces what 0.8 did. The proportions were tuned at 1.0
and judged a touch heavy, so the default is scaled inside the generator
rather than retuned. The render-mesh golden hash in the stress test follows.
Selecting a fixture panel highlights its centerline, fades the other
fixtures, tints its rendered wire in the selection color and shows an arrow
handle: supports slide along the route, hairpins and elbows set their offset
from the endpoint. The render generator records one index range per fixture
so the editor can tint a single fixture, and the component gains a route
distance setter for supports and offset setters for the two end fittings.

The mesh tint caches are keyed on the mesh generation count rather than the
geometry version, which advances when a rebuild is requested while the
rebuild itself is deferred in the editor; the old key could freeze a stale
mesh under a new version.
Each layout and fixture sits in a bordered box with a header band and an
even inset on both sides, so it is clear where one ends and the next begins.
The solder settings share one line, the ring's Apply to All is the last row
of its panel, and the elbow's attached rails show as disabled cutoff fields
instead of blank rows. End fitting panels measure their help box from the
actual message, so a long notice no longer overflows the frame.
Adds the inspector and Scene view screenshots the wire rail pages reference,
updates the ones that changed with the inspector work, and tightens the
fixture page's wording on how fixtures relate to layouts.
Wire rails now survive a .vpe round trip. Before, the writer dropped the
component, its spline and the marker with a warning each, leaving a frozen
mesh with no collider in the player.

Two packables carry a rail. WireRailSplineComponent packs the route (knots,
tangents, rotations, tangent modes, closed flag) and restores the spline
container on its own imported child, which keeps the child's transform,
mesh and material in the glTF like every other renderable. WireRailComponent
packs settings, layouts with per-wire offsets, activation and transition
curves, and the fixture list with a kind discriminator for all seven types;
its physics material assets go through the asset table.

The two unpacks coordinate so layouts and fixtures are only synchronized
once the route exists, whichever half arrives first. The component adopts an
existing spline child without a container instead of creating a second one
(the editor import adds the component before unpacking), and keeps an
imported material rather than overwriting it. The writer skips
SplineContainer silently.

The payload version is threaded into the restore path for future gating,
documented on WireRailPackable. Tests cover a full pack/unpack of every
field in both orders, an export followed by a runtime import that ends with
a collidable rail, and the frozen version 1 payloads.
@greptile-apps

greptile-apps Bot commented Sep 5, 2026

Copy link
Copy Markdown

Greptile Summary

The PR introduces Unity-native wire rails with spline-authored routes, configurable wire layouts and fixtures, generated render and collision geometry, packaging support, editor tooling, tests, and documentation. Changes since the previous review:

  • Prevent non-generatable elbows from suppressing otherwise valid endpoint fittings.
  • Restore version 1 wire-rail splines with their legacy Catmull-Rom AutoSmooth tension.
  • Add focused regression coverage for endpoint conflicts and legacy spline curvature.
  • All previous Greptile findings are resolved.

Confidence Score: 5/5

The PR appears safe to merge; the previous findings are resolved and the latest compatibility and fixture-conflict fixes introduce no actionable regressions.

The endpoint-conflict change consistently excludes non-generatable elbows from trim ownership, and the legacy spline loader preserves the AutoSmooth curvature produced by the original version 1 loading behavior. No outstanding findings remain.

Important Files Changed

Filename Overview
VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailGeometryGenerator.cs Endpoint conflict detection now ignores elbows that cannot generate, consistently across rendering and collider generation.
VisualPinball.Unity/VisualPinball.Unity/VPT/WireRail/WireRailPackable.cs Version 1 spline payloads now restore the Catmull-Rom tension used by the original loading path.
VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailComponentTests.cs Adds regression coverage ensuring an omitted elbow does not suppress a valid hairpin.
VisualPinball.Unity/VisualPinball.Unity.Test/VPT/WireRail/WireRailPackagingTests.cs Adds compatibility assertions for version 1 AutoSmooth tension and route shape.

Reviews (6): Last reviewed commit: "wire-rail: omitted elbows no longer conf..." | Re-trigger Greptile

Comment thread VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs Outdated
Comment thread VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs
…tch once

Hardware-rule wire coils were registered on whichever SimulationThread
existed at that moment: a rule installed before the component's Start(), or
kept across a stop/start cycle, never reached native-input dispatch. The
component now keeps the registrations at its own scope and applies them to
every thread it creates.

While native polling owns the keys, a coil on the simulation-thread path
also received the managed Input System event, so one press could reach it
twice. The wire player now skips the managed path for destinations the
simulation thread dispatches, and only while native polling is active, so
the managed path remains the fallback without native input. Registration
reports whether the action is one the native poller can dispatch, so
unmapped actions stay on the managed path.
Comment thread VisualPinball.Unity/VisualPinball.Unity/Game/WirePlayer.cs
…remains

The same hardware rule installed twice yields two wire assignments sharing
one native coil registration. Removing one of them unregistered the coil
while the survivor kept suppressing its managed fallback, so the rule went
dead. Unregistering now leaves the native registration in place as long as
another assignment on the same action still routes to that coil.
@freezy freezy self-assigned this Sep 5, 2026
Restoring a route with Spline.Add reset every AutoSmooth knot to the
default tension, so a rail authored with custom tension changed shape on
import. The spline payload is now version 2 with a per-knot tension, and
restoring uses the tension-aware insert. Version 1 payloads load with the
default tension they were authored with. The test compares evaluated
positions along the route, not only knot positions.
Trim collection checked a hairpin's rail pair and conflicts but not whether
both rails exist and are active at its offset attachment, which the fitting
generator does check. An omitted hairpin could therefore still shorten its
surviving rail and the collision channel. The attachment validation is now
shared, mirroring the elbow.
Double-clicking the closing curve inserted the new knot at index 0, which
moved the route origin while layouts and fixtures kept their distances from
it, so everything slid along the route. The knot is appended after the last
knot instead, which lies on the same curve and leaves knot 0 in place.
Skipping the managed path for a coil the simulation thread fires also
skipped the coil's enabled state and CoilStatusChanged, which coil sounds
and animations rely on. The wire player now publishes the state to the
main-thread listeners through a status-only path on DeviceCoil that keeps
the physics callbacks with the simulation thread, whichever side runs first.
A hairpin or elbow offset at or beyond the route length clamped the
attachment to the opposite endpoint while trimming used the raw offset, so
the fitting appeared at the wrong end over rails that had vanished from
both the render mesh and the collider. Such a fitting is now not
generatable: the shared predicates refuse it, the trim collection, profile
generators and flat-cap handling all follow them, and the inspector says
why.
… with

The version 1 spline payload carried no tension and was restored through
Spline.Add, which assigns the Catmull-Rom tension of one half. The fallback
used Unity's default of one third instead, so a version 1 package loaded
with a different curve. The golden-payload test now checks the restored
tension and compares the evaluated route against the old restoration.
The conflict check counted an elbow's positive offset on a shared rail even
when that elbow is not generated, so an omitted elbow could still suppress
a valid hairpin at the same endpoint. The check now receives the route
context and skips elbows that IsElbowGeneratable refuses, the same rule the
trims and the geometry follow.
@freezy
freezy merged commit 272dafb into master Sep 5, 2026
15 checks passed
@freezy
freezy deleted the feat/wire-rail-spline-authoring branch September 5, 2026 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant