From 9ef35701ede3b4c1613bccc7d447e4d90c26da97 Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Thu, 3 Sep 2026 22:08:01 +0800 Subject: [PATCH 01/12] robot_simulation: let a control program be written in Python Adds createPythonCSE(code), a Python-flavoured sibling of the existing Source-only createCSE(...): the string passed to it is interpreted by an embedded py-slang CSE machine (Control/Stash/ generateCSEMachineStateStream, imported as a plain library dependency - see companion PR on source-academy/py-slang), stepped in lockstep with the physics tick exactly like the existing js-slang-driven path. - pythonRuntime.ts (new): builds a py-slang Context seeded with SICPy builtins plus the ev3_* robot API (1:1 wrapping of ev3_functions.ts, so a Python program and a Source one drive the simulation identically), output routed to the Robot Console. - evaluate.ts: runPythonECEvaluator, the async-generator mirror of the existing runECEvaluator. - Program.ts: language/pyContext constructor params; an async "pump" drives the Python generator once per tick, since fixedUpdate is called synchronously but py-slang's stepper is async. - helper_functions.ts: createPythonCSE export, plus unwrapCallbackResult() - a defensive fix for an unrelated pre-existing js-slang bug (closureToJS leaves a tail-call wrapper {isTail, value} on values returned from native callbacks) that broke robot_simulation's init_simulation for every program, Python or Source, before any of this change. Worth reporting upstream to js-slang separately. - lib/buildtools/.../commons.ts: acorn ecmaVersion 6 -> 2020, needed because py-slang's BigInt literals otherwise crash buildtools' post- esbuild AST pass (py-slang isn't in the external:['js-slang*'] esbuild list, so it gets fully inlined). - Program.python.test.ts (new): real (non-mocked) test proving multi-tick Python stepping mutates real py-slang state correctly. Scope note: this is intentionally NOT a Conductor migration. robot_simulation still imports js-slang/context and loads exactly as before; the *setup* program (init_simulation, createWorld, ...) stays Source. Only the robot's *control* program can now be Python. Making the whole module - setup included - Python-only requires migrating robot_simulation to the BaseModulePlugin/attachModuleMethod pattern (as csg/rune/curve/plotly already did), which is separate, larger, follow-up work. Local proof: 123/123 tests pass, including the new Python-path tests alongside the unchanged existing js-slang-path tests. Manually confirmed end-to-end in a local frontend + language-directory + locally-built modules/py-slang stack: a Python control program (print + two ev3_runToRelativePosition calls) rendered the 3D scene and visibly drove the robot forward, with print() output reaching the Robot Console. Depends on source-academy/py-slang#457 (draft, not yet published) - robot_simulation/package.json currently points @sourceacademy/py-slang at a portal: path local to this machine as a result; that'll need to become a real registry reference once py-slang#457 publishes. Draft: checkpoint of local proof-of-concept work. Not requesting review yet. --- lib/buildtools/src/build/modules/commons.ts | 10 +- src/bundles/robot_simulation/package.json | 1 + .../src/controllers/program/Program.ts | 113 ++++++- .../program/__tests__/Program.python.test.ts | 56 +++ .../src/controllers/program/evaluate.ts | 92 ++++- .../src/controllers/program/pythonRuntime.ts | 221 ++++++++++++ .../robot_simulation/src/helper_functions.ts | 78 ++++- src/bundles/robot_simulation/src/index.ts | 1 + yarn.lock | 318 +++++++++++++++++- 9 files changed, 876 insertions(+), 14 deletions(-) create mode 100644 src/bundles/robot_simulation/src/controllers/program/__tests__/Program.python.test.ts create mode 100644 src/bundles/robot_simulation/src/controllers/program/pythonRuntime.ts diff --git a/lib/buildtools/src/build/modules/commons.ts b/lib/buildtools/src/build/modules/commons.ts index 4336dd696a..90300b6f3a 100644 --- a/lib/buildtools/src/build/modules/commons.ts +++ b/lib/buildtools/src/build/modules/commons.ts @@ -128,7 +128,13 @@ function convertAst(parsed: es.Program): ConvertAstResult { * Write the compiled output from ESBuild to the file system after performing AST transformation */ export async function outputBundleOrTab({ text }: OutputFile, input: InputAsset, outDir: string): Promise { - const parsed = parse(text, { ecmaVersion: 6 }) as es.Program; + // ecmaVersion bumped from 6 -> 2020: this is a purely mechanical AST-surgery pass (lifting the + // esbuild IIFE into an ExportDefaultDeclaration in convertAst below), not a target/syntax-lowering + // control — that's esbuild's own separate `target: 'es6'` option. ES6/acorn's parser can't parse + // BigInt literals (`0n`, added ES2020), which now show up here because py-slang (linked in for + // Python-flavoured bundles like robot_simulation) represents Python's arbitrary-precision ints as + // native BigInt at runtime and isn't in the esbuild `external` list the way 'js-slang*' is. + const parsed = parse(text, { ecmaVersion: 2020 }) as es.Program; const astResult = convertAst(parsed); if (astResult.severity === 'error') { @@ -181,7 +187,7 @@ export function builderPlugin(input: InputAsset, outDir: string): ESBuildPlugin onEnd(result => { const [{ text }] = result.outputFiles!; - const parsed = parse(text, { ecmaVersion: 6 }) as es.Program; + const parsed = parse(text, { ecmaVersion: 2020 }) as es.Program; const astResult = convertAst(parsed); if (astResult.severity === 'success') { generate(astResult.output, { output: writeStream }); diff --git a/src/bundles/robot_simulation/package.json b/src/bundles/robot_simulation/package.json index b080144392..398ff18ea3 100644 --- a/src/bundles/robot_simulation/package.json +++ b/src/bundles/robot_simulation/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@sourceacademy/modules-lib": "workspace:^", + "@sourceacademy/py-slang": "portal:/home/vakshay/Projects/local-pyslang-build/py-slang", "es-toolkit": "^1.44.0", "js-slang": "catalog:", "three": "^0.185.0" diff --git a/src/bundles/robot_simulation/src/controllers/program/Program.ts b/src/bundles/robot_simulation/src/controllers/program/Program.ts index 948a49cd49..1b3cdcd07c 100644 --- a/src/bundles/robot_simulation/src/controllers/program/Program.ts +++ b/src/bundles/robot_simulation/src/controllers/program/Program.ts @@ -1,5 +1,6 @@ import { GeneralRuntimeError } from '@sourceacademy/modules-lib/errors'; import type { DeepPartial } from '@sourceacademy/modules-lib/types'; +import type { Context as PyContext } from '@sourceacademy/py-slang'; import type { IOptions } from 'js-slang'; import context from 'js-slang/context'; import { CallbackHandler } from '../../engine/Core/CallbackHandler'; @@ -7,7 +8,7 @@ import type { Controller } from '../../engine/Core/Controller'; import type { PhysicsTimingInfo } from '../../engine/Physics'; import { mergeConfig } from '../utils/mergeConfig'; import { ProgramError } from './error'; -import { runECEvaluator } from './evaluate'; +import { runECEvaluator, runPythonECEvaluator } from './evaluate'; type ProgramConfig = { stepsPerTick: number; @@ -19,20 +20,65 @@ const defaultProgramConfig: ProgramConfig = { export const program_controller_identifier = 'program_controller'; +/** + * Which language flavour a Program should evaluate its code with. + * + * `'source'` is what {@link createCSE} produces: the surrounding Source program is re-run as the + * robot's control program, using the js-slang `Context` the host frontend injects into this bundle + * via the esbuild `external: ['js-slang*']` rule (see + * modules/lib/buildtools/src/build/modules/commons.ts). + * + * `'python'` is what {@link createPythonCSE} produces. There is no 'py-slang/context'-style + * runtime-injection convention anywhere in this codebase — buildtools' external list is still just + * `js-slang*` — so nothing hands this bundle a py-slang `Context`. It therefore builds its own; see + * controllers/program/pythonRuntime.ts, which also explains why a Python program running under + * py-slang's own conductor evaluator cannot simply `import robot_simulation` instead. + */ +export type ProgramLanguage = 'source' | 'python'; + export class Program implements Controller { code: string; + language: ProgramLanguage; + /** Only used when `language === 'python'`. The py-slang `Context` this Program's shadow + * evaluation runs against — entirely separate from js-slang's `context` singleton above. */ + pyContext: PyContext | null; iterator: ReturnType | null; + /** Only used when `language === 'python'`; `runECEvaluator`'s async counterpart. */ + pyIterator: ReturnType | null; + /** Guards against a new tick's pump starting before the previous tick's `await`ed steps have + * all landed. Needed only for the Python path: `fixedUpdate` is a synchronous callback (see + * Controller.ts / World.ts), so it cannot itself `await` — it kicks off a pump and returns + * immediately, and this flag stops a second pump from overlapping the first if steps ever take + * longer than one physics tick to resolve (e.g. a slow native/module call). */ + private pythonPumpBusy = false; + /** Set by `fixedUpdatePython`'s async pump if a step throws. Since the pump is fire-and-forget + * (fixedUpdate can't await it), the error can't be thrown synchronously from the tick that + * caused it — it's stashed here and re-thrown from the *next* `fixedUpdate` call instead, so it + * still surfaces to (and is convertible by) the same call site the sync path throws from. */ + private pythonError: unknown = null; isPaused: boolean; callbackHandler = new CallbackHandler(); name: string; config: ProgramConfig; - constructor(code: string, config?: DeepPartial) { + constructor( + code: string, + config?: DeepPartial, + language: ProgramLanguage = 'source', + pyContext: PyContext | null = null + ) { this.config = mergeConfig(defaultProgramConfig, config); this.name = program_controller_identifier; this.code = code; + this.language = language; + this.pyContext = pyContext; this.iterator = null; + this.pyIterator = null; this.isPaused = false; + + if (this.language === 'python' && this.pyContext === null) { + throw new GeneralRuntimeError('Program: pyContext is required when language is "python"'); + } } pause(pauseDuration: number) { @@ -43,6 +89,13 @@ export class Program implements Controller { } start() { + if (this.language === 'python') { + this.pyIterator = runPythonECEvaluator(this.code, this.pyContext!, { + stepLimit: -1, + }); + return; + } + const options: Partial = { originalMaxExecTime: Infinity, stepLimit: Infinity, @@ -56,15 +109,20 @@ export class Program implements Controller { } fixedUpdate() { + if (this.isPaused) { + return; + } + + if (this.language === 'python') { + this.fixedUpdatePython(); + return; + } + try { if (!this.iterator) { throw new GeneralRuntimeError('Program not started'); } - if (this.isPaused) { - return; - } - // steps per tick for (let i = 0; i < this.config.stepsPerTick; i++) { this.iterator.next(); @@ -75,6 +133,49 @@ export class Program implements Controller { } } + /** + * Steps py-slang's async CSE-machine generator `stepsPerTick` times. Since `fixedUpdate` itself + * must stay synchronous (it's called synchronously from the physics tick loop — see + * World.ts/Controller.ts), this fires an async pump and returns immediately rather than + * blocking on it. `pythonPumpBusy` prevents a second tick's pump from overlapping the first's + * still-in-flight `await`s. + */ + private fixedUpdatePython() { + if (this.pythonError !== null) { + const error = this.pythonError; + this.pythonError = null; + console.error(error); + throw new ProgramError('Error in program execution. Please check your code and try again.',); + } + + if (!this.pyIterator) { + throw new GeneralRuntimeError('Program not started'); + } + if (this.pythonPumpBusy) { + // Previous tick's steps haven't all resolved yet; skip this tick rather than + // interleaving two concurrent pumps against the same generator. + return; + } + + const iterator = this.pyIterator; + const stepsPerTick = this.config.stepsPerTick; + this.pythonPumpBusy = true; + (async () => { + try { + for (let i = 0; i < stepsPerTick; i++) { + const { done } = await iterator.next(); + if (done) break; + } + } catch (e) { + // Fire-and-forget: this pump isn't awaited by fixedUpdate, so the error can't be + // thrown synchronously here — stash it for the next fixedUpdatePython call to raise. + this.pythonError = e; + } finally { + this.pythonPumpBusy = false; + } + })(); + } + update(frameTiming: PhysicsTimingInfo): void { this.callbackHandler.checkCallbacks(frameTiming); } diff --git a/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.python.test.ts b/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.python.test.ts new file mode 100644 index 0000000000..3681f46d9d --- /dev/null +++ b/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.python.test.ts @@ -0,0 +1,56 @@ +import { Context as PyContext } from '@sourceacademy/py-slang'; +import { describe, expect, it, vi } from 'vitest'; +import { Program } from '../Program'; + +/** + * Exercises the Python-flavoured path end-to-end (real py-slang CSE machine, not mocked), + * proving that Program's async pump actually drives py-slang's async generator correctly across + * several simulated physics ticks. Complements Program.test.ts, which only exercises the + * pre-existing (mocked) synchronous js-slang path. + */ +describe('Program (python path)', () => { + it('steps a Python program to completion across several fixedUpdate ticks', async () => { + const pyContext = new PyContext(); + const program = new Program('x = 1\nx = x + 1\ny = x * 3\n', { stepsPerTick: 3 }, 'python', pyContext); + + program.start(); + expect(program.pyIterator).not.toBeNull(); + + // Drain the program across several ticks the way World's physics-tick loop would, waiting + // a macrotask between ticks so each tick's fire-and-forget async pump gets a chance to + // actually run (mirrors real ticks being spaced out over time, not back-to-back synchronously). + for (let tick = 0; tick < 20; tick++) { + program.fixedUpdate(); + await new Promise(resolve => setTimeout(resolve, 0)); + } + + // No error should have been surfaced. + expect(() => program.fixedUpdate()).not.toThrow(); + + // The global environment should now hold the final bindings. + const globalEnv = pyContext.runtime.environments[0]; + expect(globalEnv.head['x']).toEqual({ type: 'bigint', value: 2n }); + expect(globalEnv.head['y']).toEqual({ type: 'bigint', value: 6n }); + }); + + it('throws GeneralRuntimeError when constructed with language "python" but no pyContext', () => { + expect(() => new Program('x = 1', undefined, 'python', null)).toThrow( + 'pyContext is required when language is "python"' + ); + }); + + it('surfaces a Python evaluation error on the next tick without throwing synchronously', async () => { + const pyContext = new PyContext(); + // Name error: `z` is never defined. + const program = new Program('print(z)', { stepsPerTick: 5 }, 'python', pyContext); + vi.spyOn(console, 'error').mockImplementation(vi.fn()); + + program.start(); + program.fixedUpdate(); // kicks off the pump; the analyze()-time error surfaces async + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(() => program.fixedUpdate()).toThrow( + 'Error in program execution. Please check your code and try again.' + ); + }); +}); diff --git a/src/bundles/robot_simulation/src/controllers/program/evaluate.ts b/src/bundles/robot_simulation/src/controllers/program/evaluate.ts index 5647052bdb..b4c3b13c86 100644 --- a/src/bundles/robot_simulation/src/controllers/program/evaluate.ts +++ b/src/bundles/robot_simulation/src/controllers/program/evaluate.ts @@ -4,16 +4,31 @@ import { Stash, generateCSEMachineStateStream, } from 'js-slang/dist/cse-machine/interpreter'; -import { Variant } from 'js-slang/dist/langs'; +import type { Variant } from 'js-slang/dist/langs'; import { parse } from 'js-slang/dist/parser/parser'; import type { Context } from 'js-slang/dist/types'; +import { + analyze as analyzePython, + Context as PyContext, + Control as PyControl, + generateCSEMachineStateStream as generatePyCSEMachineStateStream, + parse as parsePython, + Stash as PyStash, +} from '@sourceacademy/py-slang'; export const DEFAULT_SOURCE_OPTIONS = { scheduler: 'async', steps: 1000, stepLimit: -1, executionMethod: 'auto', - variant: Variant.DEFAULT, + // Literal rather than `Variant.DEFAULT`: the frontend satisfies this bundle's `js-slang/*` + // imports at runtime through js-slang's own requireProvider allowlist + // (js-slang/dist/modules/loader/requireProvider.js), which exposes createContext, cse-machine, + // errors, parser, stdlib, types and utils - but NOT `langs`. A value import of + // 'js-slang/dist/langs' therefore makes the whole bundle fail to load in the real frontend with + // "Dynamic require of js-slang/dist/langs is not supported", before any user code runs. The + // type-only import above is erased at build time and so is safe. + variant: 'default' as Variant, originalMaxExecTime: 1000, useSubst: false, isPrelude: false, @@ -57,3 +72,76 @@ export function* runECEvaluator( context.runtime.isRunning = false; } } + +export const DEFAULT_PYTHON_OPTIONS = { + variant: 4, + stepLimit: -1, + recursionLimit: 1024, + isPrelude: false, +}; + +/** + * Python-flavoured counterpart of {@link runECEvaluator}, driving py-slang's CSE machine + * instead of js-slang's. Mirrors the same "parse -> analyze -> new Control/Stash -> step the + * generator" sequence that py-slang's own PyCseEvaluator (src/conductor/PyCseEvaluator.ts, + * upstream in the py-slang repo) uses internally, except the generator here is stepped one + * tick's worth of steps at a time (via {@link Program.fixedUpdate}) instead of being drained to + * completion in one go. + * + * Unlike {@link runECEvaluator} (a *sync* generator, since js-slang's + * generateCSEMachineStateStream is `function*`), py-slang's generateCSEMachineStateStream is an + * `async function*` — every step requires an `await`. Callers must drive this with + * `for await`/manual `await iterator.next()`, never a bare `.next()`. + * + * The `context` here is a py-slang `Context`, entirely distinct from js-slang's `Context` used by + * `runECEvaluator` — it is NOT sourced from any 'js-slang/context'-style runtime injection (no such + * convention exists for py-slang). It is built by this bundle itself, in pythonRuntime.ts's + * `createRobotPythonContext()`, which seeds it with the SICPy builtins plus the `ev3_*` robot API; + * `createPythonCSE()` in helper_functions.ts is the caller that puts the two together. + */ +export async function* runPythonECEvaluator( + code: string, + context: PyContext, + options: Partial = {} +): AsyncGenerator<{ steps: number }, void, undefined> { + const theOptions = merge({ ...DEFAULT_PYTHON_OPTIONS }, options); + const script = code + '\n'; + const ast = parsePython(script); + + // `preludeNames` seeds the resolver's *root builtins* environment, so every name the machine + // can actually resolve at runtime must appear here or the program fails analysis with a + // NameError before a single step runs. Runtime lookup (pyGetVariable in py-slang's + // engines/cse/utils.ts) falls back to `nativeStorage.builtins` after walking the environment + // chain, so the two sources below are exactly the two places a name can come from: + // the builtins registered on the context (see pythonRuntime.ts - SICPy primitives plus the + // ev3_* robot API) and anything already bound in the global environment. + const errors = analyzePython( + ast, + script, + theOptions.variant, + [], + [ + ...context.nativeStorage.builtins.keys(), + ...Object.keys(context.runtime.environments[0]?.head ?? {}), + ] + ); + if (errors.length > 0) { + throw errors; + } + + const control = new PyControl(ast); + const stash = new PyStash(); + context.control = control; + context.stash = stash; + + yield* generatePyCSEMachineStateStream( + script, + context, + control, + stash, + theOptions.stepLimit, + theOptions.recursionLimit, + theOptions.variant, + theOptions.isPrelude + ); +} diff --git a/src/bundles/robot_simulation/src/controllers/program/pythonRuntime.ts b/src/bundles/robot_simulation/src/controllers/program/pythonRuntime.ts new file mode 100644 index 0000000000..48ddedbac3 --- /dev/null +++ b/src/bundles/robot_simulation/src/controllers/program/pythonRuntime.ts @@ -0,0 +1,221 @@ +import { + Context as PyContext, + VARIANT_GROUPS, + type BuiltinValue, + type Value, +} from '@sourceacademy/py-slang'; +import type { Motor } from '../ev3/components/Motor'; +import type { ColorSensor } from '../ev3/sensor/ColorSensor'; +import type { UltrasonicSensor } from '../ev3/sensor/UltrasonicSensor'; +import * as ev3 from '../../ev3_functions'; +import { getWorldFromContext } from '../../helper_functions'; + +/** + * Builds the py-slang `Context` that a Python-flavoured {@link Program} evaluates against. + * + * ## Why this exists + * + * `robot_simulation` re-runs the robot's control program *inside* the simulation loop: the + * `Program` controller (see Program.ts) owns a CSE machine that is stepped `stepsPerTick` steps + * per physics tick, so `ev3_*` calls happen in simulated time rather than all at once. For a + * Source program that machine is js-slang's, and its `Context` arrives for free — the bundle + * imports `js-slang/context`, which the host frontend injects at runtime (esbuild leaves + * `js-slang*` external; see lib/buildtools/src/build/modules/commons.ts). + * + * There is no equivalent injection for py-slang: nothing hands this bundle a populated py-slang + * `Context`. So for the Python path the bundle builds its own, here — a `Context` seeded with + * + * * the standard SICPy builtins for the chosen chapter (`VARIANT_GROUPS`), and + * * the `ev3_*` robot API, wrapped as py-slang `BuiltinValue`s so a Python program can call + * them directly by name (no `import` needed — see the note on module imports below), and + * * an output stream routed into the simulation's own Robot Console panel. + * + * ## Note on `from robot_simulation import ...` + * + * A Python program running under py-slang's *own* conductor evaluator (`PyCseEvaluator3/4`) + * cannot `import` this bundle: py-slang's CSE machine resolves every import through + * `ModuleLoaderRunnerPlugin`, which requires the bundle to `export default` a + * `BaseModulePlugin` subclass (as csg/rune/curve do). `robot_simulation` is a legacy js-slang + * bundle with named exports only and a tab that reaches into the live `World` object through + * `js-slang/context`, so it has not been migrated to Conductor. Hence: the robot's *control* + * program can be Python (this file), while the *setup* program that calls `init_simulation` is + * still Source. + * + * The group preludes (the parts of the SICPy library written in Python itself, e.g. `map`, + * `filter`) are deliberately NOT evaluated here: doing so would need an async drain before the + * first physics tick. Only the primitive builtins each group defines in TypeScript are + * registered, which covers everything a robot control program realistically needs + * (arithmetic, comparisons, `print`, `math`, ...). + */ + +/** The SICPy chapter the robot's Python control program is evaluated at. */ +export const ROBOT_PYTHON_VARIANT = 4; + +function toJsNumber(value: Value | undefined, name: string): number { + if (value === undefined) { + throw new TypeError(`${name}: expected a number, got nothing`); + } + if (value.type === 'number') { + return value.value; + } + if (value.type === 'bigint') { + return Number(value.value); + } + throw new TypeError(`${name}: expected a number, got '${value.type}'`); +} + +/** + * Unwraps a value the Python program is passing back to us that it originally received from one + * of these same builtins (a `Motor`, `ColorSensor`, ...). Those cross the boundary as py-slang + * `opaque` values, which is exactly what `opaque` is for: py-slang never inspects the payload. + */ +function toJsOpaque(value: Value | undefined, name: string): T | null { + if (value === undefined || value.type === 'none') { + return null; + } + if (value.type !== 'opaque') { + throw new TypeError(`${name}: expected a robot object, got '${value.type}'`); + } + return value.value as T; +} + +function opaque(value: unknown): Value { + return value === null || value === undefined + ? { type: 'none' } + : { type: 'opaque', value }; +} + +function num(value: number): Value { + return { type: 'number', value }; +} + +function builtin( + name: string, + minArgs: number, + func: (args: Value[]) => Value | undefined +): [string, BuiltinValue] { + return [name, { type: 'builtin', name, minArgs, func: args => func(args) }]; +} + +/** + * The `ev3_*` API, as py-slang builtins. Deliberately a straight 1:1 wrapping of + * `ev3_functions.ts` — the exact same functions a Source control program calls — so a Python + * control program and a Source one drive the simulation identically. + */ +function robotBuiltins(): Array<[string, BuiltinValue]> { + return [ + builtin('ev3_motorA', 0, () => opaque(ev3.ev3_motorA())), + builtin('ev3_motorB', 0, () => opaque(ev3.ev3_motorB())), + builtin('ev3_motorC', 0, () => opaque(ev3.ev3_motorC())), + builtin('ev3_motorD', 0, () => opaque(ev3.ev3_motorD())), + builtin('ev3_pause', 1, args => { + ev3.ev3_pause(toJsNumber(args[0], 'ev3_pause')); + return { type: 'none' }; + }), + builtin('ev3_runToRelativePosition', 3, args => { + ev3.ev3_runToRelativePosition( + toJsOpaque(args[0], 'ev3_runToRelativePosition'), + toJsNumber(args[1], 'ev3_runToRelativePosition'), + toJsNumber(args[2], 'ev3_runToRelativePosition') + ); + return { type: 'none' }; + }), + builtin('ev3_colorSensor', 0, () => opaque(ev3.ev3_colorSensor())), + builtin('ev3_colorSensorRed', 1, args => num( + ev3.ev3_colorSensorRed(toJsOpaque(args[0], 'ev3_colorSensorRed')!) + )), + builtin('ev3_colorSensorGreen', 1, args => num( + ev3.ev3_colorSensorGreen(toJsOpaque(args[0], 'ev3_colorSensorGreen')!) + )), + builtin('ev3_colorSensorBlue', 1, args => num( + ev3.ev3_colorSensorBlue(toJsOpaque(args[0], 'ev3_colorSensorBlue')!) + )), + builtin('ev3_ultrasonicSensor', 0, () => opaque(ev3.ev3_ultrasonicSensor())), + builtin('ev3_ultrasonicSensorDistance', 1, args => num( + ev3.ev3_ultrasonicSensorDistance( + toJsOpaque(args[0], 'ev3_ultrasonicSensorDistance')! + ) + )), + ]; +} + +/** + * Routes the Python program's `print()` output into the simulation's own Robot Console panel + * (the "Console" tab under the 3D view), which is where a Source control program's `display()` + * output would go too. The world isn't created yet when this context is built (createPythonCSE + * runs inside the `init_simulation` callback), so the lookup is deferred to write time. + */ +function robotConsoleStreams(): PyContext['streams'] { + const stdoutStream = new WritableStream({ + write(chunk) { + const text = String(chunk).replace(/\n$/u, ''); + if (text === '') { + return; + } + try { + getWorldFromContext().robotConsole.log(text, 'source'); + } catch { + // World not available (e.g. the program printed before init finished) - drop it rather + // than killing the tick. + } + }, + }); + const stderrStream = new WritableStream({ + write(chunk) { + const message + = typeof chunk === 'string' + ? chunk + : ((chunk as { message?: string })?.message ?? String(chunk)); + try { + getWorldFromContext().robotConsole.log(message, 'error'); + } catch { + // See above. + } + }, + }); + const stdinStream = new ReadableStream({ + start(controller) { + // A robot control program has no interactive input; close immediately so a stray input() + // resolves to '' instead of hanging the physics loop forever. + controller.close(); + }, + }); + + return { + initialised: true, + stdout: { stream: stdoutStream, writer: stdoutStream.getWriter() }, + stderr: { + stream: stderrStream, + writer: stderrStream.getWriter(), + }, + stdin: { + stream: stdinStream, + reader: stdinStream.getReader(), + setNextPrompt: () => {}, + }, + } as PyContext['streams']; +} + +/** + * Creates a py-slang `Context` for a robot control program: SICPy builtins for + * {@link ROBOT_PYTHON_VARIANT}, plus the `ev3_*` robot API, plus output wired to the Robot + * Console. + */ +export function createRobotPythonContext( + variant: number = ROBOT_PYTHON_VARIANT +): PyContext { + const context = new PyContext(); + context.variant = variant; + + for (const group of VARIANT_GROUPS[variant] ?? []) { + for (const [name, value] of group.builtins) { + context.nativeStorage.builtins.set(name, value); + } + } + for (const [name, value] of robotBuiltins()) { + context.nativeStorage.builtins.set(name, value); + } + + context.streams = robotConsoleStreams(); + return context; +} diff --git a/src/bundles/robot_simulation/src/helper_functions.ts b/src/bundles/robot_simulation/src/helper_functions.ts index cbcf0c617a..c984f9226c 100644 --- a/src/bundles/robot_simulation/src/helper_functions.ts +++ b/src/bundles/robot_simulation/src/helper_functions.ts @@ -10,6 +10,7 @@ import { type DefaultEv3, } from './controllers/ev3/ev3/default/ev3'; import { Program } from './controllers/program/Program'; +import { createRobotPythonContext } from './controllers/program/pythonRuntime'; import { Physics, Renderer, Timer, World, type Controller } from './engine'; import { RobotConsole } from './engine/Core/RobotConsole'; @@ -422,6 +423,52 @@ export function createCSE() { return program; } +/** + * Creates a CSE machine as a Program Object, running **Python** instead of Source. + * + * This is the Python counterpart of {@link createCSE}. Where `createCSE` re-runs the surrounding + * Source program (`context.unTypecheckedCode[0]`) as the robot's control program, this takes the + * control program as an explicit string of Python and evaluates it with + * [py-slang](https://github.com/source-academy/py-slang)'s CSE machine, stepped in lockstep with + * the physics tick exactly the same way — so `ev3_pause`, motor commands and sensor reads all + * happen in simulated time rather than instantaneously. + * + * The Python program can call the whole `ev3_*` API directly by name; no `import` is needed (and + * none is possible — see pythonRuntime.ts for why). `print(...)` goes to the simulation's Robot + * Console panel. + * + * The returned Program object is designed to be added to the world using {@link addControllerToWorld}. + * + * **This is a Controller function and should be called within {@link init_simulation}.** + * + * @param code The robot's control program, written in Python (SICPy §4). + * @returns Program + * + * @example + * ``` + * init_simulation(() => { + * const physics = createPhysics(); + * const renderer = createRenderer(); + * const world = createWorld(physics, renderer, createTimer(), createRobotConsole()); + * const ev3 = createEv3(physics, renderer); + * saveToContext('world', world); + * saveToContext('ev3', ev3); + * addControllerToWorld(ev3, world); + * addControllerToWorld(createFloor(physics, renderer), world); + * addControllerToWorld(createPythonCSE( + * "ev3_runToRelativePosition(ev3_motorA(), 1080, 200)\n" + + * "ev3_runToRelativePosition(ev3_motorB(), 1080, 200)\n" + * ), world); + * return world; + * }); + * ``` + * + * @category Controller + */ +export function createPythonCSE(code: string) { + return new Program(code, undefined, 'python', createRobotPythonContext()); +} + /** * Add a controller to the world. * @@ -489,6 +536,35 @@ export function createEv3(physics: Physics, renderer: Renderer): DefaultEv3 { return ev3; } +/** + * Unwraps a value handed back by a Source callback that this bundle called itself. + * + * js-slang's CSE machine represents a Source closure to native (module) code through + * `closureToJS` (js-slang/dist/cse-machine/closure.js): calling it spins up a nested CSE machine, + * drains it, and returns `stash.peek()`. As of js-slang 1.0.94 the value left on that stash is + * the machine's internal tail-call envelope - `{ isTail: false, value: }` - not + * the value itself, so a module that calls a user-supplied callback and then uses the result gets + * the envelope. For `init_simulation` that surfaced as `TypeError: world.init is not a function`, + * i.e. `robot_simulation` failing on its very first call in the real frontend regardless of what + * the user's program does. + * + * That's an upstream js-slang bug (the envelope should be unwrapped before it escapes into native + * code), but it has to be tolerated here for the module to work at all against the shipped + * js-slang. The check is deliberately shape-based and non-destructive: once js-slang unwraps on + * its own side, a real `World` falls straight through this function unchanged. + */ +function unwrapCallbackResult(value: unknown): T { + if ( + value !== null + && typeof value === 'object' + && 'isTail' in value + && 'value' in value + ) { + return (value as { value: T }).value; + } + return value as T; +} + /** * Initialize the simulation world. This function is to be called before the robot code. * This function is used to describe the simulation environment and the controllers. @@ -506,7 +582,7 @@ export function init_simulation(worldFactory: () => World) { if (storedWorld !== undefined) { return; } - const world = worldFactory(); + const world = unwrapCallbackResult(worldFactory()); world.init(); interrupt(); } diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index 359190f580..a2efd6840f 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -33,6 +33,7 @@ export { createPaper, createFloor, createCSE, + createPythonCSE, addControllerToWorld, createRobotConsole, saveToContext, diff --git a/yarn.lock b/yarn.lock index d754477251..b1c18795dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -938,6 +938,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.26.10": + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e + languageName: node + linkType: hard + "@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7": version: 7.27.1 resolution: "@babel/runtime@npm:7.27.1" @@ -4307,6 +4314,7 @@ __metadata: "@dimforge/rapier3d-compat": "npm:^0.11.2" "@sourceacademy/modules-buildtools": "workspace:^" "@sourceacademy/modules-lib": "workspace:^" + "@sourceacademy/py-slang": "portal:/home/vakshay/Projects/local-pyslang-build/py-slang" "@types/three": "npm:^0.185.0" es-toolkit: "npm:^1.44.0" js-slang: "catalog:" @@ -4401,6 +4409,34 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/common-autocomplete@npm:^0.0.1": + version: 0.0.1 + resolution: "@sourceacademy/common-autocomplete@npm:0.0.1" + checksum: 10c0/24be061f41ba6629585681abafb8b29e00f3f6e79723ca92d242655324c12e9de8f38e041192040f1271d24a71974920bf48bf4aa26cc0163e221806fe15917e + languageName: node + linkType: hard + +"@sourceacademy/common-cse-machine@npm:^0.3.0": + version: 0.3.0 + resolution: "@sourceacademy/common-cse-machine@npm:0.3.0" + checksum: 10c0/c0462cc2dd8d779179c31d168aa3e71ea7f81818535166e38aeb3c048469fcf7eebc388ed4110d3300d6b6933cc310da3fbe4c3a4026746c5df409eb91dd7042 + languageName: node + linkType: hard + +"@sourceacademy/common-data-visualizer@npm:^0.0.1": + version: 0.0.1 + resolution: "@sourceacademy/common-data-visualizer@npm:0.0.1" + checksum: 10c0/ce926ba006bf5a6ed6d06fd318c51089be4015596b0b98a52b2160fde1834e1f968a4bdbfb9685e6990505836cdf2c6ac42ce77abfbf3ab58fcb3ed0d5e2402d + languageName: node + linkType: hard + +"@sourceacademy/common-stepper@npm:^0.0.1": + version: 0.0.1 + resolution: "@sourceacademy/common-stepper@npm:0.0.1" + checksum: 10c0/e3a79fb07f68e24a045553a8c5c66e652df12d3b80150a20b4b4603936605e3a2e10c5636e48ba180b2a3473287727340c4b791abe8df301d00e0984d07667a4 + languageName: node + linkType: hard + "@sourceacademy/common-tabs@npm:^0.0.1": version: 0.0.1 resolution: "@sourceacademy/common-tabs@npm:0.0.1" @@ -4418,6 +4454,13 @@ __metadata: languageName: node linkType: hard +"@sourceacademy/conductor@npm:^0.8.3": + version: 0.8.3 + resolution: "@sourceacademy/conductor@npm:0.8.3" + checksum: 10c0/25a35004fd92abc3f9342c1928a6be884388d3a8465cdc26491ccf56566121dd7a68a0c305defe668dfe7b96eebe61e93059631f80a92e0d9a4dc2775090f3e5 + languageName: node + linkType: hard + "@sourceacademy/lint-plugin@workspace:^, @sourceacademy/lint-plugin@workspace:lib/lintplugin": version: 0.0.0-use.local resolution: "@sourceacademy/lint-plugin@workspace:lib/lintplugin" @@ -4702,6 +4745,87 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/py-slang@portal:/home/vakshay/Projects/local-pyslang-build/py-slang::locator=%40sourceacademy%2Fbundle-robot_simulation%40workspace%3Asrc%2Fbundles%2Frobot_simulation": + version: 0.0.0-use.local + resolution: "@sourceacademy/py-slang@portal:/home/vakshay/Projects/local-pyslang-build/py-slang::locator=%40sourceacademy%2Fbundle-robot_simulation%40workspace%3Asrc%2Fbundles%2Frobot_simulation" + dependencies: + "@sourceacademy/common-autocomplete": "npm:^0.0.1" + "@sourceacademy/common-cse-machine": "npm:^0.3.0" + "@sourceacademy/common-data-visualizer": "npm:^0.0.1" + "@sourceacademy/common-stepper": "npm:^0.0.1" + "@sourceacademy/conductor": "npm:^0.8.3" + "@sourceacademy/pynter-wasm": "npm:^0.2.0" + "@sourceacademy/runner-autocomplete": "npm:^0.0.2" + "@sourceacademy/runner-cse-machine": "npm:^3.0.0" + "@sourceacademy/runner-data-visualizer": "npm:^0.0.1" + "@sourceacademy/runner-module-loader": "npm:^0.0.1" + "@sourceacademy/runner-stepper": "npm:^0.0.1" + "@sourceacademy/wasm-util": "npm:^1.0.6" + commander: "npm:^14.0.3" + fast-levenshtein: "npm:^3.0.0" + mathjs: "npm:^15.2.0" + moo: "npm:^0.5.2" + nearley: "npm:^2.20.1" + pyodide: "npm:^0.29.3" + wabt: "npm:^1.0.37" + languageName: node + linkType: soft + +"@sourceacademy/pynter-wasm@npm:^0.2.0": + version: 0.2.0 + resolution: "@sourceacademy/pynter-wasm@npm:0.2.0" + checksum: 10c0/7c780ca0d0df7972813b12e10dfa65582aa4f0a5916b8f5f7823a5602fecc5f898a8c49b5af5c72f7e82a21ae8128e06e282ea353889023fe8547ddba2e75489 + languageName: node + linkType: hard + +"@sourceacademy/runner-autocomplete@npm:^0.0.2": + version: 0.0.2 + resolution: "@sourceacademy/runner-autocomplete@npm:0.0.2" + dependencies: + "@sourceacademy/common-autocomplete": "npm:^0.0.1" + peerDependencies: + "@sourceacademy/conductor": ">=0.3.0" + checksum: 10c0/7bdf7ea448899b0121d240ab8ccad97b9bbea43b2abbf08c4637d6badf22c67867cf7a98763de152f90b6e84243b4223ff7ca2210e3ee55a23832d5663c13958 + languageName: node + linkType: hard + +"@sourceacademy/runner-cse-machine@npm:^3.0.0": + version: 3.0.0 + resolution: "@sourceacademy/runner-cse-machine@npm:3.0.0" + peerDependencies: + "@sourceacademy/common-cse-machine": ">=0.3.0" + "@sourceacademy/conductor": ">=0.3.0" + checksum: 10c0/dbf3c9220bb35b2f10d46dcfd13e1c827931d9bb9fb41266eff512d5859ab7734e594cda3b0e741a9896e8f7a3598cdb4d914ef13b7ecce941f8fc1798d8965e + languageName: node + linkType: hard + +"@sourceacademy/runner-data-visualizer@npm:^0.0.1": + version: 0.0.1 + resolution: "@sourceacademy/runner-data-visualizer@npm:0.0.1" + peerDependencies: + "@sourceacademy/conductor": ">=0.3.0" + checksum: 10c0/d5e1530c90e9508ed8e32f23d906fdd46696f94ac720c0c7c11c5d57fbe1781c54ca91741c22b099779f2f9937c24b6ecf426daa59fe6db14376e6793414c849 + languageName: node + linkType: hard + +"@sourceacademy/runner-module-loader@npm:^0.0.1": + version: 0.0.1 + resolution: "@sourceacademy/runner-module-loader@npm:0.0.1" + peerDependencies: + "@sourceacademy/conductor": ">=0.3.0" + checksum: 10c0/848d2a943cc0523ba6f6913d18ab8a1538e83998968e18b097c2bb485100cdbb701428168483a270a0451285a8d04019829f50b8321b8f1268313f7586b5a23c + languageName: node + linkType: hard + +"@sourceacademy/runner-stepper@npm:^0.0.1": + version: 0.0.1 + resolution: "@sourceacademy/runner-stepper@npm:0.0.1" + peerDependencies: + "@sourceacademy/conductor": ">=0.3.0" + checksum: 10c0/0c454960bba50f1f0cfbe5d08a4c0f079100a3044ef24171104ee82594646a1fcea5e2b154dfcd7963d2b85cfb88b9e35bdcbadd85a68362c7b8b4fcb443e34d + languageName: node + linkType: hard + "@sourceacademy/tab-ArcadeTwod@workspace:src/tabs/ArcadeTwod": version: 0.0.0-use.local resolution: "@sourceacademy/tab-ArcadeTwod@workspace:src/tabs/ArcadeTwod" @@ -5053,6 +5177,13 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/wasm-util@npm:^1.0.6": + version: 1.0.6 + resolution: "@sourceacademy/wasm-util@npm:1.0.6" + checksum: 10c0/5f86a348c79142c062d5b8b3c45418bc0358ceb2fc4ace0393ea75797b9d66d06ab270c4ec12d304066e0879016db36a017c1696b9f14a62ae59c42d012b47ed + languageName: node + linkType: hard + "@standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" @@ -5535,6 +5666,13 @@ __metadata: languageName: node linkType: hard +"@types/emscripten@npm:^1.41.4": + version: 1.41.5 + resolution: "@types/emscripten@npm:1.41.5" + checksum: 10c0/ae816da716f896434e59df7a71b67c71ae7e85ca067a32aef1616572fc4757459515d42ade6f5b8fd8d69733a9dbd0cf23010fec5b2f41ce52c09501aa350e45 + languageName: node + linkType: hard + "@types/estree-jsx@npm:^1.0.0": version: 1.0.5 resolution: "@types/estree-jsx@npm:1.0.5" @@ -8213,7 +8351,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:2, commander@npm:^2.15.1": +"commander@npm:2, commander@npm:^2.15.1, commander@npm:^2.19.0": version: 2.20.3 resolution: "commander@npm:2.20.3" checksum: 10c0/74c781a5248c2402a0a3e966a0a2bba3c054aad144f5c023364be83265e796b20565aa9feff624132ff629aa64e16999fa40a743c10c12f7c61e96a794b99288 @@ -8227,7 +8365,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^14.0.0": +"commander@npm:^14.0.0, commander@npm:^14.0.3": version: 14.0.3 resolution: "commander@npm:14.0.3" checksum: 10c0/755652564bbf56ff2ff083313912b326450d3f8d8c85f4b71416539c9a05c3c67dbd206821ca72635bf6b160e2afdefcb458e86b317827d5cb333b69ce7f1a24 @@ -8275,6 +8413,13 @@ __metadata: languageName: node linkType: hard +"complex.js@npm:^2.2.5": + version: 2.4.3 + resolution: "complex.js@npm:2.4.3" + checksum: 10c0/c61b225c4c2925c922ebaf2c4c6d7d0c2f9cebf4fb5eb8dce231aea7508f15db0920b52107279fdd9947b54a0320eae2911d430a421c2db8d0d24df6c2cb2cf8 + languageName: node + linkType: hard + "component-emitter@npm:^1.2.1": version: 1.3.1 resolution: "component-emitter@npm:1.3.1" @@ -9352,7 +9497,7 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.6.0": +"decimal.js@npm:^10.4.3, decimal.js@npm:^10.6.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa @@ -9534,6 +9679,13 @@ __metadata: languageName: node linkType: hard +"discontinuous-range@npm:1.0.0": + version: 1.0.0 + resolution: "discontinuous-range@npm:1.0.0" + checksum: 10c0/487b105f83c1cc528e25e65d3c4b73958ec79769b7bd0e264414702a23a7e2b282c72982b4bef4af29fcab53f47816c3f0a5c40d85a99a490f4bc35b83dc00f8 + languageName: node + linkType: hard + "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -10193,6 +10345,13 @@ __metadata: languageName: node linkType: hard +"escape-latex@npm:^1.2.0": + version: 1.2.0 + resolution: "escape-latex@npm:1.2.0" + checksum: 10c0/b77ea1594a38625295793a61105222c283c1792d1b2511bbfd6338cf02cc427dcabce7e7c1e22ec2f5c40baf3eaf2eeaf229a62dbbb74c6e69bb4a4209f2544f + languageName: node + linkType: hard + "escape-string-regexp@npm:5.0.0, escape-string-regexp@npm:^5.0.0": version: 5.0.0 resolution: "escape-string-regexp@npm:5.0.0" @@ -10883,6 +11042,15 @@ __metadata: languageName: node linkType: hard +"fast-levenshtein@npm:^3.0.0": + version: 3.0.0 + resolution: "fast-levenshtein@npm:3.0.0" + dependencies: + fastest-levenshtein: "npm:^1.0.7" + checksum: 10c0/9e147c682bd0ca54474f1cbf906f6c45262fd2e7c051d2caf2cc92729dcf66949dc809f2392de6adbe1c8716fdf012f91ce38c9422aef63b5732fc688eee4046 + languageName: node + linkType: hard + "fast-xml-builder@npm:^1.0.0": version: 1.3.0 resolution: "fast-xml-builder@npm:1.3.0" @@ -10914,6 +11082,13 @@ __metadata: languageName: node linkType: hard +"fastest-levenshtein@npm:^1.0.7": + version: 1.0.16 + resolution: "fastest-levenshtein@npm:1.0.16" + checksum: 10c0/7e3d8ae812a7f4fdf8cad18e9cde436a39addf266a5986f653ea0d81e0de0900f50c0f27c6d5aff3f686bcb48acbd45be115ae2216f36a6a13a7dbbf5cad878b + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.19.1 resolution: "fastq@npm:1.19.1" @@ -11136,6 +11311,13 @@ __metadata: languageName: node linkType: hard +"fraction.js@npm:^5.2.1": + version: 5.3.4 + resolution: "fraction.js@npm:5.3.4" + checksum: 10c0/f90079fe9bfc665e0a07079938e8ff71115bce9462f17b32fc283f163b0540ec34dc33df8ed41bb56f028316b04361b9a9995b9ee9258617f8338e0b05c5f95a + languageName: node + linkType: hard + "fragment-cache@npm:^0.2.1": version: 0.2.1 resolution: "fragment-cache@npm:0.2.1" @@ -12908,6 +13090,13 @@ __metadata: languageName: node linkType: hard +"javascript-natural-sort@npm:^0.7.1": + version: 0.7.1 + resolution: "javascript-natural-sort@npm:0.7.1" + checksum: 10c0/340f8ffc5d30fb516e06dc540e8fa9e0b93c865cf49d791fed3eac3bdc5fc71f0066fc81d44ec1433edc87caecaf9f13eec4a1fce8c5beafc709a71eaedae6fe + languageName: node + linkType: hard + "jest-haste-map@npm:^26.6.2": version: 26.6.2 resolution: "jest-haste-map@npm:26.6.2" @@ -13890,6 +14079,25 @@ __metadata: languageName: node linkType: hard +"mathjs@npm:^15.2.0": + version: 15.2.0 + resolution: "mathjs@npm:15.2.0" + dependencies: + "@babel/runtime": "npm:^7.26.10" + complex.js: "npm:^2.2.5" + decimal.js: "npm:^10.4.3" + escape-latex: "npm:^1.2.0" + fraction.js: "npm:^5.2.1" + javascript-natural-sort: "npm:^0.7.1" + seedrandom: "npm:^3.0.5" + tiny-emitter: "npm:^2.1.0" + typed-function: "npm:^4.2.1" + bin: + mathjs: bin/cli.js + checksum: 10c0/78913fc64501166185a6118975ef475bddf23151adcfce5ecac10085b0fa1df880c3d191d54f1184289dc646823eda4807bf73a46286760247230997694697d3 + languageName: node + linkType: hard + "md5.js@npm:^1.3.4": version: 1.3.5 resolution: "md5.js@npm:1.3.5" @@ -14953,6 +15161,13 @@ __metadata: languageName: node linkType: hard +"moo@npm:^0.5.0, moo@npm:^0.5.2": + version: 0.5.3 + resolution: "moo@npm:0.5.3" + checksum: 10c0/5c5e00d7c57a69ce1cc2f21a90655ff10556481cc10fa70528c01e91f00deff8a65fa8da6dc7b27d136d66085055aab238fd1b19a75070263e0028e8f07c8213 + languageName: node + linkType: hard + "mouse-event-offset@npm:^3.0.2": version: 3.0.2 resolution: "mouse-event-offset@npm:3.0.2" @@ -15101,6 +15316,23 @@ __metadata: languageName: node linkType: hard +"nearley@npm:^2.20.1": + version: 2.20.1 + resolution: "nearley@npm:2.20.1" + dependencies: + commander: "npm:^2.19.0" + moo: "npm:^0.5.0" + railroad-diagrams: "npm:^1.0.0" + randexp: "npm:0.4.6" + bin: + nearley-railroad: bin/nearley-railroad.js + nearley-test: bin/nearley-test.js + nearley-unparse: bin/nearley-unparse.js + nearleyc: bin/nearleyc.js + checksum: 10c0/d25e1fd40b19c53a0ada6a688670f4a39063fd9553ab62885e81a82927d51572ce47193b946afa3d85efa608ba2c68f433c421f69b854bfb7f599eacb5fae37e + languageName: node + linkType: hard + "needle@npm:^2.5.2": version: 2.9.1 resolution: "needle@npm:2.9.1" @@ -16318,6 +16550,16 @@ __metadata: languageName: node linkType: hard +"pyodide@npm:^0.29.3": + version: 0.29.4 + resolution: "pyodide@npm:0.29.4" + dependencies: + "@types/emscripten": "npm:^1.41.4" + ws: "npm:^8.5.0" + checksum: 10c0/46663db835971ca467672da4d95d47526ad31e23ea54e9cd3f8d8b27db15afa971b32e4832b920a8d139f70fc4f25db34349a2bb2f2f2758dc17893fc9962fb9 + languageName: node + linkType: hard + "qs@npm:^6.12.3, qs@npm:^6.4.0": version: 6.15.2 resolution: "qs@npm:6.15.2" @@ -16371,6 +16613,23 @@ __metadata: languageName: node linkType: hard +"railroad-diagrams@npm:^1.0.0": + version: 1.0.0 + resolution: "railroad-diagrams@npm:1.0.0" + checksum: 10c0/81bf8f86870a69fb9ed243102db9ad6416d09c4cb83964490d44717690e07dd982f671503236a1f8af28f4cb79d5d7a87613930f10ac08defa845ceb6764e364 + languageName: node + linkType: hard + +"randexp@npm:0.4.6": + version: 0.4.6 + resolution: "randexp@npm:0.4.6" + dependencies: + discontinuous-range: "npm:1.0.0" + ret: "npm:~0.1.10" + checksum: 10c0/14ee14b6d7f5ce69609b51cc914fb7a7c82ad337820a141c5f762c5ad1fe868f5191ea6e82359aee019b625ee1359486628fa833909d12c3b5dd9571908c3345 + languageName: node + linkType: hard + "randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" @@ -17319,6 +17578,13 @@ __metadata: languageName: node linkType: hard +"seedrandom@npm:^3.0.5": + version: 3.0.5 + resolution: "seedrandom@npm:3.0.5" + checksum: 10c0/929752ac098ff4990b3f8e0ac39136534916e72879d6eb625230141d20db26e2f44c4d03d153d457682e8cbaab0fb7d58a1e7267a157cf23fd8cf34e25044e88 + languageName: node + linkType: hard + "semver@npm:^5.5.0": version: 5.7.2 resolution: "semver@npm:5.7.2" @@ -18404,6 +18670,13 @@ __metadata: languageName: node linkType: hard +"tiny-emitter@npm:^2.1.0": + version: 2.1.0 + resolution: "tiny-emitter@npm:2.1.0" + checksum: 10c0/459c0bd6e636e80909898220eb390e1cba2b15c430b7b06cec6ac29d87acd29ef618b9b32532283af749f5d37af3534d0e3bde29fdf6bcefbf122784333c953d + languageName: node + linkType: hard + "tinybench@npm:^2.9.0": version: 2.9.0 resolution: "tinybench@npm:2.9.0" @@ -18828,6 +19101,13 @@ __metadata: languageName: node linkType: hard +"typed-function@npm:^4.2.1": + version: 4.2.2 + resolution: "typed-function@npm:4.2.2" + checksum: 10c0/92f2acc7e6d94431f4b37c2219d131cc90c1f43c19c097b7e337408cfd91336e481680d3362e30b5616318272950480ba670572b4585e8c690ca509d65c97554 + languageName: node + linkType: hard + "typedarray-pool@npm:^1.1.0": version: 1.2.0 resolution: "typedarray-pool@npm:1.2.0" @@ -19868,6 +20148,23 @@ __metadata: languageName: node linkType: hard +"wabt@npm:^1.0.37": + version: 1.0.39 + resolution: "wabt@npm:1.0.39" + bin: + wasm-decompile: bin/wasm-decompile + wasm-interp: bin/wasm-interp + wasm-objdump: bin/wasm-objdump + wasm-stats: bin/wasm-stats + wasm-strip: bin/wasm-strip + wasm-validate: bin/wasm-validate + wasm2c: bin/wasm2c + wasm2wat: bin/wasm2wat + wat2wasm: bin/wat2wasm + checksum: 10c0/6ada63c4c882688bf8d15f3b3a2a1d7edb9b83a976afa2d5754c6a991ae6372600f8ff2337aa1da8f8cb18b91cec8d93cd04e8f48e8f22b06f70915041cd6a89 + languageName: node + linkType: hard + "walk-up-path@npm:^3.0.1": version: 3.0.1 resolution: "walk-up-path@npm:3.0.1" @@ -20156,6 +20453,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.5.0": + version: 8.21.3 + resolution: "ws@npm:8.21.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/7b28dc2863ea0e2cece68d142a3eee90361021b73f750431e6d8076bb7dede5fdfdb75b3d29534b62f411261147b76b1bc80fb5cc63ab0aabd467280e85b22e0 + languageName: node + linkType: hard + "xdg-basedir@npm:^5.1.0": version: 5.1.0 resolution: "xdg-basedir@npm:5.1.0" From e31f4af229ad31de0dacc0ef234ed0d572fdc2a7 Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Thu, 3 Sep 2026 22:49:42 +0800 Subject: [PATCH 02/12] robot_simulation: migrate to a real Conductor BaseModulePlugin Replaces the js-slang/context workaround (Step 1) with the same BaseModulePlugin/attachModuleMethod pattern csg/rune/curve use, so the whole program - setup and control program alike - can be written in any Conductor language, not just Source-for-setup. Architecture: the module runs inside Conductor's runner Worker, which has no DOM/WebGL, so it can no longer own THREE.WebGLRenderer/ OrbitControls/the canvas the way it used to. Split by concern (pix_n_flix's precedent, not a "who renders" split): the module keeps physics (rapier3d-compat, worker-safe) and the robot control program's CSE stepping, and streams entity transforms to a new RobotSimulation tab plugin once per physics tick over a dedicated state channel (entity descriptors once per entity, then transferable Float32Array snapshots every tick) plus an RPC control channel for console/state/ sensor pushes - see protocol.ts and SceneRegistry's doc comment. Renderer.ts is gone from the bundle entirely; the tab now owns the live 3D view, built from engine helpers (getCamera/loadGLTF/ MeshFactory) that were already DOM-safe. ColorSensor's sensing moved from a GPU render-and-readback (no WebGL in a Worker) to a physics raycast against a small collider-color registry - documented as a narrowing versus the original (a colored `create_paper` overlay, which has no collider, is now invisible to the sensor). createCSE (a Source-flavoured control program) is deliberately not wired up: buildtools' `external: ['js-slang*']` esbuild rule means any js-slang import anywhere in a bundle - even unreachable - compiles to a top-level `require('js-slang/...')` with nothing to resolve it inside a Worker. All remaining js-slang-backed code (ProgramError, GeneralRuntimeError et al.) is replaced with conductor/common's evaluator error types or plain Error, and js-slang is dropped from package.json. createPythonCSE is unaffected - py-slang is bundled normally, same as before. Also bumps the @sourceacademy/conductor catalog pin to ^0.8.3 to match what the portal-linked local py-slang checkout requires (was ^0.8.2), fixing a duplicate-package TS error from two physical copies of conductor's types; and adds ProgramError to the throw-runtime-error lint rule's ignoredNames, since it never crosses the evaluator boundary and no longer extends js-slang's RuntimeSourceError. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Dji2eG7jb4tww8LowuSs7n --- .yarnrc.yml | 2 +- eslint.config.js | 10 +- src/bundles/robot_simulation/package.json | 2 +- .../src/controllers/environment/Cuboid.ts | 39 +- .../src/controllers/environment/Paper.ts | 38 +- .../src/controllers/ev3/components/Chassis.ts | 37 +- .../src/controllers/ev3/components/Mesh.ts | 37 +- .../src/controllers/ev3/components/Motor.ts | 36 +- .../src/controllers/ev3/components/Wheel.ts | 16 +- .../ev3/components/__tests__/Chassis.test.ts | 56 +- .../ev3/components/__tests__/Mesh.test.ts | 61 +- .../ev3/components/__tests__/Motor.test.ts | 68 +- .../ev3/components/__tests__/Wheel.test.ts | 20 +- .../ev3/ev3/default/__tests__/ev3.test.ts | 18 +- .../src/controllers/ev3/ev3/default/ev3.ts | 16 +- .../src/controllers/ev3/sensor/ColorSensor.ts | 151 ++--- .../ev3/sensor/UltrasonicSensor.ts | 24 +- .../ev3/sensor/__tests__/ColorSensor.test.ts | 85 +-- .../sensor/__tests__/UltrasonicSensor.test.ts | 28 +- .../src/controllers/program/Program.ts | 149 ++--- .../program/__tests__/Program.python.test.ts | 25 +- .../program/__tests__/Program.test.ts | 53 +- .../src/controllers/program/error.ts | 22 +- .../src/controllers/program/evaluate.ts | 98 +-- .../src/controllers/program/pythonRuntime.ts | 61 +- .../robot_simulation/src/engine/Physics.ts | 29 +- .../src/engine/Render/Renderer.ts | 75 --- .../src/engine/Render/SceneRegistry.ts | 63 ++ .../src/engine/Render/helpers/Camera.ts | 4 +- .../robot_simulation/src/engine/World.ts | 36 +- .../robot_simulation/src/engine/index.ts | 6 +- .../robot_simulation/src/ev3_functions.ts | 328 ++++------ .../robot_simulation/src/helper_functions.ts | 588 ------------------ src/bundles/robot_simulation/src/index.ts | 499 ++++++++++++++- src/bundles/robot_simulation/src/protocol.ts | 84 +++ src/tabs/RobotSimulation/package.json | 8 +- .../RobotSimulation/src/components/Main.tsx | 31 - .../RobotSimulation/src/components/Modal.tsx | 62 -- .../src/components/Simulation/index.tsx | 118 ---- .../components/TabPanels/ColorSensorPanel.tsx | 45 -- .../src/components/TabPanels/ConsolePanel.tsx | 57 -- .../components/TabPanels/MotorPidPanel.tsx | 72 --- .../TabPanels/UltrasonicSensorPanel.tsx | 42 -- .../components/TabPanels/WheelPidPanel.tsx | 79 --- .../TabPanels/tabComponents/LastUpdated.tsx | 21 - .../TabPanels/tabComponents/Wrapper.tsx | 10 - .../RobotSimulation/src/components/TabUi.tsx | 20 - .../src/hooks/fetchFromSimulation.ts | 18 - src/tabs/RobotSimulation/src/index.tsx | 294 ++++++++- yarn.lock | 15 +- 50 files changed, 1477 insertions(+), 2279 deletions(-) delete mode 100644 src/bundles/robot_simulation/src/engine/Render/Renderer.ts create mode 100644 src/bundles/robot_simulation/src/engine/Render/SceneRegistry.ts delete mode 100644 src/bundles/robot_simulation/src/helper_functions.ts create mode 100644 src/bundles/robot_simulation/src/protocol.ts delete mode 100644 src/tabs/RobotSimulation/src/components/Main.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/Modal.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/Simulation/index.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/TabPanels/ColorSensorPanel.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/TabPanels/ConsolePanel.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/TabPanels/MotorPidPanel.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/TabPanels/UltrasonicSensorPanel.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/TabPanels/WheelPidPanel.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/TabPanels/tabComponents/LastUpdated.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/TabPanels/tabComponents/Wrapper.tsx delete mode 100644 src/tabs/RobotSimulation/src/components/TabUi.tsx delete mode 100644 src/tabs/RobotSimulation/src/hooks/fetchFromSimulation.ts diff --git a/.yarnrc.yml b/.yarnrc.yml index b59ff0b237..b300d43a18 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -16,7 +16,7 @@ npmPreapprovedPackages: # https://yarnpkg.com/features/catalogs # Define here to avoid duplications catalog: - '@sourceacademy/conductor': ^0.8.2 + '@sourceacademy/conductor': ^0.8.3 js-slang: ^1.0.94 react: ^19.0.0 react-dom: ^19.0.0 diff --git a/eslint.config.js b/eslint.config.js index 748631e9ec..2dc35f293d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -447,8 +447,14 @@ export default defineConfig( rules: { // Rule doesn't work properly on CI '@sourceacademy/throw-runtime-error': process.env.CI ? 'off' : ['error', { - // Conductor's own protocol-level errors, unrelated to js-slang's RuntimeSourceError - ignoredNames: ['EvaluatorTypeError', 'EvaluatorRuntimeError', 'EvaluatorParameterTypeError'] + // Conductor's own protocol-level errors, unrelated to js-slang's RuntimeSourceError. + // ProgramError (robot_simulation) is the same idea for a bundle-internal error that never + // crosses the evaluator boundary at all (caught within the same bundle - see + // World.step()'s catch block in src/bundles/robot_simulation) - it used to satisfy this + // rule by genuinely extending js-slang's RuntimeSourceError, which pulled a real + // `require('js-slang/dist/errors/base')` into that bundle's build output even though + // robot_simulation no longer runs under js-slang (see ProgramError's own doc comment). + ignoredNames: ['EvaluatorTypeError', 'EvaluatorRuntimeError', 'EvaluatorParameterTypeError', 'ProgramError'] }] } }, diff --git a/src/bundles/robot_simulation/package.json b/src/bundles/robot_simulation/package.json index 398ff18ea3..0977d2da1b 100644 --- a/src/bundles/robot_simulation/package.json +++ b/src/bundles/robot_simulation/package.json @@ -3,10 +3,10 @@ "version": "1.0.0", "private": true, "dependencies": { + "@sourceacademy/conductor": "catalog:", "@sourceacademy/modules-lib": "workspace:^", "@sourceacademy/py-slang": "portal:/home/vakshay/Projects/local-pyslang-build/py-slang", "es-toolkit": "^1.44.0", - "js-slang": "catalog:", "three": "^0.185.0" }, "devDependencies": { diff --git a/src/bundles/robot_simulation/src/controllers/environment/Cuboid.ts b/src/bundles/robot_simulation/src/controllers/environment/Cuboid.ts index b400476306..5b8c493dc5 100644 --- a/src/bundles/robot_simulation/src/controllers/environment/Cuboid.ts +++ b/src/bundles/robot_simulation/src/controllers/environment/Cuboid.ts @@ -1,17 +1,12 @@ import * as THREE from 'three'; -import { - EntityFactory, - MeshFactory, - type Physics, - type Renderer, -} from '../../engine'; +import { EntityFactory, type Physics } from '../../engine'; import type { EntityCuboidOptions, RigidBodyType, } from '../../engine/Entity/EntityFactory'; import type { Dimension, SimpleVector } from '../../engine/Math/Vector'; -import type { RenderCuboidOptions } from '../../engine/Render/helpers/MeshFactory'; +import type { SceneRegistry } from '../../engine/Render/SceneRegistry'; export type CuboidConfig = { position: SimpleVector; @@ -28,28 +23,27 @@ const noRotation = { w: 1, }; +/** `THREE.Color` accepts numbers/named strings/hex strings; the tab only ever gets a plain hex + string over the wire (a `THREE.Color` instance itself isn't cheaply serializable). */ +function toHexColor(color: number | string): string { + return `#${new THREE.Color(color).getHexString()}`; +} + export class Cuboid { physics: Physics; - render: Renderer; config: CuboidConfig; - constructor(physics: Physics, renderer: Renderer, config: CuboidConfig) { + constructor(physics: Physics, registry: SceneRegistry, config: CuboidConfig) { this.physics = physics; - this.render = renderer; this.config = config; - const renderCuboidOption: RenderCuboidOptions = { - orientation: { - position: config.position, - rotation: noRotation, - }, + const handle = registry.add({ + kind: 'cuboid', dimension: config.dimension, - color: new THREE.Color(config.color), - debug: false, - }; - - const mesh = MeshFactory.addCuboid(renderCuboidOption); - this.render.add(mesh); + color: toHexColor(config.color), + }); + handle.position.copy(config.position); + handle.quaternion.copy(noRotation); } start() { @@ -63,6 +57,7 @@ export class Cuboid { type: this.config.type, }; - EntityFactory.addCuboid(this.physics, entityCuboidOption); + const entity = EntityFactory.addCuboid(this.physics, entityCuboidOption); + this.physics.registerColor(entity.getCollider(), toHexColor(this.config.color)); } } diff --git a/src/bundles/robot_simulation/src/controllers/environment/Paper.ts b/src/bundles/robot_simulation/src/controllers/environment/Paper.ts index 16bdb1ad11..620beb7d1b 100644 --- a/src/bundles/robot_simulation/src/controllers/environment/Paper.ts +++ b/src/bundles/robot_simulation/src/controllers/environment/Paper.ts @@ -1,6 +1,5 @@ -import * as THREE from 'three'; - -import type { Renderer } from '../../engine'; +import type * as THREE from 'three'; +import type { SceneRegistry } from '../../engine/Render/SceneRegistry'; export type PaperConfig = { url: string; @@ -12,27 +11,28 @@ export type PaperConfig = { rotation: number; }; +/** + * A purely visual overlay - unlike Cuboid, it has no physics collider, so (like before this + * migration) it is invisible to raycasts, including the color sensor's - see ColorSensor.ts's doc + * comment for the known follow-up this implies. + */ export class Paper { - render: Renderer; config: PaperConfig; - paper: THREE.Mesh; + handle: THREE.Object3D; - constructor(render: Renderer, config: PaperConfig) { - this.render = render; + constructor(registry: SceneRegistry, config: PaperConfig) { this.config = config; - - const plane = new THREE.PlaneGeometry(this.config.dimension.width, this.config.dimension.height); // Creating a 1x1 plane for the carpet - this.paper = new THREE.Mesh(plane); + this.handle = registry.add({ + kind: 'paper', + width: config.dimension.width, + height: config.dimension.height, + url: config.url, + }); } - async start() { - const texture = new THREE.TextureLoader() - .load(this.config.url); - const material = new THREE.MeshStandardMaterial({ map: texture }); - this.paper.position.set(this.config.position.x, 0.001, this.config.position.y); - this.paper.rotation.x = -Math.PI / 2; - this.paper.rotation.z = this.config.rotation; - this.paper.material = material; - this.render.add(this.paper); + start() { + this.handle.position.set(this.config.position.x, 0.001, this.config.position.y); + this.handle.rotation.x = -Math.PI / 2; + this.handle.rotation.z = this.config.rotation; } } diff --git a/src/bundles/robot_simulation/src/controllers/ev3/components/Chassis.ts b/src/bundles/robot_simulation/src/controllers/ev3/components/Chassis.ts index 646049c118..f42b4e1e2e 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/components/Chassis.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/components/Chassis.ts @@ -1,13 +1,10 @@ -import { GeneralRuntimeError } from '@sourceacademy/modules-lib/errors'; -import * as THREE from 'three'; +import { EvaluatorRuntimeError } from '@sourceacademy/conductor/common'; import { EntityFactory, - MeshFactory, type Controller, type Entity, type Physics, - type Renderer, } from '../../../engine'; import type { EntityCuboidOptions } from '../../../engine/Entity/EntityFactory'; @@ -20,40 +17,24 @@ export type ChassisWrapperConfig = EntityCuboidOptions & { * after the physics engine has been started. Therefore, the chassis entity needs to be wrapped in * a controller. * - * We also use this class to add an optional debug mesh to the chassis. + * The pre-migration debug wireframe mesh (drawn from `config.debug`) is dropped: it was a + * dev-only visual aid layered on top of the chassis's real GLTF body (see Mesh.ts), not something + * the simulation's behaviour depends on, and rendering now happens entirely on the tab. */ export class ChassisWrapper implements Controller { physics: Physics; - render: Renderer; config: ChassisWrapperConfig; chassis: Entity | null = null; - debugMesh: THREE.Mesh; - constructor( - physics: Physics, - render: Renderer, - config: ChassisWrapperConfig, - ) { + constructor(physics: Physics, config: ChassisWrapperConfig) { this.physics = physics; - this.render = render; this.config = config; - - // Debug mesh. - this.debugMesh = MeshFactory.addCuboid({ - orientation: config.orientation, - dimension: config.dimension, - color: new THREE.Color(0x00ff00), - debug: true, - }); - // Set visible based on config. - this.debugMesh.visible = config.debug; - render.add(this.debugMesh); } getEntity(): Entity { if (this.chassis === null) { - throw new GeneralRuntimeError('Chassis not initialized'); + throw new EvaluatorRuntimeError('Chassis not initialized'); } return this.chassis; } @@ -61,10 +42,4 @@ export class ChassisWrapper implements Controller { async start(): Promise { this.chassis = EntityFactory.addCuboid(this.physics, this.config); } - - update(): void { - const chassisEntity = this.getEntity(); - this.debugMesh.position.copy(chassisEntity.getTranslation()); - this.debugMesh.quaternion.copy(chassisEntity.getRotation()); - } } diff --git a/src/bundles/robot_simulation/src/controllers/ev3/components/Mesh.ts b/src/bundles/robot_simulation/src/controllers/ev3/components/Mesh.ts index 8c9d180b99..465d028c9e 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/components/Mesh.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/components/Mesh.ts @@ -1,10 +1,9 @@ import * as THREE from 'three'; -import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import type { Controller, Renderer } from '../../../engine'; +import type { Controller } from '../../../engine'; import type { Dimension, SimpleQuaternion, SimpleVector } from '../../../engine/Math/Vector'; import type { PhysicsTimingInfo } from '../../../engine/Physics'; -import { loadGLTF } from '../../../engine/Render/helpers/GLTF'; +import type { SceneRegistry } from '../../../engine/Render/SceneRegistry'; import type { ChassisWrapper } from './Chassis'; export type MeshConfig = { @@ -16,27 +15,32 @@ export type MeshConfig = { /** * This represents the mesh of the robot. In reality, the mesh could be part of the chassis, * but for the sake of clarity it is split into its own controller. + * + * The GLTF itself is no longer loaded here (worker code can't reach the network/DOM APIs + * `GLTFLoader` needs the same way it always could as ordinary main-thread code) - `registry.add` + * only allocates a transform handle and tells the tab, over the state channel, to load and + * position the real model. See SceneRegistry's doc comment. */ export class Mesh implements Controller { chassisWrapper: ChassisWrapper; - render: Renderer; + registry: SceneRegistry; config: MeshConfig; offset: SimpleVector; - mesh: GLTF | null = null; + mesh: THREE.Object3D | null = null; - previousTranslation: SimpleVector | null= null; + previousTranslation: SimpleVector | null = null; previousRotation: SimpleQuaternion | null = null; currentTranslation: SimpleVector; currentRotation: SimpleQuaternion; constructor( chassisWrapper: ChassisWrapper, - render: Renderer, + registry: SceneRegistry, config: MeshConfig, ) { this.chassisWrapper = chassisWrapper; - this.render = render; + this.registry = registry; this.config = config; this.offset = { x: this.config?.offset?.x || 0, @@ -44,13 +48,16 @@ export class Mesh implements Controller { z: this.config?.offset?.z || 0, }; this.currentTranslation = this.chassisWrapper.config.orientation.position; - this.currentRotation = new THREE.Quaternion(0,0,0,1); + this.currentRotation = new THREE.Quaternion(0, 0, 0, 1); } - async start(): Promise { - this.mesh = await loadGLTF(this.config.url, this.config.dimension); - - this.render.add(this.mesh.scene); + start(): void { + this.mesh = this.registry.add({ + kind: 'gltf', + url: this.config.url, + dimension: this.config.dimension, + offsetY: this.offset.y, + }); } fixedUpdate(): void { @@ -73,7 +80,7 @@ export class Mesh implements Controller { estimatedTranslation.y -= this.offset.y / 2; estimatedTranslation.z -= this.offset.z / 2; - this.mesh?.scene.position.copy(estimatedTranslation); - this.mesh?.scene.quaternion.copy(estimatedRotation); + this.mesh?.position.copy(estimatedTranslation); + this.mesh?.quaternion.copy(estimatedRotation); } } diff --git a/src/bundles/robot_simulation/src/controllers/ev3/components/Motor.ts b/src/bundles/robot_simulation/src/controllers/ev3/components/Motor.ts index 75a1f0ef33..18a8b18205 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/components/Motor.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/components/Motor.ts @@ -1,11 +1,10 @@ -import type * as THREE from 'three'; -import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'; -import type { Controller, Physics, Renderer } from '../../../engine'; +import * as THREE from 'three'; +import type { Controller, Physics } from '../../../engine'; import { CallbackHandler } from '../../../engine/Core/CallbackHandler'; import { vec3 } from '../../../engine/Math/Convert'; import type { Dimension, SimpleVector } from '../../../engine/Math/Vector'; import type { PhysicsTimingInfo } from '../../../engine/Physics'; -import { loadGLTF } from '../../../engine/Render/helpers/GLTF'; +import type { SceneRegistry } from '../../../engine/Render/SceneRegistry'; import { VectorPidController } from '../feedback_control/PidController'; import type { ChassisWrapper } from './Chassis'; @@ -26,11 +25,14 @@ export type MotorConfig = { /** * This represents the motor of the robot and is responsible for moving the robot. It is also * responsible for the visual representation of the wheel and the friction. + * + * As with Mesh.ts, the wheel's GLTF asset is loaded by the tab, not here - `registry.add` only + * allocates the transform handle this class keeps positioned/rotated every tick. */ export class Motor implements Controller { chassisWrapper: ChassisWrapper; physics: Physics; - render: Renderer; + registry: SceneRegistry; displacementVector: THREE.Vector3; config: MotorConfig; @@ -41,17 +43,17 @@ export class Motor implements Controller { callbackHandler = new CallbackHandler(); wheelSide: WheelSide; - mesh: GLTF | null = null; + mesh: THREE.Object3D | null = null; constructor( chassisWrapper: ChassisWrapper, physics: Physics, - render: Renderer, + registry: SceneRegistry, config: MotorConfig, ) { this.chassisWrapper = chassisWrapper; this.physics = physics; - this.render = render; + this.registry = registry; this.displacementVector = vec3(config.displacement); this.config = config; @@ -69,9 +71,13 @@ export class Motor implements Controller { }, (distance / speed) * 1000); } - async start(): Promise { - this.mesh = await loadGLTF(this.config.mesh.url, this.config.mesh.dimension); - this.render.add(this.mesh.scene); + start(): void { + this.mesh = this.registry.add({ + kind: 'gltf', + url: this.config.mesh.url, + dimension: this.config.mesh.dimension, + offsetY: 0, + }); } fixedUpdate(timingInfo: PhysicsTimingInfo): void { @@ -123,19 +129,19 @@ export class Motor implements Controller { // If mesh is loaded, update its position and orientation if (this.mesh) { - this.mesh.scene.position.copy(wheelPosition); - this.mesh.scene.quaternion.copy(chassisEntity.getRotation()); + this.mesh.position.copy(wheelPosition); + this.mesh.quaternion.copy(chassisEntity.getRotation()); // Calculate rotation adjustment based on motor velocity and frame duration const radiansPerFrame = 2 * (this.motorVelocity / this.config.mesh.dimension.height) * timingInfo.frameDuration / 1000; // Apply rotation changes to simulate wheel turning this.meshRotation += radiansPerFrame; - this.mesh.scene.rotateX(this.meshRotation); + this.mesh.rotateX(this.meshRotation); // If the wheel is on the left side, flip it to face the correct direction if (this.wheelSide === 'left') { - this.mesh.scene.rotateZ(Math.PI); + this.mesh.rotateZ(Math.PI); } } } diff --git a/src/bundles/robot_simulation/src/controllers/ev3/components/Wheel.ts b/src/bundles/robot_simulation/src/controllers/ev3/components/Wheel.ts index fe4baf7ea3..02c1b73c57 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/components/Wheel.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/components/Wheel.ts @@ -1,9 +1,8 @@ import type * as THREE from 'three'; -import type { Controller, Physics, Renderer } from '../../../engine'; +import type { Controller, Physics } from '../../../engine'; import { vec3 } from '../../../engine/Math/Convert'; import type { SimpleVector } from '../../../engine/Math/Vector'; import type { PhysicsTimingInfo } from '../../../engine/Physics'; -import { DebugArrow } from '../../../engine/Render/debug/DebugArrow'; import { NumberPidController } from '../feedback_control/PidController'; import type { ChassisWrapper } from './Chassis'; @@ -19,26 +18,24 @@ export type WheelConfig = { debug: boolean; }; +/** The pre-migration debug arrow (visualising the suspension force) is dropped along with all + other DOM-touching debug helpers - see Chassis.ts's doc comment. */ export class Wheel implements Controller { chassisWrapper: ChassisWrapper; physics: Physics; - render: Renderer; config: WheelConfig; pid: NumberPidController; displacementVector: THREE.Vector3; downVector: THREE.Vector3; - arrowHelper: DebugArrow; constructor( chassisWrapper: ChassisWrapper, physics: Physics, - render: Renderer, config: WheelConfig, ) { this.chassisWrapper = chassisWrapper; this.physics = physics; - this.render = render; this.displacementVector = vec3(config.displacement); this.config = config; @@ -48,10 +45,6 @@ export class Wheel implements Controller { y: -1, z: 0, }); - - // Debug arrow. - this.arrowHelper = new DebugArrow({ debug: config.debug }); - render.add(this.arrowHelper.getMesh()); } fixedUpdate(timingInfo: PhysicsTimingInfo): void { @@ -92,8 +85,5 @@ export class Wheel implements Controller { .multiplyScalar((error * chassis.getMass() * timingInfo.timestep) / 1000); chassis.applyImpulse(force, globalDisplacement); - - // Debug arrow. - this.arrowHelper.update(globalDisplacement, force.clone(), force.length() * 1000); } } diff --git a/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Chassis.test.ts b/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Chassis.test.ts index 926352595e..2aecd6cf4b 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Chassis.test.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Chassis.test.ts @@ -1,58 +1,21 @@ -import * as THREE from 'three'; import { describe, expect, it as baseIt, vi } from 'vitest'; -import { EntityFactory, MeshFactory, type Physics, type Renderer } from '../../../../engine'; +import { EntityFactory, type Physics } from '../../../../engine'; import { ChassisWrapper, type ChassisWrapperConfig } from '../Chassis'; -// @ts-expect-error not a complete mock of engine -vi.mock(import('../../../../engine'), () => ({ - Physics: vi.fn(), - Renderer: vi.fn(), - EntityFactory: { addCuboid: vi.fn() }, - MeshFactory: { addCuboid: vi.fn() } -})); - vi.mock(import('../../../../engine/Entity/EntityFactory')); -vi.mock(import('three'), async importOriginal => { - // @ts-expect-error Not a complete mock of three.js - return { - ...await importOriginal(), - Mesh: vi.fn(class { - position = { copy: vi.fn() }; - quaternion = { copy: vi.fn() }; - visible = false; - }), - Color: vi.fn() - } as typeof THREE; -}); - -const mockedMeshFactory = vi.mocked(MeshFactory); -mockedMeshFactory.addCuboid.mockReturnValue(new THREE.Mesh()); - const mockedEntityFactory = vi.mocked(EntityFactory); describe(ChassisWrapper, () => { const it = baseIt .extend('physicsMock', () => vi.fn() as unknown as Physics) - .extend('rendererMock', { add:vi.fn() } as unknown as Renderer) .extend('config', { dimension: { width: 1, height: 1, depth: 1 }, orientation: { x: 0, y: 0, z: 0, w: 1 }, debug: true } as unknown as ChassisWrapperConfig) - .extend('chassisWrapper', ({ physicsMock, rendererMock, config }) => new ChassisWrapper(physicsMock, rendererMock, config)); - - it('should initialize with a debug mesh if debug is true', ({ rendererMock, chassisWrapper, config }) => { - expect(MeshFactory.addCuboid).toHaveBeenCalledWith({ - orientation: config.orientation, - dimension: config.dimension, - color: expect.any(THREE.Color), - debug: true - }); - expect(rendererMock.add).toHaveBeenCalledOnce(); - expect(chassisWrapper.debugMesh.visible).toBe(true); - }); + .extend('chassisWrapper', ({ physicsMock, config }) => new ChassisWrapper(physicsMock, config)); it('should throw if getEntity is called before chassis is initialized', ({ chassisWrapper }) => { expect(chassisWrapper.chassis).toBe(null); @@ -67,19 +30,4 @@ describe(ChassisWrapper, () => { expect(chassisWrapper.chassis).toBe(mockEntity); expect(EntityFactory.addCuboid).toHaveBeenCalledWith(physicsMock, config); }); - - it('should update the position and orientation of the debug mesh to match the chassis entity', ({ chassisWrapper }) => { - const mockEntity = { - getTranslation: vi.fn().mockReturnValue(new THREE.Vector3()), - getRotation: vi.fn().mockReturnValue(new THREE.Quaternion()) - } as any; - - mockedEntityFactory.addCuboid.mockReturnValue(mockEntity); - chassisWrapper.chassis = mockEntity; - - chassisWrapper.update(); - - expect(chassisWrapper.debugMesh.position.copy).toHaveBeenCalledWith(mockEntity.getTranslation()); - expect(chassisWrapper.debugMesh.quaternion.copy).toHaveBeenCalledWith(mockEntity.getRotation()); - }); }); diff --git a/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Mesh.test.ts b/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Mesh.test.ts index dfca85d2a9..e000593bc5 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Mesh.test.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Mesh.test.ts @@ -1,32 +1,9 @@ import * as THREE from 'three'; import { describe, expect, it as baseIt, vi } from 'vitest'; -import type { Renderer } from '../../../../engine'; -import { loadGLTF } from '../../../../engine/Render/helpers/GLTF'; +import type { SceneRegistry } from '../../../../engine/Render/SceneRegistry'; import { ChassisWrapper } from '../Chassis'; import { Mesh, type MeshConfig } from '../Mesh'; -vi.mock(import('three'), async importOriginal => { - return { - ...await importOriginal(), - GLTF: vi.fn().mockImplementation(() => ({ - scene: {}, - })), - }; -}); - -vi.mock(import('../../../../engine/Render/helpers/GLTF'), () => ({ - loadGLTF: vi.fn().mockResolvedValue({ - scene: { - position: { - copy: vi.fn(), - }, - quaternion: { - copy: vi.fn(), - }, - }, - }), -})); - vi.mock(import('../Chassis'), () => ({ ChassisWrapper: vi.fn().mockImplementation(() => ({ getEntity: vi.fn().mockReturnValue({ @@ -36,15 +13,11 @@ vi.mock(import('../Chassis'), () => ({ })), })); -vi.mock(import('../../../../engine'), () => ({ - Renderer: vi.fn().mockImplementation(() => ({ - add: vi.fn(), - })), -}) as any); - describe(Mesh, () => { const it = baseIt - .extend('mockRenderer', { add: vi.fn() } as unknown as Renderer) + .extend('mockRegistry', { + add: vi.fn().mockReturnValue(new THREE.Object3D()), + } as unknown as SceneRegistry) .extend('mockChassisWrapper', { getEntity: vi.fn().mockReturnValue({ getTranslation: vi.fn().mockReturnValue(new THREE.Vector3()), @@ -71,24 +44,32 @@ describe(Mesh, () => { dimension: { width: 1, height: 2, depth: 3 }, offset: { x: 0.5, y: 0.5, z: 0.5 }, } as unknown as MeshConfig) - .extend('mesh', ({ mockChassisWrapper, mockRenderer, mockConfig }) => new Mesh(mockChassisWrapper, mockRenderer, mockConfig)); + .extend('mesh', ({ mockChassisWrapper, mockRegistry, mockConfig }) => new Mesh(mockChassisWrapper, mockRegistry, mockConfig)); it('should initialize correctly with given configurations', ({ mesh, mockConfig }) => { expect(mesh.config.url).toBe(mockConfig.url); expect(mesh.offset.x).toBe(0.5); }); - it('should load the mesh and add it to the renderer on start', async ({ mesh, mockConfig, mockRenderer }) => { - await mesh.start(); - expect(loadGLTF).toHaveBeenCalledWith(mockConfig.url, mockConfig.dimension); - expect(mockRenderer.add).toHaveBeenCalledWith(expect.any(Object)); // Checks if mesh scene is added to renderer + it('should register a transform handle in the scene registry on start', ({ mesh, mockConfig, mockRegistry }) => { + mesh.start(); + expect(mockRegistry.add).toHaveBeenCalledWith({ + kind: 'gltf', + url: mockConfig.url, + dimension: mockConfig.dimension, + offsetY: mesh.offset.y, + }); + expect(mesh.mesh).toBeInstanceOf(THREE.Object3D); }); - it('should update mesh position and orientation according to chassis', async ({ mesh }) => { - await mesh.start(); + it('should update mesh position and orientation according to chassis', ({ mesh }) => { + mesh.start(); + const positionCopy = vi.spyOn(mesh.mesh!.position, 'copy'); + const quaternionCopy = vi.spyOn(mesh.mesh!.quaternion, 'copy'); + mesh.fixedUpdate(); mesh.update({ residualFactor: 0.5 } as any); - expect(mesh.mesh!.scene.position.copy).toHaveBeenCalled(); - expect(mesh.mesh!.scene.quaternion.copy).toHaveBeenCalled(); + expect(positionCopy).toHaveBeenCalled(); + expect(quaternionCopy).toHaveBeenCalled(); }); }); diff --git a/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Motor.test.ts b/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Motor.test.ts index 8a758fda76..481153015a 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Motor.test.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Motor.test.ts @@ -1,32 +1,12 @@ import * as THREE from 'three'; import { describe, expect, it as baseIt, vi } from 'vitest'; -import { Physics, Renderer } from '../../../../engine'; -import { loadGLTF } from '../../../../engine/Render/helpers/GLTF'; +import type { Physics } from '../../../../engine'; +import type { SceneRegistry } from '../../../../engine/Render/SceneRegistry'; import { ev3Config } from '../../ev3/default/config'; import { ChassisWrapper } from '../Chassis'; import { Motor, type MotorConfig } from '../Motor'; -vi.mock(import('../../../../engine/Render/helpers/GLTF'), () => ({ - loadGLTF: vi.fn().mockResolvedValue({ - scene: { - position: { - copy: vi.fn(), - }, - quaternion: { - copy: vi.fn(), - }, - rotateX: vi.fn(), - rotateZ: vi.fn() - } - }), -})); - -vi.mock(import('../../../../engine'), () => ({ - Physics: vi.fn(), - Renderer: vi.fn().mockImplementation(() => ({ - add: vi.fn(), - })), -}) as any); +vi.mock(import('../../../../engine/Entity/EntityFactory')); vi.mock(import('../Chassis'), () => ({ ChassisWrapper: vi.fn(class { @@ -36,7 +16,7 @@ vi.mock(import('../Chassis'), () => ({ worldTranslation: vi.fn().mockReturnValue(new THREE.Vector3()), applyImpulse: vi.fn(), getMass: vi.fn().mockReturnValue(1), - getRotation: vi.fn(), + getRotation: vi.fn().mockReturnValue(new THREE.Quaternion()), }); }), } as any)); @@ -44,7 +24,7 @@ vi.mock(import('../Chassis'), () => ({ describe(Motor, () => { const it = baseIt .extend('mockPhysics', { applyImpulse: vi.fn() } as unknown as Physics) - .extend('mockRenderer', { add: vi.fn() } as unknown as Renderer) + .extend('mockRegistry', { add: vi.fn().mockReturnValue(new THREE.Object3D()) } as unknown as SceneRegistry) .extend('mockConfig', { displacement: { x: 1, y: 0, z: 0 }, pid: { @@ -57,20 +37,21 @@ describe(Motor, () => { dimension: { height: 1, width: 1, depth: 1 }, }, } as unknown as MotorConfig) - // @ts-expect-error Ignore ev3config errors - .extend('mockChassisWrapper', ({ mockPhysics, mockRenderer }) => new ChassisWrapper(mockPhysics, mockRenderer, ev3Config.motors[0])) + .extend('mockChassisWrapper', ({ mockPhysics }) => new ChassisWrapper(mockPhysics, ev3Config.chassis)) .extend( 'motor', - ({ mockChassisWrapper, mockConfig, mockPhysics, mockRenderer }) => new Motor(mockChassisWrapper, mockPhysics, mockRenderer, mockConfig ) + ({ mockChassisWrapper, mockConfig, mockPhysics, mockRegistry }) => new Motor(mockChassisWrapper, mockPhysics, mockRegistry, mockConfig) ); - it('should initialize correctly and load the mesh', async ({ motor, mockConfig, mockRenderer }) => { - await motor.start(); - expect(loadGLTF).toHaveBeenCalledWith( - mockConfig.mesh.url, - mockConfig.mesh.dimension - ); - expect(mockRenderer.add).toHaveBeenCalled(); + it('should register a transform handle and load the mesh', ({ motor, mockConfig, mockRegistry }) => { + motor.start(); + expect(mockRegistry.add).toHaveBeenCalledWith({ + kind: 'gltf', + url: mockConfig.mesh.url, + dimension: mockConfig.mesh.dimension, + offsetY: 0, + }); + expect(motor.mesh).toBeInstanceOf(THREE.Object3D); }); it('sets motor velocity and schedules stop with distance', ({ motor }) => { @@ -83,19 +64,22 @@ describe(Motor, () => { expect(mockChassisWrapper.getEntity().applyImpulse).toHaveBeenCalled(); }); - it('updates mesh', async ({ motor }) => { - await motor.start(); + it('updates mesh', ({ motor }) => { + motor.start(); + const positionCopy = vi.spyOn(motor.mesh!.position, 'copy'); + const quaternionCopy = vi.spyOn(motor.mesh!.quaternion, 'copy'); motor.update({ frameDuration: 1 } as any); - expect(motor.mesh!.scene.position.copy).toBeCalled(); - expect(motor.mesh!.scene.quaternion.copy).toBeCalled(); + expect(positionCopy).toBeCalled(); + expect(quaternionCopy).toBeCalled(); }); - it('rotates the mesh if on the left side', async ({ motor }) => { + it('rotates the mesh if on the left side', ({ motor }) => { motor.wheelSide = 'left'; - await motor.start(); + motor.start(); + const rotateZ = vi.spyOn(motor.mesh!, 'rotateZ'); motor.update({ frameDuration: 1 } as any); - expect(motor.mesh!.scene.rotateZ).toHaveBeenCalledOnce(); + expect(rotateZ).toHaveBeenCalledOnce(); }); }); diff --git a/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Wheel.test.ts b/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Wheel.test.ts index 0d75221bb2..353c411519 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Wheel.test.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/components/__tests__/Wheel.test.ts @@ -1,20 +1,12 @@ import * as THREE from 'three'; import { describe, expect, it as baseIt, vi } from 'vitest'; -import type { Physics, Renderer } from '../../../../engine'; +import type { Physics } from '../../../../engine'; import type { ChassisWrapper } from '../Chassis'; import { Wheel, type WheelConfig } from '../Wheel'; -vi.mock(import('../../../../engine/Render/debug/DebugArrow'), () => ({ - DebugArrow: class { - getMesh = vi.fn().mockReturnValue({}); - update = vi.fn(); - }, -} as any)); - describe(Wheel, () => { const it = baseIt .extend('mockPhysics', { castRay: vi.fn() } as unknown as Physics) - .extend('mockRenderer', { add: vi.fn() } as unknown as Renderer) .extend('mockChassisWrapper', { getEntity: vi.fn().mockReturnValue({ worldTranslation: vi.fn().mockImplementation(() => new THREE.Vector3()), @@ -37,19 +29,15 @@ describe(Wheel, () => { } as WheelConfig) .extend( 'wheel', - ({ mockChassisWrapper, mockPhysics, mockRenderer, mockConfig }) => new Wheel(mockChassisWrapper, mockPhysics, mockRenderer, mockConfig) + ({ mockChassisWrapper, mockPhysics, mockConfig }) => new Wheel(mockChassisWrapper, mockPhysics, mockConfig) ); - it('should initialize with a debug arrow if debug is true', ({ wheel, mockRenderer }) => { - expect(wheel.arrowHelper).toBeDefined(); - expect(mockRenderer.add).toHaveBeenCalled(); - }); - it('should correctly calculate physics interactions in fixedUpdate', ({ mockPhysics, wheel, mockChassisWrapper, mockConfig }) => { const timingInfo = { timestep: 16 }; // 16 ms timestep const mockResult = { distance: 0.3, normal: new THREE.Vector3(0, 1, 0), + collider: {} as any, }; vi.mocked(mockPhysics.castRay).mockReturnValue(mockResult); @@ -62,7 +50,6 @@ describe(Wheel, () => { expect.anything() ); expect(mockChassisWrapper.getEntity().applyImpulse).toHaveBeenCalled(); - expect(wheel.arrowHelper.update).toHaveBeenCalled(); }); it('should handle null result from castRay indicating no ground contact', ({ mockPhysics, mockChassisWrapper, wheel }) => { @@ -79,6 +66,7 @@ describe(Wheel, () => { const mockResult = { distance: 0, normal: new THREE.Vector3(0, 0, 0), + collider: {} as any, }; vi.mocked(mockPhysics.castRay).mockReturnValue(mockResult); diff --git a/src/bundles/robot_simulation/src/controllers/ev3/ev3/default/__tests__/ev3.test.ts b/src/bundles/robot_simulation/src/controllers/ev3/ev3/default/__tests__/ev3.test.ts index 4f76d0610e..1f57c0e844 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/ev3/default/__tests__/ev3.test.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/ev3/default/__tests__/ev3.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { ControllerMap, Physics, Renderer } from '../../../../../engine'; +import { ControllerMap, Physics } from '../../../../../engine'; +import type { SceneRegistry } from '../../../../../engine/Render/SceneRegistry'; import { ChassisWrapper } from '../../../components/Chassis'; import { Mesh } from '../../../components/Mesh'; import { Motor } from '../../../components/Motor'; @@ -19,7 +20,6 @@ vi.mock(import('../../../sensor/UltrasonicSensor'), () => ({ UltrasonicSensor: v vi.mock(import('../../../../../engine'), () => { return { Physics: vi.fn(), - Renderer: vi.fn(), ControllerMap: vi.fn(class { add = vi.fn(); }) @@ -28,18 +28,18 @@ vi.mock(import('../../../../../engine'), () => { describe(createDefaultEv3, () => { const mockPhysics = new Physics({ gravity:{ x:0, y:-1, z:0 }, timestep: 0.01 }); - const mockRenderer = vi.fn() as unknown as Renderer; - const mockConfig =ev3Config; + const mockRegistry = vi.fn() as unknown as SceneRegistry; + const mockConfig = ev3Config; it('should correctly create all components and return a controller map', () => { - createDefaultEv3(mockPhysics, mockRenderer, mockConfig); + createDefaultEv3(mockPhysics, mockRegistry, mockConfig); - expect(ChassisWrapper).toHaveBeenCalledWith(mockPhysics, mockRenderer, mockConfig.chassis); - expect(Mesh).toHaveBeenCalledWith(expect.any(ChassisWrapper), mockRenderer, mockConfig.mesh); + expect(ChassisWrapper).toHaveBeenCalledWith(mockPhysics, mockConfig.chassis); + expect(Mesh).toHaveBeenCalledWith(expect.any(ChassisWrapper), mockRegistry, mockConfig.mesh); expect(Wheel).toHaveBeenCalledTimes(4); expect(Motor).toHaveBeenCalledTimes(2); - expect(ColorSensor).toHaveBeenCalledWith(expect.any(ChassisWrapper), mockRenderer, mockConfig.colorSensor); - expect(UltrasonicSensor).toHaveBeenCalledWith(expect.any(ChassisWrapper), mockPhysics, mockRenderer, mockConfig.ultrasonicSensor); + expect(ColorSensor).toHaveBeenCalledWith(expect.any(ChassisWrapper), mockPhysics, mockConfig.colorSensor); + expect(UltrasonicSensor).toHaveBeenCalledWith(expect.any(ChassisWrapper), mockPhysics, mockConfig.ultrasonicSensor); expect(ControllerMap).toHaveBeenCalled(); }); diff --git a/src/bundles/robot_simulation/src/controllers/ev3/ev3/default/ev3.ts b/src/bundles/robot_simulation/src/controllers/ev3/ev3/default/ev3.ts index 805809f758..a3d3979d68 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/ev3/default/ev3.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/ev3/default/ev3.ts @@ -1,4 +1,5 @@ -import { ControllerMap, type Physics, type Renderer } from '../../../../engine'; +import { ControllerMap, type Physics } from '../../../../engine'; +import type { SceneRegistry } from '../../../../engine/Render/SceneRegistry'; import { ChassisWrapper } from '../../components/Chassis'; import { Mesh } from '../../components/Mesh'; @@ -20,11 +21,11 @@ export type DefaultEv3 = ControllerMap; export const createDefaultEv3 = ( physics: Physics, - render: Renderer, + registry: SceneRegistry, config: Ev3Config, ): DefaultEv3 => { - const chassis = new ChassisWrapper(physics, render, config.chassis); - const mesh = new Mesh(chassis, render, config.mesh); + const chassis = new ChassisWrapper(physics, config.chassis); + const mesh = new Mesh(chassis, registry, config.mesh); const wheelControllers = wheelNames.reduce((acc, name) => { const displacement = config.wheels.displacements[name]; @@ -32,7 +33,7 @@ export const createDefaultEv3 = ( ...config.wheels.config, displacement, }; - const wheel = new Wheel(chassis, physics, render, wheelConfig); + const wheel = new Wheel(chassis, physics, wheelConfig); return { ...acc, [name]: wheel, @@ -46,7 +47,7 @@ export const createDefaultEv3 = ( ...config.motors.config, displacement, }; - const motor = new Motor(chassis, physics, render, motorConfig); + const motor = new Motor(chassis, physics, registry, motorConfig); return { ...acc, [name]: motor, @@ -54,12 +55,11 @@ export const createDefaultEv3 = ( }, {} as MotorControllers); // Sensors - const colorSensor = new ColorSensor(chassis, render, config.colorSensor); + const colorSensor = new ColorSensor(chassis, physics, config.colorSensor); const ultrasonicSensor = new UltrasonicSensor( chassis, physics, - render, config.ultrasonicSensor, ); diff --git a/src/bundles/robot_simulation/src/controllers/ev3/sensor/ColorSensor.ts b/src/bundles/robot_simulation/src/controllers/ev3/sensor/ColorSensor.ts index aa38e19890..6d60abcd83 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/sensor/ColorSensor.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/sensor/ColorSensor.ts @@ -1,12 +1,7 @@ import * as THREE from 'three'; -import { Renderer } from '../../../engine'; import { vec3 } from '../../../engine/Math/Convert'; import type { SimpleVector } from '../../../engine/Math/Vector'; -import type { PhysicsTimingInfo } from '../../../engine/Physics'; -import { - getCamera, - type CameraOptions, -} from '../../../engine/Render/helpers/Camera'; +import type { Physics , PhysicsTimingInfo } from '../../../engine/Physics'; import type { ChassisWrapper } from '../components/Chassis'; import type { Sensor } from './types'; @@ -18,140 +13,94 @@ export type ColorSensorConfig = { width: number; }; displacement: SimpleVector; - camera: CameraOptions; + camera: unknown; tickRateInSeconds: number; debug: boolean; }; +/** Returned when the sensor's downward raycast doesn't hit any registered surface (e.g. hovering + * over the edge of the floor) - matches the white background the pre-migration renderer cleared + to. */ +const DEFAULT_COLOR: Color = { r: 255, g: 255, b: 255 }; + +/** + * Pre-migration, this sensor worked by literally rendering the scene from a tiny camera mounted + * where the sensor sits, then averaging the rendered pixels - real GPU work, which needs the same + * WebGL context the main view uses. That's no longer available here: this class now runs inside + * Conductor's runner Worker, which has no WebGL (see SceneRegistry's doc comment for why the + * module can't own any rendering at all any more). + * + * Replaced with a physics raycast straight down from the sensor to the nearest registered + * surface (see `Physics.registerColor`/`Cuboid.ts`), returning that surface's flat color. This is + * worker-safe and keeps the sensor meaningfully reactive to floor/wall colors (e.g. line-following + * programs against a colored floor still work), but it is a real behavioural narrowing versus the + * original: `Paper` (create_paper) has no physics collider (see Paper.ts), so a colored/textured + * paper placed on the floor is invisible to this sensor, where the original GPU-rendered version + * would have picked it up. Restoring that would mean either giving Paper a (non-solid) collider + * carrying its texture's dominant color, or routing sense() through a tab-side render-and-readback + * round trip once per sensor tick (~10/s here) - either is a real follow-up, not attempted in this + * migration. + */ export class ColorSensor implements Sensor { chassisWrapper: ChassisWrapper; + physics: Physics; displacement: THREE.Vector3; config: ColorSensorConfig; - - camera: THREE.Camera; - spotLight: THREE.SpotLight; - renderer: Renderer; accumulator = 0; - colorSensed: Color; - tempCanvas: HTMLCanvasElement; + colorSensed: Color = DEFAULT_COLOR; constructor( chassisWrapper: ChassisWrapper, - render: Renderer, + physics: Physics, config: ColorSensorConfig, ) { this.chassisWrapper = chassisWrapper; + this.physics = physics; this.displacement = vec3(config.displacement); this.config = config; - - this.camera = getCamera(config.camera); - this.spotLight = new THREE.SpotLight(0xffffff, 0.2, 1, 15/180 * Math.PI); - render.add(this.spotLight); - // We create a new renderer with the same scene. But we use a different camera. - this.renderer = new Renderer(render.scene(), this.camera, { - width: this.config.size.width, - height: this.config.size.height, - control: 'none', - }); - - this.colorSensed = { - r: 0, - g: 0, - b: 0, - }; - - this.tempCanvas = document.createElement('canvas'); - this.tempCanvas.width = this.config.size.width; - this.tempCanvas.height = this.config.size.height; - - if (config.debug) { - const helper = new THREE.CameraHelper(this.camera); - render.add(helper); - } } getColorSensorPosition() { const chassis = this.chassisWrapper.getEntity(); - const colorSensorPosition = chassis.worldTranslation(this.displacement.clone(),); - return colorSensorPosition; + return chassis.worldTranslation(this.displacement.clone()); } sense(): Color { return this.colorSensed; } - // Even though we are rendering, we use fixedUpdate because the student's code can be affected - // by the values of sense() and could affect the determinism of the simulation. + // Even though sensing no longer renders, we use fixedUpdate because the student's code can be + // affected by the values of sense() and could affect the determinism of the simulation. fixedUpdate(timingInfo: PhysicsTimingInfo) { this.accumulator += timingInfo.timestep; const tickRateInMilliseconds = this.config.tickRateInSeconds * 1000; - - // We check the accumulator to see if it's time update the color sensor. - // If it's not time, we return early. if (this.accumulator < tickRateInMilliseconds) { return; } this.accumulator -= tickRateInMilliseconds; - // We move the camera to the right position - this.camera.position.copy(this.getColorSensorPosition()); - this.camera.lookAt( - this.camera.position.x, - this.camera.position.y - 1, // 1 unit below its current position - this.camera.position.z, - ); - this.spotLight.position.copy(this.camera.position); - this.spotLight.target.position.set( - this.camera.position.x, - this.camera.position.y - 1, - this.camera.position.z, - ); - this.spotLight.target.updateMatrixWorld(); - - // We render to load the color sensor data into the renderer. - this.renderer.render(); - - // We get the HTMLCanvasElement from the renderer - const rendererCanvas = this.renderer.getElement(); - - // Get the context from the temp canvas - const tempCtx = this.tempCanvas.getContext('2d', { - willReadFrequently: true, - })!; - - // Draw the renderer canvas to the temp canvas - tempCtx.drawImage(rendererCanvas, 0, 0); - - // Get the image data from the temp canvas - const imageData = tempCtx.getImageData( - 0, - 0, - this.config.size.width, - this.config.size.height, - {}, - ); - - // Calculate the average color - const averageColor = { - r: 0, - g: 0, - b: 0, - }; + const chassis = this.chassisWrapper.getEntity(); + const position = this.getColorSensorPosition(); + const down = vec3({ x: 0, y: -1, z: 0 }); - for (let i = 0; i < imageData.data.length; i += 4) { - const r = imageData.data[i]; - const g = imageData.data[i + 1]; - const b = imageData.data[i + 2]; - averageColor.r += r; - averageColor.g += g; - averageColor.b += b; + const result = this.physics.castRay(position, down, 1, chassis.getCollider()); + if (result === null) { + this.colorSensed = DEFAULT_COLOR; + return; } - averageColor.r /= imageData.data.length; - averageColor.g /= imageData.data.length; - averageColor.b /= imageData.data.length; + const hex = this.physics.getColor(result.collider); + if (hex === undefined) { + this.colorSensed = DEFAULT_COLOR; + return; + } - this.colorSensed = averageColor; + const color = new THREE.Color(hex); + this.colorSensed = { + r: color.r * 255, + g: color.g * 255, + b: color.b * 255, + }; } } diff --git a/src/bundles/robot_simulation/src/controllers/ev3/sensor/UltrasonicSensor.ts b/src/bundles/robot_simulation/src/controllers/ev3/sensor/UltrasonicSensor.ts index ddb68031b7..1345bd9613 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/sensor/UltrasonicSensor.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/sensor/UltrasonicSensor.ts @@ -1,8 +1,6 @@ -import * as THREE from 'three'; import { vec3 } from '../../../engine/Math/Convert'; import type { SimpleVector } from '../../../engine/Math/Vector'; import type { Physics } from '../../../engine/Physics'; -import type { Renderer } from '../../../engine/Render/Renderer'; import type { ChassisWrapper } from '../components/Chassis'; import type { Sensor } from './types'; @@ -12,33 +10,26 @@ export type UltrasonicSensorConfig = { debug: boolean; }; +/** The pre-migration debug arrow is dropped - see Chassis.ts's doc comment. Sensing itself + (a physics raycast) was already worker-safe and is unchanged. */ export class UltrasonicSensor implements Sensor { chassisWrapper: ChassisWrapper; physics: Physics; - displacement: THREE.Vector3; - direction: THREE.Vector3; + displacement: ReturnType; + direction: ReturnType; distanceSensed: number = 0; - render: Renderer; config: UltrasonicSensorConfig; - debugArrow: THREE.ArrowHelper; constructor( chassis: ChassisWrapper, physics: Physics, - render: Renderer, config: UltrasonicSensorConfig, ) { this.chassisWrapper = chassis; this.physics = physics; - this.render = render; this.displacement = vec3(config.displacement); this.direction = vec3(config.direction); this.config = config; - - // Debug arrow - this.debugArrow = new THREE.ArrowHelper(); - this.debugArrow.visible = false; - this.render.add(this.debugArrow); } sense(): number { @@ -58,12 +49,6 @@ export class UltrasonicSensor implements Sensor { .getCollider(), ); - if (this.config.debug) { - this.debugArrow.visible = true; - this.debugArrow.position.copy(globalDisplacement); - this.debugArrow.setDirection(globalDirection.normalize()); - } - if (result === null) { return; } @@ -72,5 +57,4 @@ export class UltrasonicSensor implements Sensor { this.distanceSensed = wheelDistance; } - } diff --git a/src/bundles/robot_simulation/src/controllers/ev3/sensor/__tests__/ColorSensor.test.ts b/src/bundles/robot_simulation/src/controllers/ev3/sensor/__tests__/ColorSensor.test.ts index 9dc72d51df..9996241ae6 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/sensor/__tests__/ColorSensor.test.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/sensor/__tests__/ColorSensor.test.ts @@ -1,36 +1,21 @@ import * as THREE from 'three'; import { describe, expect, it as baseIt, vi } from 'vitest'; -import type { Renderer } from '../../../../engine'; +import type { Physics } from '../../../../engine'; import type { ChassisWrapper } from '../../components/Chassis'; import { ColorSensor, type ColorSensorConfig } from '../ColorSensor'; -vi.mock(import('../../../../engine'), () => ({ - Renderer: vi.fn(class { - scene = vi.fn(); - render = vi.fn(); - getElement = vi.fn(() => document.createElement('canvas')); - }), -}) as any); - -vi.mock(import('../../../../engine/Render/helpers/Camera'), () => ({ - getCamera: vi.fn().mockImplementation(() => { - return new THREE.PerspectiveCamera(); - }), -})); - describe(ColorSensor, () => { const it = baseIt .extend('mockChassisWrapper', { getEntity: vi.fn(() => ({ worldTranslation: vi.fn().mockReturnValue(new THREE.Vector3()), + getCollider: vi.fn().mockReturnValue({}), })), } as unknown as ChassisWrapper) - .extend('mockRenderer', { - add: vi.fn(), - scene: vi.fn(), - render: vi.fn(), - getElement: vi.fn(() => document.createElement('canvas')), - } as unknown as Renderer) + .extend('mockPhysics', { + castRay: vi.fn(), + getColor: vi.fn(), + } as unknown as Physics) .extend('mockConfig', { tickRateInSeconds: 0.1, displacement: { @@ -42,49 +27,45 @@ describe(ColorSensor, () => { height: 16, width: 16, }, - camera: { - type: 'perspective', - aspect: 1, - fov: 10, - near: 0.01, - far: 1, - }, + camera: {}, debug: true, } as ColorSensorConfig) .extend( 'sensor', - ({ mockChassisWrapper, mockRenderer, mockConfig }) => new ColorSensor(mockChassisWrapper, mockRenderer, mockConfig) + ({ mockChassisWrapper, mockPhysics, mockConfig }) => new ColorSensor(mockChassisWrapper, mockPhysics, mockConfig) ); - it.beforeEach(() => { - const mockCtx = { - getImageData: vi.fn(() => ({ - data: new Uint8ClampedArray([255, 255, 255, 255]), - })), - putImageData: vi.fn(), - drawImage: vi.fn(), - fillRect: vi.fn(), - clearRect: vi.fn(), - canvas: {}, - }; - - HTMLCanvasElement.prototype.getContext = vi - .fn() - .mockImplementation((_) => { - return mockCtx; - }); + it('should default to white before the first sensed tick', ({ sensor }) => { + expect(sensor.sense()).toEqual({ r: 255, g: 255, b: 255 }); }); - it('should initialize correctly', ({ sensor, mockRenderer }) => { - expect(sensor).toBeDefined(); - expect(mockRenderer.add).toHaveBeenCalled(); + it('should not update color until accumulating sufficient time', ({ sensor, mockPhysics }) => { + const timingInfo = { timestep: 50 }; + sensor.fixedUpdate(timingInfo as any); + expect(mockPhysics.castRay).not.toHaveBeenCalled(); }); - it('should update color only after accumulating sufficient time', ({ sensor, mockRenderer }) => { - const timingInfo = { timestep: 50 }; + it('should sample the raycast-hit surface color once enough time accumulates', ({ sensor, mockPhysics }) => { + vi.mocked(mockPhysics.castRay).mockReturnValue({ distance: 0.1, normal: { x: 0, y: 1, z: 0 }, collider: {} as any }); + vi.mocked(mockPhysics.getColor).mockReturnValue('#ff0000'); + + const timingInfo = { timestep: 200 }; sensor.fixedUpdate(timingInfo as any); - expect(mockRenderer.render).not.toHaveBeenCalled(); + + expect(mockPhysics.castRay).toHaveBeenCalled(); + const result = sensor.sense(); + expect(result.r).toBeCloseTo(255); + expect(result.g).toBeCloseTo(0); + expect(result.b).toBeCloseTo(0); + }); + + it('should fall back to white when the raycast hits nothing', ({ sensor, mockPhysics }) => { + vi.mocked(mockPhysics.castRay).mockReturnValue(null); + + const timingInfo = { timestep: 200 }; sensor.fixedUpdate(timingInfo as any); + + expect(sensor.sense()).toEqual({ r: 255, g: 255, b: 255 }); }); it('should give correct response for sense', ({ sensor }) => { diff --git a/src/bundles/robot_simulation/src/controllers/ev3/sensor/__tests__/UltrasonicSensor.test.ts b/src/bundles/robot_simulation/src/controllers/ev3/sensor/__tests__/UltrasonicSensor.test.ts index 4dd921176f..d05bd7f5f3 100644 --- a/src/bundles/robot_simulation/src/controllers/ev3/sensor/__tests__/UltrasonicSensor.test.ts +++ b/src/bundles/robot_simulation/src/controllers/ev3/sensor/__tests__/UltrasonicSensor.test.ts @@ -1,24 +1,9 @@ import * as THREE from 'three'; import { describe, expect, it as baseIt, vi } from 'vitest'; -import type { Physics, Renderer } from '../../../../engine'; +import type { Physics } from '../../../../engine'; import type { ChassisWrapper } from '../../components/Chassis'; import { UltrasonicSensor } from '../UltrasonicSensor'; -vi.mock(import('three'), () => ({ - Vector3: vi.fn(class { - clone = vi.fn().mockReturnThis(); - normalize = vi.fn().mockReturnThis(); - copy = vi.fn(); - }), - ArrowHelper: class { - visible = false; - position = { - copy: vi.fn() - }; - setDirection = vi.fn(); - } -}) as any); - describe(UltrasonicSensor, () => { const it = baseIt .extend('mockChassisWrapper', { @@ -28,8 +13,7 @@ describe(UltrasonicSensor, () => { getCollider: vi.fn() })) } as unknown as ChassisWrapper) - .extend('mockPhysics', { castRay: vi.fn().mockReturnValue({ distance: 5 }) } as unknown as Physics) - .extend('mockRenderer', { add: vi.fn() } as unknown as Renderer) + .extend('mockPhysics', { castRay: vi.fn().mockReturnValue({ distance: 5, normal: { x: 0, y: 1, z: 0 }, collider: {} as any }) } as unknown as Physics) .extend('mockConfig', { displacement: { x: 1, y: 1, z: 1 }, direction: { x: 0, y: 1, z: 0 }, @@ -37,13 +21,11 @@ describe(UltrasonicSensor, () => { }) .extend( 'sensor', - ({ mockChassisWrapper, mockPhysics, mockRenderer, mockConfig }) => new UltrasonicSensor(mockChassisWrapper, mockPhysics, mockRenderer, mockConfig) + ({ mockChassisWrapper, mockPhysics, mockConfig }) => new UltrasonicSensor(mockChassisWrapper, mockPhysics, mockConfig) ); - it('should create instances and set initial properties', ({ sensor, mockRenderer }) => { + it('should create instances and set initial properties', ({ sensor }) => { expect(sensor).toBeDefined(); - expect(THREE.Vector3).toHaveBeenCalledTimes(2); // Called for displacement and direction - expect(mockRenderer.add).toHaveBeenCalledWith(sensor.debugArrow); }); it('should return initial distance sensed as 0', ({ sensor }) => { @@ -54,8 +36,6 @@ describe(UltrasonicSensor, () => { sensor.fixedUpdate(); expect(sensor.distanceSensed).toEqual(5); expect(mockPhysics.castRay).toHaveBeenCalled(); - expect(sensor.debugArrow.visible).toBeTruthy(); - expect(sensor.debugArrow.setDirection).toHaveBeenCalled(); }); it('should handle null results from castRay indicating no collision detected', ({ sensor, mockPhysics }) => { diff --git a/src/bundles/robot_simulation/src/controllers/program/Program.ts b/src/bundles/robot_simulation/src/controllers/program/Program.ts index 1b3cdcd07c..4265477d26 100644 --- a/src/bundles/robot_simulation/src/controllers/program/Program.ts +++ b/src/bundles/robot_simulation/src/controllers/program/Program.ts @@ -1,14 +1,12 @@ -import { GeneralRuntimeError } from '@sourceacademy/modules-lib/errors'; +import { EvaluatorRuntimeError } from '@sourceacademy/conductor/common'; import type { DeepPartial } from '@sourceacademy/modules-lib/types'; import type { Context as PyContext } from '@sourceacademy/py-slang'; -import type { IOptions } from 'js-slang'; -import context from 'js-slang/context'; import { CallbackHandler } from '../../engine/Core/CallbackHandler'; import type { Controller } from '../../engine/Core/Controller'; import type { PhysicsTimingInfo } from '../../engine/Physics'; import { mergeConfig } from '../utils/mergeConfig'; import { ProgramError } from './error'; -import { runECEvaluator, runPythonECEvaluator } from './evaluate'; +import { runPythonECEvaluator } from './evaluate'; type ProgramConfig = { stepsPerTick: number; @@ -21,41 +19,37 @@ const defaultProgramConfig: ProgramConfig = { export const program_controller_identifier = 'program_controller'; /** - * Which language flavour a Program should evaluate its code with. + * The robot's control program is Python only, evaluated with py-slang's CSE machine - see + * {@link createPythonCSE} (index.ts) and controllers/program/pythonRuntime.ts, which builds the + * py-slang `Context` this class steps. * - * `'source'` is what {@link createCSE} produces: the surrounding Source program is re-run as the - * robot's control program, using the js-slang `Context` the host frontend injects into this bundle - * via the esbuild `external: ['js-slang*']` rule (see - * modules/lib/buildtools/src/build/modules/commons.ts). - * - * `'python'` is what {@link createPythonCSE} produces. There is no 'py-slang/context'-style - * runtime-injection convention anywhere in this codebase — buildtools' external list is still just - * `js-slang*` — so nothing hands this bundle a py-slang `Context`. It therefore builds its own; see - * controllers/program/pythonRuntime.ts, which also explains why a Python program running under - * py-slang's own conductor evaluator cannot simply `import robot_simulation` instead. + * A Source-flavoured control program (what a `createCSE` would produce, re-using js-slang's own + * CSE machine the same way) is a known, deliberately deferred follow-up, not attempted in this + * migration: buildtools applies `external: ['js-slang*']` to every bundle build unconditionally + * (lib/buildtools/src/build/modules/commons.ts), so *any* `js-slang/...` import anywhere in this + * bundle - even one merely imported but never called - compiles down to a top-level + * `require('js-slang/...')` that Conductor's runner Worker has no reason to be able to resolve + * (that require only ever worked pre-migration because js-slang's own module loader + * (`requireProvider.js`) was the thing running this bundle in the first place - see this class's + * git history for the fuller version of that story). Building a full js-slang `Context` by hand + * well enough to import it safely (chapter/prelude/global-environment setup, not just a flat + * builtins map the way py-slang's `Context` allows) is real, un-derisked work of its own. */ -export type ProgramLanguage = 'source' | 'python'; - export class Program implements Controller { code: string; - language: ProgramLanguage; - /** Only used when `language === 'python'`. The py-slang `Context` this Program's shadow - * evaluation runs against — entirely separate from js-slang's `context` singleton above. */ - pyContext: PyContext | null; - iterator: ReturnType | null; - /** Only used when `language === 'python'`; `runECEvaluator`'s async counterpart. */ - pyIterator: ReturnType | null; + pyContext: PyContext; + iterator: ReturnType | null; /** Guards against a new tick's pump starting before the previous tick's `await`ed steps have - * all landed. Needed only for the Python path: `fixedUpdate` is a synchronous callback (see - * Controller.ts / World.ts), so it cannot itself `await` — it kicks off a pump and returns - * immediately, and this flag stops a second pump from overlapping the first if steps ever take - * longer than one physics tick to resolve (e.g. a slow native/module call). */ - private pythonPumpBusy = false; - /** Set by `fixedUpdatePython`'s async pump if a step throws. Since the pump is fire-and-forget - * (fixedUpdate can't await it), the error can't be thrown synchronously from the tick that - * caused it — it's stashed here and re-thrown from the *next* `fixedUpdate` call instead, so it - * still surfaces to (and is convertible by) the same call site the sync path throws from. */ - private pythonError: unknown = null; + * all landed. `fixedUpdate` is a synchronous callback (see Controller.ts / World.ts), so it + * cannot itself `await` — it kicks off a pump and returns immediately, and this flag stops a + * second pump from overlapping the first if steps ever take longer than one physics tick to + resolve (e.g. a slow native/module call). */ + private pumpBusy = false; + /** Set by the async pump if a step throws. Since the pump is fire-and-forget (fixedUpdate can't + * await it), the error can't be thrown synchronously from the tick that caused it — it's + * stashed here and re-thrown from the *next* `fixedUpdate` call instead, so it still surfaces + to (and is convertible by) the same call site a synchronous evaluator would throw from. */ + private pendingError: unknown = null; isPaused: boolean; callbackHandler = new CallbackHandler(); name: string; @@ -64,21 +58,17 @@ export class Program implements Controller { constructor( code: string, config?: DeepPartial, - language: ProgramLanguage = 'source', - pyContext: PyContext | null = null + pyContext?: PyContext ) { + if (pyContext === undefined) { + throw new EvaluatorRuntimeError('Program: pyContext is required'); + } this.config = mergeConfig(defaultProgramConfig, config); this.name = program_controller_identifier; this.code = code; - this.language = language; this.pyContext = pyContext; this.iterator = null; - this.pyIterator = null; this.isPaused = false; - - if (this.language === 'python' && this.pyContext === null) { - throw new GeneralRuntimeError('Program: pyContext is required when language is "python"'); - } } pause(pauseDuration: number) { @@ -89,77 +79,42 @@ export class Program implements Controller { } start() { - if (this.language === 'python') { - this.pyIterator = runPythonECEvaluator(this.code, this.pyContext!, { - stepLimit: -1, - }); - return; - } - - const options: Partial = { - originalMaxExecTime: Infinity, - stepLimit: Infinity, - throwInfiniteLoops: false, - useSubst: false, - }; - - context.errors = []; - - this.iterator = runECEvaluator(this.code, context, options); - } - - fixedUpdate() { - if (this.isPaused) { - return; - } - - if (this.language === 'python') { - this.fixedUpdatePython(); - return; - } - - try { - if (!this.iterator) { - throw new GeneralRuntimeError('Program not started'); - } - - // steps per tick - for (let i = 0; i < this.config.stepsPerTick; i++) { - this.iterator.next(); - } - } catch (e) { - console.error(e); - throw new ProgramError('Error in program execution. Please check your code and try again.',); - } + this.iterator = runPythonECEvaluator(this.code, this.pyContext, { + stepLimit: -1, + }); } /** * Steps py-slang's async CSE-machine generator `stepsPerTick` times. Since `fixedUpdate` itself * must stay synchronous (it's called synchronously from the physics tick loop — see * World.ts/Controller.ts), this fires an async pump and returns immediately rather than - * blocking on it. `pythonPumpBusy` prevents a second tick's pump from overlapping the first's + * blocking on it. `pumpBusy` prevents a second tick's pump from overlapping the first's * still-in-flight `await`s. */ - private fixedUpdatePython() { - if (this.pythonError !== null) { - const error = this.pythonError; - this.pythonError = null; + fixedUpdate() { + if (this.isPaused) { + return; + } + + if (this.pendingError !== null) { + const error = this.pendingError; + this.pendingError = null; console.error(error); throw new ProgramError('Error in program execution. Please check your code and try again.',); } - if (!this.pyIterator) { - throw new GeneralRuntimeError('Program not started'); + if (!this.iterator) { + throw new EvaluatorRuntimeError('Program not started'); } - if (this.pythonPumpBusy) { + if (this.pumpBusy) { // Previous tick's steps haven't all resolved yet; skip this tick rather than // interleaving two concurrent pumps against the same generator. return; } - const iterator = this.pyIterator; + const iterator = this.iterator; const stepsPerTick = this.config.stepsPerTick; - this.pythonPumpBusy = true; + this.pumpBusy = true; (async () => { try { for (let i = 0; i < stepsPerTick; i++) { @@ -168,10 +123,10 @@ export class Program implements Controller { } } catch (e) { // Fire-and-forget: this pump isn't awaited by fixedUpdate, so the error can't be - // thrown synchronously here — stash it for the next fixedUpdatePython call to raise. - this.pythonError = e; + // thrown synchronously here — stash it for the next fixedUpdate call to raise. + this.pendingError = e; } finally { - this.pythonPumpBusy = false; + this.pumpBusy = false; } })(); } diff --git a/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.python.test.ts b/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.python.test.ts index 3681f46d9d..f89148b319 100644 --- a/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.python.test.ts +++ b/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.python.test.ts @@ -3,18 +3,17 @@ import { describe, expect, it, vi } from 'vitest'; import { Program } from '../Program'; /** - * Exercises the Python-flavoured path end-to-end (real py-slang CSE machine, not mocked), - * proving that Program's async pump actually drives py-slang's async generator correctly across - * several simulated physics ticks. Complements Program.test.ts, which only exercises the - * pre-existing (mocked) synchronous js-slang path. + * Exercises the Python path end-to-end (real py-slang CSE machine, not mocked), proving that + * Program's async pump actually drives py-slang's async generator correctly across several + * simulated physics ticks. Complements Program.test.ts, which only exercises the mocked path. */ -describe('Program (python path)', () => { +describe('Program (real py-slang CSE machine)', () => { it('steps a Python program to completion across several fixedUpdate ticks', async () => { const pyContext = new PyContext(); - const program = new Program('x = 1\nx = x + 1\ny = x * 3\n', { stepsPerTick: 3 }, 'python', pyContext); + const program = new Program('x = 1\nx = x + 1\ny = x * 3\n', { stepsPerTick: 3 }, pyContext); program.start(); - expect(program.pyIterator).not.toBeNull(); + expect(program.iterator).not.toBeNull(); // Drain the program across several ticks the way World's physics-tick loop would, waiting // a macrotask between ticks so each tick's fire-and-forget async pump gets a chance to @@ -29,20 +28,18 @@ describe('Program (python path)', () => { // The global environment should now hold the final bindings. const globalEnv = pyContext.runtime.environments[0]; - expect(globalEnv.head['x']).toEqual({ type: 'bigint', value: 2n }); - expect(globalEnv.head['y']).toEqual({ type: 'bigint', value: 6n }); + expect(globalEnv.head['x']).toEqual({ type: 'bigint', value: BigInt(2) }); + expect(globalEnv.head['y']).toEqual({ type: 'bigint', value: BigInt(6) }); }); - it('throws GeneralRuntimeError when constructed with language "python" but no pyContext', () => { - expect(() => new Program('x = 1', undefined, 'python', null)).toThrow( - 'pyContext is required when language is "python"' - ); + it('throws GeneralRuntimeError when constructed without a pyContext', () => { + expect(() => new Program('x = 1')).toThrow('pyContext is required'); }); it('surfaces a Python evaluation error on the next tick without throwing synchronously', async () => { const pyContext = new PyContext(); // Name error: `z` is never defined. - const program = new Program('print(z)', { stepsPerTick: 5 }, 'python', pyContext); + const program = new Program('print(z)', { stepsPerTick: 5 }, pyContext); vi.spyOn(console, 'error').mockImplementation(vi.fn()); program.start(); diff --git a/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.test.ts b/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.test.ts index e61cbfdf28..f3fe75bf9d 100644 --- a/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.test.ts +++ b/src/bundles/robot_simulation/src/controllers/program/__tests__/Program.test.ts @@ -1,23 +1,32 @@ +import { Context as PyContext } from '@sourceacademy/py-slang'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CallbackHandler } from '../../../engine/Core/CallbackHandler'; import { Program, program_controller_identifier } from '../Program'; -import { runECEvaluator } from '../evaluate'; - +import { runPythonECEvaluator } from '../evaluate'; + +/** + * Generic Controller-shaped behaviour (config defaults, pause/resume, error handling), against a + * mocked evaluator - what a pre-migration Program.test.ts covered against a mocked js-slang + * evaluator, now against py-slang's instead (Program is Python-only - see Program.ts's doc + * comment for why a Source path isn't wired up). Program.python.test.ts complements this with an + * end-to-end run against the real py-slang CSE machine. + */ vi.mock(import('../../../engine/Core/CallbackHandler')); vi.mock(import('../evaluate')); -const mockedRunECEvaluator = vi.mocked(runECEvaluator); +const mockedRunPythonECEvaluator = vi.mocked(runPythonECEvaluator); const mockedCallbackHandler = vi.mocked(CallbackHandler); describe(Program, () => { let program: Program; - const mockCode = 'const x = 1;'; + const mockCode = 'x = 1'; + const mockPyContext = new PyContext(); beforeEach(() => { mockedCallbackHandler.mockClear(); - mockedRunECEvaluator.mockClear(); + mockedRunPythonECEvaluator.mockClear(); - program = new Program(mockCode); + program = new Program(mockCode, undefined, mockPyContext); vi.spyOn(console, 'error').mockImplementation(vi.fn()); }); @@ -29,26 +38,30 @@ describe(Program, () => { }); it('should merge user configuration with default', () => { - const customProgram = new Program(mockCode, { stepsPerTick: 20 }); + const customProgram = new Program(mockCode, { stepsPerTick: 20 }, mockPyContext); expect(customProgram.config.stepsPerTick).toEqual(20); }); + it('throws GeneralRuntimeError when constructed without a pyContext', () => { + expect(() => new Program(mockCode)).toThrow('pyContext is required'); + }); + it('should start the evaluator with correct options', () => { - const mockIterator = { next: vi.fn() } as any; - mockedRunECEvaluator.mockReturnValue(mockIterator); + const mockIterator = { next: vi.fn().mockResolvedValue({ done: false }) } as any; + mockedRunPythonECEvaluator.mockReturnValue(mockIterator); program.start(); - expect(mockedRunECEvaluator).toHaveBeenCalledWith(mockCode, expect.anything(), expect.anything()); + expect(mockedRunPythonECEvaluator).toHaveBeenCalledWith(mockCode, mockPyContext, expect.anything()); expect(program.iterator).toBe(mockIterator); }); it('should handle pause and resume correctly', () => { - const mockIterator = { next: vi.fn() } as any; - mockedRunECEvaluator.mockReturnValue(mockIterator); + const mockIterator = { next: vi.fn().mockResolvedValue({ done: false }) } as any; + mockedRunPythonECEvaluator.mockReturnValue(mockIterator); program.start(); - const tick = { stepCount:0,timestep: 1000 } as any; + const tick = { stepCount: 0, timestep: 1000 } as any; program.update(tick); program.pause(900); expect(program.isPaused).toBeTruthy(); @@ -58,18 +71,8 @@ describe(Program, () => { expect(mockIterator.next).not.toBeCalled(); }); - it('should process fixed number of steps per tick', () => { - const mockIterator = { next: vi.fn() } as any; - mockedRunECEvaluator.mockReturnValue(mockIterator); - - program.start(); - program.fixedUpdate(); - - expect(mockIterator.next).toHaveBeenCalledTimes(11); - }); - - it('should catch errors during fixedUpdate', () => { - expect(() => program.fixedUpdate()).toThrow('Error in program execution. Please check your code and try again.'); + it('throws when fixedUpdate is called before start', () => { + expect(() => program.fixedUpdate()).toThrow('Program not started'); }); it('should check callbacks on update', () => { diff --git a/src/bundles/robot_simulation/src/controllers/program/error.ts b/src/bundles/robot_simulation/src/controllers/program/error.ts index 1edfa5c9cb..4b030357a8 100644 --- a/src/bundles/robot_simulation/src/controllers/program/error.ts +++ b/src/bundles/robot_simulation/src/controllers/program/error.ts @@ -1,11 +1,17 @@ -import { RuntimeSourceError } from '@sourceacademy/modules-lib/errors'; - -export class ProgramError extends RuntimeSourceError { +/** + * Thrown internally by `Program.fixedUpdate()`/caught by `World.step()` - never crosses the + * Conductor evaluator boundary itself, so it doesn't need to be one of `@sourceacademy/conductor/ + * common`'s evaluator error types. It used to extend `@sourceacademy/modules-lib/errors`' + * `RuntimeSourceError`, which itself imports real `js-slang/dist/errors/*` code - harmless + * pre-migration (this bundle was always hosted by js-slang's own module loader, which resolves + * those `js-slang/...` requires), but fatal now: esbuild's blanket `external: ['js-slang*']` rule + * (lib/buildtools/src/build/modules/commons.ts) means that import compiles to a top-level + * `require('js-slang/dist/errors/base')` baked into this bundle, which Conductor's runner Worker + * has no reason to be able to resolve. A plain `Error` subclass is all this actually needs. + */ +export class ProgramError extends Error { constructor(public readonly explanation: string) { - super(undefined); - } - - public override explain(): string { - return this.explanation; + super(explanation); + this.name = 'ProgramError'; } } diff --git a/src/bundles/robot_simulation/src/controllers/program/evaluate.ts b/src/bundles/robot_simulation/src/controllers/program/evaluate.ts index b4c3b13c86..93da036812 100644 --- a/src/bundles/robot_simulation/src/controllers/program/evaluate.ts +++ b/src/bundles/robot_simulation/src/controllers/program/evaluate.ts @@ -1,77 +1,12 @@ -import { merge } from 'es-toolkit'; -import { - Control, - Stash, - generateCSEMachineStateStream, -} from 'js-slang/dist/cse-machine/interpreter'; -import type { Variant } from 'js-slang/dist/langs'; -import { parse } from 'js-slang/dist/parser/parser'; -import type { Context } from 'js-slang/dist/types'; import { - analyze as analyzePython, Context as PyContext, Control as PyControl, + Stash as PyStash, + analyze as analyzePython, generateCSEMachineStateStream as generatePyCSEMachineStateStream, parse as parsePython, - Stash as PyStash, } from '@sourceacademy/py-slang'; - -export const DEFAULT_SOURCE_OPTIONS = { - scheduler: 'async', - steps: 1000, - stepLimit: -1, - executionMethod: 'auto', - // Literal rather than `Variant.DEFAULT`: the frontend satisfies this bundle's `js-slang/*` - // imports at runtime through js-slang's own requireProvider allowlist - // (js-slang/dist/modules/loader/requireProvider.js), which exposes createContext, cse-machine, - // errors, parser, stdlib, types and utils - but NOT `langs`. A value import of - // 'js-slang/dist/langs' therefore makes the whole bundle fail to load in the real frontend with - // "Dynamic require of js-slang/dist/langs is not supported", before any user code runs. The - // type-only import above is erased at build time and so is safe. - variant: 'default' as Variant, - originalMaxExecTime: 1000, - useSubst: false, - isPrelude: false, - throwInfiniteLoops: true, - envSteps: -1, - importOptions: { - wrapSourceModules: true, - checkImports: true, - loadTabs: true, - }, -}; - -export function* runECEvaluator( - code: string, - context: Context, - options: any -): Generator<{ steps: number }, void, undefined> { - const theOptions = merge({ ...DEFAULT_SOURCE_OPTIONS }, options); - const program = parse(code, context); - - if (!program) { - return; - } - - try { - context.runtime.isRunning = true; - context.runtime.control = new Control(program); - context.runtime.stash = new Stash(); - yield* generateCSEMachineStateStream( - context, - context.runtime.control, - context.runtime.stash, - theOptions.envSteps, - theOptions.stepLimit, - theOptions.isPrelude - ); - // eslint-disable-next-line no-useless-catch - } catch (error) { - throw error; - } finally { - context.runtime.isRunning = false; - } -} +import { merge } from 'es-toolkit'; export const DEFAULT_PYTHON_OPTIONS = { variant: 4, @@ -81,23 +16,20 @@ export const DEFAULT_PYTHON_OPTIONS = { }; /** - * Python-flavoured counterpart of {@link runECEvaluator}, driving py-slang's CSE machine - * instead of js-slang's. Mirrors the same "parse -> analyze -> new Control/Stash -> step the - * generator" sequence that py-slang's own PyCseEvaluator (src/conductor/PyCseEvaluator.ts, - * upstream in the py-slang repo) uses internally, except the generator here is stepped one - * tick's worth of steps at a time (via {@link Program.fixedUpdate}) instead of being drained to - * completion in one go. + * Drives py-slang's CSE machine one tick's worth of steps at a time (via + * {@link Program.fixedUpdate}) instead of draining it to completion in one go. Mirrors the same + * "parse -> analyze -> new Control/Stash -> step the generator" sequence py-slang's own + * PyCseEvaluator (upstream in the py-slang repo) uses internally. * - * Unlike {@link runECEvaluator} (a *sync* generator, since js-slang's - * generateCSEMachineStateStream is `function*`), py-slang's generateCSEMachineStateStream is an - * `async function*` — every step requires an `await`. Callers must drive this with - * `for await`/manual `await iterator.next()`, never a bare `.next()`. + * py-slang's generateCSEMachineStateStream is an `async function*` - every step requires an + * `await`. Callers must drive this with `for await`/manual `await iterator.next()`, never a bare + * `.next()`. * - * The `context` here is a py-slang `Context`, entirely distinct from js-slang's `Context` used by - * `runECEvaluator` — it is NOT sourced from any 'js-slang/context'-style runtime injection (no such - * convention exists for py-slang). It is built by this bundle itself, in pythonRuntime.ts's - * `createRobotPythonContext()`, which seeds it with the SICPy builtins plus the `ev3_*` robot API; - * `createPythonCSE()` in helper_functions.ts is the caller that puts the two together. + * `@sourceacademy/py-slang` is an ordinary bundled dependency (unlike `js-slang`, which + * buildtools always excludes from the bundle via esbuild's `external: ['js-slang*']` - see + * Program.ts's doc comment for why that rules out a js-slang-based sibling of this function for + * now), so everything here loads the same way inside Conductor's runner Worker as any other + * library code this bundle uses. */ export async function* runPythonECEvaluator( code: string, diff --git a/src/bundles/robot_simulation/src/controllers/program/pythonRuntime.ts b/src/bundles/robot_simulation/src/controllers/program/pythonRuntime.ts index 48ddedbac3..b1dcd2dd71 100644 --- a/src/bundles/robot_simulation/src/controllers/program/pythonRuntime.ts +++ b/src/bundles/robot_simulation/src/controllers/program/pythonRuntime.ts @@ -4,11 +4,11 @@ import { type BuiltinValue, type Value, } from '@sourceacademy/py-slang'; +import type { World } from '../../engine/World'; +import type { Ev3Functions } from '../../ev3_functions'; import type { Motor } from '../ev3/components/Motor'; import type { ColorSensor } from '../ev3/sensor/ColorSensor'; import type { UltrasonicSensor } from '../ev3/sensor/UltrasonicSensor'; -import * as ev3 from '../../ev3_functions'; -import { getWorldFromContext } from '../../helper_functions'; /** * Builds the py-slang `Context` that a Python-flavoured {@link Program} evaluates against. @@ -18,29 +18,22 @@ import { getWorldFromContext } from '../../helper_functions'; * `robot_simulation` re-runs the robot's control program *inside* the simulation loop: the * `Program` controller (see Program.ts) owns a CSE machine that is stepped `stepsPerTick` steps * per physics tick, so `ev3_*` calls happen in simulated time rather than all at once. For a - * Source program that machine is js-slang's, and its `Context` arrives for free — the bundle - * imports `js-slang/context`, which the host frontend injects at runtime (esbuild leaves - * `js-slang*` external; see lib/buildtools/src/build/modules/commons.ts). + * Source program that machine would be js-slang's - see Program.ts's doc comment on why that path + * isn't wired up yet. * - * There is no equivalent injection for py-slang: nothing hands this bundle a populated py-slang - * `Context`. So for the Python path the bundle builds its own, here — a `Context` seeded with + * There is no equivalent of the pre-migration 'js-slang/context' injection for py-slang either: + * nothing hands this bundle a populated py-slang `Context` (Conductor's runner Worker only hands + * a `BaseModulePlugin` its `evaluator` - the setup program's own evaluator, an entirely different + * thing from a shadow CSE context for the control program). So for the Python path the bundle + * builds its own, here - a `Context` seeded with * * * the standard SICPy builtins for the chosen chapter (`VARIANT_GROUPS`), and * * the `ev3_*` robot API, wrapped as py-slang `BuiltinValue`s so a Python program can call - * them directly by name (no `import` needed — see the note on module imports below), and + * them directly by name (no `import` needed - see the module-level doc comment in index.ts + * for why `from robot_simulation import ...` inside the *control* program specifically still + * isn't possible even now that the module is a proper Conductor plugin), and * * an output stream routed into the simulation's own Robot Console panel. * - * ## Note on `from robot_simulation import ...` - * - * A Python program running under py-slang's *own* conductor evaluator (`PyCseEvaluator3/4`) - * cannot `import` this bundle: py-slang's CSE machine resolves every import through - * `ModuleLoaderRunnerPlugin`, which requires the bundle to `export default` a - * `BaseModulePlugin` subclass (as csg/rune/curve do). `robot_simulation` is a legacy js-slang - * bundle with named exports only and a tab that reaches into the live `World` object through - * `js-slang/context`, so it has not been migrated to Conductor. Hence: the robot's *control* - * program can be Python (this file), while the *setup* program that calls `init_simulation` is - * still Source. - * * The group preludes (the parts of the SICPy library written in Python itself, e.g. `map`, * `filter`) are deliberately NOT evaluated here: doing so would need an async drain before the * first physics tick. Only the primitive builtins each group defines in TypeScript are @@ -98,11 +91,11 @@ function builtin( } /** - * The `ev3_*` API, as py-slang builtins. Deliberately a straight 1:1 wrapping of - * `ev3_functions.ts` — the exact same functions a Source control program calls — so a Python - * control program and a Source one drive the simulation identically. + * The `ev3_*` API, as py-slang builtins. Deliberately a straight 1:1 wrapping of `ev3` - the + * exact same bound functions the plugin's own module methods call (see index.ts) - so a Python + * control program and the setup program's own `ev3_*` calls drive the simulation identically. */ -function robotBuiltins(): Array<[string, BuiltinValue]> { +function robotBuiltins(ev3: Ev3Functions): Array<[string, BuiltinValue]> { return [ builtin('ev3_motorA', 0, () => opaque(ev3.ev3_motorA())), builtin('ev3_motorB', 0, () => opaque(ev3.ev3_motorB())), @@ -145,7 +138,7 @@ function robotBuiltins(): Array<[string, BuiltinValue]> { * output would go too. The world isn't created yet when this context is built (createPythonCSE * runs inside the `init_simulation` callback), so the lookup is deferred to write time. */ -function robotConsoleStreams(): PyContext['streams'] { +function robotConsoleStreams(getWorld: () => World): PyContext['streams'] { const stdoutStream = new WritableStream({ write(chunk) { const text = String(chunk).replace(/\n$/u, ''); @@ -153,7 +146,7 @@ function robotConsoleStreams(): PyContext['streams'] { return; } try { - getWorldFromContext().robotConsole.log(text, 'source'); + getWorld().robotConsole.log(text, 'source'); } catch { // World not available (e.g. the program printed before init finished) - drop it rather // than killing the tick. @@ -165,9 +158,9 @@ function robotConsoleStreams(): PyContext['streams'] { const message = typeof chunk === 'string' ? chunk - : ((chunk as { message?: string })?.message ?? String(chunk)); + : (chunk as { message?: string })?.message ?? String(chunk); try { - getWorldFromContext().robotConsole.log(message, 'error'); + getWorld().robotConsole.log(message, 'error'); } catch { // See above. } @@ -193,15 +186,23 @@ function robotConsoleStreams(): PyContext['streams'] { reader: stdinStream.getReader(), setNextPrompt: () => {}, }, - } as PyContext['streams']; + }; } /** * Creates a py-slang `Context` for a robot control program: SICPy builtins for * {@link ROBOT_PYTHON_VARIANT}, plus the `ev3_*` robot API, plus output wired to the Robot * Console. + * + * @param ev3 The same bound `ev3_*` functions the plugin's own module methods call (see + * `createEv3Functions` in ev3_functions.ts) - keeps the control program and the setup program + * observing/driving the exact same world. + * @param getWorld Deferred lookup of the current `World` (for console output) - see + * `robotConsoleStreams`'s doc comment for why this can't just be a value. */ export function createRobotPythonContext( + ev3: Ev3Functions, + getWorld: () => World, variant: number = ROBOT_PYTHON_VARIANT ): PyContext { const context = new PyContext(); @@ -212,10 +213,10 @@ export function createRobotPythonContext( context.nativeStorage.builtins.set(name, value); } } - for (const [name, value] of robotBuiltins()) { + for (const [name, value] of robotBuiltins(ev3)) { context.nativeStorage.builtins.set(name, value); } - context.streams = robotConsoleStreams(); + context.streams = robotConsoleStreams(getWorld); return context; } diff --git a/src/bundles/robot_simulation/src/engine/Physics.ts b/src/bundles/robot_simulation/src/engine/Physics.ts index 88633705c5..2612ce82ae 100644 --- a/src/bundles/robot_simulation/src/engine/Physics.ts +++ b/src/bundles/robot_simulation/src/engine/Physics.ts @@ -1,5 +1,5 @@ import rapier from '@dimforge/rapier3d-compat'; -import { GeneralRuntimeError } from '@sourceacademy/modules-lib/errors'; +import { EvaluatorRuntimeError } from '@sourceacademy/conductor/common'; import type * as THREE from 'three'; import { TypedEventTarget } from './Core/Events'; @@ -49,6 +49,15 @@ export class Physics extends TypedEventTarget { configuration: PhysicsConfig; internals: PhysicsInternals; + /** + * Surface colors keyed by rapier collider handle - populated by Cuboid when it registers a + * physical surface (see Cuboid.ts). Lets the (now raycast-based, worker-safe) ColorSensor + * answer "what color is directly below the sensor" without any GPU rendering - see + * ColorSensor.ts's doc comment for why the pre-migration camera-render approach couldn't move + * into the worker unchanged. + */ + private readonly colliderColors = new Map(); + constructor(configuration: PhysicsConfig) { super(); this.configuration = configuration; @@ -57,6 +66,14 @@ export class Physics extends TypedEventTarget { this.internals = { initialized: false }; } + registerColor(collider: rapier.Collider, color: string): void { + this.colliderColors.set(collider.handle, color); + } + + getColor(collider: rapier.Collider): string | undefined { + return this.colliderColors.get(collider.handle); + } + async start() { await rapier.init(); @@ -75,7 +92,7 @@ export class Physics extends TypedEventTarget { createRigidBody(rigidBodyDesc: rapier.RigidBodyDesc): rapier.RigidBody { if (this.internals.initialized === false) { - throw new GeneralRuntimeError("Physics engine hasn't been initialized yet"); + throw new EvaluatorRuntimeError("Physics engine hasn't been initialized yet"); } return this.internals.world.createRigidBody(rigidBodyDesc); @@ -86,7 +103,7 @@ export class Physics extends TypedEventTarget { rigidBody: rapier.RigidBody, ): rapier.Collider { if (this.internals.initialized === false) { - throw new GeneralRuntimeError("Physics engine hasn't been initialized yet"); + throw new EvaluatorRuntimeError("Physics engine hasn't been initialized yet"); } return this.internals.world.createCollider(colliderDesc, rigidBody); } @@ -99,9 +116,10 @@ export class Physics extends TypedEventTarget { ): { distance: number; normal: SimpleVector; + collider: rapier.Collider; } | null { if (this.internals.initialized === false) { - throw new GeneralRuntimeError("Physics engine hasn't been initialized yet"); + throw new EvaluatorRuntimeError("Physics engine hasn't been initialized yet"); } const ray = new this.RAPIER.Ray(globalPosition, globalDirection); @@ -125,12 +143,13 @@ export class Physics extends TypedEventTarget { return { distance: result.toi, normal: result.normal, + collider: result.collider, }; } step(timing: FrameTimingInfo): PhysicsTimingInfo { if (this.internals.initialized === false) { - throw new GeneralRuntimeError("Physics engine hasn't been initialized yet"); + throw new EvaluatorRuntimeError("Physics engine hasn't been initialized yet"); } const maxFrameTime = 0.05; diff --git a/src/bundles/robot_simulation/src/engine/Render/Renderer.ts b/src/bundles/robot_simulation/src/engine/Render/Renderer.ts deleted file mode 100644 index 5d2cab8481..0000000000 --- a/src/bundles/robot_simulation/src/engine/Render/Renderer.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as THREE from 'three'; -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; -import { - GLTFLoader, - type GLTF, -} from 'three/examples/jsm/loaders/GLTFLoader.js'; -import type { FrameTimingInfo } from '../Core/Timer'; - -type ControlType = 'none' | 'orbit'; - -export type RenderConfig = { - width: number; - height: number; - control: ControlType; -}; - -export class Renderer { - element?: HTMLElement; - - #scene: THREE.Scene; - #camera: THREE.Camera; - #renderer: THREE.WebGLRenderer; - #controls: OrbitControls; - - constructor( - scene: THREE.Scene, - camera: THREE.Camera, - configuration: RenderConfig, - ) { - this.#camera = camera; - this.#scene = scene; - this.#renderer = new THREE.WebGLRenderer({ antialias: true }); - this.#renderer.shadowMap.enabled = true; - - this.#controls = new OrbitControls(this.#camera, this.#renderer.domElement); - - this.#renderer.setSize(configuration.width, configuration.height); - this.#renderer.setPixelRatio(window.devicePixelRatio * 1.5); - - const light = new THREE.PointLight(0xffffff, 1); - const ambient = new THREE.AmbientLight(0xffffff, 0.20); - light.position.set(0, 1, 0); - this.#scene.add(light); - this.#scene.add(ambient); - this.#scene.background = new THREE.Color(0xffffff); - } - - static loadGTLF(url: string): Promise { - const loader = new GLTFLoader(); - return new Promise((resolve, reject) => { - loader.load(url, resolve, () => {}, reject); - }); - } - - scene(): THREE.Scene { - return this.#scene; - } - - render() { - return this.#renderer.render(this.#scene, this.#camera); - } - - getElement(): HTMLCanvasElement { - return this.#renderer.domElement; - } - - add(...input: Parameters): ReturnType { - return this.#scene.add(...input); - } - - step(_: FrameTimingInfo) { - this.render(); - this.#controls.update(); - } -} diff --git a/src/bundles/robot_simulation/src/engine/Render/SceneRegistry.ts b/src/bundles/robot_simulation/src/engine/Render/SceneRegistry.ts new file mode 100644 index 0000000000..3646ed955a --- /dev/null +++ b/src/bundles/robot_simulation/src/engine/Render/SceneRegistry.ts @@ -0,0 +1,63 @@ +import * as THREE from 'three'; +import type { EntityDescriptor, EntitySpawnedMessage } from '../../protocol'; + +/** + * Worker-safe replacement for the pre-migration `Renderer`. Physics/control-program code + * (Cuboid, Chassis, Motor, Mesh, Paper, ...) runs inside Conductor's runner Web Worker, which has + * no DOM/WebGL - so nothing here ever touches `THREE.WebGLRenderer`, `OrbitControls`, or a + * canvas. `add()` allocates a bare `THREE.Object3D` transform node (safe in a worker - it's just + * a plain JS scene-graph node, no rendering happens) that calling code positions exactly as it + * always positioned its old `THREE.Mesh`/`GLTF.scene`, plus a serializable {@link EntityDescriptor} + * describing what the entity should actually look like. The RobotSimulation tab (main thread, + * real DOM/WebGL) builds the real THREE geometry from that descriptor and keeps it positioned at + * this node's transform via the state channel - see protocol.ts. + */ +export class SceneRegistry { + private nextId = 1; + private readonly nodes = new Map(); + private readonly spawned: EntitySpawnedMessage[] = []; + private onSpawn: ((message: EntitySpawnedMessage) => void) | undefined; + + /** Called for every future `add()`, and (via {@link replaySpawns}) for every past one. */ + setSpawnListener(listener: (message: EntitySpawnedMessage) => void): void { + this.onSpawn = listener; + } + + /** Replays every entity spawned so far - for a tab that (re)connects after entities already + exist, mirrors csg/rune's render backlog replay. */ + replaySpawns(): void { + if (!this.onSpawn) return; + for (const message of this.spawned) this.onSpawn(message); + } + + add(descriptor: EntityDescriptor): THREE.Object3D { + const id = this.nextId++; + const node = new THREE.Object3D(); + this.nodes.set(id, node); + const message: EntitySpawnedMessage = { kind: 'entity-spawned', id, descriptor }; + this.spawned.push(message); + this.onSpawn?.(message); + return node; + } + + /** Serializes every tracked node's current transform into a flat `Float32Array`, laid out as + `[id, px, py, pz, qx, qy, qz, qw] * N` - sent as a transferable over the state channel. */ + snapshot(): Float32Array { + const stride = 8; + const buffer = new Float32Array(this.nodes.size * stride); + let i = 0; + for (const [id, node] of this.nodes) { + const offset = i * stride; + buffer[offset] = id; + buffer[offset + 1] = node.position.x; + buffer[offset + 2] = node.position.y; + buffer[offset + 3] = node.position.z; + buffer[offset + 4] = node.quaternion.x; + buffer[offset + 5] = node.quaternion.y; + buffer[offset + 6] = node.quaternion.z; + buffer[offset + 7] = node.quaternion.w; + i += 1; + } + return buffer; + } +} diff --git a/src/bundles/robot_simulation/src/engine/Render/helpers/Camera.ts b/src/bundles/robot_simulation/src/engine/Render/helpers/Camera.ts index 5b3a0ead92..b08e2ce1db 100644 --- a/src/bundles/robot_simulation/src/engine/Render/helpers/Camera.ts +++ b/src/bundles/robot_simulation/src/engine/Render/helpers/Camera.ts @@ -1,4 +1,4 @@ -import { InternalRuntimeError } from '@sourceacademy/modules-lib/errors'; +import { EvaluatorRuntimeError } from '@sourceacademy/conductor/common'; import * as THREE from 'three'; type OrthographicCameraOptions = { @@ -42,7 +42,7 @@ export function getCamera(cameraOptions: CameraOptions): THREE.Camera { } default: { // @ts-expect-error Ignore the never - throw new InternalRuntimeError(`Unknown camera type: ${cameraOptions.type}`); + throw new EvaluatorRuntimeError(`Unknown camera type: ${cameraOptions.type}`); } } } diff --git a/src/bundles/robot_simulation/src/engine/World.ts b/src/bundles/robot_simulation/src/engine/World.ts index b5dd35a2e9..3cfde942a1 100644 --- a/src/bundles/robot_simulation/src/engine/World.ts +++ b/src/bundles/robot_simulation/src/engine/World.ts @@ -4,7 +4,6 @@ import { TypedEventTarget } from './Core/Events'; import type { RobotConsole } from './Core/RobotConsole'; import type { Timer } from './Core/Timer'; import { TimeStampedEvent, type Physics } from './Physics'; -import type { Renderer } from './Render/Renderer'; export const worldStates = [ 'unintialized', @@ -22,24 +21,26 @@ type WorldEventMap = { afterRender: TimeStampedEvent; }; +/** Ticks the world loop at roughly the display refresh rate `requestAnimationFrame` used to + * drive it at. A worker has no `window`/`requestAnimationFrame` (see World's doc history in + * PR #947) - a plain interval is the direct, low-risk substitute: physics itself still steps at + * its own configured timestep via Physics's accumulator (see Physics.ts), this just sets how + often that accumulator gets a chance to drain. */ +const TICK_INTERVAL_MS = 1000 / 60; + export class World extends TypedEventTarget { state: WorldState; physics: Physics; - render: Renderer; timer: Timer; robotConsole: RobotConsole; controllers: ControllerGroup; - constructor( - physics: Physics, - render: Renderer, - timer: Timer, - robotConsole: RobotConsole - ) { + private intervalId: ReturnType | null = null; + + constructor(physics: Physics, timer: Timer, robotConsole: RobotConsole) { super(); this.state = 'unintialized'; this.physics = physics; - this.render = render; this.timer = timer; this.controllers = new ControllerGroup(); this.robotConsole = robotConsole; @@ -84,12 +85,20 @@ export class World extends TypedEventTarget { pause() { this.setState('ready'); this.timer.pause(); + this.stopInterval(); } start() { if (this.state === 'ready') { this.setState('running'); - window.requestAnimationFrame(this.step.bind(this)); + this.intervalId ??= setInterval(() => this.step(performance.now()), TICK_INTERVAL_MS); + } + } + + private stopInterval() { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; } } @@ -99,20 +108,14 @@ export class World extends TypedEventTarget { const physicsTimingInfo = this.physics.step(frameTimingInfo); - // Update render this.dispatchEvent( 'beforeRender', new TimeStampedEvent('beforeRender', physicsTimingInfo) ); - this.render.step(frameTimingInfo); this.dispatchEvent( 'afterRender', new TimeStampedEvent('afterRender', physicsTimingInfo) ); - - if (this.state === 'running') { - window.requestAnimationFrame(this.step.bind(this)); - } } catch (e) { console.log('Error caught', e); if (e instanceof Error) { @@ -124,6 +127,7 @@ export class World extends TypedEventTarget { this.robotConsole.log('An error occurred', 'error'); } this.setState('error'); + this.stopInterval(); } } } diff --git a/src/bundles/robot_simulation/src/engine/index.ts b/src/bundles/robot_simulation/src/engine/index.ts index 9e27f6dd54..ce3b935f26 100644 --- a/src/bundles/robot_simulation/src/engine/index.ts +++ b/src/bundles/robot_simulation/src/engine/index.ts @@ -1,8 +1,12 @@ export { World } from './World'; export { Physics } from './Physics'; -export { Renderer } from './Render/Renderer'; +export { SceneRegistry } from './Render/SceneRegistry'; export { Timer, type FrameTimingInfo } from './Core/Timer'; +export { RobotConsole } from './Core/RobotConsole'; export { ControllerGroup, type Controller, ControllerMap } from './Core/Controller'; export { Entity } from './Entity/Entity'; export * as EntityFactory from './Entity/EntityFactory'; export * as MeshFactory from './Render/helpers/MeshFactory'; +export { getCamera, type CameraOptions } from './Render/helpers/Camera'; +export { loadGLTF } from './Render/helpers/GLTF'; +export { createScene } from './Render/helpers/Scene'; diff --git a/src/bundles/robot_simulation/src/ev3_functions.ts b/src/bundles/robot_simulation/src/ev3_functions.ts index d87f83c5e8..94137ed138 100644 --- a/src/bundles/robot_simulation/src/ev3_functions.ts +++ b/src/bundles/robot_simulation/src/ev3_functions.ts @@ -1,13 +1,13 @@ import type { Motor } from './controllers/ev3/components/Motor'; import { motorConfig } from './controllers/ev3/ev3/default/config'; +import type { DefaultEv3 } from './controllers/ev3/ev3/default/ev3'; import type { ColorSensor } from './controllers/ev3/sensor/ColorSensor'; import type { UltrasonicSensor } from './controllers/ev3/sensor/UltrasonicSensor'; import { program_controller_identifier, type Program, } from './controllers/program/Program'; - -import { getEv3FromContext, getWorldFromContext } from './helper_functions'; +import type { World } from './engine/World'; type MotorFunctionReturnType = Motor | null; @@ -19,218 +19,116 @@ type MotorFunctionReturnType = Motor | null; */ /** - * Pauses for a period of time. - * - * @param duration The time to wait, in milliseconds. - * - * @category EV3 - */ -export function ev3_pause(duration: number): void { - const world = getWorldFromContext(); - const program = world.controllers.controllers.find((controller) => controller.name === program_controller_identifier) as Program; - program.pause(duration); -} - -/** - * Gets the motor connected to port A. - * - * @returns The motor connected to port A - * - * @category EV3 - */ -export function ev3_motorA(): MotorFunctionReturnType { - const ev3 = getEv3FromContext(); - return ev3.get('leftMotor'); + * The `ev3_*` API needs to be reachable from two entirely different call paths: + * + * - As ordinary `BaseModulePlugin` methods, when the *setup* program (any Conductor language - + * Source, Python, Scheme) calls them directly. Those go through `TypedValue`/`opaque_get` + * wrapping (see index.ts). + * - As plain synchronous native builtins, when the robot's *control* program (a `Program` + * controller's own private shadow CSE machine - see pythonRuntime.ts) calls them. That machine + * is not driven through Conductor's evaluator at all, so it needs the exact same underlying + * logic as a bare, synchronous function. + * + * Both paths need to read/write the *same* world/ev3 instance for one running program, which - + * now that this bundle is a plugin instance rather than a `context.moduleContexts` singleton - + * lives on that plugin instance. Rather than duplicate the logic once per call path, this factory + * takes small accessors for "the current world" / "the current ev3" and returns bound functions + * both call paths can share unchanged. + */ +export function createEv3Functions(deps: { + getWorld: () => World; + getEv3: () => DefaultEv3; +}) { + const { getWorld, getEv3 } = deps; + + return { + /** + * Pauses for a period of time. + * @param duration The time to wait, in milliseconds. + */ + ev3_pause(duration: number): void { + const world = getWorld(); + const program = world.controllers.controllers.find((controller) => controller.name === program_controller_identifier) as Program; + program.pause(duration); + }, + + /** Gets the motor connected to port A. */ + ev3_motorA(): MotorFunctionReturnType { + return getEv3().get('leftMotor'); + }, + + /** Gets the motor connected to port B. */ + ev3_motorB(): MotorFunctionReturnType { + return getEv3().get('rightMotor'); + }, + + /** Gets the motor connected to port C. */ + ev3_motorC(): MotorFunctionReturnType { + return null; + }, + + /** Gets the motor connected to port D. */ + ev3_motorD(): MotorFunctionReturnType { + return null; + }, + + /** + * Causes the motor to rotate until the position reaches ev3_motorGetPosition() + position + * with the given speed. Note: this works by sending instructions to the motors. This will + * return almost immediately, without waiting for the motor to reach the given absolute + * position. If you wish to wait, use ev3_pause. + */ + ev3_runToRelativePosition( + motor: MotorFunctionReturnType, + position: number, + speed: number + ): void { + if (motor === null) { + return; + } + + const wheelDiameter = motorConfig.config.mesh.dimension.height; + const speedInMetersPerSecond = (speed / 360) * Math.PI * wheelDiameter; + const distanceInMetersPerSecond = (position / 360) * Math.PI * wheelDiameter; + + motor.setSpeedDistance(speedInMetersPerSecond, distanceInMetersPerSecond); + }, + + /** Gets the colour sensor connected any of ports 1, 2, 3 or 4. */ + ev3_colorSensor(): ColorSensor { + return getEv3().get('colorSensor'); + }, + + /** Gets the amount of red seen by the colour sensor. */ + ev3_colorSensorRed(colorSensor: ColorSensor): number { + return colorSensor.sense().r; + }, + + /** Gets the amount of green seen by the colour sensor. */ + ev3_colorSensorGreen(colorSensor: ColorSensor): number { + return colorSensor.sense().g; + }, + + /** Gets the amount of blue seen by the colour sensor. */ + ev3_colorSensorBlue(colorSensor: ColorSensor): number { + return colorSensor.sense().b; + }, + + /** Gets the ultrasonic sensor connected any of ports 1, 2, 3 or 4. */ + ev3_ultrasonicSensor(): UltrasonicSensor { + return getEv3().get('ultrasonicSensor'); + }, + + /** Gets the distance read by the ultrasonic sensor in centimeters. */ + ev3_ultrasonicSensorDistance(ultraSonicSensor: UltrasonicSensor): number { + return ultraSonicSensor.sense(); + }, + + /** Checks if the peripheral is connected. */ + ev3_connected(obj: unknown): boolean { + return obj !== null; + }, + }; } -/** - * Gets the motor connected to port B. - * - * @returns The motor connected to port B - * - * @category EV3 - */ -export function ev3_motorB(): MotorFunctionReturnType { - const ev3 = getEv3FromContext(); - return ev3.get('rightMotor'); -} - -/** - * Gets the motor connected to port C. - * - * @returns The motor connected to port C - * - * @category EV3 - */ -export function ev3_motorC(): MotorFunctionReturnType { - return null; -} - -/** - * Gets the motor connected to port D. - * - * @returns The motor connected to port D - * - * @category EV3 - */ -export function ev3_motorD(): MotorFunctionReturnType { - return null; -} - -/** - * Causes the motor to rotate until the position reaches ev3_motorGetPosition() + position with the given speed. - * Note: this works by sending instructions to the motors. - * This will return almost immediately, without waiting for the motor to reach the given absolute position. - * If you wish to wait, use ev3_pause. - * - * @param motor The motor - * @param position The amount to turn - * @param speed The speed to run at, in tacho counts per second - * - * @category EV3 - */ -export function ev3_runToRelativePosition( - motor: MotorFunctionReturnType, - position: number, - speed: number -): void { - if (motor === null) { - return; - } - - const wheelDiameter = motorConfig.config.mesh.dimension.height; - const speedInMetersPerSecond = (speed / 360) * Math.PI * wheelDiameter; - const distanceInMetersPerSecond = (position / 360) * Math.PI * wheelDiameter; - - motor.setSpeedDistance(speedInMetersPerSecond, distanceInMetersPerSecond); -} - -/** - * Causes the motor to rotate for a specified duration at the specified speed. - * - * Note: this works by sending instructions to the motors. This will return almost immediately, - * without waiting for the motor to actually run for the specified duration. - * If you wish to wait, use ev3_pause. - * - * @param motor - * @param time - * @param speed - * @returns void - * - * @category EV3 - */ -export function ev3_runForTime( - motor: MotorFunctionReturnType, - time: number, - speed: number -) { - if (motor === null) { - return; - } - const wheelDiameter = motorConfig.config.mesh.dimension.height; - const speedInMetersPerSecond = (speed / 360) * Math.PI * wheelDiameter; - const distanceInMetersPerSecond = speedInMetersPerSecond * time; - - motor.setSpeedDistance(speedInMetersPerSecond, distanceInMetersPerSecond); -} - -/** - * Gets the motor's current speed, in tacho counts per second. - * - * Returns 0 if the motor is not connected. - * - * @param motor - * @returns number - * - * @category EV3 - */ -export function ev3_motorGetSpeed(motor: MotorFunctionReturnType): number { - if (motor === null) { - return 0; - } - - return motor.motorVelocity; -} - -/** - * Gets the colour sensor connected any of ports 1, 2, 3 or 4. - * - * @returns The colour sensor - * - * @category EV3 - */ -export function ev3_colorSensor() { - const ev3 = getEv3FromContext(); - return ev3.get('colorSensor'); -} - -/** - * Gets the amount of red seen by the colour sensor. - * - * @param colorSensor The color sensor - * @returns The amount of blue, in sensor-specific units. - * - * @category EV3 - */ -export function ev3_colorSensorRed(colorSensor: ColorSensor) { - return colorSensor.sense().r; -} - -/** - * Gets the amount of green seen by the colour sensor. - * - * @param colorSensor The color sensor - * @returns The amount of green, in sensor-specific units. - * - * @category EV3 - */ -export function ev3_colorSensorGreen(colorSensor: ColorSensor) { - return colorSensor.sense().g; -} - -/** - * Gets the amount of blue seen by the colour sensor. - * - * @param colorSensor The color sensor - * @returns The amount of blue, in sensor-specific units. - * - * @category EV3 - */ -export function ev3_colorSensorBlue(colorSensor: ColorSensor) { - return colorSensor.sense().b; -} - -/** - * Gets the ultrasonic sensor connected any of ports 1, 2, 3 or 4. - * - * @returns The ultrasonic sensor - * - * @category EV3 - */ -export function ev3_ultrasonicSensor() { - const ev3 = getEv3FromContext(); - return ev3.get('ultrasonicSensor'); -} -/** - * Gets the distance read by the ultrasonic sensor in centimeters. - * - * @param ultraSonicSensor The ultrasonic sensor - * @returns The distance, in centimeters. - * - * @category EV3 - */ -export function ev3_ultrasonicSensorDistance(ultraSonicSensor: UltrasonicSensor): number { - return ultraSonicSensor.sense(); -} - -/** - * Checks if the peripheral is connected. - * - * @param obj The peripheral to check. - * @returns boolean - * - * @category EV3 - */ -export function ev3_connected(obj: any) { - return obj !== null; -} +export type Ev3Functions = ReturnType; diff --git a/src/bundles/robot_simulation/src/helper_functions.ts b/src/bundles/robot_simulation/src/helper_functions.ts deleted file mode 100644 index c984f9226c..0000000000 --- a/src/bundles/robot_simulation/src/helper_functions.ts +++ /dev/null @@ -1,588 +0,0 @@ -import { GeneralRuntimeError } from '@sourceacademy/modules-lib/errors'; -import { interrupt } from '@sourceacademy/modules-lib/specialErrors'; -import context from 'js-slang/context'; -import { sceneConfig } from './config'; -import { Cuboid, type CuboidConfig } from './controllers/environment/Cuboid'; -import { Paper, type PaperConfig } from './controllers/environment/Paper'; -import { ev3Config } from './controllers/ev3/ev3/default/config'; -import { - createDefaultEv3, - type DefaultEv3, -} from './controllers/ev3/ev3/default/ev3'; -import { Program } from './controllers/program/Program'; -import { createRobotPythonContext } from './controllers/program/pythonRuntime'; -import { Physics, Renderer, Timer, World, type Controller } from './engine'; - -import { RobotConsole } from './engine/Core/RobotConsole'; -import { isRigidBodyType } from './engine/Entity/EntityFactory'; -import type { PhysicsConfig } from './engine/Physics'; -import type { RenderConfig } from './engine/Render/Renderer'; -import { getCamera, type CameraOptions } from './engine/Render/helpers/Camera'; -import { createScene } from './engine/Render/helpers/Scene'; - -/** - * @categoryDescription Configuration - * These functions are use to configure the simulation world. - * @module - */ - -/** - * A helper function that retrieves the world from the context - * - * @private - * @category helper - */ -export function getWorldFromContext(): World { - const world = context.moduleContexts.robot_simulation.state?.world; - if (world === undefined) { - throw new GeneralRuntimeError('World not initialized'); - } - return world as World; -} - -/** - * A helper function that retrieves the EV3 from context - * - * @private - * @category helper - */ -export function getEv3FromContext(): DefaultEv3 { - const ev3 = context.moduleContexts.robot_simulation.state?.ev3; - if (ev3 === undefined) { - throw new GeneralRuntimeError('ev3 not initialized'); - } - return ev3 as DefaultEv3; -} - -/** - * Create a physics engine with the provided gravity and timestep. A physics engine - * with default gravity and timestep can be created using {@link createPhysics}. - * - * The returned Physics object is designed to be passed into {@link createWorld}. - * - * **This is a configuration function and should be called within {@link init_simulation}.** - * - * @param gravity The gravity of the world - * @param timestep The timestep of the world - * @returns Physics - * - * @category Configuration - */ -export function createCustomPhysics( - gravity: number, - timestep: number -): Physics { - const physicsConfig: PhysicsConfig = { - gravity: { - x: 0, - y: gravity, - z: 0, - }, - timestep, - }; - const physics = new Physics(physicsConfig); - return physics; -} - -/** - * Create a physics engine with default gravity and timestep. Default gravity is -9.81 and timestep is 1/20. - * A custom physics engine can be created using {@link createCustomPhysics}. - * - * The returned Physics object is designed to be passed into {@link createWorld}. - * - * **This is a configuration function and should be called within {@link init_simulation}.** - * - * @returns Physics - * - * @category Configuration - */ -export function createPhysics(): Physics { - return createCustomPhysics(-9.81, 1 / 20); -} - -/** - * Creates a renderer for the simulation. - * - * The returned Renderer object is designed to be passed into {@link createWorld}. - * - * **This is a configuration function and should be called within {@link init_simulation}.** - * - * @returns Renderer - * - * @category Configuration - */ -export function createRenderer(): Renderer { - const sceneCameraOptions: CameraOptions = { - type: 'perspective', - aspect: sceneConfig.width / sceneConfig.height, - fov: 75, - near: 0.1, - far: 1000, - }; - - const renderConfig: RenderConfig = { - width: sceneConfig.width, - height: sceneConfig.height, - control: 'orbit', - }; - - const scene = createScene(); - const camera = getCamera(sceneCameraOptions); - const renderer = new Renderer(scene, camera, renderConfig); - return renderer; -} - -/** - * Creates a Timer for the simulation. - * - * The returned Timer object is designed to be passed into {@link createWorld}. - * - * **This is a configuration function and should be called within {@link init_simulation}.** - * - * @returns Timer - * - * @category Configuration - */ -export function createTimer(): Timer { - const timer = new Timer(); - return timer; -} - -/** - * Creates a RobotConsole for the simulation. - * - * The RobotConsole is used to display messages and errors to the user. The console - * messages can be seen in the console tab of the simulator. - * - * The returned RobotConsole object is designed to be passed into {@link createWorld}. - * - * **This is a configuration function and should be called within {@link init_simulation}.** - * - * @returns RobotConsole - * - * @category Configuration - */ -export function createRobotConsole(): RobotConsole { - const robot_console = new RobotConsole(); - return robot_console; -} - -/** - * Creates a custom world with the provided {@link createPhysics | physics}, {@link createRenderer | renderer}, {@link createTimer | timer} and {@link createRobotConsole | console} . - * - * A world is responsible for managing the physics, rendering, timing and console of the simulation. - * It also manages the controllers that are added to the world, ensuring that the appropriate functions - * are called at the correct time. - * - * The returned World object is designed to be returned by the {@link init_simulation} callback. - * - * You can add controllers to the world using {@link addControllerToWorld}. - * - * **This is a configuration function and should be called within {@link init_simulation}.** - * - * @example - * An empty simulation - * ``` - * init_simulation(() => { - * const physics = createPhysics(); - * const renderer = createRenderer(); - * const timer = createTimer(); - * const robot_console = createRobotConsole(); - * const world = createWorld(physics, renderer, timer, robot_console); - * - * return world; - * }); - * ``` - * - * @param physics The physics engine of the world. See {@link createPhysics} - * @param renderer The renderer engine of the world. See {@link createRenderer} - * @param timer The timer of the world. See {@link createTimer} - * @param robotConsole The console of the world. See {@link createRobotConsole} - * @returns World - * - * @category Configuration - */ -export function createWorld( - physics: Physics, - renderer: Renderer, - timer: Timer, - robotConsole: RobotConsole -): World { - const world = new World(physics, renderer, timer, robotConsole); - return world; -} - -/** - * Creates a cuboid. joel-todo: The dynamic version wont work - * - * This function is used to create the {@link createFloor | floor} and {@link createWall | wall} controllers. - * - * The returned Cuboid object is designed to be added to the world using {@link addControllerToWorld}. - * - * **This is a Controller function and should be called within {@link init_simulation}.** - * - * @param physics The physics engine passed to the world - * @param renderer The renderer engine of the world. See {@link createRenderer} - * @param position_x The x position of the cuboid - * @param position_y The y position of the cuboid - * @param position_z The z position of the cuboid - * @param width The width of the cuboid in meters - * @param length The length of the cuboid in meters - * @param height The height of the cuboid in meters - * @param mass The mass of the cuboid in kg - * @param color The color of the cuboid. Can be a hex code or a string. See {@link https://threejs.org/docs/#api/en/math/Color} - * @param bodyType "rigid" or "dynamic". Determines if the cuboid is fixed or can move. - * @returns Cuboid - * - * @example - * ``` - * init_simulation(() => { - * const physics = createPhysics(); - * const renderer = createRenderer(); - * const timer = createTimer(); - * const robot_console = createRobotConsole(); - * const world = createWorld(physics, renderer, timer, robot_console); - * - * const cuboid = createCuboid(); - * addControllerToWorld(cuboid, world); - * - * return world; - * }); - * ``` - * - * @category Controller - */ -export function createCuboid( - physics: Physics, - renderer: Renderer, - position_x: number, - position_y: number, - position_z: number, - width: number, - length: number, - height: number, - mass: number, - color: number | string, - bodyType: string -) { - if (isRigidBodyType(bodyType) === false) { - throw new GeneralRuntimeError('Invalid body type'); - } - - const config: CuboidConfig = { - position: { - x: position_x, - y: position_y, - z: position_z, - }, - dimension: { - height, - width, - length, - }, - mass, - color, - type: bodyType - }; - - const cuboid = new Cuboid(physics, renderer, config); - return cuboid; -} - -/** - * Create a floor. This function is a wrapper around {@link createCuboid}. - * - * The returned Cuboid object is designed to be added to the world using {@link addControllerToWorld}. - * - * **This is a Controller function and should be called within {@link init_simulation}.** - * - * @param physics The physics engine of the world. See {@link createPhysics} - * @param renderer The renderer engine of the world. See {@link createRenderer} - * @returns Cuboid - * - * @category Controller - */ -export function createFloor(physics: Physics, renderer: Renderer) { - const floor = createCuboid( - physics, - renderer, - 0, // position_x - -0.5, // position_y - 0, // position_z - 20, // width - 20, // length - 1, // height - 1, // mass - 'white', // color - 'fixed' // bodyType - ); - return floor; -} - -/** - * Creates a wall. This function is a wrapper around {@link createCuboid}. - * - * The returned Cuboid object is designed to be added to the world using {@link addControllerToWorld}. - * - * **This is a Controller function and should be called within {@link init_simulation}.** - * - * @param physics The physics engine of the world. See {@link createPhysics} - * @param renderer The renderer engine of the world. See {@link createRenderer} - * @param x The x position of the wall - * @param y The y position of the wall - * @param width The width of the wall in meters - * @param length The length of the wall in meters - * @param height The height of the wall in meters - * @returns Cuboid - * - * @category Controller - */ -export function createWall( - physics: Physics, - renderer: Renderer, - x: number, - y: number, - width: number, - length: number, - height: number -) { - const wall = createCuboid( - physics, - renderer, - x, // position_x - height / 2, - y, // position_y - width, // width - length, // length - height, // height - 1, // mass - 'yellow', // color - 'fixed' // bodyType - ); - return wall; -} - -/** - * Creates a paper on the floor. - * - * The returned Paper object is designed to be added to the world using {@link addControllerToWorld}. - * - * **This is a Controller function and should be called within {@link init_simulation}.** - * - * @param render The renderer engine of the world. See {@link createRenderer} - * @param url The url of the image to be displayed on the paper. - * @param width The width of the paper in meters. - * @param height The height of the paper in meters. - * @param x The x position of the paper. - * @param y The y position of the paper. - * @param rotation The rotation of the paper in degrees. - * - * @returns Paper - * - * @category Controller - */ -export function createPaper( - render: Renderer, - url: string, - width: number, - height: number, - x: number, - y: number, - rotation: number -) { - const paperConfig: PaperConfig = { - url, - dimension: { - width, - height, - }, - position: { x, y }, - rotation: (rotation * Math.PI) / 180, - }; - const paper = new Paper(render, paperConfig); - return paper; -} - -/** - * Creates a CSE machine as a Program Object. The CSE machine is used to evaluate the code written - * by the user. The execution of the code will be automatically synchronized with the simulation - * to ensure that the code is executed at the correct time. - * - * The returned Program object is designed to be added to the world using {@link addControllerToWorld}. - * - * **This is a Controller function and should be called within {@link init_simulation}.** - * - * @returns Program - * - * @category Controller - * - */ -export function createCSE() { - const code = context.unTypecheckedCode[0]; - const program = new Program(code); - return program; -} - -/** - * Creates a CSE machine as a Program Object, running **Python** instead of Source. - * - * This is the Python counterpart of {@link createCSE}. Where `createCSE` re-runs the surrounding - * Source program (`context.unTypecheckedCode[0]`) as the robot's control program, this takes the - * control program as an explicit string of Python and evaluates it with - * [py-slang](https://github.com/source-academy/py-slang)'s CSE machine, stepped in lockstep with - * the physics tick exactly the same way — so `ev3_pause`, motor commands and sensor reads all - * happen in simulated time rather than instantaneously. - * - * The Python program can call the whole `ev3_*` API directly by name; no `import` is needed (and - * none is possible — see pythonRuntime.ts for why). `print(...)` goes to the simulation's Robot - * Console panel. - * - * The returned Program object is designed to be added to the world using {@link addControllerToWorld}. - * - * **This is a Controller function and should be called within {@link init_simulation}.** - * - * @param code The robot's control program, written in Python (SICPy §4). - * @returns Program - * - * @example - * ``` - * init_simulation(() => { - * const physics = createPhysics(); - * const renderer = createRenderer(); - * const world = createWorld(physics, renderer, createTimer(), createRobotConsole()); - * const ev3 = createEv3(physics, renderer); - * saveToContext('world', world); - * saveToContext('ev3', ev3); - * addControllerToWorld(ev3, world); - * addControllerToWorld(createFloor(physics, renderer), world); - * addControllerToWorld(createPythonCSE( - * "ev3_runToRelativePosition(ev3_motorA(), 1080, 200)\n" + - * "ev3_runToRelativePosition(ev3_motorB(), 1080, 200)\n" - * ), world); - * return world; - * }); - * ``` - * - * @category Controller - */ -export function createPythonCSE(code: string) { - return new Program(code, undefined, 'python', createRobotPythonContext()); -} - -/** - * Add a controller to the world. - * - * The controller is a unit of computation modelled after Unity's MonoBehaviour. It is used to - * encapsulate the logic of the simulation. Controllers can be used to create robots, sensors, - * actuators, and other objects in the simulation. - * - * The controller should be added to the world using this function in order for the simulation to - * access the controller's logic. - * - * **This is a Utility function and should be called within {@link init_simulation}.* - * - * @param controller - * @param world - * - * @category Utility - */ -export function addControllerToWorld(controller: Controller, world: World) { - world.addController(controller); -} - -/** - * Save a value to the context. - * - * There are 2 important values to be saved. The world and the ev3. - * The world needs to be saved in order for the simulation to access the physics, renderer, timer and console. - * The ev3 needs to be saved in order for the "ev3_" functions to access the EV3 - * - * @param key The key to save the value as - * @param value The value to save - * - * @returns void - */ -export function saveToContext(key: string, value: any) { - if (!context.moduleContexts.robot_simulation.state) { - context.moduleContexts.robot_simulation.state = {}; - } - context.moduleContexts.robot_simulation.state[key] = value; -} - -/** - * Create an EV3. - * - * The resulting EV3 should be saved to the context using {@link saveToContext}. - * - * The returned EV3 object is designed to be added to the world using {@link addControllerToWorld}. - * - * **This is a Controller function and should be called within {@link init_simulation}.** - * - * @example - * ``` - * init_simulation(() => { - * // ... other code - * const ev3 = createEv3(physics, renderer); - * saveToContext('ev3', ev3); - * }); - * ``` - * - * @param physics The physics engine of the world. See {@link createPhysics} - * @param renderer The renderer engine of the world. See {@link createRenderer} - * @returns EV3 - */ -export function createEv3(physics: Physics, renderer: Renderer): DefaultEv3 { - const ev3 = createDefaultEv3(physics, renderer, ev3Config); - return ev3; -} - -/** - * Unwraps a value handed back by a Source callback that this bundle called itself. - * - * js-slang's CSE machine represents a Source closure to native (module) code through - * `closureToJS` (js-slang/dist/cse-machine/closure.js): calling it spins up a nested CSE machine, - * drains it, and returns `stash.peek()`. As of js-slang 1.0.94 the value left on that stash is - * the machine's internal tail-call envelope - `{ isTail: false, value: }` - not - * the value itself, so a module that calls a user-supplied callback and then uses the result gets - * the envelope. For `init_simulation` that surfaced as `TypeError: world.init is not a function`, - * i.e. `robot_simulation` failing on its very first call in the real frontend regardless of what - * the user's program does. - * - * That's an upstream js-slang bug (the envelope should be unwrapped before it escapes into native - * code), but it has to be tolerated here for the module to work at all against the shipped - * js-slang. The check is deliberately shape-based and non-destructive: once js-slang unwraps on - * its own side, a real `World` falls straight through this function unchanged. - */ -function unwrapCallbackResult(value: unknown): T { - if ( - value !== null - && typeof value === 'object' - && 'isTail' in value - && 'value' in value - ) { - return (value as { value: T }).value; - } - return value as T; -} - -/** - * Initialize the simulation world. This function is to be called before the robot code. - * This function is used to describe the simulation environment and the controllers. - * - * The callback function takes in no parameters and returns a world created by {@link createWorld}. - * The world should be configured with the physics, renderer, timer and console. - * The controllers should be added to the world using {@link addControllerToWorld}. - * The world should be saved to the context using {@link saveToContext}. - * - * @param worldFactory A callback function that returns the world object. Type signature: () => World - * @returns void - */ -export function init_simulation(worldFactory: () => World) { - const storedWorld = context.moduleContexts.robot_simulation.state?.world; - if (storedWorld !== undefined) { - return; - } - const world = unwrapCallbackResult(worldFactory()); - world.init(); - interrupt(); -} diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index a2efd6840f..00a50c3816 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -1,40 +1,473 @@ /** * Robot simulator for EV3. * + * The entire program - setup (creating the world, floor, EV3, adding controllers) *and* the + * robot's control program - can be written in any Conductor language (Source, Python, Scheme): + * this module is a normal `BaseModulePlugin`, the same shape as csg/rune/curve. It used to only + * be reachable from Source, via a `js-slang/context` import that a Python or Scheme setup program + * has no equivalent of - see PR #947 for that history. + * + * The module itself never touches the DOM/WebGL - it runs inside Conductor's runner Worker, which + * has neither. All rendering (`THREE.WebGLRenderer`, `OrbitControls`, the canvas, the draw loop) + * lives entirely in the RobotSimulation tab; this plugin streams entity transforms to it once per + * physics tick over a dedicated channel, mirroring pix_n_flix's frame channel - see protocol.ts + * and SceneRegistry's doc comment for the full design. + * + * `from robot_simulation import ...` still only works for the *setup* program, not the robot's + * own control program string passed to `createPythonCSE`: that string is evaluated by a private, + * hand-built py-slang `Context` (see controllers/program/pythonRuntime.ts) stepped in lockstep + * with the physics tick, entirely separate from Conductor's own evaluator/module-loading + * machinery - there is no `ModuleLoaderRunnerPlugin` inside that shadow context for an `import` to + * resolve through. The `ev3_*` API is available to it directly by name instead (no import). + * * @module robot_simulation * @author Joel Chan */ +import { EvaluatorParameterTypeError, EvaluatorRuntimeError } from '@sourceacademy/conductor/common'; +import { makeRpc, type IChannel, type IConduit } from '@sourceacademy/conductor/conduit'; +import { BaseModulePlugin } from '@sourceacademy/conductor/module'; +import type { IInterfacableEvaluator } from '@sourceacademy/conductor/runner'; +import { DataType, type TypedValue } from '@sourceacademy/conductor/types'; +import { attachModuleMethod } from '@sourceacademy/modules-lib/conductor/methods'; + +import { Cuboid, type CuboidConfig } from './controllers/environment/Cuboid'; +import { Paper } from './controllers/environment/Paper'; +import type { Motor } from './controllers/ev3/components/Motor'; +import { ev3Config } from './controllers/ev3/ev3/default/config'; +import { createDefaultEv3, type DefaultEv3 } from './controllers/ev3/ev3/default/ev3'; +import type { ColorSensor } from './controllers/ev3/sensor/ColorSensor'; +import type { UltrasonicSensor } from './controllers/ev3/sensor/UltrasonicSensor'; +import { Program } from './controllers/program/Program'; +import { createRobotPythonContext } from './controllers/program/pythonRuntime'; +import { + EntityFactory, + Physics, + RobotConsole, + SceneRegistry, + Timer, + World, + type Controller, +} from './engine'; +import type { Dimension, SimpleVector } from './engine/Math/Vector'; +import { createEv3Functions, type Ev3Functions } from './ev3_functions'; +import { + ROBOT_SIMULATION_CONTROL_CHANNEL_ID, + ROBOT_SIMULATION_STATE_CHANNEL_ID, + ROBOT_SIMULATION_TAB_NAME, + type RobotSimulationTabRpc, + type StateChannelMessage, +} from './protocol'; + +type RobotSimulationTabLoader = { + tabs: string[]; + loadTab: (tab: string) => void; +}; + +export default class RobotSimulationModulePlugin extends BaseModulePlugin { + id = 'robot_simulation'; + static override channelAttach = [ROBOT_SIMULATION_CONTROL_CHANNEL_ID, ROBOT_SIMULATION_STATE_CHANNEL_ID]; + override exportedNames = [ + 'createCustomPhysics', + 'createPhysics', + 'createTimer', + 'createRobotConsole', + 'createWorld', + 'createCuboid', + 'createFloor', + 'createWall', + 'createPaper', + 'createEv3', + 'createPythonCSE', + 'addControllerToWorld', + 'saveToContext', + 'init_simulation', + 'ev3_motorA', + 'ev3_motorB', + 'ev3_motorC', + 'ev3_motorD', + 'ev3_runToRelativePosition', + 'ev3_pause', + 'ev3_colorSensor', + 'ev3_colorSensorRed', + 'ev3_colorSensorGreen', + 'ev3_colorSensorBlue', + 'ev3_ultrasonicSensor', + 'ev3_ultrasonicSensorDistance', + ] as const; + + private readonly __tabRpc: RobotSimulationTabRpc; + private readonly __stateChannel: IChannel; + private readonly __tabLoader: RobotSimulationTabLoader | undefined; + private readonly __sceneRegistry = new SceneRegistry(); + private readonly __ev3Fns: Ev3Functions; + private __tabLoaded = false; + + /** What `saveToContext`/the `ev3_*` API read/write - one plugin instance per run, so this + replaces the pre-migration `context.moduleContexts.robot_simulation.state`. */ + private readonly __state: Record = {}; + + constructor( + conduit: IConduit, + [controlChannel, stateChannel]: IChannel[], + evaluator: IInterfacableEvaluator, + tabLoader?: RobotSimulationTabLoader + ) { + if (!controlChannel || !stateChannel) { + throw new EvaluatorRuntimeError('Robot simulation control/state channels are required but were not provided.'); + } + super(conduit, [controlChannel, stateChannel], evaluator); + + this.__tabLoader = tabLoader; + this.__tabRpc = makeRpc, RobotSimulationTabRpc>(controlChannel, {}); + this.__stateChannel = stateChannel as IChannel; + this.__sceneRegistry.setSpawnListener(message => this.__stateChannel.send(message)); + this.__ev3Fns = createEv3Functions({ + getWorld: () => this.__getWorldFromContext(), + getEv3: () => this.__getEv3FromContext(), + }); + + // A tab that (re)connects after entities already exist needs the full backlog replayed - + // mirrors csg/rune's `{ type: 'request' }` handling. + this.__stateChannel.subscribe(message => { + if (message.kind === 'request-replay') { + this.__sceneRegistry.replaySpawns(); + } + }); + } + + private __ensureTabLoaded(): void { + if (this.__tabLoaded || this.__tabLoader === undefined) return; + const tabName = this.__tabLoader.tabs.find(tab => tab === ROBOT_SIMULATION_TAB_NAME); + if (tabName === undefined) return; + this.__tabLoader.loadTab(tabName); + this.__tabLoaded = true; + } + + private __getWorldFromContext(): World { + const world = this.__state.world; + if (world === undefined) { + throw new EvaluatorRuntimeError('World not initialized'); + } + return world as World; + } + + private __getEv3FromContext(): DefaultEv3 { + const ev3 = this.__state.ev3; + if (ev3 === undefined) { + throw new EvaluatorRuntimeError('ev3 not initialized'); + } + return ev3 as DefaultEv3; + } + + private async __getOpaque(value: TypedValue): Promise { + return (await this.evaluator.opaque_get(value)) as T; + } + + private __createCuboid( + physics: Physics, + position: SimpleVector, + dimension: Dimension, + mass: number, + color: number | string, + bodyType: string + ): Cuboid { + if (!EntityFactory.isRigidBodyType(bodyType)) { + throw new EvaluatorParameterTypeError('createCuboid', 'bodyType', '"fixed" or "dynamic"', bodyType); + } + const config: CuboidConfig = { position, dimension, mass, color, type: bodyType }; + return new Cuboid(physics, this.__sceneRegistry, config); + } + + /** Wires a freshly-created World's console/state/physics events into the tab - see + * protocol.ts's doc comment for why everything DOM-facing goes over these channels instead of + this module touching anything itself. */ + private __hookWorld(world: World): void { + world.addEventListener('worldStateChange', () => this.__tabRpc.$worldStateChanged(world.state)); + + const originalLog = world.robotConsole.log.bind(world.robotConsole); + world.robotConsole.log = (message, level) => { + originalLog(message, level); + this.__tabRpc.$consoleLog(message, level); + }; + + world.physics.addEventListener('afterPhysicsUpdate', () => this.__pushSnapshot()); + } + + private __pushSnapshot(): void { + // `.buffer` is freshly allocated by `new Float32Array(...)` inside `snapshot()`, so it is + // always a plain ArrayBuffer - its declared type is only wider (ArrayBufferLike) because a + // typed array could in general wrap a SharedArrayBuffer instead. + const buffer = this.__sceneRegistry.snapshot().buffer as ArrayBuffer; + this.__stateChannel.send({ kind: 'state-snapshot', buffer }, [buffer]); + + const ev3 = this.__state.ev3 as DefaultEv3 | undefined; + if (!ev3) return; + this.__tabRpc.$sensorSnapshot({ + leftMotorVelocity: ev3.get('leftMotor').motorVelocity, + rightMotorVelocity: ev3.get('rightMotor').motorVelocity, + colorSensor: ev3.get('colorSensor').sense(), + ultrasonicDistanceCm: ev3.get('ultrasonicSensor').sense(), + }); + } + + // [Configuration] + + async* createCustomPhysics( + gravity: TypedValue, + timestep: TypedValue + ): AsyncGenerator, undefined> { + const physics = new Physics({ gravity: { x: 0, y: gravity.value, z: 0 }, timestep: timestep.value }); + return await this.evaluator.opaque_make(physics, true); + } + + async* createPhysics(): AsyncGenerator, undefined> { + const physics = new Physics({ gravity: { x: 0, y: -9.81, z: 0 }, timestep: 1 / 20 }); + return await this.evaluator.opaque_make(physics, true); + } + + async* createTimer(): AsyncGenerator, undefined> { + return await this.evaluator.opaque_make(new Timer(), true); + } + + async* createRobotConsole(): AsyncGenerator, undefined> { + return await this.evaluator.opaque_make(new RobotConsole(), true); + } + + async* createWorld( + physics: TypedValue, + timer: TypedValue, + robotConsole: TypedValue + ): AsyncGenerator, undefined> { + const world = new World( + await this.__getOpaque(physics), + await this.__getOpaque(timer), + await this.__getOpaque(robotConsole) + ); + return await this.evaluator.opaque_make(world, true); + } + + async* createCuboid( + physics: TypedValue, + position_x: TypedValue, + position_y: TypedValue, + position_z: TypedValue, + width: TypedValue, + length: TypedValue, + height: TypedValue, + mass: TypedValue, + color: TypedValue, + bodyType: TypedValue + ): AsyncGenerator, undefined> { + const cuboid = this.__createCuboid( + await this.__getOpaque(physics), + { x: position_x.value, y: position_y.value, z: position_z.value }, + { width: width.value, length: length.value, height: height.value }, + mass.value, + color.value, + bodyType.value + ); + return await this.evaluator.opaque_make(cuboid, true); + } + + async* createFloor(physics: TypedValue): AsyncGenerator, undefined> { + const floor = this.__createCuboid( + await this.__getOpaque(physics), + { x: 0, y: -0.5, z: 0 }, + { width: 20, length: 20, height: 1 }, + 1, + 'white', + 'fixed' + ); + return await this.evaluator.opaque_make(floor, true); + } + + async* createWall( + physics: TypedValue, + x: TypedValue, + y: TypedValue, + width: TypedValue, + length: TypedValue, + height: TypedValue + ): AsyncGenerator, undefined> { + const wall = this.__createCuboid( + await this.__getOpaque(physics), + { x: x.value, y: height.value / 2, z: y.value }, + { width: width.value, length: length.value, height: height.value }, + 1, + 'yellow', + 'fixed' + ); + return await this.evaluator.opaque_make(wall, true); + } + + async* createPaper( + url: TypedValue, + width: TypedValue, + height: TypedValue, + x: TypedValue, + y: TypedValue, + rotation: TypedValue + ): AsyncGenerator, undefined> { + const paper = new Paper(this.__sceneRegistry, { + url: url.value, + dimension: { width: width.value, height: height.value }, + position: { x: x.value, y: y.value }, + rotation: (rotation.value * Math.PI) / 180, + }); + return await this.evaluator.opaque_make(paper, true); + } + + async* createEv3(physics: TypedValue): AsyncGenerator, undefined> { + const ev3 = createDefaultEv3(await this.__getOpaque(physics), this.__sceneRegistry, ev3Config); + return await this.evaluator.opaque_make(ev3, true); + } + + /** + * Creates a CSE machine as a Program Object, running Python. The Python program can call the + * whole `ev3_*` API directly by name; no `import` is needed (and none is possible - see + * pythonRuntime.ts). `print(...)` goes to the simulation's Robot Console panel. + * + * @param code The robot's control program, written in Python (SICPy §4). + */ + async* createPythonCSE(code: TypedValue): AsyncGenerator, undefined> { + const pyContext = createRobotPythonContext(this.__ev3Fns, () => this.__getWorldFromContext()); + const program = new Program(code.value, undefined, pyContext); + return await this.evaluator.opaque_make(program, true); + } + + async* addControllerToWorld( + controller: TypedValue, + world: TypedValue + ): AsyncGenerator, undefined> { + const worldValue = await this.__getOpaque(world); + worldValue.addController(await this.__getOpaque(controller)); + return { type: DataType.VOID, value: undefined }; + } + + async* saveToContext( + key: TypedValue, + value: TypedValue + ): AsyncGenerator, undefined> { + this.__state[key.value] = await this.evaluator.opaque_get(value); + return { type: DataType.VOID, value: undefined }; + } + + /** + * Initialize the simulation world. The callback takes no parameters and returns a world created + * by `createWorld` (with controllers already added via `addControllerToWorld`). Physics starts + * running as soon as `init()` resolves - unlike the pre-migration version, this no longer waits + * for the tab to be opened first (the tab has no way to signal the module at all besides the + * exported functions student code calls - see protocol.ts), so the simulation is "live" from + * the moment `init_simulation` returns, whether or not anyone has the tab open to watch it yet. + */ + async* init_simulation( + worldFactory: TypedValue + ): AsyncGenerator, undefined> { + if (this.__state.world !== undefined) { + return { type: DataType.VOID, value: undefined }; + } + this.__ensureTabLoaded(); + + const result = yield* this.evaluator.closure_call_unchecked( + worldFactory as TypedValue, + [] + ); + if (result.type !== DataType.OPAQUE) { + throw new EvaluatorRuntimeError('init_simulation: the callback must return a World (see createWorld)'); + } + const world = (await this.evaluator.opaque_get(result)) as World; + + this.__hookWorld(world); + await world.init(); + world.start(); + + return { type: DataType.VOID, value: undefined }; + } + + // [EV3] + + async* ev3_motorA(): AsyncGenerator, undefined> { + return await this.evaluator.opaque_make(this.__ev3Fns.ev3_motorA(), true); + } + + async* ev3_motorB(): AsyncGenerator, undefined> { + return await this.evaluator.opaque_make(this.__ev3Fns.ev3_motorB(), true); + } + + async* ev3_motorC(): AsyncGenerator, undefined> { + return await this.evaluator.opaque_make(this.__ev3Fns.ev3_motorC(), true); + } + + async* ev3_motorD(): AsyncGenerator, undefined> { + return await this.evaluator.opaque_make(this.__ev3Fns.ev3_motorD(), true); + } + + async* ev3_runToRelativePosition( + motor: TypedValue, + position: TypedValue, + speed: TypedValue + ): AsyncGenerator, undefined> { + this.__ev3Fns.ev3_runToRelativePosition(await this.__getOpaque(motor), position.value, speed.value); + return { type: DataType.VOID, value: undefined }; + } + + async* ev3_pause(duration: TypedValue): AsyncGenerator, undefined> { + this.__ev3Fns.ev3_pause(duration.value); + return { type: DataType.VOID, value: undefined }; + } + + async* ev3_colorSensor(): AsyncGenerator, undefined> { + return await this.evaluator.opaque_make(this.__ev3Fns.ev3_colorSensor(), true); + } + + async* ev3_colorSensorRed(colorSensor: TypedValue): AsyncGenerator, undefined> { + return { type: DataType.NUMBER, value: this.__ev3Fns.ev3_colorSensorRed(await this.__getOpaque(colorSensor)) }; + } + + async* ev3_colorSensorGreen(colorSensor: TypedValue): AsyncGenerator, undefined> { + return { type: DataType.NUMBER, value: this.__ev3Fns.ev3_colorSensorGreen(await this.__getOpaque(colorSensor)) }; + } + + async* ev3_colorSensorBlue(colorSensor: TypedValue): AsyncGenerator, undefined> { + return { type: DataType.NUMBER, value: this.__ev3Fns.ev3_colorSensorBlue(await this.__getOpaque(colorSensor)) }; + } + + async* ev3_ultrasonicSensor(): AsyncGenerator, undefined> { + return await this.evaluator.opaque_make(this.__ev3Fns.ev3_ultrasonicSensor(), true); + } + + async* ev3_ultrasonicSensorDistance(sensor: TypedValue): AsyncGenerator, undefined> { + return { type: DataType.NUMBER, value: this.__ev3Fns.ev3_ultrasonicSensorDistance(await this.__getOpaque(sensor)) }; + } +} -export { - ev3_motorA, - ev3_motorB, - ev3_motorC, - ev3_motorD, - ev3_runToRelativePosition, - ev3_colorSensorRed, - ev3_colorSensorGreen, - ev3_pause, - ev3_colorSensor, - ev3_colorSensorBlue, - ev3_ultrasonicSensor, - ev3_ultrasonicSensorDistance, -} from './ev3_functions'; - -export { - createCustomPhysics, - createPhysics, - createRenderer, - init_simulation, - createCuboid, - createTimer, - createWorld, - createWall, - createEv3, - createPaper, - createFloor, - createCSE, - createPythonCSE, - addControllerToWorld, - createRobotConsole, - saveToContext, -} from './helper_functions'; +attachModuleMethod(RobotSimulationModulePlugin, 'createCustomPhysics', [DataType.NUMBER, DataType.NUMBER], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createPhysics', [], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createTimer', [], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createRobotConsole', [], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createWorld', [DataType.OPAQUE, DataType.OPAQUE, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createCuboid', [ + DataType.OPAQUE, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, + DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, + DataType.CONST_STRING, DataType.CONST_STRING, +], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createFloor', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createWall', [DataType.OPAQUE, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createPaper', [DataType.CONST_STRING, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createEv3', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'createPythonCSE', [DataType.CONST_STRING], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'addControllerToWorld', [DataType.OPAQUE, DataType.OPAQUE], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'saveToContext', [DataType.CONST_STRING, DataType.OPAQUE], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'init_simulation', [DataType.CLOSURE], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorA', [], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorB', [], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorC', [], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorD', [], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_runToRelativePosition', [DataType.OPAQUE, DataType.NUMBER, DataType.NUMBER], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_pause', [DataType.NUMBER], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_colorSensor', [], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_colorSensorRed', [DataType.OPAQUE], DataType.NUMBER); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_colorSensorGreen', [DataType.OPAQUE], DataType.NUMBER); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_colorSensorBlue', [DataType.OPAQUE], DataType.NUMBER); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_ultrasonicSensor', [], DataType.OPAQUE); +attachModuleMethod(RobotSimulationModulePlugin, 'ev3_ultrasonicSensorDistance', [DataType.OPAQUE], DataType.NUMBER); diff --git a/src/bundles/robot_simulation/src/protocol.ts b/src/bundles/robot_simulation/src/protocol.ts new file mode 100644 index 0000000000..0e4c1d9cdc --- /dev/null +++ b/src/bundles/robot_simulation/src/protocol.ts @@ -0,0 +1,84 @@ +/** + * Conductor wiring for robot_simulation: two channels, following pix_n_flix's + * (`pix_n_flix/src/protocol.ts`) precedent for splitting a real-time visual bundle across the + * worker/main-thread boundary. + * + * The module (this bundle's `BaseModulePlugin`) runs inside Conductor's runner Web Worker, + * alongside the evaluator - it has no `window`, no `document`, no WebGL. It owns physics + * (rapier3d-compat, WASM, worker-safe) and the robot control program's CSE stepping. It NEVER + * constructs a `THREE.WebGLRenderer`, `OrbitControls`, or a canvas - see SceneRegistry's doc + * comment. All of that - the camera, the canvas, the render loop, mouse-driven orbit controls - + * is owned entirely by the RobotSimulation tab (real DOM, main thread), exactly like pix_n_flix's + * tab owning the camera/video/canvas ("unlike the pre-migration version, the module never touches + * DOM elements"). + * + * - {@link ROBOT_SIMULATION_CONTROL_CHANNEL_ID}: infrequent RPC (module -> tab pushes: console + * log lines, world state changes, sensor snapshots for the debug panels) via Conductor's + * `makeRpc`. + * - {@link ROBOT_SIMULATION_STATE_CHANNEL_ID}: a dedicated channel for the two things that need + * to move every physics tick - "a new entity was spawned" (rare) and "here are everyone's + * transforms" (every tick). Deliberately NOT routed through `makeRpc` (which structured-clones + * every argument): the per-tick transform snapshot is sent as a raw `Float32Array.buffer` + * transferred via `IChannel.send(message, [buffer])`, mirroring pix_n_flix's frame channel. + */ +export const ROBOT_SIMULATION_CONTROL_CHANNEL_ID = 'sourceacademy-robot-simulation-control-channel'; +export const ROBOT_SIMULATION_STATE_CHANNEL_ID = 'sourceacademy-robot-simulation-state-channel'; +export const ROBOT_SIMULATION_TAB_NAME = 'RobotSimulation'; + +export type EntityDimension = { width: number, height: number, length: number }; + +/** + * What the tab should actually draw for one entity. The module only ever produces a bare + * `THREE.Object3D` transform node for physics/game-logic purposes (see SceneRegistry) - it has no + * geometry, material, or texture of its own, since building any of those in the worker would be + * pointless (nothing there can render them). This descriptor is the one-time (per entity) message + * that tells the tab what real THREE object to build and keep positioned at that node's transform. + */ +export type EntityDescriptor = + | { kind: 'cuboid', dimension: EntityDimension, color: string } + | { kind: 'paper', width: number, height: number, url: string } + | { kind: 'gltf', url: string, dimension: EntityDimension, offsetY: number }; + +/** Module -> tab: a new entity was added to the scene registry. Replayed in full to a tab that + (re)connects after entities already exist - mirrors csg/rune's render backlog replay. */ +export interface EntitySpawnedMessage { + kind: 'entity-spawned'; + id: number; + descriptor: EntityDescriptor; +} + +/** Module -> tab: every tracked entity's current transform, laid out as + * `[id, px, py, pz, qx, qy, qz, qw] * N` in a single `Float32Array` - sent as a transferable, not + cloned, since this goes out once per physics tick (~20Hz). */ +export interface StateSnapshotMessage { + kind: 'state-snapshot'; + buffer: ArrayBuffer; +} + +/** Tab -> module: replay every entity spawned so far (a tab that just mounted / reconnected), + mirrors csg/rune's `{ type: 'request' }`. */ +export interface RequestReplayMessage { + kind: 'request-replay'; +} + +export type StateChannelMessage = EntitySpawnedMessage | StateSnapshotMessage | RequestReplayMessage; + +export type WorldStateName = 'unintialized' | 'loading' | 'ready' | 'running' | 'error'; + +export interface SensorSnapshot { + leftMotorVelocity: number; + rightMotorVelocity: number; + colorSensor: { r: number, g: number, b: number }; + ultrasonicDistanceCm: number; +} + +/** + * Host-side (tab, browser main thread) operations the robot_simulation module invokes over + * {@link ROBOT_SIMULATION_CONTROL_CHANNEL_ID} via Conductor's `makeRpc` - mirrors + * `PixNFlixTabRpc`/`SoundTabRpc`. `$`-prefixed methods are fire-and-forget. + */ +export interface RobotSimulationTabRpc { + $consoleLog(message: string, level: 'error' | 'source'): void; + $worldStateChanged(state: WorldStateName): void; + $sensorSnapshot(snapshot: SensorSnapshot): void; +} diff --git a/src/tabs/RobotSimulation/package.json b/src/tabs/RobotSimulation/package.json index e64535843b..f6b9f1594c 100644 --- a/src/tabs/RobotSimulation/package.json +++ b/src/tabs/RobotSimulation/package.json @@ -3,16 +3,18 @@ "version": "1.0.0", "private": true, "dependencies": { - "@blueprintjs/core": "^6.0.0", - "@dimforge/rapier3d-compat": "^0.11.2", "@sourceacademy/bundle-robot_simulation": "workspace:^", + "@sourceacademy/common-tabs": "^0.0.1", + "@sourceacademy/conductor": "catalog:", "@sourceacademy/modules-lib": "workspace:^", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "three": "^0.185.0" }, "devDependencies": { "@sourceacademy/modules-buildtools": "workspace:^", "@types/react": "catalog:", + "@types/three": "^0.185.0", "typescript": "catalog:" }, "scripts": { diff --git a/src/tabs/RobotSimulation/src/components/Main.tsx b/src/tabs/RobotSimulation/src/components/Main.tsx deleted file mode 100644 index f3a0c84bfd..0000000000 --- a/src/tabs/RobotSimulation/src/components/Main.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { DebuggerContext } from '@sourceacademy/modules-lib/types'; -import { useState, type FC } from 'react'; -import { Modal } from './Modal'; -import { SimulationCanvas } from './Simulation'; -import { TabUi } from './TabUi'; - -type MainProps = { - context: DebuggerContext; -}; - -export const Main: FC = ({ context }) => { - const [isCanvasShowing, setIsCanvasShowing] = useState(false); - - return ( -
- { - setIsCanvasShowing(true); - }} - /> - { - setIsCanvasShowing(false); - }} - > - - -
- ); -}; diff --git a/src/tabs/RobotSimulation/src/components/Modal.tsx b/src/tabs/RobotSimulation/src/components/Modal.tsx deleted file mode 100644 index a268dded77..0000000000 --- a/src/tabs/RobotSimulation/src/components/Modal.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { type CSSProperties, type ReactNode } from 'react'; - -type ModalProps = { - isOpen: boolean; - onClose: () => void; - children: ReactNode; -}; - -export const containerStyle: CSSProperties = { - width: '100vw', - height: '100vh', - position: 'fixed', - bottom: 0, - top: 0, - left: 0, - right: 0, - zIndex: 20, - isolation: 'isolate', -}; - -export const closeButtonStyle: CSSProperties = { - position: 'fixed', - top: '10px', - right: '10px', - cursor: 'pointer', - fontSize: 24, - color: 'white', -}; - -export const greyedOutBackground: CSSProperties = { - background: 'black', - opacity: '70%', - width: '100%', - height: '100%', - position: 'absolute', - zIndex: -1, -}; - -export const childWrapperStyle: CSSProperties = { - display: 'flex', - width: '100%', - height: '100%', - justifyContent: 'center', - alignItems: 'center', -}; - -export const Modal: React.FC = ({ children, isOpen, onClose }) => { - return ( -
-
- - x - -
{children}
-
- ); -}; diff --git a/src/tabs/RobotSimulation/src/components/Simulation/index.tsx b/src/tabs/RobotSimulation/src/components/Simulation/index.tsx deleted file mode 100644 index 5bf0a22ad4..0000000000 --- a/src/tabs/RobotSimulation/src/components/Simulation/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Tab, Tabs } from '@blueprintjs/core'; - -import type { DefaultEv3 } from '@sourceacademy/bundle-robot_simulation/controllers'; -import type { World } from '@sourceacademy/bundle-robot_simulation/engine'; -import type { WorldState } from '@sourceacademy/bundle-robot_simulation/engine/World'; -import type { DebuggerContext } from '@sourceacademy/modules-lib/types'; -import { useEffect, useRef, useState, type CSSProperties } from 'react'; - -import { ColorSensorPanel } from '../TabPanels/ColorSensorPanel'; -import { ConsolePanel } from '../TabPanels/ConsolePanel'; -import { MotorPidPanel } from '../TabPanels/MotorPidPanel'; -import { UltrasonicSensorPanel } from '../TabPanels/UltrasonicSensorPanel'; -import { WheelPidPanel } from '../TabPanels/WheelPidPanel'; - -const WrapperStyle: CSSProperties = { - display: 'flex', - flexDirection: 'column', - gap: '0.6rem', -}; - -const CanvasStyle: CSSProperties = { - width: 900, - height: 500, - borderRadius: 3, - overflow: 'hidden', - boxShadow: 'inset 0 0 0 1px rgba(255, 255, 255, 0.2)', -}; - -const bottomPanelStyle: CSSProperties = { - width: 900, - height: 200, - backgroundColor: '#1a2530', - borderRadius: 3, - overflow: 'hidden', - boxShadow: 'inset 0 0 0 1px rgba(255, 255, 255, 0.2)', -}; - -type SimulationCanvasProps = { - context: DebuggerContext; - isOpen: boolean; -}; - -export const SimulationCanvas: React.FC = ({ - context, - isOpen, -}) => { - const ref = useRef(null); - const sensorRef = useRef(null); - const [currentState, setCurrentState] = useState('unintialized'); - - // We know this is true because it is checked in RobotSimulation/index.tsx (toSpawn) - const world = context.context.moduleContexts.robot_simulation.state - .world as World; - - // This is not guaranteed to be true. - const ev3 = context.context.moduleContexts.robot_simulation.state.ev3 as - | DefaultEv3 - | undefined; - - const robotConsole = world.robotConsole; - - useEffect(() => { - const startThreeAndRapierEngines = async () => { - setCurrentState(world.state); - }; - - const attachRenderDom = () => { - if (ref.current) { - ref.current.replaceChildren(world.render.getElement()); - } - if (!ev3) { - return; - } - - if (sensorRef.current) { - sensorRef.current.replaceChildren(ev3.get('colorSensor').renderer.getElement()); - } - }; - - if (currentState === 'unintialized') { - startThreeAndRapierEngines(); - } - - if (currentState === 'ready' || currentState === 'running') { - attachRenderDom(); - } - if (currentState === 'loading') { - setTimeout(() => { - setCurrentState('unintialized'); - }, 500); - } - }, [currentState]); - - useEffect(() => { - if (isOpen) { - world.start(); - } else { - world.pause(); - } - }, [isOpen]); - - return ( -
-
-
{currentState}
-
-
- - } /> - } /> - }/> - }/> - } /> - -
-
- ); -}; diff --git a/src/tabs/RobotSimulation/src/components/TabPanels/ColorSensorPanel.tsx b/src/tabs/RobotSimulation/src/components/TabPanels/ColorSensorPanel.tsx deleted file mode 100644 index eedc69705a..0000000000 --- a/src/tabs/RobotSimulation/src/components/TabPanels/ColorSensorPanel.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { DefaultEv3 } from '@sourceacademy/bundle-robot_simulation/controllers/ev3/ev3/default/ev3'; -import React, { useEffect, useRef } from 'react'; -import { useFetchFromSimulation } from '../../hooks/fetchFromSimulation'; -import { LastUpdated } from './tabComponents/LastUpdated'; -import { TabWrapper } from './tabComponents/Wrapper'; - -export const ColorSensorPanel: React.FC<{ ev3?: DefaultEv3 }> = ({ ev3 }) => { - const colorSensor = ev3?.get('colorSensor'); - const sensorVisionRef = useRef(null); - - const [timing, color] = useFetchFromSimulation(() => { - if (colorSensor === undefined) { - return null; - } - return colorSensor.sense(); - }, 1000); - - useEffect(() => { - if (colorSensor && sensorVisionRef.current) { - sensorVisionRef.current.replaceChildren(colorSensor.renderer.getElement()); - } - }, [timing]); - - if (!ev3) { - return EV3 not found in context. Did you call saveToContext('ev3', ev3);; - } - - if (timing === null) { - return Loading color sensor; - } - - if (color === null) { - return Color sensor not found; - } - - return ( - - -
-

Red: {color.r}

-

Green: {color.g}

-

Blue: {color.b}

- - ); -}; diff --git a/src/tabs/RobotSimulation/src/components/TabPanels/ConsolePanel.tsx b/src/tabs/RobotSimulation/src/components/TabPanels/ConsolePanel.tsx deleted file mode 100644 index 283fcaff90..0000000000 --- a/src/tabs/RobotSimulation/src/components/TabPanels/ConsolePanel.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { LogEntry, RobotConsole } from '@sourceacademy/bundle-robot_simulation/engine/Core/RobotConsole'; -import { useFetchFromSimulation } from '../../hooks/fetchFromSimulation'; -import { LastUpdated, getTimeString } from './tabComponents/LastUpdated'; -import { TabWrapper } from './tabComponents/Wrapper'; - -const getLogString = (log: LogEntry) => { - const logLevelText: Record = { - source: 'Runtime Source Error', - error: 'Error', - }; - - const timeString = getTimeString(new Date(log.timestamp)); - return `[${timeString}] ${logLevelText[log.level]}: ${log.message}`; -}; - -export const ConsolePanel: React.FC<{ - robot_console: RobotConsole; -}> = ({ robot_console }) => { - const [timing, logs] = useFetchFromSimulation(() => { - if (robot_console === undefined) { - return null; - } - return robot_console.getLogs(); - }, 1000); - - if (timing === null) { - return Not fetched yet; - } - - if (logs === null) { - return ( - - Console not found. Ensure that the world is initialized properly. - - ); - } - - if (logs.length === 0) { - return ( - - -

There is currently no logs

-
- ); - } - - return ( - - -
    - {logs.map((log, i) => ( -
  • {getLogString(log)}
  • - ))} -
-
- ); -}; diff --git a/src/tabs/RobotSimulation/src/components/TabPanels/MotorPidPanel.tsx b/src/tabs/RobotSimulation/src/components/TabPanels/MotorPidPanel.tsx deleted file mode 100644 index 539ab28808..0000000000 --- a/src/tabs/RobotSimulation/src/components/TabPanels/MotorPidPanel.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { NumericInput } from '@blueprintjs/core'; -import type { DefaultEv3 } from '@sourceacademy/bundle-robot_simulation/controllers'; -import type { CSSProperties } from 'react'; -import { TabWrapper } from './tabComponents/Wrapper'; - -const RowStyle: CSSProperties = { - display: 'flex', - flexDirection: 'row', - gap: '0.6rem', -}; - -export const MotorPidPanel: React.FC<{ ev3?: DefaultEv3 }> = ({ ev3 }) => { - if (!ev3) { - return ( - - EV3 not found in context. Did you call saveToContext('ev3', ev3); - - ); - } - - const leftMotor = ev3.get('leftMotor'); - const rightMotor = ev3.get('rightMotor'); - - if (!leftMotor || !rightMotor) { - return Motor not found; - } - - const onChangeProportional = (value: number) => { - ev3.get('leftMotor').pid.proportionalGain = value; - ev3.get('rightMotor').pid.proportionalGain = value; - }; - const onChangeIntegral = (value: number) => { - ev3.get('leftMotor').pid.integralGain = value; - ev3.get('rightMotor').pid.integralGain = value; - }; - const onChangeDerivative = (value: number) => { - ev3.get('leftMotor').pid.derivativeGain = value; - ev3.get('rightMotor').pid.derivativeGain = value; - }; - - return ( - -
- Proportional Gain: - -
-
- Integral Gain: - -
-
- Derivative Gain: - -
-
- ); -}; diff --git a/src/tabs/RobotSimulation/src/components/TabPanels/UltrasonicSensorPanel.tsx b/src/tabs/RobotSimulation/src/components/TabPanels/UltrasonicSensorPanel.tsx deleted file mode 100644 index 3ef9b98de0..0000000000 --- a/src/tabs/RobotSimulation/src/components/TabPanels/UltrasonicSensorPanel.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { DefaultEv3 } from '@sourceacademy/bundle-robot_simulation/controllers/ev3/ev3/default/ev3'; -import React from 'react'; -import { useFetchFromSimulation } from '../../hooks/fetchFromSimulation'; -import { LastUpdated } from './tabComponents/LastUpdated'; -import { TabWrapper } from './tabComponents/Wrapper'; - -export const UltrasonicSensorPanel: React.FC<{ ev3?: DefaultEv3 }> = ({ - ev3, -}) => { - const ultrasonicSensor = ev3?.get('ultrasonicSensor'); - const [timing, distanceSensed] = useFetchFromSimulation(() => { - if (ultrasonicSensor === undefined) { - return null; - } - return ultrasonicSensor.sense(); - }, 1000); - - if (!ev3) { - return ( - - EV3 not found in context. Did you call saveToContext('ev3', ev3); - - ); - } - - if (timing === null) { - return Loading ultrasonic sensor; - } - - if (distanceSensed === null) { - return Ultrasonic sensor not found; - } - - return ( - - -
-

Distance: {distanceSensed}

-
-
- ); -}; diff --git a/src/tabs/RobotSimulation/src/components/TabPanels/WheelPidPanel.tsx b/src/tabs/RobotSimulation/src/components/TabPanels/WheelPidPanel.tsx deleted file mode 100644 index 4977751a7b..0000000000 --- a/src/tabs/RobotSimulation/src/components/TabPanels/WheelPidPanel.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { NumericInput } from '@blueprintjs/core'; -import type { DefaultEv3 } from '@sourceacademy/bundle-robot_simulation/controllers'; -import type { CSSProperties } from 'react'; -import { TabWrapper } from './tabComponents/Wrapper'; - -const RowStyle: CSSProperties = { - display: 'flex', - flexDirection: 'row', - gap: '0.6rem', -}; - -export const WheelPidPanel: React.FC<{ ev3?: DefaultEv3 }> = ({ ev3 }) => { - if (!ev3) { - return ( - - EV3 not found in context. Did you call saveToContext('ev3', ev3); - - ); - } - if ( - !ev3.get('backLeftWheel') || - !ev3.get('backRightWheel') || - !ev3.get('frontLeftWheel') || - !ev3.get('frontRightWheel') - ) { - return Wheel not found; - } - - const onChangeProportional = (value: number) => { - ev3.get('backLeftWheel').pid.proportionalGain = value; - ev3.get('backRightWheel').pid.proportionalGain = value; - ev3.get('frontLeftWheel').pid.proportionalGain = value; - ev3.get('frontRightWheel').pid.proportionalGain = value; - }; - const onChangeIntegral = (value: number) => { - ev3.get('backLeftWheel').pid.integralGain = value; - ev3.get('backRightWheel').pid.integralGain = value; - ev3.get('frontLeftWheel').pid.integralGain = value; - ev3.get('frontRightWheel').pid.integralGain = value; - }; - const onChangeDerivative = (value: number) => { - ev3.get('backLeftWheel').pid.derivativeGain = value; - ev3.get('backRightWheel').pid.derivativeGain = value; - ev3.get('frontLeftWheel').pid.derivativeGain = value; - ev3.get('frontRightWheel').pid.derivativeGain = value; - }; - - return ( - -
- Proportional Gain: - -
-
- Integral Gain: - -
-
- Derivative Gain: - -
-
- ); -}; diff --git a/src/tabs/RobotSimulation/src/components/TabPanels/tabComponents/LastUpdated.tsx b/src/tabs/RobotSimulation/src/components/TabPanels/tabComponents/LastUpdated.tsx deleted file mode 100644 index 116de13cad..0000000000 --- a/src/tabs/RobotSimulation/src/components/TabPanels/tabComponents/LastUpdated.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type React from 'react'; - -export const getTimeString = (date: Date) => { - const options: Intl.DateTimeFormatOptions = { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, // Use 24-hour format. Set to true for 12-hour format if preferred. - }; - return date.toLocaleTimeString([], options); -}; - -export const LastUpdated: React.FC<{ time: Date }> = ({ - time, -}: { - time: Date; -}) => { - const timeString = getTimeString(time); - - return Last updated: {timeString}; -}; diff --git a/src/tabs/RobotSimulation/src/components/TabPanels/tabComponents/Wrapper.tsx b/src/tabs/RobotSimulation/src/components/TabPanels/tabComponents/Wrapper.tsx deleted file mode 100644 index f036be2e82..0000000000 --- a/src/tabs/RobotSimulation/src/components/TabPanels/tabComponents/Wrapper.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { CSSProperties } from 'react'; - -const panelWrapperStyle: CSSProperties = { - 'padding': '10px' -}; -export const TabWrapper: React.FC<{ - children?: React.ReactNode; -}> = ({ children }) => { - return
{children}
; -}; diff --git a/src/tabs/RobotSimulation/src/components/TabUi.tsx b/src/tabs/RobotSimulation/src/components/TabUi.tsx deleted file mode 100644 index 90018c32e6..0000000000 --- a/src/tabs/RobotSimulation/src/components/TabUi.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; - -type TabUiProps = { - onOpenCanvas: () => void; -}; - -export const TabUi: React.FC = ({ onOpenCanvas }) => { - return ( -
-

Welcome to robot simulator.

- -
- ); -}; diff --git a/src/tabs/RobotSimulation/src/hooks/fetchFromSimulation.ts b/src/tabs/RobotSimulation/src/hooks/fetchFromSimulation.ts deleted file mode 100644 index 50f7cb8d5a..0000000000 --- a/src/tabs/RobotSimulation/src/hooks/fetchFromSimulation.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect, useState } from 'react'; - -export const useFetchFromSimulation = (fetchFn: () => T, fetchInterval: number) => { - const [fetchTime, setFetchTime] = useState(null); - const [fetchedData, setFetchedData] = useState(null); - - useEffect(() => { - const interval = setInterval(() => { - const data = fetchFn(); - setFetchedData(data); - setFetchTime(new Date()); - }, fetchInterval); - - return () => clearInterval(interval); - }); - - return [fetchTime, fetchedData] as const; -}; diff --git a/src/tabs/RobotSimulation/src/index.tsx b/src/tabs/RobotSimulation/src/index.tsx index 87727d3e6d..1d470ea316 100644 --- a/src/tabs/RobotSimulation/src/index.tsx +++ b/src/tabs/RobotSimulation/src/index.tsx @@ -1,18 +1,284 @@ -import { defineTab } from '@sourceacademy/modules-lib/tabs/utils'; -import { Main } from './components/Main'; +import { sceneConfig } from '@sourceacademy/bundle-robot_simulation/config'; +import { MeshFactory, getCamera, loadGLTF } from '@sourceacademy/bundle-robot_simulation/engine'; +import { + ROBOT_SIMULATION_CONTROL_CHANNEL_ID, + ROBOT_SIMULATION_STATE_CHANNEL_ID, + type EntityDescriptor, + type RobotSimulationTabRpc, + type SensorSnapshot, + type StateChannelMessage, + type WorldStateName, +} from '@sourceacademy/bundle-robot_simulation/protocol'; +import type { ITabService, Tab } from '@sourceacademy/common-tabs'; +import { checkIsPluginClass, makeRpc, type IChannel, type IConduit, type IPlugin } from '@sourceacademy/conductor/conduit'; +import { createElement, useEffect, useRef, useSyncExternalStore } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; + +export const ROBOT_SIMULATION_TAB_ID = 'robot_simulation'; + +type LogEntry = { message: string, level: 'error' | 'source', timestamp: number }; + +interface ViewState { + worldState: WorldStateName; + sensors: SensorSnapshot | undefined; + logs: readonly LogEntry[]; +} + +const MAX_LOGS = 200; /** - * Robot Simulation - * @author Joel Chan + * Host-side (browser main thread) counterpart of `RobotSimulationModulePlugin` (in the + * robot_simulation bundle) - owns everything DOM/WebGL-touching: `THREE.WebGLRenderer`, + * `OrbitControls`, the canvas, and the render loop, none of which the module itself can touch any + * more (see protocol.ts and SceneRegistry's doc comment in the bundle). Mirrors + * `PixNFlixTabPlugin`'s shape: implements the module's RPC interface directly, and separately + * subscribes to a dedicated state channel for the high-frequency (per physics tick) entity + * transform stream, which is sent as a transferable rather than routed through RPC. + * + * Builds real THREE geometry from each `EntityDescriptor` the module streams over the state + * channel the first time an entity is seen, then just keeps repositioning/reorienting that + * geometry every tick from the transform snapshot - the module never sends geometry more than + * once per entity. */ +// eslint-disable-next-line @sourceacademy/tab-type +export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulationTabRpc { + readonly id = 'robot-simulation-web'; + static readonly channelAttach = [ROBOT_SIMULATION_CONTROL_CHANNEL_ID, ROBOT_SIMULATION_STATE_CHANNEL_ID]; + + private readonly __tabService: ITabService; + private readonly __stateChannel: IChannel; + private readonly __listeners = new Set<() => void>(); + + private readonly __scene = new THREE.Scene(); + private readonly __camera = getCamera({ + type: 'perspective', + aspect: sceneConfig.width / sceneConfig.height, + fov: 75, + near: 0.1, + far: 1000, + }); + private __renderer: THREE.WebGLRenderer | undefined; + private __controls: OrbitControls | undefined; + private __requestId: number | undefined; + + /** One entry per entity the module has ever told us about. For a 'gltf' entity this is a + * placeholder `Object3D` added immediately (so transform updates never have nowhere to go) - + the actual loaded model is added as its child once `loadGLTF` resolves. */ + private readonly __entities = new Map(); + + private __state: ViewState = { + worldState: 'unintialized', + sensors: undefined, + logs: [], + }; + + constructor(_conduit: IConduit, [controlChannel, stateChannel]: IChannel[], tabService: ITabService) { + if (!controlChannel || !stateChannel) { + throw new Error('Robot simulation control/state channels are required but were not provided.'); + } + this.__tabService = tabService; + this.__stateChannel = stateChannel as IChannel; + + makeRpc>(controlChannel, this); + + const light = new THREE.PointLight(0xffffff, 1); + light.position.set(0, 1, 0); + this.__scene.add(light); + this.__scene.add(new THREE.AmbientLight(0xffffff, 0.2)); + this.__scene.background = new THREE.Color(0xffffff); + + this.__stateChannel.subscribe(message => { + if (message.kind === 'entity-spawned') { + this.__spawnEntity(message.id, message.descriptor); + } else if (message.kind === 'state-snapshot') { + this.__applySnapshot(message.buffer); + } + }); + // A tab that mounts after the module has already spawned entities needs the backlog replayed + // - mirrors csg/rune's `{ type: 'request' }`. + this.__stateChannel.send({ kind: 'request-replay' }); + + const subscribe = (listener: () => void) => this.__subscribe(listener); + const getState = () => this.__state; + // eslint-disable-next-line @typescript-eslint/no-this-alias + const plugin = this; + function RobotSimulationView() { + const state = useSyncExternalStore(subscribe, getState); + const canvasRef = useRef(null); + + useEffect(() => { + if (canvasRef.current) plugin.__attachCanvas(canvasRef.current); + return () => plugin.__detachCanvas(); + }, []); + + return createElement(RobotSimulationView_, { state, canvasRef }); + } + + const tab = { + id: ROBOT_SIMULATION_TAB_ID, + iconName: 'build', + body: createElement(RobotSimulationView), + label: 'Robot Simulation', + disabled: false, + } satisfies Tab; + + this.__tabService.registerTab(tab); + this.__tabService.showTab(ROBOT_SIMULATION_TAB_ID); + } + + destroy(): void { + this.__detachCanvas(); + } + + private __subscribe(listener: () => void): () => void { + this.__listeners.add(listener); + return () => this.__listeners.delete(listener); + } + + private __emit(): void { + this.__listeners.forEach(listener => listener()); + } + + private __setState(patch: Partial): void { + this.__state = { ...this.__state, ...patch }; + this.__emit(); + } + + private __spawnEntity(id: number, descriptor: EntityDescriptor): void { + if (this.__entities.has(id)) return; + + if (descriptor.kind === 'cuboid') { + const mesh = MeshFactory.addCuboid({ + orientation: { position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0, w: 1 } }, + dimension: descriptor.dimension, + color: new THREE.Color(descriptor.color), + debug: false, + }); + this.__scene.add(mesh); + this.__entities.set(id, mesh); + return; + } + + if (descriptor.kind === 'paper') { + const geometry = new THREE.PlaneGeometry(descriptor.width, descriptor.height); + const texture = new THREE.TextureLoader().load(descriptor.url); + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ map: texture })); + this.__scene.add(mesh); + this.__entities.set(id, mesh); + return; + } + + // 'gltf': add a placeholder immediately so a transform snapshot arriving before the model + // finishes loading still has somewhere to go; the real model becomes its child once ready. + const holder = new THREE.Object3D(); + this.__scene.add(holder); + this.__entities.set(id, holder); + loadGLTF(descriptor.url, descriptor.dimension) + .then(data => holder.add(data.scene)) + .catch(error => console.warn('robot_simulation tab: failed to load GLTF asset:', descriptor.url, error)); + } + + private __applySnapshot(buffer: ArrayBuffer): void { + const view = new Float32Array(buffer); + const stride = 8; + for (let offset = 0; offset + stride <= view.length; offset += stride) { + const node = this.__entities.get(view[offset]); + if (!node) continue; + node.position.set(view[offset + 1], view[offset + 2], view[offset + 3]); + node.quaternion.set(view[offset + 4], view[offset + 5], view[offset + 6], view[offset + 7]); + } + } + + private __attachCanvas(canvas: HTMLCanvasElement): void { + this.__renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); + this.__renderer.shadowMap.enabled = true; + this.__renderer.setSize(sceneConfig.width, sceneConfig.height); + this.__renderer.setPixelRatio(window.devicePixelRatio * 1.5); + this.__controls = new OrbitControls(this.__camera, this.__renderer.domElement); + this.__requestId = window.requestAnimationFrame(this.__tick); + } + + private __detachCanvas(): void { + if (this.__requestId !== undefined) { + window.cancelAnimationFrame(this.__requestId); + this.__requestId = undefined; + } + this.__controls?.dispose(); + this.__controls = undefined; + this.__renderer = undefined; + } + + private __tick = (): void => { + this.__requestId = window.requestAnimationFrame(this.__tick); + this.__controls?.update(); + this.__renderer?.render(this.__scene, this.__camera); + }; + + // [RobotSimulationTabRpc] + + $consoleLog(message: string, level: 'error' | 'source'): void { + const logs = [...this.__state.logs, { message, level, timestamp: Date.now() }]; + this.__setState({ logs: logs.length > MAX_LOGS ? logs.slice(logs.length - MAX_LOGS) : logs }); + } + + $worldStateChanged(state: WorldStateName): void { + this.__setState({ worldState: state }); + } + + $sensorSnapshot(snapshot: SensorSnapshot): void { + this.__setState({ sensors: snapshot }); + } +} +checkIsPluginClass(RobotSimulationTabPlugin); + +function RobotSimulationView_({ state, canvasRef }: { state: ViewState, canvasRef: React.RefObject }) { + return ( +
+
+ +
+
+
World: {state.worldState}
+ {state.sensors && ( + <> +
Left motor: {state.sensors.leftMotorVelocity.toFixed(2)}
+
Right motor: {state.sensors.rightMotorVelocity.toFixed(2)}
+
+ Color: rgb({state.sensors.colorSensor.r.toFixed(0)}, {state.sensors.colorSensor.g.toFixed(0)}, {state.sensors.colorSensor.b.toFixed(0)}) +
+
Ultrasonic: {state.sensors.ultrasonicDistanceCm.toFixed(1)} cm
+ + )} +
+
+ {state.logs.map((log, index) => ( -export default defineTab({ - toSpawn(context) { - const worldState = - context.context.moduleContexts.robot_simulation.state?.world?.state; - return worldState !== undefined; - }, - body: context =>
, - label: 'Robot Simulation Tab', - iconName: 'build', -}); +
+ {log.message} +
+ ))} +
+
+ ); +} diff --git a/yarn.lock b/yarn.lock index b1c18795dd..7eed5a8842 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4312,12 +4312,12 @@ __metadata: resolution: "@sourceacademy/bundle-robot_simulation@workspace:src/bundles/robot_simulation" dependencies: "@dimforge/rapier3d-compat": "npm:^0.11.2" + "@sourceacademy/conductor": "catalog:" "@sourceacademy/modules-buildtools": "workspace:^" "@sourceacademy/modules-lib": "workspace:^" "@sourceacademy/py-slang": "portal:/home/vakshay/Projects/local-pyslang-build/py-slang" "@types/three": "npm:^0.185.0" es-toolkit: "npm:^1.44.0" - js-slang: "catalog:" three: "npm:^0.185.0" typescript: "catalog:" languageName: unknown @@ -4447,13 +4447,6 @@ __metadata: languageName: node linkType: hard -"@sourceacademy/conductor@npm:^0.8.2": - version: 0.8.2 - resolution: "@sourceacademy/conductor@npm:0.8.2" - checksum: 10c0/a37dbed6c9ff1f92cb1b7938366c4647cc7c89aaa54b9b843ceaa2088933e251b84b2acae61753bad599c11f0becf95013a771bd7c999735ed8896c3195c9f53 - languageName: node - linkType: hard - "@sourceacademy/conductor@npm:^0.8.3": version: 0.8.3 resolution: "@sourceacademy/conductor@npm:0.8.3" @@ -5077,14 +5070,16 @@ __metadata: version: 0.0.0-use.local resolution: "@sourceacademy/tab-RobotSimulation@workspace:src/tabs/RobotSimulation" dependencies: - "@blueprintjs/core": "npm:^6.0.0" - "@dimforge/rapier3d-compat": "npm:^0.11.2" "@sourceacademy/bundle-robot_simulation": "workspace:^" + "@sourceacademy/common-tabs": "npm:^0.0.1" + "@sourceacademy/conductor": "catalog:" "@sourceacademy/modules-buildtools": "workspace:^" "@sourceacademy/modules-lib": "workspace:^" "@types/react": "catalog:" + "@types/three": "npm:^0.185.0" react: "catalog:" react-dom: "catalog:" + three: "npm:^0.185.0" typescript: "catalog:" languageName: unknown linkType: soft From 07ace2be586613c73fd0479d6f069d69e31bd3ca Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Thu, 3 Sep 2026 23:38:56 +0800 Subject: [PATCH 03/12] robot_simulation: report World state after transitioning, not before World.setState() dispatched worldStateChange before assigning this.state, so every listener (including the tab's $worldStateChanged RPC) always observed the previous state - the tab's "World: ..." readout got stuck on "ready" forever, even once the world was actually "running". Assign the new state before dispatching the event. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Dji2eG7jb4tww8LowuSs7n --- src/bundles/robot_simulation/src/engine/World.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundles/robot_simulation/src/engine/World.ts b/src/bundles/robot_simulation/src/engine/World.ts index 3cfde942a1..311b910518 100644 --- a/src/bundles/robot_simulation/src/engine/World.ts +++ b/src/bundles/robot_simulation/src/engine/World.ts @@ -77,8 +77,8 @@ export class World extends TypedEventTarget { private setState(newState: WorldState) { if (this.state !== newState) { - this.dispatchEvent('worldStateChange', new Event('worldStateChange')); this.state = newState; + this.dispatchEvent('worldStateChange', new Event('worldStateChange')); } } From eeaa9f144b1d22aebf29a9061ab30ab5a6c36b9e Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Thu, 3 Sep 2026 23:39:09 +0800 Subject: [PATCH 04/12] robot_simulation: bring the default camera in close, add F-to-focus and a saved view The default camera sat 2.8m away from an EV3 that's only ~0.25m across, rendering the robot as a barely-visible speck. Default to a close-in, mostly-overhead ("bird's eye") framing instead - ~0.33m out, elevated to a steep angle - so the robot fills a comfortable fraction of the frame by default while still reading as a 3D object rather than a flat silhouette. Also, in the RobotSimulation tab: - "F to focus" (Unity/Blender-style): press F while the canvas has focus to recenter OrbitControls on the EV3's current (live) position and reframe based on its actual bounding box, preserving whatever orbit angle the viewer had set up. Scoped to the canvas only (not the whole page) so it doesn't steal "f" keystrokes from the code editor. - The viewer's camera position/orbit target now survives a reload or a re-run of the program (persisted to localStorage, keyed per-browser) instead of resetting to the default every time. - OrbitControls damping/zoom-speed tuned, and a short on-canvas hint line ("drag to orbit, scroll to zoom... press F to focus"). Verified with a headless Playwright run against a local rebuild: default framing screenshot, before/after F-focus after orbiting away, and a camera-position screenshot surviving a program re-run. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Dji2eG7jb4tww8LowuSs7n --- .../src/engine/Render/helpers/Camera.ts | 20 ++- .../robot_simulation/src/engine/index.ts | 2 +- src/tabs/RobotSimulation/src/index.tsx | 156 +++++++++++++++++- 3 files changed, 172 insertions(+), 6 deletions(-) diff --git a/src/bundles/robot_simulation/src/engine/Render/helpers/Camera.ts b/src/bundles/robot_simulation/src/engine/Render/helpers/Camera.ts index b08e2ce1db..8b19da6d5f 100644 --- a/src/bundles/robot_simulation/src/engine/Render/helpers/Camera.ts +++ b/src/bundles/robot_simulation/src/engine/Render/helpers/Camera.ts @@ -17,13 +17,27 @@ export type CameraOptions = | OrthographicCameraOptions | PerspectiveCameraOptions; -const setCameraPosition = (camera: THREE.Camera, position: THREE.Vector3) => { +/** + * Roughly where the EV3 spawns (see `chassisConfig.orientation.position` in + * controllers/ev3/ev3/default/config.ts) - the camera should default to looking here, not at the + * world origin, since the two aren't quite the same point and the gap matters at this scale. + */ +export const DEFAULT_LOOK_AT = new THREE.Vector3(0, 0.08, 0); + +const setCameraPosition = (camera: THREE.Camera, position: THREE.Vector3, lookAt: THREE.Vector3 = DEFAULT_LOOK_AT) => { camera.position.copy(position); - camera.lookAt(0, 0, 0); + camera.lookAt(lookAt); }; export function getCamera(cameraOptions: CameraOptions): THREE.Camera { - const defaultPosition = new THREE.Vector3(0, 2, -2); + // The EV3's chassis alone is ~0.145 x 0.18 x 0.095m (diagonal ~0.25m), and the full robot with + // wheels/motors attached is only a little larger than that - so a camera sitting 2.8m away (the + // old (0, 2, -2) default) rendered it as a barely-visible speck. ~0.33m out (under 1.5x the + // chassis diagonal) and mostly overhead (a steep ~73 degree elevation) gives a close-in bird's + // eye default view - the robot fills a large, comfortable fraction of the frame and its heading + // reads clearly, while the slight tilt (vs. a dead-straight-down 90 degrees) keeps it looking + // like a 3D object instead of a flat silhouette. + const defaultPosition = new THREE.Vector3(0, 0.32, -0.1); switch (cameraOptions.type) { case 'perspective': { const camera = new THREE.PerspectiveCamera( diff --git a/src/bundles/robot_simulation/src/engine/index.ts b/src/bundles/robot_simulation/src/engine/index.ts index ce3b935f26..89049b4cd8 100644 --- a/src/bundles/robot_simulation/src/engine/index.ts +++ b/src/bundles/robot_simulation/src/engine/index.ts @@ -7,6 +7,6 @@ export { ControllerGroup, type Controller, ControllerMap } from './Core/Controll export { Entity } from './Entity/Entity'; export * as EntityFactory from './Entity/EntityFactory'; export * as MeshFactory from './Render/helpers/MeshFactory'; -export { getCamera, type CameraOptions } from './Render/helpers/Camera'; +export { DEFAULT_LOOK_AT, getCamera, type CameraOptions } from './Render/helpers/Camera'; export { loadGLTF } from './Render/helpers/GLTF'; export { createScene } from './Render/helpers/Scene'; diff --git a/src/tabs/RobotSimulation/src/index.tsx b/src/tabs/RobotSimulation/src/index.tsx index 1d470ea316..0710fe9e21 100644 --- a/src/tabs/RobotSimulation/src/index.tsx +++ b/src/tabs/RobotSimulation/src/index.tsx @@ -1,5 +1,5 @@ import { sceneConfig } from '@sourceacademy/bundle-robot_simulation/config'; -import { MeshFactory, getCamera, loadGLTF } from '@sourceacademy/bundle-robot_simulation/engine'; +import { DEFAULT_LOOK_AT, MeshFactory, getCamera, loadGLTF } from '@sourceacademy/bundle-robot_simulation/engine'; import { ROBOT_SIMULATION_CONTROL_CHANNEL_ID, ROBOT_SIMULATION_STATE_CHANNEL_ID, @@ -27,6 +27,25 @@ interface ViewState { const MAX_LOGS = 200; +/** + * Where the viewer's last camera position/orbit target is remembered across a reload or a + * re-run of the program - see `__saveCameraView`/`__loadCameraView`. Keyed in `localStorage`, + * which is per-browser, not per-program-run - exactly "did the person looking at this tab move + * the camera" state, same category as "which tab is open", not simulation state. + */ +const CAMERA_VIEW_STORAGE_KEY = 'robot_simulation:camera-view'; + +type StoredCameraView = { + position: [number, number, number]; + target: [number, number, number]; +}; + +function isStoredCameraView(value: unknown): value is StoredCameraView { + const isVec3 = (v: unknown): v is [number, number, number] => Array.isArray(v) && v.length === 3 && v.every(n => typeof n === 'number' && Number.isFinite(n)); + return typeof value === 'object' && value !== null + && isVec3((value as StoredCameraView).position) && isVec3((value as StoredCameraView).target); +} + /** * Host-side (browser main thread) counterpart of `RobotSimulationModulePlugin` (in the * robot_simulation bundle) - owns everything DOM/WebGL-touching: `THREE.WebGLRenderer`, @@ -67,6 +86,17 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio the actual loaded model is added as its child once `loadGLTF` resolves. */ private readonly __entities = new Map(); + /** ids of every 'gltf'-kind entity seen so far - the EV3's chassis mesh (Mesh.ts) and its wheels + * (Motor.ts/Wheel.ts) are the *only* things this bundle ever spawns as 'gltf' (everything else - + * `createCuboid`/`createWall`/`createFloor`, `createPaper` - is 'cuboid'/'paper'), so this set is + * exactly "the EV3, as a set of parts" with no extra bookkeeping needed to tell it apart from + * other scene content. Used by {@link __focusOnEv3} ("F to focus", Unity/Blender-style). + */ + private readonly __ev3EntityIds = new Set(); + + private __keydownListener: ((e: KeyboardEvent) => void) | undefined; + private __controlsEndListener: (() => void) | undefined; + private __state: ViewState = { worldState: 'unintialized', sensors: undefined, @@ -174,6 +204,7 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio const holder = new THREE.Object3D(); this.__scene.add(holder); this.__entities.set(id, holder); + this.__ev3EntityIds.add(id); loadGLTF(descriptor.url, descriptor.dimension) .then(data => holder.add(data.scene)) .catch(error => console.warn('robot_simulation tab: failed to load GLTF asset:', descriptor.url, error)); @@ -196,6 +227,41 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio this.__renderer.setSize(sceneConfig.width, sceneConfig.height); this.__renderer.setPixelRatio(window.devicePixelRatio * 1.5); this.__controls = new OrbitControls(this.__camera, this.__renderer.domElement); + // Orbiting needs a target, not just a camera position - without this the controls' own + // `update()` (called every render frame in __tick) would silently re-point the camera back at + // the world origin (their default target) on the very first frame, undoing getCamera()'s own + // "look at the EV3's spawn point" default. + this.__controls.target.copy(DEFAULT_LOOK_AT); + this.__controls.enableDamping = true; + this.__controls.dampingFactor = 0.1; + this.__controls.zoomSpeed = 0.6; + + // A view the viewer already set up (by dragging/zooming, or F-focusing) survives a reload or + // a re-run of the program instead of snapping back to getCamera()'s default every time - see + // `__loadCameraView`/`__saveCameraView`. Only overrides the (camera, controls.target) pair + // just set above if something was actually saved. + this.__loadCameraView(); + this.__controls.update(); + + // "F to focus" (Unity/Blender-style): only wired to this canvas, not the page, and only fires + // while the canvas itself has focus - a plain keydown on `window` would steal every "f" + // keystroke typed anywhere else in the app (e.g. the code editor). `tabIndex` on the canvas + // (set in RobotSimulationView_) is what makes it focusable/receive keyboard events at all. + this.__keydownListener = (e: KeyboardEvent) => { + if (e.key === 'f' || e.key === 'F') { + e.preventDefault(); + this.__focusOnEv3(); + this.__saveCameraView(); + } + }; + canvas.addEventListener('keydown', this.__keydownListener); + + // Persist on 'end' (fired once per drag/zoom/pan gesture), not 'change' (fired continuously + // mid-gesture, dozens of times a second) - plenty responsive for "remember where I left the + // camera" without hammering localStorage on every frame of a drag. + this.__controlsEndListener = () => this.__saveCameraView(); + this.__controls.addEventListener('end', this.__controlsEndListener); + this.__requestId = window.requestAnimationFrame(this.__tick); } @@ -204,11 +270,94 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio window.cancelAnimationFrame(this.__requestId); this.__requestId = undefined; } + if (this.__keydownListener) { + this.__renderer?.domElement.removeEventListener('keydown', this.__keydownListener); + this.__keydownListener = undefined; + } + if (this.__controlsEndListener) { + this.__controls?.removeEventListener('end', this.__controlsEndListener); + this.__controlsEndListener = undefined; + } this.__controls?.dispose(); this.__controls = undefined; this.__renderer = undefined; } + /** + * Persists the current camera position + orbit target to `localStorage`, keyed per-browser (not + * per-program-run) - see {@link CAMERA_VIEW_STORAGE_KEY}. Best-effort: a private-browsing tab or + * a full storage quota throws on `setItem`, which just means the view doesn't stick - not worth + * failing the render loop over. + */ + private __saveCameraView(): void { + if (!this.__controls) return; + try { + const view: StoredCameraView = { + position: this.__camera.position.toArray(), + target: this.__controls.target.toArray(), + }; + window.localStorage.setItem(CAMERA_VIEW_STORAGE_KEY, JSON.stringify(view)); + } catch { + // See doc comment - not fatal. + } + } + + /** + * Counterpart to {@link __saveCameraView} - applies a previously-saved view, if any, to the + * current camera/controls. Leaves both at whatever `getCamera()`'s default already put them at + * if nothing was saved yet, or if what's stored doesn't parse as a valid view (e.g. an older + * format from a previous version of this tab). + */ + private __loadCameraView(): void { + if (!this.__controls) return; + try { + const raw = window.localStorage.getItem(CAMERA_VIEW_STORAGE_KEY); + if (!raw) return; + const parsed: unknown = JSON.parse(raw); + if (!isStoredCameraView(parsed)) return; + this.__camera.position.fromArray(parsed.position); + this.__controls.target.fromArray(parsed.target); + } catch { + // Corrupt/inaccessible storage - fall back to whatever's already set (the default view). + } + } + + /** Unity/Blender-style "F to focus selected", hardcoded to "selected" = the EV3 (the only thing + * a robot_simulation scene ever really has to look at - see `__ev3EntityIds`'s doc comment for + * why picking it out from arbitrary other scene content, e.g. walls/paper, needs no extra + * bookkeeping). Recenters `OrbitControls.target` on the EV3's current (live, physics-driven) + * position and pulls the camera to a distance based on its actual on-screen bounding box, + * preserving whatever orbit angle/direction the viewer had already set up rather than resetting + * it to some fixed "front" view. + */ + private __focusOnEv3(): void { + if (this.__ev3EntityIds.size === 0 || !this.__controls) return; + + const box = new THREE.Box3(); + let any = false; + for (const id of this.__ev3EntityIds) { + const node = this.__entities.get(id); + if (!node) continue; + box.expandByObject(node); + any = true; + } + if (!any || box.isEmpty()) return; + + const center = box.getCenter(new THREE.Vector3()); + const radius = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 0.05); + // ~2.2x the bounding radius keeps the whole robot comfortably inside the frame with a little + // margin, rather than filling it edge-to-edge. + const distance = radius * 2.2; + + const direction = this.__camera.position.clone().sub(this.__controls.target); + if (direction.lengthSq() === 0) direction.set(0, 0.35, -0.4); + direction.normalize().multiplyScalar(distance); + + this.__controls.target.copy(center); + this.__camera.position.copy(center).add(direction); + this.__controls.update(); + } + private __tick = (): void => { this.__requestId = window.requestAnimationFrame(this.__tick); this.__controls?.update(); @@ -244,7 +393,10 @@ function RobotSimulationView_({ state, canvasRef }: { state: ViewState, canvasRe boxShadow: 'inset 0 0 0 1px rgba(255, 255, 255, 0.2)', }} > - + +
+
+
Drag to orbit, scroll to zoom, click the view then press F to focus the robot
World: {state.worldState}
From 8e759a0a89d1ee98c1a0a39a67fee8cdfff1ad7c Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Fri, 4 Sep 2026 00:17:21 +0800 Subject: [PATCH 05/12] robot_simulation: add init_default_simulation for boilerplate-free setup Investigated whether the full Joel-faithful "instructor prepend / student control-code" split can be reached at the module level under Conductor. It can't yet: frontend's evalEditorSaga Conductor branch concatenates `${prepend}\n${studentCode}` into one plain string before it ever reaches an evaluator, and py-slang's PyCseEvaluator.evaluateChunk(chunk: string) only ever receives that single opaque string - no delimiter, chunk boundary, or line-count is forwarded into the evaluator or exposed to a running module (the frontend's own preludeLineOffset is saga-local bookkeeping for error line numbers, never sent over the wire). Reaching the real split needs new plumbing above this module (e.g. evaluateChunk taking structured {prepend, studentCode}) - out of scope here. As the documented middle ground, add init_default_simulation(control_code): one call that builds default physics/world/floor/ev3 and wires up a Python control program, collapsing the createPhysics/createWorld/createFloor/ createEv3/createPythonCSE/addControllerToWorld/saveToContext/init_simulation boilerplate a student previously had to write by hand. init_simulation still exists unchanged for anyone who needs a customised World. --- .../src/__tests__/index.test.ts | 104 ++++++++++++++++++ src/bundles/robot_simulation/src/index.ts | 64 +++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/bundles/robot_simulation/src/__tests__/index.test.ts diff --git a/src/bundles/robot_simulation/src/__tests__/index.test.ts b/src/bundles/robot_simulation/src/__tests__/index.test.ts new file mode 100644 index 0000000000..82116b50dc --- /dev/null +++ b/src/bundles/robot_simulation/src/__tests__/index.test.ts @@ -0,0 +1,104 @@ +import rapier from '@dimforge/rapier3d-compat'; +import { DataType } from '@sourceacademy/conductor/types'; +import { TestDataHandler, runAsyncGenerator, stringValue } from '@sourceacademy/modules-testplugin'; +import { describe, expect, test, vi } from 'vitest'; +import RobotSimulationModulePlugin from '..'; + +function makeRigidBody() { + return { + setTranslation: vi.fn(), + setRotation: vi.fn(), + translation: vi.fn(() => ({ x: 0, y: 0, z: 0 })), + rotation: vi.fn(() => ({ x: 0, y: 0, z: 0, w: 1 })), + linvel: vi.fn(() => ({ x: 0, y: 0, z: 0 })), + angvel: vi.fn(() => ({ x: 0, y: 0, z: 0 })), + applyImpulseAtPoint: vi.fn(), + }; +} + +function makeCollider() { + return { setMass: vi.fn(), mass: vi.fn(() => 0) }; +} + +// Same rapier mock as engine/__tests__/Physics.test.ts - init_default_simulation constructs a +// real Physics/World underneath, and this bundle otherwise has no way to step rapier's actual +// WASM in a plain vitest environment. +vi.mock(import('@dimforge/rapier3d-compat'), () => { + const mocked: typeof rapier = { + init: vi.fn(), + World: class { + timestep = vi.fn(); + createRigidBody = vi.fn(() => makeRigidBody()); + createCollider = vi.fn(() => makeCollider()); + castRayAndGetNormal = vi.fn(); + step = vi.fn(); + castRay = vi.fn(); + }, + Ray: vi.fn(), + RigidBodyDesc: { + fixed: vi.fn(() => ({})), + dynamic: vi.fn(() => ({})), + } as any, + ColliderDesc: { + cuboid: vi.fn(() => ({})), + } as any, + } as any; + return { default: mocked }; +}); + +function makePlugin() { + const controlChannel = { send: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(), close: vi.fn(), name: 'control' }; + const stateChannel = { send: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn(), close: vi.fn(), name: 'state' }; + const evaluator = new TestDataHandler(); + const tabLoader = { tabs: ['RobotSimulation'], loadTab: vi.fn() }; + const plugin = new RobotSimulationModulePlugin( + {} as any, + [controlChannel, stateChannel] as any, + evaluator, + tabLoader + ); + return { plugin, evaluator, controlChannel, stateChannel, tabLoader }; +} + +describe(RobotSimulationModulePlugin, () => { + test('every exported name carries an attached signature', () => { + const { plugin } = makePlugin(); + const missing = plugin.exportedNames.filter(name => { + const method: unknown = (plugin as any)[name]; + return typeof method !== 'function' + || (method as { signature?: unknown }).signature === undefined; + }); + + expect(missing).toStrictEqual([]); + }); + + describe('init_default_simulation', () => { + test('builds a default world, loads the tab, and starts the simulation from one call', async () => { + const { plugin, tabLoader } = makePlugin(); + + const result = await runAsyncGenerator( + (plugin as any).init_default_simulation(stringValue('ev3_pause(1)\n')) + ); + + expect(result).toStrictEqual({ type: DataType.VOID, value: undefined }); + expect(tabLoader.loadTab).toHaveBeenCalledWith('RobotSimulation'); + }); + + test('is idempotent - a second call is a no-op once a world already exists', async () => { + const { plugin, controlChannel } = makePlugin(); + + await runAsyncGenerator((plugin as any).init_default_simulation(stringValue('ev3_pause(1)\n'))); + const callsAfterFirst = controlChannel.send.mock.calls.length; + await runAsyncGenerator((plugin as any).init_default_simulation(stringValue('ev3_pause(2)\n'))); + + // No new world was built (and hence no new worldStateChanged RPC was queued) the second time. + expect(controlChannel.send.mock.calls.length).toBe(callsAfterFirst); + }); + + test('declares a single CONST_STRING parameter', () => { + const { signature } = (RobotSimulationModulePlugin.prototype as any).init_default_simulation; + expect(signature.args).toStrictEqual([DataType.CONST_STRING]); + expect(signature.returnType).toBe(DataType.VOID); + }); + }); +}); diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index 00a50c3816..5ae4e86e3e 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -81,6 +81,7 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { 'addControllerToWorld', 'saveToContext', 'init_simulation', + 'init_default_simulation', 'ev3_motorA', 'ev3_motorB', 'ev3_motorC', @@ -384,6 +385,68 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return { type: DataType.VOID, value: undefined }; } + /** + * The boilerplate-free alternative to `init_simulation`: builds default physics, a default + * world, a default floor and a default EV3 (the same defaults `createPhysics`/`createFloor`/ + * `createEv3` use), wires `control_code` up as the robot's Python control program, and starts + * the simulation - all in one call. + * + * This exists because a true "prepend" split - where the instructor's scene setup runs + * invisibly ahead of the student's own code, and the student never calls a setup function or + * wraps their control code in a string at all - is not reachable at this module's level today: + * Conductor's frontend already concatenates `${prepend}\n${studentCode}` into one source string + * before it ever reaches an evaluator (see evalEditorSaga's Conductor branch in + * source-academy/frontend), and `PyCseEvaluator.evaluateChunk(chunk: string)` (py-slang) only + * ever receives that single opaque string - there is no delimiter, chunk boundary, or line + * count forwarded into the evaluator or exposed to a running module that would let + * `robot_simulation` tell "instructor-authored setup" and "student code" apart at runtime. The + * `lineOffset`/`preludeLineOffset` value the frontend does track lives only in its own saga, for + * shifting reported error line numbers back after the fact - it is never sent over the wire. + * Reaching the real Joel-faithful split would need new plumbing above this module (e.g. + * evaluateChunk taking a structured `{ prepend, studentCode }` instead of one string, and + * GenericDataHandler/IDataHandler exposing the boundary to a module) - out of scope here. + * + * For a customised World (non-default physics/gravity, extra walls or paper, a control program + * written in Source/Scheme instead of Python), use `createPhysics`/`createWorld`/`createWall`/ + * `createPaper`/`createPythonCSE`/`addControllerToWorld`/`saveToContext`/`init_simulation` + * directly instead, exactly as before. + * + * @param control_code The robot's control program, written in Python (SICPy §4). + */ + async* init_default_simulation( + control_code: TypedValue + ): AsyncGenerator, undefined> { + if (this.__state.world !== undefined) { + return { type: DataType.VOID, value: undefined }; + } + this.__ensureTabLoaded(); + + const physics = new Physics({ gravity: { x: 0, y: -9.81, z: 0 }, timestep: 1 / 20 }); + const world = new World(physics, new Timer(), new RobotConsole()); + const floor = this.__createCuboid( + physics, + { x: 0, y: -0.5, z: 0 }, + { width: 20, length: 20, height: 1 }, + 1, + 'white', + 'fixed' + ); + const ev3 = createDefaultEv3(physics, this.__sceneRegistry, ev3Config); + const pyContext = createRobotPythonContext(this.__ev3Fns, () => this.__getWorldFromContext()); + const program = new Program(control_code.value, undefined, pyContext); + + world.addController(floor, ev3, program); + + this.__state.world = world; + this.__state.ev3 = ev3; + + this.__hookWorld(world); + await world.init(); + world.start(); + + return { type: DataType.VOID, value: undefined }; + } + // [EV3] async* ev3_motorA(): AsyncGenerator, undefined> { @@ -459,6 +522,7 @@ attachModuleMethod(RobotSimulationModulePlugin, 'createPythonCSE', [DataType.CON attachModuleMethod(RobotSimulationModulePlugin, 'addControllerToWorld', [DataType.OPAQUE, DataType.OPAQUE], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'saveToContext', [DataType.CONST_STRING, DataType.OPAQUE], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'init_simulation', [DataType.CLOSURE], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'init_default_simulation', [DataType.CONST_STRING], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorA', [], DataType.OPAQUE); attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorB', [], DataType.OPAQUE); attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorC', [], DataType.OPAQUE); From 1e198bc042947796305d157bd483a86110ce93cc Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Fri, 4 Sep 2026 18:58:20 +0800 Subject: [PATCH 06/12] robot_simulation: split scene setup from robot code via the repl module init_default_simulation() now only builds the default scene (no more control_code string arg). Two new pieces support the split: - add_wall/add_paper: friendly wrappers so a student can customise the live scene from the main pane without touching physics/world handles. - run_robot_code(code): pass this to repl's set_evaluator so the robot's own code runs from a separate, rerunnable REPL tab instead of a string baked into setup. Reuses one py-slang Context across runs (REPL-style variable persistence) and stops the previous run's Program before starting a new one so they don't stomp the shared context mid-flight. World gains addLiveController for adding a controller to a world that's already running (addController's start() hookup only fires on the worldStart event, which already happened by the time a student calls add_wall/run_robot_code). --- .../src/__tests__/index.test.ts | 67 +++++++- .../src/controllers/program/Program.ts | 13 +- .../robot_simulation/src/engine/World.ts | 17 ++ src/bundles/robot_simulation/src/index.ts | 148 ++++++++++++++---- 4 files changed, 202 insertions(+), 43 deletions(-) diff --git a/src/bundles/robot_simulation/src/__tests__/index.test.ts b/src/bundles/robot_simulation/src/__tests__/index.test.ts index 82116b50dc..26bcba3627 100644 --- a/src/bundles/robot_simulation/src/__tests__/index.test.ts +++ b/src/bundles/robot_simulation/src/__tests__/index.test.ts @@ -1,6 +1,6 @@ import rapier from '@dimforge/rapier3d-compat'; import { DataType } from '@sourceacademy/conductor/types'; -import { TestDataHandler, runAsyncGenerator, stringValue } from '@sourceacademy/modules-testplugin'; +import { TestDataHandler, numberValue, runAsyncGenerator, stringValue } from '@sourceacademy/modules-testplugin'; import { describe, expect, test, vi } from 'vitest'; import RobotSimulationModulePlugin from '..'; @@ -76,9 +76,7 @@ describe(RobotSimulationModulePlugin, () => { test('builds a default world, loads the tab, and starts the simulation from one call', async () => { const { plugin, tabLoader } = makePlugin(); - const result = await runAsyncGenerator( - (plugin as any).init_default_simulation(stringValue('ev3_pause(1)\n')) - ); + const result = await runAsyncGenerator((plugin as any).init_default_simulation()); expect(result).toStrictEqual({ type: DataType.VOID, value: undefined }); expect(tabLoader.loadTab).toHaveBeenCalledWith('RobotSimulation'); @@ -87,18 +85,71 @@ describe(RobotSimulationModulePlugin, () => { test('is idempotent - a second call is a no-op once a world already exists', async () => { const { plugin, controlChannel } = makePlugin(); - await runAsyncGenerator((plugin as any).init_default_simulation(stringValue('ev3_pause(1)\n'))); + await runAsyncGenerator((plugin as any).init_default_simulation()); const callsAfterFirst = controlChannel.send.mock.calls.length; - await runAsyncGenerator((plugin as any).init_default_simulation(stringValue('ev3_pause(2)\n'))); + await runAsyncGenerator((plugin as any).init_default_simulation()); // No new world was built (and hence no new worldStateChanged RPC was queued) the second time. expect(controlChannel.send.mock.calls.length).toBe(callsAfterFirst); }); - test('declares a single CONST_STRING parameter', () => { + test('declares no parameters', () => { const { signature } = (RobotSimulationModulePlugin.prototype as any).init_default_simulation; - expect(signature.args).toStrictEqual([DataType.CONST_STRING]); + expect(signature.args).toStrictEqual([]); expect(signature.returnType).toBe(DataType.VOID); }); }); + + describe('add_wall / add_paper', () => { + test('add a controller to the already-running default world without needing physics/world handles', async () => { + const { plugin } = makePlugin(); + await runAsyncGenerator((plugin as any).init_default_simulation()); + + const world = (plugin as any).__state.world; + const controllersBefore = world.controllers.controllers.length; + + await runAsyncGenerator( + (plugin as any).add_wall( + numberValue(0), numberValue(3), numberValue(2), numberValue(0.2), numberValue(1) + ) + ); + await runAsyncGenerator( + (plugin as any).add_paper( + stringValue('red.png'), numberValue(1), numberValue(1), numberValue(0), numberValue(1), numberValue(0) + ) + ); + + // Both controllers were added live (start() already fired) rather than only queued for a + // future worldStart that already happened. + expect(world.controllers.controllers.length).toBe(controllersBefore + 2); + }); + }); + + describe('run_robot_code', () => { + test('drives the shared robot Python context across repeated calls, sharing state between runs', async () => { + const { plugin } = makePlugin(); + await runAsyncGenerator((plugin as any).init_default_simulation()); + + await runAsyncGenerator((plugin as any).run_robot_code(stringValue('x = 1'))); + const firstProgram = (plugin as any).__state.replProgram; + + await runAsyncGenerator((plugin as any).run_robot_code(stringValue('y = x + 1'))); + const secondProgram = (plugin as any).__state.replProgram; + + // A fresh Program per call... + expect(secondProgram).not.toBe(firstProgram); + // ...but the first one is stopped so it can't keep pumping the shared pyContext. + expect((firstProgram as any).isStopped).toBe(true); + // ...and both runs share the same pyContext, so `y = x + 1` could resolve `x` at all + // (analyzePython would have thrown a NameError otherwise - see evaluate.ts). + expect((plugin as any).__state.replPyContext).toBeDefined(); + }); + + test('throws if the world has not been initialised yet', async () => { + const { plugin } = makePlugin(); + await expect( + runAsyncGenerator((plugin as any).run_robot_code(stringValue('ev3_pause(1)'))) + ).rejects.toThrow(); + }); + }); }); diff --git a/src/bundles/robot_simulation/src/controllers/program/Program.ts b/src/bundles/robot_simulation/src/controllers/program/Program.ts index 4265477d26..017fdd382a 100644 --- a/src/bundles/robot_simulation/src/controllers/program/Program.ts +++ b/src/bundles/robot_simulation/src/controllers/program/Program.ts @@ -50,6 +50,13 @@ export class Program implements Controller { * stashed here and re-thrown from the *next* `fixedUpdate` call instead, so it still surfaces to (and is convertible by) the same call site a synchronous evaluator would throw from. */ private pendingError: unknown = null; + /** Set by `stop()` when a newer REPL run replaces this one - see `run_robot_code` (index.ts). + * Prevents this Program from pumping its (now-superseded) generator any further; several + * `Program`s can share one `pyContext` across REPL re-runs (that's how variables persist between + * runs), and `runPythonECEvaluator` reassigns `context.control`/`context.stash` at the *start* of + * a run, so an old Program still ticking after a new one has started would corrupt the new run's + * state. */ + private isStopped = false; isPaused: boolean; callbackHandler = new CallbackHandler(); name: string; @@ -78,6 +85,10 @@ export class Program implements Controller { }, pauseDuration); } + stop() { + this.isStopped = true; + } + start() { this.iterator = runPythonECEvaluator(this.code, this.pyContext, { stepLimit: -1, @@ -92,7 +103,7 @@ export class Program implements Controller { * still-in-flight `await`s. */ fixedUpdate() { - if (this.isPaused) { + if (this.isStopped || this.isPaused) { return; } diff --git a/src/bundles/robot_simulation/src/engine/World.ts b/src/bundles/robot_simulation/src/engine/World.ts index 311b910518..2f21ebe7ad 100644 --- a/src/bundles/robot_simulation/src/engine/World.ts +++ b/src/bundles/robot_simulation/src/engine/World.ts @@ -68,6 +68,23 @@ export class World extends TypedEventTarget { }); } + /** + * Adds controllers to a world that has already started (state is 'ready'/'running'/'error') - + * `addController`'s `start()` hookup only fires on the `worldStart` event, which for a live + * world already happened once inside `init()`, so a controller added afterwards would never get + * its `start()` called and would throw the first time it ticks (e.g. `Program.fixedUpdate`'s + * "Program not started"). Used by `run_robot_code` to add a fresh `Program` controller each time + * the REPL tab's registered evaluator runs, without restarting the whole world. + */ + addLiveController(...controllers: Controller[]) { + this.addController(...controllers); + if (this.state !== 'unintialized' && this.state !== 'loading') { + controllers.forEach((controller) => { + controller.start?.(); + }); + } + } + async init() { this.setState('loading'); await this.physics.start(); diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index 5ae4e86e3e..32a561435f 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -13,12 +13,18 @@ * physics tick over a dedicated channel, mirroring pix_n_flix's frame channel - see protocol.ts * and SceneRegistry's doc comment for the full design. * - * `from robot_simulation import ...` still only works for the *setup* program, not the robot's - * own control program string passed to `createPythonCSE`: that string is evaluated by a private, - * hand-built py-slang `Context` (see controllers/program/pythonRuntime.ts) stepped in lockstep - * with the physics tick, entirely separate from Conductor's own evaluator/module-loading - * machinery - there is no `ModuleLoaderRunnerPlugin` inside that shadow context for an `import` to - * resolve through. The `ev3_*` API is available to it directly by name instead (no import). + * `from robot_simulation import ...` still only works for the *setup* program, not the robot's own + * control program: whether that code arrives as a string literal (`createPythonCSE`) or from the + * `repl` tab (`run_robot_code`), it's evaluated by a private, hand-built py-slang `Context` (see + * controllers/program/pythonRuntime.ts) stepped in lockstep with the physics tick, entirely + * separate from Conductor's own evaluator/module-loading machinery - there is no + * `ModuleLoaderRunnerPlugin` inside that shadow context for an `import` to resolve through. The + * `ev3_*` API is available to it directly by name instead (no import). + * + * The recommended student-facing shape is: `init_default_simulation()` + `add_wall`/`add_paper` + * calls in the main pane (one-time scene setup), then `set_evaluator(run_robot_code)` (from the + * `repl` module) to hand the robot's own code to a separate, rerunnable REPL tab - see + * `run_robot_code`'s doc comment. * * @module robot_simulation * @author Joel Chan @@ -82,6 +88,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { 'saveToContext', 'init_simulation', 'init_default_simulation', + 'add_wall', + 'add_paper', + 'run_robot_code', 'ev3_motorA', 'ev3_motorB', 'ev3_motorC', @@ -388,34 +397,16 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { /** * The boilerplate-free alternative to `init_simulation`: builds default physics, a default * world, a default floor and a default EV3 (the same defaults `createPhysics`/`createFloor`/ - * `createEv3` use), wires `control_code` up as the robot's Python control program, and starts - * the simulation - all in one call. - * - * This exists because a true "prepend" split - where the instructor's scene setup runs - * invisibly ahead of the student's own code, and the student never calls a setup function or - * wraps their control code in a string at all - is not reachable at this module's level today: - * Conductor's frontend already concatenates `${prepend}\n${studentCode}` into one source string - * before it ever reaches an evaluator (see evalEditorSaga's Conductor branch in - * source-academy/frontend), and `PyCseEvaluator.evaluateChunk(chunk: string)` (py-slang) only - * ever receives that single opaque string - there is no delimiter, chunk boundary, or line - * count forwarded into the evaluator or exposed to a running module that would let - * `robot_simulation` tell "instructor-authored setup" and "student code" apart at runtime. The - * `lineOffset`/`preludeLineOffset` value the frontend does track lives only in its own saga, for - * shifting reported error line numbers back after the fact - it is never sent over the wire. - * Reaching the real Joel-faithful split would need new plumbing above this module (e.g. - * evaluateChunk taking a structured `{ prepend, studentCode }` instead of one string, and - * GenericDataHandler/IDataHandler exposing the boundary to a module) - out of scope here. + * `createEv3` use), and starts the simulation - all in one call. Takes no control program: pair + * this with `run_robot_code`/the `repl` module (see that method's doc comment) to drive the EV3 + * from a separate, rerunnable REPL tab instead of a control-code string baked into setup. * - * For a customised World (non-default physics/gravity, extra walls or paper, a control program - * written in Source/Scheme instead of Python), use `createPhysics`/`createWorld`/`createWall`/ + * For a customised World (non-default physics/gravity, a control program written in + * Source/Scheme instead of Python), use `createPhysics`/`createWorld`/`createWall`/ * `createPaper`/`createPythonCSE`/`addControllerToWorld`/`saveToContext`/`init_simulation` * directly instead, exactly as before. - * - * @param control_code The robot's control program, written in Python (SICPy §4). */ - async* init_default_simulation( - control_code: TypedValue - ): AsyncGenerator, undefined> { + async* init_default_simulation(): AsyncGenerator, undefined> { if (this.__state.world !== undefined) { return { type: DataType.VOID, value: undefined }; } @@ -432,10 +423,8 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { 'fixed' ); const ev3 = createDefaultEv3(physics, this.__sceneRegistry, ev3Config); - const pyContext = createRobotPythonContext(this.__ev3Fns, () => this.__getWorldFromContext()); - const program = new Program(control_code.value, undefined, pyContext); - world.addController(floor, ev3, program); + world.addController(floor, ev3); this.__state.world = world; this.__state.ev3 = ev3; @@ -447,6 +436,94 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return { type: DataType.VOID, value: undefined }; } + /** + * Adds a fixed yellow wall to the already-initialised default world (`init_default_simulation` + * must have been called first) - a friendly wrapper over `createWall`/`addControllerToWorld` + * that doesn't need `physics`/`world` opaque handles, since `init_default_simulation` already + * owns both. Uses `World.addLiveController` rather than `addController` because the world is + * already running by the time a student calls this from the setup pane. + */ + async* add_wall( + x: TypedValue, + y: TypedValue, + width: TypedValue, + length: TypedValue, + height: TypedValue + ): AsyncGenerator, undefined> { + const world = this.__getWorldFromContext(); + const wall = this.__createCuboid( + world.physics, + { x: x.value, y: height.value / 2, z: y.value }, + { width: width.value, length: length.value, height: height.value }, + 1, + 'yellow', + 'fixed' + ); + world.addLiveController(wall); + return { type: DataType.VOID, value: undefined }; + } + + /** + * Adds a visual (non-collidable - see Paper.ts's doc comment) floor overlay to the + * already-initialised default world - a friendly wrapper over `createPaper`/ + * `addControllerToWorld` for the same reason as `add_wall`. + */ + async* add_paper( + url: TypedValue, + width: TypedValue, + height: TypedValue, + x: TypedValue, + y: TypedValue, + rotation: TypedValue + ): AsyncGenerator, undefined> { + const world = this.__getWorldFromContext(); + const paper = new Paper(this.__sceneRegistry, { + url: url.value, + dimension: { width: width.value, height: height.value }, + position: { x: x.value, y: y.value }, + rotation: (rotation.value * Math.PI) / 180, + }); + world.addLiveController(paper); + return { type: DataType.VOID, value: undefined }; + } + + /** + * The `repl`-module hook: pass this function itself to `repl`'s `set_evaluator`, and the `repl` + * tab it opens will call it with whatever the student typed there each time they hit Run - + * `code` is exactly what `createPythonCSE`/`init_default_simulation`'s old `control_code` + * argument used to be, just supplied interactively instead of baked into the setup program. + * + * Each call adds a fresh `Program` controller (via `World.addLiveController`, since the world is + * already running by this point) rather than editing one in place, but all calls share one + * `pyContext` (lazily created on the first call, cached in `__state`) - `runPythonECEvaluator` + * re-analyzes each run against that same context's global environment (see evaluate.ts), so + * variables and function defs a student's REPL code creates in one run are still visible in the + * next, the way a REPL is expected to behave. The previous run's `Program` is `stop()`'d first so + * it can't keep pumping its now-superseded generator against the same shared `pyContext` (see + * `Program.stop`'s doc comment). + * + * Requires `init_default_simulation`/`init_simulation` to have already been called - there must + * be a live World for the robot code to act on. + */ + async* run_robot_code( + code: TypedValue + ): AsyncGenerator, undefined> { + const world = this.__getWorldFromContext(); + + if (this.__state.replPyContext === undefined) { + this.__state.replPyContext = createRobotPythonContext(this.__ev3Fns, () => this.__getWorldFromContext()); + } + const pyContext = this.__state.replPyContext as ReturnType; + + (this.__state.replProgram as Program | undefined)?.stop(); + + const program = new Program(code.value, undefined, pyContext); + world.addLiveController(program); + this.__state.replProgram = program; + + return { type: DataType.VOID, value: undefined }; + } + // [EV3] async* ev3_motorA(): AsyncGenerator, undefined> { @@ -522,7 +599,10 @@ attachModuleMethod(RobotSimulationModulePlugin, 'createPythonCSE', [DataType.CON attachModuleMethod(RobotSimulationModulePlugin, 'addControllerToWorld', [DataType.OPAQUE, DataType.OPAQUE], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'saveToContext', [DataType.CONST_STRING, DataType.OPAQUE], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'init_simulation', [DataType.CLOSURE], DataType.VOID); -attachModuleMethod(RobotSimulationModulePlugin, 'init_default_simulation', [DataType.CONST_STRING], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'init_default_simulation', [], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'add_wall', [DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'add_paper', [DataType.CONST_STRING, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'run_robot_code', [DataType.CONST_STRING], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorA', [], DataType.OPAQUE); attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorB', [], DataType.OPAQUE); attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorC', [], DataType.OPAQUE); From 85ae9db8a538313e2b7ff005e61c8228cdb0b009 Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Fri, 4 Sep 2026 19:40:54 +0800 Subject: [PATCH 07/12] robot_simulation/repl: auto-switch tabs on run, default to a focused camera - run_robot_code (robot_simulation) now asks its own tab to come to the front once a run is successfully handed to the physics loop, so driving the robot from the Repl tab brings the 3D view into focus automatically. - set_evaluator (repl) now explicitly asks the Repl tab to come to the front on success, so finishing setup in the main pane lands the student on the Repl tab next instead of leaving them on the editor. Both are no-ops on failure (thrown before reaching the focus call), so an error stays wherever it's already shown rather than yanking focus. - RobotSimulation tab: the camera defaults to the same framing "F to focus" produces (centered/scaled to the EV3's live bounding box) the first time the EV3 actually appears, instead of leaving new viewers staring at whatever getCamera()'s hardcoded default happens to show. Skipped once the viewer has a deliberate view (a saved one, or a manual F-focus/drag already this session). --- src/bundles/repl/src/index.ts | 7 +++++ src/bundles/repl/src/protocol.ts | 10 +++++- src/bundles/robot_simulation/src/index.ts | 12 +++++++ src/bundles/robot_simulation/src/protocol.ts | 4 +++ src/tabs/Repl/src/index.tsx | 3 ++ src/tabs/RobotSimulation/src/index.tsx | 33 +++++++++++++++++--- 6 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/bundles/repl/src/index.ts b/src/bundles/repl/src/index.ts index 4a58ac6d0c..1a48529ffc 100644 --- a/src/bundles/repl/src/index.ts +++ b/src/bundles/repl/src/index.ts @@ -144,6 +144,13 @@ export default class ReplModulePlugin extends BaseModulePlugin { // this, a program whose only interaction with the module is set_evaluator() never opens the // tab at all, since nothing else would ever call __loadReplTab() first. this.__loadReplTab(); + // Explicit, not just a side effect of the tab's own constructor already calling showTab once: + // a program that calls set_evaluator() again later (or whose module import order put another + // tab-opening call after this one) still ends up back on the Repl tab, since that's what + // set_evaluator succeeding means for the student - "go use the Repl now". Dropped if nothing's + // subscribed yet (the very first call, mid-tab-bootstrap) - harmless, since the tab's own + // constructor already calls showTab unconditionally once it exists. + this.__replChannel.send({ type: 'focus' }); return mVoid(); } diff --git a/src/bundles/repl/src/protocol.ts b/src/bundles/repl/src/protocol.ts index a506dec74f..97e7f596cf 100644 --- a/src/bundles/repl/src/protocol.ts +++ b/src/bundles/repl/src/protocol.ts @@ -39,7 +39,15 @@ export type ReplSetProgramTextMessage = { text: string; }; -export type ReplDisplayMessage = ReplOutputMessage | ReplEditorPropsMessage | ReplSetProgramTextMessage; +/** Bundle -> tab: bring this tab to the front - sent once `set_evaluator` registers successfully, + * so a program that just finished wiring up the Repl (per the module's documented usage: setup in + * the main pane, then `set_evaluator` as its last step) lands the student on the Repl tab next, + rather than leaving them on the editor they just ran. */ +export type ReplFocusMessage = { + type: 'focus'; +}; + +export type ReplDisplayMessage = ReplOutputMessage | ReplEditorPropsMessage | ReplSetProgramTextMessage | ReplFocusMessage; /** Tab -> bundle: run this code through whatever evaluator was registered via set_evaluator. */ export type ReplRunMessage = { diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index 32a561435f..47695a939c 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -504,6 +504,16 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { * * Requires `init_default_simulation`/`init_simulation` to have already been called - there must * be a live World for the robot code to act on. + * + * On success, brings the RobotSimulation tab to the front (`$focusTab`) - a student driving the + * robot from the `repl` tab wants to watch it, not keep looking at the editor they just ran code + * from. Only reached once every synchronous precondition above has passed, so a call that fails + * before this point (no World yet) leaves the `repl` tab showing its own error message instead + * of yanking focus away from it. A *runtime* error in the robot code itself (a Python exception + * partway through, which - see `Program.fixedUpdate`'s doc comment - only ever surfaces several + * physics ticks later) still ends up shown exactly where this just switched to: the RobotSimulation + * tab's own Robot Console (routed via `robotConsoleStreams`/`World.step`'s catch), not the `repl` + * tab. */ async* run_robot_code( code: TypedValue @@ -521,6 +531,8 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { world.addLiveController(program); this.__state.replProgram = program; + this.__tabRpc.$focusTab(); + return { type: DataType.VOID, value: undefined }; } diff --git a/src/bundles/robot_simulation/src/protocol.ts b/src/bundles/robot_simulation/src/protocol.ts index 0e4c1d9cdc..65b5246971 100644 --- a/src/bundles/robot_simulation/src/protocol.ts +++ b/src/bundles/robot_simulation/src/protocol.ts @@ -81,4 +81,8 @@ export interface RobotSimulationTabRpc { $consoleLog(message: string, level: 'error' | 'source'): void; $worldStateChanged(state: WorldStateName): void; $sensorSnapshot(snapshot: SensorSnapshot): void; + /** Brings the RobotSimulation tab to the front - sent once `run_robot_code` successfully hands a + * fresh run to the physics loop, so a student driving the robot from the `repl` tab lands back + on the 3D view to watch it, without having to switch tabs manually. */ + $focusTab(): void; } diff --git a/src/tabs/Repl/src/index.tsx b/src/tabs/Repl/src/index.tsx index 5db07413fc..eac5afdfb9 100644 --- a/src/tabs/Repl/src/index.tsx +++ b/src/tabs/Repl/src/index.tsx @@ -177,6 +177,9 @@ export default class ReplTabPlugin implements IPlugin { case 'set_program_text': this.__setProgramText(message.text); break; + case 'focus': + this.__tabService.showTab(REPL_TAB_ID); + break; case 'run': case 'request': // Tab -> bundle messages, never received here - ReplChannelMessage's union just describes diff --git a/src/tabs/RobotSimulation/src/index.tsx b/src/tabs/RobotSimulation/src/index.tsx index 0710fe9e21..01f0a028f8 100644 --- a/src/tabs/RobotSimulation/src/index.tsx +++ b/src/tabs/RobotSimulation/src/index.tsx @@ -96,6 +96,11 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio private __keydownListener: ((e: KeyboardEvent) => void) | undefined; private __controlsEndListener: (() => void) | undefined; + /** True once either a saved view was loaded or the EV3 has been auto-focused - either way, the + * viewer already has a deliberate view, so `__applySnapshot` shouldn't keep re-focusing on every + * subsequent snapshot (which would fight `OrbitControls` while the viewer is mid-drag). See + `__attachCanvas`/`__applySnapshot`. */ + private __hasDeliberateView = false; private __state: ViewState = { worldState: 'unintialized', @@ -219,6 +224,18 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio node.position.set(view[offset + 1], view[offset + 2], view[offset + 3]); node.quaternion.set(view[offset + 4], view[offset + 5], view[offset + 6], view[offset + 7]); } + + // The EV3's spawn position isn't known until its first transform snapshot lands (it's created + // at the origin - see __spawnEntity's placeholder), so "default to the F-focused view" can only + // happen here, on the first snapshot that actually contains it - not at canvas-attach time, + // when __focusOnEv3 would find an empty/zero-size bounding box and no-op. Skipped once the + // viewer already has a deliberate view (a saved one, or a manual F-focus/drag already happened + // this session), so this never fights `OrbitControls` mid-interaction - see + // `__hasDeliberateView`'s doc comment. + if (!this.__hasDeliberateView && this.__ev3EntityIds.size > 0 && this.__controls) { + this.__hasDeliberateView = true; + this.__focusOnEv3(); + } } private __attachCanvas(canvas: HTMLCanvasElement): void { @@ -240,7 +257,7 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio // a re-run of the program instead of snapping back to getCamera()'s default every time - see // `__loadCameraView`/`__saveCameraView`. Only overrides the (camera, controls.target) pair // just set above if something was actually saved. - this.__loadCameraView(); + this.__hasDeliberateView = this.__loadCameraView(); this.__controls.update(); // "F to focus" (Unity/Blender-style): only wired to this canvas, not the page, and only fires @@ -308,17 +325,19 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio * if nothing was saved yet, or if what's stored doesn't parse as a valid view (e.g. an older * format from a previous version of this tab). */ - private __loadCameraView(): void { - if (!this.__controls) return; + private __loadCameraView(): boolean { + if (!this.__controls) return false; try { const raw = window.localStorage.getItem(CAMERA_VIEW_STORAGE_KEY); - if (!raw) return; + if (!raw) return false; const parsed: unknown = JSON.parse(raw); - if (!isStoredCameraView(parsed)) return; + if (!isStoredCameraView(parsed)) return false; this.__camera.position.fromArray(parsed.position); this.__controls.target.fromArray(parsed.target); + return true; } catch { // Corrupt/inaccessible storage - fall back to whatever's already set (the default view). + return false; } } @@ -378,6 +397,10 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio $sensorSnapshot(snapshot: SensorSnapshot): void { this.__setState({ sensors: snapshot }); } + + $focusTab(): void { + this.__tabService.showTab(ROBOT_SIMULATION_TAB_ID); + } } checkIsPluginClass(RobotSimulationTabPlugin); From 89633ea6b2648f39791eb8293e7be94bb87ee93f Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Fri, 4 Sep 2026 20:02:51 +0800 Subject: [PATCH 08/12] repl: guard set_evaluator's focus send against the tab-bootstrap race set_evaluator's 'focus' send raced loadTab()'s async, cross-thread tab bootstrap - sent before the Repl tab had subscribed, it was silently dropped. Now deferred to the tab's own 'request' handshake (sent right after it subscribes) when the tab hasn't connected yet, so the message can no longer go out before anyone's listening. Also fixes the RobotSimulation tab's default-camera-focus flag being sticky for the tab's whole lifetime instead of per Run: re-running the program left the camera wherever a previous run's auto-focus (or the viewer's own drag) had settled, even though the new run's World/EV3 may not be there any more. Reset on every fresh World ($worldStateChanged 'loading'), re-checking localStorage instead of unconditionally clearing so a saved view still survives a re-run as before. --- src/bundles/repl/src/index.ts | 26 +++++++++++-- src/tabs/RobotSimulation/src/index.tsx | 51 ++++++++++++++++++++------ 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src/bundles/repl/src/index.ts b/src/bundles/repl/src/index.ts index 1a48529ffc..205c7e43e4 100644 --- a/src/bundles/repl/src/index.ts +++ b/src/bundles/repl/src/index.ts @@ -82,6 +82,11 @@ export default class ReplModulePlugin extends BaseModulePlugin { private __evaluator: TypedValue | undefined; private __tabLoaded = false; private __tabRequested = false; + // Set by set_evaluator() when the Repl tab hasn't connected yet (loadTab() just kicked off an + // async, cross-thread tab bootstrap - see the 'request' handler below) - the focus send that + // would otherwise race that bootstrap is deferred until the tab's own 'request' arrives, + // guaranteeing it's actually subscribed by then instead of silently dropping the message. + private __focusOnConnect = false; // Guards against two overlapping __runCode calls (e.g. a fast double-click on Run) driving the // same evaluator closure concurrently - most evaluators (a tree-walking/CSE-machine interpreter) // assume single-threaded, sequential calls and aren't safe to re-enter. @@ -115,6 +120,14 @@ export default class ReplModulePlugin extends BaseModulePlugin { this.__outputHistory.forEach(entry => this.__replChannel.send(entry)); if (this.__latestEditorProps) this.__replChannel.send(this.__latestEditorProps); if (this.__latestProgramText) this.__replChannel.send(this.__latestProgramText); + // A 'request' only ever arrives once the tab's own channel subscription is already live + // (it's sent right after subscribing - see the Repl tab's constructor), so replying with + // 'focus' here can never race the bootstrap the way sending it directly from + // set_evaluator() could. + if (this.__focusOnConnect) { + this.__focusOnConnect = false; + this.__replChannel.send({ type: 'focus' }); + } return; } @@ -147,10 +160,15 @@ export default class ReplModulePlugin extends BaseModulePlugin { // Explicit, not just a side effect of the tab's own constructor already calling showTab once: // a program that calls set_evaluator() again later (or whose module import order put another // tab-opening call after this one) still ends up back on the Repl tab, since that's what - // set_evaluator succeeding means for the student - "go use the Repl now". Dropped if nothing's - // subscribed yet (the very first call, mid-tab-bootstrap) - harmless, since the tab's own - // constructor already calls showTab unconditionally once it exists. - this.__replChannel.send({ type: 'focus' }); + // set_evaluator succeeding means for the student - "go use the Repl now". If the tab has + // already connected (__tabRequested), send it directly; otherwise loadTab() just kicked off an + // async bootstrap the tab hasn't caught up with yet, so defer to the 'request' handler above, + // which can send it without racing that bootstrap. + if (this.__tabRequested) { + this.__replChannel.send({ type: 'focus' }); + } else { + this.__focusOnConnect = true; + } return mVoid(); } diff --git a/src/tabs/RobotSimulation/src/index.tsx b/src/tabs/RobotSimulation/src/index.tsx index 01f0a028f8..733af24439 100644 --- a/src/tabs/RobotSimulation/src/index.tsx +++ b/src/tabs/RobotSimulation/src/index.tsx @@ -96,11 +96,18 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio private __keydownListener: ((e: KeyboardEvent) => void) | undefined; private __controlsEndListener: (() => void) | undefined; - /** True once either a saved view was loaded or the EV3 has been auto-focused - either way, the - * viewer already has a deliberate view, so `__applySnapshot` shouldn't keep re-focusing on every - * subsequent snapshot (which would fight `OrbitControls` while the viewer is mid-drag). See - `__attachCanvas`/`__applySnapshot`. */ - private __hasDeliberateView = false; + /** + * Whether the default auto-focus (see `__applySnapshot`) should stay suppressed for the + * *current* World - true once it has already fired for this run, or once a saved view exists + * (re-checked fresh from `localStorage` on every `$worldStateChanged('loading')`, i.e. every Run + * - not just cached from the tab's initial attach - so a manual drag/F-focus made *during* an + * earlier run still suppresses auto-focus on a later re-run of the program, matching + * `__loadCameraView`/`__saveCameraView`'s existing "the viewer's view survives a re-run" intent). + * Reset to that same "does a saved view exist" check (not unconditionally to `false`) so a fresh + * run still frames whatever the new World actually spawned, unless the viewer already has a view + they set up themselves. + */ + private __suppressAutoFocus = false; private __state: ViewState = { worldState: 'unintialized', @@ -228,12 +235,11 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio // The EV3's spawn position isn't known until its first transform snapshot lands (it's created // at the origin - see __spawnEntity's placeholder), so "default to the F-focused view" can only // happen here, on the first snapshot that actually contains it - not at canvas-attach time, - // when __focusOnEv3 would find an empty/zero-size bounding box and no-op. Skipped once the - // viewer already has a deliberate view (a saved one, or a manual F-focus/drag already happened - // this session), so this never fights `OrbitControls` mid-interaction - see - // `__hasDeliberateView`'s doc comment. - if (!this.__hasDeliberateView && this.__ev3EntityIds.size > 0 && this.__controls) { - this.__hasDeliberateView = true; + // when __focusOnEv3 would find an empty/zero-size bounding box and no-op. Skipped once already + // suppressed for this run (a saved view, or this having already fired) - see + // `__suppressAutoFocus`'s doc comment. + if (!this.__suppressAutoFocus && this.__ev3EntityIds.size > 0 && this.__controls) { + this.__suppressAutoFocus = true; this.__focusOnEv3(); } } @@ -257,7 +263,7 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio // a re-run of the program instead of snapping back to getCamera()'s default every time - see // `__loadCameraView`/`__saveCameraView`. Only overrides the (camera, controls.target) pair // just set above if something was actually saved. - this.__hasDeliberateView = this.__loadCameraView(); + this.__suppressAutoFocus = this.__loadCameraView(); this.__controls.update(); // "F to focus" (Unity/Blender-style): only wired to this canvas, not the page, and only fires @@ -341,6 +347,18 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio } } + /** Existence check only (no camera mutation) - unlike `__loadCameraView`, safe to call any time, + * including before `__controls` exists. See `__suppressAutoFocus`'s doc comment for why this is + re-checked fresh on every Run rather than cached once. */ + private __hasSavedCameraView(): boolean { + try { + const raw = window.localStorage.getItem(CAMERA_VIEW_STORAGE_KEY); + return raw !== null && isStoredCameraView(JSON.parse(raw)); + } catch { + return false; + } + } + /** Unity/Blender-style "F to focus selected", hardcoded to "selected" = the EV3 (the only thing * a robot_simulation scene ever really has to look at - see `__ev3EntityIds`'s doc comment for * why picking it out from arbitrary other scene content, e.g. walls/paper, needs no extra @@ -392,6 +410,15 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio $worldStateChanged(state: WorldStateName): void { this.__setState({ worldState: state }); + // 'loading' fires exactly once per Run (World.init(), right at the start of a fresh World's + // lifecycle - see World.ts), before any entity-spawned/state-snapshot message for it can + // arrive - the right moment to decide whether *this* run gets a default auto-focus, without + // racing the snapshots that would otherwise immediately re-suppress it. See + // `__suppressAutoFocus`'s doc comment for why this re-checks localStorage instead of just + // resetting to `false`. + if (state === 'loading') { + this.__suppressAutoFocus = this.__hasSavedCameraView(); + } } $sensorSnapshot(snapshot: SensorSnapshot): void { From 30d6313fc95f87a4a6db3675bba40d3bf796dc82 Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Fri, 4 Sep 2026 20:58:45 +0800 Subject: [PATCH 09/12] robot_simulation: embed a mini code editor next to the 3D view The frontend only ever shows one side-content tab at a time, and won't switch tabs away from wherever a student navigated (SideContentManager's "don't yank the student's focus" guard) - so a separate repl tab and the RobotSimulation tab can never actually sit on screen together, no matter what a module does. Rather than fight that guard, this puts both halves in one tab instead: a small Ace-based editor + Run button now lives right next to the 3D canvas in RobotSimulation's own tab body, wired to a new tab -> module RPC ($runReplCode) that has the exact same effect as run_robot_code/the repl module's Run button. - protocol.ts: RobotSimulationModuleRpc, the tab -> module half of the existing makeRpc pairing (previously module -> tab only). - index.ts: run_robot_code and the new $runReplCode handler now share one __runReplCode implementation; run_robot_code still throws synchronously on a missing World (repl displays that as an error), while the RPC handler catches and logs instead, since there's no caller boundary to surface a throw to there. - RobotSimulation tab: two-column layout (3D view left, embedded editor right); editor code persists to localStorage like the camera view does. run_robot_code/set_evaluator's own $focusTab/'focus' sends are left as-is (still correct, still occasionally useful pre-first-navigation) but their doc comments now say plainly that they rarely do anything once a student has looked at any tab - this embedded editor is the actual fix for wanting the view and the code on screen together. --- src/bundles/robot_simulation/src/index.ts | 50 +++-- src/bundles/robot_simulation/src/protocol.ts | 19 ++ src/tabs/RobotSimulation/package.json | 2 + src/tabs/RobotSimulation/src/index.tsx | 193 ++++++++++++++----- yarn.lock | 2 + 5 files changed, 203 insertions(+), 63 deletions(-) diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index 47695a939c..1edae88bde 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -60,6 +60,7 @@ import { ROBOT_SIMULATION_CONTROL_CHANNEL_ID, ROBOT_SIMULATION_STATE_CHANNEL_ID, ROBOT_SIMULATION_TAB_NAME, + type RobotSimulationModuleRpc, type RobotSimulationTabRpc, type StateChannelMessage, } from './protocol'; @@ -128,7 +129,19 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { super(conduit, [controlChannel, stateChannel], evaluator); this.__tabLoader = tabLoader; - this.__tabRpc = makeRpc, RobotSimulationTabRpc>(controlChannel, {}); + this.__tabRpc = makeRpc(controlChannel, { + $runReplCode: code => { + try { + this.__runReplCode(code); + } catch (error) { + // No live World yet (e.g. the embedded editor's Run button was clicked before the main + // program ran) - there is no evaluator/caller boundary here to surface this to, unlike + // run_robot_code's own throw (see its doc comment). The World-state readout already + // shown in this same tab makes "nothing is running yet" obvious without one. + console.warn('robot_simulation: could not run code from the RobotSimulation tab\'s embedded editor', error); + } + }, + }); this.__stateChannel = stateChannel as IChannel; this.__sceneRegistry.setSpawnListener(message => this.__stateChannel.send(message)); this.__ev3Fns = createEv3Functions({ @@ -505,19 +518,32 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { * Requires `init_default_simulation`/`init_simulation` to have already been called - there must * be a live World for the robot code to act on. * - * On success, brings the RobotSimulation tab to the front (`$focusTab`) - a student driving the - * robot from the `repl` tab wants to watch it, not keep looking at the editor they just ran code - * from. Only reached once every synchronous precondition above has passed, so a call that fails - * before this point (no World yet) leaves the `repl` tab showing its own error message instead - * of yanking focus away from it. A *runtime* error in the robot code itself (a Python exception - * partway through, which - see `Program.fixedUpdate`'s doc comment - only ever surfaces several - * physics ticks later) still ends up shown exactly where this just switched to: the RobotSimulation - * tab's own Robot Console (routed via `robotConsoleStreams`/`World.step`'s catch), not the `repl` - * tab. + * Also calls `$focusTab` on success, asking the RobotSimulation tab to bring itself to the + * front - but the frontend's side-content host only actually honours that the *first* time any + * tab is shown in a session (see `SideContentManager.showTab`'s "don't yank the student away from + * wherever they navigated" guard), so in practice this rarely does anything once the student has + * looked at any tab at all. A student who wants the 3D view and the code they're driving the + * robot with on screen *together*, without fighting that guard, should use the RobotSimulation + * tab's own embedded editor instead (`$runReplCode` in protocol.ts) - same effect as this + * function, just triggered from inside the tab that's already showing the 3D view, so there's + * nothing to focus/switch away from in the first place. */ async* run_robot_code( code: TypedValue ): AsyncGenerator, undefined> { + this.__runReplCode(code.value); + return { type: DataType.VOID, value: undefined }; + } + + /** + * Shared by `run_robot_code` (the `repl`-module hook, above) and `$runReplCode` (the + * RobotSimulation tab's own embedded mini-editor - see protocol.ts's doc comment on + * `RobotSimulationModuleRpc`) - same effect either way, just reached from two different callers. + * Throws (via `__getWorldFromContext`) if no World exists yet; `run_robot_code` lets that + * propagate (repl displays it as an error), while the `$runReplCode` RPC handler catches and logs + * it instead, since there is no evaluator/caller boundary there to surface a throw to. + */ + private __runReplCode(code: string): void { const world = this.__getWorldFromContext(); if (this.__state.replPyContext === undefined) { @@ -527,13 +553,11 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { (this.__state.replProgram as Program | undefined)?.stop(); - const program = new Program(code.value, undefined, pyContext); + const program = new Program(code, undefined, pyContext); world.addLiveController(program); this.__state.replProgram = program; this.__tabRpc.$focusTab(); - - return { type: DataType.VOID, value: undefined }; } // [EV3] diff --git a/src/bundles/robot_simulation/src/protocol.ts b/src/bundles/robot_simulation/src/protocol.ts index 65b5246971..b2c3e5f927 100644 --- a/src/bundles/robot_simulation/src/protocol.ts +++ b/src/bundles/robot_simulation/src/protocol.ts @@ -86,3 +86,22 @@ export interface RobotSimulationTabRpc { on the 3D view to watch it, without having to switch tabs manually. */ $focusTab(): void; } + +/** + * Tab -> module operations, over the same {@link ROBOT_SIMULATION_CONTROL_CHANNEL_ID} `makeRpc` + * pairing as {@link RobotSimulationTabRpc} (`makeRpc` is bidirectional over one channel - each side + * supplies its own implementation and gets a caller for the other's). Exists for the RobotSimulation + * tab's own embedded mini-editor (see the tab's doc comment) - a from-scratch, minimal alternative + * to routing through the separate `repl` module/tab, which the frontend's side-content host only + * ever shows one of at a time (see `showTab`'s "don't yank the student's focus" guard in + * `SideContentManager`) - two *tabs* can't be on screen together, but a `Tab`'s own `body` can + * render whatever a plugin wants, including its own split "3D view + code editor" layout in one. + */ +export interface RobotSimulationModuleRpc { + /** Same effect as `run_robot_code` (see index.ts) - runs `code` as the robot's control program + * against the shared REPL `pyContext`, replacing whatever the previous run left ticking. Silently + * a no-op (logged, not thrown - there is no caller/evaluator boundary here to catch or display a + * throw) if no World exists yet (the embedded editor's Run button was clicked before the main + program set one up). */ + $runReplCode(code: string): void; +} diff --git a/src/tabs/RobotSimulation/package.json b/src/tabs/RobotSimulation/package.json index f6b9f1594c..17379b340d 100644 --- a/src/tabs/RobotSimulation/package.json +++ b/src/tabs/RobotSimulation/package.json @@ -7,7 +7,9 @@ "@sourceacademy/common-tabs": "^0.0.1", "@sourceacademy/conductor": "catalog:", "@sourceacademy/modules-lib": "workspace:^", + "ace-builds": "^1.25.1", "react": "catalog:", + "react-ace": "^14.0.0", "react-dom": "catalog:", "three": "^0.185.0" }, diff --git a/src/tabs/RobotSimulation/src/index.tsx b/src/tabs/RobotSimulation/src/index.tsx index 733af24439..0bd942a3a6 100644 --- a/src/tabs/RobotSimulation/src/index.tsx +++ b/src/tabs/RobotSimulation/src/index.tsx @@ -4,17 +4,22 @@ import { ROBOT_SIMULATION_CONTROL_CHANNEL_ID, ROBOT_SIMULATION_STATE_CHANNEL_ID, type EntityDescriptor, + type RobotSimulationModuleRpc, type RobotSimulationTabRpc, type SensorSnapshot, type StateChannelMessage, type WorldStateName, } from '@sourceacademy/bundle-robot_simulation/protocol'; import type { ITabService, Tab } from '@sourceacademy/common-tabs'; -import { checkIsPluginClass, makeRpc, type IChannel, type IConduit, type IPlugin } from '@sourceacademy/conductor/conduit'; -import { createElement, useEffect, useRef, useSyncExternalStore } from 'react'; +import { checkIsPluginClass, makeRpc, type IChannel, type IConduit, type IPlugin, type Remote } from '@sourceacademy/conductor/conduit'; +import { createElement, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import AceEditor from 'react-ace'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import 'ace-builds/src-noconflict/mode-python'; +import 'ace-builds/src-noconflict/theme-twilight'; + export const ROBOT_SIMULATION_TAB_ID = 'robot_simulation'; type LogEntry = { message: string, level: 'error' | 'source', timestamp: number }; @@ -35,6 +40,15 @@ const MAX_LOGS = 200; */ const CAMERA_VIEW_STORAGE_KEY = 'robot_simulation:camera-view'; +/** Keyed like {@link CAMERA_VIEW_STORAGE_KEY} - per-browser, not per-program-run, so whatever the + student was iterating on survives a reload/re-run. See `EmbeddedReplEditor`. */ +const EMBEDDED_EDITOR_CODE_STORAGE_KEY = 'robot_simulation:embedded-editor-code'; +const EMBEDDED_EDITOR_PLACEHOLDER = `left = ev3_motorA() +right = ev3_motorB() +ev3_runToRelativePosition(left, 1080, 500) +ev3_runToRelativePosition(right, 1080, 500) +`; + type StoredCameraView = { position: [number, number, number]; target: [number, number, number]; @@ -94,6 +108,10 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio */ private readonly __ev3EntityIds = new Set(); + /** Calls into the module - only `$runReplCode` for now, see protocol.ts's doc comment on + `RobotSimulationModuleRpc`. Used by the embedded mini-editor's Run button. */ + private readonly __moduleRpc: Remote; + private __keydownListener: ((e: KeyboardEvent) => void) | undefined; private __controlsEndListener: (() => void) | undefined; /** @@ -122,7 +140,7 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio this.__tabService = tabService; this.__stateChannel = stateChannel as IChannel; - makeRpc>(controlChannel, this); + this.__moduleRpc = makeRpc(controlChannel, this); const light = new THREE.PointLight(0xffffff, 1); light.position.set(0, 1, 0); @@ -154,7 +172,7 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio return () => plugin.__detachCanvas(); }, []); - return createElement(RobotSimulationView_, { state, canvasRef }); + return createElement(RobotSimulationView_, { state, canvasRef, onRunCode: code => plugin.__runReplCode(code) }); } const tab = { @@ -428,59 +446,134 @@ export default class RobotSimulationTabPlugin implements IPlugin, RobotSimulatio $focusTab(): void { this.__tabService.showTab(ROBOT_SIMULATION_TAB_ID); } + + /** + * Called by the embedded mini-editor's Run button (see `RobotSimulationView_`) - sends the + * student's typed code to the module's `$runReplCode` (same effect as `run_robot_code`/the + * `repl` module's Run button, just from an editor that lives right next to the 3D view instead of + * a separate tab - see protocol.ts's doc comment on `RobotSimulationModuleRpc` for why this + * exists at all). Fire-and-forget: there's no return value to wait on, and any problem running + * the code shows up in this same tab's own Robot Console (routed via `$consoleLog`) rather than + coming back through this call. + */ + __runReplCode(code: string): void { + this.__moduleRpc.$runReplCode(code); + } } checkIsPluginClass(RobotSimulationTabPlugin); -function RobotSimulationView_({ state, canvasRef }: { state: ViewState, canvasRef: React.RefObject }) { +function RobotSimulationView_({ + state, + canvasRef, + onRunCode, +}: { + state: ViewState, + canvasRef: React.RefObject, + onRunCode: (code: string) => void, +}) { return ( -
-
- -
-
-
Drag to orbit, scroll to zoom, click the view then press F to focus the robot
-
-
-
World: {state.worldState}
- {state.sensors && ( - <> -
Left motor: {state.sensors.leftMotorVelocity.toFixed(2)}
-
Right motor: {state.sensors.rightMotorVelocity.toFixed(2)}
-
- Color: rgb({state.sensors.colorSensor.r.toFixed(0)}, {state.sensors.colorSensor.g.toFixed(0)}, {state.sensors.colorSensor.b.toFixed(0)}) +
+
+
+ +
+
+
Drag to orbit, scroll to zoom, click the view then press F to focus the robot
+
+
+
World: {state.worldState}
+ {state.sensors && ( + <> +
Left motor: {state.sensors.leftMotorVelocity.toFixed(2)}
+
Right motor: {state.sensors.rightMotorVelocity.toFixed(2)}
+
+ Color: rgb({state.sensors.colorSensor.r.toFixed(0)}, {state.sensors.colorSensor.g.toFixed(0)}, {state.sensors.colorSensor.b.toFixed(0)}) +
+
Ultrasonic: {state.sensors.ultrasonicDistanceCm.toFixed(1)} cm
+ + )} +
+
+ {state.logs.map((log, index) => ( + +
+ {log.message}
-
Ultrasonic: {state.sensors.ultrasonicDistanceCm.toFixed(1)} cm
- - )} + ))} +
-
- {state.logs.map((log, index) => ( - -
- {log.message} -
- ))} + +
+ ); +} + +/** + * A minimal code editor + Run button living right next to the 3D view, in the same tab - see + * protocol.ts's doc comment on `RobotSimulationModuleRpc` for why this exists instead of just + * telling students to use the separate `repl` module/tab: the frontend's side-content host only + * ever shows one tab at a time and won't switch focus away from wherever the student already is + * (`SideContentManager.showTab`'s guard), so two *tabs* can never actually sit side by side - this + * puts both halves (3D view, robot code) in one tab's own body instead, where there's no tab + * switching involved at all. Deliberately not a full copy of the `repl` tab's own editor (rich + * output history, background image, custom font size, `set_program_text` support, ...) - just + * enough to type and run code, since output already has a home in this same tab's Robot Console. + */ +function EmbeddedReplEditor({ onRunCode, height }: { onRunCode: (code: string) => void, height: number }) { + const [code, setCode] = useState(() => { + try { + return window.localStorage.getItem(EMBEDDED_EDITOR_CODE_STORAGE_KEY) ?? EMBEDDED_EDITOR_PLACEHOLDER; + } catch { + return EMBEDDED_EDITOR_PLACEHOLDER; + } + }); + + const handleChange = (value: string) => { + setCode(value); + try { + window.localStorage.setItem(EMBEDDED_EDITOR_CODE_STORAGE_KEY, value); + } catch { + // Private-browsing tab or a full storage quota - the code just won't survive a reload, not + // worth failing the editor over (mirrors __saveCameraView's same best-effort tradeoff). + } + }; + + return ( +
+
+ + drives the robot straight away, no tab switch needed
+
); } diff --git a/yarn.lock b/yarn.lock index 7eed5a8842..353620c657 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5077,7 +5077,9 @@ __metadata: "@sourceacademy/modules-lib": "workspace:^" "@types/react": "catalog:" "@types/three": "npm:^0.185.0" + ace-builds: "npm:^1.25.1" react: "catalog:" + react-ace: "npm:^14.0.0" react-dom: "catalog:" three: "npm:^0.185.0" typescript: "catalog:" From 72ccfe1e39b9e7da659bbcbd9bf6548af7600109 Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Fri, 4 Sep 2026 21:26:36 +0800 Subject: [PATCH 10/12] robot_simulation: add @category tags and doc comments for the docs site Adds robot_simulation to conductor-modules.json (the allowlist filterDocsVisibleBundles reads) - without it the module was silently excluded from the generated documentation site, same as any not-yet-migrated bundle. Also fills in JSDoc (@param/@category) on every exported method that was missing it - mainly the raw createX/addControllerToWorld/saveToContext setup API and all ev3_* functions, which previously had no doc comment at all. @category groups the sidebar into Scene Setup / Control Program / EV3 instead of one flat list, matching repl's existing convention. No behavior change - doc comments and one allowlist entry only. --- conductor-modules.json | 1 + src/bundles/robot_simulation/src/index.ts | 124 ++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/conductor-modules.json b/conductor-modules.json index 600efe71ff..abec063173 100644 --- a/conductor-modules.json +++ b/conductor-modules.json @@ -8,6 +8,7 @@ "plotly", "repeat", "repl", + "robot_simulation", "rune", "scrabble", "sound" diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index 1edae88bde..d1a419a0d0 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -235,6 +235,13 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { // [Configuration] + /** + * Creates a physics world with custom gravity and timestep - use `createPhysics` instead for the + * usual Earth-like defaults. + * @param gravity Downward acceleration, in m/s². + * @param timestep Simulated seconds advanced per physics step. + * @category Scene Setup + */ async* createCustomPhysics( gravity: TypedValue, timestep: TypedValue @@ -243,19 +250,31 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return await this.evaluator.opaque_make(physics, true); } + /** Creates a physics world with Earth-like gravity and a default timestep. + * @category Scene Setup */ async* createPhysics(): AsyncGenerator, undefined> { const physics = new Physics({ gravity: { x: 0, y: -9.81, z: 0 }, timestep: 1 / 20 }); return await this.evaluator.opaque_make(physics, true); } + /** Creates a timer, used by `createWorld` to track simulated time. + * @category Scene Setup */ async* createTimer(): AsyncGenerator, undefined> { return await this.evaluator.opaque_make(new Timer(), true); } + /** Creates a Robot Console (the panel the EV3's `print()` output/errors go to), used by + * `createWorld`. + * @category Scene Setup */ async* createRobotConsole(): AsyncGenerator, undefined> { return await this.evaluator.opaque_make(new RobotConsole(), true); } + /** + * Creates the simulation World from the pieces `createPhysics`/`createTimer`/`createRobotConsole` + * built - the object `init_simulation`'s callback must return. + * @category Scene Setup + */ async* createWorld( physics: TypedValue, timer: TypedValue, @@ -269,6 +288,21 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return await this.evaluator.opaque_make(world, true); } + /** + * Creates a solid box in the scene - the general-purpose building block `createFloor`/`createWall` + * are thin presets of. + * @param physics The physics world to add the box to. + * @param position_x X position, in metres. + * @param position_y Y (vertical) position, in metres. + * @param position_z Z position, in metres. + * @param width Box width, in metres. + * @param length Box length, in metres. + * @param height Box height, in metres. + * @param mass Box mass, in kg (ignored for a `"fixed"` body). + * @param color Any CSS color string. + * @param bodyType `"fixed"` (immovable) or `"dynamic"` (affected by physics). + * @category Scene Setup + */ async* createCuboid( physics: TypedValue, position_x: TypedValue, @@ -292,6 +326,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return await this.evaluator.opaque_make(cuboid, true); } + /** Creates the default 20x20m white floor. + * @param physics The physics world to add the floor to. + * @category Scene Setup */ async* createFloor(physics: TypedValue): AsyncGenerator, undefined> { const floor = this.__createCuboid( await this.__getOpaque(physics), @@ -304,6 +341,19 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return await this.evaluator.opaque_make(floor, true); } + /** + * Creates a fixed yellow wall - for a customised `World` built with `createWorld`/`init_simulation` + * rather than `init_default_simulation`. Use `add_wall` instead once the default simulation is + * already running. + * @param physics The physics world to add the wall to. + * @param x X position, in metres. + * @param y Y position, in metres (note: not vertical - this is a floor-plane coordinate, matching + * `add_wall`). + * @param width Wall width, in metres. + * @param length Wall length, in metres. + * @param height Wall height, in metres. + * @category Scene Setup + */ async* createWall( physics: TypedValue, x: TypedValue, @@ -323,6 +373,19 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return await this.evaluator.opaque_make(wall, true); } + /** + * Creates a visual (non-collidable) floor overlay, e.g. a colored patch for a color sensor demo - + * for a customised `World` built with `createWorld`/`init_simulation` rather than + * `init_default_simulation`. Use `add_paper` instead once the default simulation is already + * running. + * @param url An image URL to texture the overlay with. + * @param width Overlay width, in metres. + * @param height Overlay height, in metres. + * @param x X position, in metres. + * @param y Y position, in metres. + * @param rotation Rotation, in degrees. + * @category Scene Setup + */ async* createPaper( url: TypedValue, width: TypedValue, @@ -340,6 +403,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return await this.evaluator.opaque_make(paper, true); } + /** Creates the EV3 robot with its default chassis/motors/sensors. + * @param physics The physics world to spawn the EV3 into. + * @category Scene Setup */ async* createEv3(physics: TypedValue): AsyncGenerator, undefined> { const ev3 = createDefaultEv3(await this.__getOpaque(physics), this.__sceneRegistry, ev3Config); return await this.evaluator.opaque_make(ev3, true); @@ -351,6 +417,7 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { * pythonRuntime.ts). `print(...)` goes to the simulation's Robot Console panel. * * @param code The robot's control program, written in Python (SICPy §4). + * @category Control Program */ async* createPythonCSE(code: TypedValue): AsyncGenerator, undefined> { const pyContext = createRobotPythonContext(this.__ev3Fns, () => this.__getWorldFromContext()); @@ -358,6 +425,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return await this.evaluator.opaque_make(program, true); } + /** Adds a controller (e.g. from `createFloor`/`createWall`/`createEv3`/`createPythonCSE`) to a + * World built with `createWorld`. + * @category Scene Setup */ async* addControllerToWorld( controller: TypedValue, world: TypedValue @@ -367,6 +437,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return { type: DataType.VOID, value: undefined }; } + /** Saves a value (e.g. the World or EV3) under `key` so the `ev3_*` API can find it later - the + * `world`/`ev3` keys specifically are what `init_simulation` needs a custom setup to have saved. + * @category Scene Setup */ async* saveToContext( key: TypedValue, value: TypedValue @@ -382,6 +455,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { * for the tab to be opened first (the tab has no way to signal the module at all besides the * exported functions student code calls - see protocol.ts), so the simulation is "live" from * the moment `init_simulation` returns, whether or not anyone has the tab open to watch it yet. + * + * For the common case (no customisation needed), use `init_default_simulation` instead. + * @category Scene Setup */ async* init_simulation( worldFactory: TypedValue @@ -418,6 +494,7 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { * Source/Scheme instead of Python), use `createPhysics`/`createWorld`/`createWall`/ * `createPaper`/`createPythonCSE`/`addControllerToWorld`/`saveToContext`/`init_simulation` * directly instead, exactly as before. + * @category Scene Setup */ async* init_default_simulation(): AsyncGenerator, undefined> { if (this.__state.world !== undefined) { @@ -455,6 +532,12 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { * that doesn't need `physics`/`world` opaque handles, since `init_default_simulation` already * owns both. Uses `World.addLiveController` rather than `addController` because the world is * already running by the time a student calls this from the setup pane. + * @param x X position, in metres. + * @param y Y position, in metres (a floor-plane coordinate, not vertical). + * @param width Wall width, in metres. + * @param length Wall length, in metres. + * @param height Wall height, in metres. + * @category Scene Setup */ async* add_wall( x: TypedValue, @@ -480,6 +563,13 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { * Adds a visual (non-collidable - see Paper.ts's doc comment) floor overlay to the * already-initialised default world - a friendly wrapper over `createPaper`/ * `addControllerToWorld` for the same reason as `add_wall`. + * @param url An image URL to texture the overlay with. + * @param width Overlay width, in metres. + * @param height Overlay height, in metres. + * @param x X position, in metres. + * @param y Y position, in metres. + * @param rotation Rotation, in degrees. + * @category Scene Setup */ async* add_paper( url: TypedValue, @@ -527,6 +617,8 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { * tab's own embedded editor instead (`$runReplCode` in protocol.ts) - same effect as this * function, just triggered from inside the tab that's already showing the 3D view, so there's * nothing to focus/switch away from in the first place. + * @param code The robot's control program, written in Python (SICPy §4). + * @category Control Program */ async* run_robot_code( code: TypedValue @@ -562,22 +654,39 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { // [EV3] + /** The EV3's left-front motor. Same API on both the setup program and any robot control program + * (a Python string/REPL run/embedded-editor run) - see the module doc comment. + * @category EV3 */ async* ev3_motorA(): AsyncGenerator, undefined> { return await this.evaluator.opaque_make(this.__ev3Fns.ev3_motorA(), true); } + /** The EV3's right-front motor. + * @category EV3 */ async* ev3_motorB(): AsyncGenerator, undefined> { return await this.evaluator.opaque_make(this.__ev3Fns.ev3_motorB(), true); } + /** The EV3's left-rear motor. + * @category EV3 */ async* ev3_motorC(): AsyncGenerator, undefined> { return await this.evaluator.opaque_make(this.__ev3Fns.ev3_motorC(), true); } + /** The EV3's right-rear motor. + * @category EV3 */ async* ev3_motorD(): AsyncGenerator, undefined> { return await this.evaluator.opaque_make(this.__ev3Fns.ev3_motorD(), true); } + /** + * Rotates a motor by `position` degrees relative to its current position, at `speed`. + * @param motor A motor from `ev3_motorA`/`ev3_motorB`/`ev3_motorC`/`ev3_motorD`. + * @param position Degrees of wheel rotation, relative to the motor's current position (e.g. + * `1080` is 3 full turns). + * @param speed How fast to turn - larger is faster. + * @category EV3 + */ async* ev3_runToRelativePosition( motor: TypedValue, position: TypedValue, @@ -587,31 +696,46 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return { type: DataType.VOID, value: undefined }; } + /** Pauses the robot's control program for `duration` milliseconds, without blocking the rest of + * the simulation. + * @category EV3 */ async* ev3_pause(duration: TypedValue): AsyncGenerator, undefined> { this.__ev3Fns.ev3_pause(duration.value); return { type: DataType.VOID, value: undefined }; } + /** The EV3's color sensor. + * @category EV3 */ async* ev3_colorSensor(): AsyncGenerator, undefined> { return await this.evaluator.opaque_make(this.__ev3Fns.ev3_colorSensor(), true); } + /** The color sensor's current red reading (0-255). + * @category EV3 */ async* ev3_colorSensorRed(colorSensor: TypedValue): AsyncGenerator, undefined> { return { type: DataType.NUMBER, value: this.__ev3Fns.ev3_colorSensorRed(await this.__getOpaque(colorSensor)) }; } + /** The color sensor's current green reading (0-255). + * @category EV3 */ async* ev3_colorSensorGreen(colorSensor: TypedValue): AsyncGenerator, undefined> { return { type: DataType.NUMBER, value: this.__ev3Fns.ev3_colorSensorGreen(await this.__getOpaque(colorSensor)) }; } + /** The color sensor's current blue reading (0-255). + * @category EV3 */ async* ev3_colorSensorBlue(colorSensor: TypedValue): AsyncGenerator, undefined> { return { type: DataType.NUMBER, value: this.__ev3Fns.ev3_colorSensorBlue(await this.__getOpaque(colorSensor)) }; } + /** The EV3's ultrasonic (distance) sensor. + * @category EV3 */ async* ev3_ultrasonicSensor(): AsyncGenerator, undefined> { return await this.evaluator.opaque_make(this.__ev3Fns.ev3_ultrasonicSensor(), true); } + /** The ultrasonic sensor's current distance reading, in cm. + * @category EV3 */ async* ev3_ultrasonicSensorDistance(sensor: TypedValue): AsyncGenerator, undefined> { return { type: DataType.NUMBER, value: this.__ev3Fns.ev3_ultrasonicSensorDistance(await this.__getOpaque(sensor)) }; } From 02c3147d04a80a9793db1b7599ef9b26cccfb825 Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Fri, 4 Sep 2026 21:34:37 +0800 Subject: [PATCH 11/12] robot_simulation: add add_color_patch, a collidable alternative to add_paper add_paper's overlay has no physics collider (documented, known follow-up in ColorSensor.ts), so the color sensor's raycast can never detect it - the color sensor demo the module's own docs point to has never actually worked with add_paper. add_color_patch adds a thin, flat, fixed cuboid instead (a real collider, registered with Physics.registerColor like any other cuboid), so ev3_colorSensor can actually read it. Verified live: init_default_simulation() + add_color_patch('red', 0, 0, 1, 1) immediately shows Color: rgb(255, 0, 0) in the RobotSimulation tab's sensor readout. add_paper's doc comment now points to this for anything that needs to be sensed, not just seen. --- .../src/__tests__/index.test.ts | 26 +++++++++-- src/bundles/robot_simulation/src/index.ts | 44 ++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/bundles/robot_simulation/src/__tests__/index.test.ts b/src/bundles/robot_simulation/src/__tests__/index.test.ts index 26bcba3627..e4c669a0a2 100644 --- a/src/bundles/robot_simulation/src/__tests__/index.test.ts +++ b/src/bundles/robot_simulation/src/__tests__/index.test.ts @@ -118,10 +118,30 @@ describe(RobotSimulationModulePlugin, () => { stringValue('red.png'), numberValue(1), numberValue(1), numberValue(0), numberValue(1), numberValue(0) ) ); + await runAsyncGenerator( + (plugin as any).add_color_patch( + stringValue('red'), numberValue(0), numberValue(1), numberValue(0.5), numberValue(0.5) + ) + ); + + // All three controllers were added live (start() already fired) rather than only queued for + // a future worldStart that already happened. + expect(world.controllers.controllers.length).toBe(controllersBefore + 3); + }); + + test('add_color_patch registers a real physics collider with its color, unlike add_paper', async () => { + const { plugin } = makePlugin(); + await runAsyncGenerator((plugin as any).init_default_simulation()); + + const registerColorSpy = vi.spyOn((plugin as any).__state.world.physics, 'registerColor'); + + await runAsyncGenerator( + (plugin as any).add_color_patch( + stringValue('#ff0000'), numberValue(0), numberValue(1), numberValue(0.5), numberValue(0.5) + ) + ); - // Both controllers were added live (start() already fired) rather than only queued for a - // future worldStart that already happened. - expect(world.controllers.controllers.length).toBe(controllersBefore + 2); + expect(registerColorSpy).toHaveBeenCalledWith(expect.anything(), '#ff0000'); }); }); diff --git a/src/bundles/robot_simulation/src/index.ts b/src/bundles/robot_simulation/src/index.ts index d1a419a0d0..cd21090365 100644 --- a/src/bundles/robot_simulation/src/index.ts +++ b/src/bundles/robot_simulation/src/index.ts @@ -91,6 +91,7 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { 'init_default_simulation', 'add_wall', 'add_paper', + 'add_color_patch', 'run_robot_code', 'ev3_motorA', 'ev3_motorB', @@ -562,7 +563,9 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { /** * Adds a visual (non-collidable - see Paper.ts's doc comment) floor overlay to the * already-initialised default world - a friendly wrapper over `createPaper`/ - * `addControllerToWorld` for the same reason as `add_wall`. + * `addControllerToWorld` for the same reason as `add_wall`. Purely decorative: because it has no + * physics collider, the color sensor's raycast (see ColorSensor.ts) cannot detect it - use + * `add_color_patch` instead for a floor marking the robot needs to actually sense. * @param url An image URL to texture the overlay with. * @param width Overlay width, in metres. * @param height Overlay height, in metres. @@ -590,6 +593,44 @@ export default class RobotSimulationModulePlugin extends BaseModulePlugin { return { type: DataType.VOID, value: undefined }; } + /** + * Adds a thin, solid, fixed color patch flush with the floor to the already-initialised default + * world - unlike `add_paper` (a purely visual overlay with no physics collider), this one *has* a + * collider, so `ev3_colorSensor`'s downward raycast actually detects it (see ColorSensor.ts's doc + * comment on why `add_paper` alone can't be sensed). Use this for any floor marking the robot's + * control program needs to react to; use `add_paper` instead for purely decorative markings, or + * when many overlapping/finely-detailed images matter more than sensing them. + * @param color Any CSS color string. + * @param x X position, in metres. + * @param y Y position, in metres (a floor-plane coordinate, not vertical). + * @param width Patch width, in metres. + * @param length Patch length, in metres. + * @category Scene Setup + */ + async* add_color_patch( + color: TypedValue, + x: TypedValue, + y: TypedValue, + width: TypedValue, + length: TypedValue + ): AsyncGenerator, undefined> { + const world = this.__getWorldFromContext(); + // A hair above the floor's own top surface (floor is centered at y=-0.5 with height=1, so its + // top face is at y=0) - thin enough to look flush, but tall enough that the sensor's downward + // raycast hits this patch, not the floor underneath it (mirrors Paper's own y=0.001 for the + // same reason - see Paper.ts's start()). + const patch = this.__createCuboid( + world.physics, + { x: x.value, y: 0.001, z: y.value }, + { width: width.value, length: length.value, height: 0.01 }, + 1, + color.value, + 'fixed' + ); + world.addLiveController(patch); + return { type: DataType.VOID, value: undefined }; + } + /** * The `repl`-module hook: pass this function itself to `repl`'s `set_evaluator`, and the `repl` * tab it opens will call it with whatever the student typed there each time they hit Run - @@ -762,6 +803,7 @@ attachModuleMethod(RobotSimulationModulePlugin, 'init_simulation', [DataType.CLO attachModuleMethod(RobotSimulationModulePlugin, 'init_default_simulation', [], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'add_wall', [DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'add_paper', [DataType.CONST_STRING, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.VOID); +attachModuleMethod(RobotSimulationModulePlugin, 'add_color_patch', [DataType.CONST_STRING, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'run_robot_code', [DataType.CONST_STRING], DataType.VOID); attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorA', [], DataType.OPAQUE); attachModuleMethod(RobotSimulationModulePlugin, 'ev3_motorB', [], DataType.OPAQUE); From 29e901e03b77c2c90436f496128767e9da7c9227 Mon Sep 17 00:00:00 2001 From: Akshay-2007-1 Date: Fri, 4 Sep 2026 22:02:34 +0800 Subject: [PATCH 12/12] robot_simulation: fix ev3_pause() targeting a stale Program after a rerun Every run_robot_code/$runReplCode call (each REPL/embedded-editor Run) adds a fresh Program controller rather than replacing one in place, and never removes superseded ones from world.controllers.controllers - only marks them isStopped. ev3_pause() looked up "the" Program controller by name via .find(), which always returns the FIRST one ever added - so on any run after the first, it silently paused a dead, already-stopped Program instead of the one actually executing, making ev3_pause() a no-op. This is what looked like consecutive motor commands (e.g. a forward call immediately followed by a reverse one from a separate Run) "canceling out" instantly regardless of how large a pause was inserted - the pause never touched the program actually running. Fixed by searching from the end of the controllers list for the last non-stopped Program - the one currently driving the run - and exposing Program.isStopped (was private) so ev3_functions.ts can tell them apart. Verified live: a long-running forward command stayed at its original motor velocity throughout a second run's ev3_pause(2500) before finally reversing once the pause elapsed, instead of reversing instantly. --- .../src/__tests__/index.test.ts | 28 +++++++++++++++++++ .../src/controllers/program/Program.ts | 5 ++-- .../robot_simulation/src/ev3_functions.ts | 22 +++++++++++++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/bundles/robot_simulation/src/__tests__/index.test.ts b/src/bundles/robot_simulation/src/__tests__/index.test.ts index e4c669a0a2..f8c30b04f3 100644 --- a/src/bundles/robot_simulation/src/__tests__/index.test.ts +++ b/src/bundles/robot_simulation/src/__tests__/index.test.ts @@ -171,5 +171,33 @@ describe(RobotSimulationModulePlugin, () => { runAsyncGenerator((plugin as any).run_robot_code(stringValue('ev3_pause(1)'))) ).rejects.toThrow(); }); + + test('ev3_pause() pauses the currently-running Program, not a stale one from an earlier run', async () => { + const { plugin } = makePlugin(); + await runAsyncGenerator((plugin as any).init_default_simulation()); + + // First run: a Program that finishes immediately and is left behind, stopped, in + // world.controllers.controllers - exactly what a real student's first REPL/embedded-editor + // Run leaves behind once they move on to a second one. + await runAsyncGenerator((plugin as any).run_robot_code(stringValue('x = 1'))); + const firstProgram = (plugin as any).__state.replProgram; + + // Second run calls ev3_pause() itself - if ev3_pause found the *first* Program with a + // matching name (the bug this guards against), it would pause a dead, already-stopped + // Program that no longer affects anything, leaving this run's own isPaused false forever. + await runAsyncGenerator((plugin as any).run_robot_code(stringValue('ev3_pause(1000000)'))); + const secondProgram = (plugin as any).__state.replProgram; + + // Drive the second run's Python code far enough to actually execute the ev3_pause() call + // (mirrors Program.python.test.ts's own real-py-slang pump pattern). + for (let tick = 0; tick < 10; tick++) { + secondProgram.fixedUpdate(); + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => setTimeout(resolve, 0)); + } + + expect(secondProgram.isPaused).toBe(true); + expect(firstProgram.isPaused).toBe(false); + }); }); }); diff --git a/src/bundles/robot_simulation/src/controllers/program/Program.ts b/src/bundles/robot_simulation/src/controllers/program/Program.ts index 017fdd382a..1cc39e47eb 100644 --- a/src/bundles/robot_simulation/src/controllers/program/Program.ts +++ b/src/bundles/robot_simulation/src/controllers/program/Program.ts @@ -55,8 +55,9 @@ export class Program implements Controller { * `Program`s can share one `pyContext` across REPL re-runs (that's how variables persist between * runs), and `runPythonECEvaluator` reassigns `context.control`/`context.stash` at the *start* of * a run, so an old Program still ticking after a new one has started would corrupt the new run's - * state. */ - private isStopped = false; + * state. Public (not private) so `ev3_pause` (ev3_functions.ts) can tell old, superseded `Program` + * controllers apart from the one actually driving the current run - see its doc comment. */ + isStopped = false; isPaused: boolean; callbackHandler = new CallbackHandler(); name: string; diff --git a/src/bundles/robot_simulation/src/ev3_functions.ts b/src/bundles/robot_simulation/src/ev3_functions.ts index 94137ed138..3005784134 100644 --- a/src/bundles/robot_simulation/src/ev3_functions.ts +++ b/src/bundles/robot_simulation/src/ev3_functions.ts @@ -44,12 +44,30 @@ export function createEv3Functions(deps: { return { /** * Pauses for a period of time. + * + * Every `run_robot_code`/`$runReplCode` call (each REPL/embedded-editor Run) adds a fresh + * `Program` controller rather than replacing one in place - see `Program`'s doc comment - so + * a World that has had more than one control-program run can have several `Program`s in + * `world.controllers.controllers` at once, all sharing the same `name` + * (`program_controller_identifier`). Only the *last* one added is actually still running (the + * others are `stop()`'d - see `Program.isStopped`'s doc comment); searching from the end for + * the first non-stopped match is what makes this find the program THIS call is actually + * running in, rather than whichever `Program` happened to be created first (which - since a + * stopped `Program`'s own `pause()` has no observable effect, its `fixedUpdate` already + * unconditionally no-ops - would otherwise make ev3_pause() silently do nothing on every + * control-program run after the first). * @param duration The time to wait, in milliseconds. */ ev3_pause(duration: number): void { const world = getWorld(); - const program = world.controllers.controllers.find((controller) => controller.name === program_controller_identifier) as Program; - program.pause(duration); + const controllers = world.controllers.controllers; + for (let i = controllers.length - 1; i >= 0; i--) { + const controller = controllers[i]; + if (controller.name === program_controller_identifier && !(controller as Program).isStopped) { + (controller as Program).pause(duration); + return; + } + } }, /** Gets the motor connected to port A. */