import { createElement, type ComponentType } from 'react'; import * as BallBearingModule from './ballBearing'; import * as CentrifugalPumpModule from './centrifugalPump'; import * as DifferentialGearModule from './differentialGear'; import * as DiscBrakeCaliperModule from './discBrakeCaliper'; import * as FourStrokeModule from './fourStrokePetrolEngine'; import * as GenevaDriveModule from './genevaDrive'; import * as ManifestModule from './proceduralDemoManifest'; import * as PlanetaryGearboxModule from './planetaryGearbox'; import * as WankelRotaryModule from './wankelRotaryEngine'; export const PROCEDURAL_DEMO_IDS = [ 'four-stroke-petrol-engine', 'planetary-gearbox', 'differential-gear', 'centrifugal-pump', 'geneva-drive', 'ball-bearing', 'disc-brake-caliper', 'wankel-rotary-engine', ] as const; export type ProceduralDemoMachineId = (typeof PROCEDURAL_DEMO_IDS)[number]; export type ProceduralDemoCategory = | 'Engines' | 'Gearboxes & Drives' | 'Pumps & Fluid Systems' | 'Mechanisms' | 'Structural / Other'; export type ProceduralDemoDifficulty = 'Beginner' | 'Intermediate' | 'Advanced'; export type Vector3Tuple = [number, number, number]; export interface ProceduralDemoFact { label: string; value: string; unit?: string; description?: string; } export interface ProceduralDemoPart { id: string; name: string; description: string; material?: string; defaultVisible?: boolean; defaultOpacity?: number; color?: string; labelPosition?: Vector3Tuple; specs?: Record; } export interface ProceduralDemoCameraPose { preset?: 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom' | 'isometric'; position?: Vector3Tuple; target?: Vector3Tuple; } export interface ProceduralDemoTourStep { id: string; title: string; body: string; partIds: string[]; durationSeconds: number; camera?: ProceduralDemoCameraPose; animationCue?: string; } export interface ProceduralDemoMachine { id: ProceduralDemoMachineId; slug: string; name: string; title: string; category: ProceduralDemoCategory; difficulty: ProceduralDemoDifficulty; summary: string; description: string; keywords: string[]; complexity: number; createdAt: string; thumbnail: string; parts: ProceduralDemoPart[]; facts: ProceduralDemoFact[]; tour: ProceduralDemoTourStep[]; relatedIds: ProceduralDemoMachineId[]; Scene: ComponentType>; scene: ComponentType>; animationModule?: unknown; animation?: unknown; sourceExports: string[]; } export type ProceduralDemoCatalogItem = ProceduralDemoMachine; export type ProceduralDemoDefinition = ProceduralDemoMachine; type UnknownRecord = Record; interface FallbackDemoMetadata { id: ProceduralDemoMachineId; name: string; title: string; category: ProceduralDemoCategory; difficulty: ProceduralDemoDifficulty; summary: string; description: string; keywords: string[]; complexity: number; createdAt: string; thumbnail: string; parts: ProceduralDemoPart[]; facts: ProceduralDemoFact[]; tour: ProceduralDemoTourStep[]; relatedIds: ProceduralDemoMachineId[]; } interface ModuleSource { id: ProceduralDemoMachineId; module: UnknownRecord; } const fallbackSceneStyles = { metalColor: '#8ea4c2', accentColor: '#4C8DFF', }; const MissingProceduralScene: ComponentType> = (props) => { const machineName = typeof props.machineName === 'string' ? props.machineName : 'Procedural machine'; return createElement( 'group', { name: 'mechanica-diagnostic-scene' }, createElement( 'mesh', { name: 'diagnostic-core', position: [0, 0, 0], castShadow: true, receiveShadow: true, }, createElement('boxGeometry', { args: [1.8, 1.1, 1.1] }), createElement('meshStandardMaterial', { color: fallbackSceneStyles.metalColor, metalness: 0.65, roughness: 0.28, }), ), createElement( 'mesh', { name: 'diagnostic-accent', position: [0, 0.75, 0], castShadow: true, }, createElement('torusGeometry', { args: [0.62, 0.045, 20, 96] }), createElement('meshStandardMaterial', { color: fallbackSceneStyles.accentColor, emissive: fallbackSceneStyles.accentColor, emissiveIntensity: 0.2, metalness: 0.2, roughness: 0.2, }), ), createElement('object3D', { name: `${machineName} scene-export-diagnostic`, userData: { mechanicaDiagnostic: 'No exported React scene component could be detected for this procedural machine module.', }, }), ); }; const FALLBACK_DEMOS: Record = { 'four-stroke-petrol-engine': { id: 'four-stroke-petrol-engine', name: 'Four-Stroke Petrol Engine', title: 'Four-Stroke Petrol Engine', category: 'Engines', difficulty: 'Intermediate', summary: 'A spark-ignition reciprocating engine showing intake, compression, power, and exhaust events.', description: 'This procedural cutaway demonstrates the classic Otto-cycle petrol engine. The crankshaft converts piston reciprocation into rotation while the camshaft opens the intake and exhaust valves at the correct phase, and the spark plug ignites the compressed charge near top dead centre.', keywords: ['otto cycle', 'piston', 'crankshaft', 'camshaft', 'valves', 'spark ignition'], complexity: 7, createdAt: '2026-01-04', thumbnail: 'procedural://four-stroke-petrol-engine', parts: [ { id: 'cylinder-block', name: 'Cylinder Block', description: 'Rigid casting that locates the cylinder bore, crankcase, cooling passages, and head fasteners.', material: 'Cast aluminium or grey iron', defaultOpacity: 0.42, labelPosition: [0, 1.55, 0], specs: { role: 'Structural datum', note: 'Procedural model uses transparent cutaway walls' }, }, { id: 'piston', name: 'Piston', description: 'Sealed reciprocating member that compresses the charge and receives combustion pressure.', material: 'Aluminium alloy', labelPosition: [0, 0.85, 0], specs: { motion: 'Sinusoidal with rod-angle correction', cycle: 'Two crank revolutions per cycle' }, }, { id: 'connecting-rod', name: 'Connecting Rod', description: 'Pinned link that transmits piston force to the crank throw while oscillating each revolution.', material: 'Forged steel', labelPosition: [0.72, 0.15, 0], specs: { load: 'Alternating compression/tension', feature: 'Small-end and big-end bearings' }, }, { id: 'crankshaft', name: 'Crankshaft', description: 'Offset shaft that converts reciprocating motion into continuous rotary output and stores phase reference.', material: 'Forged steel', labelPosition: [0, -0.72, 0.52], specs: { output: 'Rotary torque', phase: '720° thermodynamic cycle' }, }, { id: 'camshaft', name: 'Camshaft', description: 'Half-speed shaft whose lobes coordinate valve lift with piston position.', material: 'Chilled cast iron or steel', labelPosition: [0, 1.95, -0.46], specs: { ratio: '1:2 relative to crankshaft', function: 'Valve timing' }, }, { id: 'intake-valve', name: 'Intake Valve', description: 'Poppet valve that opens during the intake stroke to admit the air-fuel charge.', material: 'Heat-treated steel', labelPosition: [-0.48, 1.7, 0.18], specs: { timing: 'Open near intake stroke', flow: 'Fresh mixture' }, }, { id: 'exhaust-valve', name: 'Exhaust Valve', description: 'Heat-resistant valve that opens to release combustion products during the exhaust stroke.', material: 'Austenitic stainless steel', labelPosition: [0.48, 1.7, 0.18], specs: { timing: 'Open near exhaust stroke', flow: 'Spent gases' }, }, { id: 'spark-plug', name: 'Spark Plug', description: 'Insulated electrode that initiates combustion shortly before top dead centre.', material: 'Ceramic insulator with nickel alloy electrode', labelPosition: [0, 2.2, 0.32], specs: { event: 'Ignition cue on power stroke', voltage: 'Typical tens of kV' }, }, { id: 'flywheel', name: 'Flywheel', description: 'Rotational inertia element that smooths torque pulses between power strokes.', material: 'Cast iron or steel', labelPosition: [1.25, -0.72, 0], specs: { role: 'Energy storage', benefit: 'Reduced speed ripple' }, }, ], facts: [ { label: 'Cycle', value: '4 strokes / 720° crank rotation' }, { label: 'Typical speed', value: '700–6500', unit: 'rpm' }, { label: 'Thermal efficiency', value: '25–35', unit: '%' }, { label: 'Invented', value: '1876, Nikolaus Otto' }, { label: 'Applications', value: 'Cars, motorcycles, light aircraft, small generators' }, ], tour: [ { id: 'engine-cycle-overview', title: 'One complete Otto cycle', body: 'Follow the crank through two rotations. The piston descends for intake, rises for compression, descends under combustion pressure, and rises again to clear exhaust gas.', partIds: ['piston', 'connecting-rod', 'crankshaft'], durationSeconds: 7, camera: { preset: 'isometric' }, animationCue: 'cycle', }, { id: 'engine-breathing', title: 'Valve timing controls breathing', body: 'The camshaft runs at half crank speed. Its lobes lift the intake and exhaust valves only during their assigned strokes.', partIds: ['camshaft', 'intake-valve', 'exhaust-valve'], durationSeconds: 6, camera: { position: [-2.6, 2.0, 3.3], target: [0, 0.6, 0] }, animationCue: 'valves', }, { id: 'engine-ignition', title: 'Ignition near top dead centre', body: 'The spark plug fires near the end of compression. Combustion pressure then drives the piston downward during the power stroke.', partIds: ['spark-plug', 'piston'], durationSeconds: 6, camera: { position: [0.8, 2.5, 2.8], target: [0, 1.1, 0] }, animationCue: 'spark', }, { id: 'engine-output', title: 'Torque is smoothed by inertia', body: 'The crankshaft and flywheel turn the pulsed force from one cylinder into usable rotary output.', partIds: ['crankshaft', 'flywheel'], durationSeconds: 5, camera: { preset: 'front' }, animationCue: 'output', }, ], relatedIds: ['wankel-rotary-engine', 'differential-gear', 'disc-brake-caliper'], }, 'planetary-gearbox': { id: 'planetary-gearbox', name: 'Planetary Gearbox', title: 'Planetary Gearbox', category: 'Gearboxes & Drives', difficulty: 'Advanced', summary: 'A compact epicyclic gear train with sun, planet, carrier, and ring members.', description: 'Planetary gearsets combine high torque density with selectable ratios because any of the sun, carrier, or ring can act as input, output, or reaction member. This demo highlights counter-rotation and orbiting planet kinematics.', keywords: ['epicyclic', 'sun gear', 'planet carrier', 'ring gear', 'automatic transmission'], complexity: 8, createdAt: '2026-01-05', thumbnail: 'procedural://planetary-gearbox', parts: [ { id: 'sun-gear', name: 'Sun Gear', description: 'Central gear that meshes with all planets and commonly acts as a high-speed input.', material: 'Case-hardened steel', labelPosition: [0, 0.45, 0], specs: { mesh: 'External spur teeth', role: 'Central member' }, }, { id: 'planet-gears', name: 'Planet Gears', description: 'Gears that spin on their own pins while orbiting around the sun with the carrier.', material: 'Case-hardened steel', labelPosition: [1.25, 0.68, 0.5], specs: { count: 'Three or more for load sharing', motion: 'Spin + orbit' }, }, { id: 'planet-carrier', name: 'Planet Carrier', description: 'Armature that supports planet pins and is often used as the reduced-speed output.', material: 'Forged steel or aluminium', labelPosition: [0, -0.55, 1.1], specs: { role: 'Orbit reference', output: 'High torque member' }, }, { id: 'ring-gear', name: 'Ring Gear', description: 'Internally toothed annulus that constrains planet motion and can be held or driven.', material: 'Nitrided steel', defaultOpacity: 0.55, labelPosition: [-1.6, 0.6, 0], specs: { mesh: 'Internal teeth', reaction: 'Can be fixed for reduction' }, }, { id: 'input-shaft', name: 'Input Shaft', description: 'Shaft coupled to the selected input member in the demonstration.', material: 'Steel', labelPosition: [0, 0, -1.75], specs: { selectable: 'Sun, ring, or carrier' }, }, { id: 'output-shaft', name: 'Output Shaft', description: 'Shaft driven by the selected output member, displaying the resulting ratio.', material: 'Steel', labelPosition: [0, 0, 1.8], specs: { selectable: 'Carrier, sun, or ring' }, }, ], facts: [ { label: 'Typical ratio', value: '3:1 to 10:1 per stage' }, { label: 'Torque density', value: 'High due to load sharing' }, { label: 'Common uses', value: 'Automatic transmissions, robotics, wind turbines' }, { label: 'Characteristic', value: 'Coaxial input and output' }, ], tour: [ { id: 'planetary-members', title: 'Four functional members', body: 'The sun, planets, carrier, and ring form an epicyclic system. Holding or driving different members changes the speed ratio.', partIds: ['sun-gear', 'planet-gears', 'planet-carrier', 'ring-gear'], durationSeconds: 7, camera: { preset: 'isometric' }, }, { id: 'planetary-load-sharing', title: 'Planets share tooth load', body: 'Multiple planet gears split torque across several meshes, making planetary drives compact for their capacity.', partIds: ['planet-gears', 'ring-gear'], durationSeconds: 6, camera: { preset: 'top' }, }, { id: 'planetary-coaxial', title: 'Coaxial packaging', body: 'Input and output shafts can remain on the same centreline, which is why epicyclic sets are common in automatic transmissions.', partIds: ['input-shaft', 'output-shaft', 'planet-carrier'], durationSeconds: 5, camera: { preset: 'front' }, }, ], relatedIds: ['differential-gear', 'geneva-drive', 'ball-bearing'], }, 'differential-gear': { id: 'differential-gear', name: 'Differential Gear', title: 'Differential Gear', category: 'Gearboxes & Drives', difficulty: 'Advanced', summary: 'A bevel-gear differential that splits torque while allowing left and right wheel speed difference.', description: 'The differential carrier receives torque through the ring gear. Spider gears transmit torque to the side gears, letting the wheels rotate at different speeds during cornering while maintaining average speed from the carrier.', keywords: ['bevel gears', 'axle', 'torque split', 'cornering', 'final drive'], complexity: 8, createdAt: '2026-01-06', thumbnail: 'procedural://differential-gear', parts: [ { id: 'ring-gear', name: 'Ring Gear', description: 'Large final-drive gear bolted to the carrier and driven by the pinion.', material: 'Hypoid gear steel', labelPosition: [0, 1.2, 0], specs: { role: 'Final reduction', mesh: 'Driven by pinion gear' }, }, { id: 'pinion-gear', name: 'Pinion Gear', description: 'Small input gear that transfers prop-shaft torque into the differential ring gear.', material: 'Case-hardened steel', labelPosition: [-1.75, 0.1, 0], specs: { input: 'Drives ring gear', ratio: 'Final drive reduction' }, }, { id: 'carrier', name: 'Carrier', description: 'Rotating cage that supports spider gears and carries the ring gear.', material: 'Ductile iron or steel', defaultOpacity: 0.68, labelPosition: [0, 0.35, 0.85], specs: { motion: 'Average wheel speed', role: 'Torque splitter housing' }, }, { id: 'spider-gears', name: 'Spider Gears', description: 'Small bevel gears that walk around the side gears when the wheels rotate at different speeds.', material: 'Case-hardened steel', labelPosition: [0, 0.68, 0], specs: { action: 'Stationary relative to carrier in straight-line driving', count: 'Two or four' }, }, { id: 'side-gear-left', name: 'Left Side Gear', description: 'Bevel gear splined to the left axle shaft.', material: 'Case-hardened steel', labelPosition: [-0.88, 0.05, 0], specs: { output: 'Left axle', motion: 'Slows on inside of turn' }, }, { id: 'side-gear-right', name: 'Right Side Gear', description: 'Bevel gear splined to the right axle shaft.', material: 'Case-hardened steel', labelPosition: [0.88, 0.05, 0], specs: { output: 'Right axle', motion: 'Speeds on outside of turn' }, }, { id: 'axle-left', name: 'Left Axle', description: 'Output shaft connecting the side gear to the left wheel.', material: 'Steel', labelPosition: [-1.9, -0.15, 0], specs: { connection: 'Splined to side gear' }, }, { id: 'axle-right', name: 'Right Axle', description: 'Output shaft connecting the side gear to the right wheel.', material: 'Steel', labelPosition: [1.9, -0.15, 0], specs: { connection: 'Splined to side gear' }, }, ], facts: [ { label: 'Torque split', value: 'Nominally 50:50 for open differential' }, { label: 'Kinematic rule', value: 'Carrier speed is average of axle speeds' }, { label: 'Invented', value: '1827, Onésiphore Pecqueur' }, { label: 'Applications', value: 'Automotive axles, drivetrains, robotics' }, ], tour: [ { id: 'differential-input', title: 'Final-drive input', body: 'The pinion drives the ring gear, rotating the carrier around the axle centreline.', partIds: ['pinion-gear', 'ring-gear', 'carrier'], durationSeconds: 6, camera: { preset: 'isometric' }, }, { id: 'differential-straight', title: 'Straight-line driving', body: 'When both wheels have equal speed, the spider gears mostly ride along with the carrier without spinning on their own axes.', partIds: ['spider-gears', 'side-gear-left', 'side-gear-right'], durationSeconds: 6, camera: { preset: 'front' }, }, { id: 'differential-cornering', title: 'Cornering speed difference', body: 'During a turn the spider gears walk around the side gears, increasing one axle speed while decreasing the other.', partIds: ['spider-gears', 'axle-left', 'axle-right'], durationSeconds: 7, camera: { preset: 'top' }, }, ], relatedIds: ['planetary-gearbox', 'ball-bearing', 'disc-brake-caliper'], }, 'centrifugal-pump': { id: 'centrifugal-pump', name: 'Centrifugal Pump', title: 'Centrifugal Pump', category: 'Pumps & Fluid Systems', difficulty: 'Intermediate', summary: 'A radial-flow pump showing impeller energy transfer, volute pressure recovery, and cavitation cueing.', description: 'The rotating impeller adds angular momentum to the fluid. The volute casing then converts velocity head into static pressure, while the eye of the impeller remains the low-pressure inlet region.', keywords: ['impeller', 'volute', 'fluid', 'pressure', 'cavitation', 'pump curve'], complexity: 6, createdAt: '2026-01-07', thumbnail: 'procedural://centrifugal-pump', parts: [ { id: 'impeller', name: 'Impeller', description: 'Rotating vaned wheel that accelerates fluid radially outward.', material: 'Bronze, stainless steel, or polymer', labelPosition: [0, 0.45, 0], specs: { motion: 'High-speed rotation', function: 'Adds velocity head' }, }, { id: 'volute-casing', name: 'Volute Casing', description: 'Spiral housing with increasing area that slows the flow and recovers pressure at the outlet.', material: 'Cast iron or stainless steel', defaultOpacity: 0.5, labelPosition: [-1.3, 0.75, 0], specs: { role: 'Pressure recovery', geometry: 'Expanding spiral' }, }, { id: 'inlet', name: 'Inlet Eye', description: 'Low-pressure axial entry at the centre of the impeller.', material: 'Integrated casing feature', labelPosition: [0, 0.15, -1.45], specs: { pressure: 'Lowest local static pressure', risk: 'Cavitation if NPSH is insufficient' }, }, { id: 'outlet', name: 'Discharge Outlet', description: 'Tangential outlet where high-pressure flow leaves the volute.', material: 'Integrated casing nozzle', labelPosition: [1.75, 1.05, 0], specs: { flow: 'Pressurised discharge', connection: 'Pipe flange' }, }, { id: 'shaft', name: 'Drive Shaft', description: 'Shaft transmitting motor torque to the impeller hub.', material: 'Stainless steel', labelPosition: [0, -0.55, 1.35], specs: { sealInterface: 'Passes through mechanical seal' }, }, { id: 'seal', name: 'Mechanical Seal', description: 'Sealing assembly that limits leakage where the rotating shaft exits the casing.', material: 'Carbon, ceramic, elastomer', labelPosition: [0.72, -0.2, 0.95], specs: { purpose: 'Leak prevention', concern: 'Heat and dry running' }, }, { id: 'cavitation-zone', name: 'Cavitation Indicator', description: 'Visual cue for vapour bubble formation near the inlet when local pressure falls below vapour pressure.', material: 'Diagnostic overlay', labelPosition: [-0.55, 0.1, -0.9], specs: { condition: 'Low NPSH margin', symptom: 'Noise, pitting, performance loss' }, }, ], facts: [ { label: 'Typical speed', value: '1450–3600', unit: 'rpm' }, { label: 'Best efficiency', value: '60–85', unit: '%' }, { label: 'Pressure rise', value: 'Velocity converted in volute' }, { label: 'Applications', value: 'Water supply, HVAC, process plants, cooling loops' }, ], tour: [ { id: 'pump-flow-path', title: 'Flow enters at the eye', body: 'Fluid arrives axially through the inlet eye, where pressure is lowest and cavitation margin matters.', partIds: ['inlet', 'cavitation-zone'], durationSeconds: 6, camera: { position: [0, 1.5, 3.6], target: [0, 0.25, 0] }, }, { id: 'pump-impeller-energy', title: 'Impeller adds velocity', body: 'The impeller blades throw fluid outward. The blade shape and speed determine the energy added per unit mass.', partIds: ['impeller', 'shaft'], durationSeconds: 6, camera: { preset: 'front' }, }, { id: 'pump-volute-recovery', title: 'Volute recovers pressure', body: 'The expanding spiral volute slows the high-speed flow so part of its kinetic energy becomes useful static pressure.', partIds: ['volute-casing', 'outlet'], durationSeconds: 7, camera: { preset: 'isometric' }, }, ], relatedIds: ['ball-bearing', 'disc-brake-caliper', 'four-stroke-petrol-engine'], }, 'geneva-drive': { id: 'geneva-drive', name: 'Geneva Drive', title: 'Geneva Drive', category: 'Mechanisms', difficulty: 'Intermediate', summary: 'An intermittent-motion mechanism converting continuous rotation into indexed output steps.', description: 'A drive pin enters one slot of the Geneva wheel, indexes it by a fixed angle, then exits while the locking disk holds the output stationary during dwell.', keywords: ['intermittent motion', 'indexing', 'dwell', 'drive pin', 'slot wheel'], complexity: 5, createdAt: '2026-01-08', thumbnail: 'procedural://geneva-drive', parts: [ { id: 'drive-wheel', name: 'Drive Wheel', description: 'Continuously rotating input disk that carries the indexing pin and locking surface.', material: 'Steel or aluminium', labelPosition: [-1.1, 0.55, 0], specs: { input: 'Continuous rotation', outputEffect: 'Periodic indexing' }, }, { id: 'drive-pin', name: 'Drive Pin', description: 'Pin that enters a Geneva slot and pushes the output wheel through one indexed step.', material: 'Hardened steel', labelPosition: [-0.35, 1.0, 0.25], specs: { contact: 'Slot flank', event: 'Active only during index interval' }, }, { id: 'geneva-wheel', name: 'Geneva Wheel', description: 'Slotted output wheel that advances one station per drive-pin engagement.', material: 'Steel or polymer', labelPosition: [1.05, 0.65, 0], specs: { stations: 'Typically 4, 5, 6, or more', motion: 'Index plus dwell' }, }, { id: 'locking-disk', name: 'Locking Disk', description: 'Circular surface that fits the concave stops to hold the Geneva wheel during dwell.', material: 'Machined steel', labelPosition: [-0.85, -0.55, 0], specs: { role: 'Dwell constraint', benefit: 'Repeatable positioning' }, }, { id: 'index-slots', name: 'Index Slots', description: 'Radial slots that accept the drive pin and define the indexing angle.', material: 'Machined slot surfaces', labelPosition: [1.45, -0.35, 0.12], specs: { geometry: 'Slot count sets step angle' }, }, { id: 'output-shaft', name: 'Output Shaft', description: 'Shaft connected to the Geneva wheel to deliver intermittent rotary output.', material: 'Steel', labelPosition: [1.05, -0.85, 0], specs: { output: 'Indexed rotation with dwell' }, }, ], facts: [ { label: 'Motion type', value: 'Intermittent rotary indexing' }, { label: 'Dwell fraction', value: 'Set by geometry and slot count' }, { label: 'Common uses', value: 'Film projectors, indexing tables, packaging machines' }, { label: 'Design concern', value: 'Impact and acceleration at engagement' }, ], tour: [ { id: 'geneva-continuous-input', title: 'Continuous input rotation', body: 'The drive wheel rotates steadily even while the output wheel is dwelling.', partIds: ['drive-wheel', 'locking-disk'], durationSeconds: 5, camera: { preset: 'front' }, }, { id: 'geneva-pin-engagement', title: 'Pin enters a slot', body: 'The pin enters one radial slot and forces the Geneva wheel through a controlled indexing step.', partIds: ['drive-pin', 'index-slots', 'geneva-wheel'], durationSeconds: 6, camera: { preset: 'top' }, }, { id: 'geneva-dwell', title: 'Positive dwell', body: 'After the pin exits, the locking disk holds the output at the new station until the next engagement.', partIds: ['locking-disk', 'geneva-wheel', 'output-shaft'], durationSeconds: 6, camera: { preset: 'isometric' }, }, ], relatedIds: ['planetary-gearbox', 'ball-bearing', 'centrifugal-pump'], }, 'ball-bearing': { id: 'ball-bearing', name: 'Ball Bearing', title: 'Ball Bearing', category: 'Structural / Other', difficulty: 'Beginner', summary: 'A rolling-element bearing showing inner race, outer race, balls, cage, and load distribution.', description: 'Ball bearings replace sliding friction with rolling contact. The inner and outer races guide the balls, while the cage spaces the elements to avoid rubbing and preserve lubricant film.', keywords: ['rolling element', 'raceway', 'cage', 'contact angle', 'friction'], complexity: 4, createdAt: '2026-01-09', thumbnail: 'procedural://ball-bearing', parts: [ { id: 'inner-race', name: 'Inner Race', description: 'Hardened ring attached to the rotating shaft with a precision-ground raceway.', material: 'Through-hardened bearing steel', labelPosition: [0, 0.4, 0], specs: { motion: 'Often shaft speed', feature: 'Grooved raceway' }, }, { id: 'outer-race', name: 'Outer Race', description: 'Stationary or housing-mounted ring that provides the outer raceway.', material: 'Through-hardened bearing steel', defaultOpacity: 0.62, labelPosition: [-1.35, 0.7, 0], specs: { motion: 'Often fixed', tolerance: 'Precision ground' }, }, { id: 'balls', name: 'Balls', description: 'Spherical rolling elements that carry load through point or elliptical contact patches.', material: 'Chrome steel or ceramic', labelPosition: [1.1, 0.65, 0.25], specs: { contact: 'Hertzian point contact', benefit: 'Low friction' }, }, { id: 'cage', name: 'Cage', description: 'Separator that spaces the balls evenly and prevents adjacent rolling elements from rubbing.', material: 'Pressed steel, brass, or polymer', defaultOpacity: 0.7, labelPosition: [0, -0.85, 0.32], specs: { speed: 'Typically rotates below shaft speed', role: 'Ball spacing' }, }, { id: 'load-vector', name: 'Load Vector', description: 'Overlay indicating the applied radial load and the load zone carried by several balls.', material: 'Diagnostic overlay', labelPosition: [0, 1.55, 0], specs: { type: 'Radial load demonstration' }, }, { id: 'lubrication-film', name: 'Lubrication Film', description: 'Thin oil or grease film separating surfaces and reducing wear at raceway contacts.', material: 'Oil or grease', defaultOpacity: 0.45, labelPosition: [0.72, -1.05, 0.25], specs: { regime: 'Elastohydrodynamic lubrication', role: 'Heat and wear control' }, }, ], facts: [ { label: 'Friction coefficient', value: '0.001–0.005 typical' }, { label: 'Rolling contact', value: 'Low heat versus plain bearings' }, { label: 'Invented', value: 'Modern radial bearing developed in the 19th century' }, { label: 'Applications', value: 'Motors, wheels, gearboxes, pumps, appliances' }, ], tour: [ { id: 'bearing-races', title: 'Raceways guide rolling elements', body: 'The inner and outer races constrain the balls along matching grooves.', partIds: ['inner-race', 'outer-race', 'balls'], durationSeconds: 6, camera: { preset: 'isometric' }, }, { id: 'bearing-cage', title: 'The cage spaces the balls', body: 'The cage does not carry primary load. It keeps rolling elements separated so they do not skid into each other.', partIds: ['cage', 'balls'], durationSeconds: 5, camera: { preset: 'front' }, }, { id: 'bearing-load-zone', title: 'Only part of the bearing carries radial load', body: 'Under radial load, the lower load zone carries most of the force through a few rolling elements at a time.', partIds: ['load-vector', 'balls', 'outer-race'], durationSeconds: 6, camera: { preset: 'top' }, }, ], relatedIds: ['centrifugal-pump', 'differential-gear', 'disc-brake-caliper'], }, 'disc-brake-caliper': { id: 'disc-brake-caliper', name: 'Disc Brake Caliper', title: 'Disc Brake Caliper', category: 'Structural / Other', difficulty: 'Intermediate', summary: 'A hydraulic disc brake showing caliper body, piston actuation, pads, and rotor friction.', description: 'Hydraulic pressure pushes the caliper piston into the inner pad. Reaction through the caliper clamps both pads against the rotor, converting vehicle kinetic energy into heat through friction.', keywords: ['hydraulics', 'friction', 'rotor', 'brake pads', 'caliper piston'], complexity: 6, createdAt: '2026-01-10', thumbnail: 'procedural://disc-brake-caliper', parts: [ { id: 'rotor', name: 'Rotor', description: 'Ventilated disc attached to the wheel hub; friction surfaces convert kinetic energy to heat.', material: 'Cast iron or carbon ceramic', labelPosition: [0, 0.25, 0], specs: { motion: 'Wheel speed', concern: 'Thermal capacity and runout' }, }, { id: 'caliper-body', name: 'Caliper Body', description: 'Rigid housing that bridges the rotor and reacts clamping force.', material: 'Cast aluminium or iron', defaultOpacity: 0.62, labelPosition: [-1.2, 1.0, 0.25], specs: { type: 'Floating or fixed', role: 'Force reaction structure' }, }, { id: 'piston', name: 'Hydraulic Piston', description: 'Sealed piston that converts brake-fluid pressure into pad force.', material: 'Steel, aluminium, or phenolic', labelPosition: [-0.82, 0.35, 0.74], specs: { pressure: 'Line pressure acts over piston area', seal: 'Square-cut piston seal' }, }, { id: 'inner-pad', name: 'Inner Brake Pad', description: 'Friction lining pushed directly by the piston against the rotor.', material: 'Semi-metallic, ceramic, or organic compound', labelPosition: [-0.42, -0.58, 0.54], specs: { function: 'Friction force', wear: 'Consumable lining' }, }, { id: 'outer-pad', name: 'Outer Brake Pad', description: 'Opposing pad clamped by caliper reaction force.', material: 'Semi-metallic, ceramic, or organic compound', labelPosition: [0.42, -0.58, -0.54], specs: { function: 'Opposing friction force' }, }, { id: 'hydraulic-line', name: 'Hydraulic Line', description: 'Brake hose or hard line delivering pressurised fluid from the master cylinder.', material: 'Reinforced rubber or steel tube', labelPosition: [-1.75, 0.25, 0.9], specs: { medium: 'Incompressible brake fluid', input: 'Driver pedal force amplified hydraulically' }, }, { id: 'bleeder-screw', name: 'Bleeder Screw', description: 'Service valve used to purge air from the hydraulic circuit.', material: 'Plated steel', labelPosition: [-1.55, 1.35, 0.65], specs: { service: 'Brake bleeding', issue: 'Air causes spongy pedal feel' }, }, { id: 'bracket', name: 'Mounting Bracket', description: 'Structural bracket bolting the caliper assembly to the suspension knuckle.', material: 'Ductile iron or steel', labelPosition: [1.2, 0.85, -0.7], specs: { role: 'Vehicle interface', load: 'Brake torque reaction' }, }, ], facts: [ { label: 'Actuation', value: 'Hydraulic pressure × piston area' }, { label: 'Friction coefficient', value: '0.35–0.55 typical pad μ' }, { label: 'Thermal duty', value: 'Kinetic energy converted to heat' }, { label: 'Applications', value: 'Passenger cars, motorcycles, aircraft, industrial brakes' }, ], tour: [ { id: 'brake-hydraulic-input', title: 'Pressure enters the caliper', body: 'Brake fluid pressure reaches the piston through the hydraulic line.', partIds: ['hydraulic-line', 'piston', 'caliper-body'], durationSeconds: 6, camera: { preset: 'isometric' }, }, { id: 'brake-clamping', title: 'Pads clamp the rotor', body: 'The piston pushes the inner pad; caliper reaction brings the outer pad into the rotor to create equal and opposite friction forces.', partIds: ['piston', 'inner-pad', 'outer-pad', 'rotor'], durationSeconds: 7, camera: { preset: 'front' }, }, { id: 'brake-heat', title: 'Friction creates heat', body: 'The rotor absorbs and rejects most braking heat, so ventilation and material selection are critical.', partIds: ['rotor', 'inner-pad', 'outer-pad'], durationSeconds: 6, camera: { preset: 'top' }, }, ], relatedIds: ['ball-bearing', 'differential-gear', 'centrifugal-pump'], }, 'wankel-rotary-engine': { id: 'wankel-rotary-engine', name: 'Wankel Rotary Engine', title: 'Wankel Rotary Engine', category: 'Engines', difficulty: 'Advanced', summary: 'A rotary combustion engine with triangular rotor, eccentric shaft, port timing, and apex seals.', description: 'The Wankel engine performs intake, compression, combustion, and exhaust in separate moving chambers around a triangular rotor. Its eccentric shaft converts rotor orbit into output rotation with very few reciprocating parts.', keywords: ['rotary engine', 'epitrochoid', 'apex seals', 'eccentric shaft', 'port timing'], complexity: 9, createdAt: '2026-01-11', thumbnail: 'procedural://wankel-rotary-engine', parts: [ { id: 'epitrochoid-housing', name: 'Epitrochoid Housing', description: 'Figure-eight-like housing profile that maintains three variable-volume working chambers.', material: 'Aluminium housing with hardened liner', defaultOpacity: 0.48, labelPosition: [0, 1.35, 0], specs: { geometry: 'Epitrochoid', role: 'Combustion chamber boundary' }, }, { id: 'rotor', name: 'Triangular Rotor', description: 'Reuleaux-like rotor that orbits inside the housing and separates the working chambers.', material: 'Cast iron or aluminium alloy', labelPosition: [0, 0.2, 0.35], specs: { motion: 'Orbiting rotation', chamberCount: 'Three faces' }, }, { id: 'eccentric-shaft', name: 'Eccentric Shaft', description: 'Output shaft with eccentric lobe that converts rotor orbit into shaft rotation.', material: 'Forged steel', labelPosition: [0, -0.85, 0], specs: { ratio: 'Output shaft rotates 3× rotor speed', role: 'Torque output' }, }, { id: 'apex-seals', name: 'Apex Seals', description: 'Sealing strips at rotor tips that maintain chamber separation along the housing wall.', material: 'Carbon, cast iron, or ceramic composite', labelPosition: [1.2, 0.45, 0.2], specs: { challenge: 'Wear, lubrication, sealing', function: 'Chamber isolation' }, }, { id: 'intake-port', name: 'Intake Port', description: 'Port uncovered by rotor motion to admit fresh charge into an expanding chamber.', material: 'Machined housing opening', labelPosition: [-1.4, -0.55, 0], specs: { timing: 'Port-controlled', flow: 'Fresh air-fuel mixture' }, }, { id: 'exhaust-port', name: 'Exhaust Port', description: 'Port uncovered by rotor motion to release combustion products.', material: 'Machined housing opening', labelPosition: [1.45, -0.55, 0], specs: { timing: 'Port-controlled', flow: 'Exhaust gas' }, }, { id: 'spark-plugs', name: 'Spark Plugs', description: 'Ignition plugs firing across the elongated chamber for reliable combustion.', material: 'Ceramic and nickel alloy', labelPosition: [0.92, 1.05, 0.28], specs: { count: 'Often leading and trailing plugs', event: 'Ignition during compressed chamber phase' }, }, { id: 'stationary-gear', name: 'Stationary Gear', description: 'Fixed gear that constrains the rotor through an internal rotor gear to maintain phasing.', material: 'Steel', labelPosition: [-0.82, 0.95, 0.25], specs: { purpose: 'Rotor phasing', relation: 'Guides orbital motion' }, }, ], facts: [ { label: 'Output relation', value: 'Eccentric shaft rotates 3× rotor speed' }, { label: 'Power density', value: 'High for package size' }, { label: 'Invented', value: '1950s, Felix Wankel' }, { label: 'Applications', value: 'Mazda RX series, UAVs, range extenders, racing' }, { label: 'Design challenge', value: 'Apex sealing and emissions' }, ], tour: [ { id: 'wankel-geometry', title: 'Orbiting triangular rotor', body: 'The triangular rotor orbits inside an epitrochoid housing, creating three moving working chambers.', partIds: ['rotor', 'epitrochoid-housing', 'eccentric-shaft'], durationSeconds: 7, camera: { preset: 'front' }, }, { id: 'wankel-port-timing', title: 'Ports replace poppet valves', body: 'Rotor position uncovers intake and exhaust ports, so the engine breathes without a camshaft or valve train.', partIds: ['intake-port', 'exhaust-port', 'rotor'], durationSeconds: 6, camera: { preset: 'isometric' }, }, { id: 'wankel-sealing', title: 'Apex seals separate chambers', body: 'The apex seals slide along the housing wall to keep compression, combustion, and exhaust chambers isolated.', partIds: ['apex-seals', 'epitrochoid-housing'], durationSeconds: 6, camera: { preset: 'top' }, }, { id: 'wankel-ignition', title: 'Ignition in the compressed chamber', body: 'Spark plugs fire as the chamber reaches minimum volume, driving rotor motion around the eccentric shaft.', partIds: ['spark-plugs', 'rotor', 'eccentric-shaft'], durationSeconds: 6, camera: { position: [2.5, 1.8, 2.6], target: [0, 0.15, 0] }, }, ], relatedIds: ['four-stroke-petrol-engine', 'planetary-gearbox', 'ball-bearing'], }, }; const MODULE_SOURCES: ModuleSource[] = [ { id: 'four-stroke-petrol-engine', module: FourStrokeModule as UnknownRecord }, { id: 'planetary-gearbox', module: PlanetaryGearboxModule as UnknownRecord }, { id: 'differential-gear', module: DifferentialGearModule as UnknownRecord }, { id: 'centrifugal-pump', module: CentrifugalPumpModule as UnknownRecord }, { id: 'geneva-drive', module: GenevaDriveModule as UnknownRecord }, { id: 'ball-bearing', module: BallBearingModule as UnknownRecord }, { id: 'disc-brake-caliper', module: DiscBrakeCaliperModule as UnknownRecord }, { id: 'wankel-rotary-engine', module: WankelRotaryModule as UnknownRecord }, ]; const MANIFEST_RECORD = ManifestModule as UnknownRecord; function isRecord(value: unknown): value is UnknownRecord { return typeof value === 'object' && value !== null && !Array.isArray(value); } function asString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; } function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } function asBoolean(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined; } function asVector3(value: unknown): Vector3Tuple | undefined { if (!Array.isArray(value) || value.length < 3) { return undefined; } const [x, y, z] = value.map((entry) => Number(entry)); if (![x, y, z].every(Number.isFinite)) { return undefined; } return [x, y, z]; } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; } return value .map((entry) => (typeof entry === 'string' ? entry.trim() : undefined)) .filter((entry): entry is string => Boolean(entry)); } function compactKey(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]/g, ''); } function slugify(value: string): string { return value .trim() .toLowerCase() .replace(/['"]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); } const ID_LOOKUP = new Map(); for (const id of PROCEDURAL_DEMO_IDS) { const fallback = FALLBACK_DEMOS[id]; ID_LOOKUP.set(compactKey(id), id); ID_LOOKUP.set(compactKey(fallback.name), id); ID_LOOKUP.set(compactKey(fallback.title), id); ID_LOOKUP.set(compactKey(fallback.thumbnail.replace('procedural://', '')), id); } ID_LOOKUP.set('fourstroke', 'four-stroke-petrol-engine'); ID_LOOKUP.set('fourstrokeengine', 'four-stroke-petrol-engine'); ID_LOOKUP.set('ottoengine', 'four-stroke-petrol-engine'); ID_LOOKUP.set('petrolengine', 'four-stroke-petrol-engine'); ID_LOOKUP.set('planetary', 'planetary-gearbox'); ID_LOOKUP.set('epicyclicgearbox', 'planetary-gearbox'); ID_LOOKUP.set('differential', 'differential-gear'); ID_LOOKUP.set('diffgear', 'differential-gear'); ID_LOOKUP.set('pump', 'centrifugal-pump'); ID_LOOKUP.set('centrifugal', 'centrifugal-pump'); ID_LOOKUP.set('geneva', 'geneva-drive'); ID_LOOKUP.set('genevamechanism', 'geneva-drive'); ID_LOOKUP.set('bearing', 'ball-bearing'); ID_LOOKUP.set('brake', 'disc-brake-caliper'); ID_LOOKUP.set('discbrake', 'disc-brake-caliper'); ID_LOOKUP.set('caliper', 'disc-brake-caliper'); ID_LOOKUP.set('wankel', 'wankel-rotary-engine'); ID_LOOKUP.set('rotaryengine', 'wankel-rotary-engine'); export function normalizeProceduralDemoId(value: string | null | undefined): ProceduralDemoMachineId | undefined { if (!value) { return undefined; } const trimmed = value.trim(); if (!trimmed) { return undefined; } return ID_LOOKUP.get(compactKey(trimmed)) ?? ID_LOOKUP.get(compactKey(slugify(trimmed))); } export function isProceduralDemoId(value: string | null | undefined): value is ProceduralDemoMachineId { return normalizeProceduralDemoId(value) !== undefined; } function isDefinitionLike(value: UnknownRecord): boolean { return ( 'id' in value || 'slug' in value || 'name' in value || 'title' in value || 'parts' in value || 'facts' in value || 'tour' in value || 'guidedTour' in value || 'Scene' in value || 'scene' in value || 'component' in value || 'Component' in value || 'animation' in value || 'animationModule' in value ); } function pickDefinition(moduleRecord: UnknownRecord, fallback: FallbackDemoMetadata): UnknownRecord { const defaultExport = moduleRecord.default; if (isRecord(defaultExport) && isDefinitionLike(defaultExport)) { return defaultExport; } const objectExports = Object.values(moduleRecord).filter(isRecord); const idMatch = objectExports.find((candidate) => { const rawId = asString(candidate.id) ?? asString(candidate.slug) ?? asString(candidate.machineId); return normalizeProceduralDemoId(rawId) === fallback.id; }); if (idMatch) { return idMatch; } const titleKey = compactKey(fallback.name); const titleMatch = objectExports.find((candidate) => { const rawTitle = asString(candidate.name) ?? asString(candidate.title); return rawTitle ? compactKey(rawTitle) === titleKey : false; }); if (titleMatch) { return titleMatch; } return objectExports.find(isDefinitionLike) ?? {}; } function pickManifestEntry(id: ProceduralDemoMachineId): UnknownRecord { const manifestCandidates = [ MANIFEST_RECORD.proceduralDemoManifest, MANIFEST_RECORD.PROCEDURAL_DEMO_MANIFEST, MANIFEST_RECORD.proceduralDemoCatalogManifest, MANIFEST_RECORD.default, MANIFEST_RECORD.manifest, ]; for (const candidate of manifestCandidates) { if (Array.isArray(candidate)) { const entry = candidate.find((item) => { if (!isRecord(item)) { return false; } return normalizeProceduralDemoId(asString(item.id) ?? asString(item.slug) ?? asString(item.machineId)) === id; }); if (isRecord(entry)) { return entry; } } if (isRecord(candidate)) { const direct = candidate[id]; if (isRecord(direct)) { return direct; } const byAlias = Object.values(candidate).find((item) => { if (!isRecord(item)) { return false; } return normalizeProceduralDemoId(asString(item.id) ?? asString(item.slug) ?? asString(item.machineId)) === id; }); if (isRecord(byAlias)) { return byAlias; } } } return {}; } function isComponent(value: unknown): value is ComponentType> { return typeof value === 'function'; } function pickSceneComponent( moduleRecord: UnknownRecord, definition: UnknownRecord, ): ComponentType> { const directKeys = [ 'Scene', 'scene', 'Component', 'component', 'SceneComponent', 'MachineScene', 'Model', 'model', 'View', 'view', ]; for (const key of directKeys) { const value = definition[key]; if (isComponent(value)) { return value; } if (isRecord(value)) { const nested = value.Scene ?? value.component ?? value.Component; if (isComponent(nested)) { return nested; } } } if (isComponent(moduleRecord.default)) { return moduleRecord.default; } const namedComponent = Object.entries(moduleRecord).find(([key, value]) => { return ( isComponent(value) && /^[A-Z]/.test(key) && /(Scene|Machine|Engine|Gearbox|Differential|Pump|Drive|Bearing|Caliper|Wankel|Demo|Model)$/.test(key) ); }); if (namedComponent && isComponent(namedComponent[1])) { return namedComponent[1]; } return MissingProceduralScene; } function pickAnimationModule(moduleRecord: UnknownRecord, definition: UnknownRecord): unknown { const directKeys = ['animationModule', 'animation', 'animator', 'runtimeAnimation', 'machineAnimation']; for (const key of directKeys) { if (definition[key] !== undefined) { return definition[key]; } if (moduleRecord[key] !== undefined) { return moduleRecord[key]; } } const named = Object.entries(moduleRecord).find(([key, value]) => { if (!isRecord(value) && typeof value !== 'function') { return false; } return /(animation|animator|timeline|motion)$/i.test(key); }); return named?.[1]; } function normalizeCategory(value: unknown, fallback: ProceduralDemoCategory): ProceduralDemoCategory { const text = asString(value); if ( text === 'Engines' || text === 'Gearboxes & Drives' || text === 'Pumps & Fluid Systems' || text === 'Mechanisms' || text === 'Structural / Other' ) { return text; } if (text === 'Gearboxes' || text === 'Drives') { return 'Gearboxes & Drives'; } if (text === 'Pumps' || text === 'Fluid Systems') { return 'Pumps & Fluid Systems'; } if (text === 'Structural' || text === 'Other') { return 'Structural / Other'; } return fallback; } function normalizeDifficulty(value: unknown, fallback: ProceduralDemoDifficulty): ProceduralDemoDifficulty { const text = asString(value); if (text === 'Beginner' || text === 'Intermediate' || text === 'Advanced') { return text; } return fallback; } function normalizeSpecs(value: unknown, fallback?: Record): Record | undefined { const specs: Record = { ...(fallback ?? {}) }; if (isRecord(value)) { for (const [key, entry] of Object.entries(value)) { if (entry === undefined || entry === null) { continue; } specs[key] = String(entry); } } return Object.keys(specs).length > 0 ? specs : undefined; } function normalizePart(value: unknown, fallback: ProceduralDemoPart | undefined, index: number): ProceduralDemoPart { if (typeof value === 'string') { return { id: slugify(value) || fallback?.id || `part-${index + 1}`, name: value, description: fallback?.description ?? 'Mechanical component in the procedural training model.', material: fallback?.material, defaultVisible: fallback?.defaultVisible, defaultOpacity: fallback?.defaultOpacity, color: fallback?.color, labelPosition: fallback?.labelPosition, specs: fallback?.specs, }; } const record = isRecord(value) ? value : {}; const id = asString(record.id) ?? asString(record.partId) ?? asString(record.key) ?? asString(record.slug) ?? fallback?.id ?? `part-${index + 1}`; const name = asString(record.name) ?? asString(record.title) ?? fallback?.name ?? id .split('-') .map((piece) => piece.charAt(0).toUpperCase() + piece.slice(1)) .join(' '); return { id, name, description: asString(record.description) ?? asString(record.summary) ?? fallback?.description ?? 'Mechanical component in the procedural training model.', material: asString(record.material) ?? fallback?.material, defaultVisible: asBoolean(record.defaultVisible) ?? asBoolean(record.visible) ?? fallback?.defaultVisible, defaultOpacity: asNumber(record.defaultOpacity) ?? asNumber(record.opacity) ?? fallback?.defaultOpacity, color: asString(record.color) ?? fallback?.color, labelPosition: asVector3(record.labelPosition) ?? asVector3(record.label) ?? asVector3(record.position) ?? fallback?.labelPosition, specs: normalizeSpecs(record.specs ?? record.properties, fallback?.specs), }; } function normalizeParts(value: unknown, fallbackParts: ProceduralDemoPart[]): ProceduralDemoPart[] { const source = Array.isArray(value) && value.length > 0 ? value : fallbackParts; const fallbackById = new Map(fallbackParts.map((part) => [part.id, part])); const result: ProceduralDemoPart[] = []; const seen = new Set(); source.forEach((entry, index) => { const provisionalId = isRecord(entry) ? asString(entry.id) ?? asString(entry.partId) ?? asString(entry.slug) ?? undefined : typeof entry === 'string' ? slugify(entry) : undefined; const fallback = provisionalId ? fallbackById.get(provisionalId) : fallbackParts[index]; const part = normalizePart(entry, fallback, index); if (!seen.has(part.id)) { result.push(part); seen.add(part.id); } }); for (const fallback of fallbackParts) { if (!seen.has(fallback.id)) { result.push(fallback); seen.add(fallback.id); } } return result; } function normalizeFact(value: unknown, fallback: ProceduralDemoFact | undefined, index: number): ProceduralDemoFact { if (typeof value === 'string') { return { label: fallback?.label ?? `Fact ${index + 1}`, value, unit: fallback?.unit, description: fallback?.description, }; } const record = isRecord(value) ? value : {}; return { label: asString(record.label) ?? asString(record.name) ?? fallback?.label ?? `Fact ${index + 1}`, value: asString(record.value) ?? asString(record.text) ?? fallback?.value ?? '—', unit: asString(record.unit) ?? fallback?.unit, description: asString(record.description) ?? fallback?.description, }; } function normalizeFacts(value: unknown, fallbackFacts: ProceduralDemoFact[]): ProceduralDemoFact[] { const result: ProceduralDemoFact[] = []; if (Array.isArray(value) && value.length > 0) { value.forEach((entry, index) => { result.push(normalizeFact(entry, fallbackFacts[index], index)); }); } else if (isRecord(value)) { Object.entries(value).forEach(([label, factValue], index) => { result.push({ label, value: String(factValue), unit: fallbackFacts[index]?.unit, description: fallbackFacts[index]?.description, }); }); } else { result.push(...fallbackFacts); } const seenLabels = new Set(result.map((fact) => compactKey(fact.label))); for (const fallback of fallbackFacts) { if (!seenLabels.has(compactKey(fallback.label))) { result.push(fallback); seenLabels.add(compactKey(fallback.label)); } } return result; } function normalizeTourStep( value: unknown, fallback: ProceduralDemoTourStep | undefined, index: number, ): ProceduralDemoTourStep { const record = isRecord(value) ? value : {}; const cameraRecord = record.camera ?? record.cameraPose ?? record.view; return { id: asString(record.id) ?? asString(record.key) ?? fallback?.id ?? `tour-step-${index + 1}`, title: asString(record.title) ?? asString(record.name) ?? fallback?.title ?? `Tour step ${index + 1}`, body: asString(record.body) ?? asString(record.description) ?? asString(record.caption) ?? fallback?.body ?? 'Observe the highlighted mechanical components and their coordinated motion.', partIds: asStringArray(record.partIds).length > 0 ? asStringArray(record.partIds) : asStringArray(record.parts).length > 0 ? asStringArray(record.parts) : fallback?.partIds ?? [], durationSeconds: asNumber(record.durationSeconds) ?? asNumber(record.duration) ?? asNumber(record.seconds) ?? fallback?.durationSeconds ?? 6, camera: isRecord(cameraRecord) ? { preset: asString(cameraRecord.preset) === 'front' || asString(cameraRecord.preset) === 'back' || asString(cameraRecord.preset) === 'left' || asString(cameraRecord.preset) === 'right' || asString(cameraRecord.preset) === 'top' || asString(cameraRecord.preset) === 'bottom' || asString(cameraRecord.preset) === 'isometric' ? (asString(cameraRecord.preset) as ProceduralDemoCameraPose['preset']) : fallback?.camera?.preset, position: asVector3(cameraRecord.position) ?? fallback?.camera?.position, target: asVector3(cameraRecord.target) ?? fallback?.camera?.target, } : fallback?.camera, animationCue: asString(record.animationCue) ?? asString(record.cue) ?? fallback?.animationCue, }; } function normalizeTour(value: unknown, fallbackTour: ProceduralDemoTourStep[]): ProceduralDemoTourStep[] { const source = Array.isArray(value) && value.length > 0 ? value : fallbackTour; return source.map((entry, index) => normalizeTourStep(entry, fallbackTour[index], index)); } function normalizeRelatedIds(value: unknown, fallback: ProceduralDemoMachineId[]): ProceduralDemoMachineId[] { const candidates = asStringArray(value) .map(normalizeProceduralDemoId) .filter((entry): entry is ProceduralDemoMachineId => Boolean(entry)); return candidates.length > 0 ? candidates : fallback; } function readFirstString(records: UnknownRecord[], keys: string[], fallback: string): string { for (const record of records) { for (const key of keys) { const value = asString(record[key]); if (value) { return value; } } } return fallback; } function readFirstNumber(records: UnknownRecord[], keys: string[], fallback: number): number { for (const record of records) { for (const key of keys) { const value = asNumber(record[key]); if (value !== undefined) { return value; } } } return fallback; } function readFirstStringArray(records: UnknownRecord[], keys: string[], fallback: string[]): string[] { for (const record of records) { for (const key of keys) { const value = asStringArray(record[key]); if (value.length > 0) { return value; } } } return fallback; } function buildMachine(source: ModuleSource): ProceduralDemoMachine { const fallback = FALLBACK_DEMOS[source.id]; const definition = pickDefinition(source.module, fallback); const manifest = pickManifestEntry(source.id); const records = [definition, manifest]; const Scene = pickSceneComponent(source.module, definition); const title = readFirstString(records, ['title', 'name'], fallback.title); const slug = slugify(readFirstString(records, ['slug', 'id', 'machineId'], fallback.id)); const parts = normalizeParts(definition.parts ?? definition.components ?? manifest.parts ?? manifest.components, fallback.parts); return { id: fallback.id, slug: slug || fallback.id, name: readFirstString(records, ['name', 'title'], fallback.name), title, category: normalizeCategory(definition.category ?? manifest.category, fallback.category), difficulty: normalizeDifficulty(definition.difficulty ?? manifest.difficulty, fallback.difficulty), summary: readFirstString(records, ['summary', 'shortDescription', 'subtitle'], fallback.summary), description: readFirstString(records, ['description', 'body'], fallback.description), keywords: readFirstStringArray(records, ['keywords', 'tags', 'searchTerms'], fallback.keywords), complexity: readFirstNumber(records, ['complexity', 'complexityScore'], fallback.complexity), createdAt: readFirstString(records, ['createdAt', 'date', 'newest'], fallback.createdAt), thumbnail: readFirstString(records, ['thumbnail', 'thumbnailUrl', 'image'], fallback.thumbnail), parts, facts: normalizeFacts(definition.facts ?? definition.engineeringFacts ?? manifest.facts, fallback.facts), tour: normalizeTour( definition.tour ?? definition.guidedTour ?? definition.tourSteps ?? manifest.tour ?? manifest.guidedTour, fallback.tour, ), relatedIds: normalizeRelatedIds(definition.relatedIds ?? definition.related ?? manifest.relatedIds, fallback.relatedIds), Scene, scene: Scene, animationModule: pickAnimationModule(source.module, definition), animation: pickAnimationModule(source.module, definition), sourceExports: Object.keys(source.module).sort((a, b) => a.localeCompare(b)), }; } export const proceduralDemoCatalog: ProceduralDemoMachine[] = MODULE_SOURCES.map(buildMachine); export const proceduralDemoMachines = proceduralDemoCatalog; export const proceduralDemoMachineCatalog = proceduralDemoCatalog; export const proceduralDemoDefinitions = proceduralDemoCatalog; export const proceduralDemoMachineMap: ReadonlyMap = new Map( proceduralDemoCatalog.map((machine) => [machine.id, machine]), ); export function getDefaultProceduralDemoId(): ProceduralDemoMachineId { return 'four-stroke-petrol-engine'; } export function getProceduralDemoMachine( idOrSlug: string | null | undefined, ): ProceduralDemoMachine | undefined { const normalized = normalizeProceduralDemoId(idOrSlug); if (normalized) { return proceduralDemoMachineMap.get(normalized); } if (!idOrSlug) { return undefined; } const slug = slugify(idOrSlug); return proceduralDemoCatalog.find((machine) => machine.slug === slug); } export function requireProceduralDemoMachine(idOrSlug: string | null | undefined): ProceduralDemoMachine { return ( getProceduralDemoMachine(idOrSlug) ?? proceduralDemoMachineMap.get(getDefaultProceduralDemoId()) ?? proceduralDemoCatalog[0] ); } export function getProceduralDemoById(idOrSlug: string | null | undefined): ProceduralDemoMachine | undefined { return getProceduralDemoMachine(idOrSlug); } export function getProceduralDemoMachineById( idOrSlug: string | null | undefined, ): ProceduralDemoMachine | undefined { return getProceduralDemoMachine(idOrSlug); } export function getProceduralMachineBySlug( idOrSlug: string | null | undefined, ): ProceduralDemoMachine | undefined { return getProceduralDemoMachine(idOrSlug); } export function listProceduralDemoMachines(): ProceduralDemoMachine[] { return [...proceduralDemoCatalog]; } export function listProceduralDemoCategories(): ProceduralDemoCategory[] { return Array.from(new Set(proceduralDemoCatalog.map((machine) => machine.category))); } export function listProceduralDemoDifficulties(): ProceduralDemoDifficulty[] { return Array.from(new Set(proceduralDemoCatalog.map((machine) => machine.difficulty))); } export function getProceduralDemoSearchText(machine: ProceduralDemoMachine): string { return [ machine.id, machine.slug, machine.name, machine.title, machine.category, machine.difficulty, machine.summary, machine.description, ...machine.keywords, ...machine.parts.flatMap((part) => [part.id, part.name, part.description, part.material ?? '']), ...machine.facts.flatMap((fact) => [fact.label, fact.value, fact.unit ?? '', fact.description ?? '']), ] .join(' ') .toLowerCase(); } export default proceduralDemoCatalog;