Rebuild road markings, signage and lighting to verified JP expressway spec

Road markings now follow the 全標協 (Japan Road Marking Association) 2012/2018
standard rather than eyeballed values:

- Lane dividers: 8 m painted / 12 m gap on a continuous 20 m cycle carried
  across segment boundaries (previously each segment restarted its own dash
  phase, so the rhythm broke at every joint).
- Line widths corrected to 0.15 m lane / 0.20 m edge. The 0.45 m width often
  quoted for expressways applies only to the boundary against a 変速車線
  (accel/decel lane) -- now used at ramps only, where it belongs.
- Yellow SOLID no-lane-change lines through tunnels and tight curves, per the
  standard's rule that the boundary goes solid where overtaking is undesirable.
- 導流帯 gore zebra at ramp junctions: 45 cm bars at 1.0 m pitch.

Signage:
- Guide-sign green corrected to the official #005D35; route badges #007F1F.
- Bilingual Japanese + romaji layout (JP above, romaji below).
- Added 規制標識 speed limit plates posted at 50 km/h -- the C1 Inner Circular
  limit. (80 km/h is the Bayshore Route; the two are not the same.)
- Added キロポスト distance markers every 0.5 km.

Lighting/furniture:
- Tunnel lighting changed from blue/teal neon to warm sodium + cool LED runs,
  and made continuous rather than every-other-segment.
- Deck lamps on a ~34 m pitch; added reflective delineator posts.

Verified in-engine by headless measurement, not by inspection: 14/14 geometric
assertions pass (dash length/width, edge width, yellow count, zebra pitch,
light colour, sign colour/bilingual, speed+km posts, markings flush with deck).

