From ce691684d20c93f59f27575d13e4a0d3daf09227 Mon Sep 17 00:00:00 2001 From: bnair123 Date: Wed, 31 Dec 2025 01:14:49 +0400 Subject: [PATCH] Phase 2: Core math (BezierCurve, Vec3d) and road data structures (RoadProfile, RoadSegment) --- .../com/bnair/roadrunner/math/BezierCurve.kt | 201 ++++++++++++++++++ .../kotlin/com/bnair/roadrunner/math/Vec3d.kt | 63 ++++++ .../com/bnair/roadrunner/road/RoadProfile.kt | 131 ++++++++++++ .../com/bnair/roadrunner/road/RoadSegment.kt | 149 +++++++++++++ .../resources/META-INF/neoforge.mods.toml | 2 +- src/main/resources/fabric.mod.json | 6 +- src/main/resources/roadrunner.mixins.json | 2 +- 7 files changed, 549 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/com/bnair/roadrunner/math/BezierCurve.kt create mode 100644 src/main/kotlin/com/bnair/roadrunner/math/Vec3d.kt create mode 100644 src/main/kotlin/com/bnair/roadrunner/road/RoadProfile.kt create mode 100644 src/main/kotlin/com/bnair/roadrunner/road/RoadSegment.kt diff --git a/src/main/kotlin/com/bnair/roadrunner/math/BezierCurve.kt b/src/main/kotlin/com/bnair/roadrunner/math/BezierCurve.kt new file mode 100644 index 0000000..e70a482 --- /dev/null +++ b/src/main/kotlin/com/bnair/roadrunner/math/BezierCurve.kt @@ -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 { + 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 { + val points = mutableListOf() + 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 + ) + } + } +} diff --git a/src/main/kotlin/com/bnair/roadrunner/math/Vec3d.kt b/src/main/kotlin/com/bnair/roadrunner/math/Vec3d.kt new file mode 100644 index 0000000..776502b --- /dev/null +++ b/src/main/kotlin/com/bnair/roadrunner/math/Vec3d.kt @@ -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 { + 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) + } +} diff --git a/src/main/kotlin/com/bnair/roadrunner/road/RoadProfile.kt b/src/main/kotlin/com/bnair/roadrunner/road/RoadProfile.kt new file mode 100644 index 0000000..273e258 --- /dev/null +++ b/src/main/kotlin/com/bnair/roadrunner/road/RoadProfile.kt @@ -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, + val lanesRight: List, + 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) + ) + } + } +} diff --git a/src/main/kotlin/com/bnair/roadrunner/road/RoadSegment.kt b/src/main/kotlin/com/bnair/roadrunner/road/RoadSegment.kt new file mode 100644 index 0000000..5c45405 --- /dev/null +++ b/src/main/kotlin/com/bnair/roadrunner/road/RoadSegment.kt @@ -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 { + val blocks = mutableListOf() + 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 { + val blocks = mutableListOf() + 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 = 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 { + return segments.flatMap { it.generateBlocks() } + } + + fun estimateMaterialCost(): Map { + return getAllBlocks() + .groupBy { it.material } + .mapValues { it.value.size } + } +} diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml index 898f472..3f26b67 100644 --- a/src/main/resources/META-INF/neoforge.mods.toml +++ b/src/main/resources/META-INF/neoforge.mods.toml @@ -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]] diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 3515f20..404661c 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -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": [ diff --git a/src/main/resources/roadrunner.mixins.json b/src/main/resources/roadrunner.mixins.json index a4ba588..01acaf7 100644 --- a/src/main/resources/roadrunner.mixins.json +++ b/src/main/resources/roadrunner.mixins.json @@ -1,6 +1,6 @@ { "required": true, - "package": "com.example.template.mixin", + "package": "com.bnair.roadrunner.mixin", "compatibilityLevel": "JAVA_21", "mixins": [ ],