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()