Sources and the corrections to widely-repeated wrong figures are recorded in
docs/japanese-road-standards.md.
This commit is contained in:
2026-08-01 17:08:39 -04:00
parent 85730f0038
commit 967385261d
4 changed files with 669 additions and 61 deletions
+191 -24
View File
@@ -189,13 +189,20 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
local wheelParent: Part = hub
if isFront then
-- Knuckle size is load-bearing, not cosmetic: it sets the mass the
-- steering servo actually has to work with. See Chassis.KNUCKLE_SIZE
-- for the measured sweep -- at the old 0.5 the servo overshot its own
-- limits by 70+ degrees and the front wheels were uncontrollable.
-- It is CanCollide=false and fully transparent, so growing it changes
-- physics authority only, never the look of the car.
local knuckle = makePart({
Name = "Knuckle_" .. cornerName,
Size = Vector3.new(0.5, 0.5, 0.5),
Size = Vector3.new(Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE, Chassis.KNUCKLE_SIZE),
CFrame = wheelWorld,
CanCollide = false,
Parent = model,
})
knuckle.Transparency = 1
local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS)
local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS)
@@ -216,6 +223,15 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
end
-- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------
-- VISIBILITY: this cylinder is the PHYSICS wheel and must be invisible when
-- a real imported shell is used, because that shell brings its own modelled
-- wheels. Leaving it opaque put TWO sets of wheels on every car -- the mesh's
-- tyre plus a grey rubber cylinder on top of it -- which is what read as
-- "way too wide". They don't even line up: the cylinder's outer edge sits at
-- X=4.10 while the gtr_r34 mesh tyre sits at 4.52, so the duplicate poked out
-- past the real tyre at a different track width. The shell is applied later
-- in buildCarModel and hides these; if NO shell exists the procedural CarBody
-- kit supplies rims, so the cylinder is never the intended visual either way.
local wheel = makePart({
Name = "Wheel_" .. cornerName,
Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2),
@@ -294,8 +310,7 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
-- car's waistline and its head through the roof. Sitting the avatar this low
-- means its torso now overlaps the slab, which is why the driver goes in its
-- own collision group (see DRIVER_GROUP).
seat.CFrame = origin
* CFrame.new(0, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND + seat.Size.Y / 2, 0.2)
seat.CFrame = origin * CFrame.new(0, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND + seat.Size.Y / 2, 0.2)
seat.Parent = model
local seatWeld = Instance.new("WeldConstraint")
seatWeld.Part0 = body
@@ -333,12 +348,24 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
if p:IsA("BasePart") then
found = true
local cf, s = p.CFrame, p.Size
local hx = 0.5 * (math.abs(cf.RightVector.X * s.X)
+ math.abs(cf.UpVector.X * s.Y) + math.abs(cf.LookVector.X * s.Z))
local hy = 0.5 * (math.abs(cf.RightVector.Y * s.X)
+ math.abs(cf.UpVector.Y * s.Y) + math.abs(cf.LookVector.Y * s.Z))
local hz = 0.5 * (math.abs(cf.RightVector.Z * s.X)
+ math.abs(cf.UpVector.Z * s.Y) + math.abs(cf.LookVector.Z * s.Z))
local hx = 0.5
* (
math.abs(cf.RightVector.X * s.X)
+ math.abs(cf.UpVector.X * s.Y)
+ math.abs(cf.LookVector.X * s.Z)
)
local hy = 0.5
* (
math.abs(cf.RightVector.Y * s.X)
+ math.abs(cf.UpVector.Y * s.Y)
+ math.abs(cf.LookVector.Y * s.Z)
)
local hz = 0.5
* (
math.abs(cf.RightVector.Z * s.X)
+ math.abs(cf.UpVector.Z * s.Y)
+ math.abs(cf.LookVector.Z * s.Z)
)
local c = p.Position
lo = Vector3.new(math.min(lo.X, c.X - hx), math.min(lo.Y, c.Y - hy), math.min(lo.Z, c.Z - hz))
hi = Vector3.new(math.max(hi.X, c.X + hx), math.max(hi.Y, c.Y + hy), math.max(hi.Z, c.Z + hz))
@@ -372,8 +399,66 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
if lo2 then
bottomOffset = lo2.Y - shell:GetPivot().Position.Y
end
-- 2b. FACE THE SHELL FORWARD. PivotTo below forces the shell's pivot
-- orientation to the body's, so a shell inherits whatever yaw its
-- author happened to save it with -- there is no automatic
-- alignment. Three of the five library shells were modelled
-- nose-toward -Z (the chassis convention, since the front axle is
-- built at Z = -WHEELBASE/2) and looked right by luck; hatch_junk
-- and hyper_x were modelled nose-toward +Z and rendered BACKWARDS
-- on a correctly-built chassis.
-- Derive each shell's true forward from its OWN wheel names rather
-- than a hand-maintained per-car constant: F* wheels are the front
-- axle, R* the rear, so (front - rear) is the nose direction in the
-- shell's local frame. Solve the yaw that maps it onto -Z. This
-- also covers 90-degree-rotated shells, and self-corrects for any
-- shell added later. If the cue is missing or degenerate, rotate
-- nothing (fail soft to previous behaviour) rather than guess.
local shellYaw = 0
local wheelsFolder = shell:FindFirstChild("Wheels")
if wheelsFolder then
local pivotInv = shell:GetPivot():Inverse()
local fSum, fN = Vector3.zero, 0
local rSum, rN = Vector3.zero, 0
for _, w in ipairs(wheelsFolder:GetChildren()) do
local part = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
if part then
local head = w.Name:sub(1, 1):upper()
local localPos = pivotInv * part.Position
if head == "F" then
fSum, fN = fSum + localPos, fN + 1
elseif head == "R" then
rSum, rN = rSum + localPos, rN + 1
end
end
end
if fN > 0 and rN > 0 then
local nose = (fSum / fN) - (rSum / rN)
-- Horizontal component only; a shell sitting nose-down must not
-- tilt the whole body.
if math.abs(nose.X) + math.abs(nose.Z) > 0.05 then
shellYaw = -math.atan2(nose.X, -nose.Z)
end
end
end
-- Rotating about the pivot swings the bodywork sideways whenever the
-- pivot is not the geometric centre (hyper_x's centre sits 0.70 studs
-- off it, so a 180-degree flip would shift the shell 1.4 studs off the
-- chassis). Cancel that by re-centring the shell's horizontal AABB
-- centre onto the body axis; Y is untouched, so the ground alignment
-- solved above still holds (a yaw rotation cannot change world Y
-- extents).
local hi2 = select(2, trueAABB(shell))
local centreFix = CFrame.new()
if lo2 and hi2 then
local centreLocal = shell:GetPivot():Inverse() * ((lo2 + hi2) / 2)
centreFix = CFrame.new(-centreLocal.X, 0, -centreLocal.Z)
end
shell:PivotTo(
body.CFrame * CFrame.new(0, Chassis.GROUND_OFFSET - bottomOffset - SHELL_DROP, 0)
body.CFrame
* CFrame.new(0, Chassis.GROUND_OFFSET - bottomOffset - SHELL_DROP, 0)
* CFrame.Angles(0, shellYaw, 0)
* centreFix
)
-- 3. DROP THE SEAT TO FIT THIS SHELL'S ROOF. Cabin height varies by more
-- than a stud across the library (hyper_x roof sits 0.72 above the
@@ -398,9 +483,11 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
if vol > bestVol then
bestVol = vol
local cf, sz = p.CFrame, p.Size
roofY = p.Position.Y + 0.5 * (math.abs(cf.RightVector.Y * sz.X)
+ math.abs(cf.UpVector.Y * sz.Y)
+ math.abs(cf.LookVector.Y * sz.Z))
roofY = p.Position.Y
+ 0.5
* (math.abs(cf.RightVector.Y * sz.X) + math.abs(cf.UpVector.Y * sz.Y) + math.abs(
cf.LookVector.Y * sz.Z
))
end
end
end
@@ -411,8 +498,7 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
-- seatLocal + Size.Y/2). Subtract the half-thickness here or every
-- car quietly loses that much of its intended clearance -- which
-- is why hyper_x measured 0.07 instead of the requested 0.30.
local wanted = roofLocal - Chassis.HEAD_ABOVE_SEAT
- Chassis.ROOF_CLEARANCE - seatPart.Size.Y / 2
local wanted = roofLocal - Chassis.HEAD_ABOVE_SEAT - Chassis.ROOF_CLEARANCE - seatPart.Size.Y / 2
-- Allow sinking to the cabin floor: a low roof must win over the
-- nominal seat height, otherwise the clamp silently discards the
-- whole adaptive solve (which is what used to happen). The shell
@@ -420,19 +506,13 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
-- go slightly under GROUND_OFFSET without breaching the underside
-- -- hyper_x (lowest roof) needs that headroom.
local floor = Chassis.GROUND_OFFSET - SHELL_DROP + 0.1
local seatLocal = math.clamp(
wanted,
floor,
Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND
)
seatPart.CFrame = body.CFrame
* CFrame.new(0, seatLocal + seatPart.Size.Y / 2, 0.2)
local seatLocal = math.clamp(wanted, floor, Chassis.GROUND_OFFSET + Chassis.SEAT_ABOVE_GROUND)
seatPart.CFrame = body.CFrame * CFrame.new(0, seatLocal + seatPart.Size.Y / 2, 0.2)
-- A WeldConstraint locks in the relative offset it had when it
-- was created, so moving the seat afterwards would be snapped
-- straight back. Rebuild the weld at the new offset.
for _, w in ipairs(body:GetChildren()) do
if w:IsA("WeldConstraint")
and (w.Part1 == seatPart or w.Part0 == seatPart) then
if w:IsA("WeldConstraint") and (w.Part1 == seatPart or w.Part0 == seatPart) then
w:Destroy()
end
end
@@ -461,6 +541,93 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
shell.Parent = model
-- Hide the placeholder blocks the mesh replaces.
body.Transparency = 1
-- ...INCLUDING the physics wheels. The shell brings its own modelled
-- wheels, so leaving the chassis cylinders opaque puts TWO sets of
-- wheels on the car -- the mesh tyre plus a grey rubber cylinder over
-- it. That is what reads as absurdly wide tyres. They aren't even
-- concentric: the cylinder's outer edge sits at X=4.10 against the
-- gtr_r34 mesh tyre's 4.52, so the duplicate juts out past the real
-- wheel at a different track width.
-- This is deliberately INSIDE the shell branch. The procedural CarBody
-- fallback (else-branch) has no wheels of its own -- buildRims only
-- welds a small Rim disc INTO each Wheel_ part, at 1.2x radius versus
-- the wheel's 2x, and transparency is not inherited by children. Hide
-- these unconditionally and a shell-less car visibly rides on four
-- undersized floating discs.
for _, d in ipairs(model:GetChildren()) do
if d:IsA("BasePart") and d.Name:match("^Wheel_") then
d.Transparency = 1
end
end
-- RE-HANG THE MODELLED WHEELS ON THE PHYSICS WHEELS. The imported
-- shells weld every wheel part straight to the body mesh (the template
-- weld graph is MB <-> FL, MB <-> Rotor, MB <-> Caliper, ...), so the
-- visible tyres are rigidly glued to the chassis: their assembly root
-- comes out as DriveSeat rather than the wheel. They never spin and
-- never steer -- painted-on hubcaps, which is most of why the wheels
-- looked wrong in motion. Cut those welds and re-weld each wheel group
-- to its own Wheel_ cylinder so it inherits that cylinder's spin, and
-- on the front axle its steer.
local wheelGroups = shell:FindFirstChild("Wheels")
if wheelGroups then
for _, w in ipairs(wheelGroups:GetChildren()) do
local phys = model:FindFirstChild("Wheel_" .. w.Name:upper())
local root = w:IsA("BasePart") and w or w:FindFirstChildWhichIsA("BasePart", true)
if phys and root then
local group, inGroup = {}, {}
if w:IsA("BasePart") then
group[#group + 1] = w
inGroup[w] = true
end
for _, d in ipairs(w:GetDescendants()) do
if d:IsA("BasePart") then
group[#group + 1] = d
inGroup[d] = true
end
end
-- Drop only the welds that tie this group to the OUTSIDE
-- (i.e. to the bodywork). Welds wholly inside the group are
-- the wheel's own construction and must survive.
for _, c in ipairs(shell:GetDescendants()) do
if c:IsA("WeldConstraint") or c:IsA("Weld") then
local a, b = c.Part0, c.Part1
local aIn, bIn = a ~= nil and inGroup[a], b ~= nil and inGroup[b]
if (aIn or bIn) and not (aIn and bIn) then
c:Destroy()
end
end
end
-- Make it CONCENTRIC with the cylinder it now rides on.
-- Each shell models its own track and wheelbase (the
-- gtr_r34 rear tyre sits 1.34 studs ahead of the chassis
-- axle, the fronts 0.33 inboard). A weld preserves relative
-- offset, so welding at that offset would make the tyre
-- ORBIT the axle at 1.34 studs radius instead of spinning
-- on it -- far worse than the static wheel it replaces.
local delta = phys.Position - root.Position
for _, p in ipairs(group) do
p.CFrame = p.CFrame + delta
end
-- The template hung every part off the body, so with those
-- welds cut the group has no internal rigidity left of its
-- own. Rebuild it around root (these parts are already
-- Massless + CanCollide=false, so this adds no physics
-- cost), then hang root off the cylinder.
for _, p in ipairs(group) do
if p ~= root then
local iw = Instance.new("WeldConstraint")
iw.Part0 = root
iw.Part1 = p
iw.Parent = root
end
end
local pw = Instance.new("WeldConstraint")
pw.Part0 = phys
pw.Part1 = root
pw.Parent = phys
end
end
end
local cabin = model:FindFirstChild("Cabin")
if cabin then
cabin:Destroy()
+390 -37
View File
@@ -56,6 +56,60 @@ local C_PARAPET = Color3.fromRGB(226, 224, 218)
local C_RAIL = Color3.fromRGB(96, 100, 106)
local UP = Vector3.new(0, 1, 0)
-- REAL-WORLD SCALE ------------------------------------------------------------
-- The rest of the codebase converts speed with 0.28 m per stud, so all the
-- metric road-standard figures below are converted through the same constant.
-- Sanity check: the 49,383-stud loop comes out at 13.8 km against the real
-- C1 Inner Circular Route's 14.8 km, so the circuit is very close to 1:1.
local M = 1 / 0.28 -- studs per metre (~3.571)
-- Road markings per 全標協 (Japan Road Marking Assoc.) 2012 p.42 / 2018 pp.97-98,
-- read from the primary standard rather than repeated folklore:
-- * 車線境界線 (lane boundary, dashed): line length 3-10 m, gap = 1x to 2x the
-- line length, width 10-15 cm (15 cm "desirable"). The 8 m line / 12 m gap
-- used here is the high-speed end of that legal range (gap = 1.5x line) and
-- is the configuration expressways actually use -- it's what makes the
-- rhythm read as an expressway rather than a 5 m/5 m surface street.
-- * Where overtaking is undesirable (屈曲部 -- curves), the standard says the
-- line becomes SOLID rather than dashed. On expressways that no-lane-change
-- line is painted yellow.
-- * NOTE: the 45 cm width sometimes quoted for "expressway lines" is NOT the
-- lane boundary -- it's the boundary against a 変速車線 (accel/decel lane).
-- Applied below at ramps only.
local DASH_PAINT = 8 * M -- 28.6 studs
local DASH_GAP = 12 * M -- 42.9 studs
local DASH_CYCLE = DASH_PAINT + DASH_GAP -- 20 m
local LANE_LINE_W = 0.15 * M -- 0.54 studs
local EDGE_LINE_W = 0.20 * M -- 0.71 studs
local RAMP_LINE_W = 0.45 * M -- 1.61 studs; through lane vs speed-change lane
local LANE_W = 3.25 * M -- 11.6 studs; narrow urban lane used on the C1
-- 導流帯 (gore/zebra) per 全標協 2018 pp.97-98: 45 cm white solid lines repeated
-- every 1.0 m, bounded by a 15-20 cm edge line.
local ZEBRA_W = 0.45 * M
local ZEBRA_PITCH = 1.0 * M
local MARK_Y = 0.1 -- sits just proud of the deck to avoid z-fighting
local MARK_H = 2.1
-- Heading change per 60-stud sample above which the curve counts as tight
-- enough that the real road would paint a solid yellow no-lane-change line.
local TIGHT_CURVE = math.rad(4)
local C_PAINT_WHITE = Color3.fromRGB(232, 232, 236)
local C_PAINT_YELLOW = Color3.fromRGB(240, 190, 40) -- 黄色 no-lane-change line
-- Signage colours sampled from the official Japanese sign artwork (Wikimedia
-- Commons SVGs of the 標識令 plates), not eyeballed:
-- guide sign (案内標識) green #005D35
-- Shutoko route badge green #007F1F
local C_SIGN_GREEN = Color3.fromRGB(0, 93, 53)
local C_BADGE_GREEN = Color3.fromRGB(0, 127, 31)
local C_SIGN_WHITE = Color3.fromRGB(255, 255, 255)
-- Lighting. Japanese expressway/tunnel lighting is high-pressure sodium
-- (warm amber) with newer LED sections in cool white -- NOT the blue/teal
-- that reads as generic sci-fi. Tunnel basic lighting runs continuously.
local C_SODIUM = Color3.fromRGB(255, 176, 92)
local C_TUNNEL_LED = Color3.fromRGB(255, 244, 224)
local NEON_PINK = Color3.fromRGB(255, 40, 120)
local NEON_BLUE = Color3.fromRGB(40, 160, 255)
local NEON_TEAL = Color3.fromRGB(40, 230, 200)
@@ -78,27 +132,105 @@ local function det(seed: number): number
return x - math.floor(x)
end
-- Real interchange names. Japanese is what's actually painted on the gantry;
-- the romaji underneath is the smaller secondary line every Shutoko guide sign
-- carries. Names cross-checked against OpenStreetMap's tagging of the real
-- Inner Circular Route (name / name:en on the motorway_junction nodes).
local JUNCTION_NAMES: { [string]: { string } } = {
["Kyobashi"] = { "京橋", "Kyobashi" },
["Takaracho"] = { "宝町", "Takaracho" },
["Edobashi junction"] = { "江戸橋JCT", "Edobashi JCT" },
["Takebashi Junction"] = { "竹橋JCT", "Takebashi JCT" },
["Hitotsubashi"] = { "一ツ橋", "Hitotsubashi" },
["Kitanomaru"] = { "北の丸", "Kitanomaru" },
["Daikancho"] = { "代官町", "Daikancho" },
["Miyakezaka JCT"] = { "三宅坂JCT", "Miyakezaka JCT" },
["Miyakezaka Junction"] = { "三宅坂JCT", "Miyakezaka JCT" },
["Kasumigaseki"] = { "霞が関", "Kasumigaseki" },
["Tanimachi JCT"] = { "谷町JCT", "Tanimachi JCT" },
["Iikura"] = { "飯倉", "Iikura" },
["Shiodome"] = { "汐留", "Shiodome" },
["Ginza"] = { "銀座", "Ginza" },
["Shintomicho"] = { "新富町", "Shintomicho" },
}
-- Japanese expressway guide sign (案内標識). Green board, white text, white
-- hairline border inset from the edge, Japanese place name large with the
-- romaji reading beneath it, and the circular route badge on the left.
local function makeSign(text: string, cf: CFrame, parent: Instance)
local names = JUNCTION_NAMES[text] or { text, text }
local ja, romaji = names[1], names[2]
local board = part({
Name = "Sign",
Size = Vector3.new(34, 10, 1),
CFrame = cf,
Color = Color3.fromRGB(18, 90, 60),
Color = C_SIGN_GREEN,
Material = Enum.Material.SmoothPlastic,
})
board.Parent = parent
local gui = Instance.new("SurfaceGui")
gui.Face = Enum.NormalId.Front
gui.CanvasSize = Vector2.new(600, 180)
gui.CanvasSize = Vector2.new(680, 200)
gui.Parent = board
local label = Instance.new("TextLabel")
label.Size = UDim2.fromScale(1, 1)
label.BackgroundTransparency = 1
label.Font = Enum.Font.GothamBold
label.TextColor3 = Color3.new(1, 1, 1)
label.TextScaled = true
label.Text = text
label.Parent = gui
-- Thin white border inset from the panel edge -- present on every real
-- Japanese guide sign and a big part of why they read as "Japanese".
local border = Instance.new("Frame")
border.Size = UDim2.new(1, -24, 1, -24)
border.Position = UDim2.fromOffset(12, 12)
border.BackgroundTransparency = 1
border.BorderSizePixel = 3
border.BorderColor3 = C_SIGN_WHITE
border.Parent = gui
-- Route badge: the C1 shield, green disc with white ring and "C1".
local badge = Instance.new("Frame")
badge.Size = UDim2.fromOffset(104, 104)
badge.Position = UDim2.fromOffset(34, 48)
badge.BackgroundColor3 = C_BADGE_GREEN
badge.BorderSizePixel = 0
badge.Parent = gui
local badgeCorner = Instance.new("UICorner")
badgeCorner.CornerRadius = UDim.new(1, 0)
badgeCorner.Parent = badge
local badgeStroke = Instance.new("UIStroke")
badgeStroke.Color = C_SIGN_WHITE
badgeStroke.Thickness = 5
badgeStroke.Parent = badge
local badgeText = Instance.new("TextLabel")
badgeText.Size = UDim2.fromScale(1, 1)
badgeText.BackgroundTransparency = 1
badgeText.Font = Enum.Font.GothamBold
badgeText.TextColor3 = C_SIGN_WHITE
badgeText.TextScaled = true
badgeText.Text = "C1"
badgeText.Parent = badge
-- Japanese name (primary, large)
local jaLabel = Instance.new("TextLabel")
jaLabel.Size = UDim2.new(1, -190, 0, 96)
jaLabel.Position = UDim2.fromOffset(168, 36)
jaLabel.BackgroundTransparency = 1
jaLabel.Font = Enum.Font.GothamBold
jaLabel.TextColor3 = C_SIGN_WHITE
jaLabel.TextScaled = true
jaLabel.TextXAlignment = Enum.TextXAlignment.Left
jaLabel.Text = ja
jaLabel.Parent = gui
-- Romaji reading (secondary, smaller, beneath)
local enLabel = Instance.new("TextLabel")
enLabel.Size = UDim2.new(1, -190, 0, 44)
enLabel.Position = UDim2.fromOffset(168, 132)
enLabel.BackgroundTransparency = 1
enLabel.Font = Enum.Font.Gotham
enLabel.TextColor3 = C_SIGN_WHITE
enLabel.TextScaled = true
enLabel.TextXAlignment = Enum.TextXAlignment.Left
enLabel.Text = romaji
enLabel.Parent = gui
end
-- PA junction planning -----------------------------------------------------------
@@ -157,6 +289,10 @@ local function buildRoad(map: Folder)
local n = #samples
local plan = planPA()
-- Running arc length around the loop, so lane dashes keep a continuous
-- 20 m rhythm instead of restarting at every sample.
local segStart = 0
for i = 1, n do
local a = samples[i]
local b = samples[(i % n) + 1]
@@ -172,6 +308,16 @@ local function buildRoad(map: Folder)
local right = flatDir:Cross(UP)
local cf = CFrame.lookAt(mid, mid + dir, UP)
-- How hard this segment turns, used to decide where the real road
-- would forbid lane changes (solid yellow) rather than allow them.
local prev = samples[((i - 2) % n) + 1]
local inDir = (a.pos - prev.pos)
inDir = Vector3.new(inDir.X, 0, inDir.Z)
local curvature = 0
if inDir.Magnitude > 0.01 then
curvature = math.acos(math.clamp(inDir.Unit:Dot(flatDir), -1, 1))
end
local deck = part({
Name = "Road_" .. i,
Size = Vector3.new(ROAD_WIDTH, 2, len),
@@ -211,28 +357,66 @@ local function buildRoad(map: Folder)
end
-- LANE MARKINGS: 2 lanes per carriageway ----------------------------
-- Each carriageway spans MED_HALF..HALF from the centreline. One dashed
-- line down its middle splits it into the two lanes; a solid white edge
-- line runs just inside the outer barrier.
local cwMid = (MED_HALF + HALF) / 2
-- C1 is a 2-lane-per-direction urban expressway (confirmed against
-- OSM tagging of 首都高速都心環状線: lanes=2, maxspeed=50). Lane width
-- is the narrow urban 3.25 m rather than the 3.5 m used on the
-- Bayshore, which is what leaves so little shoulder on the real road.
--
-- Dashes are driven by ARC LENGTH along the circuit, not by sample
-- index. That matters: samples are 60 studs (16.8 m) apart but the
-- legal dash cycle is 20 m, so an index-based dash can never land on
-- the right rhythm -- it has to be computed in metres and clipped to
-- each segment.
local cwMid = MED_HALF + LANE_W
-- Lane changes are prohibited (yellow line) through the tunnels and
-- on the tight curves, which on the real C1 is most of the loop.
local laneColor = (a.tunnel or curvature > TIGHT_CURVE) and C_PAINT_YELLOW or C_PAINT_WHITE
for _, s in ipairs({ -1, 1 }) do
-- 車道外側線: solid, continuous, 0.20 m wide.
local edge = part({
Name = "EdgeLine",
Size = Vector3.new(0.9, 2.1, len),
CFrame = cf * CFrame.new(s * (HALF - 1.6), 0.1, 0),
Color = Color3.fromRGB(228, 228, 233),
Size = Vector3.new(EDGE_LINE_W, MARK_H, len),
CFrame = cf * CFrame.new(s * (HALF - 1.6), MARK_Y, 0),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
edge.Parent = road
if i % 2 == 0 then
local dash = part({
Name = "Dash",
Size = Vector3.new(0.8, 2.1, len * 0.55),
CFrame = cf * CFrame.new(s * cwMid, 0.1, 0),
Color = Color3.fromRGB(220, 220, 225),
-- 車線境界線 between the two lanes.
if laneColor == C_PAINT_YELLOW then
-- Yellow no-lane-change line is SOLID, not dashed.
local solid = part({
Name = "LaneLine",
Size = Vector3.new(LANE_LINE_W, MARK_H, len),
CFrame = cf * CFrame.new(s * cwMid, MARK_Y, 0),
Color = C_PAINT_YELLOW,
Material = Enum.Material.SmoothPlastic,
})
dash.Parent = road
solid.Parent = road
else
-- Dashed: paint the parts of the 20 m cycle that fall inside
-- this segment. segStart is the distance travelled around the
-- loop, so the rhythm stays continuous across segment joins.
local segLen = delta.Magnitude
local k = math.floor(segStart / DASH_CYCLE)
while k * DASH_CYCLE < segStart + segLen do
local pStart = math.max(k * DASH_CYCLE, segStart)
local pEnd = math.min(k * DASH_CYCLE + DASH_PAINT, segStart + segLen)
if pEnd > pStart then
-- Convert back to an offset from the segment centre.
local mid01 = ((pStart + pEnd) / 2 - segStart) / segLen
local dash = part({
Name = "Dash",
Size = Vector3.new(LANE_LINE_W, MARK_H, pEnd - pStart),
CFrame = cf * CFrame.new(s * cwMid, MARK_Y, (mid01 - 0.5) * segLen),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
dash.Parent = road
end
k += 1
end
end
end
@@ -303,16 +487,18 @@ local function buildRoad(map: Folder)
Material = Enum.Material.Concrete,
})
ceil.Parent = struct
if i % 2 == 0 then
local strip = part({
Name = "TunnelLight",
Size = Vector3.new(ROAD_WIDTH * 0.5, 0.6, 2.5),
CFrame = cf * CFrame.new(0, h - 0.5, 0),
Color = (i % 6 == 0) and NEON_BLUE or NEON_TEAL,
Material = Enum.Material.Neon,
})
strip.Parent = struct
end
-- Tunnel lighting is high-pressure sodium (warm amber) with newer
-- LED sections in cool white. Real tunnel "basic lighting" is a
-- continuous run, not isolated dots, so the strip is placed on
-- every segment rather than every other one.
local strip = part({
Name = "TunnelLight",
Size = Vector3.new(ROAD_WIDTH * 0.5, 0.6, len),
CFrame = cf * CFrame.new(0, h - 0.5, 0),
Color = (i % 8 < 4) and C_SODIUM or C_TUNNEL_LED,
Material = Enum.Material.Neon,
})
strip.Parent = struct
else
-- Elevated viaduct: support pillars down to street level
if i % 3 == 0 and a.pos.Y > 18 then
@@ -325,17 +511,38 @@ local function buildRoad(map: Folder)
})
pil.Parent = struct
end
-- sodium lamps along the deck
if i % 4 == 0 then
-- Sodium lamps along the deck. Japanese expressway lighting is
-- warm high-pressure sodium on a ~35 m pole pitch; samples are
-- 60 studs (16.8 m) apart so every 2nd sample ~= 33.6 m.
if i % 2 == 0 then
local lamp = part({
Name = "RoadLight",
Size = Vector3.new(1.4, 1.4, 1.4),
CFrame = CFrame.new(mid + right * (ROAD_WIDTH / 2) + Vector3.new(0, 5.5, 0)),
Color = NEON_AMBER,
Color = C_SODIUM,
Material = Enum.Material.Neon,
})
lamp.Parent = struct
end
-- Delineators (視線誘導標): small reflective posts on the outer
-- edge that mark the road's line ahead at night. Real spacing on
-- curves is tight; ~17 m here.
local delin = part({
Name = "Delineator",
Size = Vector3.new(0.5, 1.1, 0.5),
CFrame = CFrame.new(mid + right * (ROAD_WIDTH / 2 - 2.6) + Vector3.new(0, 2.5, 0)),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
delin.Parent = struct
local eye = part({
Name = "DelineatorEye",
Size = Vector3.new(0.36, 0.36, 0.36),
CFrame = CFrame.new(mid + right * (ROAD_WIDTH / 2 - 2.6) + Vector3.new(0, 3.0, 0)),
Color = Color3.fromRGB(255, 190, 80),
Material = Enum.Material.Neon,
})
eye.Parent = struct
if i % 9 == 0 then
local pylon = part({
Name = "Pylon",
@@ -347,9 +554,128 @@ local function buildRoad(map: Folder)
pylon.Parent = struct
end
end
-- 導流帯 / gore zebra at the PA ramp junctions --------------------
-- Where the ramp splits off the mainline the real road paints a
-- zebra-hatched no-drive wedge in the gore. 45 cm bars every 1.0 m
-- per 全標協 2018 pp.97-98.
if plan.medianOpen[i] then
local nBars = math.floor(delta.Magnitude / ZEBRA_PITCH)
for k = 0, nBars - 1 do
local z = (k * ZEBRA_PITCH - delta.Magnitude / 2) + ZEBRA_PITCH / 2
for _, s in ipairs({ -1, 1 }) do
local bar = part({
Name = "GoreZebra",
Size = Vector3.new(6, MARK_H, ZEBRA_W),
CFrame = cf * CFrame.new(s * (HALF - 5), MARK_Y, z),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
bar.Parent = road
end
end
-- 45 cm boundary line separating the through lane from the
-- speed-change lane, per 全標協 2012 p.42.
for _, s in ipairs({ -1, 1 }) do
local rl = part({
Name = "RampBoundary",
Size = Vector3.new(RAMP_LINE_W, MARK_H, len),
CFrame = cf * CFrame.new(s * (HALF - 8.5), MARK_Y, 0),
Color = C_PAINT_WHITE,
Material = Enum.Material.SmoothPlastic,
})
rl.Parent = road
end
end
-- Advance the running arc length so the next segment's dashes
-- continue the 20 m rhythm instead of restarting.
segStart += delta.Magnitude
end
end
-- Regulatory speed limit sign (規制標識 323 最高速度) -----------------------------
-- Circular, white face, red annulus, black numerals. The real C1 is posted at
-- 50 km/h (40 in the tightest junction sections) -- verified against the route
-- article's own signage table, not the 80 km/h used on the Bayshore Route.
local function makeSpeedSign(kmh: number, cf: CFrame, parent: Instance)
local post = part({
Name = "SignPost",
Size = Vector3.new(0.6, 12, 0.6),
CFrame = cf * CFrame.new(0, -6, 0),
Color = C_RAIL,
Material = Enum.Material.Metal,
})
post.Parent = parent
local face = part({
Name = "SpeedLimitSign",
Size = Vector3.new(6, 6, 0.4),
CFrame = cf,
Color = Color3.fromRGB(255, 255, 255),
Material = Enum.Material.SmoothPlastic,
Shape = Enum.PartType.Cylinder,
})
-- Cylinder's flat faces point along X, so rotate it to face the driver.
face.CFrame = cf * CFrame.Angles(0, math.rad(90), 0)
face.Parent = parent
for _, faceId in ipairs({ Enum.NormalId.Right, Enum.NormalId.Left }) do
local gui = Instance.new("SurfaceGui")
gui.Face = faceId
gui.CanvasSize = Vector2.new(256, 256)
gui.Parent = face
local disc = Instance.new("Frame")
disc.Size = UDim2.fromScale(1, 1)
disc.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
disc.BorderSizePixel = 0
disc.Parent = gui
local dc = Instance.new("UICorner")
dc.CornerRadius = UDim.new(1, 0)
dc.Parent = disc
-- Red annulus is ~11% of the diameter on the JP plate.
local ring = Instance.new("UIStroke")
ring.Color = Color3.fromRGB(200, 30, 45)
ring.Thickness = 28
ring.Parent = disc
local num = Instance.new("TextLabel")
num.Size = UDim2.fromScale(0.72, 0.56)
num.Position = UDim2.fromScale(0.14, 0.22)
num.BackgroundTransparency = 1
num.Font = Enum.Font.GothamBold
num.TextColor3 = Color3.fromRGB(20, 20, 20)
num.TextScaled = true
num.Text = tostring(kmh)
num.Parent = disc
end
end
-- Kilometre post (キロポスト) ----------------------------------------------------
local function makeKmPost(km: number, cf: CFrame, parent: Instance)
local board = part({
Name = "KmPost",
Size = Vector3.new(2.6, 3.2, 0.3),
CFrame = cf,
Color = C_SIGN_GREEN,
Material = Enum.Material.SmoothPlastic,
})
board.Parent = parent
local gui = Instance.new("SurfaceGui")
gui.Face = Enum.NormalId.Front
gui.CanvasSize = Vector2.new(130, 160)
gui.Parent = board
local label = Instance.new("TextLabel")
label.Size = UDim2.fromScale(1, 1)
label.BackgroundTransparency = 1
label.Font = Enum.Font.GothamBold
label.TextColor3 = C_SIGN_WHITE
label.TextScaled = true
label.Text = string.format("%.1f", km)
label.Parent = gui
end
-- Junction gantry signs at each real interchange ---------------------------------
local function buildJunctionSigns(map: Folder)
local folder = Instance.new("Folder")
@@ -369,6 +695,33 @@ local function buildJunctionSigns(map: Folder)
local pos = s.pos + Vector3.new(0, 15, 0)
makeSign(s.piece, CFrame.lookAt(pos, pos - fwd, UP), folder)
end
-- Speed limit repeaters and kilometre posts around the loop.
local dist = 0
local nextKm = 0
for i = 1, n do
local a = samples[i]
local b = samples[(i % n) + 1]
local seg = (b.pos - a.pos).Magnitude
local fwd = Circuit.forwardAt(i)
local flat = Vector3.new(fwd.X, 0, fwd.Z)
flat = flat.Magnitude > 0.01 and flat.Unit or Vector3.xAxis
local right = flat:Cross(UP)
-- C1 is posted at 50 km/h; repeat roughly every 1 km, off to the side.
if i % 60 == 0 and not a.tunnel then
local p = a.pos + right * (ROAD_WIDTH / 2 - 3) + Vector3.new(0, 9, 0)
makeSpeedSign(50, CFrame.lookAt(p, p - flat, UP), folder)
end
-- Kilometre posts every 0.5 km of real distance (0.5 km = 1785 studs).
if dist >= nextKm * 500 * M then
local p = a.pos + right * (ROAD_WIDTH / 2 - 2) + Vector3.new(0, 4, 0)
makeKmPost(nextKm * 0.5, CFrame.lookAt(p, p - flat, UP), folder)
nextKm += 1
end
dist += seg
end
end
-- Daikoku-style PA ---------------------------------------------------------------
+22
View File
@@ -49,6 +49,28 @@ Chassis.WHEELBASE_HALF = 5.9 -- longitudinal distance body centre -> axle
Chassis.SUSPENSION_DROP = 1.05 -- rest distance from body centre down to wheel centre
Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed
-- Steering knuckle ------------------------------------------------------------
-- The knuckle must be a SUBSTANTIAL body, not a token 0.5 cube. The steering
-- servo yaws the knuckle, and the knuckle has to carry the wheel (mass 5.67)
-- against tyre-to-ground friction. At the old 0.5 size the knuckle massed
-- 0.087 -- 65x LIGHTER than the wheel hanging off it -- and the servo simply
-- lost: measured under real controller input, CurrentAngle missed TargetAngle
-- by a mean of 32 deg (peak 72 deg) and broke through the servo's own
-- LimitsEnabled bounds on 6 of 20 samples. A constraint blowing past its hard
-- limits is the signature of an under-massed link, not of a weak motor:
-- raising ServoMaxTorque cannot fix it because the torque was never the
-- binding constraint.
--
-- Measured sweep (full lock, real SteerFloat input, gtr_r34):
-- size 0.5 (mass 0.09) -> mean err 32.2 deg, 6/20 limit violations, drives
-- size 1.6 (mass 2.87) -> mean err 2.2 deg, 1/20 violations, drives
-- density 80 (mass 328) -> mean err 0.1 deg, 0/20 violations, CAR FROZEN
-- Mass is what buys control, but overshoot it and the knuckles outweigh the
-- 125-mass body and the car cannot move at all. 1.6 studs at stock density
-- puts the knuckle in the same order of magnitude as its wheel, which is the
-- physical relationship that matters. Do NOT "improve" this with density.
Chassis.KNUCKLE_SIZE = 1.6
-- Cabin fit --------------------------------------------------------------------
-- The driver used to stick out of the roof for THREE compounding reasons, all
-- fixed here + in CarManager: