import { measure, at, pointSegmentDistance } from './motion.js'; const TAU = Math.PI * 2; const mod = value => (value % TAU + TAU) % TAU; // One continuous trip includes the merge, perimeter and garage. Booking only // the internal road would move the same queue to the perimeter entrance. export function journey(departure, perimeter, finishPath) { const lane = measure(perimeter.points); const ringLength = mod(-departure.entry) * perimeter.unit; const entryDistance = mod(departure.entry) * perimeter.unit; const lanePoints = lane.points.slice(0, -1).map((point, i) => ({ point, ahead: mod((lane.lengths[i] - entryDistance) / perimeter.unit) * perimeter.unit, })).filter(p => p.ahead > 1e-6 && p.ahead < ringLength - 1e-6).sort((a, b) => a.ahead - b.ahead); const points = [...departure.path.points, ...lanePoints.map(p => p.point), ...finishPath.points]; // Remove coincident track endpoints so every timed segment has a duration. const path = measure(points.filter((p, i) => !i || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 1e-7)); const boardLength = departure.boardLength; const timeAt = d => Math.min(d, boardLength) / 240 + Math.max(0, d - boardLength) / 180; return { path, boardLength, ringStart: departure.path.length, finishStart: departure.path.length + ringLength, times: path.lengths.map(timeAt), duration: timeAt(path.length) }; } export function journeyPosition(trip, elapsed) { const boardTime = trip.boardLength / 240; const distance = Math.min(trip.path.length, Math.max(0, Math.min(elapsed, boardTime) * 240 + Math.max(0, elapsed - boardTime) * 180)); return { ...at(trip.path, distance), distance }; } function cursor(actor, time) { const elapsed = time - actor.start, times = actor.journey.times; let lo = 0, hi = times.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (times[mid] <= elapsed + 1e-9) lo = mid + 1; else hi = mid; } return lo; } function position(actor, i, time) { const { path, times } = actor.journey; if (!i) return path.points[0]; const a = path.points[i - 1], b = path.points[i]; const t = (time - actor.start - times[i - 1]) / (times[i] - times[i - 1]); return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; } // Exact closest approach on each overlapping pair of linear time segments. // This checks the swept interval, including between rendered frames at 3×. export function tripsConflict(a, b, now, clearance, parkedClearance = clearance) { const end = Math.min(a.start + a.journey.duration, b.start + b.journey.duration); let time = now, i = cursor(a, time), j = cursor(b, time); while (time < end - 1e-8) { const nextA = a.start + a.journey.times[i], nextB = b.start + b.journey.times[j]; const next = Math.min(end, nextA, nextB); const p = position(a, i, time), q = position(b, j, time); const r = position(a, i, next), s = position(b, j, next); const gap = time < a.start - 1e-8 || time < b.start - 1e-8 ? parkedClearance : clearance; if (pointSegmentDistance({ x: 0, y: 0 }, { x: p.x - q.x, y: p.y - q.y }, { x: r.x - s.x, y: r.y - s.y }) < gap) return true; time = next; if (nextA <= next + 1e-8) i++; if (nextB <= next + 1e-8) j++; } return false; } // The caller supplies the parked-car dependency order. Each trip gets the first // free time slot; unrelated trips can share a start or overlap most of a trip. // All times use one simulation clock, so changing playback speed preserves gaps. export function scheduleDepartures(candidates, { now = 0, active = [], parked = [], clearance, parkedClearance = clearance }) { const planned = [], reservations = [...active]; const released = new Set(); for (const candidate of candidates) { released.add(candidate.car.id); const fixed = parked.filter(p => !released.has(p.id)); const points = candidate.journey.path.points; if (fixed.some(p => points.some((b, i) => i && pointSegmentDistance(p, points[i - 1], b) < parkedClearance))) { throw new Error(`Departure order crosses a parked car: ${candidate.car.id}`); } const actor = { ...candidate, start: now }; // After the last reserved arrival, a statically clear route is always free. const horizon = Math.max(now, ...reservations.map(r => r.start + r.journey.duration)); while (actor.start < horizon && reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) { actor.start = Math.min(horizon, actor.start + .06); } if (reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) { throw new Error(`Existing traffic crosses a waiting car: ${candidate.car.id}`); } planned.push(actor); reservations.push(actor); } return planned; }