diff --git a/eslint.config.js b/eslint.config.js index cc0e7a2898..7cef2d8a13 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -245,7 +245,7 @@ export default defineConfig( 'jsdoc/check-tag-names': ['error', { // NOTE: Not all Typedoc supported tags are present here. Feel free to add any other // Typedoc supported tags to this list - definedTags: ['category', 'categoryDescription', 'hidden', 'title'], + definedTags: ['category', 'categoryDescription', 'hidden', 'title', 'publicType', 'publicReturnType'], inlineTags: ['link', 'see'], }], 'jsdoc/empty-tags': ['error', { diff --git a/lib/buildtools/src/build/docs/__tests__/conductor.test.ts b/lib/buildtools/src/build/docs/__tests__/conductor.test.ts index 269c1c8e53..b44598a1b7 100644 --- a/lib/buildtools/src/build/docs/__tests__/conductor.test.ts +++ b/lib/buildtools/src/build/docs/__tests__/conductor.test.ts @@ -58,6 +58,23 @@ function createParameter( return parameter; } +function addExportedNames( + project: td.ProjectReflection, + plugin: td.DeclarationReflection, + names: string[] +) { + const exportedNames = register( + project, + new td.DeclarationReflection('exportedNames', td.ReflectionKind.Property, plugin) + ); + plugin.addChild(exportedNames); + exportedNames.type = new td.TypeOperatorType( + new td.TupleType(names.map(name => new td.LiteralType(name))), + 'readonly' + ); + return exportedNames; +} + describe(normalizeConductorType, () => { it('maps TypedValue numbers to native numbers', () => { const project = createProject(); @@ -126,15 +143,7 @@ describe(normalizeConductorDocs, () => { conductorReference(project, 'RenamedModulePluginBase') ]; - const exportedNames = register( - project, - new td.DeclarationReflection('exportedNames', td.ReflectionKind.Property, plugin) - ); - plugin.addChild(exportedNames); - exportedNames.type = new td.TypeOperatorType( - new td.TupleType([new td.LiteralType('repeat')]), - 'readonly' - ); + addExportedNames(project, plugin, ['repeat']); const method = register( project, @@ -179,6 +188,61 @@ describe(normalizeConductorDocs, () => { expect(signature.type?.stringify(td.TypeContext.none)).toEqual('Function'); }); + it('uses @functionDeclaration decorator types for promoted Conductor methods', () => { + const project = createProject(); + const fixturePath = pathlib.resolve(import.meta.dirname, 'fixtures/conductorDeclarations.ts'); + + const plugin = register( + project, + new td.DeclarationReflection('default', td.ReflectionKind.Class, project) + ); + project.addChild(plugin); + addExportedNames(project, plugin, ['make_widget']); + + const method = register( + project, + new td.DeclarationReflection('make_widget', td.ReflectionKind.Method, plugin) + ); + plugin.addChild(method); + method.sources = [new td.SourceReference(fixturePath, 22, 10)]; + + const property = register( + project, + new td.DeclarationReflection('sample_widget', td.ReflectionKind.Property, plugin) + ); + plugin.addChild(property); + property.sources = [new td.SourceReference(fixturePath, 19, 3)]; + property.type = typedValue(project, 'OPAQUE'); + + const methodSignature = register( + project, + new td.SignatureReflection('make_widget', td.ReflectionKind.CallSignature, method) + ); + method.signatures = [methodSignature]; + methodSignature.parameters = [ + createParameter(project, methodSignature, 'count', typedValue(project, 'NUMBER')), + createParameter(project, methodSignature, 'mapper', typedValue(project, 'CLOSURE')) + ]; + methodSignature.type = asyncGenerator(project, typedValue(project, 'OPAQUE')); + + normalizeConductorDocs(project); + + const publicFunction = project.children?.find(child => child.name === 'make_widget'); + const [signature] = publicFunction?.signatures ?? []; + const publicVariable = project.children?.find(child => child.name === 'sample_widget'); + + expect(signature.parameters?.map(parameter => [ + parameter.name, + parameter.type?.stringify(td.TypeContext.none) + ])).toEqual([ + ['count', 'number'], + ['mapper', '(widget: Widget) => Widget'] + ]); + expect(signature.type?.stringify(td.TypeContext.none)).toEqual('Widget'); + expect(publicVariable?.kind).toEqual(td.ReflectionKind.Variable); + expect(publicVariable?.type?.stringify(td.TypeContext.none)).toEqual('Widget'); + }); + it('normalizes the migrated repeat bundle docs', async () => { const repeatBundle: ResolvedBundle = { type: 'bundle', @@ -213,4 +277,32 @@ describe(normalizeConductorDocs, () => { expect(publicFunctions.find(func => func.name === 'repeat')?.signatures?.[0].parameters?.map(parameter => parameter.name)) .toEqual(['func', 'n']); }); + + it('uses declaration decorators in the migrated rune bundle docs', async () => { + const runeBundle: ResolvedBundle = { + type: 'bundle', + name: 'rune', + manifest: {}, + directory: pathlib.resolve(import.meta.dirname, '../../../../../../src/bundles/rune') + }; + const app = await initTypedocForJson(runeBundle, td.LogLevel.None); + const project = await app.convert(); + expect(project).toBeDefined(); + + normalizeConductorDocs(project!); + + const show = project!.children?.find(child => child.name === 'show'); + const [signature] = show?.signatures ?? []; + const blank = project!.children?.find(child => child.name === 'blank'); + + expect(signature.parameters?.map(parameter => [ + parameter.name, + parameter.type?.stringify(td.TypeContext.none) + ])).toEqual([ + ['rune', 'Rune'] + ]); + expect(signature.type?.stringify(td.TypeContext.none)).toEqual('Rune'); + expect(blank?.kind).toEqual(td.ReflectionKind.Variable); + expect(blank?.type?.stringify(td.TypeContext.none)).toEqual('Rune'); + }); }); diff --git a/lib/buildtools/src/build/docs/__tests__/fixtures/conductorDeclarations.ts b/lib/buildtools/src/build/docs/__tests__/fixtures/conductorDeclarations.ts new file mode 100644 index 0000000000..d13499646c --- /dev/null +++ b/lib/buildtools/src/build/docs/__tests__/fixtures/conductorDeclarations.ts @@ -0,0 +1,29 @@ +type Decorator = (...args: any[]) => any; + +const functionDeclaration = (_paramTypes: string, _returnType: string): Decorator => { + return () => undefined; +}; + +const variableDeclaration = (_type: string): Decorator => { + return () => undefined; +}; + +const classDeclaration = (_name: string): Decorator => { + return () => undefined; +}; + +@classDeclaration('Widget') +export class Widget {} + +export class WidgetPlugin { + exportedNames = ['make_widget'] as const; + + @variableDeclaration('Widget') + sample_widget!: Widget; + + @functionDeclaration('count: number, mapper: (widget: Widget) => Widget', 'Widget') + async* make_widget(_count: unknown, _mapper: unknown) { + await Promise.resolve(); + return undefined; + } +} diff --git a/lib/buildtools/src/build/docs/conductor/normalisation.ts b/lib/buildtools/src/build/docs/conductor/normalisation.ts index 0f111b42f3..b6a29eafe0 100644 --- a/lib/buildtools/src/build/docs/conductor/normalisation.ts +++ b/lib/buildtools/src/build/docs/conductor/normalisation.ts @@ -6,7 +6,24 @@ import { dataTypeToNativeType, getDataTypeName, isConductorPluginClass, isConduc * Rewrites one call signature from Conductor-facing types to public module-facing types. */ function normalizeSignature(signature: td.SignatureReflection, project: td.ProjectReflection) { - if (signature.type) { + + const devDefinedTypeDeclaration: DevDefinedTypeDeclaration = { arguments: {}, return: undefined }; + if (signature.comment?.blockTags) { + signature.comment.blockTags.forEach(tag => { + if (tag.tag == '@publicType' && tag.content) { + const [argName, argType] = tag.content[0].text.split(/:\s(.+)/).map(s => s.trim()); + devDefinedTypeDeclaration.arguments[argName] = argType; + } + if (tag.tag == '@publicReturnType' && tag.content) { + devDefinedTypeDeclaration.return = tag.content[0].text; + } + }); + signature.comment.blockTags = signature.comment.blockTags.filter(tag => tag.tag !== '@publicType' && tag.tag !== '@publicReturnType'); + } + + if (devDefinedTypeDeclaration.return) { + signature.type = new td.UnknownType(devDefinedTypeDeclaration.return); + } else if (signature.type) { project.removeTypeReflections(signature.type); signature.type = normalizeConductorType(signature.type, project); } @@ -14,12 +31,15 @@ function normalizeSignature(signature: td.SignatureReflection, project: td.Proje signature.parameters = signature.parameters ?.filter(parameter => !isServiceParameter(parameter)) .map(parameter => { - if (parameter.type) { - project.removeTypeReflections(parameter.type); + project.removeTypeReflections(parameter.type); + if (devDefinedTypeDeclaration.arguments[parameter.name]) { + parameter.type = new td.UnknownType(devDefinedTypeDeclaration.arguments[parameter.name]); + } else if (parameter.type) { parameter.type = normalizeConductorType(parameter.type, project); } return parameter; }); + } /** @@ -122,15 +142,29 @@ export function normalizeConductorType(type: td.SomeType, project: td.ProjectRef return type; } +type DevDefinedTypeDeclaration = { + arguments: { + [name: string]: string; + }; + return: string | undefined; +}; + /** * Applies signature/type normalization to a declaration and its accessors. */ function normalizeDeclaration(declaration: td.DeclarationReflection, project: td.ProjectReflection) { if (declaration.type) { + const publicData = declaration.comment?.getTag('@publicType')?.content?.[0]?.text; + if (declaration.comment) { + declaration.comment.blockTags = declaration.comment.blockTags.filter(tag => tag.tag !== '@publicType' && tag.tag !== '@publicReturnType'); + } project.removeTypeReflections(declaration.type); - declaration.type = normalizeConductorType(declaration.type, project); + if (publicData) { + declaration.type = td.ReferenceType.createBrokenReference(publicData, project, undefined); + } else { + declaration.type = normalizeConductorType(declaration.type, project); + } } - declaration.signatures?.forEach(signature => normalizeSignature(signature, project)); declaration.indexSignatures?.forEach(signature => normalizeSignature(signature, project)); diff --git a/lib/buildtools/src/build/docs/conductor/utils.ts b/lib/buildtools/src/build/docs/conductor/utils.ts index df908ca13c..0d712bffff 100644 --- a/lib/buildtools/src/build/docs/conductor/utils.ts +++ b/lib/buildtools/src/build/docs/conductor/utils.ts @@ -24,7 +24,7 @@ export function isDataTypeReference(type: td.SomeType | undefined): type is td.R const qualifiedName = type.qualifiedName ?? type.name; return DATA_TYPE_NAMES.has(type.name) - && (qualifiedName === type.name || qualifiedName === `DataType.${type.name}`); + && (qualifiedName === type.name || qualifiedName === `DataType.${type.name}`); } /** @@ -85,10 +85,10 @@ export function isServiceParameter(parameter: td.ParameterReflection) { export function isConductorPluginClass(reflection: td.DeclarationReflection) { if (reflection.kind !== td.ReflectionKind.Class) return false; - const exportedNames = getExportedNames(reflection); - if (exportedNames.size === 0) return false; - - return reflection.children?.some(child => isExportedConductorMethod(child, exportedNames)) ?? false; + return reflection.extendedTypes?.some(type => { + if (!isReferenceType(type)) return false; + return isConductorReference(type, new Set(['BaseModulePlugin', 'IModulePlugin', 'IPlugin', 'Plugin'])); + }) ?? false; } /** @@ -168,7 +168,7 @@ export function isExportedConductorMethod( return child.signatures?.some(signature => { return hasConductorTransportType(signature.type) - || signature.parameters?.some(parameter => hasConductorTransportType(parameter.type)); + || signature.parameters?.some(parameter => hasConductorTransportType(parameter.type)); }) ?? false; } @@ -189,9 +189,13 @@ export function cloneParameter( parameter: td.ParameterReflection, parent: td.SignatureReflection, project: td.ProjectReflection, - commentSource: td.ParameterReflection | undefined + commentSource: td.ParameterReflection | undefined, ) { - const clone = new td.ParameterReflection(parameter.name, td.ReflectionKind.Parameter, parent); + const clone = new td.ParameterReflection( + parameter.name, + td.ReflectionKind.Parameter, + parent + ); cloneFlags(parameter, clone); clone.comment = commentSource?.comment ?? parameter.comment; clone.defaultValue = parameter.defaultValue; @@ -212,12 +216,13 @@ export function cloneFlags(source: td.Reflection, target: td.Reflection) { */ export function copyPluginSignature( target: td.DeclarationReflection, + method: td.DeclarationReflection, pluginSignature: td.SignatureReflection, project: td.ProjectReflection, implementationSignature: td.SignatureReflection | undefined ) { const targetSignature = target.signatures?.[0] - ?? new td.SignatureReflection(target.name, td.ReflectionKind.CallSignature, target); + ?? new td.SignatureReflection(target.name, td.ReflectionKind.CallSignature, target); if (!target.signatures?.includes(targetSignature)) { target.signatures = [targetSignature]; @@ -233,17 +238,29 @@ export function copyPluginSignature( targetSignature.parameters = pluginSignature.parameters?.map(parameter => { const implementationParameter = implementationSignature?.parameters ?.find(each => each.name === parameter.name); - return cloneParameter(parameter, targetSignature, project, implementationParameter); + return cloneParameter( + parameter, + targetSignature, + project, + implementationParameter + ); }); } +/** + * Creates a public type label from a bundle declaration decorator. + */ +export function declaredType(typeName: string) { + return new td.UnknownType(typeName); +} + /** * Finds an existing top-level export function reflection for a module export. */ export function findPublicFunction(container: td.ContainerReflection, name: string) { return container.children?.find(child => { return child.name === name - && child.kind === td.ReflectionKind.Function; + && child.kind === td.ReflectionKind.Function; }); } @@ -261,6 +278,50 @@ export function createPublicFunction( return reflection; } +/** + * Finds an existing top-level export variable reflection for a module export. + */ +export function findPublicVariable(container: td.ContainerReflection, name: string) { + return container.children?.find(child => { + return child.name === name + && child.kind === td.ReflectionKind.Variable; + }); +} + +/** + * Creates a new top-level export variable reflection for a decorated plugin constant. + */ +export function createPublicVariable( + container: td.ContainerReflection, + name: string, + project: td.ProjectReflection +) { + const reflection = new td.DeclarationReflection(name, td.ReflectionKind.Variable, container); + container.addChild(reflection); + project.registerReflection(reflection, undefined, undefined); + return reflection; +} + +/** + * Copies a decorated plugin property onto a public variable reflection. + */ +export function copyPluginVariable( + target: td.DeclarationReflection, + property: td.DeclarationReflection, + project: td.ProjectReflection +) { + cloneFlags(property, target); + target.comment = target.comment ?? property.comment; + target.defaultValue = property.defaultValue; + target.type = property.type + ? normalizeConductorType(property.type, project) + : undefined; +} + +export function isExportedVariable(reflection: td.DeclarationReflection) { + return reflection.comment?.blockTags?.some(tag => tag.tag === '@publicType'); +} + /** * Converts exported plugin methods into top-level functions and merges their public docs. */ @@ -271,6 +332,14 @@ export function promotePluginMethods( ) { const exportedNames = getExportedNames(plugin); + plugin.children + ?.filter(child => child.kind === td.ReflectionKind.Property && isExportedVariable(child)) + .forEach(property => { + const publicVariable = findPublicVariable(container, property.name) + ?? createPublicVariable(container, property.name, project); + copyPluginVariable(publicVariable, property, project); + }); + plugin.children ?.filter(child => child.kind === td.ReflectionKind.Method && exportedNames.has(child.name)) .forEach(method => { @@ -278,10 +347,10 @@ export function promotePluginMethods( if (!pluginSignature) return; const publicFunction = findPublicFunction(container, method.name) - ?? createPublicFunction(container, method.name, project); + ?? createPublicFunction(container, method.name, project); const implementationSignature = publicFunction.signatures?.[0]; - copyPluginSignature(publicFunction, pluginSignature, project, implementationSignature); + copyPluginSignature(publicFunction, method, pluginSignature, project, implementationSignature); }); } diff --git a/lib/buildtools/src/build/docs/index.ts b/lib/buildtools/src/build/docs/index.ts index 0309c02e9e..175cd1f012 100644 --- a/lib/buildtools/src/build/docs/index.ts +++ b/lib/buildtools/src/build/docs/index.ts @@ -5,7 +5,7 @@ import { mapAsync } from '@sourceacademy/modules-repotools/utils'; import * as td from 'typedoc'; import { normalizeConductorDocs } from './conductor/index.js'; import { buildJson } from './json.js'; -import { initTypedocForHtml, initTypedocForJson } from './typedoc.js'; +import { initTypedocForHtml, initTypedocForJson, stripTypeDocSources } from './typedoc.js'; /** * First builds an intermediate JSON file in the dist directory of the bundle\ @@ -25,6 +25,7 @@ export async function buildSingleBundleDocs(bundle: ResolvedBundle, outDir: stri } normalizeConductorDocs(project); + stripTypeDocSources(project); // TypeDoc expects POSIX paths const directoryAsPosix = bundle.directory.replace(/\\/g, '/'); @@ -78,6 +79,7 @@ export async function buildHtml(bundles: Record, outDir: } normalizeConductorDocs(project); + stripTypeDocSources(project); const htmlPath = pathlib.join(outDir, 'documentation'); await app.generateDocs(project, htmlPath); diff --git a/lib/buildtools/src/build/docs/typedoc.ts b/lib/buildtools/src/build/docs/typedoc.ts index 54563079ed..4efdb23b79 100644 --- a/lib/buildtools/src/build/docs/typedoc.ts +++ b/lib/buildtools/src/build/docs/typedoc.ts @@ -5,7 +5,7 @@ import * as td from 'typedoc'; // #region commonOpts const typedocPackageOptions: td.Configuration.TypeDocOptions = { categorizeByGroup: true, - disableSources: true, + disableSources: false, excludeInternal: true, skipErrorChecking: true, sort: ['documents-last'], @@ -21,6 +21,16 @@ export function convertToTypedocPath(p: string) { return p.split(pathlib.sep).join(pathlib.posix.sep); } +/** + * Removes source locations after Conductor decorator parsing has used them. + */ +export function stripTypeDocSources(reflection: td.Reflection) { + (reflection as { sources?: td.SourceReference[] }).sources = undefined; + reflection.traverse(child => { + stripTypeDocSources(child); + }); +} + /** * Initialize Typedoc to build the JSON documentation for each bundle */ diff --git a/lib/modules-lib/package.json b/lib/modules-lib/package.json index 3f51b71dfc..20e3e31760 100644 --- a/lib/modules-lib/package.json +++ b/lib/modules-lib/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "devDependencies": { + "@sourceacademy/conductor": "^0.6.0", "@sourceacademy/modules-buildtools": "workspace:^", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/lib/modules-lib/src/conductor/methods.ts b/lib/modules-lib/src/conductor/methods.ts new file mode 100644 index 0000000000..bf1c4c54e3 --- /dev/null +++ b/lib/modules-lib/src/conductor/methods.ts @@ -0,0 +1,43 @@ + +import type { IModulePlugin } from '@sourceacademy/conductor/module'; +import type { DataType, TypedValue } from '@sourceacademy/conductor/types'; + +type ModuleFunction< + Args extends readonly TypedValue[] = readonly TypedValue[], + Return extends DataType = DataType, +> = (...args: Args) => AsyncGenerator, undefined>; + +type ModuleMethodName = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [K in keyof T]: T[K] extends ModuleFunction ? K : never; +}[keyof T]; + +type MethodArgs> = + T[K] extends ModuleFunction + ? { [I in keyof A]: A[I] extends TypedValue ? D : never } + : never; + +type MethodReturn> = + T[K] extends ModuleFunction + ? R + : never; + +type SignaturedModuleMethod = { + signature?: { + args: readonly DataType[]; + returnType: DataType; + }; +}; + +export function attachModuleMethod>( + clss: new (...args: any[]) => T, + methodName: K, + args: MethodArgs, + returnType: MethodReturn +) { + const method = clss.prototype[methodName] as SignaturedModuleMethod | undefined; + if (method === undefined) { + throw new Error(`Rune module method "${String(methodName)}" does not exist.`); + } + method.signature = { args, returnType }; +} diff --git a/src/bundles/repeat/src/index.ts b/src/bundles/repeat/src/index.ts index 8ef2a49862..d92bc216fd 100644 --- a/src/bundles/repeat/src/index.ts +++ b/src/bundles/repeat/src/index.ts @@ -6,18 +6,41 @@ */ import type { IChannel, IConduit } from '@sourceacademy/conductor/conduit'; -import { BaseModulePlugin, moduleMethod } from '@sourceacademy/conductor/module'; +import { BaseModulePlugin, moduleMethod, type IModuleExport } from '@sourceacademy/conductor/module'; import type { IInterfacableEvaluator } from '@sourceacademy/conductor/runner'; import { DataType, type TypedValue } from '@sourceacademy/conductor/types'; import { repeat as repeat_func, thrice as thrice_func, twice as twice_func } from './functions'; +type SignaturedModuleMethod = { + signature?: { + args: readonly DataType[]; + returnType: DataType; + }; +}; + export default class RepeatModulePlugin extends BaseModulePlugin { id = 'repeat'; exportedNames = ['repeat', 'twice', 'thrice'] as const; static channelAttach = []; constructor(conduit: IConduit, channels: IChannel[], evaluator: IInterfacableEvaluator) { super(conduit, channels, evaluator); + this.__bindExportedMethods(); + } + + private __bindExportedMethods() { + for (const name of this.exportedNames) { + const method = this[name]; + if (typeof method !== 'function') continue; + + const signature = (method as SignaturedModuleMethod).signature; + const boundMethod = method.bind(this) as typeof method & SignaturedModuleMethod; + boundMethod.signature = signature; + Object.defineProperty(this, name, { + configurable: true, + value: boundMethod + }); + } } /** * Returns a new function which when applied to an argument, has the same effect diff --git a/src/bundles/rune/package.json b/src/bundles/rune/package.json index 2b4d3346b0..f1e56661f7 100644 --- a/src/bundles/rune/package.json +++ b/src/bundles/rune/package.json @@ -5,9 +5,10 @@ "dependencies": { "@sourceacademy/modules-lib": "workspace:^", "es-toolkit": "^1.44.0", - "gl-matrix": "^3.3.0" + "gl-matrix": "^3.4.4" }, "devDependencies": { + "@sourceacademy/conductor": "^0.6.0", "@sourceacademy/modules-buildtools": "workspace:^", "js-slang": "^1.0.85", "typescript": "^6.0.2" diff --git a/src/bundles/rune/src/__tests__/index.test.ts b/src/bundles/rune/src/__tests__/index.test.ts index 826a57cb22..db7d7ac212 100644 --- a/src/bundles/rune/src/__tests__/index.test.ts +++ b/src/bundles/rune/src/__tests__/index.test.ts @@ -1,36 +1,59 @@ +import { DataType } from '@sourceacademy/conductor/types'; import { stringify } from 'js-slang/dist/utils/stringify'; import { describe, expect, it, test, vi } from 'vitest'; -import * as display from '../display'; +import RuneModulePlugin from '..'; import * as funcs from '../functions'; import type { Rune } from '../rune'; -describe(display.anaglyph, () => { - it('throws when argument is not rune', () => { - expect(() => display.anaglyph(0 as any)).toThrowError('anaglyph expects a rune as argument'); - }); - - it('returns the rune passed to it', () => { - expect(display.anaglyph(funcs.heart)).toBe(funcs.heart); - }); -}); - -describe(display.hollusion, () => { - it('throws when argument is not rune', () => { - expect(() => display.hollusion(0 as any)).toThrowError('hollusion expects a rune as argument'); - }); - - it('returns the rune passed to it', () => { - expect(display.hollusion(funcs.heart)).toBe(funcs.heart); - }); -}); - -describe(display.show, () => { - it('throws when argument is not rune', () => { - expect(() => display.show(0 as any)).toThrowError('show expects a rune as argument'); - }); +describe(RuneModulePlugin, () => { + test('exported methods stay bound when called by a Conductor closure', async () => { + const sentMessages: unknown[] = []; + const channel = { + send: vi.fn(message => sentMessages.push(message)), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + close: vi.fn(), + name: 'rune-test-channel' + }; + const evaluator = { + hasDataInterface: true, + closure_make: vi.fn(async (sig, func, dependsOn) => ({ + type: DataType.CLOSURE, + value: { sig, dependsOn, func } + })), + opaque_make: vi.fn(async value => ({ + type: DataType.OPAQUE, + value + })), + opaque_get: vi.fn(async value => value.value) + }; + const plugin = new RuneModulePlugin({} as any, [channel] as any, evaluator as any, { + tabs: [], + loadTab: vi.fn() + }); - it('returns the rune passed to it', () => { - expect(display.show(funcs.heart)).toBe(funcs.heart); + await plugin.initialise(); + + const showExport = plugin.exports.find(each => each.symbol === 'show')!; + const closureObject = showExport.value.value as unknown as { + func: (rune: Awaited>) => AsyncGenerator; + }; + const runeValue = await evaluator.opaque_make(funcs.blank); + const result = await closureObject.func.call(closureObject, runeValue).next(); + + expect(result.done).toBe(true); + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0]).toMatchObject({ + type: 'render', + mode: 'normal', + rune: { + vertices: [], + colors: null, + textureUrl: null, + subRunes: [] + } + }); + expect('draw' in (sentMessages[0] as any).rune).toBe(false); }); }); diff --git a/src/bundles/rune/src/display.ts b/src/bundles/rune/src/display.ts deleted file mode 100644 index c00eb000de..0000000000 --- a/src/bundles/rune/src/display.ts +++ /dev/null @@ -1,133 +0,0 @@ -import context from 'js-slang/context'; -import { AnaglyphRune, HollusionRune } from './functions'; -import { AnimatedRune, NormalRune, Rune, type DrawnRune, type RuneAnimation } from './rune'; -import { throwIfNotRune } from './runes_ops'; -import { functionDeclaration } from './type_map'; - -// ============================================================================= -// Drawing functions -// ============================================================================= - -const drawnRunes: (AnimatedRune | DrawnRune)[] = []; -context.moduleContexts.rune.state = { - drawnRunes -}; - -class RuneDisplay { - @functionDeclaration('rune: Rune', 'Rune') - static show(rune: Rune): Rune { - throwIfNotRune(RuneDisplay.show.name, rune); - drawnRunes.push(new NormalRune(rune)); - return rune; - } - - @functionDeclaration('rune: Rune', 'Rune') - static anaglyph(rune: Rune): Rune { - throwIfNotRune(RuneDisplay.anaglyph.name, rune); - drawnRunes.push(new AnaglyphRune(rune)); - return rune; - } - - @functionDeclaration('rune: Rune, magnitude: number', 'Rune') - static hollusion_magnitude(rune: Rune, magnitude: number): Rune { - throwIfNotRune(RuneDisplay.hollusion_magnitude.name, rune); - drawnRunes.push(new HollusionRune(rune, magnitude)); - return rune; - } - - @functionDeclaration('rune: Rune', 'Rune') - static hollusion(rune: Rune): Rune { - throwIfNotRune(RuneDisplay.hollusion.name, rune); - return RuneDisplay.hollusion_magnitude(rune, 0.1); - } - - @functionDeclaration('duration: number, fps: number, func: RuneAnimation', 'AnimatedRune') - static animate_rune(duration: number, fps: number, func: RuneAnimation) { - const anim = new AnimatedRune(duration, fps, (n) => { - const rune = func(n); - throwIfNotRune(RuneDisplay.animate_rune.name, rune); - return new NormalRune(rune); - }); - drawnRunes.push(anim); - return anim; - } - - @functionDeclaration('duration: number, fps: number, func: RuneAnimation', 'AnimatedRune') - static animate_anaglyph(duration: number, fps: number, func: RuneAnimation) { - const anim = new AnimatedRune(duration, fps, (n) => { - const rune = func(n); - throwIfNotRune(RuneDisplay.animate_anaglyph.name, rune); - return new AnaglyphRune(rune); - }); - drawnRunes.push(anim); - return anim; - } -} - -/** - * Renders the specified Rune in a tab as a basic drawing. - * @function - * @param rune - The Rune to render - * @returns {Rune} The specified Rune - * - * @category Main - */ -export const show = RuneDisplay.show; - -/** - * Renders the specified Rune in a tab as an anaglyph. Use 3D glasses to view the - * anaglyph. - * @function - * @param rune - The Rune to render - * @returns {Rune} The specified Rune - * - * @category Main - */ -export const anaglyph = RuneDisplay.anaglyph; - -/** - * Renders the specified Rune in a tab as a hollusion, with a default magnitude - * of 0.1. - * @function - * @param rune - The Rune to render - * @returns {Rune} The specified Rune - * - * @category Main - */ -export const hollusion = RuneDisplay.hollusion; - -/** - * Renders the specified Rune in a tab as a hollusion, using the specified - * magnitude. - * @function - * @param rune - The Rune to render - * @param {number} magnitude - The hollusion's magnitude - * @returns {Rune} The specified Rune - * - * @category Main - */ -export const hollusion_magnitude = RuneDisplay.hollusion_magnitude; - -/** - * Create an animation of runes - * @function - * @param duration Duration of the entire animation in seconds - * @param fps Duration of each frame in frames per seconds - * @param func Takes in the timestamp and returns a Rune to draw - * @returns A rune animation - * - * @category Main - */ -export const animate_rune = RuneDisplay.animate_rune; - -/** - * Create an animation of anaglyph runes - * @function - * @param duration Duration of the entire animation in seconds - * @param fps Duration of each frame in frames per seconds - * @param func Takes in the timestamp and returns a Rune to draw - * @returns A rune animation - * - * @category Main - */ -export const animate_anaglyph = RuneDisplay.animate_anaglyph; diff --git a/src/bundles/rune/src/functions.ts b/src/bundles/rune/src/functions.ts index 51f8310f38..8e17b92b72 100644 --- a/src/bundles/rune/src/functions.ts +++ b/src/bundles/rune/src/functions.ts @@ -28,7 +28,6 @@ import { initShaderProgram, type FrameBufferWithTexture } from './runes_webgl'; -import { functionDeclaration, variableDeclaration } from './type_map'; export type RuneModuleState = { drawnRunes: (AnimatedRune | DrawnRune)[]; @@ -52,49 +51,34 @@ function throwIfNotFraction(val: unknown, param_name: string, func_name: string) // Exported for testing export class RuneFunctions { - @variableDeclaration('Rune') static square: Rune = getSquare(); - - @variableDeclaration('Rune') static blank: Rune = getBlank(); - @variableDeclaration('Rune') static rcross: Rune = getRcross(); - @variableDeclaration('Rune') static sail: Rune = getSail(); - @variableDeclaration('Rune') static triangle: Rune = getTriangle(); - @variableDeclaration('Rune') static corner: Rune = getCorner(); - @variableDeclaration('Rune') static nova: Rune = getNova(); - @variableDeclaration('Rune') static circle: Rune = getCircle(); - @variableDeclaration('Rune') static heart: Rune = getHeart(); - @variableDeclaration('Rune') static pentagram: Rune = getPentagram(); - @variableDeclaration('Rune') static ribbon: Rune = getRibbon(); // ============================================================================= // Textured Runes // ============================================================================= - @functionDeclaration('imageUrl: string', 'Rune') static from_url(imageUrl: string): Rune { const rune = getSquare(); - rune.texture = new Image(); - rune.texture.crossOrigin = 'anonymous'; - rune.texture.src = imageUrl; + rune.texture = imageUrl; return rune; } @@ -102,7 +86,6 @@ export class RuneFunctions { // XY-axis Transformation functions // ============================================================================= - @functionDeclaration('ratio_x: number, ratio_y: number, rune: Rune', 'Rune') static scale_independent( ratio_x: number, ratio_y: number, @@ -121,13 +104,11 @@ export class RuneFunctions { }); } - @functionDeclaration('ratio: number, rune: Rune', 'Rune') static scale(ratio: number, rune: Rune): Rune { throwIfNotRune(RuneFunctions.scale.name, rune); return RuneFunctions.scale_independent(ratio, ratio, rune); } - @functionDeclaration('x: number, y: number, rune: Rune', 'Rune') static translate(x: number, y: number, rune: Rune): Rune { throwIfNotRune(RuneFunctions.translate.name, rune); const translateVec = vec3.fromValues(x, -y, 0); @@ -142,7 +123,6 @@ export class RuneFunctions { }); } - @functionDeclaration('rad: number, rune: Rune', 'Rune') static rotate(rad: number, rune: Rune): Rune { throwIfNotRune(RuneFunctions.rotate.name, rune); const rotateMat = mat4.create(); @@ -156,7 +136,6 @@ export class RuneFunctions { }); } - @functionDeclaration('frac: number, rune1: Rune, rune2: Rune', 'Rune') static stack_frac(frac: number, rune1: Rune, rune2: Rune): Rune { throwIfNotRune(RuneFunctions.stack_frac.name, rune1); throwIfNotRune(RuneFunctions.stack_frac.name, rune2); @@ -169,14 +148,12 @@ export class RuneFunctions { }); } - @functionDeclaration('rune1: Rune, rune2: Rune', 'Rune') static stack(rune1: Rune, rune2: Rune): Rune { throwIfNotRune(RuneFunctions.stack.name, rune1); throwIfNotRune(RuneFunctions.stack.name, rune2); return RuneFunctions.stack_frac(1 / 2, rune1, rune2); } - @functionDeclaration('n: number, rune: Rune', 'Rune') static stackn(n: number, rune: Rune): Rune { throwIfNotRune(RuneFunctions.stackn.name, rune); if (!Number.isInteger(n)) { @@ -189,25 +166,21 @@ export class RuneFunctions { return RuneFunctions.stack_frac(1 / n, rune, RuneFunctions.stackn(n - 1, rune)); } - @functionDeclaration('rune: Rune', 'Rune') static quarter_turn_right(rune: Rune): Rune { throwIfNotRune(RuneFunctions.quarter_turn_right.name, rune); return RuneFunctions.rotate(-Math.PI / 2, rune); } - @functionDeclaration('rune: Rune', 'Rune') static quarter_turn_left(rune: Rune): Rune { throwIfNotRune(RuneFunctions.quarter_turn_left.name, rune); return RuneFunctions.rotate(Math.PI / 2, rune); } - @functionDeclaration('rune: Rune', 'Rune') static turn_upside_down(rune: Rune): Rune { throwIfNotRune(RuneFunctions.turn_upside_down.name, rune); return RuneFunctions.rotate(Math.PI, rune); } - @functionDeclaration('frac: number, rune1: Rune, rune2: Rune', 'Rune') static beside_frac(frac: number, rune1: Rune, rune2: Rune): Rune { throwIfNotRune(RuneFunctions.beside_frac.name, rune1); throwIfNotRune(RuneFunctions.beside_frac.name, rune2); @@ -220,26 +193,22 @@ export class RuneFunctions { }); } - @functionDeclaration('rune1: Rune, rune2: Rune', 'Rune') static beside(rune1: Rune, rune2: Rune): Rune { throwIfNotRune(RuneFunctions.beside.name, rune1); throwIfNotRune(RuneFunctions.beside.name, rune2); return RuneFunctions.beside_frac(0.5, rune1, rune2); } - @functionDeclaration('rune: Rune', 'Rune') static flip_vert(rune: Rune): Rune { throwIfNotRune(RuneFunctions.flip_vert.name, rune); return RuneFunctions.scale_independent(1, -1, rune); } - @functionDeclaration('rune: Rune', 'Rune') static flip_horiz(rune: Rune): Rune { throwIfNotRune(RuneFunctions.flip_horiz.name, rune); return RuneFunctions.scale_independent(-1, 1, rune); } - @functionDeclaration('rune: Rune', 'Rune') static make_cross(rune: Rune): Rune { throwIfNotRune(RuneFunctions.make_cross.name, rune); return RuneFunctions.stack( @@ -248,7 +217,6 @@ export class RuneFunctions { ); } - @functionDeclaration('n: number, pattern: (a: Rune) => Rune, initial: Rune', 'Rune') static repeat_pattern( n: number, pattern: (a: Rune) => Rune, @@ -265,7 +233,6 @@ export class RuneFunctions { // Z-axis Transformation functions // ============================================================================= - @functionDeclaration('frac: number, rune1: Rune, rune2: Rune', 'Rune') static overlay_frac(frac: number, rune1: Rune, rune2: Rune): Rune { // to developer: please read https://www.tutorialspoint.com/webgl/webgl_basics.htm to understand the webgl z-axis interpretation. // The key point is that positive z is closer to the screen. Hence, the image at the back should have smaller z value. Primitive runes have z = 0. @@ -303,7 +270,6 @@ export class RuneFunctions { }); } - @functionDeclaration('rune1: Rune, rune2: Rune', 'Rune') static overlay(rune1: Rune, rune2: Rune): Rune { throwIfNotRune(RuneFunctions.overlay.name, rune1); throwIfNotRune(RuneFunctions.overlay.name, rune2); @@ -314,7 +280,6 @@ export class RuneFunctions { // Color functions // ============================================================================= - @functionDeclaration('rune: Rune, r: number, g: number, b: number', 'Rune') static color(rune: Rune, r: number, g: number, b: number): Rune { throwIfNotRune(RuneFunctions.color.name, rune); throwIfNotFraction(r, 'r', RuneFunctions.color.name); @@ -344,72 +309,60 @@ export class RuneColours { yellow: '#FFEB3B', } as const; - @functionDeclaration('rune: Rune', 'Rune') static black(rune: Rune): Rune { throwIfNotRune(RuneColours.black.name, rune); return addColorFromHex(rune, '#000000'); } - @functionDeclaration('rune: Rune', 'Rune') static blue(rune: Rune): Rune { throwIfNotRune(RuneColours.blue.name, rune); return addColorFromHex(rune, RuneColours.colours.blue); } - @functionDeclaration('rune: Rune', 'Rune') static brown(rune: Rune): Rune { throwIfNotRune(RuneColours.brown.name, rune); return addColorFromHex(rune, RuneColours.colours.brown); } - @functionDeclaration('rune: Rune', 'Rune') static green(rune: Rune): Rune { throwIfNotRune(RuneColours.green.name, rune); return addColorFromHex(rune, RuneColours.colours.green); } - @functionDeclaration('rune: Rune', 'Rune') static indigo(rune: Rune): Rune { throwIfNotRune(RuneColours.indigo.name, rune); return addColorFromHex(rune, RuneColours.colours.indigo); } - @functionDeclaration('rune: Rune', 'Rune') static red(rune: Rune): Rune { throwIfNotRune(RuneColours.red.name, rune); return addColorFromHex(rune, RuneColours.colours.red); } - @functionDeclaration('rune: Rune', 'Rune') static pink(rune: Rune): Rune { throwIfNotRune(RuneColours.pink.name, rune); return addColorFromHex(rune, RuneColours.colours.pink); } - @functionDeclaration('rune: Rune', 'Rune') static orange(rune: Rune): Rune { throwIfNotRune(RuneColours.orange.name, rune); return addColorFromHex(rune, RuneColours.colours.orange); } - @functionDeclaration('rune: Rune', 'Rune') static purple(rune: Rune): Rune { throwIfNotRune(RuneColours.purple.name, rune); return addColorFromHex(rune, RuneColours.colours.purple); } - @functionDeclaration('rune: Rune', 'Rune') static white(rune: Rune): Rune { throwIfNotRune(RuneColours.white.name, rune); return addColorFromHex(rune, '#FFFFFF'); } - @functionDeclaration('rune: Rune', 'Rune') static yellow(rune: Rune): Rune { throwIfNotRune(RuneColours.yellow.name, rune); return addColorFromHex(rune, RuneColours.colours.yellow); } - @functionDeclaration('rune: Rune', 'Rune') static random_color(rune: Rune): Rune { throwIfNotRune(RuneColours.random_color.name, rune); const colourNames = Object.keys(RuneColours.colours); @@ -453,7 +406,7 @@ export class AnaglyphRune extends DrawnRune { super(rune, false); } - public draw = (canvas: HTMLCanvasElement) => { + public draw = async (canvas: HTMLCanvasElement) => { const gl = getWebGlFromCanvas(canvas); // before draw the runes to framebuffer, we need to first draw a white background to cover the transparent places @@ -481,7 +434,7 @@ export class AnaglyphRune extends DrawnRune { // left/right eye images are drawn into respective framebuffers const leftBuffer = initFramebufferObject(gl); const rightBuffer = initFramebufferObject(gl); - drawRunesToFrameBuffer( + await drawRunesToFrameBuffer( gl, runes, leftCameraMatrix, @@ -489,7 +442,7 @@ export class AnaglyphRune extends DrawnRune { leftBuffer.framebuffer, true ); - drawRunesToFrameBuffer( + await drawRunesToFrameBuffer( gl, runes, rightCameraMatrix, @@ -561,7 +514,7 @@ export class HollusionRune extends DrawnRune { } `; - public draw = (canvas: HTMLCanvasElement) => { + public draw = async (canvas: HTMLCanvasElement) => { const gl = getWebGlFromCanvas(canvas); const runes = white(overlay_frac(0.999999999, blank, scale(2.2, square))) @@ -574,7 +527,7 @@ export class HollusionRune extends DrawnRune { const frameCount = 50; // in total 50 frames, gives rise to 25 fps const frameBuffer: FrameBufferWithTexture[] = []; - const renderFrame = (framePos: number): FrameBufferWithTexture => { + const renderFrame = async (framePos: number): Promise => { const fb = initFramebufferObject(gl); // prepare camera projection array const cameraMatrix = mat4.create(); @@ -592,7 +545,7 @@ export class HollusionRune extends DrawnRune { vec3.fromValues(0, 1, 0) ); - drawRunesToFrameBuffer( + await drawRunesToFrameBuffer( gl, runes, cameraMatrix, @@ -604,7 +557,7 @@ export class HollusionRune extends DrawnRune { }; for (let i = 0; i < frameCount; i += 1) { - frameBuffer.push(renderFrame(i)); + frameBuffer.push(await renderFrame(i)); } // Then, draw a frame from framebuffer for each update @@ -654,463 +607,88 @@ export function isHollusionRune(rune: DrawnRune): rune is HollusionRune { return rune.isHollusion; } -/** - * Makes a new Rune from two given Runes by - * placing the first on the left of the second, - * both occupying equal portions of the width - * of the result - * @param {Rune} rune1 - Given Rune - * @param {Rune} rune2 - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const beside = RuneFunctions.beside; -/** - * Makes a new Rune from two given Runes by - * placing the first on the left of the second - * such that the first one occupies frac - * portion of the width of the result and - * the second the rest - * @param {number} frac - Fraction between 0 and 1 (inclusive) - * @param {Rune} rune1 - Given Rune - * @param {Rune} rune2 - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const beside_frac = RuneFunctions.beside_frac; -/** - * Colors the given rune black (#000000). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const black = RuneColours.black; -/** - * Rune with the shape of a blank square - * - * @category Primitive - */ export const blank = RuneFunctions.blank; -/** - * Colors the given rune blue (#2196F3). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const blue = RuneColours.blue; -/** - * Colors the given rune brown. - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const brown = RuneColours.brown; -/** - * Rune with the shape of a circle - * - * @category Primitive - */ export const circle = RuneFunctions.circle; -/** - * Adds color to rune by specifying - * the red, green, blue (RGB) value, ranging from 0.0 to 1.0. - * RGB is additive: if all values are 1, the color is white, - * and if all values are 0, the color is black. - * @param {Rune} rune - The rune to add color to - * @param {number} r - Red value [0.0, 1.0] - * @param {number} g - Green value [0.0, 1.0] - * @param {number} b - Blue value [0.0, 1.0] - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const color = RuneFunctions.color; -/** - * Rune with black triangle, - * filling upper right corner - * - * @category Primitive - */ export const corner = RuneFunctions.corner; -/** - * Makes a new Rune from a given Rune by - * flipping it around a vertical axis, - * creating a mirror image - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const flip_horiz = RuneFunctions.flip_horiz; -/** - * Makes a new Rune from a given Rune by - * flipping it around a horizontal axis, - * turning it upside down - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const flip_vert = RuneFunctions.flip_vert; -/** - * Create a rune using the image provided in the url - * @param {string} imageUrl URL to the image that is used to create the rune. - * Note that the url must be from a domain that allows CORS. - * @returns {Rune} Rune created using the image. - * @function - * - * @category Main - */ export const from_url = RuneFunctions.from_url; -/** - * Colors the given rune green (#4CAF50). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const green = RuneColours.green; -/** - * Rune with the shape of a heart - * - * @category Primitive - */ export const heart = RuneFunctions.heart; -/** - * Colors the given rune indigo (#3F51B5). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const indigo = RuneColours.indigo; -/** - * Makes a new Rune from a given Rune by - * arranging into a square for copies of the - * given Rune in different orientations - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const make_cross = RuneFunctions.make_cross; -/** - * Rune with the shape of two overlapping - * triangles, residing in the upper half - * of the shape - * - * @category Primitive - */ export const nova = RuneFunctions.nova; -/** - * Colors the given rune orange (#FF9800). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const orange = RuneColours.orange; -/** - * The depth range of the z-axis of a rune is [0,-1], this function maps the depth range of rune1 and rune2 to [0,-0.5] and [-0.5,-1] respectively. - * @param {Rune} rune1 - Given Rune - * @param {Rune} rune2 - Given Rune - * @returns {Rune} Resulting Runes - * @function - * - * @category Main - */ export const overlay = RuneFunctions.overlay; -/** - * The depth range of the z-axis of a rune is [0,-1], this function gives a [0, -frac] of the depth range to rune1 and the rest to rune2. - * @param {number} frac - Fraction between 0 and 1 (inclusive) - * @param {Rune} rune1 - Given Rune - * @param {Rune} rune2 - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const overlay_frac = RuneFunctions.overlay_frac; -/** - * Rune with the shape of a pentagram - * - * @category Primitive - */ export const pentagram = RuneFunctions.pentagram; -/** - * Colors the given rune pink (#E91E63s). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const pink = RuneColours.pink; -/** - * Colors the given rune purple (#AA00FF). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const purple = RuneColours.purple; -/** - * Makes a new Rune from a given Rune - * by turning it a quarter-turn in - * anti-clockwise direction. - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const quarter_turn_left = RuneFunctions.quarter_turn_left; -/** - * Makes a new Rune from a given Rune - * by turning it a quarter-turn around the centre in - * clockwise direction. - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const quarter_turn_right = RuneFunctions.quarter_turn_right; -/** - * Gives random color to the given rune. - * The color is chosen randomly from the following nine - * colors: red, pink, purple, indigo, blue, green, yellow, orange, brown - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const random_color = RuneColours.random_color; -/** - * Rune with the shape of a - * small square inside a large square, - * each diagonally split into a - * black and white half - * - * @category Primitive - */ export const rcross = RuneFunctions.rcross; -/** - * Colors the given rune red (#F44336). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const red = RuneColours.red; -/** - * Applies a given function n times to an initial value - * @param {number} n - A non-negative integer - * @param {function} pattern - Unary function from Rune to Rune - * @param {Rune} initial - The initial Rune - * @returns {Rune} - Result of n times application of pattern to initial: - * pattern(pattern(...pattern(pattern(initial))...)) - * @function - * - * @category Main - */ export const repeat_pattern = RuneFunctions.repeat_pattern; -/** - * Rune with the shape of a ribbon - * winding outwards in an anticlockwise spiral - * - * @category Primitive - */ export const ribbon = RuneFunctions.ribbon; -/** - * Rotates a given Rune by a given angle, - * given in radians, in anti-clockwise direction. - * Note that parts of the Rune - * may be cropped as a result. - * @param {number} rad - Angle in radians - * @param {Rune} rune - Given Rune - * @returns {Rune} Rotated Rune - * @function - * - * @category Main - */ export const rotate = RuneFunctions.rotate; -/** - * Rune with the shape of a sail - * - * @category Primitive - */ export const sail = RuneFunctions.sail; -/** - * Scales a given Rune by a given factor in both x and y direction - * @param {number} ratio - Scaling factor - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting scaled Rune - * @function - * - * @category Main - */ export const scale = RuneFunctions.scale; -/** - * Scales a given Rune by separate factors in x and y direction - * @param {number} ratio_x - Scaling factor in x direction - * @param {number} ratio_y - Scaling factor in y direction - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting scaled Rune - * @function - * - * @category Main - */ export const scale_independent = RuneFunctions.scale_independent; -/** - * Makes a new Rune from two given Runes by - * placing the first on top of the second, each - * occupying equal parts of the height of the - * result - * @param {Rune} rune1 - Given Rune - * @param {Rune} rune2 - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const stack = RuneFunctions.stack; -/** - * Makes a new Rune from two given Runes by - * placing the first on top of the second - * such that the first one occupies frac - * portion of the height of the result and - * the second the rest - * @param {number} frac - Fraction between 0 and 1 (inclusive) - * @param {Rune} rune1 - Given Rune - * @param {Rune} rune2 - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const stack_frac = RuneFunctions.stack_frac; -/** - * Makes a new Rune from a given Rune - * by vertically stacking n copies of it - * @param {number} n - Positive integer - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const stackn = RuneFunctions.stackn; -/** - * Rune with the shape of a full square - * - * @category Primitive - */ export const square = RuneFunctions.square; -/** - * Translates a given Rune by given values in x and y direction - * @param {number} x - Translation in x direction - * @param {number} y - Translation in y direction - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting translated Rune - * @function - * - * @category Main - */ export const translate = RuneFunctions.translate; -/** - * Rune with the shape of a triangle - * - * @category Primitive - */ export const triangle = RuneFunctions.triangle; -/** - * Makes a new Rune from a given Rune - * by turning it upside-down - * @param {Rune} rune - Given Rune - * @returns {Rune} Resulting Rune - * @function - * - * @category Main - */ export const turn_upside_down = RuneFunctions.turn_upside_down; -/** - * Colors the given rune yellow (#FFEB3B). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const yellow = RuneColours.yellow; -/** - * Colors the given rune white (#FFFFFF). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * @function - * - * @category Color - */ export const white = RuneColours.white; diff --git a/src/bundles/rune/src/index.ts b/src/bundles/rune/src/index.ts index 4db0c66d23..1b9b1f931b 100644 --- a/src/bundles/rune/src/index.ts +++ b/src/bundles/rune/src/index.ts @@ -1,63 +1,1050 @@ /** * The module `rune` provides functions for drawing runes. * - * A *Rune* is defined by its vertices (x,y,z,t), the colors on its vertices (r,g,b,a), a transformation matrix for rendering the Rune and a (could be empty) list of its sub-Runes. + * A *Rune* is defined by its vertices (x,y,z,t), the colors on its vertices + * (r,g,b,a), a transformation matrix for rendering the Rune and a list of + * sub-Runes. * @module rune * @author Hou Ruomu */ -export { - beside, - beside_frac, - black, - blank, - blue, - brown, - circle, - color, - corner, - flip_horiz, - flip_vert, - from_url, - green, - heart, - indigo, - make_cross, - nova, - orange, - overlay, - overlay_frac, - pentagram, - pink, - purple, - quarter_turn_left, - quarter_turn_right, - random_color, - rcross, - red, - repeat_pattern, - ribbon, - rotate, - sail, - scale, - scale_independent, - square, - stack, - stackn, - stack_frac, - translate, - triangle, - turn_upside_down, - white, - yellow -} from './functions'; - -export { type_map } from './type_map'; - -export { - anaglyph, - animate_anaglyph, - animate_rune, - hollusion, - hollusion_magnitude, - show -} from './display'; + +import type { IChannel, 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 * as funcs from './functions'; +import { + RUNE_CHANNEL_ID, + serializeRune, + type RuneAnimationMessage, + type RuneChannelMessage, + type RuneDisplayMessage, + type RuneRenderMessage +} from './protocol'; +import { Rune } from './rune'; +import { throwIfNotRune } from './runes_ops'; + +type RuneTabLoader = { + tabs: string[]; + loadTab: (tab: string) => void; +}; + +export default class RuneModulePlugin extends BaseModulePlugin { + id = 'rune'; + static channelAttach = [RUNE_CHANNEL_ID]; + exportedNames = [ + 'anaglyph', + 'animate_anaglyph', + 'animate_rune', + 'beside', + 'beside_frac', + 'black', + 'blue', + 'brown', + 'color', + 'flip_horiz', + 'flip_vert', + 'from_url', + 'green', + 'hollusion', + 'hollusion_magnitude', + 'indigo', + 'make_cross', + 'orange', + 'overlay', + 'overlay_frac', + 'pink', + 'purple', + 'quarter_turn_left', + 'quarter_turn_right', + 'random_color', + 'red', + 'repeat_pattern', + 'rotate', + 'scale', + 'scale_independent', + 'show', + 'stack', + 'stack_frac', + 'stackn', + 'translate', + 'turn_upside_down', + 'white', + 'yellow' + ] as const; + + private readonly __runeChannel: IChannel; + private readonly __tabLoader: RuneTabLoader | undefined; + private readonly __displayed: RuneDisplayMessage[] = []; + private __tabLoaded = false; + + /** + * Rune with the shape of a blank square + * + * @category Primitive + * @publicType Rune + */ + blank!: Rune; + + /** + * Rune with the shape of a circle + * + * @category Primitive + * @publicType Rune + */ + circle!: Rune; + + /** + * Rune with black triangle, + * filling upper right corner + * + * @category Primitive + * @publicType Rune + */ + corner!: Rune; + + /** + * Rune with the shape of a heart + * + * @category Primitive + * @publicType Rune + */ + heart!: Rune; + + /** + * Rune with the shape of two overlapping + * triangles, residing in the upper half + * of the shape + * + * @category Primitive + * @publicType Rune + */ + nova!: Rune; + + /** + * Rune with the shape of a pentagram + * + * @category Primitive + * @publicType Rune + */ + pentagram!: Rune; + + /** + * Rune with the shape of a + * small square inside a large square, + * each diagonally split into a + * black and white half + * + * @category Primitive + * @publicType Rune + */ + rcross!: Rune; + + /** + * Rune with the shape of a ribbon + * winding outwards in an anticlockwise spiral + * + * @category Primitive + * @publicType Rune + */ + ribbon!: Rune; + + /** + * Rune with the shape of a sail + * + * @category Primitive + * @publicType Rune + */ + sail!: Rune; + + /** + * Rune with the shape of a full square + * + * @category Primitive + * @publicType Rune + */ + square!: Rune; + + /** + * Rune with the shape of a triangle + * + * @category Primitive + * @publicType Rune + */ + triangle!: Rune; + + constructor( + conduit: IConduit, + [runeChannel]: IChannel[], + evaluator: IInterfacableEvaluator, + tabLoader: RuneTabLoader + ) { + super(conduit, [runeChannel], evaluator); + + if (!runeChannel) { + throw new Error('Rune channel is required but was not provided.'); + } + + this.__runeChannel = runeChannel as IChannel; + this.__tabLoader = tabLoader; + this.__runeChannel.subscribe(message => { + if (message.type === 'request') { + this.__displayed.forEach(displayedMessage => this.__runeChannel.send(displayedMessage)); + } + }); + } + + async initialise() { + await super.initialise(); + for (const name in funcs.RuneFunctions) { + const value = funcs.RuneFunctions[name as keyof typeof funcs.RuneFunctions]; + if (!(value instanceof Rune)) { + continue; + } + this[name] = funcs.RuneFunctions[name]; + this.exports.push({ + symbol: name, + value: await this.__makeRune(this[name]) + }); + } + } + + /** + * Loads the host-side tab + * @returns Whether the tab was already loaded + */ + private __loadRuneTab(): boolean { + if (this.__tabLoaded || this.__tabLoader === undefined) return true; + + const tabName = this.__tabLoader.tabs[0]; + if (tabName === undefined) return true; + + this.__tabLoader.loadTab(tabName); + this.__tabLoaded = true; + return false; + } + + private async __display(message: RuneDisplayMessage): Promise { + this.__displayed.push(message); + if (this.__loadRuneTab()) { + this.__runeChannel.send(message); + } + } + + private async __getRune( + rune: TypedValue, + funcName: string + ): Promise { + const value = await this.evaluator.opaque_get(rune); + throwIfNotRune(funcName, value); + return value; + } + + private async __makeRune(rune: Rune): Promise> { + return await this.evaluator.opaque_make(rune, true); + } + + private async __callUnaryRune( + arg: TypedValue, + funcName: string, + operation: (rune: Rune) => Rune + ): Promise> { + return this.__makeRune(operation(await this.__getRune(arg, funcName))); + } + + private async __sendRuneRender( + rune: Rune, + message: Omit + ): Promise { + await this.__display({ + ...message, + rune: serializeRune(rune) + }); + } + + private async* __evaluateAnimationFrames( + duration: number, + fps: number, + func: TypedValue, + funcName: string + ): AsyncGenerator { + const frameCount = Math.max(1, Math.ceil(duration * fps) + 1); + const frames: RuneAnimationMessage['frames'] = []; + + for (let frame = 0; frame < frameCount; frame += 1) { + const timestamp = Math.min(frame / fps, duration); + const result = yield* this.evaluator.closure_call_unchecked( + func as TypedValue, + [{ type: DataType.NUMBER, value: timestamp }] + ); + frames.push(serializeRune(await this.__getRune(result, funcName))); + } + + return frames; + } + + /** + * Renders the specified Rune in a tab as a basic drawing. + * @function + * @param rune - The Rune to render + * @returns The specified Rune + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* show(rune: TypedValue): AsyncGenerator, undefined> { + const value = await this.__getRune(rune, 'show'); + await this.__sendRuneRender(value, { type: 'render', mode: 'normal' }); + return rune; + } + + /** + * Renders the specified Rune in a tab as an anaglyph. Use 3D glasses to view the + * anaglyph. + * @function + * @param rune - The Rune to render + * @returns The specified Rune + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* anaglyph(rune: TypedValue): AsyncGenerator, undefined> { + const value = await this.__getRune(rune, 'anaglyph'); + await this.__sendRuneRender(value, { type: 'render', mode: 'anaglyph' }); + return rune; + } + + /** + * Renders the specified Rune in a tab as a hollusion, using the specified + * magnitude. + * @function + * @param rune - The Rune to render + * @param magnitude - The hollusion's magnitude + * @returns The specified Rune + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* hollusion_magnitude( + rune: TypedValue, + magnitude: TypedValue + ): AsyncGenerator, undefined> { + const value = await this.__getRune(rune, 'hollusion_magnitude'); + await this.__sendRuneRender(value, { + type: 'render', + mode: 'hollusion', + magnitude: magnitude.value + }); + return rune; + } + + /** + * Renders the specified Rune in a tab as a hollusion, with a default magnitude + * of 0.1. + * @function + * @param rune - The Rune to render + * @returns The specified Rune + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* hollusion(rune: TypedValue): AsyncGenerator, undefined> { + const value = await this.__getRune(rune, 'hollusion'); + await this.__sendRuneRender(value, { + type: 'render', + mode: 'hollusion', + magnitude: 0.1 + }); + return rune; + } + + /** + * Create an animation of runes + * @function + * @param duration Duration of the entire animation in seconds + * @param fps Duration of each frame in frames per seconds + * @param func Takes in the timestamp and returns a Rune to draw + * @returns A rune animation + * + * @category Main + * @publicType func: RuneAnimation + * @publicReturnType AnimatedRune + */ + async* animate_rune( + duration: TypedValue, + fps: TypedValue, + func: TypedValue + ): AsyncGenerator, undefined> { + const frames = yield* this.__evaluateAnimationFrames(duration.value, fps.value, func, 'animate_rune'); + const message: RuneAnimationMessage = { + type: 'animation', + mode: 'normal', + duration: duration.value, + fps: fps.value, + frames + }; + await this.__display(message); + return await this.evaluator.opaque_make({ message, toReplString: () => '' }, true); + } + + /** + * Create an animation of anaglyph runes + * @function + * @param duration Duration of the entire animation in seconds + * @param fps Duration of each frame in frames per seconds + * @param func Takes in the timestamp and returns a Rune to draw + * @returns A rune animation + * + * @category Main + * @publicType func: RuneAnimation + * @publicReturnType AnimatedRune + */ + async* animate_anaglyph( + duration: TypedValue, + fps: TypedValue, + func: TypedValue + ): AsyncGenerator, undefined> { + const frames = yield* this.__evaluateAnimationFrames(duration.value, fps.value, func, 'animate_anaglyph'); + const message: RuneAnimationMessage = { + type: 'animation', + mode: 'anaglyph', + duration: duration.value, + fps: fps.value, + frames + }; + await this.__display(message); + return await this.evaluator.opaque_make({ message, toReplString: () => '' }, true); + } + + /** + * Create a rune using the image provided in the url + * @param imageUrl URL to the image that is used to create the rune. + * Note that the url must be from a domain that allows CORS. + * @returns Rune created using the image. + * @function + * + * @category Main + * @publicReturnType Rune + */ + async* from_url(imageUrl: TypedValue): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.from_url(imageUrl.value)); + } + + /** + * Scales a given Rune by separate factors in x and y direction + * @param ratioX - Scaling factor in x direction + * @param ratioY - Scaling factor in y direction + * @param rune - Given Rune + * @returns Resulting scaled Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* scale_independent( + ratioX: TypedValue, + ratioY: TypedValue, + rune: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.scale_independent(ratioX.value, ratioY.value, await this.__getRune(rune, funcs.scale_independent.name))); + } + + /** + * Scales a given Rune by a given factor in both x and y direction + * @param ratio - Scaling factor + * @param rune - Given Rune + * @returns Resulting scaled Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* scale( + ratio: TypedValue, + rune: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.scale(ratio.value, await this.__getRune(rune, funcs.scale.name))); + } + + /** + * Translates a given Rune by given values in x and y direction + * @param x - Translation in x direction + * @param y - Translation in y direction + * @param rune - Given Rune + * @returns Resulting translated Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* translate( + x: TypedValue, + y: TypedValue, + rune: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.translate(x.value, y.value, await this.__getRune(rune, funcs.translate.name))); + } + + /** + * Rotates a given Rune by a given angle, + * given in radians, in anti-clockwise direction. + * Note that parts of the Rune + * may be cropped as a result. + * @param rad - Angle in radians + * @param rune - Given Rune + * @returns Rotated Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* rotate( + rad: TypedValue, + rune: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.rotate(rad.value, await this.__getRune(rune, funcs.rotate.name))); + } + + /** + * Makes a new Rune from two given Runes by + * placing the first on top of the second + * such that the first one occupies frac + * portion of the height of the result and + * the second the rest + * @param frac - Fraction between 0 and 1 (inclusive) + * @param rune1 - Given Rune + * @param rune2 - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune1: Rune + * @publicType rune2: Rune + * @publicReturnType Rune + */ + async* stack_frac( + frac: TypedValue, + rune1: TypedValue, + rune2: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.stack_frac( + frac.value, + await this.__getRune(rune1, funcs.stack_frac.name), + await this.__getRune(rune2, funcs.stack_frac.name) + )); + } + + /** + * Makes a new Rune from two given Runes by + * placing the first on top of the second, each + * occupying equal parts of the height of the + * result + * @param rune1 - Given Rune + * @param rune2 - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune1: Rune + * @publicType rune2: Rune + * @publicReturnType Rune + */ + async* stack( + rune1: TypedValue, + rune2: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.stack( + await this.__getRune(rune1, funcs.stack.name), + await this.__getRune(rune2, funcs.stack.name) + )); + } + + /** + * Makes a new Rune from a given Rune + * by vertically stacking n copies of it + * @param n - Positive integer + * @param rune - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* stackn( + n: TypedValue, + rune: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.stackn(n.value, await this.__getRune(rune, funcs.stackn.name))); + } + + /** + * Makes a new Rune from a given Rune + * by turning it a quarter-turn around the centre in + * clockwise direction. + * @param rune - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* quarter_turn_right(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.quarter_turn_right.name, funcs.quarter_turn_right); + } + + /** + * Makes a new Rune from a given Rune + * by turning it a quarter-turn in + * anti-clockwise direction. + * @param rune - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* quarter_turn_left(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.quarter_turn_left.name, funcs.quarter_turn_left); + } + + /** + * Makes a new Rune from a given Rune + * by turning it upside-down + * @param rune - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* turn_upside_down(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.turn_upside_down.name, funcs.turn_upside_down); + } + + /** + * Makes a new Rune from two given Runes by + * placing the first on the left of the second + * such that the first one occupies frac + * portion of the width of the result and + * the second the rest + * @param frac - Fraction between 0 and 1 (inclusive) + * @param rune1 - Given Rune + * @param rune2 - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune1: Rune + * @publicType rune2: Rune + * @publicReturnType Rune + */ + async* beside_frac( + frac: TypedValue, + rune1: TypedValue, + rune2: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.beside_frac( + frac.value, + await this.__getRune(rune1, funcs.beside_frac.name), + await this.__getRune(rune2, funcs.beside_frac.name) + )); + } + + /** + * Makes a new Rune from two given Runes by + * placing the first on the left of the second, + * both occupying equal portions of the width + * of the result + * @param rune1 - Given Rune + * @param rune2 - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune1: Rune + * @publicType rune2: Rune + * @publicReturnType Rune + */ + async* beside( + rune1: TypedValue, + rune2: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.beside( + await this.__getRune(rune1, funcs.beside.name), + await this.__getRune(rune2, funcs.beside.name) + )); + } + + /** + * Makes a new Rune from a given Rune by + * flipping it around a horizontal axis, + * turning it upside down + * @param rune - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* flip_vert(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.flip_vert.name, funcs.flip_vert); + } + + /** + * Makes a new Rune from a given Rune by + * flipping it around a vertical axis, + * creating a mirror image + * @param rune - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* flip_horiz(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.flip_horiz.name, funcs.flip_horiz); + } + + /** + * Makes a new Rune from a given Rune by + * arranging into a square for copies of the + * given Rune in different orientations + * @param rune - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* make_cross(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.make_cross.name, funcs.make_cross); + } + + /** + * Applies a given function n times to an initial value + * @param n - A non-negative integer + * @param pattern - Unary function from Rune to Rune + * @param initial - The initial Rune + * @returns - Result of n times application of pattern to initial: + * pattern(pattern(...pattern(pattern(initial))...)) + * @function + * + * @category Main + * @publicType pattern: (Rune) => Rune + * @publicType initial: Rune + * @publicReturnType Rune + */ + async* repeat_pattern( + n: TypedValue, + pattern: TypedValue, + initial: TypedValue + ): AsyncGenerator, undefined> { + let current = initial; + for (let i = 0; i < n.value; i += 1) { + current = yield* this.evaluator.closure_call_unchecked( + pattern as TypedValue, + [current] + ); + await this.__getRune(current, funcs.repeat_pattern.name); + } + return current; + } + + /** + * The depth range of the z-axis of a rune is [0,-1], this function gives a [0, -frac] of the depth range to rune1 and the rest to rune2. + * @param frac - Fraction between 0 and 1 (inclusive) + * @param rune1 - Given Rune + * @param rune2 - Given Rune + * @returns Resulting Rune + * @function + * + * @category Main + * @publicType rune1: Rune + * @publicType rune2: Rune + * @publicReturnType Rune + */ + async* overlay_frac( + frac: TypedValue, + rune1: TypedValue, + rune2: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.overlay_frac( + frac.value, + await this.__getRune(rune1, funcs.overlay_frac.name), + await this.__getRune(rune2, funcs.overlay_frac.name) + )); + } + + /** + * The depth range of the z-axis of a rune is [0,-1], this function maps the depth range of rune1 and rune2 to [0,-0.5] and [-0.5,-1] respectively. + * @param rune1 - Given Rune + * @param rune2 - Given Rune + * @returns Resulting Runes + * @function + * + * @category Main + * @publicType rune1: Rune + * @publicType rune2: Rune + * @publicReturnType Rune + */ + async* overlay( + rune1: TypedValue, + rune2: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.overlay( + await this.__getRune(rune1, funcs.overlay.name), + await this.__getRune(rune2, funcs.overlay.name) + )); + } + + /** + * Adds color to rune by specifying + * the red, green, blue (RGB) value, ranging from 0.0 to 1.0. + * RGB is additive: if all values are 1, the color is white, + * and if all values are 0, the color is black. + * @param rune - The rune to add color to + * @param r - Red value [0.0, 1.0] + * @param g - Green value [0.0, 1.0] + * @param b - Blue value [0.0, 1.0] + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* color( + rune: TypedValue, + r: TypedValue, + g: TypedValue, + b: TypedValue + ): AsyncGenerator, undefined> { + return await this.__makeRune(funcs.color(await this.__getRune(rune, funcs.color.name), r.value, g.value, b.value)); + } + + /** + * Colors the given rune black (#000000). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* black(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.black.name, funcs.black); + } + + /** + * Colors the given rune blue (#2196F3). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* blue(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.blue.name, funcs.blue); + } + + /** + * Colors the given rune brown. + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* brown(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.brown.name, funcs.brown); + } + + /** + * Colors the given rune green (#4CAF50). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* green(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.green.name, funcs.green); + } + + /** + * Colors the given rune indigo (#3F51B5). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* indigo(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.indigo.name, funcs.indigo); + } + + /** + * Colors the given rune red (#F44336). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* red(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.red.name, funcs.red); + } + + /** + * Colors the given rune pink (#E91E63s). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* pink(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.pink.name, funcs.pink); + } + + /** + * Colors the given rune orange (#FF9800). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* orange(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.orange.name, funcs.orange); + } + + /** + * Colors the given rune purple (#AA00FF). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* purple(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.purple.name, funcs.purple); + } + + /** + * Colors the given rune white (#FFFFFF). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* white(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.white.name, funcs.white); + } + + /** + * Colors the given rune yellow (#FFEB3B). + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* yellow(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.yellow.name, funcs.yellow); + } + + /** + * Gives random color to the given rune. + * The color is chosen randomly from the following nine + * colors: red, pink, purple, indigo, blue, green, yellow, orange, brown + * @param rune - The rune to color + * @returns The colored Rune + * @function + * + * @category Color + * @publicType rune: Rune + * @publicReturnType Rune + */ + async* random_color(rune: TypedValue): AsyncGenerator, undefined> { + return await this.__callUnaryRune(rune, funcs.random_color.name, funcs.random_color); + } +} + +attachModuleMethod(RuneModulePlugin, 'anaglyph', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'animate_anaglyph', [DataType.NUMBER, DataType.NUMBER, DataType.CLOSURE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'animate_rune', [DataType.NUMBER, DataType.NUMBER, DataType.CLOSURE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'beside', [DataType.OPAQUE, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'beside_frac', [DataType.NUMBER, DataType.OPAQUE, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'black', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'blue', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'brown', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'color', [DataType.OPAQUE, DataType.NUMBER, DataType.NUMBER, DataType.NUMBER], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'flip_horiz', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'flip_vert', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'from_url', [DataType.CONST_STRING], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'green', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'hollusion', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'hollusion_magnitude', [DataType.OPAQUE, DataType.NUMBER], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'indigo', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'make_cross', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'orange', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'overlay', [DataType.OPAQUE, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'overlay_frac', [DataType.NUMBER, DataType.OPAQUE, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'pink', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'purple', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'quarter_turn_left', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'quarter_turn_right', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'random_color', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'red', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'repeat_pattern', [DataType.NUMBER, DataType.CLOSURE, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'rotate', [DataType.NUMBER, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'scale', [DataType.NUMBER, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'scale_independent', [DataType.NUMBER, DataType.NUMBER, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'show', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'stack', [DataType.OPAQUE, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'stack_frac', [DataType.NUMBER, DataType.OPAQUE, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'stackn', [DataType.NUMBER, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'translate', [DataType.NUMBER, DataType.NUMBER, DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'turn_upside_down', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'white', [DataType.OPAQUE], DataType.OPAQUE); +attachModuleMethod(RuneModulePlugin, 'yellow', [DataType.OPAQUE], DataType.OPAQUE); diff --git a/src/bundles/rune/src/protocol.ts b/src/bundles/rune/src/protocol.ts new file mode 100644 index 0000000000..c7d4d5a4f0 --- /dev/null +++ b/src/bundles/rune/src/protocol.ts @@ -0,0 +1,56 @@ +import { Rune } from './rune'; + +export const RUNE_CHANNEL_ID = 'sourceacademy-rune-channel'; +export const RUNE_RUNNER_ID = 'rune-runner'; +export const RUNE_WEB_ID = 'rune-web'; +export const RUNE_TAB_NAME = 'Rune'; + +export type SerializedRune = { + vertices: number[]; + colors: number[] | null; + transformMatrix: number[]; + subRunes: SerializedRune[]; + textureUrl: string | null; + hollusionDistance: number; +}; + +export type RuneRenderMode = 'normal' | 'anaglyph' | 'hollusion'; +export type RuneAnimationMode = 'normal' | 'anaglyph'; + +export type RuneRenderMessage = { + type: 'render'; + mode: RuneRenderMode; + rune: SerializedRune; + magnitude?: number; +}; + +export type RuneAnimationMessage = { + type: 'animation'; + mode: RuneAnimationMode; + duration: number; + fps: number; + frames: SerializedRune[]; +}; + +export type RuneDisplayMessage = RuneRenderMessage | RuneAnimationMessage; + +export type RuneChannelMessage = RuneDisplayMessage | { + type: 'request'; +}; + +function serializeTexture(texture: Rune['texture']): string | null { + if (texture === null) return null; + if (typeof texture === 'string') return texture; + return texture.src; +} + +export function serializeRune(rune: Rune): SerializedRune { + return { + vertices: Array.from(rune.vertices), + colors: rune.colors === null ? null : Array.from(rune.colors), + transformMatrix: Array.from(rune.transformMatrix), + subRunes: rune.subRunes.map(serializeRune), + textureUrl: serializeTexture(rune.texture), + hollusionDistance: rune.hollusionDistance + }; +} diff --git a/src/bundles/rune/src/rune.ts b/src/bundles/rune/src/rune.ts index 77426d7890..9d1b2f7dfb 100644 --- a/src/bundles/rune/src/rune.ts +++ b/src/bundles/rune/src/rune.ts @@ -1,7 +1,6 @@ import { glAnimation, type AnimFrame, type ReplResult } from '@sourceacademy/modules-lib/types'; import { mat4 } from 'gl-matrix'; import { getWebGlFromCanvas, initShaderProgram } from './runes_webgl'; -import { classDeclaration } from './type_map'; const normalVertexShader = ` attribute vec4 aVertexPosition; @@ -53,7 +52,6 @@ void main(void) { /** * The basic data-representation of a Rune. When the Rune is drawn, every 3 consecutive vertex will form a triangle. */ -@classDeclaration('Rune') export class Rune { constructor( /** @@ -75,7 +73,7 @@ export class Rune { * A (potentially empty) list of Runes */ public subRunes: Rune[], - public texture: HTMLImageElement | null, + public texture: HTMLImageElement | string | null, public hollusionDistance: number ) { } @@ -125,7 +123,7 @@ export class Rune { colors?: Float32Array | null; transformMatrix?: mat4; subRunes?: Rune[]; - texture?: HTMLImageElement | null; + texture?: HTMLImageElement | string | null; hollusionDistance?: number; } = {}) => { const paramGetter = (name: string, defaultValue: () => any) => (params[name] === undefined ? defaultValue() : params[name]); @@ -149,7 +147,7 @@ export class Rune { * @param gl a prepared WebGLRenderingContext with shader program linked * @param runes a list of rune (Rune[]) to be drawn sequentially */ -export function drawRunesToFrameBuffer( +export async function drawRunesToFrameBuffer( gl: WebGLRenderingContext, runes: Rune[], cameraMatrix: mat4, @@ -211,8 +209,8 @@ export function drawRunesToFrameBuffer( // load projection and camera const orthoCam = mat4.create(); mat4.ortho(orthoCam, -1, 1, -1, 1, -0.5, 1.5); - gl.uniformMatrix4fv(projectionMatrixPointer, false, orthoCam); - gl.uniformMatrix4fv(cameraMatrixPointer, false, cameraMatrix); + gl.uniformMatrix4fv(projectionMatrixPointer, false, orthoCam as Float32List); + gl.uniformMatrix4fv(cameraMatrixPointer, false, cameraMatrix as Float32List); // load colorfilter gl.uniform4fv(vertexColorFilterPt, colorFilter); @@ -223,7 +221,27 @@ export function drawRunesToFrameBuffer( * Initialize a texture and load an image. * When the image finished loading copy it into the texture. */ - const loadTexture = (image: HTMLImageElement): WebGLTexture | null => { + const loadTexture = async (rune: Rune): Promise => { + if (rune.texture === null) return null; + const imageSource = rune.texture; + let image: HTMLImageElement; + if (typeof imageSource !== 'string') { + image = imageSource; + } else { + image = await new Promise((resolve, reject) => { + const image = Object.assign(new Image(), { + crossOrigin: 'anonymous', + src: imageSource + }); + image.onload = () => { + rune.texture = image; + resolve(image); + }; + image.onabort = reject; + image.onerror = reject; + }); + } + const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); function isPowerOf2(value) { @@ -253,7 +271,6 @@ export function drawRunesToFrameBuffer( srcType, pixel ); - gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D( gl.TEXTURE_2D, @@ -281,7 +298,7 @@ export function drawRunesToFrameBuffer( return texture; }; - runes.forEach((rune: Rune) => { + for (const rune of runes) { // load position buffer const positionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); @@ -297,7 +314,7 @@ export function drawRunesToFrameBuffer( ); gl.uniform1i(textureSwitchPointer, 0); } else { - const texture = loadTexture(rune.texture); + const texture = await loadTexture(rune); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(texturePointer, 0); @@ -305,12 +322,12 @@ export function drawRunesToFrameBuffer( } // load transformation matrix - gl.uniformMatrix4fv(modelViewMatrixPointer, false, rune.transformMatrix); + gl.uniformMatrix4fv(modelViewMatrixPointer, false, rune.transformMatrix as Float32List); // draw const vertexCount = rune.vertices.length / 4; gl.drawArrays(gl.TRIANGLES, 0, vertexCount); - }); + } } /** @@ -372,7 +389,7 @@ export abstract class DrawnRune implements ReplResult { public toReplString = () => ''; - public abstract draw: (canvas: HTMLCanvasElement) => void; + public abstract draw: (canvas: HTMLCanvasElement) => Promise; } export class NormalRune extends DrawnRune { @@ -380,14 +397,14 @@ export class NormalRune extends DrawnRune { super(rune, false); } - public draw = (canvas: HTMLCanvasElement) => { + public draw = async (canvas: HTMLCanvasElement) => { const gl = getWebGlFromCanvas(canvas); // prepare camera projection array const cameraMatrix = mat4.create(); // color filter set to [1,1,1,1] for transparent filter - drawRunesToFrameBuffer( + await drawRunesToFrameBuffer( gl, this.rune.flatten(), cameraMatrix, diff --git a/src/bundles/rune/src/type_map.ts b/src/bundles/rune/src/type_map.ts deleted file mode 100644 index adf3306464..0000000000 --- a/src/bundles/rune/src/type_map.ts +++ /dev/null @@ -1,8 +0,0 @@ -import createTypeMap from '@sourceacademy/modules-lib/type_map'; - -const typeMapCreator = createTypeMap(); - -export const { functionDeclaration, variableDeclaration, classDeclaration } = typeMapCreator; - -/** @hidden */ -export const type_map = typeMapCreator.type_map; diff --git a/src/tabs/Rune/package.json b/src/tabs/Rune/package.json index 5f9e3de26a..c48490b3fc 100644 --- a/src/tabs/Rune/package.json +++ b/src/tabs/Rune/package.json @@ -3,9 +3,13 @@ "version": "1.0.0", "private": true, "dependencies": { + "@blueprintjs/icons": "^6.0.0", "@sourceacademy/bundle-rune": "workspace:^", + "@sourceacademy/common-tabs": "^0.0.1", + "@sourceacademy/conductor": "https://github.com/source-academy/conductor", "@sourceacademy/modules-lib": "workspace:^", "es-toolkit": "^1.44.0", + "gl-matrix": "^3.4.4", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/src/tabs/Rune/src/__tests__/Rune.test.tsx b/src/tabs/Rune/src/__tests__/Rune.test.tsx index 8aa59b67f4..f3a3c52867 100644 --- a/src/tabs/Rune/src/__tests__/Rune.test.tsx +++ b/src/tabs/Rune/src/__tests__/Rune.test.tsx @@ -1,25 +1,40 @@ -import { animate_anaglyph, animate_rune, blank } from '@sourceacademy/bundle-rune'; -import { HollusionRune, type RuneModuleState } from '@sourceacademy/bundle-rune/functions'; -import type { DebuggerContext } from '@sourceacademy/modules-lib/types'; -import { mockDebuggerContext } from '@sourceacademy/modules-lib/utilities'; +import { HollusionRune, blank } from '@sourceacademy/bundle-rune/functions'; +import { + serializeRune, + type RuneChannelMessage +} from '@sourceacademy/bundle-rune/protocol'; +import type { ITabService } from '@sourceacademy/common-tabs'; +import type { IChannel, IConduit } from '@sourceacademy/conductor/conduit'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-react'; -import RuneSideContent, { RuneTab } from '..'; +import RuneTabPlugin, { RUNE_TAB_ID } from '..'; import HollusionCanvas from '../hollusion_canvas'; -test('Ensure that rune animations error gracefully', () => { - const badAnimation = animate_rune(1, 60, _t => 1 as any); - const mockContext = mockDebuggerContext({ drawnRunes: [badAnimation] }, 'rune'); - expect() - .toMatchSnapshot(); -}); +class MockChannel implements IChannel { + readonly name = 'mock-rune-channel'; + readonly sent: T[] = []; + private readonly subscribers = new Set<(message: T) => void>(); -test('Ensure that anaglyph animations error gracefully', () => { - const badAnimation = animate_anaglyph(1, 60, _t => 1 as any); - const mockContext = mockDebuggerContext({ drawnRunes: [badAnimation] }, 'rune'); - expect() - .toMatchSnapshot(); -}); + send(message: T) { + this.sent.push(message); + } + + subscribe(subscriber: (message: T) => void) { + this.subscribers.add(subscriber); + } + + unsubscribe(subscriber: (message: T) => void) { + this.subscribers.delete(subscriber); + } + + close() { + this.subscribers.clear(); + } + + emit(message: T) { + this.subscribers.forEach(subscriber => subscriber(message)); + } +} describe(HollusionCanvas, () => { beforeEach(() => { @@ -46,38 +61,40 @@ describe(HollusionCanvas, () => { }); }); -describe('Test Rune Side Content', () => { - const propertyAccessor = vi.fn((target: any, prop: string) => { - return target[prop]; - }); +describe(RuneTabPlugin, () => { + test('registers the rune tab and requests replay', () => { + const channel = new MockChannel(); + const tabService = { + registerTab: vi.fn(), + unregisterTab: vi.fn(), + showTab: vi.fn(), + hideTab: vi.fn() + } satisfies ITabService; - const contextObject: DebuggerContext = { - context: { - moduleContexts: new Proxy({ - rune: { - state: { - drawnRunes: [] - }, - tabs: null - } - }, { - get: propertyAccessor - }) - } - } as any; + new RuneTabPlugin({} as IConduit, [channel], tabService); - beforeEach(() => { - propertyAccessor.mockClear(); + expect(tabService.registerTab).toHaveBeenCalledOnce(); + expect(channel.sent).toContainEqual({ type: 'request' }); }); - test('toSpawn asks for rune module state', () => { - RuneSideContent.toSpawn(contextObject); - expect(propertyAccessor).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 'rune', expect.any(Object)); - }); + test('stores render messages and shows the tab', () => { + const channel = new MockChannel(); + const tabService = { + registerTab: vi.fn(), + unregisterTab: vi.fn(), + showTab: vi.fn(), + hideTab: vi.fn() + } satisfies ITabService; + const plugin = new RuneTabPlugin({} as IConduit, [channel], tabService); + const message = { + type: 'render', + mode: 'normal', + rune: serializeRune(blank) + } satisfies RuneChannelMessage; - test('body asks for rune module state', async () => { - await render(); - expect(propertyAccessor).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 'rune', expect.any(Object)); - cleanup(); + channel.emit(message); + + expect(plugin.getMessages()).toEqual([message]); + expect(tabService.showTab).toHaveBeenCalledExactlyOnceWith(RUNE_TAB_ID); }); }); diff --git a/src/tabs/Rune/src/__tests__/__snapshots__/Rune.test.tsx.snap b/src/tabs/Rune/src/__tests__/__snapshots__/Rune.test.tsx.snap deleted file mode 100644 index 749b975190..0000000000 --- a/src/tabs/Rune/src/__tests__/__snapshots__/Rune.test.tsx.snap +++ /dev/null @@ -1,53 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Ensure that anaglyph animations error gracefully 1`] = ` - -`; - -exports[`Ensure that rune animations error gracefully 1`] = ` - -`; diff --git a/src/tabs/Rune/src/hollusion_canvas.tsx b/src/tabs/Rune/src/hollusion_canvas.tsx index 7e2d229e3a..060003a271 100644 --- a/src/tabs/Rune/src/hollusion_canvas.tsx +++ b/src/tabs/Rune/src/hollusion_canvas.tsx @@ -7,20 +7,30 @@ type Props = { rune: HollusionRune; }; +type RenderFuncState = + | { type: 'loading' } + | { type: 'ready', renderFunc: (time: number) => unknown } + | { type: 'uninitialized' }; + /** * Canvas used to display Hollusion runes */ export default function HollusionCanvas({ rune }: Props) { // We memoize the render function so that we don't have // to reinitialize the shaders every time - const renderFuncRef = React.useRef<(time: number) => void>(undefined); - + const renderFuncRef = React.useRef({ type: 'uninitialized' }); const { setCanvas } = useAnimation({ callback(timestamp, canvas) { - if (renderFuncRef.current === undefined) { - renderFuncRef.current = rune.draw(canvas); + if (renderFuncRef.current.type === 'ready') { + return renderFuncRef.current.renderFunc(timestamp); + } + if (renderFuncRef.current.type === 'uninitialized') { + renderFuncRef.current = { type: 'loading' }; + rune.draw(canvas).then(renderFunc => { + renderFuncRef.current = { type: 'ready', renderFunc }; + renderFuncRef.current.renderFunc(timestamp); + }); } - renderFuncRef.current(timestamp); }, autoStart: true }); diff --git a/src/tabs/Rune/src/index.tsx b/src/tabs/Rune/src/index.tsx index 46385bbc89..e01c2c32c8 100644 --- a/src/tabs/Rune/src/index.tsx +++ b/src/tabs/Rune/src/index.tsx @@ -1,45 +1,180 @@ -import { isHollusionRune, type RuneModuleState } from '@sourceacademy/bundle-rune/functions'; +import { AnaglyphRune, HollusionRune } from '@sourceacademy/bundle-rune/functions'; +import { + RUNE_CHANNEL_ID, + RUNE_WEB_ID, + type RuneChannelMessage, + type RuneDisplayMessage, + type SerializedRune +} from '@sourceacademy/bundle-rune/protocol'; +import { NormalRune, Rune } from '@sourceacademy/bundle-rune/rune'; +import type { ITabService, Tab } from '@sourceacademy/common-tabs'; +import { + checkIsPluginClass, + type IChannel, + type IConduit, + type IPlugin +} from '@sourceacademy/conductor/conduit'; import AnimationCanvas from '@sourceacademy/modules-lib/tabs/AnimationCanvas'; import MultiItemDisplay from '@sourceacademy/modules-lib/tabs/MultiItemDisplay/index'; import WebGLCanvas from '@sourceacademy/modules-lib/tabs/WebGLCanvas'; -import { defineTab, getModuleState } from '@sourceacademy/modules-lib/tabs/utils'; -import { glAnimation, type ModuleTab } from '@sourceacademy/modules-lib/types'; +import { glAnimation, type AnimFrame } from '@sourceacademy/modules-lib/types'; +import type { mat4 } from 'gl-matrix'; +import { createElement, useMemo, useSyncExternalStore } from 'react'; + import HollusionCanvas from './hollusion_canvas'; -export const RuneTab: ModuleTab = ({ debuggerCtx: context }) => { - const { drawnRunes } = getModuleState(context, 'rune')!; - const runeCanvases = drawnRunes.map((rune, i) => { - const elemKey = i.toString(); +function imageFromUrl(url: string): HTMLImageElement { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.src = url; + return image; +} - if (glAnimation.isAnimation(rune)) { - return ; - } - if (isHollusionRune(rune)) { - return ; +function deserializeRune(serialized: SerializedRune): Rune { + return Rune.of({ + vertices: new Float32Array(serialized.vertices), + colors: serialized.colors === null ? null : new Float32Array(serialized.colors), + transformMatrix: new Float32Array(serialized.transformMatrix) as unknown as mat4, + subRunes: serialized.subRunes.map(deserializeRune), + texture: serialized.textureUrl === null ? null : imageFromUrl(serialized.textureUrl), + hollusionDistance: serialized.hollusionDistance + }); +} + +class SerializedRuneAnimation extends glAnimation { + constructor(private readonly message: Extract) { + super(message.duration, message.fps); + } + + getFrame(timestamp: number): AnimFrame { + if (this.message.frames.length === 0) { + return { + draw: new NormalRune(Rune.of()).draw + }; } - return ( - { - if (r) { - rune.draw(r); - } - }} - key={elemKey} - /> + + const frame = Math.min( + Math.floor(timestamp * this.message.fps), + this.message.frames.length - 1 ); + const rune = deserializeRune(this.message.frames[frame]); + const drawnRune = this.message.mode === 'anaglyph' + ? new AnaglyphRune(rune) + : new NormalRune(rune); + + return { + draw: drawnRune.draw + }; + } +} + +function RenderedRune({ message }: { message: Extract }) { + const rune = useMemo(() => deserializeRune(message.rune), [message]); + const drawnRune = useMemo(() => { + if (message.mode === 'anaglyph') return new AnaglyphRune(rune); + if (message.mode === 'hollusion') return new HollusionRune(rune, message.magnitude ?? 0.1); + return new NormalRune(rune); + }, [message, rune]); + + if (drawnRune instanceof HollusionRune) { + return ; + } + + return ( + { + if (canvas) { + drawnRune.draw(canvas); + } + }} + /> + ); +} + +function RenderedAnimation({ message }: { message: Extract }) { + const animation = useMemo(() => new SerializedRuneAnimation(message), [message]); + return ; +} + +export function RuneTab({ messages }: { messages: readonly RuneDisplayMessage[] }) { + const runeCanvases = messages.map((message, index) => { + const key = index.toString(); + if (message.type === 'animation') { + return ; + } + return ; }); return ; -}; - -export default defineTab({ - toSpawn(context) { - const moduleState = getModuleState(context, 'rune'); - return !!moduleState && moduleState.drawnRunes.length > 0; - }, - body(context) { - return ; - }, - label: 'Runes Tab', - iconName: 'group-objects' -}); +} + +export const RUNE_TAB_ID = 'rune'; + +// eslint-disable-next-line @sourceacademy/tab-type +export default class RuneTabPlugin implements IPlugin { + readonly id = RUNE_WEB_ID; + static readonly channelAttach = [RUNE_CHANNEL_ID]; + + private readonly __runeChannel: IChannel; + private readonly __tabService: ITabService; + private readonly __listeners = new Set<() => void>(); + private __messages: readonly RuneDisplayMessage[] = []; + + private readonly __handleMessage = (message: RuneChannelMessage) => { + if (message.type === 'request') return; + this.__messages = [...this.__messages, message]; + this.__emit(); + this.__tabService.showTab(RUNE_TAB_ID); + }; + + constructor( + _conduit: IConduit, + [runeChannel]: IChannel[], + tabService: ITabService + ) { + if (!runeChannel) { + throw new Error('Rune channel is required but was not provided.'); + } + + this.__runeChannel = runeChannel as IChannel; + this.__tabService = tabService; + + const subscribe = (listener: () => void) => this.subscribe(listener); + const getMessages = () => this.getMessages(); + function RunePluginTab() { + const messages = useSyncExternalStore(subscribe, getMessages); + return createElement(RuneTab, { messages }); + } + + const tab = { + id: RUNE_TAB_ID, + iconName: 'group-objects', + body: createElement(RunePluginTab), + label: 'Runes Tab', + disabled: false + } satisfies Tab; + + this.__tabService.registerTab(tab); + this.__runeChannel.subscribe(this.__handleMessage); + this.__runeChannel.send({ type: 'request' }); + } + + getMessages(): readonly RuneDisplayMessage[] { + return this.__messages; + } + + subscribe(listener: () => void): () => void { + this.__listeners.add(listener); + return () => this.__listeners.delete(listener); + } + + destroy(): void { + this.__runeChannel.unsubscribe(this.__handleMessage); + this.__tabService.unregisterTab(RUNE_TAB_ID); + } + + private __emit(): void { + this.__listeners.forEach(listener => listener()); + } +} +checkIsPluginClass(RuneTabPlugin); diff --git a/yarn.lock b/yarn.lock index ecd05915c1..79c8c6e575 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4028,10 +4028,11 @@ __metadata: version: 0.0.0-use.local resolution: "@sourceacademy/bundle-rune@workspace:src/bundles/rune" dependencies: + "@sourceacademy/conductor": "npm:^0.6.0" "@sourceacademy/modules-buildtools": "workspace:^" "@sourceacademy/modules-lib": "workspace:^" es-toolkit: "npm:^1.44.0" - gl-matrix: "npm:^3.3.0" + gl-matrix: "npm:^3.4.4" js-slang: "npm:^1.0.85" typescript: "npm:^6.0.2" languageName: unknown @@ -4125,6 +4126,16 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/common-tabs@npm:^0.0.1": + version: 0.0.1 + resolution: "@sourceacademy/common-tabs@npm:0.0.1" + peerDependencies: + "@blueprintjs/icons": ^6.0.0 + react: ^18 || ^19 + checksum: 10c0/ca9d9b626b9fcf1eeed40703438cc3c5af8bcdc04475cd5071794a5a5effbe7ae064ad72da315491f6065b9cdb895cb86ea00176f20b5ff318cda831dd33f780 + languageName: node + linkType: hard + "@sourceacademy/conductor@https://github.com/source-academy/conductor": version: 0.5.0 resolution: "@sourceacademy/conductor@https://github.com/source-academy/conductor.git#commit=8bda154466fd1e8c97ac0fff01f42bc7d3b196df" @@ -4132,6 +4143,13 @@ __metadata: languageName: node linkType: hard +"@sourceacademy/conductor@npm:^0.6.0": + version: 0.6.0 + resolution: "@sourceacademy/conductor@npm:0.6.0" + checksum: 10c0/51c38382854718c10594534e59746309dbd9c6ee919bdec87fc4f9d4c74bb6ac3fd3ad7a7b0a56911bda4ac12241c4231a09e48aa0f56080cdd8286abf78c0ba + 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" @@ -4289,6 +4307,7 @@ __metadata: dependencies: "@blueprintjs/core": "npm:^6.0.0" "@blueprintjs/icons": "npm:^6.0.0" + "@sourceacademy/conductor": "npm:^0.6.0" "@sourceacademy/modules-buildtools": "workspace:^" "@types/react": "npm:^19.0.0" "@types/react-dom": "npm:^19.0.0" @@ -4624,12 +4643,16 @@ __metadata: version: 0.0.0-use.local resolution: "@sourceacademy/tab-Rune@workspace:src/tabs/Rune" dependencies: + "@blueprintjs/icons": "npm:^6.0.0" "@sourceacademy/bundle-rune": "workspace:^" + "@sourceacademy/common-tabs": "npm:^0.0.1" + "@sourceacademy/conductor": "https://github.com/source-academy/conductor" "@sourceacademy/modules-buildtools": "workspace:^" "@sourceacademy/modules-lib": "workspace:^" "@types/react": "npm:^19.0.0" "@vitest/browser-playwright": "npm:4.1.4" es-toolkit: "npm:^1.44.0" + gl-matrix: "npm:^3.4.4" playwright: "npm:^1.55.1" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" @@ -10396,7 +10419,7 @@ __metadata: languageName: node linkType: hard -"gl-matrix@npm:^3.3.0": +"gl-matrix@npm:^3.3.0, gl-matrix@npm:^3.4.4": version: 3.4.4 resolution: "gl-matrix@npm:3.4.4" checksum: 10c0/9aa022ffac0d158212ad0cd29939864ad919ac31cd5dc5a5d35e9d66bb62679ddf152ff7b2173ded20131045e40572b87f31b26a920be2a7583a1516b13b5b4b