Phase 2: Core math (BezierCurve, Vec3d) and road data structures (RoadProfile, RoadSegment)

This commit is contained in:
bnair123
2025-12-31 01:14:49 +04:00
parent 9a44e600ea
commit ce691684d2
7 changed files with 549 additions and 5 deletions

View File

@@ -0,0 +1,201 @@
package com.bnair.roadrunner.math
/**
* A Cubic Bezier Curve in 3D space.
*
* The curve is defined by 4 control points:
* - p0: Start point (the curve passes through this)
* - p1: First control point (influences curve direction from start)
* - p2: Second control point (influences curve direction towards end)
* - p3: End point (the curve passes through this)
*
* The curve is parametrized by t in [0, 1].
*/
data class BezierCurve(
val p0: Vec3d,
val p1: Vec3d,
val p2: Vec3d,
val p3: Vec3d
) {
/**
* Evaluates the curve at parameter t (0 to 1).
* Uses the cubic Bezier formula: B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3
*/
fun evaluate(t: Double): Vec3d {
val u = 1.0 - t
val tt = t * t
val uu = u * u
val uuu = uu * u
val ttt = tt * t
return p0 * uuu +
p1 * (3.0 * uu * t) +
p2 * (3.0 * u * tt) +
p3 * ttt
}
/**
* Computes the tangent (first derivative) at parameter t.
* This gives the direction of the curve at that point.
* B'(t) = 3(1-t)²(P1-P0) + 6(1-t)t(P2-P1) + 3t²(P3-P2)
*/
fun tangent(t: Double): Vec3d {
val u = 1.0 - t
val uu = u * u
val tt = t * t
return (p1 - p0) * (3.0 * uu) +
(p2 - p1) * (6.0 * u * t) +
(p3 - p2) * (3.0 * tt)
}
/**
* Computes a normalized tangent (unit direction vector) at parameter t.
*/
fun direction(t: Double): Vec3d = tangent(t).normalize()
/**
* Computes the second derivative (acceleration) at parameter t.
* Useful for calculating curvature and banking.
* B''(t) = 6(1-t)(P2-2P1+P0) + 6t(P3-2P2+P1)
*/
fun acceleration(t: Double): Vec3d {
val u = 1.0 - t
return (p2 - p1 * 2.0 + p0) * (6.0 * u) +
(p3 - p2 * 2.0 + p1) * (6.0 * t)
}
/**
* Computes the curvature at parameter t.
* Curvature = |B'(t) × B''(t)| / |B'(t)|³
* Higher curvature = sharper turn = more banking needed.
*/
fun curvature(t: Double): Double {
val d1 = tangent(t)
val d2 = acceleration(t)
val cross = d1.cross(d2)
val d1Len = d1.length()
return if (d1Len > 0.0) cross.length() / (d1Len * d1Len * d1Len) else 0.0
}
/**
* Samples the curve into discrete points.
* @param segments Number of segments (returns segments + 1 points)
* @return List of points along the curve
*/
fun sample(segments: Int): List<Vec3d> {
require(segments > 0) { "Segments must be positive" }
return (0..segments).map { i ->
evaluate(i.toDouble() / segments)
}
}
/**
* Samples the curve adaptively based on curvature.
* Straighter sections get fewer samples, curves get more.
* @param minSegments Minimum number of segments
* @param maxSegments Maximum number of segments
* @param curvatureThreshold How sensitive to curvature changes
*/
fun sampleAdaptive(
minSegments: Int = 10,
maxSegments: Int = 100,
curvatureThreshold: Double = 0.1
): List<Vec3d> {
val points = mutableListOf<Vec3d>()
points.add(p0)
fun subdivide(tStart: Double, tEnd: Double, depth: Int) {
if (depth > 10) { // Prevent infinite recursion
points.add(evaluate(tEnd))
return
}
val tMid = (tStart + tEnd) / 2.0
val curveMid = curvature(tMid)
// If curvature is high or segment is large, subdivide
val segmentLength = (evaluate(tEnd) - evaluate(tStart)).length()
if (curveMid > curvatureThreshold && segmentLength > 1.0 && points.size < maxSegments) {
subdivide(tStart, tMid, depth + 1)
subdivide(tMid, tEnd, depth + 1)
} else {
points.add(evaluate(tEnd))
}
}
val initialSegments = minSegments.coerceAtLeast(4)
for (i in 0 until initialSegments) {
val tStart = i.toDouble() / initialSegments
val tEnd = (i + 1).toDouble() / initialSegments
subdivide(tStart, tEnd, 0)
}
return points.distinct()
}
/**
* Approximates the total arc length of the curve.
* Uses numerical integration (sampling).
*/
fun arcLength(segments: Int = 100): Double {
var length = 0.0
var prev = p0
for (i in 1..segments) {
val curr = evaluate(i.toDouble() / segments)
length += (curr - prev).length()
prev = curr
}
return length
}
/**
* Creates a new curve that smoothly continues from this one.
* The new curve starts at this curve's end with matching tangent.
* @param endPoint Where the new curve should end
* @param endTangentScale How far the control point extends (affects curve shape)
*/
fun continueTo(endPoint: Vec3d, endTangentScale: Double = 0.3): BezierCurve {
val startTangent = tangent(1.0).normalize()
val newP1 = p3 + startTangent * (p3 - p2).length() // Match incoming tangent
val curveLength = (endPoint - p3).length()
val newP2 = endPoint - (endPoint - newP1).normalize() * (curveLength * endTangentScale)
return BezierCurve(p3, newP1, newP2, endPoint)
}
companion object {
/**
* Creates a simple straight line as a degenerate Bezier curve.
*/
fun line(start: Vec3d, end: Vec3d): BezierCurve {
val third = (end - start) / 3.0
return BezierCurve(
start,
start + third,
end - third,
end
)
}
/**
* Creates a curve from start to end with specified start and end tangents.
* This is useful for connecting road segments with specific angles.
*/
fun fromTangents(
start: Vec3d,
startTangent: Vec3d,
end: Vec3d,
endTangent: Vec3d,
tangentScale: Double = 0.3
): BezierCurve {
val distance = (end - start).length()
val scale = distance * tangentScale
return BezierCurve(
start,
start + startTangent.normalize() * scale,
end - endTangent.normalize() * scale,
end
)
}
}
}

