From db5370eaf1f77f3cccd3cd9038bd9a42c857da76 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:29:58 +0000 Subject: [PATCH 1/3] feat: rest carriages on two bogies that follow the rail A carriage was one rigid body placed and tilted at the track under its centre, so on bends and changes of grade the wheels at each end left the rail. With behaviour.train.bogies naming its two bogie bones, a carriage now rests on the rail under each bogie: the body lies along the line between them, and each bogie bone turns and tilts to the rail under it, as real coaches do. Such cars skip the coupler snap from #36; their couplers swing to meet instead. Bogie bones must pivot at the bogie centre and the body rotator at the model origin. The bundled passenger car uses it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../vehicles/handlers/TrainHandler.java | 32 ++++- .../vehicles/handlers/train/Bogies.java | 131 ++++++++++++++++++ src/main/resources/vehicles/passenger_car.yml | 4 + .../vehicles/handlers/train/BogiesTest.java | 97 +++++++++++++ 4 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java 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 0f6839d..b0bdcd8 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java @@ -55,6 +55,7 @@ import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; import net.tfminecraft.vehicleframework.vehicles.component.fuel.FuelTank; import net.tfminecraft.vehicleframework.vehicles.handlers.container.Container; +import net.tfminecraft.vehicleframework.vehicles.handlers.train.Bogies; import net.tfminecraft.vehicleframework.vehicles.handlers.train.Connector; import net.tfminecraft.vehicleframework.vehicles.handlers.train.LocomotiveOverdrive; @@ -91,10 +92,15 @@ 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; + // Two-bogie carriages rest on the rail under each bogie instead of their centre. + private Bogies bogies; public TrainHandler(ConfigurationSection config) { locomotive = config.getBoolean("locomotive", false); wheelDiameter = Math.max(0, config.getDouble("wheel-diameter", 0)); + if (config.contains("bogies")) { + bogies = new Bogies(config.getStringList("bogies")); + } if(config.contains("front-connector")) { front = new Connector(config.getString("front-connector")); } @@ -114,6 +120,9 @@ public TrainHandler(ActiveVehicle v, TrainHandler another) { locomotive = another.locomotive; wheelDiameter = another.wheelDiameter; this.v = v; + if (another.bogies != null) { + bogies = new Bogies(v, another.bogies); + } if(another.isAttachable()) { front = new Connector(v, another.getFront()); } @@ -140,6 +149,9 @@ public LocomotiveOverdrive getOverdrive() { } public void updateModel(ActiveModel m) { + if (bogies != null) { + bogies.updateModel(m); + } if(isAttachable()) { front.updateModel(m); } @@ -775,16 +787,28 @@ private void applyPlacements(List placements) { train.s = placement.s; train.travelSign = placement.sign; applyPose(placement.vehicle, placement.pose()); + if (train.onBogies()) { + train.bogies.follow(placement.spline, placement.s, placement.pose()); + } } } + private boolean onBogies() { + return bogies != null && bogies.isReady(); + } + + // A car's pose at s: on its two bogies if it has them, else the track under its centre. + private TrackPose carPose(TrackSpline spline, double at) { + return onBogies() ? bogies.bodyPose(spline, at) : spline.sampleAt(at); + } + private List planCars() { List placements = new ArrayList<>(); TrackSpline spline = boundSpline(); if (spline == null) { return placements; } - placements.add(new CarPlacement(v, spline, s, travelSign, 0, spline.sampleAt(s))); + placements.add(new CarPlacement(v, spline, s, travelSign, 0, carPose(spline, s))); TrackRegistry registry = VehicleFramework.getTrackRegistry(); if (registry == null) { return placements; @@ -836,8 +860,10 @@ private List planCars() { } TrackSpline carSpline = pose.splineId == null ? null : registry.get(pose.splineId).orElse(null); if (carSpline != null) { - TrackPose carPose = carSpline.sampleAt(pose.s); - if (parentTrain.canHaveAttached() && carTrain.isAttachable() && pose.missingSpacing <= 1e-9) { + TrackPose carPose = carTrain.carPose(carSpline, pose.s); + // A car on bogies follows its own rails; its couplers swing to meet, as real ones do. + if (!carTrain.onBogies() && parentTrain.canHaveAttached() && carTrain.isAttachable() + && pose.missingSpacing <= 1e-9) { // Arc spacing locates the car on the route; rigid couplers must meet // in world space. A separate tangent at each centre opens a gap on bends. TrackPose parentPose = placements.get(placements.size() - 1).pose(); diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java new file mode 100644 index 0000000..23f9861 --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java @@ -0,0 +1,131 @@ +package net.tfminecraft.vehicleframework.vehicles.handlers.train; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.util.Vector; + +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.bone.ModelBone; + +import net.tfminecraft.vehicleframework.bones.BoneRotator; +import net.tfminecraft.vehicleframework.bones.ConvertedAngle; +import net.tfminecraft.vehicleframework.bones.RotationLimits; +import net.tfminecraft.vehicleframework.tracks.TrackPose; +import net.tfminecraft.vehicleframework.tracks.TrackSpline; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +/** + * A carriage on two bogies, as real coaches are built. The body rests on the rail under + * each bogie, so it lies along the line between them, and each bogie turns and tilts to + * follow the rail under it. Bogie bones must pivot at the bogie's centre, and the body + * rotator must pivot at the model's origin. + */ +public final class Bogies { + private final List bones; + private ActiveVehicle v; + private final List rotators = new ArrayList<>(); + // Along the car from the model's origin, in blocks; +z faces +s. + private double[] offsets; + + public Bogies(List bones) { + this.bones = List.copyOf(bones); + } + + public Bogies(ActiveVehicle v, Bogies another) { + this.bones = another.bones; + this.v = v; + } + + /** Whether this car has two bogies and its model is loaded. */ + public boolean isReady() { + if (bones.size() != 2 || v == null) { + return false; + } + if (offsets != null) { + return true; + } + try { + ActiveModel model = v.getModel(); + double[] found = new double[2]; + List made = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + ModelBone bone = model.getBone(bones.get(i)).orElseThrow(); + found[i] = bone.getBlueprintBone().getRotatedGlobalPosition().z() * model.getScale().z(); + made.add(new BoneRotator(v, v.getEntity(), bone, new RotationLimits())); + } + rotators.addAll(made); + offsets = found; + return true; + } catch (RuntimeException notLoaded) { + // The model or its bones are not there yet; place the car as a rigid one. + return false; + } + } + + public void updateModel(ActiveModel model) { + for (BoneRotator rotator : rotators) { + rotator.updateModel(model); + } + } + + /** Where the car's origin goes so both bogies sit on the rail around {@code s}. */ + public TrackPose bodyPose(TrackSpline spline, double s) { + return bodyPose(spline, s, offsets[0], offsets[1]); + } + + /** Turns each bogie to the rail under it, relative to the body. */ + public void follow(TrackSpline spline, double s, TrackPose body) { + for (int i = 0; i < rotators.size(); i++) { + float[] turn = turn(body, rail(spline, s + offsets[i])); + rotators.get(i).rotateToTarget(turn[0], turn[1], 0f, 1f, true, true, false); + } + } + + static TrackPose bodyPose(TrackSpline spline, double s, double first, double second) { + double front = Math.max(first, second); + double back = Math.min(first, second); + if (front - back < 1e-6) { + return rail(spline, s + front); + } + TrackPose a = rail(spline, s + front); + TrackPose b = rail(spline, s + back); + Vector along = new Vector(a.x - b.x, a.y - b.y, a.z - b.z); + double length = along.length(); + if (length < 1e-6) { + return rail(spline, s); + } + along.multiply(1 / length); + // The body is the straight line between the two bogie centres; the origin sits + // where it would along that line. + double middle = (front + back) / 2; + double x = (a.x + b.x) / 2 - along.getX() * middle; + double y = (a.y + b.y) / 2 - along.getY() * middle; + double z = (a.z + b.z) / 2 - along.getZ() * middle; + float yaw = (float) Math.toDegrees(Math.atan2(-along.getX(), along.getZ())); + float pitch = (float) Math.toDegrees(Math.atan2(-along.getY(), Math.hypot(along.getX(), along.getZ()))); + return new TrackPose(x, y, z, yaw, pitch); + } + + /** Yaw and pitch that turn a bogie on this body to the rail under it, as bone angles. */ + static float[] turn(TrackPose body, TrackPose rail) { + float yaw = ConvertedAngle.wrapDegrees(-(rail.yaw - body.yaw)); + return new float[] {yaw, rail.pitch - body.pitch}; + } + + // The rail at a distance along the track, carrying on straight past its ends. + static TrackPose rail(TrackSpline spline, double at) { + double end = spline.length(); + double clamped = spline.isLoop() ? at : Math.max(0, Math.min(end, at)); + TrackPose pose = spline.sampleAt(clamped); + double past = at - clamped; + if (Math.abs(past) < 1e-9) { + return pose; + } + double yaw = Math.toRadians(pose.yaw); + double pitch = Math.toRadians(pose.pitch); + double horizontal = Math.cos(pitch) * past; + return new TrackPose(pose.x - Math.sin(yaw) * horizontal, pose.y - Math.sin(pitch) * past, + pose.z + Math.cos(yaw) * horizontal, pose.yaw, pose.pitch); + } +} diff --git a/src/main/resources/vehicles/passenger_car.yml b/src/main/resources/vehicles/passenger_car.yml index 81e0244..4a63c3a 100644 --- a/src/main/resources/vehicles/passenger_car.yml +++ b/src/main/resources/vehicles/passenger_car.yml @@ -15,6 +15,10 @@ passenger_car: train: # Blocks across the wheels, so they turn at the train's speed. wheel-diameter: 1.0 + # Rest the body on its two bogies, each following its own rail. + bogies: + - bogey + - bogey2 front-connector: connector_front back-connector: connector_back states: diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java new file mode 100644 index 0000000..1eac711 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java @@ -0,0 +1,97 @@ +package net.tfminecraft.vehicleframework.vehicles.handlers.train; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import net.tfminecraft.vehicleframework.tracks.TrackPose; +import net.tfminecraft.vehicleframework.tracks.TrackSpline; + +class BogiesTest { + // Passenger car bogies, 34 px either side of the middle. + private static final double FRONT = 34.0 / 16; + private static final double BACK = -34.0 / 16; + + @Test + void onStraightLevelTrackTheCarSitsOverItsCentre() { + TrackSpline line = spline(new double[]{0.5, 64, 0}, new double[]{0.5, 64, 40}); + TrackPose body = Bogies.bodyPose(line, 20, FRONT, BACK); + TrackPose centre = line.sampleAt(20); + assertEquals(centre.x, body.x, 1e-6); + assertEquals(centre.y, body.y, 1e-6); + assertEquals(centre.z, body.z, 1e-6); + assertEquals(centre.yaw, body.yaw, 1e-3); + assertEquals(0, body.pitch, 1e-6); + } + + @ParameterizedTest + @ValueSource(doubles = {17, 19, 20, 21, 23}) + void acrossAChangeOfGradeBothBogiesStayOnTheRail(double s) { + // Level for 20 blocks, then down at 10 degrees. + TrackSpline line = spline(new double[]{0.5, 64, 0}, new double[]{0.5, 64, 20}, + new double[]{0.5, 64 - 20 * Math.tan(Math.toRadians(10)), 40}); + TrackPose body = Bogies.bodyPose(line, s, FRONT, BACK); + assertOnRail(line, body, s, FRONT, 0.02); + assertOnRail(line, body, s, BACK, 0.02); + assertTrue(body.pitch >= -1e-3 && body.pitch <= 10.01, "Body pitch " + body.pitch); + } + + @ParameterizedTest + @ValueSource(doubles = {10, 25, 40}) + void roundABendBothBogiesStayOnTheRail(double s) { + List arc = new ArrayList<>(); + for (int i = 0; i <= 30; i++) { + double a = Math.toRadians(i * 3); + arc.add(new double[]{0.5 + 25 * (1 - Math.cos(a)), 64, 25 * Math.sin(a)}); + } + TrackSpline bend = TrackSpline.fromPoints(UUID.randomUUID(), "world", false, arc); + TrackPose body = Bogies.bodyPose(bend, s, FRONT, BACK); + assertOnRail(bend, body, s, FRONT, 0.05); + assertOnRail(bend, body, s, BACK, 0.05); + // The body is the chord between the bogies, so its middle sits inside the curve. + TrackPose centre = bend.sampleAt(s); + assertTrue(Math.hypot(body.x - centre.x, body.z - centre.z) > 0.01); + } + + @Test + void pastTheEndOfTheTrackTheBogieCarriesOnStraight() { + TrackSpline line = spline(new double[]{0.5, 64, 0}, new double[]{0.5, 64, 10}); + TrackPose body = Bogies.bodyPose(line, 9.5, FRONT, BACK); + assertEquals(9.5, body.z, 1e-6); + assertEquals(0, body.pitch, 1e-6); + } + + @Test + void bogieTurnsByTheDifferenceBetweenRailAndBody() { + TrackPose body = new TrackPose(0, 64, 0, 10, 2); + TrackPose rail = new TrackPose(0, 64, 2, 15, 5); + float[] turn = Bogies.turn(body, rail); + assertEquals(-5, turn[0], 1e-5); + assertEquals(3, turn[1], 1e-5); + float[] across = Bogies.turn(new TrackPose(0, 64, 0, 179, 0), new TrackPose(0, 64, 0, -179, 0)); + assertEquals(-2, across[0], 1e-4); + } + + // The bogie centre, placed from the body pose, lies on the rail at its own distance along. + private static void assertOnRail(TrackSpline line, TrackPose body, double s, double offset, double tolerance) { + double yaw = Math.toRadians(body.yaw); + double pitch = Math.toRadians(body.pitch); + double x = body.x - Math.sin(yaw) * Math.cos(pitch) * offset; + double y = body.y - Math.sin(pitch) * offset; + double z = body.z + Math.cos(yaw) * Math.cos(pitch) * offset; + TrackPose rail = Bogies.rail(line, s + offset); + double miss = Math.sqrt((x - rail.x) * (x - rail.x) + (y - rail.y) * (y - rail.y) + (z - rail.z) * (z - rail.z)); + assertTrue(miss < tolerance, "Bogie at " + offset + " misses the rail by " + miss); + } + + private static TrackSpline spline(double[]... points) { + return TrackSpline.fromPoints(UUID.randomUUID(), "world", false, List.of(points)); + } +} From 6bdd53c621b6d99812282d79a842d4699301be20 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:50:13 +0000 Subject: [PATCH 2/3] fix: find each bogie's rail along the junction route A car can straddle a junction, with one bogie on the stem and the other on the branch. Walk from the car's centre along the consist's route to each bogie with TrackJunctionTravel.rewind, carrying on straight past the route's ends, and use those two rails for both the body pose and the bogie turns. A bogie on a track laid the other way turns as if its rail faced the body's way. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../vehicles/handlers/TrainHandler.java | 77 ++++++++++++++----- .../vehicles/handlers/train/Bogies.java | 46 +++++++---- .../vehicles/handlers/train/BogiesTest.java | 25 ++++++ 3 files changed, 116 insertions(+), 32 deletions(-) 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 b0bdcd8..cfb87cf 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/TrainHandler.java @@ -777,7 +777,48 @@ private void followTrackUnderEntity() { } private record CarPlacement(ActiveVehicle vehicle, TrackSpline spline, double s, int sign, - double missingSpacing, TrackPose pose) { + 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); + } + } + + // 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) { + /** 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, + Math.abs(offset), takeBranch, stemId == null ? splineId : stemId, branchId, junctionS, + facingSign, stemLength, stemLoop, branchLength); + TrackSpline spline = at.splineId == null ? null : registry.get(at.splineId).orElse(null); + if (spline == null) { + return null; + } + if (at.missingSpacing <= 1e-9) { + return spline.sampleAt(at.s); + } + // Past the end of the route: carry on straight off that end. + double outwards = at.s <= 1e-9 ? -1 : 1; + return Bogies.rail(spline, at.s + outwards * at.missingSpacing); + } + } + + // A bogie car's two rails along the route, or null to place it as a rigid car. + private TrackPose[] bogieRails(Route route, UUID onSpline, double at) { + if (!onBogies() || route == null) { + return null; + } + double[] offsets = bogies.offsets(); + TrackPose[] rails = new TrackPose[offsets.length]; + for (int i = 0; i < offsets.length; i++) { + rails[i] = route.rail(onSpline, at, offsets[i]); + if (rails[i] == null) { + return null; + } + } + return rails; } private void applyPlacements(List placements) { @@ -787,8 +828,8 @@ private void applyPlacements(List placements) { train.s = placement.s; train.travelSign = placement.sign; applyPose(placement.vehicle, placement.pose()); - if (train.onBogies()) { - train.bogies.follow(placement.spline, placement.s, placement.pose()); + if (placement.bogieRails() != null) { + train.bogies.follow(placement.bogieRails(), placement.pose()); } } } @@ -797,32 +838,31 @@ private boolean onBogies() { return bogies != null && bogies.isReady(); } - // A car's pose at s: on its two bogies if it has them, else the track under its centre. - private TrackPose carPose(TrackSpline spline, double at) { - return onBogies() ? bogies.bodyPose(spline, at) : spline.sampleAt(at); - } - private List planCars() { List placements = new ArrayList<>(); TrackSpline spline = boundSpline(); if (spline == null) { return placements; } - placements.add(new CarPlacement(v, spline, s, travelSign, 0, carPose(spline, s))); TrackRegistry registry = VehicleFramework.getTrackRegistry(); - if (registry == null) { - return placements; - } - TrackJunction route = routeJunction(); + TrackJunction route = registry == null ? null : routeJunction(); UUID stemId = route == null ? null : route.stemSplineId; UUID branchId = route == null ? null : route.branchSplineId; double junctionS = route == null ? 0 : route.s; int facingSign = route == null ? 1 : route.facingSign; - TrackSpline stem = stemId == null ? null : registry.get(stemId).orElse(null); - TrackSpline branch = branchId == null ? null : registry.get(branchId).orElse(null); + TrackSpline stem = stemId == null || registry == null ? null : registry.get(stemId).orElse(null); + TrackSpline branch = branchId == null || registry == null ? null : registry.get(branchId).orElse(null); double stemLength = stem == null ? spline.length() : stem.length(); boolean stemLoop = stem != null ? stem.isLoop() : spline.isLoop(); double branchLength = branch == null ? 0 : branch.length(); + Route along = registry == null ? null : new Route(registry, takeBranch && route != null, + 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)); + if (registry == null) { + return placements; + } UUID parentSpline = splineId; double parentS = s; // Models face the +s tangent even in reverse. Couplers stay on that @@ -860,9 +900,10 @@ private List planCars() { } TrackSpline carSpline = pose.splineId == null ? null : registry.get(pose.splineId).orElse(null); if (carSpline != null) { - TrackPose carPose = carTrain.carPose(carSpline, pose.s); + TrackPose[] rails = carTrain.bogieRails(along, pose.splineId, pose.s); + TrackPose carPose = rails == null ? carSpline.sampleAt(pose.s) : carTrain.bogies.bodyPose(rails); // A car on bogies follows its own rails; its couplers swing to meet, as real ones do. - if (!carTrain.onBogies() && parentTrain.canHaveAttached() && carTrain.isAttachable() + if (rails == null && parentTrain.canHaveAttached() && carTrain.isAttachable() && pose.missingSpacing <= 1e-9) { // Arc spacing locates the car on the route; rigid couplers must meet // in world space. A separate tangent at each centre opens a gap on bends. @@ -873,7 +914,7 @@ 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)); + placements.add(new CarPlacement(car, carSpline, pose.s, carTravelSign, pose.missingSpacing, carPose, rails)); } else { return List.of(); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java index 23f9861..35c7082 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java @@ -69,31 +69,42 @@ public void updateModel(ActiveModel model) { } } - /** Where the car's origin goes so both bogies sit on the rail around {@code s}. */ - public TrackPose bodyPose(TrackSpline spline, double s) { - return bodyPose(spline, s, offsets[0], offsets[1]); + /** Along the car from the model's origin to each bogie, in blocks, +z facing +s. */ + public double[] offsets() { + return offsets.clone(); + } + + /** Where the car's origin goes so each bogie sits on its rail, in {@link #offsets()} order. */ + public TrackPose bodyPose(TrackPose[] rails) { + return bodyPose(rails[0], rails[1], offsets[0], offsets[1]); } /** Turns each bogie to the rail under it, relative to the body. */ - public void follow(TrackSpline spline, double s, TrackPose body) { + public void follow(TrackPose[] rails, TrackPose body) { for (int i = 0; i < rotators.size(); i++) { - float[] turn = turn(body, rail(spline, s + offsets[i])); + float[] turn = turn(body, rails[i]); rotators.get(i).rotateToTarget(turn[0], turn[1], 0f, 1f, true, true, false); } } static TrackPose bodyPose(TrackSpline spline, double s, double first, double second) { + return bodyPose(rail(spline, s + first), rail(spline, s + second), first, second); + } + + /** The body resting on two rails, each under the bogie at its offset along the car. */ + static TrackPose bodyPose(TrackPose firstRail, TrackPose secondRail, double first, double second) { + if (Math.abs(first - second) < 1e-6) { + return firstRail; + } + boolean firstAhead = first > second; + TrackPose a = firstAhead ? firstRail : secondRail; + TrackPose b = firstAhead ? secondRail : firstRail; double front = Math.max(first, second); double back = Math.min(first, second); - if (front - back < 1e-6) { - return rail(spline, s + front); - } - TrackPose a = rail(spline, s + front); - TrackPose b = rail(spline, s + back); Vector along = new Vector(a.x - b.x, a.y - b.y, a.z - b.z); double length = along.length(); if (length < 1e-6) { - return rail(spline, s); + return a; } along.multiply(1 / length); // The body is the straight line between the two bogie centres; the origin sits @@ -109,12 +120,19 @@ static TrackPose bodyPose(TrackSpline spline, double s, double first, double sec /** Yaw and pitch that turn a bogie on this body to the rail under it, as bone angles. */ static float[] turn(TrackPose body, TrackPose rail) { - float yaw = ConvertedAngle.wrapDegrees(-(rail.yaw - body.yaw)); - return new float[] {yaw, rail.pitch - body.pitch}; + float railYaw = rail.yaw; + float railPitch = rail.pitch; + // A bogie on a track laid the other way sees its rail heading backwards. + if (Math.abs(ConvertedAngle.wrapDegrees(railYaw - body.yaw)) > 90) { + railYaw += 180; + railPitch = -railPitch; + } + float yaw = ConvertedAngle.wrapDegrees(-(railYaw - body.yaw)); + return new float[] {yaw, railPitch - body.pitch}; } // The rail at a distance along the track, carrying on straight past its ends. - static TrackPose rail(TrackSpline spline, double at) { + public static TrackPose rail(TrackSpline spline, double at) { double end = spline.length(); double clamped = spline.isLoop() ? at : Math.max(0, Math.min(end, at)); TrackPose pose = spline.sampleAt(clamped); diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java index 1eac711..e56d19c 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java @@ -79,6 +79,31 @@ void bogieTurnsByTheDifferenceBetweenRailAndBody() { assertEquals(-2, across[0], 1e-4); } + @Test + void bogieOnTrackLaidTheOtherWayTurnsTheSameAsOnOurs() { + TrackPose body = new TrackPose(0, 64, 0, 10, 2); + float[] same = Bogies.turn(body, new TrackPose(0, 64, 2, 15, 5)); + float[] reversed = Bogies.turn(body, new TrackPose(0, 64, 2, 15 - 180, -5)); + assertEquals(same[0], reversed[0], 1e-4); + assertEquals(same[1], reversed[1], 1e-4); + } + + @Test + void bodyRestsOnTwoRailsGivenSeparately() { + // As if the front bogie had crossed onto a branch: its rail comes from another track. + TrackPose front = new TrackPose(0.5 + 0.3, 64, 22.125, -8f, 0f); + TrackPose back = new TrackPose(0.5, 64, 17.875, 0f, 0f); + TrackPose body = Bogies.bodyPose(front, back, FRONT, BACK); + assertEquals(0.65, body.x, 1e-6); + assertEquals(20, body.z, 1e-6); + double yaw = Math.toDegrees(Math.atan2(-0.3, 4.25)); + assertEquals(yaw, body.yaw, 1e-4); + // Order of the rails follows the offsets, not which is ahead. + TrackPose swapped = Bogies.bodyPose(back, front, BACK, FRONT); + assertEquals(body.x, swapped.x, 1e-9); + assertEquals(body.yaw, swapped.yaw, 1e-6); + } + // The bogie centre, placed from the body pose, lies on the rail at its own distance along. private static void assertOnRail(TrackSpline line, TrackPose body, double s, double offset, double tolerance) { double yaw = Math.toRadians(body.yaw); From cc1c8fa680f99a33a33af31cc653184d5beec05b Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:58:48 +0000 Subject: [PATCH 3/3] fix: refuse bogie placement for bogies at one point along the car Two bogies at the same offset cannot set a body's angle, so such cars stay rigid. The degenerate pose also keeps the model origin back along the rail by the shared offset instead of on the rail point. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../vehicles/handlers/train/Bogies.java | 33 ++++++++++++------- .../vehicles/handlers/train/BogiesTest.java | 8 +++++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java index 35c7082..dcbff67 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/Bogies.java @@ -22,6 +22,8 @@ * rotator must pivot at the model's origin. */ public final class Bogies { + // Bogies closer than this along the car cannot set the body's angle. + static final double MIN_SPAN = 0.25; private final List bones; private ActiveVehicle v; private final List rotators = new ArrayList<>(); @@ -49,9 +51,16 @@ public boolean isReady() { ActiveModel model = v.getModel(); double[] found = new double[2]; List made = new ArrayList<>(); + ModelBone[] foundBones = new ModelBone[2]; for (int i = 0; i < 2; i++) { - ModelBone bone = model.getBone(bones.get(i)).orElseThrow(); - found[i] = bone.getBlueprintBone().getRotatedGlobalPosition().z() * model.getScale().z(); + foundBones[i] = model.getBone(bones.get(i)).orElseThrow(); + found[i] = foundBones[i].getBlueprintBone().getRotatedGlobalPosition().z() * model.getScale().z(); + } + if (Math.abs(found[0] - found[1]) < MIN_SPAN) { + // Both bogies at one point along the car: nothing to rest the body between. + return false; + } + for (ModelBone bone : foundBones) { made.add(new BoneRotator(v, v.getEntity(), bone, new RotationLimits())); } rotators.addAll(made); @@ -94,7 +103,8 @@ static TrackPose bodyPose(TrackSpline spline, double s, double first, double sec /** The body resting on two rails, each under the bogie at its offset along the car. */ static TrackPose bodyPose(TrackPose firstRail, TrackPose secondRail, double first, double second) { if (Math.abs(first - second) < 1e-6) { - return firstRail; + // One point to rest on: keep the origin back along the rail from it. + return shifted(firstRail, -first); } boolean firstAhead = first > second; TrackPose a = firstAhead ? firstRail : secondRail; @@ -131,19 +141,20 @@ static float[] turn(TrackPose body, TrackPose rail) { return new float[] {yaw, railPitch - body.pitch}; } + private static TrackPose shifted(TrackPose pose, double along) { + double yaw = Math.toRadians(pose.yaw); + double pitch = Math.toRadians(pose.pitch); + double horizontal = Math.cos(pitch) * along; + return new TrackPose(pose.x - Math.sin(yaw) * horizontal, pose.y - Math.sin(pitch) * along, + pose.z + Math.cos(yaw) * horizontal, pose.yaw, pose.pitch); + } + // The rail at a distance along the track, carrying on straight past its ends. public static TrackPose rail(TrackSpline spline, double at) { double end = spline.length(); double clamped = spline.isLoop() ? at : Math.max(0, Math.min(end, at)); TrackPose pose = spline.sampleAt(clamped); double past = at - clamped; - if (Math.abs(past) < 1e-9) { - return pose; - } - double yaw = Math.toRadians(pose.yaw); - double pitch = Math.toRadians(pose.pitch); - double horizontal = Math.cos(pitch) * past; - return new TrackPose(pose.x - Math.sin(yaw) * horizontal, pose.y - Math.sin(pitch) * past, - pose.z + Math.cos(yaw) * horizontal, pose.yaw, pose.pitch); + return Math.abs(past) < 1e-9 ? pose : shifted(pose, past); } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java index e56d19c..0872516 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/train/BogiesTest.java @@ -104,6 +104,14 @@ void bodyRestsOnTwoRailsGivenSeparately() { assertEquals(body.yaw, swapped.yaw, 1e-6); } + @Test + void bogiesAtOnePointKeepTheOriginOffTheirRail() { + TrackSpline line = spline(new double[]{0.5, 64, 0}, new double[]{0.5, 64, 40}); + TrackPose body = Bogies.bodyPose(line, 20, 1.5, 1.5); + assertEquals(20, body.z, 1e-6); + assertOnRail(line, body, 20, 1.5, 1e-6); + } + // The bogie centre, placed from the body pose, lies on the rail at its own distance along. private static void assertOnRail(TrackSpline line, TrackPose body, double s, double offset, double tolerance) { double yaw = Math.toRadians(body.yaw);