From b196e20af950e2daba58fce2a6b326c1bdad6b93 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:14:20 +0000 Subject: [PATCH] fix: stop trains before outer axles leave rails --- README.md | 12 ++ .../vehicles/handlers/TrainHandler.java | 100 +++++++++++-- .../handlers/TrainReversePlacementTest.java | 131 +++++++++++++++++- 3 files changed, 233 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1086421..3fd1b4d 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,18 @@ time. Install the updated plugin and matching ServerAssets vehicle YAML, including `behaviour.train.locomotive: true` on the locomotive. Plugin updates do not overwrite existing vehicle configurations. +## Track ends + +Set `behaviour.train.wheel-bones` to the model bones at the frontmost and rearmost +axle pivots. Their model positions and scale determine where rail support ends. +The whole train stops before either axle runs past an open track end, in either +direction; cars already overhanging can drive back onto the rails. Connected +junctions and loop seams remain traversable. + +Install the matching ServerAssets vehicle YAML with this plugin update to check +the outer axles of the locomotive, coal car, passenger car, and flat car. Without +`wheel-bones`, configured bogie pivots are checked; rigid cars retain centre checks. + ## Walkable decks Train cars can have a deck that players walk on, and players standing on it ride diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java index b881a7a..36a97c2 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java @@ -95,6 +95,7 @@ public class TrainHandler { private final ThrottleTape.DwellState tapeDwell = new ThrottleTape.DwellState(); // Blocks across the wheels. With it, the move animations turn at the train's speed. private double wheelDiameter; + private final List wheelBones = new ArrayList<>(); // Two-bogie carriages rest on the rail under each bogie instead of their centre. private Bogies bogies; // A roof or other surface players can walk on, and the boxes that make it solid. @@ -104,6 +105,7 @@ public class TrainHandler { public TrainHandler(ConfigurationSection config) { locomotive = config.getBoolean("locomotive", false); wheelDiameter = Math.max(0, config.getDouble("wheel-diameter", 0)); + wheelBones.addAll(config.getStringList("wheel-bones")); if (config.contains("bogies")) { bogies = new Bogies(config.getStringList("bogies")); } @@ -132,6 +134,7 @@ public TrainHandler(ConfigurationSection config) { public TrainHandler(ActiveVehicle v, TrainHandler another) { locomotive = another.locomotive; wheelDiameter = another.wheelDiameter; + wheelBones.addAll(another.wheelBones); this.v = v; if (another.bogies != null) { bogies = new Bogies(v, another.bogies); @@ -805,16 +808,52 @@ private void followTrackUnderEntity() { } private record CarPlacement(ActiveVehicle vehicle, TrackSpline spline, double s, int sign, - double missingSpacing, TrackPose pose, TrackPose[] bogieRails) { - CarPlacement(ActiveVehicle vehicle, TrackSpline spline, double s, int sign, - double missingSpacing, TrackPose pose) { - this(vehicle, spline, s, sign, missingSpacing, pose, null); - } + double missingSpacing, TrackPose pose, TrackPose[] bogieRails, double[] missingRail) { } // The junction route the consist is placed along, so bogies can straddle a junction. private record Route(TrackRegistry registry, boolean takeBranch, UUID stemId, UUID branchId, double junctionS, int facingSign, double stemLength, boolean stemLoop, double branchLength) { + /** Distance a support lies beyond real track; branch tips do not reconnect to the stem. */ + double missingRail(UUID splineId, double s, double offset) { + TrackSpline spline = registry.get(splineId).orElse(null); + if (spline == null) { + return Math.abs(offset); + } + if (splineId.equals(branchId) && s + offset > spline.length()) { + return s + offset - spline.length(); + } + // The leading wheels can reach a thrown turnout before the car's centre + // selects it. In particular, a turnout at the stem end still supplies rail. + if (!splineId.equals(branchId) && Math.abs(offset) > 1e-9) { + int direction = offset > 0 ? 1 : -1; + TrackJunction first = null; + double nearest = Double.POSITIVE_INFINITY; + for (TrackJunction junction : registry.junctionsOn(splineId)) { + boolean selected = takeBranch && junction.branchSplineId != null + && junction.branchSplineId.equals(branchId); + double distance = TrackJunctionTravel.ahead(s, junction.s, direction, + spline.isLoop(), spline.length()); + if ((selected || junction.thrown) && junction.facingSign == direction + && junction.branchSplineId != null && distance >= -1e-9 + && distance <= Math.abs(offset) && distance < nearest) { + first = junction; + nearest = distance; + } + } + if (first != null) { + TrackSpline branch = registry.get(first.branchSplineId).orElse(null); + if (branch != null) { + return Math.max(0, Math.abs(offset) - nearest - branch.length()); + } + } + } + return TrackJunctionTravel.rewind(splineId, s, offset > 0 ? -1 : 1, + Math.abs(offset), takeBranch || splineId.equals(branchId), + stemId == null ? splineId : stemId, branchId, junctionS, + facingSign, stemLength, stemLoop, branchLength).missingSpacing; + } + /** The rail at {@code offset} along the track from {@code s}, following the route. */ TrackPose rail(UUID splineId, double s, double offset) { TrackJunctionTravel.Pose at = TrackJunctionTravel.rewind(splineId, s, offset > 0 ? -1 : 1, @@ -849,6 +888,33 @@ private TrackPose[] bogieRails(Route route, UUID onSpline, double at) { return rails; } + // Resolve the outer axle bones in model space, including the current model scale. + // Older configurations retain bogie support checks, or centre checks for rigid cars. + private double[] missingWheelRail(Route route, UUID onSpline, double at) { + double[] missing = new double[2]; + if (route == null) { + return missing; + } + if (onBogies()) { + for (double offset : bogies.offsets()) { + int end = offset < 0 ? 0 : 1; + missing[end] = Math.max(missing[end], route.missingRail(onSpline, at, offset)); + } + } + for (String name : wheelBones) { + try { + ActiveModel model = v.getModel(); + double offset = model.getBone(name).orElseThrow().getBlueprintBone() + .getRotatedGlobalPosition().z() * model.getScale().z(); + int end = offset < 0 ? 0 : 1; + missing[end] = Math.max(missing[end], route.missingRail(onSpline, at, offset)); + } catch (RuntimeException notLoaded) { + // The model or this bone is not available yet. + } + } + return missing; + } + private void applyPlacements(List placements) { for (CarPlacement placement : placements) { TrainHandler train = placement.vehicle.getTrainHandler(); @@ -887,7 +953,8 @@ private List planCars() { stemId, branchId, junctionS, facingSign, stemLength, stemLoop, branchLength); TrackPose[] locoRails = bogieRails(along, splineId, s); placements.add(new CarPlacement(v, spline, s, travelSign, 0, - locoRails == null ? spline.sampleAt(s) : bogies.bodyPose(locoRails), locoRails)); + locoRails == null ? spline.sampleAt(s) : bogies.bodyPose(locoRails), locoRails, + missingWheelRail(along, splineId, s))); if (registry == null) { return placements; } @@ -942,7 +1009,8 @@ private List planCars() { // Keep the sampled pose until connector blueprints and model transforms load. } } - placements.add(new CarPlacement(car, carSpline, pose.s, carTravelSign, pose.missingSpacing, carPose, rails)); + placements.add(new CarPlacement(car, carSpline, pose.s, carTravelSign, pose.missingSpacing, carPose, rails, + carTrain.missingWheelRail(along, pose.splineId, pose.s))); } else { return List.of(); } @@ -1205,7 +1273,7 @@ private void splineStep(double ds) { if (!clearStep(accepted, next, shapes)) { restoreStep(before); blocked = true; - trackEnd = compressesConsist(accepted, next); + trackEnd = compressesConsist(accepted, next) || losesWheelSupport(accepted, next); break; } trackEnd = reachesTrackEnd(accepted, next); @@ -1266,7 +1334,8 @@ private boolean reachesTrackEnd(List previous, List private boolean clearStep(List previous, List next, Map> shapes) { - if (next.isEmpty() || previous.size() != next.size() || compressesConsist(previous, next)) { + if (next.isEmpty() || previous.size() != next.size() || compressesConsist(previous, next) + || losesWheelSupport(previous, next)) { return false; } for (int i = 0; i < next.size(); i++) { @@ -1311,6 +1380,19 @@ private void showWhatStopped(CarPlacement from, CarPlacement to, Map previous, List next) { + for (int i = 0; i < Math.min(previous.size(), next.size()); i++) { + // A loaded or newly attached car may already overhang. Let it recover, + // including while clamped cars ahead of it regain their coupling gaps. + for (int end = 0; end < 2; end++) { + if (next.get(i).missingRail[end] > previous.get(i).missingRail[end] + 1e-9) { + return true; + } + } + } + return false; + } + private boolean compressesConsist(List previous, List next) { double before = previous.stream().mapToDouble(CarPlacement::missingSpacing).sum(); double after = next.stream().mapToDouble(CarPlacement::missingSpacing).sum(); diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java index 27b7544..fd9bc20 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainReversePlacementTest.java @@ -329,6 +329,127 @@ void trackEndSetsThrottleToZeroAndAllowsDrivingAway(double start, double speed, assertEquals(speed > 0 ? -1 : 1, loco.v.getThrottle().getCurrent()); } + @ParameterizedTest + @CsvSource({"97.1, 0.2", "97.1, 8", "22.9, -0.2", "22.9, -8"}) + void outerAxlesStopWholeConsistBeforeLeavingRail(double start, double speed) { + TrackSpline track = straightTrack(false); + TrainHandler loco = wheeledConsist(track, start); + loco.v.getThrottle().setThrottle(speed > 0 ? 100 : -100); + loco.v.getAccessPanel().setSpeed(speed); + loco.splineTick(); + assertPositions(loco, track, start); + assertEquals(0, loco.v.getThrottle().getCurrent()); + assertEquals(0, loco.v.getAccessPanel().getSpeed(), 1e-9); + // Holding into the end cannot creep the outer axle off the rail. + loco.v.getAccessPanel().setSpeed(speed); + loco.splineTick(); + assertPositions(loco, track, start); + double away = speed > 0 ? -0.1 : 0.1; + loco.v.getAccessPanel().setSpeed(away); + loco.splineTick(); + assertPositions(loco, track, start + away); + } + + @Test + void overhangingWheelsCanDriveBackOntoTrack() { + TrackSpline track = straightTrack(false); + TrainHandler loco = wheeledConsist(track, 21); + loco.v.getAccessPanel().setSpeed(-0.1); + loco.splineTick(); + assertPositions(loco, track, 21); + loco.v.getAccessPanel().setSpeed(3); + loco.splineTick(); + assertPositions(loco, track, 24); + } + + @Test + void supportUsesScaledWheelBonesOnAnUncoupledCar() { + TrackSpline track = straightTrack(false); + TrainHandler car = car(List.of("axle_front", "axle_back")); + when(car.v.getModel().getScale()).thenReturn(new Vector3f(2)); + car.setSplineId(track.getId()); + car.setS(94.3); + car.placeLoadedCars(); + car.v.getAccessPanel().setSpeed(0.1); + car.splineTick(); + assertEquals(94.3, car.getS(), 1e-8); + } + + @Test + void wheelSupportWrapsAcrossLoopSeam() { + TrackSpline track = straightTrack(true); + TrainHandler loco = wheeledConsist(track, 0.1); + loco.v.getAccessPanel().setSpeed(-0.2); + loco.splineTick(); + assertPositions(loco, track, track.length() - 0.1); + } + + @Test + void outerAxleCannotRunOffBranchTipOntoImaginaryStem() { + TrackSpline stem = straightTrack(false); + TrackSpline branch = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, + List.of(new double[]{0, 64, 50}, new double[]{100, 64, 50})); + store.save(branch); + TrackJunction junction = new TrackJunction(UUID.randomUUID(), stem.getId(), 50, + 1, TrackJunction.Side.LEFT, branch.getId(), true); + store.saveJunction("world", junction); + registry.loadFromDisk(); + TrainHandler loco = wheeledConsist(branch, 97.1); + loco.applyConsist(new ConsistData(null, null, branch.getId().toString(), 97.1, + 1, junction.id.toString(), true)); + loco.placeLoadedCars(); + loco.v.getAccessPanel().setSpeed(0.2); + loco.splineTick(); + assertPositions(loco, branch, 97.1); + } + + @Test + void wheelsCanEnterSelectedBranchBeforeCentreReachesStemEnd() { + TrackSpline stem = straightTrack(false); + TrackSpline branch = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, + List.of(new double[]{0, 64, 100}, new double[]{0, 64, 200})); + store.save(branch); + store.saveJunction("world", new TrackJunction(UUID.randomUUID(), stem.getId(), 100, + 1, TrackJunction.Side.LEFT, branch.getId(), true)); + registry.loadFromDisk(); + TrainHandler loco = wheeledConsist(stem, 97.1); + loco.v.getAccessPanel().setSpeed(0.2); + loco.splineTick(); + assertPositions(loco, stem, 97.3); + } + + @Test + void wheelsCrossBranchStartWithoutStopping() { + TrackSpline stem = straightTrack(false); + TrackSpline branch = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, + List.of(new double[]{0, 64, 50}, new double[]{100, 64, 50})); + store.save(branch); + TrackJunction junction = new TrackJunction(UUID.randomUUID(), stem.getId(), 50, + 1, TrackJunction.Side.LEFT, branch.getId(), true); + store.saveJunction("world", junction); + registry.loadFromDisk(); + TrainHandler loco = wheeledConsist(branch, 22.9); + loco.applyConsist(new ConsistData(null, null, branch.getId().toString(), 22.9, + 1, junction.id.toString(), true)); + loco.v.getThrottle().setThrottle(-100); + loco.v.getAccessPanel().setSpeed(-0.2); + loco.splineTick(); + assertEquals(22.7, loco.getS(), 1e-8); + assertEquals(-100, loco.v.getThrottle().getCurrent()); + } + + private TrainHandler wheeledConsist(TrackSpline track, double s) { + TrainHandler loco = car(List.of("axle_front", "axle_back")); + TrainHandler first = car(List.of("axle_front", "axle_back")); + TrainHandler last = car(List.of("axle_front", "axle_back")); + loco.setChild(first.v); + first.setChild(last.v); + loco.setSplineId(track.getId()); + loco.setS(s); + loco.placeLoadedCars(); + return loco; + } + @Test void wallStopKeepsThrottleSetting() { TrackSpline track = straightTrack(false); @@ -816,6 +937,10 @@ private TrainHandler consist(TrackSpline track, double s) { } private TrainHandler car() { + return car(List.of()); + } + + private TrainHandler car(List wheels) { ActiveVehicle vehicle = stub(ActiveVehicle.class); Entity entity = stub(Entity.class); Location[] location = {new Location(null, 0, 64, 0)}; @@ -837,7 +962,11 @@ private TrainHandler car() { when(vehicle.getModel()).thenReturn(model); Connector front = connector(vehicle, model, "front", 5); Connector back = connector(vehicle, model, "back", -5); - TrainHandler handler = new TrainHandler(new YamlConfiguration()) { + connector(vehicle, model, "axle_front", 2.875f); + connector(vehicle, model, "axle_back", -2.875f); + YamlConfiguration config = new YamlConfiguration(); + config.set("wheel-bones", wheels); + TrainHandler handler = new TrainHandler(config) { @Override public boolean isAttachable() { return true; } @Override public boolean canHaveAttached() { return true; } @Override public Connector getFront() { return front; }