View File

@@ -0,0 +1,63 @@
package com.bnair.roadrunner.math
import kotlin.math.sqrt
/**
* A simple 3D vector class for road calculations.
* Using our own to avoid loader-specific dependencies in core math.
*/
data class Vec3d(
val x: Double,
val y: Double,
val z: Double
) {
operator fun plus(other: Vec3d) = Vec3d(x + other.x, y + other.y, z + other.z)
operator fun minus(other: Vec3d) = Vec3d(x - other.x, y - other.y, z - other.z)
operator fun times(scalar: Double) = Vec3d(x * scalar, y * scalar, z * scalar)
operator fun div(scalar: Double) = Vec3d(x / scalar, y / scalar, z / scalar)
fun dot(other: Vec3d): Double = x * other.x + y * other.y + z * other.z
fun cross(other: Vec3d) = Vec3d(
y * other.z - z * other.y,
z * other.x - x * other.z,
x * other.y - y * other.x
)
fun length(): Double = sqrt(x * x + y * y + z * z)
fun lengthSquared(): Double = x * x + y * y + z * z
fun normalize(): Vec3d {
val len = length()
return if (len > 0.0) this / len else this
}
fun lerp(other: Vec3d, t: Double): Vec3d {
return this + (other - this) * t
}
/**
* Returns a perpendicular vector in the XZ plane (horizontal).
* Useful for calculating road width offsets.
*/
fun perpendicularXZ(): Vec3d {
return Vec3d(-z, 0.0, x).normalize()
}
/**
* Converts to block coordinates (floored integers).
*/
fun toBlockPos(): Triple<Int, Int, Int> {
return Triple(
kotlin.math.floor(x).toInt(),
kotlin.math.floor(y).toInt(),
kotlin.math.floor(z).toInt()
)
}
companion object {
val ZERO = Vec3d(0.0, 0.0, 0.0)
val UP = Vec3d(0.0, 1.0, 0.0)
}
}

View File

