/** * RESOLVED SEMANTIC VISUAL INTENT * * Every teaching scene must resolve to an explicit visual intent at * package-build time. The runtime renderer consumes the resolved intent * rather than guessing the topic from title strings each frame. * * This replaces vague keyword matching with explicit, validated intents. */ // --------------------------------------------------------------------------- // Canonical chapter IDs from the authoritative manifest // --------------------------------------------------------------------------- export const CANONICAL_CHAPTER_IDS = [ "phy-p1-c1", // Sound Waves "phy-p1-c2", // Lenses "phy-p1-c3", // The World of Colours and Vision "phy-p1-c4", // Magnetic Effect of Electric Current "phy-p2-c5", // Electric Energy: Consumption and Conservation "phy-p2-c6", // Electromagnetic Induction in Daily Life "phy-p2-c7", // Mechanical Advantage in Action ] as const; export type CanonicalChapterId = (typeof CANONICAL_CHAPTER_IDS)[number]; export function isCanonicalChapterId(id: string): id is CanonicalChapterId { return (CANONICAL_CHAPTER_IDS as readonly string[]).includes(id); } // --------------------------------------------------------------------------- // Visual families per chapter — exhaustive enumeration // --------------------------------------------------------------------------- export type SoundVisualFamily = | "oscillation_source" | "longitudinal_wave" | "transverse_wave" | "resonance" | "echo_reflection" | "audibility_range" | "seismic_tsunami" | "sonar" | "ultrasonic_application" | "wave_graph" | "formula_workspace" | "numerical_workspace" | "exam_answer" | "recap" | "homework" | "context_image"; export type LensVisualFamily = | "lens_anatomy" | "convex_concave_comparison" | "optical_bench" | "ray_rule_1" | "ray_rule_2" | "ray_rule_3" | "convex_beyond_2f" | "convex_at_2f" | "convex_between_f_2f" | "convex_at_f" | "convex_inside_f" | "concave_construction" | "sign_convention" | "lens_formula" | "magnification" | "power_of_lens" | "camera_projector" | "magnifying_glass" | "formula_workspace" | "numerical_workspace" | "exam_answer" | "recap" | "homework" | "context_image"; export type ColourVisualFamily = | "prism_dispersion" | "two_prism_recombination" | "rainbow_raindrops" | "rgb_additive_mixing" | "complementary_colours" | "coloured_object_illumination" | "colour_filters" | "atmospheric_scattering" | "blue_sky" | "red_sunset" | "eye_anatomy" | "accommodation" | "near_far_point" | "myopia" | "hypermetropia" | "presbyopia" | "correcting_lens" | "lens_power_numerical" | "formula_workspace" | "numerical_workspace" | "exam_answer" | "recap" | "homework" | "context_image"; export type MagneticVisualFamily = | "oersted_experiment" | "straight_conductor" | "circular_coil" | "solenoid" | "electromagnet" | "electromagnet_application" | "force_on_conductor" | "flemings_left_hand" | "dc_motor" | "split_ring_commutator" | "moving_coil_loudspeaker" | "formula_workspace" | "numerical_workspace" | "exam_answer" | "recap" | "homework" | "context_image"; export type ElectricEnergyVisualFamily = | "heating_circuit" | "nichrome_coil" | "electric_iron" | "kettle_heater" | "watt_hour_meter" | "appliance_star_rating" | "tod_timeline" | "household_bill" | "solar_rooftop" | "carbon_emission" | "conservation_transport" | "power_formula" | "kwh_numerical" | "household_energy_calculation" | "formula_workspace" | "numerical_workspace" | "exam_answer" | "recap" | "homework" | "context_image"; export type InductionVisualFamily = | "magnet_stationary" | "magnet_moving_in" | "magnet_stopped" | "magnet_moving_away" | "galvanometer_deflection" | "ac_waveform" | "ac_generator" | "dc_generator" | "slip_ring" | "split_ring" | "generator_energy" | "generating_station" | "step_up_transformer" | "step_down_transformer" | "transmission_towers" | "transformer_construction" | "domestic_wiring" | "three_pin_plug" | "fuse_mcb" | "elcb" | "appliance_earthing" | "formula_workspace" | "numerical_workspace" | "exam_answer" | "recap" | "homework" | "context_image"; export type MachineVisualFamily = | "first_order_lever" | "second_order_lever" | "third_order_lever" | "all_lever_orders" | "fixed_pulley" | "movable_pulley" | "first_pulley_system" | "second_pulley_system" | "rope_segment_counting" | "wheel_and_axle" | "gear_pair" | "gear_speed_torque" | "inclined_plane" | "wedge" | "screw_wrapped_plane" | "screw_jack" | "no_gain_in_work" | "real_world_applications" | "formula_workspace" | "numerical_workspace" | "exam_answer" | "recap" | "homework" | "context_image"; export type VisualFamily = | SoundVisualFamily | LensVisualFamily | ColourVisualFamily | MagneticVisualFamily | ElectricEnergyVisualFamily | InductionVisualFamily | MachineVisualFamily; // --------------------------------------------------------------------------- // Forbidden visual families per chapter — prevent cross-topic misrouting // --------------------------------------------------------------------------- export const FORBIDDEN_FAMILIES: Record = { "phy-p1-c1": ["prism_dispersion", "lens_anatomy", "oersted_experiment", "heating_circuit", "ac_generator", "first_order_lever"], "phy-p1-c2": ["prism_dispersion", "longitudinal_wave", "oersted_experiment", "heating_circuit", "ac_generator", "first_order_lever"], "phy-p1-c3": ["longitudinal_wave", "oersted_experiment", "heating_circuit", "ac_generator", "first_order_lever"], "phy-p1-c4": ["longitudinal_wave", "prism_dispersion", "lens_anatomy", "heating_circuit", "ac_generator", "first_order_lever"], "phy-p2-c5": ["longitudinal_wave", "prism_dispersion", "lens_anatomy", "oersted_experiment", "ac_generator", "first_order_lever"], "phy-p2-c6": ["longitudinal_wave", "prism_dispersion", "lens_anatomy", "oersted_experiment", "first_order_lever"], "phy-p2-c7": ["longitudinal_wave", "prism_dispersion", "lens_anatomy", "oersted_experiment", "heating_circuit", "ac_generator"], }; // --------------------------------------------------------------------------- // Resolved Visual Intent — the output of the build-time router // --------------------------------------------------------------------------- export interface ResolvedVisualIntent { chapterId: CanonicalChapterId; sceneId: string; topicKey: string; visualFamily: VisualFamily; /** ID from the image-asset manifest, if a generated base image is used. */ baseAssetId?: string; /** State variant key for the same apparatus in different states. */ stateVariant?: string; /** Deterministic overlay IDs to render on top of the base image. */ overlayIds: string[]; /** Visual families that this scene must NOT use. */ forbiddenVisualFamilies: string[]; /** Textbook source page for scientific diagrams. */ sourcePage?: number; /** Content hash for cache invalidation. */ contentHash?: string; } // --------------------------------------------------------------------------- // Topic → VisualFamily routing rules (build-time) // --------------------------------------------------------------------------- interface RoutingRule { pattern: RegExp; family: VisualFamily; overlays?: string[]; } const SOUND_RULES: RoutingRule[] = [ { pattern: /resonance|forced vibration|natural frequency/i, family: "resonance" }, { pattern: /longitudinal|compression|rarefaction/i, family: "longitudinal_wave" }, { pattern: /transverse/i, family: "transverse_wave" }, { pattern: /echo|reflection|reverberation/i, family: "echo_reflection" }, { pattern: /audib|ultrasonic|infrasonic|frequency range/i, family: "audibility_range" }, { pattern: /seismic|tsunami/i, family: "seismic_tsunami" }, { pattern: /sonar|depth.*sea|submarine/i, family: "sonar" }, { pattern: /ultrasound.*clean|ultrasonic.*appli|medical.*ultra/i, family: "ultrasonic_application" }, { pattern: /displacement.*graph|wave.*graph/i, family: "wave_graph" }, { pattern: /oscillat|vibrat|swing|pendulum|tuning fork/i, family: "oscillation_source" }, ]; const LENS_RULES: RoutingRule[] = [ { pattern: /rule 1|parallel ray/i, family: "ray_rule_1" }, { pattern: /rule 2|optic centre|optical centre/i, family: "ray_rule_2" }, { pattern: /rule 3|focus ray/i, family: "ray_rule_3" }, { pattern: /beyond 2f|outside 2f/i, family: "convex_beyond_2f" }, { pattern: /at 2f/i, family: "convex_at_2f" }, { pattern: /between f and 2f/i, family: "convex_between_f_2f" }, { pattern: /at f$|at the focus/i, family: "convex_at_f" }, { pattern: /inside focus|between f and o|magnifying/i, family: "convex_inside_f" }, { pattern: /concave.*construct|image.*concave/i, family: "concave_construction" }, { pattern: /sign convention/i, family: "sign_convention" }, { pattern: /lens formula|1\/f/i, family: "lens_formula" }, { pattern: /magnification|m\s*=\s*v\/u/i, family: "magnification" }, { pattern: /power.*lens|dioptre/i, family: "power_of_lens" }, { pattern: /camera|projector/i, family: "camera_projector" }, { pattern: /convex.*concave|types.*lens|shape.*lens/i, family: "convex_concave_comparison" }, { pattern: /optical bench|screen.*convex/i, family: "optical_bench" }, { pattern: /anatomy|parts.*lens/i, family: "lens_anatomy" }, ]; const COLOUR_RULES: RoutingRule[] = [ { pattern: /two.prism|recombination|newton.*experiment/i, family: "two_prism_recombination" }, { pattern: /prism|dispersion|spectrum/i, family: "prism_dispersion" }, { pattern: /rainbow/i, family: "rainbow_raindrops" }, { pattern: /primary.*colour|secondary.*colour|additive.*mix/i, family: "rgb_additive_mixing" }, { pattern: /complementary/i, family: "complementary_colours" }, { pattern: /coloured.*object|illuminat.*coloured/i, family: "coloured_object_illumination" }, { pattern: /filter/i, family: "colour_filters" }, { pattern: /scatter|blue.*sky|rayleigh/i, family: "atmospheric_scattering" }, { pattern: /sunset|reddening|red.*sun/i, family: "red_sunset" }, { pattern: /myopia|short.sight|near.sight/i, family: "myopia" }, { pattern: /hypermetropia|far.sight|long.sight/i, family: "hypermetropia" }, { pattern: /presbyopia/i, family: "presbyopia" }, { pattern: /correction|corrective|correcting.*lens/i, family: "correcting_lens" }, { pattern: /eye.*anatomy|retina|cornea|ciliary/i, family: "eye_anatomy" }, { pattern: /accommod/i, family: "accommodation" }, { pattern: /near point|far point/i, family: "near_far_point" }, { pattern: /power.*numerical|power.*dioptre.*calc/i, family: "lens_power_numerical" }, ]; const MAGNETIC_RULES: RoutingRule[] = [ { pattern: /oersted/i, family: "oersted_experiment" }, { pattern: /solenoid/i, family: "solenoid" }, { pattern: /circular.*coil|circular.*loop/i, family: "circular_coil" }, { pattern: /straight.*conductor/i, family: "straight_conductor" }, { pattern: /loudspeaker|speaker/i, family: "moving_coil_loudspeaker" }, { pattern: /commutator|split.ring.*close/i, family: "split_ring_commutator" }, { pattern: /motor|force on.*conductor|left.hand/i, family: "dc_motor" }, { pattern: /fleming/i, family: "flemings_left_hand" }, { pattern: /electromagnet.*crane|scrap|electromagnet.*appli/i, family: "electromagnet_application" }, { pattern: /electromagnet/i, family: "electromagnet" }, ]; const ENERGY_RULES: RoutingRule[] = [ { pattern: /nichrome|heating coil/i, family: "nichrome_coil" }, { pattern: /iron.*cutaway|electric iron/i, family: "electric_iron" }, { pattern: /kettle|heater/i, family: "kettle_heater" }, { pattern: /watt.*hour|energy meter/i, family: "watt_hour_meter" }, { pattern: /star.*rat/i, family: "appliance_star_rating" }, { pattern: /tod|time of day|peak.*hour/i, family: "tod_timeline" }, { pattern: /bill|tariff/i, family: "household_bill" }, { pattern: /solar/i, family: "solar_rooftop" }, { pattern: /carbon|emission|footprint/i, family: "carbon_emission" }, { pattern: /conservation|transport|led/i, family: "conservation_transport" }, { pattern: /kwh.*numerical|household.*calc/i, family: "household_energy_calculation" }, { pattern: /heat|joule|resistance/i, family: "heating_circuit" }, { pattern: /power.*formula|P\s*=\s*VI/i, family: "power_formula" }, ]; const INDUCTION_RULES: RoutingRule[] = [ { pattern: /ac generator/i, family: "ac_generator" }, { pattern: /dc generator/i, family: "dc_generator" }, { pattern: /slip.ring/i, family: "slip_ring" }, { pattern: /step.up/i, family: "step_up_transformer" }, { pattern: /step.down/i, family: "step_down_transformer" }, { pattern: /transformer.*construct|iron core.*primary/i, family: "transformer_construction" }, { pattern: /transmission|power.*grid|distribut/i, family: "transmission_towers" }, { pattern: /generating station|power plant/i, family: "generating_station" }, { pattern: /domestic.*wir|house.*wir|live.*neutral/i, family: "domestic_wiring" }, { pattern: /three.pin|plug/i, family: "three_pin_plug" }, { pattern: /fuse|mcb/i, family: "fuse_mcb" }, { pattern: /elcb/i, family: "elcb" }, { pattern: /earth|ground.*fault/i, family: "appliance_earthing" }, { pattern: /ac wave|alternating.*current.*graph/i, family: "ac_waveform" }, { pattern: /galvanometer.*deflect/i, family: "galvanometer_deflection" }, { pattern: /magnet.*moving.*in|moving.*towards/i, family: "magnet_moving_in" }, { pattern: /magnet.*moving.*away|moving.*out/i, family: "magnet_moving_away" }, { pattern: /magnet.*stop|no.*relative.*motion/i, family: "magnet_stopped" }, { pattern: /magnet.*station|no.*change|stationary/i, family: "magnet_stationary" }, { pattern: /generator.*energy|mechanical.*electrical/i, family: "generator_energy" }, ]; const MACHINE_RULES: RoutingRule[] = [ { pattern: /three.*order|classif.*lever|all.*lever/i, family: "all_lever_orders" }, { pattern: /first.*order/i, family: "first_order_lever" }, { pattern: /second.*order/i, family: "second_order_lever" }, { pattern: /third.*order/i, family: "third_order_lever" }, { pattern: /block.*tackle|second.*pulley.*system|compound.*pulley/i, family: "second_pulley_system" }, { pattern: /first.*pulley.*system/i, family: "first_pulley_system" }, { pattern: /movable.*pulley/i, family: "movable_pulley" }, { pattern: /fixed.*pulley/i, family: "fixed_pulley" }, { pattern: /rope.*segment|count.*rope/i, family: "rope_segment_counting" }, { pattern: /wheel.*axle|bicycle.*axle/i, family: "wheel_and_axle" }, { pattern: /gear.*speed|gear.*torque|gear.*trade/i, family: "gear_speed_torque" }, { pattern: /gear/i, family: "gear_pair" }, { pattern: /inclined.*plane|ramp|loading/i, family: "inclined_plane" }, { pattern: /wedge/i, family: "wedge" }, { pattern: /screw.*jack/i, family: "screw_jack" }, { pattern: /screw|wrapped.*plane/i, family: "screw_wrapped_plane" }, { pattern: /no.*gain.*work/i, family: "no_gain_in_work" }, ]; const CHAPTER_RULES: Record = { "phy-p1-c1": SOUND_RULES, "phy-p1-c2": LENS_RULES, "phy-p1-c3": COLOUR_RULES, "phy-p1-c4": MAGNETIC_RULES, "phy-p2-c5": ENERGY_RULES, "phy-p2-c6": INDUCTION_RULES, "phy-p2-c7": MACHINE_RULES, }; // --------------------------------------------------------------------------- // Build-time router // --------------------------------------------------------------------------- /** Shared segment types that don't use chapter-specific visuals. */ function sharedSegmentFamily(segmentKind: string): VisualFamily | undefined { switch (segmentKind) { case "board_answer": return "exam_answer"; case "practice_sample": return "numerical_workspace"; case "recap_revision": return "recap"; case "homework": return "homework"; default: return undefined; } } /** * Resolve a scene to its visual family at package-build time. * The scene text is the concatenation of title + onScreenText + recapNote + formula. */ export function resolveVisualFamily( chapterId: string, segmentKind: string, sceneText: string, hasFormula: boolean, ): VisualFamily { // 1. Shared segment types const shared = sharedSegmentFamily(segmentKind); if (shared) return shared; // 2. Formula scenes if (hasFormula && segmentKind === "visual_board") return "formula_workspace"; // 3. Chapter-specific rules if (isCanonicalChapterId(chapterId)) { const rules = CHAPTER_RULES[chapterId]; for (const rule of rules) { if (rule.pattern.test(sceneText)) return rule.family; } } // 4. Fallback to context_image (will be resolved by context asset routing) return "context_image"; } /** * Validate that a visual family is not forbidden for the chapter. */ export function validateVisualIntent(intent: ResolvedVisualIntent): string[] { const issues: string[] = []; if (!isCanonicalChapterId(intent.chapterId)) { issues.push(`Unknown chapterId: ${intent.chapterId}`); return issues; } const forbidden = FORBIDDEN_FAMILIES[intent.chapterId]; if (forbidden.includes(intent.visualFamily)) { issues.push( `Scene ${intent.sceneId}: visual family "${intent.visualFamily}" is forbidden for chapter ${intent.chapterId}` ); } // Cross-chapter asset leak if (intent.baseAssetId && !intent.baseAssetId.startsWith(intent.chapterId.replace(/-/g, ""))) { // Allow shared assets if (!intent.baseAssetId.startsWith("shared-")) { issues.push( `Scene ${intent.sceneId}: asset "${intent.baseAssetId}" may belong to another chapter` ); } } return issues; }