fix: close California play and asset acceptance gaps
This commit is contained in:
+18
-4
@@ -89,7 +89,11 @@ function wingGeometry(span: number, rootChord: number, tipChord: number): THREE.
|
||||
]);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
|
||||
geometry.setIndex([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 5]);
|
||||
// Counter-clockwise from above. The reverse winding points the generated
|
||||
// normals down, so Three.js culls the whole wing from the chase/flyover view
|
||||
// while still drawing its shadow — leaving only two apparently floating
|
||||
// ailerons around the fuselage.
|
||||
geometry.setIndex([0, 2, 1, 0, 3, 2, 0, 4, 3, 0, 5, 4]);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
@@ -106,9 +110,14 @@ function addFan(root: THREE.Group, x: number, materials: ElectricAircraftMateria
|
||||
hub.rotation.x = Math.PI / 2;
|
||||
fan.add(hub);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const angle = index * TWO_PI / 5;
|
||||
const blade = mesh(new THREE.BoxGeometry(0.07, 0.68, 0.025), materials.dark, `${fan.name}:blade-${index}`);
|
||||
blade.position.y = 0.3;
|
||||
blade.rotation.z = index * TWO_PI / 5;
|
||||
// Place every blade on its own radial spoke. Rotating five differently
|
||||
// oriented rectangles around the same off-centre point makes a lopsided
|
||||
// paddle; matching centre and local +Y to this angle produces a balanced
|
||||
// rotor whose group can spin continuously around the hub.
|
||||
blade.position.set(-Math.sin(angle) * 0.3, Math.cos(angle) * 0.3, 0);
|
||||
blade.rotation.z = angle;
|
||||
fan.add(blade);
|
||||
}
|
||||
root.add(fan);
|
||||
@@ -122,6 +131,9 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}
|
||||
const materials = options.materials ?? createElectricAircraftMaterials(options.bodyColor);
|
||||
const root = new THREE.Group();
|
||||
root.name = "electric-aircraft";
|
||||
root.userData.kind = "aircraft";
|
||||
root.userData.aircraftModel = "electric-vtail";
|
||||
root.userData.forwardAxis = "-Z";
|
||||
|
||||
const fuselage = mesh(new THREE.CapsuleGeometry(0.66, 5.9, 8, 16), materials.body, "electric-aircraft:fuselage");
|
||||
fuselage.rotation.x = Math.PI / 2;
|
||||
@@ -144,7 +156,9 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}
|
||||
leftAileron.name = "electric-aircraft.aileron-left";
|
||||
rightAileron.name = "electric-aircraft.aileron-right";
|
||||
for (const [joint, x] of [[leftAileron, -4.2], [rightAileron, 4.2]] as const) {
|
||||
joint.position.set(x, 0.48, 0.72);
|
||||
// On the tapered wing's trailing edge. At 0.72 the outer leading corner
|
||||
// sat behind the tip chord and the bright surface read as a floating bar.
|
||||
joint.position.set(x, 0.48, 0.58);
|
||||
joint.add(mesh(new THREE.BoxGeometry(2.15, 0.08, 0.45), materials.accent, `${joint.name}:surface`));
|
||||
root.add(joint);
|
||||
}
|
||||
|
||||
@@ -168,15 +168,23 @@ function checkedEnvelope(value: CaliforniaFlightEnvelope | undefined): Californi
|
||||
return envelope;
|
||||
}
|
||||
|
||||
function checkedRoute(route: readonly AircraftWaypoint[] | undefined): readonly AircraftWaypoint[] {
|
||||
function checkedRoute(
|
||||
route: readonly AircraftWaypoint[] | undefined,
|
||||
envelope: CaliforniaFlightEnvelope,
|
||||
): readonly AircraftWaypoint[] {
|
||||
if (!route) return [];
|
||||
const ids = new Set<string>();
|
||||
return route.map((point) => {
|
||||
if (
|
||||
typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) ||
|
||||
!Number.isFinite(point.lat) || !Number.isFinite(point.lng) ||
|
||||
!Number.isFinite(point.altitudeM)
|
||||
) throw new RangeError("aircraft route waypoints must be finite with unique ids");
|
||||
!Number.isFinite(point.altitudeM) ||
|
||||
point.lat < envelope.minLat || point.lat > envelope.maxLat ||
|
||||
point.lng < envelope.minLng || point.lng > envelope.maxLng ||
|
||||
point.altitudeM < envelope.minAltitudeM || point.altitudeM > envelope.maxAltitudeM
|
||||
) throw new RangeError(
|
||||
"aircraft route waypoints must be finite, unique, and inside the flight envelope",
|
||||
);
|
||||
ids.add(point.id);
|
||||
return { ...point };
|
||||
});
|
||||
@@ -200,7 +208,7 @@ function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
|
||||
initialHeadingDeg: wrapDegrees(finiteOr(value.initialHeadingDeg, 320)),
|
||||
initialSpeedMps: clamp(finiteOr(value.initialSpeedMps, 55), minimumSpeedMps, maximumSpeedMps),
|
||||
mode: value.mode === "manual" ? "manual" : "assisted",
|
||||
route: checkedRoute(value.route),
|
||||
route: checkedRoute(value.route, envelope),
|
||||
envelope,
|
||||
minimumSpeedMps,
|
||||
maximumSpeedMps,
|
||||
|
||||
@@ -104,6 +104,23 @@ export function buildDog(options: DogBuildOptions = {}): DogRig {
|
||||
scale: [1, 0.82, 1],
|
||||
}),
|
||||
);
|
||||
// Slightly proud of the skull so the chase camera gets a readable gaze
|
||||
// instead of a blank mask. The tiny warm catchlights remain visible against
|
||||
// every supported coat without introducing another material or texture.
|
||||
for (const side of [-1, 1] as const) {
|
||||
const word = side < 0 ? "left" : "right";
|
||||
head.add(
|
||||
actorMesh(`dog.eye.${word}`, new THREE.SphereGeometry(0.023, 8, 6), m.nose, {
|
||||
position: [side * 0.075, 0.025, -0.128],
|
||||
scale: [0.85, 1, 0.58],
|
||||
receiveShadow: false,
|
||||
}),
|
||||
actorMesh(`dog.eye-catchlight.${word}`, new THREE.SphereGeometry(0.006, 6, 4), m.markings, {
|
||||
position: [side * 0.079, 0.031, -0.145],
|
||||
receiveShadow: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const side of [-1, 1] as const) {
|
||||
const word = side < 0 ? "left" : "right";
|
||||
const ear = namedGroup(`dog.ear.${word}`, [side * 0.1, 0.105, -0.015]);
|
||||
|
||||
@@ -64,6 +64,14 @@ describe("procedural actor assets", () => {
|
||||
assert.ok(size.y <= DOG_METRICS.height + 0.08, `height ${size.y}`);
|
||||
assert.ok(size.z >= DOG_METRICS.length - 0.1, `length ${size.z}`);
|
||||
assert.equal(rig.root.userData.actorType, "anonymous-dog");
|
||||
for (const side of ["left", "right"] as const) {
|
||||
const eye = rig.root.getObjectByName(`dog.eye.${side}`);
|
||||
const catchlight = rig.root.getObjectByName(`dog.eye-catchlight.${side}`);
|
||||
assert.ok(eye instanceof THREE.Mesh);
|
||||
assert.ok(catchlight instanceof THREE.Mesh);
|
||||
assert.ok(eye.getWorldPosition(new THREE.Vector3()).z < 0, `${side} eye faces -Z`);
|
||||
assert.ok(catchlight.getWorldPosition(new THREE.Vector3()).z < eye.getWorldPosition(new THREE.Vector3()).z);
|
||||
}
|
||||
|
||||
const clone = cloneDog(rig);
|
||||
assertSharedResources(rig.root, clone.root);
|
||||
|
||||
@@ -24,6 +24,8 @@ describe("procedural electric aircraft", () => {
|
||||
it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => {
|
||||
const rig = buildElectricAircraft();
|
||||
assert.equal(rig.root.name, "electric-aircraft");
|
||||
assert.equal(rig.root.userData.forwardAxis, "-Z");
|
||||
assert.equal(rig.root.userData.aircraftModel, "electric-vtail");
|
||||
assert.equal(rig.fans.length, 2);
|
||||
assert.equal(rig.ownsMaterials, true);
|
||||
assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length);
|
||||
@@ -32,10 +34,36 @@ describe("procedural electric aircraft", () => {
|
||||
assert.ok(size.x > 10);
|
||||
assert.ok(size.z > 7);
|
||||
assert.ok(size.y > 1.5);
|
||||
assert.ok(rig.leftAileron.position.z <= 0.6, "left aileron remains attached to tapered trailing edge");
|
||||
assert.ok(rig.rightAileron.position.z <= 0.6, "right aileron remains attached to tapered trailing edge");
|
||||
const wing = rig.root.getObjectByName("electric-aircraft:wing");
|
||||
assert.ok(wing instanceof THREE.Mesh);
|
||||
const normals = wing.geometry.getAttribute("normal");
|
||||
assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).every((y) => y > 0),
|
||||
"wing front faces point toward the chase/flyover camera");
|
||||
disposeElectricAircraft(rig);
|
||||
assert.equal(rig.root.children.length, 0);
|
||||
});
|
||||
|
||||
it("distributes every fan blade evenly around its hub", () => {
|
||||
const rig = buildElectricAircraft();
|
||||
for (const fan of rig.fans) {
|
||||
const blades = fan.children.filter((child) => child.name.includes(":blade-"));
|
||||
assert.equal(blades.length, 5);
|
||||
const centroid = blades.reduce(
|
||||
(sum, blade) => sum.add(blade.position),
|
||||
new THREE.Vector3(),
|
||||
).multiplyScalar(1 / blades.length);
|
||||
assert.ok(centroid.length() < 1e-12, `fan centroid ${centroid.toArray()}`);
|
||||
for (const blade of blades) {
|
||||
assert.ok(Math.abs(blade.position.length() - 0.3) < 1e-12);
|
||||
const radial = new THREE.Vector3(0, 1, 0).applyQuaternion(blade.quaternion);
|
||||
assert.ok(radial.angleTo(blade.position.clone().normalize()) < 1e-7);
|
||||
}
|
||||
}
|
||||
disposeElectricAircraft(rig);
|
||||
});
|
||||
|
||||
it("animates opposing ailerons, V-tail surfaces, and electric fans", () => {
|
||||
const rig = buildElectricAircraft();
|
||||
setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 });
|
||||
@@ -174,6 +202,12 @@ describe("aircraft controller", () => {
|
||||
assert.throws(() => new AircraftController({
|
||||
route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }],
|
||||
}), /waypoints/);
|
||||
assert.throws(() => new AircraftController({
|
||||
route: [{ id: "outside-california", lat: 45, lng: -118, altitudeM: 1_000 }],
|
||||
}), /waypoints/);
|
||||
assert.throws(() => new AircraftController({
|
||||
route: [{ id: "above-envelope", lat: 37, lng: -122, altitudeM: 8_000 }],
|
||||
}), /waypoints/);
|
||||
assert.throws(() => new AircraftController({
|
||||
envelope: {
|
||||
minLat: 40,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
normalizeVehicleActions,
|
||||
replayVehicleInputs,
|
||||
} from "../transport/vehicleController.ts";
|
||||
import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts";
|
||||
|
||||
describe("vehicle controller", () => {
|
||||
it("normalizes arbitrary adapter input into safe device-neutral actions", () => {
|
||||
@@ -161,6 +162,37 @@ describe("vehicle controller", () => {
|
||||
assert.equal(controller.state().elapsedSteps, 0);
|
||||
});
|
||||
|
||||
it("preserves the authored Bay endpoint across completion restore and reverse handoff", () => {
|
||||
const route = buildRoutePath(CALIFORNIA_TRANSPORT, "la-sf-us-101");
|
||||
const endpoint = sampleRoute(route, route.lengthM);
|
||||
assert.ok(Math.abs(endpoint.lat - 37.7749) < 1e-9);
|
||||
assert.ok(Math.abs(endpoint.lng - -122.4194) < 1e-9);
|
||||
|
||||
const restored = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
initialDistanceM: route.lengthM,
|
||||
});
|
||||
assert.equal(restored.state().progress, 1);
|
||||
assert.ok(Math.abs(restored.state().lat - endpoint.lat) < 1e-12);
|
||||
assert.ok(Math.abs(restored.state().lng - endpoint.lng) < 1e-12);
|
||||
|
||||
restored.setRoute("la-sf-i-5", true);
|
||||
assert.equal(restored.state().progress, 1);
|
||||
assert.ok(Math.abs(restored.state().lat - 37.7749) < 1e-9);
|
||||
assert.ok(Math.abs(restored.state().lng - -122.4194) < 1e-9);
|
||||
|
||||
const southbound = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-i-5",
|
||||
direction: -1,
|
||||
initialSpeedMps: 20,
|
||||
});
|
||||
assert.equal(southbound.state().progress, 1);
|
||||
const before = southbound.state().distanceM;
|
||||
southbound.stepFixed({ throttle: 0.5 });
|
||||
assert.ok(southbound.state().distanceM < before);
|
||||
assert.ok(southbound.state().progress < 1 && southbound.state().progress > 0.99);
|
||||
});
|
||||
|
||||
it("compresses corridor progress without changing vehicle dynamics", () => {
|
||||
const normal = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
|
||||
@@ -72,7 +72,7 @@ export interface VehicleControllerState extends GeographicPoint {
|
||||
routeId: string;
|
||||
mode: VehicleControlMode;
|
||||
direction: 1 | -1;
|
||||
/** Distance from the route's declared start, wrapped to its total length. */
|
||||
/** Distance from the route's declared start. A restored endpoint may equal route length. */
|
||||
distanceM: number;
|
||||
progress: number;
|
||||
/** Signed offset from route centre; positive is to the driver's right. */
|
||||
@@ -212,6 +212,9 @@ export class VehicleController {
|
||||
this.pack = pack;
|
||||
this.options = resolveOptions(options);
|
||||
this.path = buildRoutePath(pack, this.options.routeId);
|
||||
if (options.initialDistanceM === undefined && this.options.direction === -1) {
|
||||
this.options.initialDistanceM = this.path.lengthM;
|
||||
}
|
||||
const sample = sampleRoute(this.path, this.options.initialDistanceM, this.options.direction);
|
||||
const point = offsetPoint(sample, 0);
|
||||
this.current = {
|
||||
@@ -257,7 +260,12 @@ export class VehicleController {
|
||||
/** Restore the configured spawn state and clear pending fractional time. */
|
||||
reset(): void {
|
||||
this.accumulator = 0;
|
||||
const distanceM = wrap(this.options.initialDistanceM, this.path.lengthM);
|
||||
const wrappedDistanceM = wrap(this.options.initialDistanceM, this.path.lengthM);
|
||||
const distanceM = wrappedDistanceM === 0 &&
|
||||
this.options.initialDistanceM > 0 &&
|
||||
this.options.initialDistanceM <= this.path.lengthM
|
||||
? this.path.lengthM
|
||||
: wrappedDistanceM;
|
||||
const lateralOffsetM = clamp(
|
||||
this.options.initialLateralOffsetM,
|
||||
-this.options.guardrailOffsetM,
|
||||
|
||||
@@ -116,7 +116,14 @@ function wrap(value: number, modulus: number): number {
|
||||
}
|
||||
|
||||
export function sampleRoute(path: RoutePath, distanceM: number, direction: 1 | -1 = 1): RouteSample {
|
||||
const travelled = wrap(distanceM, path.lengthM);
|
||||
const wrapped = wrap(distanceM, path.lengthM);
|
||||
// Preserve the authored endpoint when a caller deliberately samples an
|
||||
// exact positive route length. Simulation steps store their already-wrapped
|
||||
// zero and still loop normally; restored journey progress=1 must remain at
|
||||
// San Francisco instead of teleporting to Los Angeles before play resumes.
|
||||
const travelled = wrapped === 0 && distanceM > 0 && distanceM <= path.lengthM
|
||||
? path.lengthM
|
||||
: wrapped;
|
||||
const leg = path.legs.find((candidate) => travelled <= candidate.endM) ?? path.legs.at(-1);
|
||||
if (!leg) throw new Error(`transport: route "${path.route.id}" has no legs`);
|
||||
const t = Math.max(0, Math.min(1, (travelled - leg.startM) / leg.lengthM));
|
||||
|
||||
Reference in New Issue
Block a user