@@ -0,0 +1,131 @@
package com.bnair.roadrunner.road
import com.bnair.roadrunner.math.Vec3d
enum class LaneType {
DRIVING,
PUBLIC_TRANSPORT,
PEDESTRIAN,
SHOULDER,
MEDIAN_BARRIER,
MEDIAN_GRASS
}
enum class TerrainMode {
FOLLOW_TERRAIN,
FLAT_CUT_FILL
}
data class LaneDefinition(
val type: LaneType,
val width: Double,
val material: String = when (type) {
LaneType.DRIVING -> "minecraft:gray_concrete"
LaneType.PUBLIC_TRANSPORT -> "minecraft:yellow_concrete"
LaneType.PEDESTRIAN -> "minecraft:stone_bricks"
LaneType.SHOULDER -> "minecraft:gravel"
LaneType.MEDIAN_BARRIER -> "minecraft:stone_brick_wall"
LaneType.MEDIAN_GRASS -> "minecraft:grass_block"
}
)
data class RoadProfile(
val lanesLeft: List<LaneDefinition>,
val lanesRight: List<LaneDefinition>,
val median: LaneDefinition?,
val terrainMode: TerrainMode = TerrainMode.FLAT_CUT_FILL,
val elevationOffset: Double = 0.0,
val bankingEnabled: Boolean = true,
val maxBankingAngle: Double = 15.0
) {
val totalWidth: Double
get() {
val leftWidth = lanesLeft.sumOf { it.width }
val rightWidth = lanesRight.sumOf { it.width }
val medianWidth = median?.width ?: 0.0
return leftWidth + rightWidth + medianWidth
}
fun getOffsetForLane(side: Side, laneIndex: Int): Double {
val medianHalfWidth = (median?.width ?: 0.0) / 2.0
return when (side) {
Side.LEFT -> {
var offset = -medianHalfWidth
for (i in 0 until laneIndex) {
offset -= lanesLeft[i].width
}
offset - lanesLeft[laneIndex].width / 2.0
}
Side.RIGHT -> {
var offset = medianHalfWidth
for (i in 0 until laneIndex) {
offset += lanesRight[i].width
}
offset + lanesRight[laneIndex].width / 2.0
}
}
}
enum class Side { LEFT, RIGHT }
companion object {
fun singleLaneEachWay(laneWidth: Double = 3.5): RoadProfile {
return RoadProfile(
lanesLeft = listOf(LaneDefinition(LaneType.DRIVING, laneWidth)),
lanesRight = listOf(LaneDefinition(LaneType.DRIVING, laneWidth)),
median = null
)
}
fun highway(
lanesPerSide: Int = 2,
laneWidth: Double = 3.5,
shoulderWidth: Double = 2.0,
medianWidth: Double = 3.0,
medianType: LaneType = LaneType.MEDIAN_BARRIER
): RoadProfile {
val sideLanes = buildList {
add(LaneDefinition(LaneType.SHOULDER, shoulderWidth))
repeat(lanesPerSide) {
add(LaneDefinition(LaneType.DRIVING, laneWidth))
}
}
return RoadProfile(
lanesLeft = sideLanes.reversed(),
lanesRight = sideLanes,
median = LaneDefinition(medianType, medianWidth)
)
}
fun withPublicTransport(
drivingLanes: Int = 1,
laneWidth: Double = 3.5,
busLaneWidth: Double = 3.5,
pedestrianWidth: Double = 2.0
): RoadProfile {
val leftLanes = buildList {
add(LaneDefinition(LaneType.PEDESTRIAN, pedestrianWidth))
add(LaneDefinition(LaneType.PUBLIC_TRANSPORT, busLaneWidth))
repeat(drivingLanes) {
add(LaneDefinition(LaneType.DRIVING, laneWidth))
}
}
val rightLanes = buildList {
repeat(drivingLanes) {
add(LaneDefinition(LaneType.DRIVING, laneWidth))
}
add(LaneDefinition(LaneType.PUBLIC_TRANSPORT, busLaneWidth))
add(LaneDefinition(LaneType.PEDESTRIAN, pedestrianWidth))
}
return RoadProfile(
lanesLeft = leftLanes.reversed(),
lanesRight = rightLanes,
median = LaneDefinition(LaneType.MEDIAN_GRASS, 2.0)
)
}
}
}

View File

