import { CORE_MACHINE_BY_ID, CORE_MACHINE_DEFINITIONS } from '../modules/machines/coreMachines'; import type { AnimationTrackDefinition, Axis3D, CycleStepDefinition, MachineAnimationDefinition, RegistryValidationResult, } from '../modules/machines/types'; function rotation( id: string, partId: string, axis: Axis3D, rpmMultiplier: number, phaseDegrees = 0, parameters: AnimationTrackDefinition['parameters'] = {}, ): AnimationTrackDefinition { return { id, kind: 'rotation', partId, axis, rpmMultiplier, phaseDegrees, easing: 'linear', parameters, }; } function linear( id: string, partId: string, axis: Axis3D, amplitude: number, phaseDegrees = 0, parameters: AnimationTrackDefinition['parameters'] = {}, ): AnimationTrackDefinition { return { id, kind: 'linear-oscillation', partId, axis, amplitude, phaseDegrees, easing: 'sine-in-out', parameters, }; } function angular( id: string, partId: string, axis: Axis3D, amplitudeDegrees: number, phaseDegrees = 0, parameters: AnimationTrackDefinition['parameters'] = {}, ): AnimationTrackDefinition { return { id, kind: 'angular-oscillation', partId, axis, amplitudeDegrees, phaseDegrees, easing: 'sine-in-out', parameters, }; } function orbit( id: string, partId: string, axis: Axis3D, rpmMultiplier: number, amplitude: number, phaseDegrees = 0, parameters: AnimationTrackDefinition['parameters'] = {}, ): AnimationTrackDefinition { return { id, kind: 'orbit', partId, axis, rpmMultiplier, amplitude, phaseDegrees, easing: 'linear', parameters, }; } function flow( id: string, partId: string, rpmMultiplier: number, phaseDegrees = 0, parameters: AnimationTrackDefinition['parameters'] = {}, ): AnimationTrackDefinition { return { id, kind: 'flow', partId, rpmMultiplier, phaseDegrees, easing: 'linear', parameters, }; } function pulse( id: string, partId: string, phaseDegrees = 0, parameters: AnimationTrackDefinition['parameters'] = {}, ): AnimationTrackDefinition { return { id, kind: 'material-pulse', partId, phaseDegrees, easing: 'sine-in-out', parameters, }; } function scalePulse( id: string, partId: string, amplitude: number, phaseDegrees = 0, parameters: AnimationTrackDefinition['parameters'] = {}, ): AnimationTrackDefinition { return { id, kind: 'scale-pulse', partId, amplitude, phaseDegrees, easing: 'sine-in-out', parameters, }; } function visibility( id: string, partId: string, phaseDegrees: number, parameters: AnimationTrackDefinition['parameters'], ): AnimationTrackDefinition { return { id, kind: 'visibility-window', partId, phaseDegrees, easing: 'hold', parameters, }; } function ratioMorph( id: string, partIds: readonly string[], parameters: AnimationTrackDefinition['parameters'], ): AnimationTrackDefinition { return { id, kind: 'ratio-morph', partIds, easing: 'sine-in-out', parameters, }; } function indexed( id: string, partIds: readonly string[], parameters: AnimationTrackDefinition['parameters'], ): AnimationTrackDefinition { return { id, kind: 'indexed-step', partIds, easing: 'step', parameters, }; } function step( id: string, label: string, atNormalizedTime: number, durationNormalized: number, description: string, activePartIds: readonly string[], ): CycleStepDefinition { return { id, label, atNormalizedTime, durationNormalized, description, activePartIds }; } function simpleSteps( activePartIds: readonly string[], labels: readonly [string, string, string, string] = ['Phase A', 'Phase B', 'Phase C', 'Phase D'], ): readonly CycleStepDefinition[] { return [ step('phase-a', labels[0], 0, 0.25, `${labels[0]} of the cycle.`, activePartIds), step('phase-b', labels[1], 0.25, 0.25, `${labels[1]} of the cycle.`, activePartIds), step('phase-c', labels[2], 0.5, 0.25, `${labels[2]} of the cycle.`, activePartIds), step('phase-d', labels[3], 0.75, 0.25, `${labels[3]} of the cycle.`, activePartIds), ]; } function animation( machineId: string, cycleSeconds: number, defaultRpm: number, rpmRange: readonly [number, number], loopMode: MachineAnimationDefinition['loopMode'], cycleSteps: readonly CycleStepDefinition[], tracks: readonly AnimationTrackDefinition[], notes: readonly string[], ): MachineAnimationDefinition { const machine = CORE_MACHINE_BY_ID[machineId]; return { id: `anim-${machineId}`, machineId, title: machine ? `${machine.title} working cycle` : `${machineId} working cycle`, cycleSeconds, defaultRpm, rpmRange, timeScaleRange: [0.1, 3], loopMode, physicallyBelievableNotes: notes, cycleSteps, tracks, }; } export const CORE_MACHINE_ANIMATIONS: readonly MachineAnimationDefinition[] = [ animation( 'four-stroke-petrol-engine', 4, 900, [150, 6500], 'continuous', [ step('intake', 'Intake', 0, 0.25, 'Piston descends while the intake valve opens and the cylinder draws fresh mixture.', ['piston', 'intake-valve', 'intake-manifold']), step('compression', 'Compression', 0.25, 0.25, 'Both valves close and the piston rises to compress the charge.', ['piston', 'combustion-volume']), step('combustion', 'Combustion / power', 0.5, 0.25, 'Spark event and expanding gases drive the piston downward.', ['spark-plug', 'combustion-volume', 'crankshaft']), step('exhaust', 'Exhaust', 0.75, 0.25, 'The exhaust valve opens and the piston pushes burned gas out.', ['exhaust-valve', 'exhaust-manifold']), ], [ rotation('crank-rotation', 'crankshaft', 'z', 1), rotation('flywheel-rotation', 'flywheel', 'z', 1), rotation('cam-half-speed', 'camshaft', 'z', 0.5), linear('piston-reciprocation', 'piston', 'y', 0.46, 180, { secondaryRodCorrection: 0.08 }), angular('rod-swing', 'connecting-rod', 'z', 10, 90), linear('intake-valve-lift', 'intake-valve', 'y', 0.16, 30, { activeWindow: [0.0, 0.22] }), linear('exhaust-valve-lift', 'exhaust-valve', 'y', 0.16, 300, { activeWindow: [0.72, 0.98] }), pulse('spark-pulse', 'spark-plug', 360, { window: [0.49, 0.53], intensity: 1 }), scalePulse('combustion-glow', 'combustion-volume', 0.45, 380, { window: [0.5, 0.68] }), flow('intake-flow', 'intake-manifold', 1, 15, { activeWindow: [0.0, 0.22] }), flow('exhaust-flow', 'exhaust-manifold', 1, 285, { activeWindow: [0.72, 0.98] }), ], [ 'One animation cycle represents two crankshaft revolutions.', 'Camshaft speed is constrained to half crankshaft speed.', 'Valve lift windows are intentionally broadened so learners can see overlap at low playback speed.', ], ), animation( 'two-stroke-engine', 2, 1800, [500, 12000], 'continuous', [ step('compression', 'Compression and intake', 0, 0.5, 'Piston rises, compressing cylinder charge while crankcase draws fresh mixture.', ['piston', 'intake-port', 'reed-valve']), step('power', 'Power and crankcase compression', 0.5, 0.22, 'Ignition drives the piston down and compresses fresh mixture below.', ['spark-plug', 'crankcase']), step('blowdown', 'Exhaust blowdown', 0.72, 0.12, 'Exhaust port opens first and pressure begins to leave the cylinder.', ['exhaust-port', 'expansion-chamber']), step('transfer', 'Transfer scavenging', 0.84, 0.16, 'Transfer port opens and fresh mixture sweeps through the cylinder.', ['transfer-port', 'fuel-air-flow']), ], [ rotation('crank-rotation', 'crankshaft', 'z', 1), linear('piston-reciprocation', 'piston', 'y', 0.45, 180), angular('rod-swing', 'connecting-rod', 'z', 11, 90), flow('fuel-flow', 'fuel-air-flow', 1, 0, { segments: ['intake', 'crankcase', 'transfer', 'cylinder'] }), pulse('spark-event', 'spark-plug', 350, { window: [0.48, 0.52] }), visibility('intake-window', 'intake-port', 0, { visibleWindow: [0.05, 0.42] }), visibility('transfer-window', 'transfer-port', 285, { visibleWindow: [0.78, 0.96] }), visibility('exhaust-window', 'exhaust-port', 260, { visibleWindow: [0.70, 0.98] }), flow('exhaust-pulse', 'expansion-chamber', 1, 260, { reflectedWave: true }), angular('reed-flex', 'reed-valve', 'z', 7, 40, { activeWindow: [0.05, 0.42] }), ], [ 'One cycle equals one crankshaft revolution.', 'Port visibility windows are tied to piston position rather than cam motion.', 'Scavenging flow is diagrammatic and intentionally semi-transparent.', ], ), animation( 'diesel-engine', 4, 850, [150, 4500], 'continuous', [ step('intake', 'Air intake', 0, 0.25, 'Air alone enters through the intake valve.', ['intake-valve', 'high-compression-piston']), step('compression', 'High compression', 0.25, 0.25, 'The piston compresses air until temperature rises enough for ignition.', ['high-compression-piston', 'combustion-bowl']), step('injection', 'Injection and burn', 0.5, 0.25, 'Injector sprays diesel into hot air and a diffusion flame forms.', ['injector', 'fuel-rail', 'combustion-bowl']), step('exhaust', 'Exhaust', 0.75, 0.25, 'Burned gases leave through the exhaust valve.', ['exhaust-valve']), ], [ rotation('crank-rotation', 'crankshaft', 'z', 1), rotation('flywheel-rotation', 'flywheel', 'z', 1), rotation('cam-half-speed', 'camshaft', 'z', 0.5), linear('piston-reciprocation', 'high-compression-piston', 'y', 0.48, 180, { compressionRatioCue: 17 }), angular('rod-swing', 'connecting-rod', 'z', 9, 90), linear('intake-valve-lift', 'intake-valve', 'y', 0.15, 25, { activeWindow: [0.0, 0.22] }), linear('exhaust-valve-lift', 'exhaust-valve', 'y', 0.15, 300, { activeWindow: [0.73, 0.98] }), pulse('injector-pulse', 'injector', 358, { window: [0.49, 0.55], sprayCone: true }), flow('fuel-rail-pulse', 'fuel-rail', 1, 352, { activeWindow: [0.49, 0.55] }), scalePulse('diesel-combustion', 'combustion-bowl', 0.55, 368, { window: [0.5, 0.72] }), ], [ 'Cycle timing follows a four-stroke engine, but fuel is injected directly near top dead centre.', 'The glow plug is not pulsed during normal running; it remains selectable as a starting aid.', ], ), animation( 'v8-engine', 4, 900, [300, 7500], 'continuous', simpleSteps(['crankshaft', 'pistons-left-bank', 'pistons-right-bank', 'ignition-coils'], [ 'Cylinders 1 & 6 reference', 'Cross-bank firing', 'Opposite crank throws', 'Cycle repeat', ]), [ rotation('crank-rotation', 'crankshaft', 'z', 1), rotation('flywheel-rotation', 'flywheel', 'z', 1), rotation('cam-half-speed', 'camshaft', 'z', 0.5), rotation('timing-chain-motion', 'timing-chain', 'z', 0.5, 0, { beltLoop: true }), linear('left-bank-piston-array', 'pistons-left-bank', 'y', 0.28, 180, { phaseArrayDegrees: [0, 270, 90, 180] }), linear('right-bank-piston-array', 'pistons-right-bank', 'y', 0.28, 180, { phaseArrayDegrees: [180, 90, 270, 0] }), angular('rod-array-swing', 'connecting-rods', 'z', 8, 90), flow('intake-pulses', 'intake-manifold', 1, 0, { cylinders: 8, firingOrder: [1, 8, 4, 3, 6, 5, 7, 2] }), flow('exhaust-pulses', 'exhaust-headers', 1, 180, { cylinders: 8, firingOrder: [1, 8, 4, 3, 6, 5, 7, 2] }), indexed('coil-firing-order', ['ignition-coils', 'firing-order-indicator'], { sequence: [1, 8, 4, 3, 6, 5, 7, 2], stepsPerCycle: 8 }), ], [ 'The grouped piston tracks carry phase arrays to avoid eight separate meshes in the default procedural model.', 'A detailed GLB can expose each piston as a child mesh while preserving the grouped id for bulk controls.', ], ), animation( 'wankel-rotary-engine', 3, 1200, [500, 9000], 'continuous', simpleSteps(['triangular-rotor', 'eccentric-shaft', 'combustion-chambers'], [ 'Intake chamber grows', 'Compression chamber shrinks', 'Combustion expands', 'Exhaust opens', ]), [ rotation('eccentric-shaft', 'eccentric-shaft', 'z', 3), rotation('rotor-spin', 'triangular-rotor', 'z', 1, 0, { gearRatioToShaft: 0.333333 }), orbit('rotor-orbit', 'triangular-rotor', 'z', 1, 0.22, 0, { maintainApexSealContact: true }), rotation('output-gear-reference', 'output-gear', 'z', -1), pulse('spark-plug-pulse', 'spark-plugs', 240, { repeatedFaces: 3, window: [0.35, 0.42] }), scalePulse('chamber-volume-phase', 'combustion-chambers', 0.5, 120, { chamberCount: 3 }), flow('intake-port-flow', 'intake-port', 1, 20, { activeWindow: [0.0, 0.28] }), flow('exhaust-port-flow', 'exhaust-port', 1, 250, { activeWindow: [0.7, 0.98] }), pulse('apex-seal-contact', 'apex-seals', 0, { trail: true, contactPoints: 3 }), ], [ 'Shaft-to-rotor ratio is represented as 3:1.', 'The chamber volumes are semantic overlays rather than sealed CFD regions.', ], ), animation( 'steam-engine', 3, 120, [30, 400], 'continuous', [ step('left-admission', 'Left-side admission', 0, 0.25, 'Slide valve admits steam to one side of the piston.', ['boiler-steam-chest', 'slide-valve', 'piston']), step('right-exhaust', 'Right-side exhaust', 0.25, 0.25, 'Opposite cylinder side exhausts through the valve chest.', ['slide-valve', 'exhaust-stack']), step('right-admission', 'Right-side admission', 0.5, 0.25, 'Valve reverses admission to drive the piston back.', ['boiler-steam-chest', 'slide-valve', 'piston']), step('left-exhaust', 'Left-side exhaust', 0.75, 0.25, 'Spent steam leaves as the flywheel carries through dead centre.', ['flywheel', 'exhaust-stack']), ], [ rotation('crank-rotation', 'crankshaft', 'z', 1), rotation('flywheel-rotation', 'flywheel', 'z', 1), rotation('eccentric-rotation', 'eccentric', 'z', 1, 80), linear('piston-motion', 'piston', 'x', 0.42, 0), linear('piston-rod-motion', 'piston-rod', 'x', 0.42, 0), angular('connecting-rod-angle', 'connecting-rod', 'z', 9, 90), linear('slide-valve-motion', 'slide-valve', 'x', 0.18, 80), angular('valve-gear-linkage', 'valve-gear-linkage', 'z', 8, 80), rotation('governor-spin', 'centrifugal-governor', 'y', 1.4), angular('governor-ball-rise', 'centrifugal-governor', 'x', 12, 0, { speedSensitive: true }), flow('steam-chest-flow', 'boiler-steam-chest', 1, 0, { alternatingCylinderSides: true }), flow('exhaust-flow', 'exhaust-stack', 1, 180, { alternatingCylinderSides: true }), ], [ 'Valve motion leads crank motion by an eccentric phase offset.', 'Governor arm angle is a qualitative speed cue, not a full flyball dynamics solver.', ], ), animation( 'turbojet-engine', 1.6, 9000, [1000, 22000], 'continuous', simpleSteps(['airflow-path', 'compressor-spool', 'combustion-chamber', 'turbine-spool'], [ 'Intake', 'Compression', 'Combustion', 'Expansion / exhaust', ]), [ rotation('compressor-spool', 'compressor-spool', 'x', 1), rotation('turbine-spool', 'turbine-spool', 'x', 1), rotation('core-shaft', 'core-shaft', 'x', 1), flow('core-airflow', 'airflow-path', 1, 0, { pressureGradient: true, temperatureGradient: true }), pulse('combustor-glow', 'combustion-chamber', 0, { steady: true, flicker: 0.08 }), pulse('fuel-injector-ring', 'fuel-injector-ring', 0, { steadySpray: true }), angular('compressor-stator-guide', 'compressor-stators', 'x', 2, 0, { shimmerOnly: true }), angular('turbine-stator-heat', 'turbine-stators', 'x', 2, 0, { heatShimmer: true }), ], [ 'Gas turbine flow is continuous, so cycle steps are educational stations rather than discrete piston strokes.', 'A single-spool relationship keeps compressor, shaft, and turbine at the same RPM multiplier.', ], ), animation( 'turbofan-engine', 1.6, 4500, [800, 12000], 'continuous', simpleSteps(['fan', 'bypass-flow', 'core-flow', 'low-pressure-turbine'], [ 'Fan split', 'Core compression', 'Combustion', 'Turbine powers fan', ]), [ rotation('fan-rotation', 'fan', 'x', 0.45), rotation('lp-compressor', 'low-pressure-compressor', 'x', 0.65), rotation('hp-compressor', 'high-pressure-compressor', 'x', 1.35), rotation('hp-turbine', 'high-pressure-turbine', 'x', 1.35), rotation('lp-turbine', 'low-pressure-turbine', 'x', 0.65), rotation('core-shaft', 'core-shaft', 'x', 1.35), rotation('fan-shaft', 'fan-shaft', 'x', 0.65), flow('bypass-air', 'bypass-flow', 0.55, 0, { annular: true, massFlowShare: 0.8 }), flow('core-air', 'core-flow', 1.2, 0, { temperatureGradient: true, massFlowShare: 0.2 }), pulse('combustor-glow', 'combustor', 0, { steady: true, flicker: 0.06 }), ], [ 'Twin-spool multipliers communicate different shaft speeds without requiring separate physics solvers.', 'Bypass and core mass-flow shares are visual teaching cues and can be overridden per engine variant.', ], ), animation( 'planetary-gearbox', 2.4, 120, [10, 1200], 'continuous', simpleSteps(['sun-gear', 'planet-gears', 'planet-carrier', 'ring-gear'], [ 'Sun input', 'Planets spin', 'Carrier outputs', 'Ring reaction', ]), [ rotation('sun-input', 'sun-gear', 'y', 1), orbit('planet-orbit', 'planet-gears', 'y', 0.33, 0.62, 0, { aroundPartId: 'sun-gear' }), rotation('planet-spin', 'planet-gears', 'y', -1.67, 0, { count: 3, meshWith: ['sun-gear', 'ring-gear'] }), rotation('carrier-output', 'planet-carrier', 'y', 0.33), rotation('input-shaft', 'input-shaft', 'y', 1), rotation('output-shaft', 'output-shaft', 'y', 0.33), pulse('brake-band-hold', 'brake-band', 0, { holdingPartId: 'ring-gear' }), flow('lubrication', 'lubrication-flow', 1, 0), ], [ 'Default mode is sun input, ring held, carrier output.', 'The animation schema supports alternate selectable input/output mappings through ratio-morph parameters.', ], ), animation( 'differential-gear', 2.4, 180, [10, 1200], 'continuous', simpleSteps(['drive-pinion', 'ring-gear', 'spider-gears', 'left-axle-shaft', 'right-axle-shaft'], [ 'Pinion drives ring', 'Straight-line case rotation', 'Cornering difference', 'Torque exits axles', ]), [ rotation('drive-pinion', 'drive-pinion', 'z', 3.4), rotation('ring-gear', 'ring-gear', 'x', 1), rotation('case-rotation', 'differential-case', 'x', 1), rotation('left-side-gear', 'side-gear-left', 'x', 0.78, 0, { corneringMultiplier: 0.65 }), rotation('right-side-gear', 'side-gear-right', 'x', 1.22, 0, { corneringMultiplier: 1.35 }), rotation('left-axle', 'left-axle-shaft', 'x', 0.78, 0, { corneringMultiplier: 0.65 }), rotation('right-axle', 'right-axle-shaft', 'x', 1.22, 0, { corneringMultiplier: 1.35 }), rotation('spider-spin', 'spider-gears', 'y', 0.45, 0, { activeWhen: 'cornering' }), flow('torque-flow', 'torque-flow', 1, 0, { split: [0.5, 0.5] }), ], [ 'Cornering state is represented by unequal axle multipliers while torque-flow remains split.', 'Straight-line demonstration can set both side multipliers to 1 and spider spin to 0.', ], ), animation( 'manual-gearbox-5-speed', 3, 900, [100, 6500], 'stepped-indexed', [ step('gear-1', '1st gear', 0, 0.16, 'High reduction gear pair locked for launch torque.', ['first-gear-pair', 'synchronizer-hubs']), step('gear-2', '2nd gear', 0.16, 0.16, 'Second gear selected for lower reduction.', ['second-gear-pair', 'synchronizer-hubs']), step('gear-3', '3rd gear', 0.32, 0.16, 'Mid ratio selected.', ['third-gear-pair', 'synchronizer-hubs']), step('gear-4', '4th gear', 0.48, 0.16, 'Near-direct gear selected.', ['fourth-gear-pair', 'synchronizer-hubs']), step('gear-5', '5th gear', 0.64, 0.16, 'Overdrive gear selected.', ['fifth-gear-pair', 'synchronizer-hubs']), step('reverse', 'Reverse', 0.8, 0.2, 'Reverse idler engages to invert output direction.', ['reverse-idler', 'selector-forks']), ], [ rotation('input-shaft', 'input-shaft', 'z', 1), rotation('clutch-gear', 'clutch-gear', 'z', 1), rotation('layshaft', 'layshaft', 'z', -0.82), rotation('output-shaft', 'output-shaft', 'z', 0.26, 0, { selectedGearRatios: [0.26, 0.42, 0.62, 0.95, 1.18, -0.31] }), rotation('gear-pair-1', 'first-gear-pair', 'z', 0.26), rotation('gear-pair-2', 'second-gear-pair', 'z', 0.42), rotation('gear-pair-3', 'third-gear-pair', 'z', 0.62), rotation('gear-pair-4', 'fourth-gear-pair', 'z', 0.95), rotation('gear-pair-5', 'fifth-gear-pair', 'z', 1.18), rotation('reverse-idler', 'reverse-idler', 'y', -0.48, 0, { activeStep: 'reverse' }), indexed('selector-index', ['selector-forks', 'shift-rail', 'synchronizer-hubs'], { steps: ['gear-1', 'gear-2', 'gear-3', 'gear-4', 'gear-5', 'reverse'], forkTravel: 0.26 }), ], [ 'The output-shaft multiplier is indexed by selected gear for deterministic step-through.', 'Forward gear pairs are always shown rotating to teach constant-mesh construction.', ], ), animation( 'cvt-transmission', 3, 900, [100, 7000], 'reciprocating', simpleSteps(['drive-pulley-moving-sheave', 'driven-pulley-moving-sheave', 'steel-belt'], [ 'Low ratio', 'Ratio increasing', 'High ratio', 'Ratio decreasing', ]), [ rotation('input-shaft', 'input-shaft', 'z', 1), rotation('output-shaft', 'output-shaft', 'z', 0.45, 0, { ratioRange: [0.45, 1.65] }), rotation('drive-fixed', 'drive-pulley-fixed-sheave', 'z', 1), rotation('drive-moving', 'drive-pulley-moving-sheave', 'z', 1), rotation('driven-fixed', 'driven-pulley-fixed-sheave', 'z', 0.45, 0, { ratioRange: [0.45, 1.65] }), rotation('driven-moving', 'driven-pulley-moving-sheave', 'z', 0.45, 0, { ratioRange: [0.45, 1.65] }), ratioMorph('cvt-ratio-morph', ['drive-pulley-moving-sheave', 'driven-pulley-moving-sheave', 'steel-belt', 'ratio-actuator'], { driveRadiusRange: [0.22, 0.42], drivenRadiusRange: [0.5, 0.28], sheaveTravel: 0.16 }), flow('belt-motion', 'steel-belt', 1, 0, { segmented: true }), linear('actuator-travel', 'ratio-actuator', 'x', 0.16, 0), ], [ 'Ratio morph keeps belt length visually constant while effective pitch radii trade places.', 'The output multiplier is intentionally dynamic and should be sampled from the ratio track.', ], ), animation( 'worm-gear-drive', 3, 300, [20, 3000], 'continuous', simpleSteps(['worm-thread', 'worm-wheel', 'self-locking-brake-arm'], [ 'Worm input', 'Wheel advances', 'Load resists', 'Self-locking cue', ]), [ rotation('worm-shaft', 'worm-shaft', 'x', 1), rotation('worm-thread', 'worm-thread', 'x', 1), rotation('worm-wheel', 'worm-wheel', 'z', -0.025, 0, { toothRatio: 40 }), rotation('output-shaft', 'output-shaft', 'z', -0.025), pulse('thrust-bearing-load', 'thrust-bearing', 0, { axialLoad: true }), flow('oil-bath', 'lubrication-bath', 0.2, 0, { splash: true }), angular('brake-arm-deflection', 'self-locking-brake-arm', 'z', 4, 180, { backDrivePrevented: true }), pulse('load-indicator', 'load-indicator', 180, { label: 'self-locking' }), ], [ 'Default ratio is one-start worm driving a 40-tooth wheel.', 'Self-locking is presented as a qualitative load cue because true behaviour depends on lead angle, friction, and lubrication.', ], ), animation( 'bevel-gear-set', 2.2, 240, [20, 3000], 'continuous', simpleSteps(['input-bevel-pinion', 'output-bevel-gear', 'bearing-set'], [ 'Input shaft turns', 'Teeth mesh', 'Output turns 90°', 'Bearing loads', ]), [ rotation('input-shaft', 'input-shaft', 'x', 1), rotation('input-pinion', 'input-bevel-pinion', 'x', 1), rotation('output-bevel', 'output-bevel-gear', 'z', -0.5625, 0, { toothRatio: '18:32' }), rotation('output-shaft', 'output-shaft', 'z', -0.5625), pulse('bearing-load', 'bearing-set', 0, { separatingForces: true }), flow('oil-slinger', 'oil-slinger', 1, 0, { splash: true }), pulse('spiral-engagement', 'spiral-tooth-guide', 0, { travelingContactLine: true }), ], [ 'Gear ratio follows the 18-tooth pinion and 32-tooth output gear metadata.', 'Spiral engagement is a contact overlay rather than tooth-level simulation.', ], ), animation( 'centrifugal-pump', 2, 1450, [300, 3600], 'continuous', simpleSteps(['inlet-eye', 'impeller', 'volute-casing', 'discharge-nozzle'], [ 'Inlet suction', 'Impeller acceleration', 'Volute recovery', 'Discharge', ]), [ rotation('shaft', 'shaft', 'z', 1), rotation('impeller', 'impeller', 'z', 1), flow('streamlines', 'flow-streamlines', 1, 0, { inletToOutlet: true, pressureGradient: true }), flow('inlet', 'inlet-eye', 1, 0), flow('discharge', 'discharge-nozzle', 1, 90), pulse('cavitation', 'cavitation-indicator', 180, { appearsBelowNpsh: true }), pulse('wear-ring-leakage', 'wear-ring', 0, { leakageCue: true }), rotation('bearings', 'bearings', 'z', 1), ], [ 'Flow arrows are tied to impeller speed but do not model a pump curve.', 'Cavitation indicator is controlled by a semantic parameter so future UI can expose NPSH margin.', ], ), animation( 'gear-pump', 2, 600, [100, 3000], 'continuous', simpleSteps(['drive-gear', 'idler-gear', 'trapped-fluid-volumes'], [ 'Teeth unmesh', 'Pockets fill', 'Pockets carry', 'Teeth mesh / discharge', ]), [ rotation('drive-gear', 'drive-gear', 'z', 1), rotation('idler-gear', 'idler-gear', 'z', -1), rotation('drive-shaft', 'drive-shaft', 'z', 1), flow('inlet-flow', 'inlet-port', 1, 0, { activeWindow: [0.0, 0.35] }), flow('outlet-flow', 'outlet-port', 1, 180, { activeWindow: [0.55, 0.95] }), flow('pocket-loop', 'crescent-pocket', 1, 0, { aroundOutsideOnly: true }), indexed('volume-count', ['trapped-fluid-volumes'], { pocketsPerRevolution: 14 }), pulse('relief-valve', 'relief-valve', 270, { opensAtPressure: true }), ], [ 'External gear pumps move fluid around the casing perimeter, not through the gear mesh centre.', 'Relief valve pulsing is pressure-threshold semantic metadata.', ], ), animation( 'piston-pump', 2.8, 240, [30, 1200], 'continuous', [ step('suction', 'Suction', 0, 0.5, 'Piston retracts and inlet valve opens.', ['reciprocating-piston', 'inlet-valve', 'suction-manifold']), step('discharge', 'Discharge', 0.5, 0.5, 'Piston advances and outlet valve opens.', ['reciprocating-piston', 'outlet-valve', 'discharge-manifold']), ], [ rotation('crankshaft', 'crankshaft', 'z', 1), linear('piston', 'reciprocating-piston', 'x', 0.36, 0), angular('connecting-rod', 'connecting-rod', 'z', 9, 90), angular('inlet-valve-open', 'inlet-valve', 'z', 9, 0, { activeWindow: [0.0, 0.48] }), angular('outlet-valve-open', 'outlet-valve', 'z', 9, 180, { activeWindow: [0.52, 0.98] }), flow('suction-flow', 'suction-manifold', 1, 0, { activeWindow: [0.0, 0.48] }), flow('discharge-flow', 'discharge-manifold', 1, 180, { activeWindow: [0.52, 0.98] }), pulse('gauge-needle', 'pressure-gauge', 180, { pressureRipple: true }), scalePulse('pulsation-damper', 'pulsation-damper', 0.12, 180, { pressureSmoothing: true }), ], [ 'Valve timing is pressure-driven in reality; here it is phase-windowed to the piston stroke.', 'Pressure gauge and damper tracks provide clear visual feedback at low playback speed.', ], ), animation( 'hydraulic-cylinder', 4, 60, [5, 180], 'reciprocating', [ step('extend', 'Extend', 0, 0.5, 'Base port pressure fills the cap-end chamber and extends the rod.', ['base-port', 'pressure-chamber', 'piston-rod']), step('retract', 'Retract', 0.5, 0.5, 'Rod port pressure fills the annular rod-end chamber and retracts the rod.', ['rod-port', 'return-chamber', 'piston-rod']), ], [ linear('piston', 'piston', 'x', 0.58, 0), linear('rod', 'piston-rod', 'x', 0.58, 0), linear('clevis', 'clevis-mount', 'x', 0.58, 0), scalePulse('cap-chamber', 'pressure-chamber', 0.32, 0, { inverseOf: 'return-chamber' }), scalePulse('rod-chamber', 'return-chamber', 0.32, 180, { inverseOf: 'pressure-chamber' }), flow('base-port-flow', 'base-port', 1, 0, { activeWindow: [0.0, 0.5] }), flow('rod-port-flow', 'rod-port', 1, 180, { activeWindow: [0.5, 1.0] }), pulse('rod-seal-friction', 'rod-seal', 90, { sealDragCue: true }), pulse('piston-seal', 'piston-seal', 0, { bypassLeakageCue: false }), ], [ 'The animation is reciprocating rather than rotational; RPM maps to extend/retract cycles per minute.', 'Chamber volumes scale in opposite phase to communicate effective hydraulic area.', ], ), animation( 'scotch-yoke', 2.6, 90, [5, 600], 'continuous', simpleSteps(['rotating-crank', 'crank-pin', 'yoke-slot', 'slider-block'], [ 'Right stroke', 'Centre crossing', 'Left stroke', 'Centre crossing', ]), [ rotation('crank', 'rotating-crank', 'z', 1), rotation('flywheel', 'flywheel', 'z', 1), orbit('pin-orbit', 'crank-pin', 'z', 1, 0.3), linear('yoke-slot', 'yoke-slot', 'x', 0.3, 0), linear('slider', 'slider-block', 'x', 0.3, 0), linear('output-rod', 'output-rod', 'x', 0.3, 0), pulse('dead-centres', 'dead-center-markers', 0, { velocityZeroAt: [0, 0.5] }), ], [ 'Output displacement is pure sinusoidal in the ideal Scotch yoke.', 'Pin/slot sliding is highlighted as a wear consideration.', ], ), animation( 'geneva-drive', 3, 60, [5, 600], 'stepped-indexed', [ step('dwell-1', 'Dwell 1', 0, 0.18, 'Output is locked between slots.', ['locking-disc', 'geneva-wheel']), step('index-1', 'Index', 0.18, 0.14, 'Drive pin enters a slot and advances the wheel.', ['drive-pin', 'radial-slots']), step('dwell-2', 'Dwell 2', 0.32, 0.18, 'Output holds position.', ['dwell-indicator']), step('index-2', 'Next index', 0.5, 0.14, 'Next slot engagement advances another quarter turn.', ['drive-pin', 'geneva-wheel']), ], [ rotation('drive-wheel', 'drive-wheel', 'z', 1), rotation('drive-pin-parent', 'drive-pin', 'z', 1, 0, { orbitAround: 'drive-wheel', radius: 0.32 }), orbit('drive-pin-orbit', 'drive-pin', 'z', 1, 0.32), indexed('geneva-index', ['geneva-wheel', 'radial-slots', 'output-shaft'], { slots: 4, dwellFraction: 0.64, indexAngleDegrees: 90 }), pulse('dwell-indicator', 'dwell-indicator', 0, { activeDuringDwell: true }), pulse('locking-disc-contact', 'locking-disc', 180, { activeDuringDwell: true }), ], [ 'Output is held during dwell and indexes one quarter turn for a four-slot wheel.', 'The pin uses an orbit track plus an indexed output track for clear, loop-safe behaviour.', ], ), animation( 'cam-and-follower', 3, 120, [10, 2000], 'continuous', simpleSteps(['eccentric-cam', 'heart-cam', 'snail-cam', 'roller-follower'], [ 'Eccentric lift', 'Heart cam comparison', 'Snail ramp', 'Follower return', ]), [ rotation('camshaft', 'camshaft', 'z', 1), rotation('eccentric', 'eccentric-cam', 'z', 1), rotation('heart', 'heart-cam', 'z', 1), rotation('snail', 'snail-cam', 'z', 1), linear('roller-lift', 'roller-follower', 'y', 0.24, 0, { profileSelector: ['eccentric', 'heart', 'snail'] }), linear('stem-lift', 'follower-stem', 'y', 0.24, 0, { profileSelector: ['eccentric', 'heart', 'snail'] }), scalePulse('spring-compression', 'return-spring', 0.18, 180, { compressesWithLift: true }), pulse('lift-indicator', 'lift-indicator', 0, { drawLiftCurve: true }), indexed('profile-selector', ['profile-selector'], { profiles: ['eccentric-cam', 'heart-cam', 'snail-cam'] }), ], [ 'Follower lift can be driven by selected cam profile metadata.', 'Snail cam drop is stylised for readability at all time scales.', ], ), animation( 'rack-and-pinion', 3, 80, [5, 500], 'reciprocating', simpleSteps(['pinion-gear', 'rack-bar', 'linear-carriage'], [ 'Clockwise drive', 'Right travel', 'Reverse', 'Left travel', ]), [ rotation('pinion', 'pinion-gear', 'z', 1), rotation('input-shaft', 'input-shaft', 'z', 1), linear('rack', 'rack-bar', 'x', 0.62, 0, { pitchRadius: 0.32 }), linear('carriage', 'linear-carriage', 'x', 0.62, 0), pulse('backlash', 'backlash-indicator', 180, { lostMotionOnReversal: true }), pulse('position-scale', 'position-scale', 0, { showTravel: true }), pulse('end-stops', 'end-stops', 0, { limitAtExtremes: true }), ], [ 'Rack travel is mapped from pinion pitch radius for a mechanically believable relation.', 'The loop reverses direction to show backlash and end-stop semantics.', ], ), animation( 'slider-crank', 2.4, 120, [5, 1200], 'continuous', simpleSteps(['crank-disc', 'crank-pin', 'connecting-rod', 'slider-piston'], [ 'TDC', 'Expansion stroke', 'BDC', 'Return stroke', ]), [ rotation('crank-disc', 'crank-disc', 'z', 1), rotation('crankshaft', 'crankshaft', 'z', 1), rotation('flywheel', 'flywheel', 'z', 1), orbit('crank-pin-orbit', 'crank-pin', 'z', 1, 0.27), angular('rod-angle', 'connecting-rod', 'z', 14, 90, { finiteRodLength: 0.92 }), linear('slider', 'slider-piston', 'x', 0.42, 0, { finiteRodLengthCorrection: 0.08 }), pulse('tdc-marker', 'top-dead-center-marker', 0, { activeAt: 0 }), pulse('bdc-marker', 'bottom-dead-center-marker', 180, { activeAt: 0.5 }), ], [ 'Finite rod correction makes slider motion distinct from the Scotch yoke.', 'Dead-centre marker pulses align with crank/rod collinearity.', ], ), animation( 'toggle-clamp', 3.2, 45, [5, 120], 'reciprocating', [ step('open', 'Open', 0, 0.25, 'Handle and clamp arm are away from the workpiece.', ['handle-lever', 'clamp-arm']), step('approach', 'Approach centre', 0.25, 0.3, 'Links approach alignment and mechanical advantage rises.', ['pivot-link', 'toggle-link', 'force-vector']), step('locked', 'Over-centre locked', 0.55, 0.2, 'Toggle passes centre and rests against the stop.', ['toggle-link', 'over-center-stop', 'pressure-pad']), step('release', 'Release', 0.75, 0.25, 'Handle reverses and spring opens the clamp.', ['handle-lever', 'spring-return']), ], [ angular('handle', 'handle-lever', 'z', 38, 0, { clampRangeDegrees: [-48, 28] }), angular('pivot-link', 'pivot-link', 'z', 28, 25), angular('toggle-link', 'toggle-link', 'z', 34, 35), angular('clamp-arm', 'clamp-arm', 'z', 16, 15), linear('pressure-pad', 'pressure-pad', 'y', 0.16, 0, { contactWith: 'workpiece' }), scalePulse('force-vector', 'force-vector', 0.55, 90, { peaksNearOverCenter: true }), scalePulse('spring-return', 'spring-return', 0.18, 180, { extendsWhenOpen: true }), pulse('over-center-stop', 'over-center-stop', 0, { contactWindow: [0.52, 0.72] }), ], [ 'Mechanical advantage is represented by a force-vector scale peak near toggle alignment.', 'The locked window is intentionally held so users can inspect over-centre geometry.', ], ), animation( 'ball-bearing', 2.4, 400, [10, 20000], 'continuous', simpleSteps(['inner-race', 'ball-set', 'cage', 'contact-patches'], [ 'Inner race turns', 'Balls roll', 'Cage advances', 'Loaded zone', ]), [ rotation('inner-race', 'inner-race', 'z', 1), rotation('ball-orbit', 'ball-set', 'z', 0.42, 0, { orbitalAround: 'inner-race' }), rotation('ball-spin', 'ball-set', 'x', -2.8, 0, { rollingElements: 10 }), rotation('cage-speed', 'cage', 'z', 0.42), flow('grease-film', 'grease', 0.2, 0), pulse('load-vector', 'load-vector', 0, { steady: true }), pulse('contact-patches', 'contact-patches', 0, { loadedZone: 'top' }), ], [ 'Cage speed is lower than inner race speed, matching rolling-element bearing behaviour qualitatively.', 'Contact patches are learning overlays, not stress-analysis output.', ], ), animation( 'roller-bearing', 2.6, 250, [10, 8000], 'continuous', simpleSteps(['inner-race', 'tapered-rollers', 'cage', 'preload-adjuster'], [ 'Cone turns', 'Rollers spin', 'Load line', 'Preload cue', ]), [ rotation('inner-race', 'inner-race', 'z', 1), rotation('tapered-roller-orbit', 'tapered-rollers', 'z', 0.38, 0, { conicalGeometry: true }), rotation('tapered-roller-spin', 'tapered-rollers', 'x', -2.1, 0, { rollingElements: 12 }), rotation('cage', 'cage', 'z', 0.38), flow('lubrication', 'lubrication-film', 0.2, 0), pulse('combined-load', 'load-vector', 0, { radialAxialComponents: true }), angular('preload-adjuster-cue', 'preload-adjuster', 'z', 3, 0, { adjustmentCueOnly: true }), ], [ 'Tapered roller tracks include conical-geometry metadata so a renderer can cant individual rollers.', 'Preload adjuster oscillates subtly as an inspection cue rather than during normal operation.', ], ), animation( 'disc-brake-caliper', 3.2, 160, [0, 1200], 'reciprocating', [ step('free-rolling', 'Free rolling', 0, 0.25, 'Rotor spins with pads retracted.', ['brake-rotor', 'wheel-hub']), step('apply-pressure', 'Apply pressure', 0.25, 0.25, 'Hydraulic piston moves the inner pad into contact.', ['hydraulic-line', 'piston', 'inner-pad']), step('clamp', 'Clamp and heat', 0.5, 0.25, 'Caliper reaction brings the outer pad in and heat rises.', ['outer-pad', 'caliper-body', 'heat-band']), step('release', 'Release', 0.75, 0.25, 'Pressure decays and seals retract the pads slightly.', ['dust-boot', 'piston']), ], [ rotation('rotor', 'brake-rotor', 'z', 1), rotation('hub', 'wheel-hub', 'z', 1), linear('piston-apply', 'piston', 'z', 0.08, 90, { activeWindow: [0.25, 0.7] }), linear('inner-pad', 'inner-pad', 'z', 0.06, 90, { closesGap: true }), linear('outer-pad', 'outer-pad', 'z', 0.04, 90, { caliperReaction: true }), linear('caliper-float', 'caliper-body', 'z', 0.025, 90, { guidePins: 'guide-pins' }), flow('hydraulic-pressure', 'hydraulic-line', 1, 90, { activeWindow: [0.22, 0.62] }), scalePulse('heat-band', 'heat-band', 0.45, 150, { accumulatesDuringClamp: true }), pulse('dust-boot-flex', 'dust-boot', 90, { sealRollback: true }), ], [ 'Pad movement is exaggerated so the actuation path is visible.', 'Heat band is a scalar educational overlay, not a thermal finite-element result.', ], ), animation( 'turbocharger', 1.4, 40000, [5000, 200000], 'continuous', simpleSteps(['exhaust-flow', 'turbine-wheel', 'common-shaft', 'compressor-wheel', 'intake-flow'], [ 'Exhaust drives turbine', 'Shaft transmits power', 'Compressor accelerates air', 'Wastegate regulates', ]), [ rotation('turbine-wheel', 'turbine-wheel', 'x', 1), rotation('compressor-wheel', 'compressor-wheel', 'x', 1), rotation('common-shaft', 'common-shaft', 'x', 1), flow('exhaust-flow', 'exhaust-flow', 1, 0, { hotSide: true, drives: 'turbine-wheel' }), flow('intake-flow', 'intake-flow', 1, 0, { coldSide: true, compressedBy: 'compressor-wheel' }), flow('compressor-outlet', 'intercooler-outlet', 1, 90, { pressureRise: true }), angular('wastegate-flap', 'wastegate-flap', 'z', 22, 180, { opensAtBoost: true }), linear('actuator-rod', 'actuator', 'x', 0.08, 180, { linkedPartId: 'wastegate-flap' }), flow('oil-feed', 'oil-feed', 0.5, 0, { bearingCooling: true }), pulse('bearing-heat', 'center-bearing-housing', 0, { oilCooled: true }), ], [ 'Turbine, compressor, and shaft share a multiplier to reflect direct coupling.', 'Wastegate opening is phase-driven by default and can later be bound to boost pressure UI.', ], ), ]; export const CORE_MACHINE_ANIMATION_BY_MACHINE_ID: Readonly> = Object.fromEntries(CORE_MACHINE_ANIMATIONS.map((definition) => [definition.machineId, definition])) as Record< string, MachineAnimationDefinition >; export function getCoreMachineAnimation(machineId: string): MachineAnimationDefinition | undefined { return CORE_MACHINE_ANIMATION_BY_MACHINE_ID[machineId]; } export function validateCoreMachineAnimations(): RegistryValidationResult { const errors: string[] = []; const warnings: string[] = []; const animationMachineIds = new Set(); for (const definition of CORE_MACHINE_ANIMATIONS) { animationMachineIds.add(definition.machineId); const machine = CORE_MACHINE_BY_ID[definition.machineId]; if (!machine) { errors.push(`Animation "${definition.id}" references missing machine "${definition.machineId}".`); continue; } if (machine.animationModuleId !== definition.id) { errors.push( `Machine "${machine.id}" expects animation module "${machine.animationModuleId}" but animation file exports "${definition.id}".`, ); } const partIds = new Set(machine.parts.map((part) => part.id)); for (const cycleStep of definition.cycleSteps) { for (const partId of cycleStep.activePartIds) { if (!partIds.has(partId)) { errors.push(`Animation "${definition.id}" cycle step "${cycleStep.id}" references missing part "${partId}".`); } } } for (const track of definition.tracks) { const referencedPartIds = [ ...(track.partId ? [track.partId] : []), ...(track.partIds ?? []), ]; for (const partId of referencedPartIds) { if (!partIds.has(partId)) { errors.push(`Animation "${definition.id}" track "${track.id}" references missing part "${partId}".`); } } } } for (const machine of CORE_MACHINE_DEFINITIONS) { if (!animationMachineIds.has(machine.id)) { errors.push(`Machine "${machine.id}" has no exported animation definition.`); } } if (CORE_MACHINE_ANIMATIONS.length !== CORE_MACHINE_DEFINITIONS.length) { warnings.push( `Animation count ${CORE_MACHINE_ANIMATIONS.length} does not match machine count ${CORE_MACHINE_DEFINITIONS.length}.`, ); } return { ok: errors.length === 0, errors, warnings, }; }