@@ -0,0 +1,149 @@
package com.bnair.roadrunner.road
import com.bnair.roadrunner.math.BezierCurve
import com.bnair.roadrunner.math.Vec3d
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
data class RoadSegment(
val curve: BezierCurve,
val profile: RoadProfile,
val id: String = java.util.UUID.randomUUID().toString()
) {
fun generateBlocks(samplesPerBlock: Double = 0.5): List<RoadBlock> {
val blocks = mutableListOf<RoadBlock>()
val arcLen = curve.arcLength()
val numSamples = (arcLen / samplesPerBlock).toInt().coerceAtLeast(10)
for (i in 0..numSamples) {
val t = i.toDouble() / numSamples
val centerPoint = curve.evaluate(t)
val direction = curve.direction(t)
val perpendicular = direction.perpendicularXZ()
val curvature = curve.curvature(t)
val bankingAngle = if (profile.bankingEnabled) {
calculateBankingAngle(curvature, profile.maxBankingAngle)
} else 0.0
profile.median?.let { median ->
val medianBlocks = generateLaneBlocks(
centerPoint,
perpendicular,
0.0,
median,
bankingAngle
)
blocks.addAll(medianBlocks)
}
profile.lanesLeft.forEachIndexed { index, lane ->
val offset = profile.getOffsetForLane(RoadProfile.Side.LEFT, index)
val laneBlocks = generateLaneBlocks(
centerPoint,
perpendicular,
offset,
lane,
bankingAngle
)
blocks.addAll(laneBlocks)
}
profile.lanesRight.forEachIndexed { index, lane ->
val offset = profile.getOffsetForLane(RoadProfile.Side.RIGHT, index)
val laneBlocks = generateLaneBlocks(
centerPoint,
perpendicular,
offset,
lane,
bankingAngle
)
blocks.addAll(laneBlocks)
}
}
return blocks.distinctBy { Triple(it.x, it.y, it.z) }
}
private fun generateLaneBlocks(
centerPoint: Vec3d,
perpendicular: Vec3d,
offset: Double,
lane: LaneDefinition,
bankingAngle: Double
): List<RoadBlock> {
val blocks = mutableListOf<RoadBlock>()
val halfWidth = lane.width / 2.0
val bankingOffset = if (bankingAngle != 0.0) {
abs(offset) * sin(Math.toRadians(bankingAngle))
} else 0.0
val widthSteps = (lane.width).toInt().coerceAtLeast(1)
for (w in 0 until widthSteps) {
val widthOffset = -halfWidth + (w + 0.5) * (lane.width / widthSteps)
val totalOffset = offset + widthOffset
val blockPos = centerPoint + perpendicular * totalOffset
val adjustedY = blockPos.y + profile.elevationOffset + bankingOffset
val (bx, by, bz) = Vec3d(blockPos.x, adjustedY, blockPos.z).toBlockPos()
blocks.add(
RoadBlock(
x = bx,
y = by,
z = bz,
material = lane.material,
laneType = lane.type
)
)
}
return blocks
}
private fun calculateBankingAngle(curvature: Double, maxAngle: Double): Double {
val normalizedCurvature = (curvature * 100).coerceIn(0.0, 1.0)
return normalizedCurvature * maxAngle
}
}
data class RoadBlock(
val x: Int,
val y: Int,
val z: Int,
val material: String,
val laneType: LaneType
)
data class RoadNetwork(
val segments: MutableList<RoadSegment> = mutableListOf()
) {
fun addSegment(segment: RoadSegment) {
segments.add(segment)
}
fun removeSegment(id: String) {
segments.removeIf { it.id == id }
}
fun findSegmentAt(pos: Vec3d, tolerance: Double = 5.0): RoadSegment? {
return segments.find { segment ->
val samples = segment.curve.sample(20)
samples.any { (it - pos).length() < tolerance }
}
}
fun getAllBlocks(): List<RoadBlock> {
return segments.flatMap { it.generateBlocks() }
}
fun estimateMaterialCost(): Map<String, Int> {
return getAllBlocks()
.groupBy { it.material }
.mapValues { it.value.size }
}
}

View File

@@ -1,6 +1,6 @@
modLoader = "javafml"
loaderVersion = "*"
issueTrackerURL = "https://github.com/ThePandaOliver/Multiloader-Template/issues"
issueTrackerURL = "https://gitea.thefetagroup.com/bnair/RoadBuildMC/issues"
license = "${mod_license}"
[[mods]]

View File

@@ -6,15 +6,15 @@
"description": "${mod_description}",
"authors": [${fabric_mod_authors}],
"contact": {
"issues": "https://github.com/ThePandaOliver/Multiloader-Template/issues",
"sources": "https://github.com/ThePandaOliver/Multiloader-Template"
"issues": "https://gitea.thefetagroup.com/bnair/RoadBuildMC/issues",
"sources": "https://gitea.thefetagroup.com/bnair/RoadBuildMC"
},
"license": "${mod_license}",
"icon": "assets/${mod_id}/icon.png",
"environment": "*",
"entrypoints": {
"main": [
"com.example.template.TemplateModFabric"
"com.bnair.roadrunner.RoadRunnerFabric"
]
},
"mixins": [

View File

@@ -1,6 +1,6 @@
{
"required": true,
"package": "com.example.template.mixin",
"package": "com.bnair.roadrunner.mixin",
"compatibilityLevel": "JAVA_21",
"mixins": [
],