diff --git a/.eslintignore b/.eslintignore index 27fa7672c..31f010ad3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,6 @@ .github/ .husky/ build/ + +**/.eslintrc*.js +scripts/templates/templates \ No newline at end of file diff --git a/.eslintrc.base.js b/.eslintrc.base.js index e65f2b28c..e172f8289 100644 --- a/.eslintrc.base.js +++ b/.eslintrc.base.js @@ -209,7 +209,7 @@ module.exports = { 'no-lonely-if': 1, 'no-loop-func': 1, // "no-magic-numbers": 0, - 'no-mixed-operators': 1, + 'no-mixed-operators': 0, 'no-multi-assign': 1, 'no-multi-str': 1, // "no-negated-condition": 0, @@ -318,7 +318,6 @@ module.exports = { 'require-await': 1, 'require-unicode-regexp': 1, 'require-yield': 1, // Was 2 - // 'sort-imports': 0, // "sort-keys": 0, // "sort-vars": 0, 'spaced-comment': [ diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 50d6ff02f..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,145 +0,0 @@ -module.exports = { - extends: ['./.eslintrc.base.js', 'plugin:prettier/recommended'], - - root: true, - parserOptions: { - sourceType: 'module', - }, - - rules: { - 'func-style': 0, - indent: [ - 1, - 2, // Was "tabs" - { - SwitchCase: 1, // Same - // VariableDeclarator: 1, - // outerIIFEBody: 1, - // MemberExpression: 1, - // FunctionDeclaration: { - // parameters: 1, - // body: 1 - // }, - // FunctionExpression: { - // parameters: 1, - // body: 1 - // }, - // StaticBlock: { - // body: 1 - // }, - // CallExpression: { - // arguments: 1, - // }, - // ArrayExpression: 1, - // ObjectExpression: 1, - // ImportDeclaration: 1, - // flatTernaryExpressions: false, - // offsetTernaryExpressions: false, - // ignoreComments: false - }, - ], - quotes: [ - 1, - 'single', // Was "double" - { - avoidEscape: true, // Same - // allowTemplateLiterals: false - }, - ], - - 'prettier/prettier': 1, // Was 2 - }, - - overrides: [ - { - files: ['*.ts', '*.tsx'], - - plugins: ['import', 'react', 'jsx-a11y', '@typescript-eslint'], - extends: ['airbnb-typescript', 'plugin:prettier/recommended'], - - parser: '@typescript-eslint/parser', - parserOptions: { - project: './tsconfig.json', - }, - - rules: { - // [typescript-eslint Extension Rules] - /* NOTE - .eslintrc.base.js has been configured for every rule off the - eslint:recommended config as of V8. - A similar complete config but for all typescript-eslint rules hasn't - been made, instead simply using airbnb-typescript's layers of - extended configs & plugins. - - This section is for reconfiguring the typescript-eslint extension - rules configured by airbnb-typescript that have replaced their eslint - equivalents, to make them match the behaviour in .eslintrc.base.js - */ - '@typescript-eslint/no-unused-vars': [ - 1, // Was 2 - { - // vars: "all", - // args: "after-used", - // ignoreRestSiblings: false, - argsIgnorePattern: '^_', - caughtErrors: 'all', // Was "none" - caughtErrorsIgnorePattern: '^_', - }, - ], - '@typescript-eslint/no-use-before-define': [ - 1, // Was 2 - { - functions: false, - // classes: true, - // variables: true, - // enums: true, // TS - // typedefs: true, // TS - // ignoreTypeReferences: true, // TS - }, - ], - '@typescript-eslint/default-param-last': 1, // Was 2 - '@typescript-eslint/no-shadow': [ - 1, // Was 2 - { - builtinGlobals: true, - // hoist: "functions", - // ignoreTypeValueShadow: true, // TS - // ignoreFunctionTypeParameterNameValueShadow: true, // TS - }, - ], - '@typescript-eslint/lines-between-class-members': 0, // Was 2 - - // [Error → Warn] - /* NOTE - This section is for reducing the severity of rules configured by - airbnb-typescript from 2 to 1, if the problems they point out do not - have the possibility of directly leading to errors - */ - 'prettier/prettier': 1, - - // [Other] - 'import/extensions': 0, - - '@typescript-eslint/naming-convention': [ - 1, - { - selector: 'variable', - // Was ['camelCase', 'PascalCase', 'UPPER_CASE']. - // Add snake case to let exported module variables match Source - format: ['camelCase', 'PascalCase', 'UPPER_CASE', 'snake_case'], - }, - { - selector: 'function', - // Was ['camelCase', 'PascalCase']. - // Add snake case to let exported module functions match Source - format: ['camelCase', 'PascalCase', 'snake_case'], - }, - { - selector: 'typeLike', - format: ['PascalCase'], - }, - ], - }, - }, - ], -}; diff --git a/.eslintrc.test.json b/.eslintrc.test.json new file mode 100644 index 000000000..112f0258d --- /dev/null +++ b/.eslintrc.test.json @@ -0,0 +1,4 @@ +{ + "extends": ["plugin:jest/recommended"], + "plugins": ["jest"] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index cbd7e30b5..828c32ffc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ build/* /package-lock.json coverage/ -scripts/rollup/database.json +scripts/build/database.json # Compiled source # ################### diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 57670c03a..000000000 --- a/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "es5", - "jsxSingleQuote": true, - "printWidth": 80, - "endOfLine": "auto" -} diff --git a/babel.config.js b/babel.config.js index 38660c587..fac36d9b9 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,4 @@ -module.exports = { +const defaultConfig = { /** * - **script** - Parse the file using the ECMAScript Script grammar. No import/export statements allowed, and files are not in strict mode. * - **module** - Parse the file using the ECMAScript Module grammar. Files are automatically strict, and import/export statements are allowed. @@ -89,3 +89,10 @@ module.exports = { ], ], }; + +module.exports = api => api.env('test') ? { + ...defaultConfig, + plugins: [ + ["babel-plugin-transform-import-meta", { module: "ES6" }] + ], +} : defaultConfig; diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 000000000..626ad435c --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,19 @@ +export default { + roots: [ + "/src/bundles", + "/src/tabs", + "/scripts" + ], + modulePaths:[ + '', + ], + // Module Name settings required to make chalk work with jest + moduleNameMapper: { + "#(.*)": "/node_modules/$1", + "lowdb": "/node_modules/lowdb/lib", + "steno": "/node_modules/steno/lib", + }, + transformIgnorePatterns: [ + 'node_modules/(?!=chalk)/' + ], +} \ No newline at end of file diff --git a/package.json b/package.json index 79993a135..212631b21 100644 --- a/package.json +++ b/package.json @@ -6,29 +6,29 @@ "license": "Apache-2.0", "scripts-info": { "//NOTE": "Run `npm i npm-scripts-info -g` to install once globally, then run `npm-scripts-info` as needed to list these descriptions", - "module": "Interactively initialise a new bundle or tab from their templates", + "create": "Interactively initialise a new bundle or tab from their templates", "lint": "Check code for eslint warnings and errors", - "lint:fix": "Lint code and fix automatically fixable problems", + "lint:src": "Lint bundle and tab code", + "lint:scripts": "Lint build script code", "build": "Lint code, then build modules and documentation", - "build:rollup": "Transpile modules into the `build/` folder and copy over `modules.json`", "build:docs": "Generate TypeDocs in the `build/documentation/` folder", - "rebuild": "Build only modules whose files have been modified since the last build/rebuild", "serve": "Start the HTTP server to serve all files in `build/`, with the same directory structure", + "scripts": "Run a script within the scripts directory using ts-node", "dev": "Rebuild modules, then serve", "prepare": "Enable git hooks", "test": "Run unit tests", "test:watch": "Watch files for changes and rerun tests related to changed files" }, "scripts": { - "module": "node ./scripts/templates/app.js", - "lint": "./node_modules/.bin/eslint --ext \".ts, .tsx\" src/", - "lint:fix": "./node_modules/.bin/eslint --ext \".ts, .tsx\" src/ --fix", - "build": "yarn lint && yarn build:rollup && yarn build:docs", - "build:rollup": "./node_modules/.bin/tsc && rollup -c scripts/rollup/rollup.config.js", - "build:docs": "node ./scripts/docs/index.js", - "rebuild": "./node_modules/.bin/tsc && rollup -c scripts/rollup/rollup.config.js --quick", + "create": "yarn scripts create", + "lint": " yarn lint:src && yarn lint:scripts", + "lint:src": "./node_modules/.bin/eslint -c src/.eslintrc.js --ext \".ts, .tsx\" src/", + "lint:scripts": "./node_modules/.bin/eslint -c scripts/.eslintrc.js --ext \".ts, .tsx\" scripts/", + "build": "yarn lint && yarn scripts build", + "build:docs": "yarn scripts build docs", "serve": "http-server --cors=* -c-1 -p 8022 ./build", - "dev": "yarn rebuild && yarn serve", + "dev": "yarn build && yarn serve", + "scripts": "cross-env TS_NODE_PROJECT=scripts/tsconfig.json ts-node scripts/index.ts", "prepare": "husky install", "test": "jest --verbose", "test:watch": "jest --watch" @@ -44,27 +44,30 @@ "@rollup/plugin-typescript": "^8.2.0", "@types/dom-mediacapture-record": "^1.0.11", "@types/jest": "^27.4.1", + "@types/lowdb": "^1.0.11", "@types/node": "^17.0.23", "@types/react": "^17.0.43", "@typescript-eslint/eslint-plugin": "^5.18.0", "@typescript-eslint/parser": "^5.18.0", "babel-jest": "^26.6.3", - "chalk": "^4.1.2", - "eslint": "^8.12.0", + "babel-plugin-transform-import-meta": "^2.2.0", + "chalk": "^5.0.1", + "commander": "^9.4.0", + "cross-env": "^7.0.3", + "eslint": "^8.21.0", + "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^17.0.0", - "eslint-config-prettier": "^8.5.0", "eslint-import-resolver-typescript": "^2.7.1", "eslint-plugin-import": "^2.26.0", + "eslint-plugin-jest": "^26.8.1", "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.4.0", "generate-template-files": "^3.0.0", "http-server": "^0.12.3", "husky": "5", "jest": "^26.6.3", - "lowdb": "1.0.0", - "prettier": "^2.2.1", + "lowdb": "^3.0.0", "rollup": "^2.41.2", "rollup-plugin-babel": "^4.4.0", "rollup-plugin-commonjs": "^10.1.0", @@ -72,14 +75,15 @@ "rollup-plugin-filesize": "^9.1.1", "rollup-plugin-inject-process-env": "^1.3.1", "ts-jest": "^26.5.4", - "typedoc": "^0.20.33", + "ts-node": "^10.9.1", + "typedoc": "^0.23.10", "typescript": "^4.2.3", "yarnhook": "^0.5.1" }, "dependencies": { - "@blueprintjs/core": "^3.54.0", - "@blueprintjs/icons": "^3.33.0", - "@blueprintjs/popover2": "^0.14.0", + "@blueprintjs/core": "^4.6.1", + "@blueprintjs/icons": "^4.4.0", + "@blueprintjs/popover2": "^1.4.3", "@jscad/modeling": "^2.9.5", "@jscad/regl-renderer": "^2.6.1", "classnames": "^2.3.1", @@ -93,12 +97,5 @@ "regl": "^2.1.0", "tslib": "^2.3.1", "typedoc-default-themes": "^0.12.10" - }, - "jest": { - "roots": [ - "/src/bundles", - "/src/tabs", - "/scripts" - ] } } diff --git a/scripts/.eslintignore b/scripts/.eslintignore new file mode 100644 index 000000000..a78717e20 --- /dev/null +++ b/scripts/.eslintignore @@ -0,0 +1,2 @@ +.eslintrc.js +templates/templates \ No newline at end of file diff --git a/scripts/.eslintrc.js b/scripts/.eslintrc.js new file mode 100644 index 000000000..9601e317f --- /dev/null +++ b/scripts/.eslintrc.js @@ -0,0 +1,27 @@ +// Leaving everything double quoted so it's easier to switch between JS and JSON +// Since JSON has automatic schema validation + +module.exports = { + // Need react here because otherwise we get undefined rule errors + "plugins": ["import", "react", "@typescript-eslint"], + "extends": ["../.eslintrc.base.js", "airbnb-typescript"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2022, + "project": "./tsconfig.json", + "tsconfigRootDir": __dirname, + }, + "rules": { + "func-style": 0, + "import/no-extraneous-dependencies": 0, + "no-console": 0, + "no-continue": 0, + "no-param-reassign": 0, + "no-restricted-syntax": 0, + "prefer-const": 0 + }, + "overrides": [{ + "extends": "../.eslintrc.test.json", + "files": ["./**/__tests__/**.test.ts"], + }] +} \ No newline at end of file diff --git a/scripts/__tests__/tabs.test.ts b/scripts/__tests__/tabs.test.ts new file mode 100644 index 000000000..250997cd3 --- /dev/null +++ b/scripts/__tests__/tabs.test.ts @@ -0,0 +1,37 @@ +import { convertRawTab } from '../build/build'; + +describe('Testing raw tab processing', () => { + /* + Basically in my (lee yi's) time testing, rollup's output keeps varying and I can't + figure out why. The convert raw tab function is my latest effort so far in trying + to get a tab processing method that can handle all of rollup's outputs + */ + + test('Converts React tab properly', () => { + const rawTab0 = '(function (React) {}(React))'; + const result0 = convertRawTab(rawTab0); + + expect(result0) + .toEqual('(function (React) {})'); + + const rawTab1 = '(function (React) {})(React)'; + const result1 = convertRawTab(rawTab1); + + expect(result1) + .toEqual('(function (React) {})'); + }); + + test('Converts ReactDOM tab properly', () => { + const rawTab0 = '(function (React, ReactDom) {}(React, ReactDom))'; + const result0 = convertRawTab(rawTab0); + + expect(result0) + .toEqual('(function (React, ReactDom) {})'); + + const rawTab1 = '(function (React, ReactDom) {})(React, ReactDom)'; + const result1 = convertRawTab(rawTab1); + + expect(result1) + .toEqual('(function (React, ReactDom) {})'); + }); +}); diff --git a/scripts/build/build.ts b/scripts/build/build.ts new file mode 100644 index 000000000..88f96f089 --- /dev/null +++ b/scripts/build/build.ts @@ -0,0 +1,251 @@ +import fs from 'fs'; +import fsPromises from 'fs/promises'; +import { + checkForUnknowns, + DBType, + defaultConfig, + EntryWithReason, + getDb, + isFolderModified, + Opts, + removeDuplicates, +} from './buildUtils'; +import { modules } from '../utilities'; +import { BUILD_PATH, SOURCE_PATH } from '../constants'; +import chalk from 'chalk'; +import { rollup } from 'rollup'; +import memoize from 'lodash/memoize'; +import type { Low } from 'lowdb/lib'; +import copy from './misc'; + +type Config = { + bundlesWithReason: EntryWithReason[]; + tabsWithReason: EntryWithReason[]; +}; + +/** + * Converts the iife output from rollup to the format that js-slang + * expects + */ +export const convertRawTab = (rawTab: string) => { + const regexp = /(?\(React(?:,\s?ReactDom)?\))/ugm; + const [{ index, groups: { str } }] = [...rawTab.matchAll(regexp)].slice(-1); + return rawTab.substring(0, index) + rawTab.substring(index + str.length); +}; + +/** + * Determine which bundles and tabs to build + */ +export const getBundlesAndTabs = async (db: Low, opts: Opts): Promise => { + const getBundles = async (): Promise => { + const shouldBuildBundle = (bundleName: string): false | string => { + if (!fs.existsSync(`${BUILD_PATH}/bundles/${bundleName}.js`)) { + return 'Bundle file missing from build folder'; + } + + // Folder was modified + const timestamp = db.data.bundles[bundleName] ?? 0; + if (!timestamp || isFolderModified(`${SOURCE_PATH}/bundles/${bundleName}`, timestamp)) { + return 'Outdated build'; + } + return false; + }; + const bundleNames = Object.keys(modules); + + if (opts.force) return bundleNames.map((bundle) => [bundle, '--force specified']); + + if (opts.modules) { + const unknowns = checkForUnknowns(opts.modules, bundleNames); + if (unknowns.length > 0) { + throw new Error(`Unknown modules: ${unknowns.join(', ')}`); + } + + return opts.modules.map((bundle) => [bundle, 'Specified by --module']) as EntryWithReason[]; + } + + try { + // Bundles build directory is empty or doesn't exist + const bundlesDir = await fsPromises.readdir(`${BUILD_PATH}/bundles`); + if (bundlesDir.length === 0) return bundleNames.map((bundle) => [bundle, 'Bundles build directory is empty']); + } catch (error) { + if (error.code === 'ENOENT') { + await fsPromises.mkdir(`${BUILD_PATH}/bundles`); + return bundleNames.map((bundle) => [bundle, 'Bundles build directory missing']); + } + throw error; + } + + return bundleNames.map((bundleName) => { + const result = shouldBuildBundle(bundleName); + return result === false ? result : [bundleName, result]; + }) + .filter((x) => x !== false) as EntryWithReason[]; + }; + + const bundlesWithReason = await getBundles(); + + const getTabs = async (): Promise => { + // Use lodash to memoize so we don't have to keep performing this tab calculation + const getAllTabs = memoize(() => removeDuplicates(Object.values(modules) + .flatMap((bundle) => bundle.tabs))); + + // If forcing, just get all tabs + if (opts.force) { + return getAllTabs() + .map((tabName) => [tabName, '--force specified']); + } + + try { + const tabBuildDir = await fsPromises.readdir(`${BUILD_PATH}/tabs`); + + // If the tab build directory is empty, build all tabs + if (tabBuildDir.length === 0) { + return getAllTabs() + .map((tabName) => [tabName, 'Tabs build directory is empty']); + } + } catch (error) { + // If the tab build directory doesn't exist, build all tabs + if (error.code === 'ENOENT') { + await fsPromises.mkdir(`${BUILD_PATH}/tabs`); + return getAllTabs() + .map((tabName) => [tabName, 'Tabs build directory is missing']); + } + throw error; + } + + const tabNames = {} as { [name: string]: string }; + + // If tab was specified to be rebuilt, then rebuild + if (opts.tabs) { + const unknowns = checkForUnknowns(opts.tabs, getAllTabs()); + + if (unknowns.length > 0) throw new Error(`Unknown tabs: ${unknowns.join('\n')}`); + opts.tabs.forEach((tabName) => { + tabNames[tabName] = 'Specified by --tab'; + }); + } + + // Add tabs for bundles that we are rebuilding + bundlesWithReason.forEach(([bundleName]) => { + modules[bundleName].tabs.forEach((tabName) => { + if (!tabNames[tabName]) tabNames[tabName] = `${bundleName} bundle is being rebuilt`; + }); + }); + + getAllTabs() + .forEach((tabName) => { + if (tabNames[tabName]) return; + + if (!fs.existsSync(`${BUILD_PATH}/tabs/${tabName}.js`)) { + tabNames[tabName] = 'Tab file missing from build folder'; + return; + } + + const timestamp = db.data.tabs[tabName] ?? 0; + if (!timestamp || isFolderModified(`${SOURCE_PATH}/tabs/${tabName}`, timestamp)) { + tabNames[tabName] = 'Outdated buiid'; + } + }); + + return Object.entries(tabNames); + }; + + const tabsWithReason = await getTabs(); + + return { + bundlesWithReason, + tabsWithReason, + }; +}; + +/** + * Use rollup to rebuild bundles and tabs + */ +export const buildBundlesAndTabs = async (db: Low, { + bundlesWithReason, + tabsWithReason, +}: Config, verbose: boolean) => { + if (bundlesWithReason.length === 0) { + console.log(chalk.greenBright('All bundles up to date')); + } else { + console.log('Building the following bundles:'); + if (verbose) { + console.log(bundlesWithReason.map(([bundle, reason]) => `• ${chalk.blueBright(bundle)}: ${reason}`) + .join('\n')); + } else { + console.log(bundlesWithReason.map(([bundle]) => `• ${chalk.blueBright(bundle)}`) + .join('\n')); + } + } + + const bundlesToBuild = bundlesWithReason.map(([bundle]) => bundle); + console.log(); + + if (tabsWithReason.length === 0) { + console.log(chalk.greenBright('All tabs up to date')); + } else { + console.log('Building the following tabs:'); + if (verbose) { + console.log(tabsWithReason.map(([tabName, reason]) => `• ${chalk.blueBright(tabName)}: ${reason}`) + .join('\n')); + } else { + console.log(tabsWithReason.map(([tabName]) => `• ${chalk.blueBright(tabName)}`) + .join('\n')); + } + } + + const tabsToBuild = tabsWithReason.map(([tabName]) => tabName); + const buildTime = new Date() + .getTime(); + + const bundlePromises = bundlesToBuild.map(async (bundle) => { + const rollupBundle = await rollup({ + ...defaultConfig, + input: `${SOURCE_PATH}/bundles/${bundle}/index.ts`, + }); + + await rollupBundle.write({ + file: `${BUILD_PATH}/bundles/${bundle}.js`, + format: 'iife', + }); + await rollupBundle.close(); + + db.data.bundles[bundle] = buildTime; + }); + + const tabPromises = tabsToBuild.map(async (tabName) => { + const rollupBundle = await rollup({ + ...defaultConfig, + input: `${SOURCE_PATH}/tabs/${tabName}/index.tsx`, + external: ['react', 'react-dom'], + }); + + const tabFile = `${BUILD_PATH}/tabs/${tabName}.js`; + + // Only one chunk should be generated + const { output: [{ code: rawTab }] } = await rollupBundle.generate({ + file: tabFile, + format: 'iife', + globals: { + 'react': 'React', + 'react-dom': 'ReactDom', + }, + }); + + await fsPromises.writeFile(tabFile, convertRawTab(rawTab)); + await rollupBundle.close(); + + db.data.tabs[tabName] = buildTime; + }); + + await Promise.all(bundlePromises.concat(tabPromises)); + await db.write(); +}; + +export default async (opts: Opts) => { + const db = await getDb(); + const parsedOpts = await getBundlesAndTabs(db, opts); + + await buildBundlesAndTabs(db, parsedOpts, opts.verbose); + await copy(); +}; diff --git a/scripts/build/buildUtils.ts b/scripts/build/buildUtils.ts new file mode 100644 index 000000000..6b8c2a1ad --- /dev/null +++ b/scripts/build/buildUtils.ts @@ -0,0 +1,178 @@ +// /* [Imports] */ +import fs from 'fs'; +import { babel } from '@rollup/plugin-babel'; +import rollupResolve from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; +import chalk from 'chalk'; +import commonJS from 'rollup-plugin-commonjs'; +import filesize from 'rollup-plugin-filesize'; +import injectProcessEnv from 'rollup-plugin-inject-process-env'; +import { join } from 'path'; +import { Low, JSONFile } from 'lowdb'; +import { + DATABASE_NAME, + NODE_MODULES_PATTERN, + SOURCE_PATH, + SUPPRESSED_WARNINGS, +} from '../constants'; +import { cjsDirname } from '../utilities'; + +/** + * Default configuration used by rollup for transpiling both tabs and bundles + */ +export const defaultConfig = { + onwarn(warning: any, warn: any) { + if (SUPPRESSED_WARNINGS.includes(warning.code)) return; + + warn(warning); + }, + plugins: [ + typescript({ + tsconfig: 'src/tsconfig.json', + }), + babel({ + babelHelpers: 'bundled', + extensions: ['.ts', '.tsx'], + include: [`${SOURCE_PATH}/**`], + }), + rollupResolve({ + // Source Academy's modules run in a browser environment. The default setting (false) may + // cause compilation issues when using some imported packages. + // https://github.com/rollup/plugins/tree/master/packages/node-resolve#browser + browser: true, + // Tells rollup to look for locally installed modules instead of preferring built-in ones. + // Node's built-in modules include `fs` and `path`, which the jsdom browser environment does + // not have. + // https://github.com/rollup/plugins/tree/master/packages/node-resolve#preferbuiltins + preferBuiltins: false, + }), + commonJS({ + include: NODE_MODULES_PATTERN, + + // https://github.com/rollup/rollup-plugin-commonjs#custom-named-exports + namedExports: { + 'react': [ + 'Children', + 'cloneElement', + 'Component', + 'createContext', + 'createElement', + 'createRef', + 'isValidElement', + 'PureComponent', + 'useCallback', + 'useContext', + 'useEffect', + 'useMemo', + 'useReducer', + 'useRef', + 'useState', + ], + 'react-dom': [ + 'createPortal', + 'findDOMNode', + 'render', + 'unmountComponentAtNode', + 'unstable_renderSubtreeIntoContainer', + ], + }, + }), + injectProcessEnv({ + NODE_ENV: process.env.NODE_ENV, + }), + + filesize({ + showMinifiedSize: false, + showGzippedSize: false, + }), + ], +}; + +// Function takes in relative paths, for cleaner logging +export function isFolderModified(relativeFolderPath: string, storedTimestamp: number) { + function toFullPath(rootRelativePath: string) { + return join(process.cwd(), rootRelativePath); + } + + let fullFolderPath = toFullPath(relativeFolderPath); + + let contents = fs.readdirSync(fullFolderPath); + for (let content of contents) { + let relativeContentPath = join(relativeFolderPath, content); + let fullContentPath = join(fullFolderPath, content); + + let stats = fs.statSync(fullContentPath); + + // If is folder, recurse. If found something modified, stop early + if ( + stats.isDirectory() + && isFolderModified(relativeContentPath, storedTimestamp) + ) { + return true; + } + + // Is file. Compare timestamps to see if stop early + if (stats.mtimeMs > storedTimestamp) { + console.log(chalk.grey(`• File modified: ${relativeContentPath}`)); + return true; + } + } + + return false; +} + +/** + * Get the path to the database file + */ +export function getDbPath() { + return join(cjsDirname(import.meta.url), DATABASE_NAME); +} + +export const checkForUnknowns = (inputs: T[], existing: T[]) => inputs.filter((each) => !existing.includes(each)); + +const DBKeys = ['jsons', 'bundles', 'tabs'] as const; + +type ObjectFromList, V = string> = { + [K in (T extends ReadonlyArray ? U : never)]: V +}; + +export type DBType = { + docs: number; +} & ObjectFromList; + +export type EntryWithReason = [string, string]; + +export type Opts = Partial<{ + force: boolean; + verbose: boolean; + modules: string[]; + tabs: string[]; + jsons: string[]; +}>; + +/** + * Get a new Lowdb instance + */ +export async function getDb() { + const db = new Low(new JSONFile(getDbPath())); + await db.read(); + + if (!db.data) { + // Set default data if database.json is missing + db.data = { + docs: 0, + jsons: {}, + bundles: {}, + tabs: {}, + }; + } + return db; +} + +export type BuildTask = (db: Low) => Promise; + +export function removeDuplicates(arr: T[]) { + return [...new Set(arr)]; +} diff --git a/scripts/docs/README.md b/scripts/build/docs/README.md similarity index 100% rename from scripts/docs/README.md rename to scripts/build/docs/README.md diff --git a/scripts/build/docs/drawdown.ts b/scripts/build/docs/drawdown.ts new file mode 100644 index 000000000..39e0fe1a3 --- /dev/null +++ b/scripts/build/docs/drawdown.ts @@ -0,0 +1,190 @@ +/* eslint-disable*/ +/** + * Module to convert from markdown into HTML + * drawdown.js + * (c) Adam Leggett + */ + +export default (src) => { + var rx_lt = //g; + var rx_space = /\t|\r|\uf8ff/g; + var rx_escape = /\\([\\\|`*_{}\[\]()#+\-~])/g; + var rx_hr = /^([*\-=_] *){3,}$/gm; + var rx_blockquote = /\n *> *([^]*?)(?=(\n|$){2})/g; + var rx_list = /\n( *)(?:[*\-+]|((\d+)|([a-z])|[A-Z])[.)]) +([^]*?)(?=(\n|$){2})/g; + var rx_listjoin = /<\/(ol|ul)>\n\n<\1>/g; + var rx_highlight = /(^|[^A-Za-z\d\\])(([*_])|(~)|(\^)|(--)|(\+\+)|`)(\2?)([^<]*?)\2\8(?!\2)(?=\W|_|$)/g; + var rx_code = /\n((```|~~~).*\n?([^]*?)\n?\2|(( {4}.*?\n)+))/g; + var rx_link = /((!?)\[(.*?)\]\((.*?)( ".*")?\)|\\([\\`*_{}\[\]()#+\-.!~]))/g; + var rx_table = /\n(( *\|.*?\| *\n)+)/g; + var rx_thead = /^.*\n( *\|( *\:?-+\:?-+\:? *\|)* *\n|)/; + var rx_row = /.*\n/g; + var rx_cell = /\||(.*?[^\\])\|/g; + var rx_heading = /(?=^|>|\n)([>\s]*?)(#{1,6}) (.*?)( #*)? *(?=\n|$)/g; + var rx_para = /(?=^|>|\n)\s*\n+([^<]+?)\n+\s*(?=\n|<|$)/g; + var rx_stash = /-\d+\uf8ff/g; + + function replace(rex, fn) { + src = src.replace(rex, fn); + } + + function element(tag, content) { + return '<' + tag + '>' + content + ''; + } + + function blockquote(src) { + return src.replace(rx_blockquote, function (all, content) { + return element( + 'blockquote', + blockquote(highlight(content.replace(/^ *> */gm, ''))) + ); + }); + } + + function list(src) { + return src.replace(rx_list, function (all, ind, ol, num, low, content) { + var entry = element( + 'li', + highlight( + content + .split( + RegExp('\n ?' + ind + '(?:(?:\\d+|[a-zA-Z])[.)]|[*\\-+]) +', 'g') + ) + .map(list) + .join('
  • ') + ) + ); + + return ( + '\n' + + (ol + ? '
      ' + : parseInt(ol, 36) - + 9 + + '" style="list-style-type:' + + (low ? 'low' : 'upp') + + 'er-alpha">') + + entry + + '
    ' + : element('ul', entry)) + ); + }); + } + + function highlight(src) { + return src.replace( + rx_highlight, + function (all, _, p1, emp, sub, sup, small, big, p2, content) { + return ( + _ + + element( + emp + ? p2 + ? 'strong' + : 'em' + : sub + ? p2 + ? 's' + : 'sub' + : sup + ? 'sup' + : small + ? 'small' + : big + ? 'big' + : 'code', + highlight(content) + ) + ); + } + ); + } + + function unesc(str) { + return str.replace(rx_escape, '$1'); + } + + var stash = []; + var si = 0; + + src = '\n' + src + '\n'; + + replace(rx_lt, '<'); + replace(rx_gt, '>'); + replace(rx_space, ' '); + + // blockquote + src = blockquote(src); + + // horizontal rule + replace(rx_hr, '
    '); + + // list + src = list(src); + replace(rx_listjoin, ''); + + // code + replace(rx_code, function (all, p1, p2, p3, p4) { + stash[--si] = element( + 'pre', + element('code', p3 || p4.replace(/^ {4}/gm, '')) + ); + return si + '\uf8ff'; + }); + + // link or image + replace(rx_link, function (all, p1, p2, p3, p4, p5, p6) { + stash[--si] = p4 + ? p2 + ? '' + p3 + '' + : '' + unesc(highlight(p3)) + '' + : p6; + return si + '\uf8ff'; + }); + + // table + replace(rx_table, function (all, table) { + var sep = table.match(rx_thead)[1]; + return ( + '\n' + + element( + 'table', + table.replace(rx_row, function (row, ri) { + return row == sep + ? '' + : element( + 'tr', + row.replace(rx_cell, function (all, cell, ci) { + return ci + ? element( + sep && !ri ? 'th' : 'td', + unesc(highlight(cell || '')) + ) + : ''; + }) + ); + }) + ) + ); + }); + + // heading + replace(rx_heading, function (all, _, p1, p2) { + return _ + element('h' + p1.length, unesc(highlight(p2))); + }); + + // paragraph + replace(rx_para, function (all, content) { + return element('p', unesc(highlight(content))); + }); + + // stash + replace(rx_stash, function (all) { + return stash[parseInt(all)]; + }); + + return src.trim(); +}; \ No newline at end of file diff --git a/scripts/build/docs/index.ts b/scripts/build/docs/index.ts new file mode 100644 index 000000000..ec15c1f76 --- /dev/null +++ b/scripts/build/docs/index.ts @@ -0,0 +1,255 @@ +import fs, { promises as fsPromises } from 'fs'; +import * as typedoc from 'typedoc'; +import chalk from 'chalk'; +import drawdown from './drawdown'; +import { + isFolderModified, + getDb, + DBType, + checkForUnknowns, + EntryWithReason, + Opts, +} from '../buildUtils'; +import { cjsDirname, modules as manifest } from '../../utilities'; +import { BUILD_PATH, SOURCE_PATH } from '../../constants'; +import type { Low } from 'lowdb/lib'; + +const warner = (msg: string, bundle: string) => console.log(`${chalk.yellow('Warning:')} ${bundle}: ${msg}`); + +/** + * Convert each element type (e.g. variable, function) to its respective HTML docstring + * to be displayed to users + */ +const parsers: { + [name: string]: (element: any, bundle: string) => { + header: string; + desc: string; + } +} = { + Variable(element, bundle) { + const getDesc = () => { + try { + const { comment: { summary: [{ text }] } } = element; + return drawdown(text); + } catch (_error) { + warner(`Could not get description for ${element.name}`, bundle); + return element.name; + } + }; + + if (!element.type?.name) { + warner(`Could not determine type for ${element.name}`, bundle); + } + + return { + header: `${element.name}: ${element.type.name || 'unknown'}`, + desc: getDesc(), + }; + }, + Function(element, bundle) { + // In source all functions should only have one signature + const { signatures: [signature] } = element; + + const getHeader = () => { + // Form the parameter string for the function + let paramStr: string; + if (!signature.parameters) paramStr = '()'; + else { + paramStr = `(${signature.parameters + .map((param) => { + const typeStr = param.type ? param.type.name : 'unknown'; + return `${param.name}: ${typeStr}`; + }) + .join(', ')})`; + } + + // Form the result representation for the function + const resultStr = !signature.type ? 'void' : signature.type.name; + + return `${element.name}${paramStr} → {${resultStr}}`; + }; + + const getDesc = () => { + try { + const { comment: { summary: [{ text }] } } = signature; + return drawdown(text); + } catch (_error) { + console.warn( + `${chalk.yellow('Warning:')} ${bundle}: Could not get description for ${ + element.name + }`, + ); + return element.name; + } + }; + + return { + header: getHeader(), + desc: getDesc(), + }; + }, +}; + +type JsonOpts = { + force?: boolean; + jsons: string[]; +}; + +/** + * Determine which json files to build + */ +export const getJsonsToBuild = async (db: Low, opts: JsonOpts): Promise => { + const bundleNames = Object.keys(manifest); + + try { + const docsDir = await fsPromises.readdir(`${BUILD_PATH}/jsons`); + if (docsDir.length === 0) return bundleNames.map((bundleName) => [bundleName, 'JSONs build directory empty']); + } catch (error) { + if (error.code === 'ENOENT') { + await fsPromises.mkdir(`${BUILD_PATH}/jsons`); + return bundleNames.map((bundleName) => [bundleName, 'JSONs build directory missing']); + } + throw error; + } + + if (opts.force) { + return bundleNames.map((bundleName) => [bundleName, '--force specified']); + } + + if (opts.jsons) { + const unknowns = checkForUnknowns(opts.jsons, bundleNames); + if (unknowns.length > 0) throw new Error(`Unknown modules: ${unknowns.join(', ')}`); + + return opts.jsons.map((bundleName) => [bundleName, 'Specified by --module']); + } + + return bundleNames.map((bundleName) => { + if (!fs.existsSync(`${BUILD_PATH}/jsons/${bundleName}.json`)) { + return [bundleName, 'JSON missing from JSONS build directory']; + } + + const timestamp = db.data.jsons[bundleName]; + if (!timestamp || isFolderModified(`${SOURCE_PATH}/bundles/${bundleName}`, timestamp)) { + return [bundleName, 'Outdated build']; + } + + return false; + }) + .filter((x) => x !== false) as EntryWithReason[]; +}; + +export const buildDocsAndJsons = async (db: Low, bundlesWithReason: EntryWithReason[], verbose: boolean) => { + const app = new typedoc.Application(); + app.options.addReader(new typedoc.TSConfigReader()); + app.options.addReader(new typedoc.TypeDocReader()); + + app.bootstrap({ + entryPoints: Object.keys(manifest) + .map( + (bundle) => `${SOURCE_PATH}/bundles/${bundle}/functions.ts`, + ), + tsconfig: 'src/tsconfig.json', + theme: 'default', + excludeInternal: true, + categorizeByGroup: true, + name: 'Source Academy Modules', + }); + + const project = app.convert(); + if (!project) throw new Error('Failed to initialize Typedoc project'); + + const docsTask = (async () => { + await app.generateDocs(project, `${BUILD_PATH}/documentation`); + + // For some reason typedoc's not working, so do a manual copy + await fsPromises.copyFile( + `${cjsDirname(import.meta.url)}/README.md`, + `${BUILD_PATH}/documentation/README.md`, + ); + db.data.docs = new Date() + .getTime(); + })(); + + const jsonTask = (async () => { + if (bundlesWithReason.length === 0) { + console.log(chalk.greenBright('No jsons to build.')); + return; + } + console.log( + chalk.greenBright('Building documentation for the following bundles:'), + ); + + if (verbose) { + console.log( + bundlesWithReason.map(([bundle, reason]) => `• ${chalk.blueBright(bundle)}: ${reason}`) + .join('\n'), + ); + } else { + console.log(bundlesWithReason.map(([bundle]) => `• ${chalk.blueBright(bundle)}`) + .join('\n')); + } + await app.generateJson(project, `${BUILD_PATH}/docs.json`); + + const bundleNames = bundlesWithReason.map(([bundle]) => bundle); + const buildTime = new Date() + .getTime(); + + const docsFile = await fsPromises.readFile(`${BUILD_PATH}/docs.json`, 'utf-8'); + const parsedJSON = JSON.parse(docsFile)?.children; + + if (!parsedJSON) { + throw new Error('Failed to parse docs.json'); + } + + await Promise.all( + bundleNames.map(async (bundle) => { + const docs = parsedJSON.find((x) => x.name === bundle)?.children; + + if (!docs) { + console.warn( + `${chalk.yellow('Warning:')} No documentation found for ${bundle}`, + ); + } else { + // Run through each item in the bundle and run its parser + const output: { [name: string]: string } = {}; + docs.forEach((element) => { + if (parsers[element.kindString]) { + const { header, desc } = parsers[element.kindString](element, bundle); + output[element.name] = `

    ${header}

    ${desc}
    `; + } else { + console.warn( + `${chalk.yellow('Warning:')} ${bundle}: No parser found for ${ + element.name + } of type ${element.type}`, + ); + } + }); + + // Then write that output to the bundles' respective json files + await fsPromises.writeFile( + `${BUILD_PATH}/jsons/${bundle}.json`, + JSON.stringify(output, null, 2), + ); + } + + db.data.jsons[bundle] = buildTime; + }), + ); + })(); + + await Promise.all([docsTask, jsonTask]); + await db.write(); +}; + +/** + * Build both JSONS and HTML documentation + */ +export default async ({ verbose, jsons, force }: Opts) => { + const db = await getDb(); + const jsonsToBuild = await getJsonsToBuild(db, { + jsons, + force, + }); + + await buildDocsAndJsons(db, jsonsToBuild, verbose); +}; diff --git a/scripts/build/index.ts b/scripts/build/index.ts new file mode 100644 index 000000000..fc4966699 --- /dev/null +++ b/scripts/build/index.ts @@ -0,0 +1,31 @@ +import { buildBundlesAndTabs, getBundlesAndTabs } from './build'; +import { buildDocsAndJsons, getJsonsToBuild } from './docs'; +import { DBType, getDb, Opts } from './buildUtils'; +import type { Low } from 'lowdb/lib'; +import copy from './misc'; + +const getThingsToBuild = async (db: Low, { force, modules, tabs, jsons }: Opts) => { + const [jsonOpts, bundleOpts] = await Promise.all([getJsonsToBuild(db, { + force, + jsons, + }), getBundlesAndTabs(db, { + force, + modules, + tabs, + })]); + + return { + jsonOpts, + bundleOpts, + }; +}; + +/** + * Build bundles, tabs, jsons and docs + */ +export default async (opts: Opts) => { + const db = await getDb(); + const { jsonOpts, bundleOpts } = await getThingsToBuild(db, opts); + await Promise.all([buildBundlesAndTabs(db, bundleOpts, opts.verbose), buildDocsAndJsons(db, jsonOpts, opts.verbose)]); + await copy(); +}; diff --git a/scripts/build/misc.ts b/scripts/build/misc.ts new file mode 100644 index 000000000..11860278b --- /dev/null +++ b/scripts/build/misc.ts @@ -0,0 +1,15 @@ +import { promises as fs } from 'fs'; +import { BUILD_PATH } from '../constants'; +import { getDbPath } from './buildUtils'; + +/** + * Copy `modules.json` to the build folder + */ +export const copyModules = () => fs.copyFile('modules.json', `${BUILD_PATH}/modules.json`); + +/** + * Copy `database.json` to the build folder + */ +export const copyDatabase = () => fs.copyFile(getDbPath(), `${BUILD_PATH}/database.json`); + +export default () => Promise.all([copyModules(), copyDatabase()]); diff --git a/scripts/constants.ts b/scripts/constants.ts new file mode 100644 index 000000000..494cecc6d --- /dev/null +++ b/scripts/constants.ts @@ -0,0 +1,9 @@ +/* [Exports] */ +export const SUPPRESSED_WARNINGS = ['MISSING_NAME_OPTION_FOR_IIFE_EXPORT']; + +export const DATABASE_NAME = 'database.json'; + +export const SOURCE_PATH = './src'; +export const BUILD_PATH = './build'; + +export const NODE_MODULES_PATTERN = './node_modules/**'; diff --git a/scripts/docs/index.js b/scripts/docs/index.js deleted file mode 100644 index 046060cf6..000000000 --- a/scripts/docs/index.js +++ /dev/null @@ -1,30 +0,0 @@ -const TypeDoc = require('typedoc'); -const paths = require('./paths'); -const modules = require('../../modules.json'); - -async function main() { - const app = new TypeDoc.Application(); - app.options.addReader(new TypeDoc.TSConfigReader()); - app.options.addReader(new TypeDoc.TypeDocReader()); - - app.bootstrap({ - entryPoints: Object.keys(modules).map( - (bundle) => `${paths.root}/src/bundles/${bundle}/functions.ts` - ), - theme: 'typedoc-modules-theme', - readme: `${paths.root}/scripts/docs/README.md`, - excludeInternal: true, - categorizeByGroup: true, - name: 'Source Academy Modules', - }); - - const project = app.convert(); - - if (project) { - const outputDir = 'build/documentation'; - await app.generateDocs(project, outputDir); - await app.generateJson(project, `${outputDir}/documentation.json`); - } -} - -main().catch(console.error); diff --git a/scripts/docs/paths.js b/scripts/docs/paths.js deleted file mode 100644 index 5741e8be8..000000000 --- a/scripts/docs/paths.js +++ /dev/null @@ -1,7 +0,0 @@ -const path = require('path'); - -const root = path.resolve(__dirname, '..', '..'); - -module.exports = { - root, -}; diff --git a/scripts/index.ts b/scripts/index.ts new file mode 100644 index 000000000..f1cb30c59 --- /dev/null +++ b/scripts/index.ts @@ -0,0 +1,52 @@ +import { promises as fs, constants as fsConstants } from 'fs'; +import build from './build'; +import buildDocs from './build/docs'; +import buildBundlesAndTabs from './build/build'; +import create from './templates'; +import type { Opts } from './build/buildUtils'; +import { Command } from 'commander'; +import { BUILD_PATH } from './constants'; +import chalk from 'chalk'; + +const buildTasks: { [name: string]: (opts: Opts) => Promise } = { + docs: buildDocs, + modules: buildBundlesAndTabs, +}; + +async function main() { + const parser = new Command(); + + parser.command('create', 'Interactive script for creating modules and tabs') + .action(create); + + parser.command('build') + .argument('[script]', 'Build task to execute') + .option('-f, --force', 'Force all files to be rebuilt') + .option('-m, --modules ', 'Specify bundles to be rebuilt') + .option('-t, --tabs ', 'Specify tabs to be rebuilt') + .option('-j, --jsons ', 'Specify jsons to be rebuilt') + .option('-v, --verbose', 'Enable verbose information') + .action(async (script: string, options: Opts) => { + if (script !== undefined && !buildTasks[script]) { + console.error(chalk.redBright(`Unknown task: ${script}`)); + return; + } + + try { + // Create the build folder if it doesn't already exist + await fs.access(BUILD_PATH, fsConstants.F_OK); + } catch (error) { + await fs.mkdir(BUILD_PATH); + } + + if (script === undefined) await build(options); + else await buildTasks[script](options); + }); + + await parser.parseAsync(); +} + +main() + .then(() => process.exit(0)); +// Something is keeping the process alive after it should die +// but I haven't found how to close it so process.exit will have to do diff --git a/scripts/rollup/constants.js b/scripts/rollup/constants.js deleted file mode 100644 index 52758dfc1..000000000 --- a/scripts/rollup/constants.js +++ /dev/null @@ -1,12 +0,0 @@ -/* [Exports] */ -export const SUPPRESSED_WARNINGS = ['MISSING_NAME_OPTION_FOR_IIFE_EXPORT']; - -export const DATABASE_NAME = 'database'; -export const DATABASE_KEY = 'timestamp'; - -export const SOURCE_PATH = './src/'; -export const BUILD_PATH = './build/'; - -export const SOURCE_PATTERN = `${SOURCE_PATH}**`; -export const NODE_MODULES_PATTERN = './node_modules/**'; -export const MODULES_PATH = './modules.json'; diff --git a/scripts/rollup/rollup.config.js b/scripts/rollup/rollup.config.js deleted file mode 100644 index 43739b48a..000000000 --- a/scripts/rollup/rollup.config.js +++ /dev/null @@ -1,35 +0,0 @@ -/* [Imports] */ -import chalk from 'chalk'; -import { - bundleNamesToConfigs, - getFinalPlugins, - getRollupBundleNames, - tabNamesToConfigs, -} from './utilities.js'; - -/* [Exports] */ -export default function (commandLineArguments) { - let rollupBundleNames = getRollupBundleNames( - Boolean(commandLineArguments.quick) - ); - let { bundleNames, tabNames } = rollupBundleNames; - - // Delete so rollup ignores the custom argument and doesn't log a warning - delete commandLineArguments.quick; - - let bundleConfigs = bundleNamesToConfigs(bundleNames); - let tabConfigs = tabNamesToConfigs(tabNames); - - // Rollup bundle configs, for module bundles and/or module tabs - let rollupBundleConfigs = [...bundleConfigs, ...tabConfigs]; - if (rollupBundleConfigs.length === 0) { - console.log(chalk.yellowBright('(Nothing new to build)\n')); - //NOTE The lack of any config for something real to transpile also means the - // final plugins below don't get the chance to run - process.exit(); - } - - let lastConfig = rollupBundleConfigs[rollupBundleConfigs.length - 1]; - lastConfig.plugins = [...lastConfig.plugins, ...getFinalPlugins()]; - return rollupBundleConfigs; -} diff --git a/scripts/rollup/utilities.js b/scripts/rollup/utilities.js deleted file mode 100644 index f12efcaa7..000000000 --- a/scripts/rollup/utilities.js +++ /dev/null @@ -1,264 +0,0 @@ -/* [Imports] */ -import babel from '@rollup/plugin-babel'; -import resolve from '@rollup/plugin-node-resolve'; -import typescript from '@rollup/plugin-typescript'; -import chalk from 'chalk'; -import Low from 'lowdb'; -import FileSync from 'lowdb/adapters/FileSync'; -import { dirname, join } from 'path'; -import commonJS from 'rollup-plugin-commonjs'; -import copy from 'rollup-plugin-copy'; -import filesize from 'rollup-plugin-filesize'; -import injectProcessEnv from 'rollup-plugin-inject-process-env'; -import { fileURLToPath } from 'url'; -import modules from '../../modules.json'; -import fs from 'fs'; -import { - BUILD_PATH, - DATABASE_KEY, - DATABASE_NAME, - MODULES_PATH, - NODE_MODULES_PATTERN, - SOURCE_PATH, - SOURCE_PATTERN, - SUPPRESSED_WARNINGS, -} from './constants.js'; - -/* [Main] */ -let fullDatabasePath = join( - dirname(fileURLToPath(import.meta.url)), - `${DATABASE_NAME}.json` -); -let adapter = new FileSync(fullDatabasePath); -let database = new Low(adapter); - -function getTimestamp() { - return database.get(DATABASE_KEY).value() ?? 0; -} - -function updateTimestamp() { - let newTimestamp = new Date().getTime(); - database.set(DATABASE_KEY, newTimestamp).write(); -} - -// Function takes in relative paths, for cleaner logging -function isFolderModified(relativeFolderPath, storedTimestamp) { - let fullFolderPath = toFullPath(relativeFolderPath); - - let contents = fs.readdirSync(fullFolderPath); - for (let content of contents) { - let relativeContentPath = join(relativeFolderPath, content); - let fullContentPath = join(fullFolderPath, content); - - let stats = fs.statSync(fullContentPath); - - // If is folder, recurse. If found something modified, stop early - if ( - stats.isDirectory() && - isFolderModified(relativeContentPath, storedTimestamp) - ) { - return true; - } - - // Is file. Compare timestamps to see if stop early - if (stats.mtimeMs > storedTimestamp) { - console.log(chalk.grey(`• File modified: ${relativeContentPath}`)); - return true; - } - } - - return false; -} - -function removeDuplicates(array) { - return [...new Set(array)]; -} - -function makeDefaultConfig() { - return { - onwarn(warning, warn) { - if (SUPPRESSED_WARNINGS.includes(warning.code)) return; - - warn(warning); - }, - plugins: [ - typescript(), - babel({ - babelHelpers: 'bundled', - extensions: ['.ts', '.tsx'], - include: [SOURCE_PATTERN], - }), - resolve({ - // Source Academy's modules run in a browser environment. The default setting (false) may - // cause compilation issues when using some imported packages. - // https://github.com/rollup/plugins/tree/master/packages/node-resolve#browser - browser: true, - // Tells rollup to look for locally installed modules instead of preferring built-in ones. - // Node's built-in modules include `fs` and `path`, which the jsdom browser environment does - // not have. - // https://github.com/rollup/plugins/tree/master/packages/node-resolve#preferbuiltins - preferBuiltins: false, - }), - commonJS({ - include: NODE_MODULES_PATTERN, - - // https://github.com/rollup/rollup-plugin-commonjs#custom-named-exports - namedExports: { - react: [ - 'Children', - 'cloneElement', - 'Component', - 'createContext', - 'createElement', - 'createRef', - 'isValidElement', - 'PureComponent', - 'useCallback', - 'useContext', - 'useEffect', - 'useMemo', - 'useReducer', - 'useRef', - 'useState', - ], - 'react-dom': [ - 'createPortal', - 'findDOMNode', - 'render', - 'unmountComponentAtNode', - 'unstable_renderSubtreeIntoContainer', - ], - }, - }), - injectProcessEnv({ - NODE_ENV: process.env.NODE_ENV, - }), - - filesize({ - showMinifiedSize: false, - showGzippedSize: false, - }), - ], - }; -} - -function bundleNameToSourceFolder(bundleName) { - // Root relative path - return `${SOURCE_PATH}bundles/${bundleName}/`; -} - -function tabNameToSourceFolder(tabName) { - // Root relative path - return `${SOURCE_PATH}tabs/${tabName}/`; -} - -function toFullPath(rootRelativePath) { - return join(process.cwd(), rootRelativePath); -} - -/* [Exports] */ -export function getRollupBundleNames(skipUnmodified) { - // All module bundles - let moduleNames = Object.keys(modules); - - // Skip modules whose files haven't been modified - console.log(''); - if (skipUnmodified) { - let storedTimestamp = getTimestamp(); - console.log( - chalk.grey( - `Quick rebuild mode (newer than ${new Date( - storedTimestamp - ).toLocaleString()}):` - ) - ); - - moduleNames = moduleNames.filter((moduleName) => { - // Check module bundle - let relativeBundleFolderPath = bundleNameToSourceFolder(moduleName); - if (isFolderModified(relativeBundleFolderPath, storedTimestamp)) - return true; - - // Check each module tab - for (let tabName of modules[moduleName].tabs) { - let relativeTabFolderPath = tabNameToSourceFolder(tabName); - if (isFolderModified(relativeTabFolderPath, storedTimestamp)) - return true; - } - - return false; - }); - } - - // All module tabs - let tabNames = moduleNames.flatMap((moduleName) => modules[moduleName].tabs); - tabNames = removeDuplicates(tabNames); - - return { - bundleNames: moduleNames, - tabNames, - }; -} - -export function bundleNamesToConfigs(names) { - let defaultConfig = makeDefaultConfig(); - - console.log(chalk.greenBright('Configured module bundles:')); - let configs = names.map((bundleName) => { - console.log(`• ${chalk.blueBright(bundleName)}`); - - return { - ...defaultConfig, - - input: `${bundleNameToSourceFolder(bundleName)}index.ts`, - output: { - file: `${BUILD_PATH}bundles/${bundleName}.js`, - format: 'iife', - }, - }; - }); - - return configs; -} - -export function tabNamesToConfigs(names) { - let defaultConfig = makeDefaultConfig(); - - console.log(chalk.greenBright('Configured module tabs:')); - let configs = names.map((tabName) => { - console.log(`• ${chalk.blueBright(tabName)}`); - - return { - ...defaultConfig, - - input: `${tabNameToSourceFolder(tabName)}index.tsx`, - output: { - file: `${BUILD_PATH}tabs/${tabName}.js`, - format: 'iife', - - globals: { - react: 'React', - 'react-dom': 'ReactDom', - }, - }, - external: ['react', 'react-dom'], - }; - }); - - return configs; -} - -export function getFinalPlugins() { - // Run these only once, at the end - return [ - copy({ - targets: [{ src: MODULES_PATH, dest: BUILD_PATH }], - }), - { - name: 'lowdb-timestamp', - buildEnd(error) { - if (error === undefined) updateTimestamp(); - }, - }, - ]; -} diff --git a/scripts/templates/app.js b/scripts/templates/app.js deleted file mode 100644 index b1d45f3d6..000000000 --- a/scripts/templates/app.js +++ /dev/null @@ -1,29 +0,0 @@ -const print = require('./print'); -const modules = require('./module'); -const tabs = require('./tab'); - -async function main() { - try { - const mode = await askMode(); - if (mode === 'module') await modules.addNew(); - else if (mode === 'tab') await tabs.addNew(); - } catch (error) { - print.error(`ERROR: ${error.message}`); - print.info('Terminating module app...'); - } finally { - print.rl.close(); - } -} - -async function askMode() { - const mode = await print.askQuestion( - 'What would you like to create? (module/tab)' - ); - if (mode !== 'module' && mode !== 'tab') { - print.warn("Please answer with only 'module' or 'tab'."); - return askMode(); - } - return mode; -} - -main(); diff --git a/scripts/templates/index.ts b/scripts/templates/index.ts new file mode 100644 index 000000000..e693b3ab3 --- /dev/null +++ b/scripts/templates/index.ts @@ -0,0 +1,30 @@ +import { error as _error, info, rl, askQuestion, warn } from './print'; +import { addNew as addNewModule } from './module'; +import { addNew as addNewTab } from './tab'; + +async function askMode() { + while (true) { + // eslint-disable-next-line no-await-in-loop + const mode = await askQuestion( + 'What would you like to create? (module/tab)', + ); + if (mode !== 'module' && mode !== 'tab') { + warn("Please answer with only 'module' or 'tab'."); + } else { + return mode; + } + } +} + +export default async function () { + try { + const mode = await askMode(); + if (mode === 'module') await addNewModule(); + else if (mode === 'tab') await addNewTab(); + } catch (error) { + _error(`ERROR: ${error.message}`); + info('Terminating module app...'); + } finally { + rl.close(); + } +} diff --git a/scripts/templates/module.js b/scripts/templates/module.js deleted file mode 100644 index 33bb61871..000000000 --- a/scripts/templates/module.js +++ /dev/null @@ -1,43 +0,0 @@ -const fs = require('fs').promises; -const paths = require('./paths'); -const print = require('./print'); -const utilities = require('./utilities'); -const manifest = require('../../modules.json'); - -function check(moduleName) { - return Object.keys(manifest).includes(moduleName); -} - -async function askModuleName() { - const name = await print.askQuestion( - 'What is the name of your new module? (eg. binary_tree)' - ); - if (utilities.isSnakeCase(name) === false) { - print.warn('Module names must be in snake case. (eg. binary_tree)'); - return askModuleName(); - } - if (check(name) === true) { - print.warn('A module with the same name already exists.'); - return askModuleName(); - } - return name; -} - -async function addNew() { - const moduleName = await askModuleName(); - const bundleDestination = `${paths.root}/src/bundles/${moduleName}`; - await fs.mkdir(bundleDestination, { recursive: true }); - await fs.copyFile(paths.bundleTemplate, `${bundleDestination}/index.ts`); - await fs.writeFile( - paths.manifest, - JSON.stringify({ ...manifest, [moduleName]: { tabs: [] } }, null, 2) - ); - print.success( - `Bundle for module ${moduleName} created at ${bundleDestination}.` - ); -} - -module.exports = { - addNew, - check, -}; diff --git a/scripts/templates/module.ts b/scripts/templates/module.ts new file mode 100644 index 000000000..2378f59f6 --- /dev/null +++ b/scripts/templates/module.ts @@ -0,0 +1,44 @@ +import { askQuestion, success, warn } from './print'; +import { cjsDirname, modules as manifest } from '../utilities'; +import { promises as fs } from 'fs'; +import { isSnakeCase } from './utilities'; +import { SOURCE_PATH } from '../constants'; + +export function check(moduleName: string) { + return Object.keys(manifest) + .includes(moduleName); +} + +async function askModuleName() { + while (true) { + // eslint-disable-next-line no-await-in-loop + const name = await askQuestion( + 'What is the name of your new module? (eg. binary_tree)', + ); + if (isSnakeCase(name) === false) { + warn('Module names must be in snake case. (eg. binary_tree)'); + } else if (check(name)) { + warn('A module with the same name already exists.'); + } else { + return name; + } + } +} + +export async function addNew() { + const moduleName = await askModuleName(); + const bundleDestination = `${SOURCE_PATH}/bundles/${moduleName}`; + await fs.mkdir(bundleDestination, { recursive: true }); + await fs.copyFile( + `${cjsDirname(import.meta.url)}/__bundle__.ts`, + `${bundleDestination}/index.ts`, + ); + await fs.writeFile( + 'modules.json', + JSON.stringify({ + ...manifest, + [moduleName]: { tabs: [] }, + }, null, 2), + ); + success(`Bundle for module ${moduleName} created at ${bundleDestination}.`); +} diff --git a/scripts/templates/paths.js b/scripts/templates/paths.js deleted file mode 100644 index e7d7bfb8a..000000000 --- a/scripts/templates/paths.js +++ /dev/null @@ -1,19 +0,0 @@ -const path = require('path'); - -const root = path.resolve(__dirname, '..', '..'); -const manifest = path.resolve(root, 'modules.json'); -const bundleTemplate = path.resolve( - root, - './scripts/templates/templates/__bundle__.ts' -); -const tabTemplate = path.resolve( - root, - './scripts/templates/templates/__tab__.tsx' -); - -module.exports = { - root, - manifest, - bundleTemplate, - tabTemplate, -}; diff --git a/scripts/templates/print.js b/scripts/templates/print.ts similarity index 50% rename from scripts/templates/print.js rename to scripts/templates/print.ts index 6ddfcb3cc..9433dd499 100644 --- a/scripts/templates/print.js +++ b/scripts/templates/print.ts @@ -1,38 +1,29 @@ -const chalk = require('chalk'); -const readline = require('readline'); +import chalk from 'chalk'; +import { createInterface } from 'readline'; -const rl = readline.createInterface({ +export const rl = createInterface({ input: process.stdin, output: process.stdout, }); -function info(...args) { +export function info(...args) { return console.log(...args.map((string) => chalk.grey(string))); } -function error(...args) { +export function error(...args) { return console.log(...args.map((string) => chalk.red(string))); } -function warn(...args) { +export function warn(...args) { return console.log(...args.map((string) => chalk.yellow(string))); } -function success(...args) { +export function success(...args) { return console.log(...args.map((string) => chalk.green(string))); } -function askQuestion(question) { - return new Promise((resolve) => { +export function askQuestion(question: string) { + return new Promise((resolve) => { rl.question(chalk.blueBright(`${question}\n`), resolve); }); } - -module.exports = { - rl, - info, - error, - warn, - success, - askQuestion, -}; diff --git a/scripts/templates/tab.js b/scripts/templates/tab.js deleted file mode 100644 index 8569a9f1f..000000000 --- a/scripts/templates/tab.js +++ /dev/null @@ -1,68 +0,0 @@ -const fs = require('fs').promises; -const paths = require('./paths'); -const print = require('./print'); -const utilities = require('./utilities'); -const modules = require('./module'); -const manifest = require('../../modules.json'); - -const existingTabs = Object.keys(manifest).reduce( - (accumulator, current) => accumulator.concat(manifest[current].tabs), - [] -); - -function check(tabName) { - return existingTabs.includes(tabName); -} - -async function askModuleName() { - const name = await print.askQuestion('Add a new tab to which module?'); - if (modules.check(name) === false) { - print.warn(`Module ${name} does not exist.`); - return askModuleName(); - } - return name; -} - -async function askTabName() { - const name = await print.askQuestion( - 'What is the name of your new tab? (eg. BinaryTree)' - ); - if (utilities.isPascalCase(name) === false) { - print.warn('Tab names must be in pascal case. (eg. BinaryTree)'); - return askTabName(); - } - if (check(name)) { - print.warn('A tab with the same name already exists.'); - return askTabName(); - } - return name; -} - -async function addNew() { - const moduleName = await askModuleName(); - const tabName = await askTabName(); - - // Copy module tab template into correct destination and show success message - const tabDestination = `${paths.root}/src/tabs/${tabName}`; - await fs.mkdir(tabDestination, { recursive: true }); - await fs.copyFile(paths.tabTemplate, `${tabDestination}/index.tsx`); - await fs.writeFile( - paths.manifest, - JSON.stringify( - { - ...manifest, - [moduleName]: { tabs: [...manifest[moduleName].tabs, tabName] }, - }, - null, - 2 - ) - ); - print.success( - `Tab ${tabName} for module ${moduleName} created at ${tabDestination}.` - ); -} - -module.exports = { - addNew, - check, -}; diff --git a/scripts/templates/tab.ts b/scripts/templates/tab.ts new file mode 100644 index 000000000..4fab87150 --- /dev/null +++ b/scripts/templates/tab.ts @@ -0,0 +1,67 @@ +/* eslint-disable no-await-in-loop */ +import { promises as fs } from 'fs'; +import { askQuestion, warn, success } from './print'; +import { isPascalCase } from './utilities'; +import { check as _check } from './module'; +import { modules as manifest, cjsDirname } from '../utilities'; +import { SOURCE_PATH } from '../constants'; + +const existingTabs = Object.values(manifest) + .flatMap((value) => value.tabs); + +export function check(tabName: string) { + return existingTabs.includes(tabName); +} + +async function askModuleName() { + while (true) { + const name = await askQuestion('Add a new tab to which module?'); + if (!_check(name)) { + warn(`Module ${name} does not exist.`); + } else { + return name; + } + } +} + +async function askTabName() { + while (true) { + const name = await askQuestion( + 'What is the name of your new tab? (eg. BinaryTree)', + ); + if (!isPascalCase(name)) { + warn('Tab names must be in pascal case. (eg. BinaryTree)'); + } else if (check(name)) { + warn('A tab with the same name already exists.'); + } else { + return name; + } + } +} + +export async function addNew() { + const moduleName = await askModuleName(); + const tabName = await askTabName(); + + // Copy module tab template into correct destination and show success message + const tabDestination = `${SOURCE_PATH}/tabs/${tabName}`; + await fs.mkdir(tabDestination, { recursive: true }); + await fs.copyFile( + `${cjsDirname(import.meta.url)}/templates/__templates__.ts`, + `${tabDestination}/index.tsx`, + ); + await fs.writeFile( + 'modules.json', + JSON.stringify( + { + ...manifest, + [moduleName]: { tabs: [...manifest[moduleName].tabs, tabName] }, + }, + null, + 2, + ), + ); + success( + `Tab ${tabName} for module ${moduleName} created at ${tabDestination}.`, + ); +} diff --git a/scripts/templates/templates/__bundle__.ts b/scripts/templates/templates/__bundle__.ts index 3394a2428..8715efd53 100644 --- a/scripts/templates/templates/__bundle__.ts +++ b/scripts/templates/templates/__bundle__.ts @@ -1,34 +1,34 @@ -/** - * A single sentence summarising the module (this sentence is displayed larger). - * - * Sentences describing the module. More sentences about the module. - * - * @module module_name - * @author Author Name - * @author Author Name - */ - -import { - ModuleContexts, - ModuleParams, -} from '../../../src/typings/type_helpers.js'; - -/** - * Sample function. Increments a number by 1. - * - * @param x The number to be incremented. - * @returns The incremented value of the number. - */ -function sample_function(x: number): number { - return ++x; -} - -//NOTE Remove the underscores before the parameter names if you will be using -// them. These parameters are passed in over on the frontend, and can later be -// accessed again in your module's tab via the DebuggerContext it gets passed -export default ( - _moduleParams: ModuleParams, - _moduleContexts: ModuleContexts -) => ({ - sample_function, -}); +/** + * A single sentence summarising the module (this sentence is displayed larger). + * + * Sentences describing the module. More sentences about the module. + * + * @module module_name + * @author Author Name + * @author Author Name + */ + +import { + ModuleContexts, + ModuleParams, +} from '../../../src/typings/type_helpers.js'; + +/** + * Sample function. Increments a number by 1. + * + * @param x The number to be incremented. + * @returns The incremented value of the number. + */ +function sample_function(x: number): number { + return ++x; +} + +//NOTE Remove the underscores before the parameter names if you will be using +// them. These parameters are passed in over on the frontend, and can later be +// accessed again in your module's tab via the DebuggerContext it gets passed +export default ( + _moduleParams: ModuleParams, + _moduleContexts: ModuleContexts +) => ({ + sample_function, +}); \ No newline at end of file diff --git a/scripts/templates/templates/__tab__.tsx b/scripts/templates/templates/__tab__.tsx index faa7ff3cd..34eb95b09 100644 --- a/scripts/templates/templates/__tab__.tsx +++ b/scripts/templates/templates/__tab__.tsx @@ -1,71 +1,71 @@ -import React from 'react'; - -/** - * - * @author - * @author - */ - -/** - * React Component props for the Tab. - */ -type Props = { - children?: never; - className?: never; - context?: any; -}; - -/** - * React Component state for the Tab. - */ -type State = { - counter: number; -}; - -/** - * The main React Component of the Tab. - */ -class Repeat extends React.Component { - constructor(props) { - super(props); - this.state = { - counter: 0, - }; - } - - public render() { - const { counter } = this.state; - return ( -
    This is spawned from the repeat package. Counter is {counter}
    - ); - } -} - -export default { - /** - * This function will be called to determine if the component will be - * rendered. Currently spawns when the result in the REPL is "test". - * @param {DebuggerContext} context - * @returns {boolean} - */ - toSpawn: (context: any) => context.result.value === 'test', - - /** - * This function will be called to render the module tab in the side contents - * on Source Academy frontend. - * @param {DebuggerContext} context - */ - body: (context: any) => , - - /** - * The Tab's icon tooltip in the side contents on Source Academy frontend. - */ - label: 'Sample Tab', - - /** - * BlueprintJS IconName element's name, used to render the icon which will be - * displayed in the side contents panel. - * @see https://blueprintjs.com/docs/#icons - */ - iconName: 'build', -}; +import React from 'react'; + +/** + * + * @author + * @author + */ + +/** + * React Component props for the Tab. + */ +type Props = { + children?: never; + className?: never; + context?: any; +}; + +/** + * React Component state for the Tab. + */ +type State = { + counter: number; +}; + +/** + * The main React Component of the Tab. + */ +class Repeat extends React.Component { + constructor(props) { + super(props); + this.state = { + counter: 0, + }; + } + + public render() { + const { counter } = this.state; + return ( +
    This is spawned from the repeat package. Counter is {counter}
    + ); + } +} + +export default { + /** + * This function will be called to determine if the component will be + * rendered. Currently spawns when the result in the REPL is "test". + * @param {DebuggerContext} context + * @returns {boolean} + */ + toSpawn: (context: any) => context.result.value === 'test', + + /** + * This function will be called to render the module tab in the side contents + * on Source Academy frontend. + * @param {DebuggerContext} context + */ + body: (context: any) => , + + /** + * The Tab's icon tooltip in the side contents on Source Academy frontend. + */ + label: 'Sample Tab', + + /** + * BlueprintJS IconName element's name, used to render the icon which will be + * displayed in the side contents panel. + * @see https://blueprintjs.com/docs/#icons + */ + iconName: 'build', +}; \ No newline at end of file diff --git a/scripts/templates/utilities.js b/scripts/templates/utilities.ts similarity index 59% rename from scripts/templates/utilities.js rename to scripts/templates/utilities.ts index 05d1e6dee..28dc4102e 100644 --- a/scripts/templates/utilities.js +++ b/scripts/templates/utilities.ts @@ -1,15 +1,10 @@ const snakeCaseRegex = /\b[a-z]+(?:_[a-z]+)*\b/u; const pascalCaseRegex = /^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/u; -function isSnakeCase(string) { +export function isSnakeCase(string: string) { return snakeCaseRegex.test(string); } -function isPascalCase(string) { +export function isPascalCase(string: string) { return pascalCaseRegex.test(string); } - -module.exports = { - isSnakeCase, - isPascalCase, -}; diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000..f930059b8 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,19 @@ +{ + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node", + "moduleTypes": { + "./**/*.ts": "esm", + }, + }, + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2020", + }, + "include": ["."], + // "exclude": ["./**/__tests__/**"] +} \ No newline at end of file diff --git a/scripts/utilities.ts b/scripts/utilities.ts new file mode 100644 index 000000000..e5c2e70f2 --- /dev/null +++ b/scripts/utilities.ts @@ -0,0 +1,49 @@ +/** + * Utilities for scripts + */ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import _modules from '../modules.json'; + +export type ModuleManifest = { + [name: string]: { + tabs: string[]; + }; +}; + +export const modules = _modules as ModuleManifest; + +/** + * Function to replace the functionality of `__dirname` in CommonJS modules + */ +export const cjsDirname = (url: string) => dirname(fileURLToPath(url)); + +export async function* asCompleted(promises: Promise[]) { + /** + * Named after the C# TaskCompletionSource + */ + class TCS { + public isResolved: boolean; + + constructor( + public readonly promise: Promise, + ) { + this.isResolved = false; + + promise + .catch(() => { this.isResolved = true; }) + .then(() => { this.isResolved = true; }); + } + } + + const tcs = promises.map((promise) => new TCS(promise)); + + while (tcs.length > 0) { + // eslint-disable-next-line no-await-in-loop + await Promise.race(tcs.map((each) => each.promise)); + const index = tcs.findIndex((each) => each.isResolved); + const [toYield] = tcs.splice(index, 1); + + yield toYield.promise; + } +} diff --git a/src/.eslintignore b/src/.eslintignore new file mode 100644 index 000000000..82a9a730d --- /dev/null +++ b/src/.eslintignore @@ -0,0 +1 @@ +.eslintrc.js \ No newline at end of file diff --git a/src/.eslintrc.js b/src/.eslintrc.js new file mode 100644 index 000000000..d57be1ff0 --- /dev/null +++ b/src/.eslintrc.js @@ -0,0 +1,129 @@ +module.exports = { + "extends": ["../.eslintrc.base.js", "airbnb-typescript"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.json", + "tsconfigRootDir": __dirname, + }, + "plugins": ["import", "react", "jsx-a11y", "@typescript-eslint"], + "rules": { + "func-style": 0, + "indent": [ + 1, + 2, // Was "tabs" + { + "SwitchCase": 1 // Same + // VariableDeclarator: 1, + // outerIIFEBody: 1, + // MemberExpression: 1, + // FunctionDeclaration: { + // parameters: 1, + // body: 1 + // }, + // FunctionExpression: { + // parameters: 1, + // body: 1 + // }, + // StaticBlock: { + // body: 1 + // }, + // CallExpression: { + // arguments: 1, + // }, + // ArrayExpression: 1, + // ObjectExpression: 1, + // ImportDeclaration: 1, + // flatTernaryExpressions: false, + // offsetTernaryExpressions: false, + // ignoreComments: false + } + ], + "quotes": [ + 1, + "single", // Was "double" + { + "avoidEscape": true // Same + // allowTemplateLiterals: false + } + ], + + // [typescript-eslint Extension Rules] + /* NOTE + .eslintrc.base.js has been configured for every rule off the + eslint:recommended config as of V8. + A similar complete config but for all typescript-eslint rules hasn"t + been made, instead simply using airbnb-typescript"s layers of + extended configs & plugins. + + This section is for reconfiguring the typescript-eslint extension + rules configured by airbnb-typescript that have replaced their eslint + equivalents, to make them match the behaviour in .eslintrc.base.js + */ + "@typescript-eslint/no-unused-vars": [ + 1, // Was 2 + { + // vars: "all", + // args: "after-used", + // ignoreRestSiblings: false, + "argsIgnorePattern": "^_", + "caughtErrors": "all", // Was "none" + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/no-use-before-define": [ + 1, // Was 2 + { + "functions": false + // classes: true, + // variables: true, + // enums: true, // TS + // typedefs: true, // TS + // ignoreTypeReferences: true, // TS + } + ], + "@typescript-eslint/default-param-last": 1, // Was 2 + "@typescript-eslint/no-shadow": [ + 1, // Was 2 + { + "builtinGlobals": true + // hoist: "functions", + // ignoreTypeValueShadow: true, // TS + // ignoreFunctionTypeParameterNameValueShadow: true, // TS + } + ], + "@typescript-eslint/lines-between-class-members": 0, // Was 2 + // "@typescript-eslint/consistent-type-imports": 1, + + // [Error → Warn] + /* NOTE + This section is for reducing the severity of rules configured by + airbnb-typescript from 2 to 1, if the problems they point out do not + have the possibility of directly leading to errors + */ + + // [Other] + "@typescript-eslint/naming-convention": [ + 1, + { + "selector": "variable", + // Was ["camelCase", "PascalCase", "UPPER_CASE"]. + // Add snake case to let exported module variables match Source + "format": ["camelCase", "PascalCase", "UPPER_CASE", "snake_case"] + }, + { + "selector": "function", + // Was ["camelCase", "PascalCase"]. + // Add snake case to let exported module functions match Source + "format": ["camelCase", "PascalCase", "snake_case"] + }, + { + "selector": "typeLike", + "format": ["PascalCase"] + } + ] + }, + "overrides": [{ + "extends": ["../.eslintrc.test.json"], + "files": ["./**/__tests__/**/*.ts"], + }] +} \ No newline at end of file diff --git a/src/bundles/binary_tree/functions.ts b/src/bundles/binary_tree/functions.ts index 1120fec6b..29ebe4fec 100644 --- a/src/bundles/binary_tree/functions.ts +++ b/src/bundles/binary_tree/functions.ts @@ -35,7 +35,7 @@ export function make_empty_tree(): BinaryTree { export function make_tree( value: any, left: BinaryTree, - right: BinaryTree + right: BinaryTree, ): BinaryTree { return [left, value, right]; } diff --git a/src/bundles/copy_gc/index.ts b/src/bundles/copy_gc/index.ts index 832f2cd68..81b52e0fc 100644 --- a/src/bundles/copy_gc/index.ts +++ b/src/bundles/copy_gc/index.ts @@ -111,7 +111,7 @@ function newCommand( heap, description, firstDesc, - lastDesc + lastDesc, ): void { const newType = type; const newToSpace = toSpace; @@ -166,7 +166,7 @@ function newCopy(left, right, heap): void { heap, desc, 'index', - 'free' + 'free', ); } @@ -187,7 +187,7 @@ function endFlip(left, heap): void { heap, desc, 'free', - '' + '', ); updateFlip(); } @@ -215,7 +215,7 @@ function startFlip(toSpace, fromSpace, heap): void { heap, desc, '', - '' + '', ); updateFlip(); } @@ -236,7 +236,7 @@ function newPush(left, right, heap): void { heap, desc, 'last child address slot', - 'new child pushed' + 'new child pushed', ); } @@ -257,14 +257,14 @@ function newPop(res, left, right, heap): void { heap, desc, 'popped memory', - 'last child address slot' + 'last child address slot', ); } function doneShowRoot(heap): void { const toSpace = 0; const fromSpace = 0; - const desc = `All root nodes are copied`; + const desc = 'All root nodes are copied'; newCommand( 'Copied Roots', toSpace, @@ -276,7 +276,7 @@ function doneShowRoot(heap): void { heap, desc, '', - '' + '', ); } @@ -297,7 +297,7 @@ function showRoots(left, heap): void { heap, desc, 'roots', - '' + '', ); } @@ -318,7 +318,7 @@ function newAssign(res, left, heap): void { heap, desc, 'assigned memory', - '' + '', ); } @@ -339,7 +339,7 @@ function newNew(left, heap): void { heap, desc, 'new memory allocated', - '' + '', ); } @@ -390,7 +390,7 @@ function updateSlotSegment( tag: number, size: number, first: number, - last: number + last: number, ): void { if (tag >= 0) { TAG_SLOT = tag; diff --git a/src/bundles/copy_gc/types.ts b/src/bundles/copy_gc/types.ts index 32ae5b243..61caf5942 100644 --- a/src/bundles/copy_gc/types.ts +++ b/src/bundles/copy_gc/types.ts @@ -1,32 +1,32 @@ -export type Memory = number[]; -export type MemoryHeaps = Memory[]; -export type Tag = number; - -// command type - -export enum COMMAND { - FLIP = 'Flip', - PUSH = 'Push', - POP = 'Pop', - COPY = 'Copy', - ASSIGN = 'Assign', - NEW = 'New', - SCAN = 'Scan', - INIT = 'Initialize Memory', -} - -export type CommandHeapObject = { - type: String; - to: number; - from: number; - heap: number[]; - left: number; - right: number; - sizeLeft: number; - sizeRight: number; - desc: String; - scan: number; - leftDesc: String; - rightDesc: String; - free: number; -}; +export type Memory = number[]; +export type MemoryHeaps = Memory[]; +export type Tag = number; + +// command type + +export enum COMMAND { + FLIP = 'Flip', + PUSH = 'Push', + POP = 'Pop', + COPY = 'Copy', + ASSIGN = 'Assign', + NEW = 'New', + SCAN = 'Scan', + INIT = 'Initialize Memory', +} + +export type CommandHeapObject = { + type: String; + to: number; + from: number; + heap: number[]; + left: number; + right: number; + sizeLeft: number; + sizeRight: number; + desc: String; + scan: number; + leftDesc: String; + rightDesc: String; + free: number; +}; diff --git a/src/bundles/csg/constants.ts b/src/bundles/csg/constants.ts index 9e08d9b91..c2945dfaa 100644 --- a/src/bundles/csg/constants.ts +++ b/src/bundles/csg/constants.ts @@ -1,41 +1,41 @@ -/* [Imports] */ -import { IconSize } from '@blueprintjs/core'; - -/* [Exports] */ - -// Silver is in here to avoid circular dependencies -export const SILVER: string = '#AAAAAA'; -export const DEFAULT_COLOR: string = SILVER; - -// Values extracted from the styling of the frontend -export const SA_TAB_BUTTON_WIDTH: string = '40px'; -export const SA_TAB_ICON_SIZE: number = IconSize.LARGE; - -export const BP_TOOLTIP_PADDING: string = '10px 12px'; -export const BP_TAB_BUTTON_MARGIN: string = '20px'; -export const BP_TAB_PANEL_MARGIN: string = '20px'; -export const BP_BORDER_RADIUS: string = '3px'; -export const STANDARD_MARGIN: string = '10px'; - -export const BP_TEXT_COLOR: string = '#F5F8FA'; -export const BP_TOOLTIP_BACKGROUND_COLOR: string = '#E1E8ED'; -export const BP_ICON_COLOR: string = '#A7B6C2'; -export const ACE_GUTTER_TEXT_COLOR: string = '#8091A0'; -export const ACE_GUTTER_BACKGROUND_COLOR: string = '#34495E'; -export const BP_TOOLTIP_TEXT_COLOR: string = '#394B59'; - -// Renderer grid constants -export const MAIN_TICKS: number = 1; -export const SUB_TICKS: number = MAIN_TICKS / 4; -export const GRID_PADDING: number = MAIN_TICKS; -export const ROUND_UP_INTERVAL: number = MAIN_TICKS; - -// Controls zoom constants -export const ZOOM_TICK_SCALE: number = 0.1; - -// Controls rotation constants -export const ROTATION_SPEED: number = 0.0015; - -// Controls pan constants -export const X_FACTOR: number = 1; -export const Y_FACTOR: number = 0.75; +/* [Imports] */ +import { IconSize } from '@blueprintjs/core'; + +/* [Exports] */ + +// Silver is in here to avoid circular dependencies +export const SILVER: string = '#AAAAAA'; +export const DEFAULT_COLOR: string = SILVER; + +// Values extracted from the styling of the frontend +export const SA_TAB_BUTTON_WIDTH: string = '40px'; +export const SA_TAB_ICON_SIZE: number = IconSize.LARGE; + +export const BP_TOOLTIP_PADDING: string = '10px 12px'; +export const BP_TAB_BUTTON_MARGIN: string = '20px'; +export const BP_TAB_PANEL_MARGIN: string = '20px'; +export const BP_BORDER_RADIUS: string = '3px'; +export const STANDARD_MARGIN: string = '10px'; + +export const BP_TEXT_COLOR: string = '#F5F8FA'; +export const BP_TOOLTIP_BACKGROUND_COLOR: string = '#E1E8ED'; +export const BP_ICON_COLOR: string = '#A7B6C2'; +export const ACE_GUTTER_TEXT_COLOR: string = '#8091A0'; +export const ACE_GUTTER_BACKGROUND_COLOR: string = '#34495E'; +export const BP_TOOLTIP_TEXT_COLOR: string = '#394B59'; + +// Renderer grid constants +export const MAIN_TICKS: number = 1; +export const SUB_TICKS: number = MAIN_TICKS / 4; +export const GRID_PADDING: number = MAIN_TICKS; +export const ROUND_UP_INTERVAL: number = MAIN_TICKS; + +// Controls zoom constants +export const ZOOM_TICK_SCALE: number = 0.1; + +// Controls rotation constants +export const ROTATION_SPEED: number = 0.0015; + +// Controls pan constants +export const X_FACTOR: number = 1; +export const Y_FACTOR: number = 0.75; diff --git a/src/bundles/csg/core.ts b/src/bundles/csg/core.ts index fafe5be5a..df3f1ce32 100644 --- a/src/bundles/csg/core.ts +++ b/src/bundles/csg/core.ts @@ -1,20 +1,20 @@ -/* [Imports] */ -import { CsgModuleState, RenderGroupManager } from './utilities.js'; - -/* [Exports] */ -// After bundle initialises, tab will need to reinit on its end, as they run -// independently and are different versions of Core. Same reason why we need -// looseInstanceof() -export class Core { - private static moduleState: CsgModuleState | null = null; - - static initialize(csgModuleState: CsgModuleState) { - Core.moduleState = csgModuleState; - } - - static getRenderGroupManager(): RenderGroupManager { - let moduleState: CsgModuleState = Core.moduleState as CsgModuleState; - - return moduleState.renderGroupManager; - } -} +/* [Imports] */ +import { CsgModuleState, RenderGroupManager } from './utilities.js'; + +/* [Exports] */ +// After bundle initialises, tab will need to reinit on its end, as they run +// independently and are different versions of Core. Same reason why we need +// looseInstanceof() +export class Core { + private static moduleState: CsgModuleState | null = null; + + static initialize(csgModuleState: CsgModuleState) { + Core.moduleState = csgModuleState; + } + + static getRenderGroupManager(): RenderGroupManager { + let moduleState: CsgModuleState = Core.moduleState as CsgModuleState; + + return moduleState.renderGroupManager; + } +} diff --git a/src/bundles/csg/functions.ts b/src/bundles/csg/functions.ts index 5eeda6614..3efdb343e 100644 --- a/src/bundles/csg/functions.ts +++ b/src/bundles/csg/functions.ts @@ -1,822 +1,838 @@ -/** - * The module `csg` provides functions for drawing Constructive Solid Geometry (CSG) called `Shape`. - * - * A *Shape* is defined by its polygons and vertices. - * - * @module csg - * @author Liu Muchen - * @author Joel Leow - */ - -/* [Imports] */ -import { primitives } from '@jscad/modeling'; -import { colorize } from '@jscad/modeling/src/colors'; -import { - BoundingBox, - measureArea, - measureBoundingBox, - measureVolume, -} from '@jscad/modeling/src/measurements'; -import { - intersect as _intersect, - subtract as _subtract, - union as _union, -} from '@jscad/modeling/src/operations/booleans'; -import { extrudeLinear } from '@jscad/modeling/src/operations/extrusions'; -import { - align, - center, - mirror, - rotate as _rotate, - scale as _scale, - translate as _translate, -} from '@jscad/modeling/src/operations/transforms'; -import { SILVER } from './constants.js'; -import { Core } from './core.js'; -import { Color, Coordinates, Solid } from './jscad/types.js'; -import { clamp, hexToColor, RenderGroup, Shape } from './utilities'; - -/* [Exports] */ - -// [Variables - Primitive shapes] - -/** - * Primitive Shape of a cube. - * - * @category Primitive - */ -export const cube: Shape = shapeSetOrigin( - new Shape(primitives.cube({ size: 1 })) -); - -/** - * Primitive Shape of a sphere. - * - * @category Primitive - */ -export const sphere: Shape = shapeSetOrigin( - new Shape(primitives.sphere({ radius: 0.5 })) -); - -/** - * Primitive Shape of a cylinder. - * - * @category Primitive - */ -export const cylinder: Shape = shapeSetOrigin( - new Shape(primitives.cylinder({ radius: 0.5, height: 1 })) -); - -/** - * Primitive Shape of a prism. - * - * @category Primitive - */ -export const prism: Shape = shapeSetOrigin( - new Shape(extrudeLinear({ height: 1 }, primitives.triangle())) -); - -/** - * Primitive Shape of an extruded star. - * - * @category Primitive - */ -export const star: Shape = shapeSetOrigin( - new Shape(extrudeLinear({ height: 1 }, primitives.star({ outerRadius: 0.5 }))) -); - -/** - * Primitive Shape of a square pyramid. - * - * @category Primitive - */ -export const pyramid: Shape = shapeSetOrigin( - new Shape( - primitives.cylinderElliptic({ - height: 1, - startRadius: [0.5, 0.5], - endRadius: [Number.MIN_VALUE, Number.MIN_VALUE], - segments: 4, - }) - ) -); - -/** - * Primitive Shape of a cone. - * - * @category Primitive - */ -export const cone: Shape = shapeSetOrigin( - new Shape( - primitives.cylinderElliptic({ - height: 1, - startRadius: [0.5, 0.5], - endRadius: [Number.MIN_VALUE, Number.MIN_VALUE], - }) - ) -); - -/** - * Primitive Shape of a torus. - * - * @category Primitive - */ -export const torus: Shape = shapeSetOrigin( - new Shape(primitives.torus({ innerRadius: 0.125, outerRadius: 0.375 })) -); - -/** - * Primitive Shape of a rounded cube. - * - * @category Primitive - */ -export const rounded_cube: Shape = shapeSetOrigin( - new Shape(primitives.roundedCuboid({ size: [1, 1, 1] })) -); - -/** - * Primitive Shape of a rounded cylinder. - * - * @category Primitive - */ -export const rounded_cylinder: Shape = shapeSetOrigin( - new Shape(primitives.roundedCylinder({ height: 1, radius: 0.5 })) -); - -/** - * Primitive Shape of a geodesic sphere. - * - * @category Primitive - */ -export const geodesic_sphere: Shape = shapeSetOrigin( - new Shape(primitives.geodesicSphere({ radius: 0.5 })) -); - -// [Variables - Colours] - -/** - * A hex colour code for black (#000000). - * - * @category Colour - */ -export const black: string = '#000000'; - -/** - * A hex colour code for dark blue (#0000AA). - * - * @category Colour - */ -export const navy: string = '#0000AA'; - -/** - * A hex colour code for green (#00AA00). - * - * @category Colour - */ -export const green: string = '#00AA00'; - -/** - * A hex colour code for dark cyan (#00AAAA). - * - * @category Colour - */ -export const teal: string = '#00AAAA'; - -/** - * A hex colour code for dark red (#AA0000). - * - * @category Colour - */ -export const crimson: string = '#AA0000'; - -/** - * A hex colour code for purple (#AA00AA). - * - * @category Colour - */ -export const purple: string = '#AA00AA'; - -/** - * A hex colour code for orange (#FFAA00). - * - * @category Colour - */ -export const orange: string = '#FFAA00'; - -/** - * A hex colour code for light grey (#AAAAAA). This is the default colour used - * when storing a Shape. - * - * @category Colour - */ -export const silver: string = SILVER; - -/** - * A hex colour code for dark grey (#555555). - * - * @category Colour - */ -export const gray: string = '#555555'; - -/** - * A hex colour code for blue (#5555FF). - * - * @category Colour - */ -export const blue: string = '#5555FF'; - -/** - * A hex colour code for light green (#55FF55). - * - * @category Colour - */ -export const lime: string = '#55FF55'; - -/** - * A hex colour code for cyan (#55FFFF). - * - * @category Colour - */ -export const cyan: string = '#55FFFF'; - -/** - * A hex colour code for light red (#FF5555). - * - * @category Colour - */ -export const rose: string = '#FF5555'; - -/** - * A hex colour code for pink (#FF55FF). - * - * @category Colour - */ -export const pink: string = '#FF55FF'; - -/** - * A hex colour code for yellow (#FFFF55). - * - * @category Colour - */ -export const yellow: string = '#FFFF55'; - -/** - * A hex colour code for white (#FFFFFF). - * - * @category Colour - */ -export const white: string = '#FFFFFF'; - -// [Functions] - -/** - * Union of the two provided shapes to produce a new shape. - * - * @param {Shape} a - The first shape - * @param {Shape} b - The second shape - * @returns {Shape} The resulting unioned shape - */ -export function union(a: Shape, b: Shape): Shape { - let newSolid: Solid = _union(a.solid, b.solid); - return new Shape(newSolid); -} - -/** - * Subtraction of the second shape from the first shape to produce a new shape. - * - * @param {Shape} a - The shape to be subtracted from - * @param {Shape} b - The shape to remove from the first shape - * @returns {Shape} The resulting subtracted shape - */ -export function subtract(a: Shape, b: Shape): Shape { - let newSolid: Solid = _subtract(a.solid, b.solid); - return new Shape(newSolid); -} - -/** - * Intersection of the two shape to produce a new shape. - * - * @param {Shape} a - The first shape - * @param {Shape} b - The second shape - * @returns {Shape} The resulting intersection shape - */ -export function intersect(a: Shape, b: Shape): Shape { - let newSolid: Solid = _intersect(a.solid, b.solid); - return new Shape(newSolid); -} - -/** - * Scales the shape in the x, y and z direction with the specified factor, - * ranging from 0 to infinity. - * For example scaling the shape by 1 in x, y and z direction results in - * the original shape. - * - * @param {Shape} shape - The shape to be scaled - * @param {number} x - Scaling in the x direction - * @param {number} y - Scaling in the y direction - * @param {number} z - Scaling in the z direction - * @returns {Shape} Resulting Shape - */ -export function scale(shape: Shape, x: number, y: number, z: number): Shape { - let newSolid: Solid = _scale([x, y, z], shape.solid); - return new Shape(newSolid); -} - -/** - * Scales the shape in the x direction with the specified factor, - * ranging from 0 to infinity. - * For example scaling the shape by 1 in x direction results in the - * original shape. - * - * @param {Shape} shape - The shape to be scaled - * @param {number} x - Scaling in the x direction - * @returns {Shape} Resulting Shape - */ -export function scale_x(shape: Shape, x: number): Shape { - return scale(shape, x, 1, 1); -} - -/** - * Scales the shape in the y direction with the specified factor, - * ranging from 0 to infinity. - * For example scaling the shape by 1 in y direction results in the - * original shape. - * - * @param {Shape} shape - The shape to be scaled - * @param {number} y - Scaling in the y direction - * @returns {Shape} Resulting Shape - */ -export function scale_y(shape: Shape, y: number): Shape { - return scale(shape, 1, y, 1); -} - -/** - * Scales the shape in the z direction with the specified factor, - * ranging from 0 to infinity. - * For example scaling the shape by 1 in z direction results in the - * original shape. - * - * @param {Shape} shape - The shape to be scaled - * @param {number} z - Scaling in the z direction - * @returns {Shape} Resulting Shape - */ -export function scale_z(shape: Shape, z: number): Shape { - return scale(shape, 1, 1, z); -} - -/** - * Returns a lambda function that contains the center of the given shape in the - * x, y and z direction. Providing 'x', 'y', 'z' as input would return x, y and - * z coordinates of shape's center - * - * For example - * ```` - * const a = shape_center(sphere); - * a('x'); // Returns the x coordinate of the shape's center - * ```` - * - * @param {Shape} shape - The scale to be measured - * @returns {(String) => number} A lambda function providing the shape's center - * coordinates - */ -export function shape_center(shape: Shape): (axis: String) => number { - let bounds: BoundingBox = measureBoundingBox(shape.solid); - let centerCoords: Coordinates = [ - bounds[0][0] + (bounds[1][0] - bounds[0][0]) / 2, - bounds[0][1] + (bounds[1][1] - bounds[0][1]) / 2, - bounds[0][2] + (bounds[1][2] - bounds[0][2]) / 2, - ]; - return (axis: String): number => { - let i: number = axis === 'x' ? 0 : axis === 'y' ? 1 : axis === 'z' ? 2 : -1; - if (i === -1) { - throw Error(`shape_center's returned function expects a proper axis.`); - } else { - return centerCoords[i]; - } - }; -} - -/** - * Set the center of the shape with the provided x, y and z coordinates. - * - * @param {Shape} shape - The scale to have the center set - * @param {nunber} x - The center with the x coordinate - * @param {nunber} y - The center with the y coordinate - * @param {nunber} z - The center with the z coordinate - * @returns {Shape} The shape with the new center - */ -export function shape_set_center( - shape: Shape, - x: number, - y: number, - z: number -): Shape { - let newSolid: Solid = center({ relativeTo: [x, y, z] }, shape.solid); - return new Shape(newSolid); -} - -/** - * Measure the area of the provided shape. - * - * @param {Shape} shape - The shape to measure the area from - * @returns {number} The area of the shape - */ -export function area(shape: Shape): number { - return measureArea(shape.solid); -} - -/** - * Measure the volume of the provided shape. - * - * @param {Shape} shape - The shape to measure the volume from - * @returns {number} The volume of the shape - */ -export function volume(shape: Shape): number { - return measureVolume(shape.solid); -} - -//TODO -/** - * Mirror / Flip the provided shape by the plane with normal direction vector - * given by the x, y and z components. - * - * @param {Shape} shape - The shape to mirror / flip - * @param {number} x - The x coordinate of the direction vector - * @param {number} y - The y coordinate of the direction vector - * @param {number} z - The z coordinate of the direction vector - * @returns {Shape} The mirrored / flipped shape - */ -function shape_mirror(shape: Shape, x: number, y: number, z: number) { - let newSolid: Solid = mirror({ normal: [x, y, z] }, shape.solid); - return new Shape(newSolid); -} - -/** - * Mirror / Flip the provided shape in the x direction. - * - * @param {Shape} shape - The shape to mirror / flip - * @returns {Shape} The mirrored / flipped shape - */ -export function flip_x(shape: Shape): Shape { - return shape_mirror(shape, 1, 0, 0); -} - -/** - * Mirror / Flip the provided shape in the y direction. - * - * @param {Shape} shape - The shape to mirror / flip - * @returns {Shape} The mirrored / flipped shape - */ -export function flip_y(shape: Shape): Shape { - return shape_mirror(shape, 0, 1, 0); -} - -/** - * Mirror / Flip the provided shape in the z direction. - * - * @param {Shape} shape - The shape to mirror / flip - * @returns {Shape} The mirrored / flipped shape - */ -export function flip_z(shape: Shape): Shape { - return shape_mirror(shape, 0, 0, 1); -} - -/** - * Translate / Move the shape by the provided x, y and z units from negative - * infinity to infinity. - * - * @param {Shape} shape - * @param {number} x - The number to shift the shape in the x direction - * @param {number} y - The number to shift the shape in the y direction - * @param {number} z - The number to shift the shape in the z direction - * @returns {Shape} The translated shape - */ -export function translate( - shape: Shape, - x: number, - y: number, - z: number -): Shape { - let newSolid: Solid = _translate([x, y, z], shape.solid); - return new Shape(newSolid); -} - -/** - * Translate / Move the shape by the provided x units from negative infinity - * to infinity. - * - * @param {Shape} shape - * @param {number} x - The number to shift the shape in the x direction - * @returns {Shape} The translated shape - */ -export function translate_x(shape: Shape, x: number): Shape { - return translate(shape, x, 0, 0); -} - -/** - * Translate / Move the shape by the provided y units from negative infinity - * to infinity. - * - * @param {Shape} shape - * @param {number} y - The number to shift the shape in the y direction - * @returns {Shape} The translated shape - */ -export function translate_y(shape: Shape, y: number): Shape { - return translate(shape, 0, y, 0); -} - -/** - * Translate / Move the shape by the provided z units from negative infinity - * to infinity. - * - * @param {Shape} shape - * @param {number} z - The number to shift the shape in the z direction - * @returns {Shape} The translated shape - */ -export function translate_z(shape: Shape, z: number): Shape { - return translate(shape, 0, 0, z); -} - -/** - * Places the second shape `b` beside the first shape `a` in the positive x direction, - * centering the `b`'s y and z on the `a`'s y and z center. - * - * @param {Shape} a - The shape to be placed beside with - * @param {Shape} b - The shape placed beside - * @returns {Shape} The final shape - */ -export function beside_x(a: Shape, b: Shape): Shape { - let aBounds: BoundingBox = measureBoundingBox(a.solid); - let newX: number = aBounds[1][0]; - let newY: number = aBounds[0][1] + (aBounds[1][1] - aBounds[0][1]) / 2; - let newZ: number = aBounds[0][2] + (aBounds[1][2] - aBounds[0][2]) / 2; - let newSolid: Solid = _union( - a.solid, - align( - { - modes: ['min', 'center', 'center'], - relativeTo: [newX, newY, newZ], - }, - b.solid - ) - ); - return new Shape(newSolid); -} - -/** - * Places the second shape `b` beside the first shape `a` in the positive y direction, - * centering the `b`'s x and z on the `a`'s x and z center. - * - * @param {Shape} a - The shape to be placed beside with - * @param {Shape} b - The shape placed beside - * @returns {Shape} The final shape - */ -export function beside_y(a: Shape, b: Shape): Shape { - let aBounds: BoundingBox = measureBoundingBox(a.solid); - let newX: number = aBounds[0][0] + (aBounds[1][0] - aBounds[0][0]) / 2; - let newY: number = aBounds[1][1]; - let newZ: number = aBounds[0][2] + (aBounds[1][2] - aBounds[0][2]) / 2; - let newSolid: Solid = _union( - a.solid, - align( - { - modes: ['center', 'min', 'center'], - relativeTo: [newX, newY, newZ], - }, - b.solid - ) - ); - return new Shape(newSolid); -} - -/** - * Places the second shape `b` beside the first shape `a` in the positive z direction, - * centering the `b`'s x and y on the `a`'s x and y center. - * - * @param {Shape} a - The shape to be placed beside with - * @param {Shape} b - The shape placed beside - * @returns {Shape} The final shape - */ -export function beside_z(a: Shape, b: Shape): Shape { - let aBounds: BoundingBox = measureBoundingBox(a.solid); - let newX: number = aBounds[0][0] + (aBounds[1][0] - aBounds[0][0]) / 2; - let newY: number = aBounds[0][1] + (aBounds[1][1] - aBounds[0][1]) / 2; - let newZ: number = aBounds[1][2]; - let newSolid: Solid = _union( - a.solid, - align( - { - modes: ['center', 'center', 'min'], - relativeTo: [newX, newY, newZ], - }, - b.solid - ) - ); - return new Shape(newSolid); -} - -/** - * Returns a lambda function that contains the coordinates of the bounding box. - * Provided with the axis 'x', 'y' or 'z' and value 'min' for minimum and 'max' - * for maximum, it returns the coordinates of the bounding box. - * - * For example - * ```` - * const a = bounding_box(sphere); - * a('x', 'min'); // Returns the maximum x coordinate of the bounding box - * ```` - * - * @param {Shape} shape - The scale to be measured - * @returns {(String, String) => number} A lambda function providing the - * shape's bounding box coordinates - */ - -export function bounding_box( - shape: Shape -): (axis: String, min: String) => number { - let bounds: BoundingBox = measureBoundingBox(shape.solid); - return (axis: String, min: String): number => { - let i: number = axis === 'x' ? 0 : axis === 'y' ? 1 : axis === 'z' ? 2 : -1; - let j: number = min === 'min' ? 0 : min === 'max' ? 1 : -1; - if (i === -1 || j === -1) { - throw Error( - `bounding_box returned function expects a proper axis and min String.` - ); - } else { - return bounds[j][i]; - } - }; -} - -/** - * Rotate the shape by the provided angles in the x, y and z direction. - * Angles provided are in the form of radians (i.e. 2π represent 360 - * degrees) - * - * @param {Shape} shape - The shape to be rotated - * @param {number} x - Angle of rotation in the x direction - * @param {number} y - Angle of rotation in the y direction - * @param {number} z - Angle of rotation in the z direction - * @returns {Shape} The rotated shape - */ -export function rotate(shape: Shape, x: number, y: number, z: number): Shape { - let newSolid: Solid = _rotate([x, y, z], shape.solid); - return new Shape(newSolid); -} - -/** - * Rotate the shape by the provided angles in the x direction. Angles - * provided are in the form of radians (i.e. 2π represent 360 degrees) - * - * @param {Shape} shape - The shape to be rotated - * @param {number} x - Angle of rotation in the x direction - * @returns {Shape} The rotated shape - */ -export function rotate_x(shape: Shape, x: number): Shape { - return rotate(shape, x, 0, 0); -} - -/** - * Rotate the shape by the provided angles in the y direction. Angles - * provided are in the form of radians (i.e. 2π represent 360 degrees) - * - * @param {Shape} shape - The shape to be rotated - * @param {number} y - Angle of rotation in the y direction - * @returns {Shape} The rotated shape - */ -export function rotate_y(shape: Shape, y: number): Shape { - return rotate(shape, 0, y, 0); -} - -/** - * Rotate the shape by the provided angles in the z direction. Angles - * provided are in the form of radians (i.e. 2π represent 360 degrees) - * - * @param {Shape} shape - The shape to be rotated - * @param {number} z - Angle of rotation in the z direction - * @returns {Shape} The rotated shape - */ -export function rotate_z(shape: Shape, z: number): Shape { - return rotate(shape, 0, 0, z); -} - -//TODO -/** - * Center the provided shape with the middle base of the shape at (0, 0, 0). - * - * @param {Shape} shape - The shape to be centered - * @returns {Shape} The shape that is centered - */ -function shapeSetOrigin(shape: Shape) { - let newSolid: Solid = align({ modes: ['min', 'min', 'min'] }, shape.solid); - return new Shape(newSolid); -} - -/** - * Checks if the specified argument is a Shape. - * - * @param {unknown} argument - The value to check. - * @returns {boolean} Whether the argument is a Shape. - */ -export function is_shape(argument: unknown): boolean { - return argument instanceof Shape; -} - -/** - * Creates a clone of the specified Shape. - * - * @param {Shape} shape - The Shape to be cloned. - * @returns {Shape} The cloned Shape. - */ -export function clone(shape: Shape): Shape { - return shape.clone(); -} - -/** - * Stores a clone of the specified Shape for later rendering. Its colour - * defaults to the module's provided silver colour variable. - * - * @param {Shape} shape - The Shape to be stored. - */ -export function store(shape: Shape) { - Core.getRenderGroupManager().storeShape(shape.clone()); -} - -/** - * Colours a clone of the specified Shape using the specified hex colour code, - * then stores it for later rendering. You may use one of the colour variables - * provided by the module, or you may specify your own custom colour code. - * - * Colour codes must be of the form "#XXXXXX" or "XXXXXX", where each X - * represents a non-case sensitive hexadecimal number. Invalid colour codes - * default to black. - * - * @param {Shape} shape - The Shape to be coloured and stored. - * @param {string} hex - The colour code to use. - */ -export function store_as_color(shape: Shape, hex: string) { - let color: Color = hexToColor(hex); - let coloredSolid: Solid = colorize(color, shape.solid); - Core.getRenderGroupManager().storeShape(new Shape(coloredSolid)); -} - -/** - * Colours a clone of the specified Shape using the specified RGB values, then - * stores it for later rendering. - * - * RGB values are clamped between 0 and 1. - * - * @param {Shape} shape - The Shape to be coloured and stored. - * @param {number} redComponent - The colour's red component. - * @param {number} greenComponent - The colour's green component. - * @param {number} blueComponent - The colour's blue component. - */ -export function store_as_rgb( - shape: Shape, - redComponent: number, - greenComponent: number, - blueComponent: number -) { - redComponent = clamp(redComponent, 0, 1); - greenComponent = clamp(greenComponent, 0, 1); - blueComponent = clamp(blueComponent, 0, 1); - - let coloredSolid: Solid = colorize( - [redComponent, greenComponent, blueComponent], - shape.solid - ); - Core.getRenderGroupManager().storeShape(new Shape(coloredSolid)); -} - -/** - * Renders using any Shapes stored thus far, along with a grid and axis. The - * Shapes will then not be included in any subsequent renders. - */ -export function render_grid_axis(): RenderGroup { - // Render group is returned for REPL text only; do not document - return Core.getRenderGroupManager().nextRenderGroup(true, true); -} - -/** - * Renders using any Shapes stored thus far, along with a grid. The Shapes will - * then not be included in any subsequent renders. - */ -export function render_grid(): RenderGroup { - return Core.getRenderGroupManager().nextRenderGroup(true); -} - -/** - * Renders using any Shapes stored thus far, along with an axis. The Shapes will - * then not be included in any subsequent renders. - */ -export function render_axis(): RenderGroup { - return Core.getRenderGroupManager().nextRenderGroup(undefined, true); -} - -/** - * Renders using any Shapes stored thus far. The Shapes will then not be - * included in any subsequent renders. - */ -export function render(): RenderGroup { - return Core.getRenderGroupManager().nextRenderGroup(); -} +/** + * The module `csg` provides functions for drawing Constructive Solid Geometry (CSG) called `Shape`. + * + * A *Shape* is defined by its polygons and vertices. + * + * @module csg + * @author Liu Muchen + * @author Joel Leow + */ + +/* [Imports] */ +import { primitives } from '@jscad/modeling'; +import { colorize } from '@jscad/modeling/src/colors'; +import { + BoundingBox, + measureArea, + measureBoundingBox, + measureVolume, +} from '@jscad/modeling/src/measurements'; +import { + intersect as _intersect, + subtract as _subtract, + union as _union, +} from '@jscad/modeling/src/operations/booleans'; +import { extrudeLinear } from '@jscad/modeling/src/operations/extrusions'; +import { + align, + center, + mirror, + rotate as _rotate, + scale as _scale, + translate as _translate, +} from '@jscad/modeling/src/operations/transforms'; +import { SILVER } from './constants.js'; +import { Core } from './core.js'; +import { Color, Coordinates, Solid } from './jscad/types.js'; +import { clamp, hexToColor, RenderGroup, Shape } from './utilities'; + +/* [Exports] */ + +// [Variables - Primitive shapes] + +/** + * Primitive Shape of a cube. + * + * @category Primitive + */ +export const cube: Shape = shapeSetOrigin( + new Shape(primitives.cube({ size: 1 })), +); + +/** + * Primitive Shape of a sphere. + * + * @category Primitive + */ +export const sphere: Shape = shapeSetOrigin( + new Shape(primitives.sphere({ radius: 0.5 })), +); + +/** + * Primitive Shape of a cylinder. + * + * @category Primitive + */ +export const cylinder: Shape = shapeSetOrigin( + new Shape(primitives.cylinder({ + radius: 0.5, + height: 1, + })), +); + +/** + * Primitive Shape of a prism. + * + * @category Primitive + */ +export const prism: Shape = shapeSetOrigin( + new Shape(extrudeLinear({ height: 1 }, primitives.triangle())), +); + +/** + * Primitive Shape of an extruded star. + * + * @category Primitive + */ +export const star: Shape = shapeSetOrigin( + new Shape(extrudeLinear({ height: 1 }, primitives.star({ outerRadius: 0.5 }))), +); + +/** + * Primitive Shape of a square pyramid. + * + * @category Primitive + */ +export const pyramid: Shape = shapeSetOrigin( + new Shape( + primitives.cylinderElliptic({ + height: 1, + startRadius: [0.5, 0.5], + endRadius: [Number.MIN_VALUE, Number.MIN_VALUE], + segments: 4, + }), + ), +); + +/** + * Primitive Shape of a cone. + * + * @category Primitive + */ +export const cone: Shape = shapeSetOrigin( + new Shape( + primitives.cylinderElliptic({ + height: 1, + startRadius: [0.5, 0.5], + endRadius: [Number.MIN_VALUE, Number.MIN_VALUE], + }), + ), +); + +/** + * Primitive Shape of a torus. + * + * @category Primitive + */ +export const torus: Shape = shapeSetOrigin( + new Shape(primitives.torus({ + innerRadius: 0.125, + outerRadius: 0.375, + })), +); + +/** + * Primitive Shape of a rounded cube. + * + * @category Primitive + */ +export const rounded_cube: Shape = shapeSetOrigin( + new Shape(primitives.roundedCuboid({ size: [1, 1, 1] })), +); + +/** + * Primitive Shape of a rounded cylinder. + * + * @category Primitive + */ +export const rounded_cylinder: Shape = shapeSetOrigin( + new Shape(primitives.roundedCylinder({ + height: 1, + radius: 0.5, + })), +); + +/** + * Primitive Shape of a geodesic sphere. + * + * @category Primitive + */ +export const geodesic_sphere: Shape = shapeSetOrigin( + new Shape(primitives.geodesicSphere({ radius: 0.5 })), +); + +// [Variables - Colours] + +/** + * A hex colour code for black (#000000). + * + * @category Colour + */ +export const black: string = '#000000'; + +/** + * A hex colour code for dark blue (#0000AA). + * + * @category Colour + */ +export const navy: string = '#0000AA'; + +/** + * A hex colour code for green (#00AA00). + * + * @category Colour + */ +export const green: string = '#00AA00'; + +/** + * A hex colour code for dark cyan (#00AAAA). + * + * @category Colour + */ +export const teal: string = '#00AAAA'; + +/** + * A hex colour code for dark red (#AA0000). + * + * @category Colour + */ +export const crimson: string = '#AA0000'; + +/** + * A hex colour code for purple (#AA00AA). + * + * @category Colour + */ +export const purple: string = '#AA00AA'; + +/** + * A hex colour code for orange (#FFAA00). + * + * @category Colour + */ +export const orange: string = '#FFAA00'; + +/** + * A hex colour code for light grey (#AAAAAA). This is the default colour used + * when storing a Shape. + * + * @category Colour + */ +export const silver: string = SILVER; + +/** + * A hex colour code for dark grey (#555555). + * + * @category Colour + */ +export const gray: string = '#555555'; + +/** + * A hex colour code for blue (#5555FF). + * + * @category Colour + */ +export const blue: string = '#5555FF'; + +/** + * A hex colour code for light green (#55FF55). + * + * @category Colour + */ +export const lime: string = '#55FF55'; + +/** + * A hex colour code for cyan (#55FFFF). + * + * @category Colour + */ +export const cyan: string = '#55FFFF'; + +/** + * A hex colour code for light red (#FF5555). + * + * @category Colour + */ +export const rose: string = '#FF5555'; + +/** + * A hex colour code for pink (#FF55FF). + * + * @category Colour + */ +export const pink: string = '#FF55FF'; + +/** + * A hex colour code for yellow (#FFFF55). + * + * @category Colour + */ +export const yellow: string = '#FFFF55'; + +/** + * A hex colour code for white (#FFFFFF). + * + * @category Colour + */ +export const white: string = '#FFFFFF'; + +// [Functions] + +/** + * Union of the two provided shapes to produce a new shape. + * + * @param {Shape} a - The first shape + * @param {Shape} b - The second shape + * @returns {Shape} The resulting unioned shape + */ +export function union(a: Shape, b: Shape): Shape { + let newSolid: Solid = _union(a.solid, b.solid); + return new Shape(newSolid); +} + +/** + * Subtraction of the second shape from the first shape to produce a new shape. + * + * @param {Shape} a - The shape to be subtracted from + * @param {Shape} b - The shape to remove from the first shape + * @returns {Shape} The resulting subtracted shape + */ +export function subtract(a: Shape, b: Shape): Shape { + let newSolid: Solid = _subtract(a.solid, b.solid); + return new Shape(newSolid); +} + +/** + * Intersection of the two shape to produce a new shape. + * + * @param {Shape} a - The first shape + * @param {Shape} b - The second shape + * @returns {Shape} The resulting intersection shape + */ +export function intersect(a: Shape, b: Shape): Shape { + let newSolid: Solid = _intersect(a.solid, b.solid); + return new Shape(newSolid); +} + +/** + * Scales the shape in the x, y and z direction with the specified factor, + * ranging from 0 to infinity. + * For example scaling the shape by 1 in x, y and z direction results in + * the original shape. + * + * @param {Shape} shape - The shape to be scaled + * @param {number} x - Scaling in the x direction + * @param {number} y - Scaling in the y direction + * @param {number} z - Scaling in the z direction + * @returns {Shape} Resulting Shape + */ +export function scale(shape: Shape, x: number, y: number, z: number): Shape { + let newSolid: Solid = _scale([x, y, z], shape.solid); + return new Shape(newSolid); +} + +/** + * Scales the shape in the x direction with the specified factor, + * ranging from 0 to infinity. + * For example scaling the shape by 1 in x direction results in the + * original shape. + * + * @param {Shape} shape - The shape to be scaled + * @param {number} x - Scaling in the x direction + * @returns {Shape} Resulting Shape + */ +export function scale_x(shape: Shape, x: number): Shape { + return scale(shape, x, 1, 1); +} + +/** + * Scales the shape in the y direction with the specified factor, + * ranging from 0 to infinity. + * For example scaling the shape by 1 in y direction results in the + * original shape. + * + * @param {Shape} shape - The shape to be scaled + * @param {number} y - Scaling in the y direction + * @returns {Shape} Resulting Shape + */ +export function scale_y(shape: Shape, y: number): Shape { + return scale(shape, 1, y, 1); +} + +/** + * Scales the shape in the z direction with the specified factor, + * ranging from 0 to infinity. + * For example scaling the shape by 1 in z direction results in the + * original shape. + * + * @param {Shape} shape - The shape to be scaled + * @param {number} z - Scaling in the z direction + * @returns {Shape} Resulting Shape + */ +export function scale_z(shape: Shape, z: number): Shape { + return scale(shape, 1, 1, z); +} + +/** + * Returns a lambda function that contains the center of the given shape in the + * x, y and z direction. Providing 'x', 'y', 'z' as input would return x, y and + * z coordinates of shape's center + * + * For example + * ```` + * const a = shape_center(sphere); + * a('x'); // Returns the x coordinate of the shape's center + * ```` + * + * @param {Shape} shape - The scale to be measured + * @returns {(String) => number} A lambda function providing the shape's center + * coordinates + */ +export function shape_center(shape: Shape): (axis: String) => number { + let bounds: BoundingBox = measureBoundingBox(shape.solid); + let centerCoords: Coordinates = [ + bounds[0][0] + (bounds[1][0] - bounds[0][0]) / 2, + bounds[0][1] + (bounds[1][1] - bounds[0][1]) / 2, + bounds[0][2] + (bounds[1][2] - bounds[0][2]) / 2, + ]; + return (axis: String): number => { + let i: number = axis === 'x' ? 0 : axis === 'y' ? 1 : axis === 'z' ? 2 : -1; + if (i === -1) { + throw Error('shape_center\'s returned function expects a proper axis.'); + } else { + return centerCoords[i]; + } + }; +} + +/** + * Set the center of the shape with the provided x, y and z coordinates. + * + * @param {Shape} shape - The scale to have the center set + * @param {nunber} x - The center with the x coordinate + * @param {nunber} y - The center with the y coordinate + * @param {nunber} z - The center with the z coordinate + * @returns {Shape} The shape with the new center + */ +export function shape_set_center( + shape: Shape, + x: number, + y: number, + z: number, +): Shape { + let newSolid: Solid = center({ relativeTo: [x, y, z] }, shape.solid); + return new Shape(newSolid); +} + +/** + * Measure the area of the provided shape. + * + * @param {Shape} shape - The shape to measure the area from + * @returns {number} The area of the shape + */ +export function area(shape: Shape): number { + return measureArea(shape.solid); +} + +/** + * Measure the volume of the provided shape. + * + * @param {Shape} shape - The shape to measure the volume from + * @returns {number} The volume of the shape + */ +export function volume(shape: Shape): number { + return measureVolume(shape.solid); +} + +//TODO +/** + * Mirror / Flip the provided shape by the plane with normal direction vector + * given by the x, y and z components. + * + * @param {Shape} shape - The shape to mirror / flip + * @param {number} x - The x coordinate of the direction vector + * @param {number} y - The y coordinate of the direction vector + * @param {number} z - The z coordinate of the direction vector + * @returns {Shape} The mirrored / flipped shape + */ +function shape_mirror(shape: Shape, x: number, y: number, z: number) { + let newSolid: Solid = mirror({ normal: [x, y, z] }, shape.solid); + return new Shape(newSolid); +} + +/** + * Mirror / Flip the provided shape in the x direction. + * + * @param {Shape} shape - The shape to mirror / flip + * @returns {Shape} The mirrored / flipped shape + */ +export function flip_x(shape: Shape): Shape { + return shape_mirror(shape, 1, 0, 0); +} + +/** + * Mirror / Flip the provided shape in the y direction. + * + * @param {Shape} shape - The shape to mirror / flip + * @returns {Shape} The mirrored / flipped shape + */ +export function flip_y(shape: Shape): Shape { + return shape_mirror(shape, 0, 1, 0); +} + +/** + * Mirror / Flip the provided shape in the z direction. + * + * @param {Shape} shape - The shape to mirror / flip + * @returns {Shape} The mirrored / flipped shape + */ +export function flip_z(shape: Shape): Shape { + return shape_mirror(shape, 0, 0, 1); +} + +/** + * Translate / Move the shape by the provided x, y and z units from negative + * infinity to infinity. + * + * @param {Shape} shape + * @param {number} x - The number to shift the shape in the x direction + * @param {number} y - The number to shift the shape in the y direction + * @param {number} z - The number to shift the shape in the z direction + * @returns {Shape} The translated shape + */ +export function translate( + shape: Shape, + x: number, + y: number, + z: number, +): Shape { + let newSolid: Solid = _translate([x, y, z], shape.solid); + return new Shape(newSolid); +} + +/** + * Translate / Move the shape by the provided x units from negative infinity + * to infinity. + * + * @param {Shape} shape + * @param {number} x - The number to shift the shape in the x direction + * @returns {Shape} The translated shape + */ +export function translate_x(shape: Shape, x: number): Shape { + return translate(shape, x, 0, 0); +} + +/** + * Translate / Move the shape by the provided y units from negative infinity + * to infinity. + * + * @param {Shape} shape + * @param {number} y - The number to shift the shape in the y direction + * @returns {Shape} The translated shape + */ +export function translate_y(shape: Shape, y: number): Shape { + return translate(shape, 0, y, 0); +} + +/** + * Translate / Move the shape by the provided z units from negative infinity + * to infinity. + * + * @param {Shape} shape + * @param {number} z - The number to shift the shape in the z direction + * @returns {Shape} The translated shape + */ +export function translate_z(shape: Shape, z: number): Shape { + return translate(shape, 0, 0, z); +} + +/** + * Places the second shape `b` beside the first shape `a` in the positive x direction, + * centering the `b`'s y and z on the `a`'s y and z center. + * + * @param {Shape} a - The shape to be placed beside with + * @param {Shape} b - The shape placed beside + * @returns {Shape} The final shape + */ +export function beside_x(a: Shape, b: Shape): Shape { + let aBounds: BoundingBox = measureBoundingBox(a.solid); + let newX: number = aBounds[1][0]; + let newY: number = aBounds[0][1] + (aBounds[1][1] - aBounds[0][1]) / 2; + let newZ: number = aBounds[0][2] + (aBounds[1][2] - aBounds[0][2]) / 2; + let newSolid: Solid = _union( + a.solid, + align( + { + modes: ['min', 'center', 'center'], + relativeTo: [newX, newY, newZ], + }, + b.solid, + ), + ); + return new Shape(newSolid); +} + +/** + * Places the second shape `b` beside the first shape `a` in the positive y direction, + * centering the `b`'s x and z on the `a`'s x and z center. + * + * @param {Shape} a - The shape to be placed beside with + * @param {Shape} b - The shape placed beside + * @returns {Shape} The final shape + */ +export function beside_y(a: Shape, b: Shape): Shape { + let aBounds: BoundingBox = measureBoundingBox(a.solid); + let newX: number = aBounds[0][0] + (aBounds[1][0] - aBounds[0][0]) / 2; + let newY: number = aBounds[1][1]; + let newZ: number = aBounds[0][2] + (aBounds[1][2] - aBounds[0][2]) / 2; + let newSolid: Solid = _union( + a.solid, + align( + { + modes: ['center', 'min', 'center'], + relativeTo: [newX, newY, newZ], + }, + b.solid, + ), + ); + return new Shape(newSolid); +} + +/** + * Places the second shape `b` beside the first shape `a` in the positive z direction, + * centering the `b`'s x and y on the `a`'s x and y center. + * + * @param {Shape} a - The shape to be placed beside with + * @param {Shape} b - The shape placed beside + * @returns {Shape} The final shape + */ +export function beside_z(a: Shape, b: Shape): Shape { + let aBounds: BoundingBox = measureBoundingBox(a.solid); + let newX: number = aBounds[0][0] + (aBounds[1][0] - aBounds[0][0]) / 2; + let newY: number = aBounds[0][1] + (aBounds[1][1] - aBounds[0][1]) / 2; + let newZ: number = aBounds[1][2]; + let newSolid: Solid = _union( + a.solid, + align( + { + modes: ['center', 'center', 'min'], + relativeTo: [newX, newY, newZ], + }, + b.solid, + ), + ); + return new Shape(newSolid); +} + +/** + * Returns a lambda function that contains the coordinates of the bounding box. + * Provided with the axis 'x', 'y' or 'z' and value 'min' for minimum and 'max' + * for maximum, it returns the coordinates of the bounding box. + * + * For example + * ```` + * const a = bounding_box(sphere); + * a('x', 'min'); // Returns the maximum x coordinate of the bounding box + * ```` + * + * @param {Shape} shape - The scale to be measured + * @returns {(String, String) => number} A lambda function providing the + * shape's bounding box coordinates + */ + +export function bounding_box( + shape: Shape, +): (axis: String, min: String) => number { + let bounds: BoundingBox = measureBoundingBox(shape.solid); + return (axis: String, min: String): number => { + let i: number = axis === 'x' ? 0 : axis === 'y' ? 1 : axis === 'z' ? 2 : -1; + let j: number = min === 'min' ? 0 : min === 'max' ? 1 : -1; + if (i === -1 || j === -1) { + throw Error( + 'bounding_box returned function expects a proper axis and min String.', + ); + } else { + return bounds[j][i]; + } + }; +} + +/** + * Rotate the shape by the provided angles in the x, y and z direction. + * Angles provided are in the form of radians (i.e. 2π represent 360 + * degrees) + * + * @param {Shape} shape - The shape to be rotated + * @param {number} x - Angle of rotation in the x direction + * @param {number} y - Angle of rotation in the y direction + * @param {number} z - Angle of rotation in the z direction + * @returns {Shape} The rotated shape + */ +export function rotate(shape: Shape, x: number, y: number, z: number): Shape { + let newSolid: Solid = _rotate([x, y, z], shape.solid); + return new Shape(newSolid); +} + +/** + * Rotate the shape by the provided angles in the x direction. Angles + * provided are in the form of radians (i.e. 2π represent 360 degrees) + * + * @param {Shape} shape - The shape to be rotated + * @param {number} x - Angle of rotation in the x direction + * @returns {Shape} The rotated shape + */ +export function rotate_x(shape: Shape, x: number): Shape { + return rotate(shape, x, 0, 0); +} + +/** + * Rotate the shape by the provided angles in the y direction. Angles + * provided are in the form of radians (i.e. 2π represent 360 degrees) + * + * @param {Shape} shape - The shape to be rotated + * @param {number} y - Angle of rotation in the y direction + * @returns {Shape} The rotated shape + */ +export function rotate_y(shape: Shape, y: number): Shape { + return rotate(shape, 0, y, 0); +} + +/** + * Rotate the shape by the provided angles in the z direction. Angles + * provided are in the form of radians (i.e. 2π represent 360 degrees) + * + * @param {Shape} shape - The shape to be rotated + * @param {number} z - Angle of rotation in the z direction + * @returns {Shape} The rotated shape + */ +export function rotate_z(shape: Shape, z: number): Shape { + return rotate(shape, 0, 0, z); +} + +//TODO +/** + * Center the provided shape with the middle base of the shape at (0, 0, 0). + * + * @param {Shape} shape - The shape to be centered + * @returns {Shape} The shape that is centered + */ +function shapeSetOrigin(shape: Shape) { + let newSolid: Solid = align({ modes: ['min', 'min', 'min'] }, shape.solid); + return new Shape(newSolid); +} + +/** + * Checks if the specified argument is a Shape. + * + * @param {unknown} argument - The value to check. + * @returns {boolean} Whether the argument is a Shape. + */ +export function is_shape(argument: unknown): boolean { + return argument instanceof Shape; +} + +/** + * Creates a clone of the specified Shape. + * + * @param {Shape} shape - The Shape to be cloned. + * @returns {Shape} The cloned Shape. + */ +export function clone(shape: Shape): Shape { + return shape.clone(); +} + +/** + * Stores a clone of the specified Shape for later rendering. Its colour + * defaults to the module's provided silver colour variable. + * + * @param {Shape} shape - The Shape to be stored. + */ +export function store(shape: Shape) { + Core.getRenderGroupManager() + .storeShape(shape.clone()); +} + +/** + * Colours a clone of the specified Shape using the specified hex colour code, + * then stores it for later rendering. You may use one of the colour variables + * provided by the module, or you may specify your own custom colour code. + * + * Colour codes must be of the form "#XXXXXX" or "XXXXXX", where each X + * represents a non-case sensitive hexadecimal number. Invalid colour codes + * default to black. + * + * @param {Shape} shape - The Shape to be coloured and stored. + * @param {string} hex - The colour code to use. + */ +export function store_as_color(shape: Shape, hex: string) { + let color: Color = hexToColor(hex); + let coloredSolid: Solid = colorize(color, shape.solid); + Core.getRenderGroupManager() + .storeShape(new Shape(coloredSolid)); +} + +/** + * Colours a clone of the specified Shape using the specified RGB values, then + * stores it for later rendering. + * + * RGB values are clamped between 0 and 1. + * + * @param {Shape} shape - The Shape to be coloured and stored. + * @param {number} redComponent - The colour's red component. + * @param {number} greenComponent - The colour's green component. + * @param {number} blueComponent - The colour's blue component. + */ +export function store_as_rgb( + shape: Shape, + redComponent: number, + greenComponent: number, + blueComponent: number, +) { + redComponent = clamp(redComponent, 0, 1); + greenComponent = clamp(greenComponent, 0, 1); + blueComponent = clamp(blueComponent, 0, 1); + + let coloredSolid: Solid = colorize( + [redComponent, greenComponent, blueComponent], + shape.solid, + ); + Core.getRenderGroupManager() + .storeShape(new Shape(coloredSolid)); +} + +/** + * Renders using any Shapes stored thus far, along with a grid and axis. The + * Shapes will then not be included in any subsequent renders. + */ +export function render_grid_axis(): RenderGroup { + // Render group is returned for REPL text only; do not document + return Core.getRenderGroupManager() + .nextRenderGroup(true, true); +} + +/** + * Renders using any Shapes stored thus far, along with a grid. The Shapes will + * then not be included in any subsequent renders. + */ +export function render_grid(): RenderGroup { + return Core.getRenderGroupManager() + .nextRenderGroup(true); +} + +/** + * Renders using any Shapes stored thus far, along with an axis. The Shapes will + * then not be included in any subsequent renders. + */ +export function render_axis(): RenderGroup { + return Core.getRenderGroupManager() + .nextRenderGroup(undefined, true); +} + +/** + * Renders using any Shapes stored thus far. The Shapes will then not be + * included in any subsequent renders. + */ +export function render(): RenderGroup { + return Core.getRenderGroupManager() + .nextRenderGroup(); +} diff --git a/src/bundles/csg/index.ts b/src/bundles/csg/index.ts index e6baa0ca0..ade885340 100644 --- a/src/bundles/csg/index.ts +++ b/src/bundles/csg/index.ts @@ -1,153 +1,153 @@ -/* [Imports] */ -import { ModuleContext } from 'js-slang'; -import { ModuleContexts, ModuleParams } from '../../typings/type_helpers.js'; -import { Core } from './core.js'; -import { - area, - beside_x, - beside_y, - beside_z, - black, - blue, - bounding_box, - clone, - cone, - crimson, - cube, - cyan, - cylinder, - flip_x, - flip_y, - flip_z, - geodesic_sphere, - gray, - green, - intersect, - is_shape, - lime, - navy, - orange, - pink, - prism, - purple, - pyramid, - render, - render_axis, - render_grid, - render_grid_axis, - rose, - rotate, - rotate_x, - rotate_y, - rotate_z, - rounded_cube, - rounded_cylinder, - scale, - scale_x, - scale_y, - scale_z, - shape_center, - shape_set_center, - silver, - sphere, - star, - store, - store_as_color, - store_as_rgb, - subtract, - teal, - torus, - translate, - translate_x, - translate_y, - translate_z, - union, - volume, - white, - yellow, -} from './functions'; -import { CsgModuleState, getModuleContext } from './utilities.js'; - -/* [Exports] */ -export default (moduleParams: ModuleParams, moduleContexts: ModuleContexts) => { - let potentialModuleContext: ModuleContext | null = getModuleContext( - moduleContexts - ); - if (potentialModuleContext !== null) { - let moduleContext: ModuleContext = potentialModuleContext; - - let moduleState: CsgModuleState = new CsgModuleState(); - moduleContext.state = moduleState; - Core.initialize(moduleState); - } - - return { - // [Variables - Primitive shapes] - cube, - sphere, - cylinder, - prism, - star, - pyramid, - cone, - torus, - rounded_cube, - rounded_cylinder, - geodesic_sphere, - - // [Variables - Colours] - black, - navy, - green, - teal, - crimson, - purple, - orange, - silver, - gray, - blue, - lime, - cyan, - rose, - pink, - yellow, - white, - - // [Functions] - union, - subtract, - intersect, - scale, - scale_x, - scale_y, - scale_z, - shape_center, - shape_set_center, - area, - volume, - flip_x, - flip_y, - flip_z, - translate, - translate_x, - translate_y, - translate_z, - beside_x, - beside_y, - beside_z, - bounding_box, - rotate, - rotate_x, - rotate_y, - rotate_z, - is_shape, - clone, - store, - store_as_color, - store_as_rgb, - render_grid_axis, - render_grid, - render_axis, - render, - }; -}; +/* [Imports] */ +import { ModuleContext } from 'js-slang'; +import { ModuleContexts, ModuleParams } from '../../typings/type_helpers.js'; +import { Core } from './core.js'; +import { + area, + beside_x, + beside_y, + beside_z, + black, + blue, + bounding_box, + clone, + cone, + crimson, + cube, + cyan, + cylinder, + flip_x, + flip_y, + flip_z, + geodesic_sphere, + gray, + green, + intersect, + is_shape, + lime, + navy, + orange, + pink, + prism, + purple, + pyramid, + render, + render_axis, + render_grid, + render_grid_axis, + rose, + rotate, + rotate_x, + rotate_y, + rotate_z, + rounded_cube, + rounded_cylinder, + scale, + scale_x, + scale_y, + scale_z, + shape_center, + shape_set_center, + silver, + sphere, + star, + store, + store_as_color, + store_as_rgb, + subtract, + teal, + torus, + translate, + translate_x, + translate_y, + translate_z, + union, + volume, + white, + yellow, +} from './functions'; +import { CsgModuleState, getModuleContext } from './utilities.js'; + +/* [Exports] */ +export default (moduleParams: ModuleParams, moduleContexts: ModuleContexts) => { + let potentialModuleContext: ModuleContext | null = getModuleContext( + moduleContexts, + ); + if (potentialModuleContext !== null) { + let moduleContext: ModuleContext = potentialModuleContext; + + let moduleState: CsgModuleState = new CsgModuleState(); + moduleContext.state = moduleState; + Core.initialize(moduleState); + } + + return { + // [Variables - Primitive shapes] + cube, + sphere, + cylinder, + prism, + star, + pyramid, + cone, + torus, + rounded_cube, + rounded_cylinder, + geodesic_sphere, + + // [Variables - Colours] + black, + navy, + green, + teal, + crimson, + purple, + orange, + silver, + gray, + blue, + lime, + cyan, + rose, + pink, + yellow, + white, + + // [Functions] + union, + subtract, + intersect, + scale, + scale_x, + scale_y, + scale_z, + shape_center, + shape_set_center, + area, + volume, + flip_x, + flip_y, + flip_z, + translate, + translate_x, + translate_y, + translate_z, + beside_x, + beside_y, + beside_z, + bounding_box, + rotate, + rotate_x, + rotate_y, + rotate_z, + is_shape, + clone, + store, + store_as_color, + store_as_rgb, + render_grid_axis, + render_grid, + render_axis, + render, + }; +}; diff --git a/src/bundles/csg/input_tracker.ts b/src/bundles/csg/input_tracker.ts index 57a4754b9..64051dfb1 100644 --- a/src/bundles/csg/input_tracker.ts +++ b/src/bundles/csg/input_tracker.ts @@ -1,282 +1,282 @@ -/* [Imports] */ -import vec3 from '@jscad/modeling/src/maths/vec3'; -import { ZOOM_TICK_SCALE } from './constants.js'; -import { - cloneControlsState, - pan, - rotate, - updateProjection, - updateStates, - zoomToFit, -} from './jscad/renderer.js'; -import { - ControlsState, - GeometryEntity, - PerspectiveCameraState, -} from './jscad/types.js'; -import ListenerTracker from './listener_tracker.js'; - -/* [Main] */ -enum MousePointer { - // Based on MouseEvent#button - LEFT = 0, - MIDDLE = 1, - RIGHT = 2, - BACK = 3, - FORWARD = 4, - - NONE = -1, - OTHER = 7050, -} - -/* [Exports] */ -export default class InputTracker { - private controlsState: ControlsState = cloneControlsState(); - - // Start off the first frame by initially zooming to fit - private zoomToFit: boolean = true; - - private zoomTicks: number = 0; - - private heldPointer: MousePointer = MousePointer.NONE; - - private lastX: number | null = null; - private lastY: number | null = null; - - private rotateX: number = 0; - private rotateY: number = 0; - private panX: number = 0; - private panY: number = 0; - - private listenerTracker: ListenerTracker; - - // Set to true when a new frame must be requested, as states have changed and - // the canvas should look different - public frameDirty: boolean = false; - - constructor( - private canvas: HTMLCanvasElement, - private cameraState: PerspectiveCameraState, - private geometryEntities: GeometryEntity[] - ) { - this.listenerTracker = new ListenerTracker(canvas); - } - - private changeZoomTicks(wheelDelta: number) { - // Regardless of scroll magnitude, which the OS can change, each event - // firing should only tick once up or down - this.zoomTicks += Math.sign(wheelDelta); - } - - private setHeldPointer(mouseEventButton: number) { - switch (mouseEventButton) { - case MousePointer.LEFT: - case MousePointer.RIGHT: - case MousePointer.MIDDLE: - this.heldPointer = mouseEventButton; - break; - default: - this.heldPointer = MousePointer.OTHER; - break; - } - } - - private unsetHeldPointer() { - this.heldPointer = MousePointer.NONE; - } - - private shouldIgnorePointerMove(): boolean { - return ![MousePointer.LEFT, MousePointer.MIDDLE].includes(this.heldPointer); - } - - private isPointerPan(isShiftKey: boolean): boolean { - return ( - this.heldPointer === MousePointer.MIDDLE || - (this.heldPointer === MousePointer.LEFT && isShiftKey) - ); - } - - private unsetLastCoordinates() { - this.lastX = null; - this.lastY = null; - } - - private tryDynamicResize() { - let { width: oldWidth, height: oldHeight } = this.canvas; - - // Account for display scaling - let canvasBounds: DOMRect = this.canvas.getBoundingClientRect(); - let { devicePixelRatio } = window; - let newWidth: number = Math.floor(canvasBounds.width * devicePixelRatio); - let newHeight: number = Math.floor(canvasBounds.height * devicePixelRatio); - - if (oldWidth === newWidth && oldHeight === newHeight) return; - this.frameDirty = true; - - this.canvas.width = newWidth; - this.canvas.height = newHeight; - - updateProjection(this.cameraState, newWidth, newHeight); - } - - private tryZoomToFit() { - if (!this.zoomToFit) return; - this.frameDirty = true; - - zoomToFit(this.cameraState, this.controlsState, this.geometryEntities); - - this.zoomToFit = false; - } - - private tryZoom() { - if (this.zoomTicks === 0) return; - - while (this.zoomTicks !== 0) { - let currentTick: number = Math.sign(this.zoomTicks); - this.zoomTicks -= currentTick; - - let scaledChange: number = currentTick * ZOOM_TICK_SCALE; - let potentialNewScale: number = this.controlsState.scale + scaledChange; - let potentialNewDistance: number = - vec3.distance(this.cameraState.position, this.cameraState.target) * - potentialNewScale; - - if ( - potentialNewDistance > this.controlsState.limits.minDistance && - potentialNewDistance < this.controlsState.limits.maxDistance - ) { - this.frameDirty = true; - this.controlsState.scale = potentialNewScale; - } else break; - } - - this.zoomTicks = 0; - } - - private tryRotate() { - if (this.rotateX === 0 && this.rotateY === 0) return; - this.frameDirty = true; - - rotate(this.cameraState, this.controlsState, this.rotateX, this.rotateY); - - this.rotateX = 0; - this.rotateY = 0; - } - - private tryPan() { - if (this.panX === 0 && this.panY === 0) return; - this.frameDirty = true; - - pan(this.cameraState, this.controlsState, this.panX, this.panY); - - this.panX = 0; - this.panY = 0; - } - - addListeners() { - this.listenerTracker.addListener('dblclick', (_mouseEvent: MouseEvent) => { - this.zoomToFit = true; - }); - - this.listenerTracker.addListener( - 'wheel', - (wheelEvent: WheelEvent) => { - // Prevent scrolling the side panel when there is overflow - wheelEvent.preventDefault(); - - this.changeZoomTicks(wheelEvent.deltaY); - }, - // Force wait for our potential preventDefault() - { passive: false } - ); - - this.listenerTracker.addListener( - 'pointerdown', - (pointerEvent: PointerEvent) => { - // Prevent middle-click from activating auto-scrolling - pointerEvent.preventDefault(); - - this.setHeldPointer(pointerEvent.button); - this.lastX = pointerEvent.pageX; - this.lastY = pointerEvent.pageY; - - // Detect drags even outside the canvas element's borders - this.canvas.setPointerCapture(pointerEvent.pointerId); - }, - // Force wait for our potential preventDefault() - { passive: false } - ); - - this.listenerTracker.addListener( - 'pointerup', - (pointerEvent: PointerEvent) => { - this.unsetHeldPointer(); - this.unsetLastCoordinates(); - - this.canvas.releasePointerCapture(pointerEvent.pointerId); - } - ); - - this.listenerTracker.addListener( - 'pointermove', - (pointerEvent: PointerEvent) => { - if (this.shouldIgnorePointerMove()) return; - - let currentX = pointerEvent.pageX; - let currentY = pointerEvent.pageY; - - if (this.lastX !== null && this.lastY !== null) { - // If tracked before, use differences to react to input - let differenceX = this.lastX - currentX; - let differenceY = this.lastY - currentY; - - if (this.isPointerPan(pointerEvent.shiftKey)) { - // Drag right (X increases) - // Camera right (still +) - // Viewport left (invert to -) - this.panX += differenceX; - - // Drag down (Y increases) - // Camera down (invert to -) - // Viewport up (still -) - this.panY -= differenceY; - } else { - // Else default to rotate - - // Drag right (X increases) - // Camera angle from origin left (invert to -) - this.rotateX -= differenceX; - - // Drag down (Y increases) - // Camera angle from origin up (still +) - this.rotateY += differenceY; - } - } - - this.lastX = currentX; - this.lastY = currentY; - } - ); - } - - removeListeners() { - this.listenerTracker.removeListeners(); - } - - respondToInput() { - this.tryZoomToFit(); - this.tryZoom(); - this.tryRotate(); - this.tryPan(); - if (this.frameDirty) updateStates(this.cameraState, this.controlsState); - - // A successful resize dirties the frame, but does not require - // updateStates(), only its own updateProjection() - this.tryDynamicResize(); - } - - flushMidInput() { - this.unsetHeldPointer(); - this.unsetLastCoordinates(); - } -} +/* [Imports] */ +import vec3 from '@jscad/modeling/src/maths/vec3'; +import { ZOOM_TICK_SCALE } from './constants.js'; +import { + cloneControlsState, + pan, + rotate, + updateProjection, + updateStates, + zoomToFit, +} from './jscad/renderer.js'; +import { + ControlsState, + GeometryEntity, + PerspectiveCameraState, +} from './jscad/types.js'; +import ListenerTracker from './listener_tracker.js'; + +/* [Main] */ +enum MousePointer { + // Based on MouseEvent#button + LEFT = 0, + MIDDLE = 1, + RIGHT = 2, + BACK = 3, + FORWARD = 4, + + NONE = -1, + OTHER = 7050, +} + +/* [Exports] */ +export default class InputTracker { + private controlsState: ControlsState = cloneControlsState(); + + // Start off the first frame by initially zooming to fit + private zoomToFit: boolean = true; + + private zoomTicks: number = 0; + + private heldPointer: MousePointer = MousePointer.NONE; + + private lastX: number | null = null; + private lastY: number | null = null; + + private rotateX: number = 0; + private rotateY: number = 0; + private panX: number = 0; + private panY: number = 0; + + private listenerTracker: ListenerTracker; + + // Set to true when a new frame must be requested, as states have changed and + // the canvas should look different + public frameDirty: boolean = false; + + constructor( + private canvas: HTMLCanvasElement, + private cameraState: PerspectiveCameraState, + private geometryEntities: GeometryEntity[], + ) { + this.listenerTracker = new ListenerTracker(canvas); + } + + private changeZoomTicks(wheelDelta: number) { + // Regardless of scroll magnitude, which the OS can change, each event + // firing should only tick once up or down + this.zoomTicks += Math.sign(wheelDelta); + } + + private setHeldPointer(mouseEventButton: number) { + switch (mouseEventButton) { + case MousePointer.LEFT: + case MousePointer.RIGHT: + case MousePointer.MIDDLE: + this.heldPointer = mouseEventButton; + break; + default: + this.heldPointer = MousePointer.OTHER; + break; + } + } + + private unsetHeldPointer() { + this.heldPointer = MousePointer.NONE; + } + + private shouldIgnorePointerMove(): boolean { + return ![MousePointer.LEFT, MousePointer.MIDDLE].includes(this.heldPointer); + } + + private isPointerPan(isShiftKey: boolean): boolean { + return ( + this.heldPointer === MousePointer.MIDDLE + || (this.heldPointer === MousePointer.LEFT && isShiftKey) + ); + } + + private unsetLastCoordinates() { + this.lastX = null; + this.lastY = null; + } + + private tryDynamicResize() { + let { width: oldWidth, height: oldHeight } = this.canvas; + + // Account for display scaling + let canvasBounds: DOMRect = this.canvas.getBoundingClientRect(); + let { devicePixelRatio } = window; + let newWidth: number = Math.floor(canvasBounds.width * devicePixelRatio); + let newHeight: number = Math.floor(canvasBounds.height * devicePixelRatio); + + if (oldWidth === newWidth && oldHeight === newHeight) return; + this.frameDirty = true; + + this.canvas.width = newWidth; + this.canvas.height = newHeight; + + updateProjection(this.cameraState, newWidth, newHeight); + } + + private tryZoomToFit() { + if (!this.zoomToFit) return; + this.frameDirty = true; + + zoomToFit(this.cameraState, this.controlsState, this.geometryEntities); + + this.zoomToFit = false; + } + + private tryZoom() { + if (this.zoomTicks === 0) return; + + while (this.zoomTicks !== 0) { + let currentTick: number = Math.sign(this.zoomTicks); + this.zoomTicks -= currentTick; + + let scaledChange: number = currentTick * ZOOM_TICK_SCALE; + let potentialNewScale: number = this.controlsState.scale + scaledChange; + let potentialNewDistance: number + = vec3.distance(this.cameraState.position, this.cameraState.target) + * potentialNewScale; + + if ( + potentialNewDistance > this.controlsState.limits.minDistance + && potentialNewDistance < this.controlsState.limits.maxDistance + ) { + this.frameDirty = true; + this.controlsState.scale = potentialNewScale; + } else break; + } + + this.zoomTicks = 0; + } + + private tryRotate() { + if (this.rotateX === 0 && this.rotateY === 0) return; + this.frameDirty = true; + + rotate(this.cameraState, this.controlsState, this.rotateX, this.rotateY); + + this.rotateX = 0; + this.rotateY = 0; + } + + private tryPan() { + if (this.panX === 0 && this.panY === 0) return; + this.frameDirty = true; + + pan(this.cameraState, this.controlsState, this.panX, this.panY); + + this.panX = 0; + this.panY = 0; + } + + addListeners() { + this.listenerTracker.addListener('dblclick', (_mouseEvent: MouseEvent) => { + this.zoomToFit = true; + }); + + this.listenerTracker.addListener( + 'wheel', + (wheelEvent: WheelEvent) => { + // Prevent scrolling the side panel when there is overflow + wheelEvent.preventDefault(); + + this.changeZoomTicks(wheelEvent.deltaY); + }, + // Force wait for our potential preventDefault() + { passive: false }, + ); + + this.listenerTracker.addListener( + 'pointerdown', + (pointerEvent: PointerEvent) => { + // Prevent middle-click from activating auto-scrolling + pointerEvent.preventDefault(); + + this.setHeldPointer(pointerEvent.button); + this.lastX = pointerEvent.pageX; + this.lastY = pointerEvent.pageY; + + // Detect drags even outside the canvas element's borders + this.canvas.setPointerCapture(pointerEvent.pointerId); + }, + // Force wait for our potential preventDefault() + { passive: false }, + ); + + this.listenerTracker.addListener( + 'pointerup', + (pointerEvent: PointerEvent) => { + this.unsetHeldPointer(); + this.unsetLastCoordinates(); + + this.canvas.releasePointerCapture(pointerEvent.pointerId); + }, + ); + + this.listenerTracker.addListener( + 'pointermove', + (pointerEvent: PointerEvent) => { + if (this.shouldIgnorePointerMove()) return; + + let currentX = pointerEvent.pageX; + let currentY = pointerEvent.pageY; + + if (this.lastX !== null && this.lastY !== null) { + // If tracked before, use differences to react to input + let differenceX = this.lastX - currentX; + let differenceY = this.lastY - currentY; + + if (this.isPointerPan(pointerEvent.shiftKey)) { + // Drag right (X increases) + // Camera right (still +) + // Viewport left (invert to -) + this.panX += differenceX; + + // Drag down (Y increases) + // Camera down (invert to -) + // Viewport up (still -) + this.panY -= differenceY; + } else { + // Else default to rotate + + // Drag right (X increases) + // Camera angle from origin left (invert to -) + this.rotateX -= differenceX; + + // Drag down (Y increases) + // Camera angle from origin up (still +) + this.rotateY += differenceY; + } + } + + this.lastX = currentX; + this.lastY = currentY; + }, + ); + } + + removeListeners() { + this.listenerTracker.removeListeners(); + } + + respondToInput() { + this.tryZoomToFit(); + this.tryZoom(); + this.tryRotate(); + this.tryPan(); + if (this.frameDirty) updateStates(this.cameraState, this.controlsState); + + // A successful resize dirties the frame, but does not require + // updateStates(), only its own updateProjection() + this.tryDynamicResize(); + } + + flushMidInput() { + this.unsetHeldPointer(); + this.unsetLastCoordinates(); + } +} diff --git a/src/bundles/csg/jscad/renderer.ts b/src/bundles/csg/jscad/renderer.ts index af3d3d3d3..ba5ad19d7 100644 --- a/src/bundles/csg/jscad/renderer.ts +++ b/src/bundles/csg/jscad/renderer.ts @@ -1,255 +1,255 @@ -/* [Imports] */ -import measureBoundingBox from '@jscad/modeling/src/measurements/measureBoundingBox.js'; -import { - cameras, - controls, - drawCommands, - entitiesFromSolids, - prepareRender, -} from '@jscad/regl-renderer'; -import { - ACE_GUTTER_BACKGROUND_COLOR, - ACE_GUTTER_TEXT_COLOR, - BP_TEXT_COLOR, - DEFAULT_COLOR, - GRID_PADDING, - MAIN_TICKS, - ROTATION_SPEED, - ROUND_UP_INTERVAL, - SUB_TICKS, - X_FACTOR, - Y_FACTOR, -} from '../constants.js'; -import { hexToAlphaColor, RenderGroup, Shape } from '../utilities.js'; -import { - AlphaColor, - AxisEntityType, - BoundingBox, - ControlsState, - EntitiesFromSolidsOptions, - Entity, - GeometryEntity, - MultiGridEntityType, - PanStates, - PerspectiveCameraState, - RotateStates, - Solid, - UpdatedStates, - WrappedRenderer, - WrappedRendererData, - ZoomToFitStates, -} from './types.js'; - -/* [Main] */ -let { orbit } = controls; - -function solidsToGeometryEntities(solids: Solid[]): GeometryEntity[] { - let options: EntitiesFromSolidsOptions = { - color: hexToAlphaColor(DEFAULT_COLOR), - }; - return (entitiesFromSolids( - options, - ...solids - ) as unknown) as GeometryEntity[]; -} - -function neatGridDistance(rawDistance: number) { - let paddedDistance: number = rawDistance + GRID_PADDING; - let roundedDistance: number = - Math.ceil(paddedDistance / ROUND_UP_INTERVAL) * ROUND_UP_INTERVAL; - return roundedDistance; -} - -class MultiGridEntity implements MultiGridEntityType { - visuals: { - drawCmd: 'drawGrid'; - show: boolean; - - color?: AlphaColor; - subColor?: AlphaColor; - } = { - drawCmd: 'drawGrid', - show: true, - - color: hexToAlphaColor(BP_TEXT_COLOR), - subColor: hexToAlphaColor(ACE_GUTTER_TEXT_COLOR), - }; - - ticks: [number, number] = [MAIN_TICKS, SUB_TICKS]; - - size: [number, number]; - - constructor(size: number) { - this.size = [size, size]; - } -} - -class AxisEntity implements AxisEntityType { - visuals: { - drawCmd: 'drawAxis'; - show: boolean; - } = { - drawCmd: 'drawAxis', - show: true, - }; - - alwaysVisible: boolean = false; - - constructor(public size?: number) {} -} - -function makeExtraEntities( - renderGroup: RenderGroup, - solids: Solid[] -): Entity[] { - let { hasGrid, hasAxis } = renderGroup; - // Run calculations for grid and/or axis only if needed - if (!(hasAxis || hasGrid)) return []; - - let boundingBoxes: BoundingBox[] = solids.map( - (solid: Solid): BoundingBox => measureBoundingBox(solid) - ); - let minMaxXys: number[][] = boundingBoxes.map( - (boundingBox: BoundingBox): number[] => { - let minX = boundingBox[0][0]; - let minY = boundingBox[0][1]; - let maxX = boundingBox[1][0]; - let maxY = boundingBox[1][1]; - return [minX, minY, maxX, maxY]; - } - ); - let xys: number[] = minMaxXys.flat(1); - let distancesFromOrigin: number[] = xys.map(Math.abs); - let furthestDistance: number = Math.max(...distancesFromOrigin); - let neatDistance: number = neatGridDistance(furthestDistance); - - let extraEntities: Entity[] = []; - if (hasGrid) extraEntities.push(new MultiGridEntity(neatDistance * 2)); - if (hasAxis) extraEntities.push(new AxisEntity(neatDistance)); - return extraEntities; -} - -/* [Exports] */ -export function makeWrappedRendererData( - renderGroup: RenderGroup, - cameraState: PerspectiveCameraState -): WrappedRendererData { - let solids: Solid[] = renderGroup.shapes.map( - (shape: Shape): Solid => shape.solid - ); - let geometryEntities: GeometryEntity[] = solidsToGeometryEntities(solids); - let extraEntities: Entity[] = makeExtraEntities(renderGroup, solids); - let allEntities: Entity[] = [...geometryEntities, ...extraEntities]; - - return { - entities: allEntities, - geometryEntities, - - camera: cameraState, - - rendering: { - background: hexToAlphaColor(ACE_GUTTER_BACKGROUND_COLOR), - }, - - drawCommands, - }; -} - -export function makeWrappedRenderer( - canvas: HTMLCanvasElement -): WrappedRenderer { - return prepareRender({ - // Used to initialise Regl from the REGL package constructor - glOptions: { canvas }, - }); -} - -export function cloneCameraState(): PerspectiveCameraState { - return { ...cameras.perspective.defaults }; -} -export function cloneControlsState(): ControlsState { - return { ...controls.orbit.defaults }; -} - -export function updateProjection( - cameraState: PerspectiveCameraState, - width: number, - height: number -) { - // Modify the projection, aspect ratio & viewport. As compared to the general - // controls.orbit.update() or even cameras.perspective.update() - cameras.perspective.setProjection(cameraState, cameraState, { - width, - height, - }); -} - -export function updateStates( - cameraState: PerspectiveCameraState, - controlsState: ControlsState -) { - let states: UpdatedStates = (orbit.update({ - camera: cameraState, - controls: controlsState, - }) as unknown) as UpdatedStates; - - cameraState.position = states.camera.position; - cameraState.view = states.camera.view; - - controlsState.thetaDelta = states.controls.thetaDelta; - controlsState.phiDelta = states.controls.phiDelta; - controlsState.scale = states.controls.scale; -} - -export function zoomToFit( - cameraState: PerspectiveCameraState, - controlsState: ControlsState, - geometryEntities: GeometryEntity[] -) { - let states: ZoomToFitStates = (orbit.zoomToFit({ - camera: cameraState, - controls: controlsState, - entities: geometryEntities as any, - }) as unknown) as ZoomToFitStates; - - cameraState.target = states.camera.target; - - controlsState.scale = states.controls.scale; -} - -export function rotate( - cameraState: PerspectiveCameraState, - controlsState: ControlsState, - rotateX: number, - rotateY: number -) { - let states: RotateStates = (orbit.rotate( - { - camera: cameraState, - controls: controlsState, - speed: ROTATION_SPEED, - }, - [rotateX, rotateY] - ) as unknown) as RotateStates; - - controlsState.thetaDelta = states.controls.thetaDelta; - controlsState.phiDelta = states.controls.phiDelta; -} - -export function pan( - cameraState: PerspectiveCameraState, - controlsState: ControlsState, - panX: number, - panY: number -) { - let states: PanStates = (orbit.pan( - { - camera: cameraState, - controls: controlsState, - }, - [panX * X_FACTOR, panY * Y_FACTOR] - ) as unknown) as PanStates; - - cameraState.position = states.camera.position; - cameraState.target = states.camera.target; -} +/* [Imports] */ +import measureBoundingBox from '@jscad/modeling/src/measurements/measureBoundingBox.js'; +import { + cameras, + controls, + drawCommands, + entitiesFromSolids, + prepareRender, +} from '@jscad/regl-renderer'; +import { + ACE_GUTTER_BACKGROUND_COLOR, + ACE_GUTTER_TEXT_COLOR, + BP_TEXT_COLOR, + DEFAULT_COLOR, + GRID_PADDING, + MAIN_TICKS, + ROTATION_SPEED, + ROUND_UP_INTERVAL, + SUB_TICKS, + X_FACTOR, + Y_FACTOR, +} from '../constants.js'; +import { hexToAlphaColor, RenderGroup, Shape } from '../utilities.js'; +import { + AlphaColor, + AxisEntityType, + BoundingBox, + ControlsState, + EntitiesFromSolidsOptions, + Entity, + GeometryEntity, + MultiGridEntityType, + PanStates, + PerspectiveCameraState, + RotateStates, + Solid, + UpdatedStates, + WrappedRenderer, + WrappedRendererData, + ZoomToFitStates, +} from './types.js'; + +/* [Main] */ +let { orbit } = controls; + +function solidsToGeometryEntities(solids: Solid[]): GeometryEntity[] { + let options: EntitiesFromSolidsOptions = { + color: hexToAlphaColor(DEFAULT_COLOR), + }; + return (entitiesFromSolids( + options, + ...solids, + ) as unknown) as GeometryEntity[]; +} + +function neatGridDistance(rawDistance: number) { + let paddedDistance: number = rawDistance + GRID_PADDING; + let roundedDistance: number + = Math.ceil(paddedDistance / ROUND_UP_INTERVAL) * ROUND_UP_INTERVAL; + return roundedDistance; +} + +class MultiGridEntity implements MultiGridEntityType { + visuals: { + drawCmd: 'drawGrid'; + show: boolean; + + color?: AlphaColor; + subColor?: AlphaColor; + } = { + drawCmd: 'drawGrid', + show: true, + + color: hexToAlphaColor(BP_TEXT_COLOR), + subColor: hexToAlphaColor(ACE_GUTTER_TEXT_COLOR), + }; + + ticks: [number, number] = [MAIN_TICKS, SUB_TICKS]; + + size: [number, number]; + + constructor(size: number) { + this.size = [size, size]; + } +} + +class AxisEntity implements AxisEntityType { + visuals: { + drawCmd: 'drawAxis'; + show: boolean; + } = { + drawCmd: 'drawAxis', + show: true, + }; + + alwaysVisible: boolean = false; + + constructor(public size?: number) {} +} + +function makeExtraEntities( + renderGroup: RenderGroup, + solids: Solid[], +): Entity[] { + let { hasGrid, hasAxis } = renderGroup; + // Run calculations for grid and/or axis only if needed + if (!(hasAxis || hasGrid)) return []; + + let boundingBoxes: BoundingBox[] = solids.map( + (solid: Solid): BoundingBox => measureBoundingBox(solid), + ); + let minMaxXys: number[][] = boundingBoxes.map( + (boundingBox: BoundingBox): number[] => { + let minX = boundingBox[0][0]; + let minY = boundingBox[0][1]; + let maxX = boundingBox[1][0]; + let maxY = boundingBox[1][1]; + return [minX, minY, maxX, maxY]; + }, + ); + let xys: number[] = minMaxXys.flat(1); + let distancesFromOrigin: number[] = xys.map(Math.abs); + let furthestDistance: number = Math.max(...distancesFromOrigin); + let neatDistance: number = neatGridDistance(furthestDistance); + + let extraEntities: Entity[] = []; + if (hasGrid) extraEntities.push(new MultiGridEntity(neatDistance * 2)); + if (hasAxis) extraEntities.push(new AxisEntity(neatDistance)); + return extraEntities; +} + +/* [Exports] */ +export function makeWrappedRendererData( + renderGroup: RenderGroup, + cameraState: PerspectiveCameraState, +): WrappedRendererData { + let solids: Solid[] = renderGroup.shapes.map( + (shape: Shape): Solid => shape.solid, + ); + let geometryEntities: GeometryEntity[] = solidsToGeometryEntities(solids); + let extraEntities: Entity[] = makeExtraEntities(renderGroup, solids); + let allEntities: Entity[] = [...geometryEntities, ...extraEntities]; + + return { + entities: allEntities, + geometryEntities, + + camera: cameraState, + + rendering: { + background: hexToAlphaColor(ACE_GUTTER_BACKGROUND_COLOR), + }, + + drawCommands, + }; +} + +export function makeWrappedRenderer( + canvas: HTMLCanvasElement, +): WrappedRenderer { + return prepareRender({ + // Used to initialise Regl from the REGL package constructor + glOptions: { canvas }, + }); +} + +export function cloneCameraState(): PerspectiveCameraState { + return { ...cameras.perspective.defaults }; +} +export function cloneControlsState(): ControlsState { + return { ...controls.orbit.defaults }; +} + +export function updateProjection( + cameraState: PerspectiveCameraState, + width: number, + height: number, +) { + // Modify the projection, aspect ratio & viewport. As compared to the general + // controls.orbit.update() or even cameras.perspective.update() + cameras.perspective.setProjection(cameraState, cameraState, { + width, + height, + }); +} + +export function updateStates( + cameraState: PerspectiveCameraState, + controlsState: ControlsState, +) { + let states: UpdatedStates = (orbit.update({ + camera: cameraState, + controls: controlsState, + }) as unknown) as UpdatedStates; + + cameraState.position = states.camera.position; + cameraState.view = states.camera.view; + + controlsState.thetaDelta = states.controls.thetaDelta; + controlsState.phiDelta = states.controls.phiDelta; + controlsState.scale = states.controls.scale; +} + +export function zoomToFit( + cameraState: PerspectiveCameraState, + controlsState: ControlsState, + geometryEntities: GeometryEntity[], +) { + let states: ZoomToFitStates = (orbit.zoomToFit({ + camera: cameraState, + controls: controlsState, + entities: geometryEntities as any, + }) as unknown) as ZoomToFitStates; + + cameraState.target = states.camera.target; + + controlsState.scale = states.controls.scale; +} + +export function rotate( + cameraState: PerspectiveCameraState, + controlsState: ControlsState, + rotateX: number, + rotateY: number, +) { + let states: RotateStates = (orbit.rotate( + { + camera: cameraState, + controls: controlsState, + speed: ROTATION_SPEED, + }, + [rotateX, rotateY], + ) as unknown) as RotateStates; + + controlsState.thetaDelta = states.controls.thetaDelta; + controlsState.phiDelta = states.controls.phiDelta; +} + +export function pan( + cameraState: PerspectiveCameraState, + controlsState: ControlsState, + panX: number, + panY: number, +) { + let states: PanStates = (orbit.pan( + { + camera: cameraState, + controls: controlsState, + }, + [panX * X_FACTOR, panY * Y_FACTOR], + ) as unknown) as PanStates; + + cameraState.position = states.camera.position; + cameraState.target = states.camera.target; +} diff --git a/src/bundles/csg/jscad/types.ts b/src/bundles/csg/jscad/types.ts index 11da4269f..e92e31299 100644 --- a/src/bundles/csg/jscad/types.ts +++ b/src/bundles/csg/jscad/types.ts @@ -1,277 +1,277 @@ -/* [Import] */ -import { RGB, RGBA } from '@jscad/modeling/src/colors/types.js'; -import { Geom3 } from '@jscad/modeling/src/geometries/types.js'; -import { cameras, controls, drawCommands } from '@jscad/regl-renderer'; -import makeDrawMultiGrid from '@jscad/regl-renderer/types/rendering/commands/drawGrid/multi'; - -/* [Main] */ -let { orbit } = controls; - -/* [Exports] */ -export type Color = RGB; -export type AlphaColor = RGBA; - -export type Numbers2 = [number, number]; -export type Numbers3 = [number, number, number]; - -export type Vector = Numbers3; -export type Coordinates = Numbers3; -export type BoundingBox = [Coordinates, Coordinates]; - -// @jscad\regl-renderer\src\rendering\renderDefaults.js -export type RenderOptions = { - // Used early on in render.js. Clears the canvas to the specified background - // colour - background?: AlphaColor; - - // Specified values used in various rendering commands as shader uniforms - lightColor?: AlphaColor; - lightDirection?: Vector; - ambientLightAmount?: number; - diffuseLightAmount?: number; - specularLightAmount?: number; - materialShininess?: number; // As uMaterialShininess in main DrawCommand - - // Specified value is unused, which seems unintentional. Their default value - // for this is used directly in V2's entitiesFromSolids.js as the default - // Geometry colour. Also gets used directly in various rendering commands as - // their shader uniforms' default colour - meshColor?: AlphaColor; - - // Unused - lightPosition?: Coordinates; // See also lightDirection -}; - -// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js -// @jscad\regl-renderer\demo-web.js -// @jscad\regl-renderer\src\rendering\render.js -/* - (This is not exhaustive. There are still other Props used for uniforms in the - various rendering commands. Eg model, color, angle.) - (There are other Entity subtypes not defined in this file - GridEntity, - LineEntity & MeshEntity) -*/ -export type Entity = { - visuals: { - // Key for the DrawCommandMaker that should be used on this Entity. Key gets - // used on WrappedRendererData#drawCommands. Property must exist & match a - // DrawCommandMaker, or behaviour is like show: false - drawCmd: 'drawAxis' | 'drawGrid' | 'drawLines' | 'drawMesh'; - - // Whether to actually draw the Entity via nested DrawCommand - show: boolean; - - // Used to retrieve created DrawCommands from cache - cacheId?: number | null; - }; -}; - -// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.js -// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.test.js -export type Geometry = { - type: '2d' | '3d'; - positions: Coordinates[]; - normals: Coordinates[]; - indices: Coordinates[]; - colors: AlphaColor[]; - transforms: Mat4; - isTransparent: boolean; -}; - -// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js -export type GeometryEntity = Entity & { - visuals: { - drawCmd: 'drawLines' | 'drawMesh'; - - // Whether the Geometry is transparent. Transparents need to be rendered - // before non-transparents - transparent: boolean; - - // Eventually determines whether to use vColorShaders (Geometry must also - // have colour), or meshShaders - useVertexColors: boolean; - }; - - // The original Geometry used to make the GeometryEntity - geometry: Geometry; -}; - -// @jscad\regl-renderer\src\rendering\commands\drawGrid\multi.js -// @jscad\regl-renderer\src\rendering\commands\drawGrid\index.js -// @jscad\regl-renderer\demo-web.js -// @jscad\web\src\ui\views\viewer.js -// @jscad\regl-renderer\src\index.js -export type MultiGridEntityType = Entity & { - // Entity#visuals gets stuffed into the nested DrawCommand as Props. The Props - // get passed on wholesale by makeDrawMultiGrid()'s returned lambda, where the - // following properties then get used (rather than while setting up the - // DrawCommands) - visuals: { - drawCmd: 'drawGrid'; - - color?: AlphaColor; - subColor?: AlphaColor; // Also as color - - fadeOut?: boolean; - }; - - size?: Numbers2; - // First number used on the main grid, second number on sub grid - ticks?: [number, number]; - - centered?: boolean; - - // Deprecated - lineWidth?: number; -}; - -// @jscad\regl-renderer\src\rendering\commands\drawAxis\index.js -// @jscad\regl-renderer\demo-web.js -export type AxisEntityType = Entity & { - visuals: { - drawCmd: 'drawAxis'; - }; - - size?: number; - alwaysVisible?: boolean; - - xColor?: AlphaColor; - yColor?: AlphaColor; - zColor?: AlphaColor; - - // Deprecated - lineWidth?: number; -}; - -// There are 4 rendering commands to use in regl-renderer: drawAxis, drawGrid, -// drawLines & drawMesh. drawExps appears abandoned. Only once passed Regl & an -// Entity do they return an actual DrawCommand -export type DrawCommandMaker = typeof drawCommands | typeof makeDrawMultiGrid; -export type DrawCommandMakers = Record; - -// @jscad\regl-renderer\src\cameras\perspectiveCamera.js -// @jscad\regl-renderer\src\cameras\orthographicCamera.js -/* - (Not exhaustive, only defines well the important properties we need.) - (Orthgraphic camera is ignored, this file assumes PerspectiveCameraState) -*/ -export type Mat4 = Float32Array; -export type PerspectiveCameraState = Omit< - typeof cameras.perspective.cameraState, - 'target' | 'position' | 'view' -> & { - target: Coordinates; - - position: Coordinates; - view: Mat4; -}; - -// @jscad\regl-renderer\src\rendering\render.js -/* - Gets used in the WrappedRenderer. Also gets passed as Props into the main - DrawCommand, where it is used in setup specified by the internal - renderContext.js/renderWrapper. The lambda of the main DrawCommand does not - use those Props, rather, it references the data in the WrappedRenderer - directly. Therefore, regl.prop() is not called, nor are Props used via the - semantic equivalent (context, props) => {}. The context passed to that lambda - also remains unused -*/ -export type WrappedRendererData = { - entities: Entity[]; - // A CUSTOM property used to easily pass only the GeometryEntities to - // InputTracker for zoom to fit - geometryEntities: GeometryEntity[]; - - // Along with all of the relevant Entity's & Entity#visuals's properties, this - // entire object gets destructured & stuffed into each nested DrawCommand as - // Props. Messy & needs tidying in regl-renderer - camera: PerspectiveCameraState; - - rendering?: RenderOptions; - - drawCommands: DrawCommandMakers; -}; - -// @jscad\regl-renderer\src\rendering\render.js -/* - When called, the WrappedRenderer creates a main DrawCommand. This main - DrawCommand then gets called as a scoped command, used to create & call more - DrawCommands for the data.entities. Nested DrawCommands get cached & may store - some Entity properties during setup, but properties passed in from Props later - may take precedence. The main DrawCommand is said to be in charge of injecting - most uniforms into the Regl context, ie keeping track of all Regl global state -*/ -export type WrappedRenderer = (data: WrappedRendererData) => void; - -// @jscad\regl-renderer\src\controls\orbitControls.js -/* - (Not exhaustive, only defines well the important properties we need) -*/ -export type ControlsState = Omit< - typeof orbit.controlsState, - 'scale' | 'thetaDelta' | 'phiDelta' -> & - typeof orbit.controlsProps & { - scale: number; - - thetaDelta: number; - phiDelta: number; - }; - -export type Solid = Geom3; - -// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js -/* - Options for the function that converts Solids into Geometries, then into - GeometryEntities -*/ -export type EntitiesFromSolidsOptions = { - // Default colour for entity rendering if the solid does not have one - color?: AlphaColor; - - // Whether to smooth the normals of 3D solids, rendering a smooth surface - smoothNormals?: boolean; -}; - -// @jscad\regl-renderer\src\controls\orbitControls.js -export type UpdatedStates = { - camera: { - position: Coordinates; - view: Mat4; - }; - controls: { - thetaDelta: number; - phiDelta: number; - scale: number; - - changed: boolean; - }; -}; - -// @jscad\regl-renderer\src\controls\orbitControls.js -export type ZoomToFitStates = { - camera: { - target: Vector; - }; - controls: { - scale: number; - }; -}; - -// @jscad\regl-renderer\src\controls\orbitControls.js -export type RotateStates = { - camera: PerspectiveCameraState; - controls: { - thetaDelta: number; - phiDelta: number; - }; -}; - -// @jscad\regl-renderer\src\controls\orbitControls.js -export type PanStates = { - camera: { - position: Coordinates; - target: Vector; - }; - controls: ControlsState; -}; +/* [Import] */ +import { RGB, RGBA } from '@jscad/modeling/src/colors/types.js'; +import { Geom3 } from '@jscad/modeling/src/geometries/types.js'; +import { cameras, controls, drawCommands } from '@jscad/regl-renderer'; +import makeDrawMultiGrid from '@jscad/regl-renderer/types/rendering/commands/drawGrid/multi'; + +/* [Main] */ +let { orbit } = controls; + +/* [Exports] */ +export type Color = RGB; +export type AlphaColor = RGBA; + +export type Numbers2 = [number, number]; +export type Numbers3 = [number, number, number]; + +export type Vector = Numbers3; +export type Coordinates = Numbers3; +export type BoundingBox = [Coordinates, Coordinates]; + +// @jscad\regl-renderer\src\rendering\renderDefaults.js +export type RenderOptions = { + // Used early on in render.js. Clears the canvas to the specified background + // colour + background?: AlphaColor; + + // Specified values used in various rendering commands as shader uniforms + lightColor?: AlphaColor; + lightDirection?: Vector; + ambientLightAmount?: number; + diffuseLightAmount?: number; + specularLightAmount?: number; + materialShininess?: number; // As uMaterialShininess in main DrawCommand + + // Specified value is unused, which seems unintentional. Their default value + // for this is used directly in V2's entitiesFromSolids.js as the default + // Geometry colour. Also gets used directly in various rendering commands as + // their shader uniforms' default colour + meshColor?: AlphaColor; + + // Unused + lightPosition?: Coordinates; // See also lightDirection +}; + +// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js +// @jscad\regl-renderer\demo-web.js +// @jscad\regl-renderer\src\rendering\render.js +/* + (This is not exhaustive. There are still other Props used for uniforms in the + various rendering commands. Eg model, color, angle.) + (There are other Entity subtypes not defined in this file - GridEntity, + LineEntity & MeshEntity) +*/ +export type Entity = { + visuals: { + // Key for the DrawCommandMaker that should be used on this Entity. Key gets + // used on WrappedRendererData#drawCommands. Property must exist & match a + // DrawCommandMaker, or behaviour is like show: false + drawCmd: 'drawAxis' | 'drawGrid' | 'drawLines' | 'drawMesh'; + + // Whether to actually draw the Entity via nested DrawCommand + show: boolean; + + // Used to retrieve created DrawCommands from cache + cacheId?: number | null; + }; +}; + +// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.js +// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.test.js +export type Geometry = { + type: '2d' | '3d'; + positions: Coordinates[]; + normals: Coordinates[]; + indices: Coordinates[]; + colors: AlphaColor[]; + transforms: Mat4; + isTransparent: boolean; +}; + +// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js +export type GeometryEntity = Entity & { + visuals: { + drawCmd: 'drawLines' | 'drawMesh'; + + // Whether the Geometry is transparent. Transparents need to be rendered + // before non-transparents + transparent: boolean; + + // Eventually determines whether to use vColorShaders (Geometry must also + // have colour), or meshShaders + useVertexColors: boolean; + }; + + // The original Geometry used to make the GeometryEntity + geometry: Geometry; +}; + +// @jscad\regl-renderer\src\rendering\commands\drawGrid\multi.js +// @jscad\regl-renderer\src\rendering\commands\drawGrid\index.js +// @jscad\regl-renderer\demo-web.js +// @jscad\web\src\ui\views\viewer.js +// @jscad\regl-renderer\src\index.js +export type MultiGridEntityType = Entity & { + // Entity#visuals gets stuffed into the nested DrawCommand as Props. The Props + // get passed on wholesale by makeDrawMultiGrid()'s returned lambda, where the + // following properties then get used (rather than while setting up the + // DrawCommands) + visuals: { + drawCmd: 'drawGrid'; + + color?: AlphaColor; + subColor?: AlphaColor; // Also as color + + fadeOut?: boolean; + }; + + size?: Numbers2; + // First number used on the main grid, second number on sub grid + ticks?: [number, number]; + + centered?: boolean; + + // Deprecated + lineWidth?: number; +}; + +// @jscad\regl-renderer\src\rendering\commands\drawAxis\index.js +// @jscad\regl-renderer\demo-web.js +export type AxisEntityType = Entity & { + visuals: { + drawCmd: 'drawAxis'; + }; + + size?: number; + alwaysVisible?: boolean; + + xColor?: AlphaColor; + yColor?: AlphaColor; + zColor?: AlphaColor; + + // Deprecated + lineWidth?: number; +}; + +// There are 4 rendering commands to use in regl-renderer: drawAxis, drawGrid, +// drawLines & drawMesh. drawExps appears abandoned. Only once passed Regl & an +// Entity do they return an actual DrawCommand +export type DrawCommandMaker = typeof drawCommands | typeof makeDrawMultiGrid; +export type DrawCommandMakers = Record; + +// @jscad\regl-renderer\src\cameras\perspectiveCamera.js +// @jscad\regl-renderer\src\cameras\orthographicCamera.js +/* + (Not exhaustive, only defines well the important properties we need.) + (Orthgraphic camera is ignored, this file assumes PerspectiveCameraState) +*/ +export type Mat4 = Float32Array; +export type PerspectiveCameraState = Omit< + typeof cameras.perspective.cameraState, +'target' | 'position' | 'view' +> & { + target: Coordinates; + + position: Coordinates; + view: Mat4; +}; + +// @jscad\regl-renderer\src\rendering\render.js +/* + Gets used in the WrappedRenderer. Also gets passed as Props into the main + DrawCommand, where it is used in setup specified by the internal + renderContext.js/renderWrapper. The lambda of the main DrawCommand does not + use those Props, rather, it references the data in the WrappedRenderer + directly. Therefore, regl.prop() is not called, nor are Props used via the + semantic equivalent (context, props) => {}. The context passed to that lambda + also remains unused +*/ +export type WrappedRendererData = { + entities: Entity[]; + // A CUSTOM property used to easily pass only the GeometryEntities to + // InputTracker for zoom to fit + geometryEntities: GeometryEntity[]; + + // Along with all of the relevant Entity's & Entity#visuals's properties, this + // entire object gets destructured & stuffed into each nested DrawCommand as + // Props. Messy & needs tidying in regl-renderer + camera: PerspectiveCameraState; + + rendering?: RenderOptions; + + drawCommands: DrawCommandMakers; +}; + +// @jscad\regl-renderer\src\rendering\render.js +/* + When called, the WrappedRenderer creates a main DrawCommand. This main + DrawCommand then gets called as a scoped command, used to create & call more + DrawCommands for the data.entities. Nested DrawCommands get cached & may store + some Entity properties during setup, but properties passed in from Props later + may take precedence. The main DrawCommand is said to be in charge of injecting + most uniforms into the Regl context, ie keeping track of all Regl global state +*/ +export type WrappedRenderer = (data: WrappedRendererData) => void; + +// @jscad\regl-renderer\src\controls\orbitControls.js +/* + (Not exhaustive, only defines well the important properties we need) +*/ +export type ControlsState = Omit< + typeof orbit.controlsState, +'scale' | 'thetaDelta' | 'phiDelta' +> & + typeof orbit.controlsProps & { + scale: number; + + thetaDelta: number; + phiDelta: number; +}; + +export type Solid = Geom3; + +// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js +/* + Options for the function that converts Solids into Geometries, then into + GeometryEntities +*/ +export type EntitiesFromSolidsOptions = { + // Default colour for entity rendering if the solid does not have one + color?: AlphaColor; + + // Whether to smooth the normals of 3D solids, rendering a smooth surface + smoothNormals?: boolean; +}; + +// @jscad\regl-renderer\src\controls\orbitControls.js +export type UpdatedStates = { + camera: { + position: Coordinates; + view: Mat4; + }; + controls: { + thetaDelta: number; + phiDelta: number; + scale: number; + + changed: boolean; + }; +}; + +// @jscad\regl-renderer\src\controls\orbitControls.js +export type ZoomToFitStates = { + camera: { + target: Vector; + }; + controls: { + scale: number; + }; +}; + +// @jscad\regl-renderer\src\controls\orbitControls.js +export type RotateStates = { + camera: PerspectiveCameraState; + controls: { + thetaDelta: number; + phiDelta: number; + }; +}; + +// @jscad\regl-renderer\src\controls\orbitControls.js +export type PanStates = { + camera: { + position: Coordinates; + target: Vector; + }; + controls: ControlsState; +}; diff --git a/src/bundles/csg/listener_tracker.ts b/src/bundles/csg/listener_tracker.ts index a5753d9d9..aeba0fda6 100644 --- a/src/bundles/csg/listener_tracker.ts +++ b/src/bundles/csg/listener_tracker.ts @@ -1,28 +1,28 @@ -/* [Exports] */ -export default class ListenerTracker { - private listeners: [string, Function][] = []; - - constructor(private element: Element) {} - - addListener( - eventType: string, - listener: Function, - options?: AddEventListenerOptions - ) { - this.listeners.push([eventType, listener]); - this.element.addEventListener( - eventType, - listener as EventListenerOrEventListenerObject, - options - ); - } - - removeListeners() { - this.listeners.forEach(([eventType, listener]) => { - this.element.removeEventListener( - eventType, - listener as EventListenerOrEventListenerObject - ); - }); - } -} +/* [Exports] */ +export default class ListenerTracker { + private listeners: [string, Function][] = []; + + constructor(private element: Element) {} + + addListener( + eventType: string, + listener: Function, + options?: AddEventListenerOptions, + ) { + this.listeners.push([eventType, listener]); + this.element.addEventListener( + eventType, + listener as EventListenerOrEventListenerObject, + options, + ); + } + + removeListeners() { + this.listeners.forEach(([eventType, listener]) => { + this.element.removeEventListener( + eventType, + listener as EventListenerOrEventListenerObject, + ); + }); + } +} diff --git a/src/bundles/csg/stateful_renderer.ts b/src/bundles/csg/stateful_renderer.ts index 455a9d580..2f6a32dd8 100644 --- a/src/bundles/csg/stateful_renderer.ts +++ b/src/bundles/csg/stateful_renderer.ts @@ -1,141 +1,141 @@ -/* [Imports] */ -import InputTracker from './input_tracker.js'; -import { - cloneCameraState, - makeWrappedRenderer, - makeWrappedRendererData, -} from './jscad/renderer.js'; -import { - Entity, - PerspectiveCameraState, - WrappedRenderer, - WrappedRendererData, -} from './jscad/types.js'; -import ListenerTracker from './listener_tracker.js'; -import { RenderGroup } from './utilities.js'; - -/* [Exports] */ -export default class StatefulRenderer { - private isStarted: boolean = false; - private currentRequestId: number | null = null; - - private cameraState: PerspectiveCameraState = cloneCameraState(); - - private webGlListenerTracker: ListenerTracker; - - private wrappedRendererData: WrappedRendererData; - - private inputTracker: InputTracker; - - constructor( - private canvas: HTMLCanvasElement, - renderGroup: RenderGroup, - private componentNumber: number, - - private loseCallback: Function, - private restoreCallback: Function - ) { - //FIXME Issue #7 - this.cameraState.position = [1000, 1000, 1500]; - - this.webGlListenerTracker = new ListenerTracker(canvas); - - this.wrappedRendererData = makeWrappedRendererData( - renderGroup, - this.cameraState - ); - - this.inputTracker = new InputTracker( - canvas, - this.cameraState, - this.wrappedRendererData.geometryEntities - ); - } - - private addWebGlListeners() { - this.webGlListenerTracker.addListener( - 'webglcontextlost', - (contextEvent: WebGLContextEvent) => { - // Allow restoration of context - contextEvent.preventDefault(); - - console.debug(`>>> CONTEXT LOST FOR #${this.componentNumber}`); - - this.loseCallback(); - - this.stop(); - } - ); - - this.webGlListenerTracker.addListener( - 'webglcontextrestored', - (_contextEvent: WebGLContextEvent) => { - console.debug(`>>> CONTEXT RESTORED FOR #${this.componentNumber}`); - - this.start(); - - this.restoreCallback(); - } - ); - } - - private forgetEntityCaches() { - // Clear draw cache IDs so starting again doesn't try to retrieve - // DrawCommands - this.wrappedRendererData.entities.forEach((entity: Entity) => { - entity.visuals.cacheId = null; - }); - } - - start(firstStart = false) { - if (this.isStarted) return; - this.isStarted = true; - - if (!firstStart) { - // As listeners were previously removed, flush some tracked inputs to - // avoid bugs like the pointer being stuck down - this.inputTracker.flushMidInput(); - - this.forgetEntityCaches(); - } - - // Creating the WrappedRenderer already involves REGL. Losing WebGL context - // requires repeating this step (ie, with each start()) - let wrappedRenderer: WrappedRenderer = makeWrappedRenderer(this.canvas); - - if (firstStart) this.addWebGlListeners(); - this.inputTracker.addListeners(); - - let frameCallback: FrameRequestCallback = ( - _timestamp: DOMHighResTimeStamp - ) => { - this.inputTracker.respondToInput(); - - if (this.inputTracker.frameDirty) { - console.debug(`>>> Frame for #${this.componentNumber}`); - - wrappedRenderer(this.wrappedRendererData); - this.inputTracker.frameDirty = false; - } - - this.currentRequestId = window.requestAnimationFrame(frameCallback); - }; - if (!firstStart) { - // Force draw upon restarting, eg after recovering from context loss - this.inputTracker.frameDirty = true; - } - this.currentRequestId = window.requestAnimationFrame(frameCallback); - } - - stop(lastStop = false) { - if (this.currentRequestId !== null) { - window.cancelAnimationFrame(this.currentRequestId); - this.currentRequestId = null; - } - - this.inputTracker.removeListeners(); - if (lastStop) this.webGlListenerTracker.removeListeners(); - - this.isStarted = false; - } -} +/* [Imports] */ +import InputTracker from './input_tracker.js'; +import { + cloneCameraState, + makeWrappedRenderer, + makeWrappedRendererData, +} from './jscad/renderer.js'; +import { + Entity, + PerspectiveCameraState, + WrappedRenderer, + WrappedRendererData, +} from './jscad/types.js'; +import ListenerTracker from './listener_tracker.js'; +import { RenderGroup } from './utilities.js'; + +/* [Exports] */ +export default class StatefulRenderer { + private isStarted: boolean = false; + private currentRequestId: number | null = null; + + private cameraState: PerspectiveCameraState = cloneCameraState(); + + private webGlListenerTracker: ListenerTracker; + + private wrappedRendererData: WrappedRendererData; + + private inputTracker: InputTracker; + + constructor( + private canvas: HTMLCanvasElement, + renderGroup: RenderGroup, + private componentNumber: number, + + private loseCallback: Function, + private restoreCallback: Function, + ) { + //FIXME Issue #7 + this.cameraState.position = [1000, 1000, 1500]; + + this.webGlListenerTracker = new ListenerTracker(canvas); + + this.wrappedRendererData = makeWrappedRendererData( + renderGroup, + this.cameraState, + ); + + this.inputTracker = new InputTracker( + canvas, + this.cameraState, + this.wrappedRendererData.geometryEntities, + ); + } + + private addWebGlListeners() { + this.webGlListenerTracker.addListener( + 'webglcontextlost', + (contextEvent: WebGLContextEvent) => { + // Allow restoration of context + contextEvent.preventDefault(); + + console.debug(`>>> CONTEXT LOST FOR #${this.componentNumber}`); + + this.loseCallback(); + + this.stop(); + }, + ); + + this.webGlListenerTracker.addListener( + 'webglcontextrestored', + (_contextEvent: WebGLContextEvent) => { + console.debug(`>>> CONTEXT RESTORED FOR #${this.componentNumber}`); + + this.start(); + + this.restoreCallback(); + }, + ); + } + + private forgetEntityCaches() { + // Clear draw cache IDs so starting again doesn't try to retrieve + // DrawCommands + this.wrappedRendererData.entities.forEach((entity: Entity) => { + entity.visuals.cacheId = null; + }); + } + + start(firstStart = false) { + if (this.isStarted) return; + this.isStarted = true; + + if (!firstStart) { + // As listeners were previously removed, flush some tracked inputs to + // avoid bugs like the pointer being stuck down + this.inputTracker.flushMidInput(); + + this.forgetEntityCaches(); + } + + // Creating the WrappedRenderer already involves REGL. Losing WebGL context + // requires repeating this step (ie, with each start()) + let wrappedRenderer: WrappedRenderer = makeWrappedRenderer(this.canvas); + + if (firstStart) this.addWebGlListeners(); + this.inputTracker.addListeners(); + + let frameCallback: FrameRequestCallback = ( + _timestamp: DOMHighResTimeStamp, + ) => { + this.inputTracker.respondToInput(); + + if (this.inputTracker.frameDirty) { + console.debug(`>>> Frame for #${this.componentNumber}`); + + wrappedRenderer(this.wrappedRendererData); + this.inputTracker.frameDirty = false; + } + + this.currentRequestId = window.requestAnimationFrame(frameCallback); + }; + if (!firstStart) { + // Force draw upon restarting, eg after recovering from context loss + this.inputTracker.frameDirty = true; + } + this.currentRequestId = window.requestAnimationFrame(frameCallback); + } + + stop(lastStop = false) { + if (this.currentRequestId !== null) { + window.cancelAnimationFrame(this.currentRequestId); + this.currentRequestId = null; + } + + this.inputTracker.removeListeners(); + if (lastStop) this.webGlListenerTracker.removeListeners(); + + this.isStarted = false; + } +} diff --git a/src/bundles/csg/types.ts b/src/bundles/csg/types.ts new file mode 100644 index 000000000..09a812d8d --- /dev/null +++ b/src/bundles/csg/types.ts @@ -0,0 +1,349 @@ +/* [Imports] */ +import { RGB, RGBA } from '@jscad/modeling/src/colors'; +import { Geom3 } from '@jscad/modeling/src/geometries/types'; +import { + cameras, + controls as _controls, + drawCommands, +} from '@jscad/regl-renderer'; +import makeDrawMultiGrid from '@jscad/regl-renderer/types/rendering/commands/drawGrid/multi'; +import { InitializationOptions } from 'regl'; + +/* [Main] */ +let orthographicCamera = cameras.orthographic; +let perspectiveCamera = cameras.perspective; + +let controls = _controls.orbit; + +/* [Exports] */ + +// [Proper typing for JS in regl-renderer] +type Numbers2 = [number, number]; + +type Numbers3 = [number, number, number]; +export type VectorXYZ = Numbers3; +export type CoordinatesXYZ = Numbers3; +export type Color = RGB; + +export type Mat4 = Float32Array; + +// @jscad\regl-renderer\src\cameras\perspectiveCamera.js +// @jscad\regl-renderer\src\cameras\orthographicCamera.js +export type PerspectiveCamera = typeof perspectiveCamera; +export type OrthographicCamera = typeof orthographicCamera; + +export type PerspectiveCameraState = Omit< + typeof perspectiveCamera.cameraState, +'target' | 'position' | 'view' +> & { + target: CoordinatesXYZ; + + position: CoordinatesXYZ; + view: Mat4; +}; +export type OrthographicCameraState = typeof orthographicCamera.cameraState; +export type CameraState = PerspectiveCameraState | OrthographicCameraState; + +// @jscad\regl-renderer\src\controls\orbitControls.js +export type Controls = Omit< + typeof controls, +'update' | 'zoomToFit' | 'rotate' | 'pan' +> & { + update: ControlsUpdate.Function; + zoomToFit: ControlsZoomToFit.Function; + rotate: ControlsRotate; + pan: ControlsPan; +}; +export namespace ControlsUpdate { + export type Function = (options: Options) => Output; + + export type Options = { + controls: ControlsState; + camera: CameraState; + }; + + export type Output = { + controls: { + thetaDelta: number; + phiDelta: number; + scale: number; + changed: boolean; + }; + camera: { + position: CoordinatesXYZ; + view: Mat4; + }; + }; +} +export namespace ControlsZoomToFit { + export type Function = (options: Options) => Output; + + export type Options = { + controls: ControlsState; + camera: CameraState; + entities: GeometryEntity[]; + }; + + export type Output = { + camera: { + target: VectorXYZ; + }; + controls: { + scale: number; + }; + }; +} +export type ControlsRotate = ( + options: { + controls: ControlsState; + camera: CameraState; + speed?: number; + }, + rotateAngles: Numbers2 +) => { + controls: { + thetaDelta: number; + phiDelta: number; + }; + camera: CameraState; +}; +export type ControlsPan = ( + options: { + controls: ControlsState; + camera: CameraState; + speed?: number; + }, + rotateAngles: Numbers2 +) => { + controls: ControlsState; + camera: { + position: CoordinatesXYZ; + target: VectorXYZ; + }; +}; + +export type ControlsState = Omit< + typeof controls.controlsState, +'scale' | 'thetaDelta' | 'phiDelta' +> & + typeof controls.controlsProps & { + scale: number; + + thetaDelta: number; + phiDelta: number; +}; + +export type Solid = Geom3; + +// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.js +// @jscad\regl-renderer\src\geometry-utils-V2\geom3ToGeometries.test.js +export type Geometry = { + type: '2d' | '3d'; + positions: CoordinatesXYZ[]; + normals: CoordinatesXYZ[]; + indices: CoordinatesXYZ[]; + colors: RGBA[]; + transforms: Mat4; + isTransparent: boolean; +}; + +// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js +// @jscad\regl-renderer\demo-web.js +// There are still other Props used for uniforms in the various rendering +// commands, eg model, color, angle +export type Entity = { + visuals: { + // Key for the draw command that should be used on this Entity. + // Key is used on WrappedRenderer.AllData#drawCommands. + // Property must exist & match a drawCommand, + // or behaviour is like show: false + drawCmd: 'drawAxis' | 'drawGrid' | 'drawLines' | 'drawMesh'; + + // Whether to actually draw the Entity via nested DrawCommand + show: boolean; + }; +}; + +// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js +export type GeometryEntity = Entity & { + visuals: { + drawCmd: 'drawLines' | 'drawMesh'; + + // Whether the Geometry is transparent. + // Transparents need to be rendered before non-transparents + transparent: boolean; + + // Eventually determines whether to use vColorShaders + // (Geometry must also have colour) or meshShaders + useVertexColors: boolean; + }; + + // The original Geometry used to make the GeometryEntity + geometry: Geometry; +}; + +// @jscad\regl-renderer\src\rendering\commands\drawAxis\index.js +// @jscad\regl-renderer\demo-web.js +export type AxisEntityType = Entity & { + visuals: { + drawCmd: 'drawAxis'; + }; + + xColor?: RGBA; + yColor?: RGBA; + zColor?: RGBA; + size?: number; + alwaysVisible?: boolean; + + // Deprecated + lineWidth?: number; +}; + +// @jscad\regl-renderer\src\rendering\commands\drawGrid\index.js +// @jscad\regl-renderer\demo-web.js +export type GridEntity = Entity & { + visuals: { + drawCmd: 'drawGrid'; + + color?: RGBA; + fadeOut?: boolean; + }; + size?: Numbers2; + ticks?: number; + centered?: boolean; + + // Deprecated + lineWidth?: number; +}; + +// @jscad\regl-renderer\src\rendering\commands\drawGrid\multi.js +// @jscad\regl-renderer\demo-web.js +// @jscad\web\src\ui\views\viewer.js +// @jscad\regl-renderer\src\index.js +export type MultiGridEntityType = Omit & { + // Entity#visuals gets stuffed into the nested DrawCommand as Props. + // The Props get passed on wholesale by makeDrawMultiGrid()'s returned lambda, + // where the following properties then get used + // (rather than while setting up the DrawCommands) + visuals: { + subColor?: RGBA; // As color + }; + + // First number used on the main grid, second number on sub grid + ticks?: [number, number]; +}; + +// @jscad\regl-renderer\src\rendering\commands\drawLines\index.js +export type LinesEntity = Entity & { + visuals: { + drawCmd: 'drawLines'; + }; + + color?: RGBA; +}; + +// @jscad\regl-renderer\src\rendering\commands\drawMesh\index.js +export type MeshEntity = Entity & { + visuals: { + drawCmd: 'drawMesh'; + }; + + dynamicCulling?: boolean; + color?: RGBA; +}; + +export namespace PrepareRender { + // @jscad\regl-renderer\src\rendering\render.js + export type Function = (options: AllOptions) => WrappedRenderer.Function; + + // @jscad\regl-renderer\src\rendering\render.js + export type AllOptions = { + // Used to initialise Regl from the REGL package constructor + glOptions: InitializationOptions; + }; +} + +// When called, the WrappedRenderer creates a main DrawCommand. +// This main DrawCommand then gets called as a scoped command, +// used to create & call more DrawCommands for the #entities. +// Nested DrawCommands get cached +// & may store some Entity properties during setup, +// but properties passed in from Props later may take precedence. +// The main DrawCommand is said to be in charge of injecting most uniforms into +// the Regl context, ie keeping track of all Regl global state +export namespace WrappedRenderer { + // @jscad\regl-renderer\src\rendering\render.js + export type Function = (data: AllData) => void; + + // @jscad\regl-renderer\src\rendering\render.js + // Gets used in the WrappedRenderer. + // Also gets passed as Props into the main DrawCommand, + // where it is used in setup specified by the internal + // renderContext.js/renderWrapper. + // The lambda of the main DrawCommand does not use those Props, rather, + // it references the data in the WrappedRenderer directly. + // Therefore, regl.prop() is not called, + // nor are Props used via the semantic equivalent (context, props) => {}. + // The context passed to that lambda also remains unused + export type AllData = { + rendering?: RenderOptions; + + entities: Entity[]; + + drawCommands: PrepareDrawCommands; + + // Along with all of the relevant Entity's & Entity#visuals's properties, + // this gets stuffed into each nested DrawCommand as Props. + // Messy & needs tidying in regl-renderer + camera: CameraState; + }; + + // @jscad\regl-renderer\src\rendering\renderDefaults.js + export type RenderOptions = { + // Custom value used early on in render.js. + // Clears the canvas to this background colour + background?: RGBA; + + // Default value used directly in V2's entitiesFromSolids.js as the default Geometry colour. + // Default value also used directly in various rendering commands as their shader uniforms' default colour. + // Custom value appears unused + meshColor?: RGBA; + + // Custom value used in various rendering commands as shader uniforms + lightColor?: RGBA; + lightDirection?: VectorXYZ; + ambientLightAmount?: number; + diffuseLightAmount?: number; + specularLightAmount?: number; + materialShininess?: number; // As uMaterialShininess in main DrawCommand + + // Unused + lightPosition?: CoordinatesXYZ; // See also lightDirection + }; + + // There are 4 rendering commands to use in regl-renderer: + // drawAxis, drawGrid, drawLines & drawMesh. + // drawExps appears abandoned. + // Only once passed Regl & an Entity do they return an actual DrawCommand + export type PrepareDrawCommands = Record; + export type PrepareDrawCommandFunction = + | typeof drawCommands + | typeof makeDrawMultiGrid; +} + +// @jscad\regl-renderer\src\geometry-utils-V2\entitiesFromSolids.js +// Converts Solids into Geometries and then into Entities +export namespace EntitiesFromSolids { + export type Function = ( + options?: Options, + ...solids: Solid[] + ) => GeometryEntity[]; + + export type Options = { + // Default colour for entity rendering if the solid does not have one + color?: RGBA; + + // Whether to smooth the normals of 3D solids, rendering a smooth surface + smoothNormals?: boolean; + }; +} diff --git a/src/bundles/csg/utilities.ts b/src/bundles/csg/utilities.ts index 8d1f9840e..e0deb4f25 100644 --- a/src/bundles/csg/utilities.ts +++ b/src/bundles/csg/utilities.ts @@ -1,150 +1,150 @@ -/* [Imports] */ -import { clone, Geom3 } from '@jscad/modeling/src/geometries/geom3'; -import { ModuleContext, ModuleState } from 'js-slang'; -import { ModuleContexts, ReplResult } from '../../typings/type_helpers.js'; -import { AlphaColor, Color, Solid } from './jscad/types.js'; - -/* [Exports] */ -export class Shape implements ReplResult { - constructor(public solid: Solid) {} - - toReplString(): string { - return ''; - } - - clone(): Shape { - return new Shape(clone(this.solid as Geom3)); - } -} - -export class RenderGroup implements ReplResult { - constructor(public canvasNumber: number) {} - - render: boolean = false; - hasGrid: boolean = true; - hasAxis: boolean = true; - - shapes: Shape[] = []; - - toReplString(): string { - return ``; - } -} - -export class RenderGroupManager { - private canvasTracker: number = 1; - private renderGroups: RenderGroup[] = []; - - constructor() { - this.addRenderGroup(); - } - - private addRenderGroup() { - // Passes in canvasTracker as is, then increments it - this.renderGroups.push(new RenderGroup(this.canvasTracker++)); - } - - private getCurrentRenderGroup(): RenderGroup { - return this.renderGroups.at(-1) as RenderGroup; - } - - // Returns the old render group - nextRenderGroup( - oldHasGrid: boolean = false, - oldHasAxis: boolean = false - ): RenderGroup { - let oldRenderGroup: RenderGroup = this.getCurrentRenderGroup(); - oldRenderGroup.render = true; - oldRenderGroup.hasGrid = oldHasGrid; - oldRenderGroup.hasAxis = oldHasAxis; - - this.addRenderGroup(); - - return oldRenderGroup; - } - - storeShape(shape: Shape) { - this.getCurrentRenderGroup().shapes.push(shape); - } - - shouldRender(): boolean { - return this.getGroupsToRender().length > 0; - } - - getGroupsToRender(): RenderGroup[] { - return this.renderGroups.filter( - (renderGroup: RenderGroup) => renderGroup.render - ); - } -} - -export class CsgModuleState implements ModuleState { - private componentCounter: number = 0; - - readonly renderGroupManager: RenderGroupManager; - - constructor() { - this.renderGroupManager = new RenderGroupManager(); - } - - // Returns the new component number - nextComponent() { - return ++this.componentCounter; - } -} - -export function getModuleContext( - moduleContexts: ModuleContexts -): ModuleContext | null { - let potentialModuleContext: ModuleContext | undefined = moduleContexts.get( - 'csg' - ); - return potentialModuleContext ?? null; -} - -export function hexToColor(hex: string): Color { - let regex: RegExp = /^#?(?[\da-f]{2})(?[\da-f]{2})(?[\da-f]{2})$/iu; - let potentialGroups: { [key: string]: string } | undefined = hex.match(regex) - ?.groups; - if (potentialGroups === undefined) return [0, 0, 0]; - let groups: { [key: string]: string } = potentialGroups; - - return [ - parseInt(groups.red, 16) / 0xff, - parseInt(groups.green, 16) / 0xff, - parseInt(groups.blue, 16) / 0xff, - ]; -} - -export function colorToAlphaColor( - color: Color, - opacity: number = 1 -): AlphaColor { - return [...color, opacity]; -} - -export function hexToAlphaColor(hex: string): AlphaColor { - return colorToAlphaColor(hexToColor(hex)); -} - -export function clamp(value: number, lowest: number, highest: number): number { - value = Math.max(value, lowest); - value = Math.min(value, highest); - return value; -} - -// When the object's class and the class used for comparison are from different -// contexts, they may appear identical, but are not recognised as such. -// This check acts as a useful yet not foolproof instanceof -export function looseInstanceof( - object: object | null | undefined, - c: any -): boolean { - const objectName: string | undefined = object?.constructor?.name; - const className: string | undefined = c?.name; - return ( - objectName !== undefined && - className !== undefined && - objectName === className - ); -} +/* [Imports] */ +import { clone, Geom3 } from '@jscad/modeling/src/geometries/geom3'; +import { ModuleContext, ModuleState } from 'js-slang'; +import { ModuleContexts, ReplResult } from '../../typings/type_helpers.js'; +import { AlphaColor, Color, Solid } from './jscad/types.js'; + +/* [Exports] */ +export class Shape implements ReplResult { + constructor(public solid: Solid) {} + + toReplString(): string { + return ''; + } + + clone(): Shape { + return new Shape(clone(this.solid as Geom3)); + } +} + +export class RenderGroup implements ReplResult { + constructor(public canvasNumber: number) {} + + render: boolean = false; + hasGrid: boolean = true; + hasAxis: boolean = true; + + shapes: Shape[] = []; + + toReplString(): string { + return ``; + } +} + +export class RenderGroupManager { + private canvasTracker: number = 1; + private renderGroups: RenderGroup[] = []; + + constructor() { + this.addRenderGroup(); + } + + private addRenderGroup() { + // Passes in canvasTracker as is, then increments it + this.renderGroups.push(new RenderGroup(this.canvasTracker++)); + } + + private getCurrentRenderGroup(): RenderGroup { + return this.renderGroups.at(-1) as RenderGroup; + } + + // Returns the old render group + nextRenderGroup( + oldHasGrid: boolean = false, + oldHasAxis: boolean = false, + ): RenderGroup { + let oldRenderGroup: RenderGroup = this.getCurrentRenderGroup(); + oldRenderGroup.render = true; + oldRenderGroup.hasGrid = oldHasGrid; + oldRenderGroup.hasAxis = oldHasAxis; + + this.addRenderGroup(); + + return oldRenderGroup; + } + + storeShape(shape: Shape) { + this.getCurrentRenderGroup().shapes.push(shape); + } + + shouldRender(): boolean { + return this.getGroupsToRender().length > 0; + } + + getGroupsToRender(): RenderGroup[] { + return this.renderGroups.filter( + (renderGroup: RenderGroup) => renderGroup.render, + ); + } +} + +export class CsgModuleState implements ModuleState { + private componentCounter: number = 0; + + readonly renderGroupManager: RenderGroupManager; + + constructor() { + this.renderGroupManager = new RenderGroupManager(); + } + + // Returns the new component number + nextComponent() { + return ++this.componentCounter; + } +} + +export function getModuleContext( + moduleContexts: ModuleContexts, +): ModuleContext | null { + let potentialModuleContext: ModuleContext | undefined = moduleContexts.get( + 'csg', + ); + return potentialModuleContext ?? null; +} + +export function hexToColor(hex: string): Color { + let regex: RegExp = /^#?(?[\da-f]{2})(?[\da-f]{2})(?[\da-f]{2})$/iu; + let potentialGroups: { [key: string]: string } | undefined = hex.match(regex) + ?.groups; + if (potentialGroups === undefined) return [0, 0, 0]; + let groups: { [key: string]: string } = potentialGroups; + + return [ + parseInt(groups.red, 16) / 0xff, + parseInt(groups.green, 16) / 0xff, + parseInt(groups.blue, 16) / 0xff, + ]; +} + +export function colorToAlphaColor( + color: Color, + opacity: number = 1, +): AlphaColor { + return [...color, opacity]; +} + +export function hexToAlphaColor(hex: string): AlphaColor { + return colorToAlphaColor(hexToColor(hex)); +} + +export function clamp(value: number, lowest: number, highest: number): number { + value = Math.max(value, lowest); + value = Math.min(value, highest); + return value; +} + +// When the object's class and the class used for comparison are from different +// contexts, they may appear identical, but are not recognised as such. +// This check acts as a useful yet not foolproof instanceof +export function looseInstanceof( + object: object | null | undefined, + c: any, +): boolean { + const objectName: string | undefined = object?.constructor?.name; + const className: string | undefined = c?.name; + return ( + objectName !== undefined + && className !== undefined + && objectName === className + ); +} diff --git a/src/bundles/curve/curves_webgl.ts b/src/bundles/curve/curves_webgl.ts index ec596c934..93dccdf8f 100644 --- a/src/bundles/curve/curves_webgl.ts +++ b/src/bundles/curve/curves_webgl.ts @@ -1,457 +1,456 @@ -import { mat4, vec3 } from 'gl-matrix'; -import { ReplResult } from '../../typings/type_helpers'; -import { CurveSpace, DrawMode, ScaleMode } from './types'; - -/** @hidden */ -export const drawnCurves: CurveDrawn[] = []; - -// Vertex shader program -const vsS: string = ` -attribute vec4 aFragColor; -attribute vec4 aVertexPosition; -uniform mat4 uModelViewMatrix; -uniform mat4 uProjectionMatrix; - -varying lowp vec4 aColor; - -void main() { - gl_PointSize = 2.0; - aColor = aFragColor; - gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition; -}`; - -// Fragment shader program -const fsS: string = ` -varying lowp vec4 aColor; -precision mediump float; -void main() { - gl_FragColor = aColor; -}`; - -// ============================================================================= -// Module's Private Functions -// -// This file contains all the private functions used by the Curves module for -// rendering curves. For documentation/tutorials on WebGL API, see -// https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API. -// ============================================================================= - -/** - * Gets shader based on given shader program code. - * - * @param gl - WebGL's rendering context - * @param type - constant describing the type of shader to load - * @param source - source code of the shader - * @returns WebGLShader used to initialize shader program - */ -function loadShader( - gl: WebGLRenderingContext, - type: number, - source: string -): WebGLShader { - const shader = gl.createShader(type); - if (!shader) { - throw new Error('WebGLShader not available.'); - } - gl.shaderSource(shader, source); - gl.compileShader(shader); - return shader; -} - -/** - * Initializes the shader program used by WebGL. - * - * @param gl - WebGL's rendering context - * @param vsSource - vertex shader program code - * @param fsSource - fragment shader program code - * @returns WebGLProgram used for getting AttribLocation and UniformLocation - */ -function initShaderProgram( - gl: WebGLRenderingContext, - vsSource: string, - fsSource: string -): WebGLProgram { - const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); - const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); - const shaderProgram = gl.createProgram(); - if (!shaderProgram) { - throw new Error('Unable to initialize the shader program.'); - } - gl.attachShader(shaderProgram, vertexShader); - gl.attachShader(shaderProgram, fragmentShader); - gl.linkProgram(shaderProgram); - return shaderProgram; -} - -/** - * The return type of the curve generated by the source code in workspace. - * WebGLCanvas in Curves tab captures the return value and calls its init function. - */ -type ProgramInfo = { - program: WebGLProgram; - attribLocations: { - vertexPosition: number; - vertexColor: number; - }; - uniformLocations: { - projectionMatrix: WebGLUniformLocation | null; - modelViewMatrix: WebGLUniformLocation | null; - }; -}; - -type BufferInfo = { - cubeBuffer: WebGLBuffer | null; - curveBuffer: WebGLBuffer | null; - curveColorBuffer: WebGLBuffer | null; -}; - -/** A function that takes in number from 0 to 1 and returns a Point. */ -export type Curve = (u: number) => Point; - -type Color = [r: number, g: number, b: number, t: number]; - -/** Encapsulates 3D point with RGB values. */ -export class Point implements ReplResult { - constructor( - public readonly x: number, - public readonly y: number, - public readonly z: number, - public readonly color: Color - ) {} - - public toReplString = () => - `(${this.x}, ${this.y}, ${this.z}, Color: ${this.color})`; -} - -/** - * Represents a Curve that has been generated from the `generateCurve` - * function. - */ -export class CurveDrawn implements ReplResult { - private renderingContext: WebGLRenderingContext | null; - - private programs: ProgramInfo | null; - - private buffersInfo: BufferInfo | null; - - constructor( - private readonly drawMode: DrawMode, - private readonly numPoints: number, - private readonly space: CurveSpace, - private readonly drawCubeArray: number[], - private readonly curvePosArray: number[], - private readonly curveColorArray: number[] - ) { - this.renderingContext = null; - this.programs = null; - this.buffersInfo = null; - } - - public toReplString = () => ''; - - public is3D = () => this.space === '3D'; - - public init = (canvas: HTMLCanvasElement) => { - this.renderingContext = canvas.getContext('webgl'); - if (!this.renderingContext) { - throw new Error('Rendering context cannot be null.'); - } - const cubeBuffer = this.renderingContext.createBuffer(); - this.renderingContext.bindBuffer( - this.renderingContext.ARRAY_BUFFER, - cubeBuffer - ); - this.renderingContext.bufferData( - this.renderingContext.ARRAY_BUFFER, - new Float32Array(this.drawCubeArray), - this.renderingContext.STATIC_DRAW - ); - - const curveBuffer = this.renderingContext.createBuffer(); - this.renderingContext.bindBuffer( - this.renderingContext.ARRAY_BUFFER, - curveBuffer - ); - this.renderingContext.bufferData( - this.renderingContext.ARRAY_BUFFER, - new Float32Array(this.curvePosArray), - this.renderingContext.STATIC_DRAW - ); - - const curveColorBuffer = this.renderingContext.createBuffer(); - this.renderingContext.bindBuffer( - this.renderingContext.ARRAY_BUFFER, - curveColorBuffer - ); - this.renderingContext.bufferData( - this.renderingContext.ARRAY_BUFFER, - new Float32Array(this.curveColorArray), - this.renderingContext.STATIC_DRAW - ); - - const shaderProgram = initShaderProgram(this.renderingContext, vsS, fsS); - this.programs = { - program: shaderProgram, - attribLocations: { - vertexPosition: this.renderingContext.getAttribLocation( - shaderProgram, - 'aVertexPosition' - ), - vertexColor: this.renderingContext.getAttribLocation( - shaderProgram, - 'aFragColor' - ), - }, - uniformLocations: { - projectionMatrix: this.renderingContext.getUniformLocation( - shaderProgram, - 'uProjectionMatrix' - ), - modelViewMatrix: this.renderingContext.getUniformLocation( - shaderProgram, - 'uModelViewMatrix' - ), - }, - }; - this.buffersInfo = { - cubeBuffer, - curveBuffer, - curveColorBuffer, - }; - }; - - public redraw = (angle: number) => { - if (!this.renderingContext) { - return; - } - - const gl = this.renderingContext; - const itemSize = this.space === '3D' ? 3 : 2; - gl.clearColor(1, 1, 1, 1); // Clear to white, fully opaque - gl.clearDepth(1.0); // Clear everything - gl.enable(gl.DEPTH_TEST); // Enable depth testing - gl.depthFunc(gl.LEQUAL); // Near things obscure far things - // eslint-disable-next-line no-bitwise - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); - - const transMat = mat4.create(); - const projMat = mat4.create(); - - if (this.space === '3D') { - const padding = Math.sqrt(1 / 3.1); - mat4.scale( - transMat, - transMat, - vec3.fromValues(padding, padding, padding) - ); - mat4.translate(transMat, transMat, [0, 0, -5]); - mat4.rotate(transMat, transMat, -(Math.PI / 2), [1, 0, 0]); // axis to rotate around X (static) - mat4.rotate(transMat, transMat, angle, [0, 0, 1]); // axis to rotate around Z (dynamic) - - const fieldOfView = (45 * Math.PI) / 180; - const aspect = gl.canvas.width / gl.canvas.height; - const zNear = 0.01; // Must not be zero, depth testing loses precision proportional to log(zFar / zNear) - const zFar = 50.0; - mat4.perspective(projMat, fieldOfView, aspect, zNear, zFar); - } - - gl.useProgram(this.programs!.program); - gl.uniformMatrix4fv( - this.programs!.uniformLocations.projectionMatrix, - false, - projMat - ); - gl.uniformMatrix4fv( - this.programs!.uniformLocations.modelViewMatrix, - false, - transMat - ); - gl.enableVertexAttribArray(this.programs!.attribLocations.vertexPosition); - gl.enableVertexAttribArray(this.programs!.attribLocations.vertexColor); - - if (this.space === '3D') { - // Draw Cube - gl.bindBuffer(gl.ARRAY_BUFFER, this.buffersInfo!.cubeBuffer); - gl.vertexAttribPointer( - this.programs!.attribLocations.vertexPosition, - 3, - gl.FLOAT, - false, - 0, - 0 - ); - const colors: number[] = []; - for (let i = 0; i < 16; i += 1) { - colors.push(0.6, 0.6, 0.6, 1); - } - const colorBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW); - gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); - gl.drawArrays(gl.LINE_STRIP, 0, 16); - } - // Draw Curve - gl.bindBuffer(gl.ARRAY_BUFFER, this.buffersInfo!.curveBuffer); - gl.vertexAttribPointer( - this.programs!.attribLocations.vertexPosition, - itemSize, - gl.FLOAT, - false, - 0, - 0 - ); - gl.bindBuffer(gl.ARRAY_BUFFER, this.buffersInfo!.curveColorBuffer); - gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); - if (this.drawMode === 'lines') { - gl.drawArrays(gl.LINE_STRIP, 0, this.numPoints + 1); - } else { - gl.drawArrays(gl.POINTS, 0, this.numPoints + 1); - } - }; -} - -// eslint-disable-next-line complexity -export function generateCurve( - scaleMode: ScaleMode, - drawMode: DrawMode, - numPoints: number, - func: Curve, - space: CurveSpace, - isFullView: boolean -) { - const curvePosArray: number[] = []; - const curveColorArray: number[] = []; - const drawCubeArray: number[] = []; - - // initialize the min/max to extreme values - let min_x = Infinity; - let max_x = -Infinity; - let min_y = Infinity; - let max_y = -Infinity; - let min_z = Infinity; - let max_z = -Infinity; - - for (let i = 0; i <= numPoints; i += 1) { - const point = func(i / numPoints); - const x = point.x * 2 - 1; - const y = point.y * 2 - 1; - const z = point.z * 2 - 1; - if (space === '2D') { - curvePosArray.push(x, y); - } else { - curvePosArray.push(x, y, z); - } - const color_r = point.color[0]; - const color_g = point.color[1]; - const color_b = point.color[2]; - const color_a = point.color[3]; - curveColorArray.push(color_r, color_g, color_b, color_a); - min_x = Math.min(min_x, x); - max_x = Math.max(max_x, x); - min_y = Math.min(min_y, y); - max_y = Math.max(max_y, y); - min_z = Math.min(min_z, z); - max_z = Math.max(max_z, z); - } - - // padding for 2d draw_connected_full_view - if (isFullView) { - const horiz_padding = 0.05 * (max_x - min_x); - min_x -= horiz_padding; - max_x += horiz_padding; - const vert_padding = 0.05 * (max_y - min_y); - min_y -= vert_padding; - max_y += vert_padding; - const depth_padding = 0.05 * (max_z - min_z); - min_z -= depth_padding; - max_z += depth_padding; - } - - // box generation, coordinates are added into the array using 4 push - // operations to improve on readability during code editing. - if (space === '3D') { - drawCubeArray.push(-1, 1, 1, -1, -1, 1, -1, -1, -1, -1, 1, -1); - drawCubeArray.push(1, 1, -1, 1, -1, -1, -1, -1, -1, 1, -1, -1); - drawCubeArray.push(1, -1, 1, -1, -1, 1, 1, -1, 1, 1, 1, 1); - drawCubeArray.push(-1, 1, 1, -1, 1, -1, 1, 1, -1, 1, 1, 1); - } else { - min_z = 0; - max_z = 0; - } - - if (scaleMode === 'fit') { - const center = [ - (min_x + max_x) / 2, - (min_y + max_y) / 2, - (min_z + max_z) / 2, - ]; - let scale = Math.max(max_x - min_x, max_y - min_y, max_z - min_z); - scale = scale === 0 ? 1 : scale; - if (space === '3D') { - for (let i = 0; i < curvePosArray.length; i += 1) { - if (i % 3 === 0) { - curvePosArray[i] -= center[0]; - curvePosArray[i] /= scale / 2; - } else if (i % 3 === 1) { - curvePosArray[i] -= center[1]; - curvePosArray[i] /= scale / 2; - } else { - curvePosArray[i] -= center[2]; - curvePosArray[i] /= scale / 2; - } - } - } else { - for (let i = 0; i < curvePosArray.length; i += 1) { - if (i % 2 === 0) { - curvePosArray[i] -= center[0]; - curvePosArray[i] /= scale / 2; - } else { - curvePosArray[i] -= center[1]; - curvePosArray[i] /= scale / 2; - } - } - } - } else if (scaleMode === 'stretch') { - const center = [ - (min_x + max_x) / 2, - (min_y + max_y) / 2, - (min_z + max_z) / 2, - ]; - const x_scale = max_x === min_x ? 1 : max_x - min_x; - const y_scale = max_y === min_y ? 1 : max_y - min_y; - const z_scale = max_z === min_z ? 1 : max_z - min_z; - if (space === '3D') { - for (let i = 0; i < curvePosArray.length; i += 1) { - if (i % 3 === 0) { - curvePosArray[i] -= center[0]; - curvePosArray[i] /= x_scale / 2; - } else if (i % 3 === 1) { - curvePosArray[i] -= center[1]; - curvePosArray[i] /= y_scale / 2; - } else { - curvePosArray[i] -= center[2]; - curvePosArray[i] /= z_scale / 2; - } - } - } else { - for (let i = 0; i < curvePosArray.length; i += 1) { - if (i % 2 === 0) { - curvePosArray[i] -= center[0]; - curvePosArray[i] /= x_scale / 2; - } else { - curvePosArray[i] -= center[1]; - curvePosArray[i] /= y_scale / 2; - } - } - } - } - - return new CurveDrawn( - drawMode, - numPoints, - space, - drawCubeArray, - curvePosArray, - curveColorArray - ); -} +import { mat4, vec3 } from 'gl-matrix'; +import { ReplResult } from '../../typings/type_helpers'; +import { CurveSpace, DrawMode, ScaleMode } from './types'; + +/** @hidden */ +export const drawnCurves: CurveDrawn[] = []; + +// Vertex shader program +const vsS: string = ` +attribute vec4 aFragColor; +attribute vec4 aVertexPosition; +uniform mat4 uModelViewMatrix; +uniform mat4 uProjectionMatrix; + +varying lowp vec4 aColor; + +void main() { + gl_PointSize = 2.0; + aColor = aFragColor; + gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition; +}`; + +// Fragment shader program +const fsS: string = ` +varying lowp vec4 aColor; +precision mediump float; +void main() { + gl_FragColor = aColor; +}`; + +// ============================================================================= +// Module's Private Functions +// +// This file contains all the private functions used by the Curves module for +// rendering curves. For documentation/tutorials on WebGL API, see +// https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API. +// ============================================================================= + +/** + * Gets shader based on given shader program code. + * + * @param gl - WebGL's rendering context + * @param type - constant describing the type of shader to load + * @param source - source code of the shader + * @returns WebGLShader used to initialize shader program + */ +function loadShader( + gl: WebGLRenderingContext, + type: number, + source: string, +): WebGLShader { + const shader = gl.createShader(type); + if (!shader) { + throw new Error('WebGLShader not available.'); + } + gl.shaderSource(shader, source); + gl.compileShader(shader); + return shader; +} + +/** + * Initializes the shader program used by WebGL. + * + * @param gl - WebGL's rendering context + * @param vsSource - vertex shader program code + * @param fsSource - fragment shader program code + * @returns WebGLProgram used for getting AttribLocation and UniformLocation + */ +function initShaderProgram( + gl: WebGLRenderingContext, + vsSource: string, + fsSource: string, +): WebGLProgram { + const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); + const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); + const shaderProgram = gl.createProgram(); + if (!shaderProgram) { + throw new Error('Unable to initialize the shader program.'); + } + gl.attachShader(shaderProgram, vertexShader); + gl.attachShader(shaderProgram, fragmentShader); + gl.linkProgram(shaderProgram); + return shaderProgram; +} + +/** + * The return type of the curve generated by the source code in workspace. + * WebGLCanvas in Curves tab captures the return value and calls its init function. + */ +type ProgramInfo = { + program: WebGLProgram; + attribLocations: { + vertexPosition: number; + vertexColor: number; + }; + uniformLocations: { + projectionMatrix: WebGLUniformLocation | null; + modelViewMatrix: WebGLUniformLocation | null; + }; +}; + +type BufferInfo = { + cubeBuffer: WebGLBuffer | null; + curveBuffer: WebGLBuffer | null; + curveColorBuffer: WebGLBuffer | null; +}; + +/** A function that takes in number from 0 to 1 and returns a Point. */ +export type Curve = (u: number) => Point; + +type Color = [r: number, g: number, b: number, t: number]; + +/** Encapsulates 3D point with RGB values. */ +export class Point implements ReplResult { + constructor( + public readonly x: number, + public readonly y: number, + public readonly z: number, + public readonly color: Color, + ) {} + + public toReplString = () => `(${this.x}, ${this.y}, ${this.z}, Color: ${this.color})`; +} + +/** + * Represents a Curve that has been generated from the `generateCurve` + * function. + */ +export class CurveDrawn implements ReplResult { + private renderingContext: WebGLRenderingContext | null; + + private programs: ProgramInfo | null; + + private buffersInfo: BufferInfo | null; + + constructor( + private readonly drawMode: DrawMode, + private readonly numPoints: number, + private readonly space: CurveSpace, + private readonly drawCubeArray: number[], + private readonly curvePosArray: number[], + private readonly curveColorArray: number[], + ) { + this.renderingContext = null; + this.programs = null; + this.buffersInfo = null; + } + + public toReplString = () => ''; + + public is3D = () => this.space === '3D'; + + public init = (canvas: HTMLCanvasElement) => { + this.renderingContext = canvas.getContext('webgl'); + if (!this.renderingContext) { + throw new Error('Rendering context cannot be null.'); + } + const cubeBuffer = this.renderingContext.createBuffer(); + this.renderingContext.bindBuffer( + this.renderingContext.ARRAY_BUFFER, + cubeBuffer, + ); + this.renderingContext.bufferData( + this.renderingContext.ARRAY_BUFFER, + new Float32Array(this.drawCubeArray), + this.renderingContext.STATIC_DRAW, + ); + + const curveBuffer = this.renderingContext.createBuffer(); + this.renderingContext.bindBuffer( + this.renderingContext.ARRAY_BUFFER, + curveBuffer, + ); + this.renderingContext.bufferData( + this.renderingContext.ARRAY_BUFFER, + new Float32Array(this.curvePosArray), + this.renderingContext.STATIC_DRAW, + ); + + const curveColorBuffer = this.renderingContext.createBuffer(); + this.renderingContext.bindBuffer( + this.renderingContext.ARRAY_BUFFER, + curveColorBuffer, + ); + this.renderingContext.bufferData( + this.renderingContext.ARRAY_BUFFER, + new Float32Array(this.curveColorArray), + this.renderingContext.STATIC_DRAW, + ); + + const shaderProgram = initShaderProgram(this.renderingContext, vsS, fsS); + this.programs = { + program: shaderProgram, + attribLocations: { + vertexPosition: this.renderingContext.getAttribLocation( + shaderProgram, + 'aVertexPosition', + ), + vertexColor: this.renderingContext.getAttribLocation( + shaderProgram, + 'aFragColor', + ), + }, + uniformLocations: { + projectionMatrix: this.renderingContext.getUniformLocation( + shaderProgram, + 'uProjectionMatrix', + ), + modelViewMatrix: this.renderingContext.getUniformLocation( + shaderProgram, + 'uModelViewMatrix', + ), + }, + }; + this.buffersInfo = { + cubeBuffer, + curveBuffer, + curveColorBuffer, + }; + }; + + public redraw = (angle: number) => { + if (!this.renderingContext) { + return; + } + + const gl = this.renderingContext; + const itemSize = this.space === '3D' ? 3 : 2; + gl.clearColor(1, 1, 1, 1); // Clear to white, fully opaque + gl.clearDepth(1.0); // Clear everything + gl.enable(gl.DEPTH_TEST); // Enable depth testing + gl.depthFunc(gl.LEQUAL); // Near things obscure far things + // eslint-disable-next-line no-bitwise + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + const transMat = mat4.create(); + const projMat = mat4.create(); + + if (this.space === '3D') { + const padding = Math.sqrt(1 / 3.1); + mat4.scale( + transMat, + transMat, + vec3.fromValues(padding, padding, padding), + ); + mat4.translate(transMat, transMat, [0, 0, -5]); + mat4.rotate(transMat, transMat, -(Math.PI / 2), [1, 0, 0]); // axis to rotate around X (static) + mat4.rotate(transMat, transMat, angle, [0, 0, 1]); // axis to rotate around Z (dynamic) + + const fieldOfView = (45 * Math.PI) / 180; + const aspect = gl.canvas.width / gl.canvas.height; + const zNear = 0.01; // Must not be zero, depth testing loses precision proportional to log(zFar / zNear) + const zFar = 50.0; + mat4.perspective(projMat, fieldOfView, aspect, zNear, zFar); + } + + gl.useProgram(this.programs!.program); + gl.uniformMatrix4fv( + this.programs!.uniformLocations.projectionMatrix, + false, + projMat, + ); + gl.uniformMatrix4fv( + this.programs!.uniformLocations.modelViewMatrix, + false, + transMat, + ); + gl.enableVertexAttribArray(this.programs!.attribLocations.vertexPosition); + gl.enableVertexAttribArray(this.programs!.attribLocations.vertexColor); + + if (this.space === '3D') { + // Draw Cube + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffersInfo!.cubeBuffer); + gl.vertexAttribPointer( + this.programs!.attribLocations.vertexPosition, + 3, + gl.FLOAT, + false, + 0, + 0, + ); + const colors: number[] = []; + for (let i = 0; i < 16; i += 1) { + colors.push(0.6, 0.6, 0.6, 1); + } + const colorBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW); + gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); + gl.drawArrays(gl.LINE_STRIP, 0, 16); + } + // Draw Curve + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffersInfo!.curveBuffer); + gl.vertexAttribPointer( + this.programs!.attribLocations.vertexPosition, + itemSize, + gl.FLOAT, + false, + 0, + 0, + ); + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffersInfo!.curveColorBuffer); + gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); + if (this.drawMode === 'lines') { + gl.drawArrays(gl.LINE_STRIP, 0, this.numPoints + 1); + } else { + gl.drawArrays(gl.POINTS, 0, this.numPoints + 1); + } + }; +} + +// eslint-disable-next-line complexity +export function generateCurve( + scaleMode: ScaleMode, + drawMode: DrawMode, + numPoints: number, + func: Curve, + space: CurveSpace, + isFullView: boolean, +) { + const curvePosArray: number[] = []; + const curveColorArray: number[] = []; + const drawCubeArray: number[] = []; + + // initialize the min/max to extreme values + let min_x = Infinity; + let max_x = -Infinity; + let min_y = Infinity; + let max_y = -Infinity; + let min_z = Infinity; + let max_z = -Infinity; + + for (let i = 0; i <= numPoints; i += 1) { + const point = func(i / numPoints); + const x = point.x * 2 - 1; + const y = point.y * 2 - 1; + const z = point.z * 2 - 1; + if (space === '2D') { + curvePosArray.push(x, y); + } else { + curvePosArray.push(x, y, z); + } + const color_r = point.color[0]; + const color_g = point.color[1]; + const color_b = point.color[2]; + const color_a = point.color[3]; + curveColorArray.push(color_r, color_g, color_b, color_a); + min_x = Math.min(min_x, x); + max_x = Math.max(max_x, x); + min_y = Math.min(min_y, y); + max_y = Math.max(max_y, y); + min_z = Math.min(min_z, z); + max_z = Math.max(max_z, z); + } + + // padding for 2d draw_connected_full_view + if (isFullView) { + const horiz_padding = 0.05 * (max_x - min_x); + min_x -= horiz_padding; + max_x += horiz_padding; + const vert_padding = 0.05 * (max_y - min_y); + min_y -= vert_padding; + max_y += vert_padding; + const depth_padding = 0.05 * (max_z - min_z); + min_z -= depth_padding; + max_z += depth_padding; + } + + // box generation, coordinates are added into the array using 4 push + // operations to improve on readability during code editing. + if (space === '3D') { + drawCubeArray.push(-1, 1, 1, -1, -1, 1, -1, -1, -1, -1, 1, -1); + drawCubeArray.push(1, 1, -1, 1, -1, -1, -1, -1, -1, 1, -1, -1); + drawCubeArray.push(1, -1, 1, -1, -1, 1, 1, -1, 1, 1, 1, 1); + drawCubeArray.push(-1, 1, 1, -1, 1, -1, 1, 1, -1, 1, 1, 1); + } else { + min_z = 0; + max_z = 0; + } + + if (scaleMode === 'fit') { + const center = [ + (min_x + max_x) / 2, + (min_y + max_y) / 2, + (min_z + max_z) / 2, + ]; + let scale = Math.max(max_x - min_x, max_y - min_y, max_z - min_z); + scale = scale === 0 ? 1 : scale; + if (space === '3D') { + for (let i = 0; i < curvePosArray.length; i += 1) { + if (i % 3 === 0) { + curvePosArray[i] -= center[0]; + curvePosArray[i] /= scale / 2; + } else if (i % 3 === 1) { + curvePosArray[i] -= center[1]; + curvePosArray[i] /= scale / 2; + } else { + curvePosArray[i] -= center[2]; + curvePosArray[i] /= scale / 2; + } + } + } else { + for (let i = 0; i < curvePosArray.length; i += 1) { + if (i % 2 === 0) { + curvePosArray[i] -= center[0]; + curvePosArray[i] /= scale / 2; + } else { + curvePosArray[i] -= center[1]; + curvePosArray[i] /= scale / 2; + } + } + } + } else if (scaleMode === 'stretch') { + const center = [ + (min_x + max_x) / 2, + (min_y + max_y) / 2, + (min_z + max_z) / 2, + ]; + const x_scale = max_x === min_x ? 1 : max_x - min_x; + const y_scale = max_y === min_y ? 1 : max_y - min_y; + const z_scale = max_z === min_z ? 1 : max_z - min_z; + if (space === '3D') { + for (let i = 0; i < curvePosArray.length; i += 1) { + if (i % 3 === 0) { + curvePosArray[i] -= center[0]; + curvePosArray[i] /= x_scale / 2; + } else if (i % 3 === 1) { + curvePosArray[i] -= center[1]; + curvePosArray[i] /= y_scale / 2; + } else { + curvePosArray[i] -= center[2]; + curvePosArray[i] /= z_scale / 2; + } + } + } else { + for (let i = 0; i < curvePosArray.length; i += 1) { + if (i % 2 === 0) { + curvePosArray[i] -= center[0]; + curvePosArray[i] /= x_scale / 2; + } else { + curvePosArray[i] -= center[1]; + curvePosArray[i] /= y_scale / 2; + } + } + } + } + + return new CurveDrawn( + drawMode, + numPoints, + space, + drawCubeArray, + curvePosArray, + curveColorArray, + ); +} diff --git a/src/bundles/curve/functions.ts b/src/bundles/curve/functions.ts index 206d9dcb1..a23f77879 100644 --- a/src/bundles/curve/functions.ts +++ b/src/bundles/curve/functions.ts @@ -1,843 +1,845 @@ -/** - * drawing *curves*, i.e. collections of *points*, on a canvas in a tools tab - * - * A *point* is defined by its coordinates (x, y and z), and the color assigned to - * it (r, g, and b). A few constructors for points is given, for example - * `make_color_point`. Selectors allow access to the coordinates and color - * components, for example `x_of`. - * - * A *curve* is a - * unary function which takes a number argument within the unit interval `[0,1]` - * and returns a point. If `C` is a curve, then the starting point of the curve - * is always `C(0)`, and the ending point is always `C(1)`. - * - * A *curve transformation* is a function that takes a curve as argument and - * returns a curve. Examples of curve transformations are `scale` and `translate`. - * - * A *curve drawer* is function that takes a number argument and returns - * a function that takes a curve as argument and visualises it in the output screen is - * shown in the Source Academy in the tab with the "Curves Canvas" icon (image). - * The following [example](https://share.sourceacademy.org/unitcircle) uses - * the curve drawer `draw_connected_full_view` to display a curve called - * `unit_circle`. - * ``` - * import { make_point, draw_connected_full_view } from "curve"; - * function unit_circle(t) { - * return make_point(math_sin(2 * math_PI * t), - * math_cos(2 * math_PI * t)); - * } - * draw_connected_full_view(100)(unit_circle); - * ``` - * draws a full circle in the display tab. - * - * @module curve - * @author Lee Zheng Han - * @author Ng Yong Xiang - */ - -/* eslint-disable @typescript-eslint/naming-convention */ -import { Curve, CurveDrawn, generateCurve, Point } from './curves_webgl'; -import { - AnimatedCurve, - CurveAnimation, - CurveSpace, - CurveTransformer, - DrawMode, - RenderFunction, - ScaleMode, -} from './types'; - -/** @hidden */ -export const drawnCurves: (CurveDrawn | AnimatedCurve)[] = []; - -function createDrawFunction( - scaleMode: ScaleMode, - drawMode: DrawMode, - space: CurveSpace, - isFullView: boolean -): (numPoints: number) => RenderFunction { - return (numPoints: number) => { - const func = (curve) => { - const curveDrawn = generateCurve( - scaleMode, - drawMode, - numPoints, - curve, - space, - isFullView - ); - - if ( - (curve as any).shouldAppend === undefined || - (curve as any).shouldAppend - ) { - drawnCurves.push(curveDrawn); - } - - return curveDrawn; - }; - // Because the draw functions are actually functions - // we need hacky workarounds like these to pass information around - func.is3D = space === '3D'; - return func; - }; -} - -// ============================================================================= -// Module's Exposed Functions -// -// This file only includes the implementation and documentation of exposed -// functions of the module. For private functions dealing with the browser's -// graphics library context, see './curves_webgl.ts'. -// ============================================================================= - -/** - * Returns a function that turns a given Curve into a Drawing, by sampling the - * Curve at `num` sample points and connecting each pair with a line. - * The parts between (0,0) and (1,1) of the resulting Drawing are shown in the window. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_connected(100)(t => make_point(t, t)); - * ``` - */ -export const draw_connected = createDrawFunction('none', 'lines', '2D', false); - -/** - * Returns a function that turns a given Curve into a Drawing, by sampling the - * Curve at `num` sample points and connecting each pair with a line. The Drawing is - * translated and stretched/shrunk to show the full curve and maximize its width - * and height, with some padding. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_connected_full_view(100)(t => make_point(t, t)); - * ``` - */ -export const draw_connected_full_view = createDrawFunction( - 'stretch', - 'lines', - '2D', - true -); - -/** - * Returns a function that turns a given Curve into a Drawing, by sampling the - * Curve at `num` sample points and connecting each pair with a line. The Drawing - * is translated and scaled proportionally to show the full curve and maximize - * its size, with some padding. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_connected_full_view_proportional(100)(t => make_point(t, t)); - * ``` - */ -export const draw_connected_full_view_proportional = createDrawFunction( - 'fit', - 'lines', - '2D', - true -); - -/** - * Returns a function that turns a given Curve into a Drawing, by sampling the - * Curve at `num` sample points. The Drawing consists of isolated - * points, and does not connect them. The parts between (0,0) and (1,1) of the - * resulting Drawing are shown in the window. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1,there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_points(100)(t => make_point(t, t)); - * ``` - */ -export const draw_points = createDrawFunction('none', 'points', '2D', false); - -/** - * Returns a function that turns a given Curve into a Drawing, by sampling the - * Curve at `num` sample points. The Drawing consists of isolated - * points, and does not connect them. The Drawing is translated and - * stretched/shrunk to show the full curve and maximize its width and height, - * with some padding. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_points_full_view(100)(t => make_point(t, t)); - * ``` - */ -export const draw_points_full_view = createDrawFunction( - 'stretch', - 'points', - '2D', - true -); - -/** - * Returns a function that turns a given Curve into a Drawing, by sampling the - * Curve at `num` sample points. The Drawing consists of isolated - * points, and does not connect them. The Drawing is translated and scaled - * proportionally with its size maximized to fit entirely inside the window, - * with some padding. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_points_full_view_proportional(100)(t => make_point(t, t)); - * ``` - */ -export const draw_points_full_view_proportional = createDrawFunction( - 'fit', - 'points', - '2D', - true -); - -/** - * Returns a function that turns a given 3D Curve into a Drawing, by sampling - * the 3D Curve at `num` sample points and connecting each pair with - * a line. The parts between (0,0,0) and (1,1,1) of the resulting Drawing are - * shown within the unit cube. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_3D_connected(100)(t => make_3D_point(t, t, t)); - * ``` - */ -export const draw_3D_connected = createDrawFunction( - 'none', - 'lines', - '3D', - false -); - -/** - * Returns a function that turns a given 3D Curve into a Drawing, by sampling - * the 3D Curve at `num` sample points and connecting each pair with - * a line. The Drawing is translated and stretched/shrunk to show the full - * curve and maximize its width and height within the cube. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_3D_connected_full_view(100)(t => make_3D_point(t, t, t)); - * ``` - */ -export const draw_3D_connected_full_view = createDrawFunction( - 'stretch', - 'lines', - '3D', - false -); - -/** - * Returns a function that turns a given 3D Curve into a Drawing, by sampling - * the 3D Curve at `num` sample points and connecting each pair with - * a line. The Drawing is translated and scaled proportionally with its size - * maximized to fit entirely inside the cube. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_3D_connected_full_view_proportional(100)(t => make_3D_point(t, t, t)); - * ``` - */ -export const draw_3D_connected_full_view_proportional = createDrawFunction( - 'fit', - 'lines', - '3D', - false -); - -/** - * Returns a function that turns a given 3D Curve into a Drawing, by sampling - * the 3D Curve at `num` sample points. The Drawing consists of - * isolated points, and does not connect them. The parts between (0,0,0) - * and (1,1,1) of the resulting Drawing are shown within the unit cube. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_3D_points(100)(t => make_3D_point(t, t, t)); - * ``` - */ -export const draw_3D_points = createDrawFunction('none', 'points', '3D', false); - -/** - * Returns a function that turns a given 3D Curve into a Drawing, by sampling - * the 3D Curve at `num` sample points. The Drawing consists of - * isolated points, and does not connect them. The Drawing is translated and - * stretched/shrunk to maximize its size to fit entirely inside the cube. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_3D_points_full_view(100)(t => make_3D_point(t, t, t)); - * ``` - */ -export const draw_3D_points_full_view = createDrawFunction( - 'stretch', - 'points', - '3D', - false -); - -/** - * Returns a function that turns a given 3D Curve into a Drawing, by sampling - * the 3D Curve at `num` sample points. The Drawing consists of - * isolated points, and does not connect them. The Drawing is translated and - * scaled proportionally with its size maximized to fit entirely inside the cube. - * - * @param num determines the number of points, lower than 65535, to be sampled. - * Including 0 and 1, there are `num + 1` evenly spaced sample points - * @return function of type Curve → Drawing - * @example - * ``` - * draw_3D_points_full_view_proportional(100)(t => make_3D_point(t, t, t)); - * ``` - */ -export const draw_3D_points_full_view_proportional = createDrawFunction( - 'fit', - 'points', - '3D', - false -); - -/** - * Makes a Point with given x and y coordinates. - * - * @param x x-coordinate of new point - * @param y y-coordinate of new point - * @returns with x and y as coordinates - * @example - * ``` - * const point = make_point(0.5, 0.5); - * ``` - */ -export function make_point(x: number, y: number): Point { - return new Point(x, y, 0, [0, 0, 0, 1]); -} - -/** - * Makes a 3D Point with given x, y and z coordinates. - * - * @param x x-coordinate of new point - * @param y y-coordinate of new point - * @param z z-coordinate of new point - * @returns with x, y and z as coordinates - * @example - * ``` - * const point = make_3D_point(0.5, 0.5, 0.5); - * ``` - */ -export function make_3D_point(x: number, y: number, z: number): Point { - return new Point(x, y, z, [0, 0, 0, 1]); -} - -/** - * Makes a color Point with given x and y coordinates, and RGB values ranging - * from 0 to 255. Any input lower than 0 for RGB will be rounded up to 0, and - * any input higher than 255 will be rounded down to 255. - * - * @param x x-coordinate of new point - * @param y y-coordinate of new point - * @param r red component of new point - * @param g green component of new point - * @param b blue component of new point - * @returns with x and y as coordinates, and r, g and b as RGB values - * @example - * ``` - * const redPoint = make_color_point(0.5, 0.5, 255, 0, 0); - * ``` - */ -export function make_color_point( - x: number, - y: number, - r: number, - g: number, - b: number -): Point { - return new Point(x, y, 0, [r / 255, g / 255, b / 255, 1]); -} - -/** - * Makes a 3D color Point with given x, y and z coordinates, and RGB values - * ranging from 0 to 255. Any input lower than 0 for RGB will be rounded up to - * 0, and any input higher than 255 will be rounded down to 255. - * - * @param x x-coordinate of new point - * @param y y-coordinate of new point - * @param z z-coordinate of new point - * @param r red component of new point - * @param g green component of new point - * @param b blue component of new point - * @returns with x, y and z as coordinates, and r, g and b as RGB values - * @example - * ``` - * const redPoint = make_color_point(0.5, 0.5, 0.5, 255, 0, 0); - * ``` - */ -export function make_3D_color_point( - x: number, - y: number, - z: number, - r: number, - g: number, - b: number -): Point { - return new Point(x, y, z, [r / 255, g / 255, b / 255, 1]); -} - -/** - * Retrieves the x-coordinate of a given Point. - * - * @param p given point - * @returns x-coordinate of the Point - * @example - * ``` - * const point = make_color_point(1, 2, 3, 50, 100, 150); - * x_of(point); // Returns 1 - * ``` - */ -export function x_of(pt: Point): number { - return pt.x; -} - -/** - * Retrieves the y-coordinate of a given Point. - * - * @param p given point - * @returns y-coordinate of the Point - * @example - * ``` - * const point = make_color_point(1, 2, 3, 50, 100, 150); - * y_of(point); // Returns 2 - * ``` - */ -export function y_of(pt: Point): number { - return pt.y; -} - -/** - * Retrieves the z-coordinate of a given Point. - * - * @param p given point - * @returns z-coordinate of the Point - * @example - * ``` - * const point = make_color_point(1, 2, 3, 50, 100, 150); - * z_of(point); // Returns 3 - * ``` - */ -export function z_of(pt: Point): number { - return pt.z; -} - -/** - * Retrieves the red component of a given Point. - * - * @param p given point - * @returns Red component of the Point - * @example - * ``` - * const point = make_color_point(1, 2, 3, 50, 100, 150); - * r_of(point); // Returns 50 - * ``` - */ -export function r_of(pt: Point): number { - return pt.color[0] * 255; -} - -/** - * Retrieves the green component of a given Point. - * - * @param p given point - * @returns Green component of the Point - * @example - * ``` - * const point = make_color_point(1, 2, 3, 50, 100, 150); - * g_of(point); // Returns 100 - * ``` - */ -export function g_of(pt: Point): number { - return pt.color[1] * 255; -} - -/** - * Retrieves the blue component of a given Point. - * - * @param p given point - * @returns Blue component of the Point - * @example - * ``` - * const point = make_color_point(1, 2, 3, 50, 100, 150); - * b_of(point); // Returns 150 - * ``` - */ -export function b_of(pt: Point): number { - return pt.color[2] * 255; -} - -/** - * This function is a Curve transformation: a function from a Curve to a Curve. - * The points of the result Curve are the same points as the points of the - * original Curve, but in reverse: The result Curve applied to 0 is the original - * Curve applied to 1 and vice versa. - * - * @param original original Curve - * @returns result Curve - */ -export function invert(curve: Curve): Curve { - return (t: number) => curve(1 - t); -} - -/** - * This function returns a Curve transformation: It takes an x-value x0, a - * y-value y0 and a z-value z0, as arguments and - * returns a Curve transformation that takes a Curve as argument and returns a - * new Curve, by translating the original by x0 in x-direction, y0 in - * y-direction and z0 in z-direction. - * - * @param x0 x-value - * @param y0 y-value - * @param z0 z-value - * @returns Curve transformation - */ -export function translate( - x0: number, - y0: number, - z0: number -): CurveTransformer { - return (curve: Curve) => { - const transformation = (cf: Curve) => (t: number) => { - const a = x0 === undefined ? 0 : x0; - const b = y0 === undefined ? 0 : y0; - const c = z0 === undefined ? 0 : z0; - const ct: Point = cf(t); - return make_3D_color_point( - a + x_of(ct), - b + y_of(ct), - c + z_of(ct), - r_of(ct), - g_of(ct), - b_of(ct) - ); - }; - return transformation(curve); - }; -} - -/** - * This function takes 3 angles, a, b and c in radians as parameter - * and returns a Curve transformation: a function that takes a Curve as argument - * and returns a new Curve, which is the original Curve rotated - * extrinsically with Euler angles (a, b, c) about x, y, - * and z axes. - * - * @param a given angle - * @param b given angle - * @param c given angle - * @returns function that takes a Curve and returns a Curve - */ -export function rotate_around_origin( - theta1: number, - theta2: number, - theta3: number -): CurveTransformer { - if (theta3 === undefined && theta1 !== undefined && theta2 !== undefined) { - // 2 args - throw new Error('Expected 1 or 3 arguments, but received 2'); - } else if ( - theta1 !== undefined && - theta2 === undefined && - theta3 === undefined - ) { - // 1 args - const cth = Math.cos(theta1); - const sth = Math.sin(theta1); - return (curve: Curve) => { - const transformation = (c: Curve) => (t: number) => { - const ct = c(t); - const x = x_of(ct); - const y = y_of(ct); - const z = z_of(ct); - return make_3D_color_point( - cth * x - sth * y, - sth * x + cth * y, - z, - r_of(ct), - g_of(ct), - b_of(ct) - ); - }; - return transformation(curve); - }; - } else { - const cthx = Math.cos(theta1); - const sthx = Math.sin(theta1); - const cthy = Math.cos(theta2); - const sthy = Math.sin(theta2); - const cthz = Math.cos(theta3); - const sthz = Math.sin(theta3); - return (curve: Curve) => { - const transformation = (c: Curve) => (t: number) => { - const ct = c(t); - const coord = [x_of(ct), y_of(ct), z_of(ct)]; - const mat = [ - [ - cthz * cthy, - cthz * sthy * sthx - sthz * cthx, - cthz * sthy * cthx + sthz * sthx, - ], - [ - sthz * cthy, - sthz * sthy * sthx + cthz * cthx, - sthz * sthy * cthx - cthz * sthx, - ], - [-sthy, cthy * sthx, cthy * cthx], - ]; - let xf = 0; - let yf = 0; - let zf = 0; - for (let i = 0; i < 3; i += 1) { - xf += mat[0][i] * coord[i]; - yf += mat[1][i] * coord[i]; - zf += mat[2][i] * coord[i]; - } - return make_3D_color_point(xf, yf, zf, r_of(ct), g_of(ct), b_of(ct)); - }; - return transformation(curve); - }; - } -} - -/** - * This function takes scaling factors `a`, `b` and - * `c`, as arguments and returns a - * Curve transformation that scales a given Curve by `a` in - * x-direction, `b` in y-direction and `c` in z-direction. - * - * @param a scaling factor in x-direction - * @param b scaling factor in y-direction - * @param c scaling factor in z-direction - * @returns function that takes a Curve and returns a Curve - */ -export function scale(a: number, b: number, c: number): CurveTransformer { - return (curve) => { - const transformation = (cf: Curve) => (t: number) => { - const ct = cf(t); - const a1 = a === undefined ? 1 : a; - const b1 = b === undefined ? 1 : b; - const c1 = c === undefined ? 1 : c; - return make_3D_color_point( - a1 * x_of(ct), - b1 * y_of(ct), - c1 * z_of(ct), - r_of(ct), - g_of(ct), - b_of(ct) - ); - }; - return transformation(curve); - }; -} - -/** - * This function takes a scaling factor s argument and returns a Curve - * transformation that scales a given Curve by s in x, y and z direction. - * - * @param s scaling factor - * @returns function that takes a Curve and returns a Curve - */ -export function scale_proportional(s: number): CurveTransformer { - return scale(s, s, s); -} - -/** - * This function is a Curve transformation: It takes a Curve as argument and - * returns a new Curve, as follows. A Curve is in standard position if it - * starts at (0,0) ends at (1,0). This function puts the given Curve in - * standard position by rigidly translating it so its start Point is at the - * origin (0,0), then rotating it about the origin to put its endpoint on the - * x axis, then scaling it to put the endpoint at (1,0). Behavior is unspecified - * on closed Curves where start-point equal end-point. - * - * @param curve given Curve - * @returns result Curve - */ -export function put_in_standard_position(curve: Curve): Curve { - const start_point = curve(0); - const curve_started_at_origin = translate( - -x_of(start_point), - -y_of(start_point), - 0 - )(curve); - const new_end_point = curve_started_at_origin(1); - const theta = Math.atan2(y_of(new_end_point), x_of(new_end_point)); - const curve_ended_at_x_axis = rotate_around_origin( - 0, - 0, - -theta - )(curve_started_at_origin); - const end_point_on_x_axis = x_of(curve_ended_at_x_axis(1)); - return scale_proportional(1 / end_point_on_x_axis)(curve_ended_at_x_axis); -} - -/** - * This function is a binary Curve operator: It takes two Curves as arguments - * and returns a new Curve. The two Curves are combined by using the full first - * Curve for the first portion of the result and by using the full second Curve - * for the second portion of the result. The second Curve is not changed, and - * therefore there might be a big jump in the middle of the result Curve. - * - * @param curve1 first Curve - * @param curve2 second Curve - * @returns result Curve - */ -export function connect_rigidly(curve1: Curve, curve2: Curve): Curve { - return (t) => (t < 1 / 2 ? curve1(2 * t) : curve2(2 * t - 1)); -} - -/** - * This function is a binary Curve operator: It takes two Curves as arguments - * and returns a new Curve. The two Curves are combined by using the full first - * Curve for the first portion of the result and by using the full second Curve - * for the second portion of the result. The second Curve is translated such - * that its point at fraction 0 is the same as the Point of the first Curve at - * fraction 1. - * - * @param curve1 first Curve - * @param curve2 second Curve - * @returns result Curve - */ -export function connect_ends(curve1: Curve, curve2: Curve): Curve { - const startPointOfCurve2 = curve2(0); - const endPointOfCurve1 = curve1(1); - return connect_rigidly( - curve1, - translate( - x_of(endPointOfCurve1) - x_of(startPointOfCurve2), - y_of(endPointOfCurve1) - y_of(startPointOfCurve2), - z_of(endPointOfCurve1) - z_of(startPointOfCurve2) - )(curve2) - ); -} - -/** - * This function is a curve: a function from a fraction t to a point. The points - * lie on the unit circle. They start at Point (1,0) when t is 0. When t is - * 0.25, they reach Point (0,1), when t is 0.5, they reach Point (-1, 0), etc. - * - * @param t fraction between 0 and 1 - * @returns Point on the circle at t - */ -export function unit_circle(t: number): Point { - return make_point(Math.cos(2 * Math.PI * t), Math.sin(2 * Math.PI * t)); -} - -/** - * This function is a curve: a function from a fraction t to a point. The - * x-coordinate at franction t is t, and the y-coordinate is 0. - * - * @param t fraction between 0 and 1 - * @returns Point on the line at t - */ -export function unit_line(t: number): Point { - return make_point(t, 0); -} - -/** - * This function is a Curve generator: it takes a number and returns a - * horizontal curve. The number is a y-coordinate, and the Curve generates only - * points with the given y-coordinate. - * - * @param t fraction between 0 and 1 - * @returns horizontal Curve - */ -export function unit_line_at(t: number): Curve { - return (a: number): Point => make_point(a, t); -} - -/** - * This function is a curve: a function from a fraction t to a point. The points - * lie on the right half of the unit circle. They start at Point (0,1) when t is - * 0. When t is 0.5, they reach Point (1,0), when t is 1, they reach Point - * (0, -1). - * - * @param t fraction between 0 and 1 - * @returns Point in the arc at t - */ -export function arc(t: number): Point { - return make_point(Math.sin(Math.PI * t), Math.cos(Math.PI * t)); -} - -/** - * Create a animation of curves using a curve generating function. - * @param duration The duration of the animation in seconds - * @param fps Framerate of the animation in frames per second - * @param drawer Draw function to the generated curves with - * @param func Curve generating function. Takes in a timestamp value and returns a curve - * @return Curve Animation - */ -export function animate_curve( - duration: number, - fps: number, - drawer: RenderFunction, - func: CurveAnimation -): AnimatedCurve { - if ((drawer as any).is3D) - throw new Error('Curve Animation cannot be used with 3D draw function!'); - - const anim = new AnimatedCurve(duration, fps, func, drawer, false); - drawnCurves.push(anim); - return anim; -} - -/** - * Create a animation of curves using a curve generating function. - * @param duration The duration of the animation in seconds - * @param fps Framerate of the animation in frames per second - * @param drawer Draw function to the generated curves with - * @param func Curve generating function. Takes in a timestamp value and returns a curve - * @return 3D Curve Animation - */ -export function animate_3D_curve( - duration: number, - fps: number, - drawer: RenderFunction, - func: CurveAnimation -): AnimatedCurve { - if (!(drawer as any).is3D) { - throw new Error('Curve 3D Animation cannot be used with 2D draw function!'); - } - - const anim = new AnimatedCurve(duration, fps, func, drawer, true); - drawnCurves.push(anim); - return anim; -} +/** + * drawing *curves*, i.e. collections of *points*, on a canvas in a tools tab + * + * A *point* is defined by its coordinates (x, y and z), and the color assigned to + * it (r, g, and b). A few constructors for points is given, for example + * `make_color_point`. Selectors allow access to the coordinates and color + * components, for example `x_of`. + * + * A *curve* is a + * unary function which takes a number argument within the unit interval `[0,1]` + * and returns a point. If `C` is a curve, then the starting point of the curve + * is always `C(0)`, and the ending point is always `C(1)`. + * + * A *curve transformation* is a function that takes a curve as argument and + * returns a curve. Examples of curve transformations are `scale` and `translate`. + * + * A *curve drawer* is function that takes a number argument and returns + * a function that takes a curve as argument and visualises it in the output screen is + * shown in the Source Academy in the tab with the "Curves Canvas" icon (image). + * The following [example](https://share.sourceacademy.org/unitcircle) uses + * the curve drawer `draw_connected_full_view` to display a curve called + * `unit_circle`. + * ``` + * import { make_point, draw_connected_full_view } from "curve"; + * function unit_circle(t) { + * return make_point(math_sin(2 * math_PI * t), + * math_cos(2 * math_PI * t)); + * } + * draw_connected_full_view(100)(unit_circle); + * ``` + * draws a full circle in the display tab. + * + * @module curve + * @author Lee Zheng Han + * @author Ng Yong Xiang + */ + +/* eslint-disable @typescript-eslint/naming-convention */ +import type { Curve, CurveDrawn } from './curves_webgl'; +import { generateCurve, Point } from './curves_webgl'; +import type { + CurveAnimation, + CurveSpace, + CurveTransformer, + DrawMode, + RenderFunction, + ScaleMode, +} from './types'; +import { + AnimatedCurve, +} from './types'; + +/** @hidden */ +export const drawnCurves: (CurveDrawn | AnimatedCurve)[] = []; + +function createDrawFunction( + scaleMode: ScaleMode, + drawMode: DrawMode, + space: CurveSpace, + isFullView: boolean, +): (numPoints: number) => RenderFunction { + return (numPoints: number) => { + const func = (curve) => { + const curveDrawn = generateCurve( + scaleMode, + drawMode, + numPoints, + curve, + space, + isFullView, + ); + + if ( + (curve as any).shouldAppend === undefined + || (curve as any).shouldAppend + ) { + drawnCurves.push(curveDrawn); + } + + return curveDrawn; + }; + // Because the draw functions are actually functions + // we need hacky workarounds like these to pass information around + func.is3D = space === '3D'; + return func; + }; +} + +// ============================================================================= +// Module's Exposed Functions +// +// This file only includes the implementation and documentation of exposed +// functions of the module. For private functions dealing with the browser's +// graphics library context, see './curves_webgl.ts'. +// ============================================================================= + +/** + * Returns a function that turns a given Curve into a Drawing, by sampling the + * Curve at `num` sample points and connecting each pair with a line. + * The parts between (0,0) and (1,1) of the resulting Drawing are shown in the window. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_connected(100)(t => make_point(t, t)); + * ``` + */ +export const draw_connected = createDrawFunction('none', 'lines', '2D', false); + +/** + * Returns a function that turns a given Curve into a Drawing, by sampling the + * Curve at `num` sample points and connecting each pair with a line. The Drawing is + * translated and stretched/shrunk to show the full curve and maximize its width + * and height, with some padding. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_connected_full_view(100)(t => make_point(t, t)); + * ``` + */ +export const draw_connected_full_view = createDrawFunction( + 'stretch', + 'lines', + '2D', + true, +); + +/** + * Returns a function that turns a given Curve into a Drawing, by sampling the + * Curve at `num` sample points and connecting each pair with a line. The Drawing + * is translated and scaled proportionally to show the full curve and maximize + * its size, with some padding. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_connected_full_view_proportional(100)(t => make_point(t, t)); + * ``` + */ +export const draw_connected_full_view_proportional = createDrawFunction( + 'fit', + 'lines', + '2D', + true, +); + +/** + * Returns a function that turns a given Curve into a Drawing, by sampling the + * Curve at `num` sample points. The Drawing consists of isolated + * points, and does not connect them. The parts between (0,0) and (1,1) of the + * resulting Drawing are shown in the window. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1,there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_points(100)(t => make_point(t, t)); + * ``` + */ +export const draw_points = createDrawFunction('none', 'points', '2D', false); + +/** + * Returns a function that turns a given Curve into a Drawing, by sampling the + * Curve at `num` sample points. The Drawing consists of isolated + * points, and does not connect them. The Drawing is translated and + * stretched/shrunk to show the full curve and maximize its width and height, + * with some padding. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_points_full_view(100)(t => make_point(t, t)); + * ``` + */ +export const draw_points_full_view = createDrawFunction( + 'stretch', + 'points', + '2D', + true, +); + +/** + * Returns a function that turns a given Curve into a Drawing, by sampling the + * Curve at `num` sample points. The Drawing consists of isolated + * points, and does not connect them. The Drawing is translated and scaled + * proportionally with its size maximized to fit entirely inside the window, + * with some padding. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_points_full_view_proportional(100)(t => make_point(t, t)); + * ``` + */ +export const draw_points_full_view_proportional = createDrawFunction( + 'fit', + 'points', + '2D', + true, +); + +/** + * Returns a function that turns a given 3D Curve into a Drawing, by sampling + * the 3D Curve at `num` sample points and connecting each pair with + * a line. The parts between (0,0,0) and (1,1,1) of the resulting Drawing are + * shown within the unit cube. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_3D_connected(100)(t => make_3D_point(t, t, t)); + * ``` + */ +export const draw_3D_connected = createDrawFunction( + 'none', + 'lines', + '3D', + false, +); + +/** + * Returns a function that turns a given 3D Curve into a Drawing, by sampling + * the 3D Curve at `num` sample points and connecting each pair with + * a line. The Drawing is translated and stretched/shrunk to show the full + * curve and maximize its width and height within the cube. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_3D_connected_full_view(100)(t => make_3D_point(t, t, t)); + * ``` + */ +export const draw_3D_connected_full_view = createDrawFunction( + 'stretch', + 'lines', + '3D', + false, +); + +/** + * Returns a function that turns a given 3D Curve into a Drawing, by sampling + * the 3D Curve at `num` sample points and connecting each pair with + * a line. The Drawing is translated and scaled proportionally with its size + * maximized to fit entirely inside the cube. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_3D_connected_full_view_proportional(100)(t => make_3D_point(t, t, t)); + * ``` + */ +export const draw_3D_connected_full_view_proportional = createDrawFunction( + 'fit', + 'lines', + '3D', + false, +); + +/** + * Returns a function that turns a given 3D Curve into a Drawing, by sampling + * the 3D Curve at `num` sample points. The Drawing consists of + * isolated points, and does not connect them. The parts between (0,0,0) + * and (1,1,1) of the resulting Drawing are shown within the unit cube. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_3D_points(100)(t => make_3D_point(t, t, t)); + * ``` + */ +export const draw_3D_points = createDrawFunction('none', 'points', '3D', false); + +/** + * Returns a function that turns a given 3D Curve into a Drawing, by sampling + * the 3D Curve at `num` sample points. The Drawing consists of + * isolated points, and does not connect them. The Drawing is translated and + * stretched/shrunk to maximize its size to fit entirely inside the cube. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_3D_points_full_view(100)(t => make_3D_point(t, t, t)); + * ``` + */ +export const draw_3D_points_full_view = createDrawFunction( + 'stretch', + 'points', + '3D', + false, +); + +/** + * Returns a function that turns a given 3D Curve into a Drawing, by sampling + * the 3D Curve at `num` sample points. The Drawing consists of + * isolated points, and does not connect them. The Drawing is translated and + * scaled proportionally with its size maximized to fit entirely inside the cube. + * + * @param num determines the number of points, lower than 65535, to be sampled. + * Including 0 and 1, there are `num + 1` evenly spaced sample points + * @return function of type Curve → Drawing + * @example + * ``` + * draw_3D_points_full_view_proportional(100)(t => make_3D_point(t, t, t)); + * ``` + */ +export const draw_3D_points_full_view_proportional = createDrawFunction( + 'fit', + 'points', + '3D', + false, +); + +/** + * Makes a Point with given x and y coordinates. + * + * @param x x-coordinate of new point + * @param y y-coordinate of new point + * @returns with x and y as coordinates + * @example + * ``` + * const point = make_point(0.5, 0.5); + * ``` + */ +export function make_point(x: number, y: number): Point { + return new Point(x, y, 0, [0, 0, 0, 1]); +} + +/** + * Makes a 3D Point with given x, y and z coordinates. + * + * @param x x-coordinate of new point + * @param y y-coordinate of new point + * @param z z-coordinate of new point + * @returns with x, y and z as coordinates + * @example + * ``` + * const point = make_3D_point(0.5, 0.5, 0.5); + * ``` + */ +export function make_3D_point(x: number, y: number, z: number): Point { + return new Point(x, y, z, [0, 0, 0, 1]); +} + +/** + * Makes a color Point with given x and y coordinates, and RGB values ranging + * from 0 to 255. Any input lower than 0 for RGB will be rounded up to 0, and + * any input higher than 255 will be rounded down to 255. + * + * @param x x-coordinate of new point + * @param y y-coordinate of new point + * @param r red component of new point + * @param g green component of new point + * @param b blue component of new point + * @returns with x and y as coordinates, and r, g and b as RGB values + * @example + * ``` + * const redPoint = make_color_point(0.5, 0.5, 255, 0, 0); + * ``` + */ +export function make_color_point( + x: number, + y: number, + r: number, + g: number, + b: number, +): Point { + return new Point(x, y, 0, [r / 255, g / 255, b / 255, 1]); +} + +/** + * Makes a 3D color Point with given x, y and z coordinates, and RGB values + * ranging from 0 to 255. Any input lower than 0 for RGB will be rounded up to + * 0, and any input higher than 255 will be rounded down to 255. + * + * @param x x-coordinate of new point + * @param y y-coordinate of new point + * @param z z-coordinate of new point + * @param r red component of new point + * @param g green component of new point + * @param b blue component of new point + * @returns with x, y and z as coordinates, and r, g and b as RGB values + * @example + * ``` + * const redPoint = make_color_point(0.5, 0.5, 0.5, 255, 0, 0); + * ``` + */ +export function make_3D_color_point( + x: number, + y: number, + z: number, + r: number, + g: number, + b: number, +): Point { + return new Point(x, y, z, [r / 255, g / 255, b / 255, 1]); +} + +/** + * Retrieves the x-coordinate of a given Point. + * + * @param p given point + * @returns x-coordinate of the Point + * @example + * ``` + * const point = make_color_point(1, 2, 3, 50, 100, 150); + * x_of(point); // Returns 1 + * ``` + */ +export function x_of(pt: Point): number { + return pt.x; +} + +/** + * Retrieves the y-coordinate of a given Point. + * + * @param p given point + * @returns y-coordinate of the Point + * @example + * ``` + * const point = make_color_point(1, 2, 3, 50, 100, 150); + * y_of(point); // Returns 2 + * ``` + */ +export function y_of(pt: Point): number { + return pt.y; +} + +/** + * Retrieves the z-coordinate of a given Point. + * + * @param p given point + * @returns z-coordinate of the Point + * @example + * ``` + * const point = make_color_point(1, 2, 3, 50, 100, 150); + * z_of(point); // Returns 3 + * ``` + */ +export function z_of(pt: Point): number { + return pt.z; +} + +/** + * Retrieves the red component of a given Point. + * + * @param p given point + * @returns Red component of the Point + * @example + * ``` + * const point = make_color_point(1, 2, 3, 50, 100, 150); + * r_of(point); // Returns 50 + * ``` + */ +export function r_of(pt: Point): number { + return pt.color[0] * 255; +} + +/** + * Retrieves the green component of a given Point. + * + * @param p given point + * @returns Green component of the Point + * @example + * ``` + * const point = make_color_point(1, 2, 3, 50, 100, 150); + * g_of(point); // Returns 100 + * ``` + */ +export function g_of(pt: Point): number { + return pt.color[1] * 255; +} + +/** + * Retrieves the blue component of a given Point. + * + * @param p given point + * @returns Blue component of the Point + * @example + * ``` + * const point = make_color_point(1, 2, 3, 50, 100, 150); + * b_of(point); // Returns 150 + * ``` + */ +export function b_of(pt: Point): number { + return pt.color[2] * 255; +} + +/** + * This function is a Curve transformation: a function from a Curve to a Curve. + * The points of the result Curve are the same points as the points of the + * original Curve, but in reverse: The result Curve applied to 0 is the original + * Curve applied to 1 and vice versa. + * + * @param original original Curve + * @returns result Curve + */ +export function invert(curve: Curve): Curve { + return (t: number) => curve(1 - t); +} + +/** + * This function returns a Curve transformation: It takes an x-value x0, a + * y-value y0 and a z-value z0, as arguments and + * returns a Curve transformation that takes a Curve as argument and returns a + * new Curve, by translating the original by x0 in x-direction, y0 in + * y-direction and z0 in z-direction. + * + * @param x0 x-value + * @param y0 y-value + * @param z0 z-value + * @returns Curve transformation + */ +export function translate( + x0: number, + y0: number, + z0: number, +): CurveTransformer { + return (curve: Curve) => { + const transformation = (cf: Curve) => (t: number) => { + const a = x0 === undefined ? 0 : x0; + const b = y0 === undefined ? 0 : y0; + const c = z0 === undefined ? 0 : z0; + const ct: Point = cf(t); + return make_3D_color_point( + a + x_of(ct), + b + y_of(ct), + c + z_of(ct), + r_of(ct), + g_of(ct), + b_of(ct), + ); + }; + return transformation(curve); + }; +} + +/** + * This function takes 3 angles, a, b and c in radians as parameter + * and returns a Curve transformation: a function that takes a Curve as argument + * and returns a new Curve, which is the original Curve rotated + * extrinsically with Euler angles (a, b, c) about x, y, + * and z axes. + * + * @param a given angle + * @param b given angle + * @param c given angle + * @returns function that takes a Curve and returns a Curve + */ +export function rotate_around_origin( + theta1: number, + theta2: number, + theta3: number, +): CurveTransformer { + if (theta3 === undefined && theta1 !== undefined && theta2 !== undefined) { + // 2 args + throw new Error('Expected 1 or 3 arguments, but received 2'); + } else if ( + theta1 !== undefined + && theta2 === undefined + && theta3 === undefined + ) { + // 1 args + const cth = Math.cos(theta1); + const sth = Math.sin(theta1); + return (curve: Curve) => { + const transformation = (c: Curve) => (t: number) => { + const ct = c(t); + const x = x_of(ct); + const y = y_of(ct); + const z = z_of(ct); + return make_3D_color_point( + cth * x - sth * y, + sth * x + cth * y, + z, + r_of(ct), + g_of(ct), + b_of(ct), + ); + }; + return transformation(curve); + }; + } else { + const cthx = Math.cos(theta1); + const sthx = Math.sin(theta1); + const cthy = Math.cos(theta2); + const sthy = Math.sin(theta2); + const cthz = Math.cos(theta3); + const sthz = Math.sin(theta3); + return (curve: Curve) => { + const transformation = (c: Curve) => (t: number) => { + const ct = c(t); + const coord = [x_of(ct), y_of(ct), z_of(ct)]; + const mat = [ + [ + cthz * cthy, + cthz * sthy * sthx - sthz * cthx, + cthz * sthy * cthx + sthz * sthx, + ], + [ + sthz * cthy, + sthz * sthy * sthx + cthz * cthx, + sthz * sthy * cthx - cthz * sthx, + ], + [-sthy, cthy * sthx, cthy * cthx], + ]; + let xf = 0; + let yf = 0; + let zf = 0; + for (let i = 0; i < 3; i += 1) { + xf += mat[0][i] * coord[i]; + yf += mat[1][i] * coord[i]; + zf += mat[2][i] * coord[i]; + } + return make_3D_color_point(xf, yf, zf, r_of(ct), g_of(ct), b_of(ct)); + }; + return transformation(curve); + }; + } +} + +/** + * This function takes scaling factors `a`, `b` and + * `c`, as arguments and returns a + * Curve transformation that scales a given Curve by `a` in + * x-direction, `b` in y-direction and `c` in z-direction. + * + * @param a scaling factor in x-direction + * @param b scaling factor in y-direction + * @param c scaling factor in z-direction + * @returns function that takes a Curve and returns a Curve + */ +export function scale(a: number, b: number, c: number): CurveTransformer { + return (curve) => { + const transformation = (cf: Curve) => (t: number) => { + const ct = cf(t); + const a1 = a === undefined ? 1 : a; + const b1 = b === undefined ? 1 : b; + const c1 = c === undefined ? 1 : c; + return make_3D_color_point( + a1 * x_of(ct), + b1 * y_of(ct), + c1 * z_of(ct), + r_of(ct), + g_of(ct), + b_of(ct), + ); + }; + return transformation(curve); + }; +} + +/** + * This function takes a scaling factor s argument and returns a Curve + * transformation that scales a given Curve by s in x, y and z direction. + * + * @param s scaling factor + * @returns function that takes a Curve and returns a Curve + */ +export function scale_proportional(s: number): CurveTransformer { + return scale(s, s, s); +} + +/** + * This function is a Curve transformation: It takes a Curve as argument and + * returns a new Curve, as follows. A Curve is in standard position if it + * starts at (0,0) ends at (1,0). This function puts the given Curve in + * standard position by rigidly translating it so its start Point is at the + * origin (0,0), then rotating it about the origin to put its endpoint on the + * x axis, then scaling it to put the endpoint at (1,0). Behavior is unspecified + * on closed Curves where start-point equal end-point. + * + * @param curve given Curve + * @returns result Curve + */ +export function put_in_standard_position(curve: Curve): Curve { + const start_point = curve(0); + const curve_started_at_origin = translate( + -x_of(start_point), + -y_of(start_point), + 0, + )(curve); + const new_end_point = curve_started_at_origin(1); + const theta = Math.atan2(y_of(new_end_point), x_of(new_end_point)); + const curve_ended_at_x_axis = rotate_around_origin( + 0, + 0, + -theta, + )(curve_started_at_origin); + const end_point_on_x_axis = x_of(curve_ended_at_x_axis(1)); + return scale_proportional(1 / end_point_on_x_axis)(curve_ended_at_x_axis); +} + +/** + * This function is a binary Curve operator: It takes two Curves as arguments + * and returns a new Curve. The two Curves are combined by using the full first + * Curve for the first portion of the result and by using the full second Curve + * for the second portion of the result. The second Curve is not changed, and + * therefore there might be a big jump in the middle of the result Curve. + * + * @param curve1 first Curve + * @param curve2 second Curve + * @returns result Curve + */ +export function connect_rigidly(curve1: Curve, curve2: Curve): Curve { + return (t) => (t < 1 / 2 ? curve1(2 * t) : curve2(2 * t - 1)); +} + +/** + * This function is a binary Curve operator: It takes two Curves as arguments + * and returns a new Curve. The two Curves are combined by using the full first + * Curve for the first portion of the result and by using the full second Curve + * for the second portion of the result. The second Curve is translated such + * that its point at fraction 0 is the same as the Point of the first Curve at + * fraction 1. + * + * @param curve1 first Curve + * @param curve2 second Curve + * @returns result Curve + */ +export function connect_ends(curve1: Curve, curve2: Curve): Curve { + const startPointOfCurve2 = curve2(0); + const endPointOfCurve1 = curve1(1); + return connect_rigidly( + curve1, + translate( + x_of(endPointOfCurve1) - x_of(startPointOfCurve2), + y_of(endPointOfCurve1) - y_of(startPointOfCurve2), + z_of(endPointOfCurve1) - z_of(startPointOfCurve2), + )(curve2), + ); +} + +/** + * This function is a curve: a function from a fraction t to a point. The points + * lie on the unit circle. They start at Point (1,0) when t is 0. When t is + * 0.25, they reach Point (0,1), when t is 0.5, they reach Point (-1, 0), etc. + * + * @param t fraction between 0 and 1 + * @returns Point on the circle at t + */ +export function unit_circle(t: number): Point { + return make_point(Math.cos(2 * Math.PI * t), Math.sin(2 * Math.PI * t)); +} + +/** + * This function is a curve: a function from a fraction t to a point. The + * x-coordinate at franction t is t, and the y-coordinate is 0. + * + * @param t fraction between 0 and 1 + * @returns Point on the line at t + */ +export function unit_line(t: number): Point { + return make_point(t, 0); +} + +/** + * This function is a Curve generator: it takes a number and returns a + * horizontal curve. The number is a y-coordinate, and the Curve generates only + * points with the given y-coordinate. + * + * @param t fraction between 0 and 1 + * @returns horizontal Curve + */ +export function unit_line_at(t: number): Curve { + return (a: number): Point => make_point(a, t); +} + +/** + * This function is a curve: a function from a fraction t to a point. The points + * lie on the right half of the unit circle. They start at Point (0,1) when t is + * 0. When t is 0.5, they reach Point (1,0), when t is 1, they reach Point + * (0, -1). + * + * @param t fraction between 0 and 1 + * @returns Point in the arc at t + */ +export function arc(t: number): Point { + return make_point(Math.sin(Math.PI * t), Math.cos(Math.PI * t)); +} + +/** + * Create a animation of curves using a curve generating function. + * @param duration The duration of the animation in seconds + * @param fps Framerate of the animation in frames per second + * @param drawer Draw function to the generated curves with + * @param func Curve generating function. Takes in a timestamp value and returns a curve + * @return Curve Animation + */ +export function animate_curve( + duration: number, + fps: number, + drawer: RenderFunction, + func: CurveAnimation, +): AnimatedCurve { + if ((drawer as any).is3D) { throw new Error('Curve Animation cannot be used with 3D draw function!'); } + + const anim = new AnimatedCurve(duration, fps, func, drawer, false); + drawnCurves.push(anim); + return anim; +} + +/** + * Create a animation of curves using a curve generating function. + * @param duration The duration of the animation in seconds + * @param fps Framerate of the animation in frames per second + * @param drawer Draw function to the generated curves with + * @param func Curve generating function. Takes in a timestamp value and returns a curve + * @return 3D Curve Animation + */ +export function animate_3D_curve( + duration: number, + fps: number, + drawer: RenderFunction, + func: CurveAnimation, +): AnimatedCurve { + if (!(drawer as any).is3D) { + throw new Error('Curve 3D Animation cannot be used with 2D draw function!'); + } + + const anim = new AnimatedCurve(duration, fps, func, drawer, true); + drawnCurves.push(anim); + return anim; +} diff --git a/src/bundles/curve/index.ts b/src/bundles/curve/index.ts index b8fb3dad8..bb9438fc0 100644 --- a/src/bundles/curve/index.ts +++ b/src/bundles/curve/index.ts @@ -1,4 +1,4 @@ -import { ModuleContexts, ModuleParams } from '../../typings/type_helpers.js'; +import type { ModuleContexts, ModuleParams } from '../../typings/type_helpers.js'; import { animate_3D_curve, animate_curve, @@ -6,7 +6,6 @@ import { b_of, connect_ends, connect_rigidly, - drawnCurves, draw_3D_connected, draw_3D_connected_full_view, draw_3D_connected_full_view_proportional, @@ -19,6 +18,7 @@ import { draw_points, draw_points_full_view, draw_points_full_view_proportional, + drawnCurves, g_of, invert, make_3D_color_point, @@ -26,8 +26,8 @@ import { make_color_point, make_point, put_in_standard_position, - rotate_around_origin, r_of, + rotate_around_origin, scale, scale_proportional, translate, @@ -38,7 +38,7 @@ import { y_of, z_of, } from './functions'; -import { CurveModuleState } from './types'; +import type { CurveModuleState } from './types'; /** * Bundle for Source Academy Curves module @@ -48,11 +48,14 @@ import { CurveModuleState } from './types'; export default function curves( moduleParams: ModuleParams, - moduleContexts: ModuleContexts + moduleContexts: ModuleContexts, ) { // Update the module's global context let moduleContext = moduleContexts.get('curve'); + // Probably can edit this because modules can only be loaded once + // Otherwise loading the module twice just overwrites the existing context + // thing if (!moduleContext) { moduleContext = { tabs: [], @@ -70,7 +73,7 @@ export default function curves( (moduleContext.state as CurveModuleState).drawnCurves = drawnCurves; } - return { + return new Proxy({ make_point, make_3D_point, make_color_point, @@ -107,5 +110,10 @@ export default function curves( rotate_around_origin, arc, invert, - }; + }, { + get(target, name) { + if (target[name]) return target[name]; + throw new Error(`Undefined symbol: ${name.toString()}`); + }, + }); } diff --git a/src/bundles/curve/types.ts b/src/bundles/curve/types.ts index cbc3222e7..603ba9127 100644 --- a/src/bundles/curve/types.ts +++ b/src/bundles/curve/types.ts @@ -1,60 +1,60 @@ -import { ModuleState } from 'js-slang'; -import { glAnimation, AnimFrame } from '../../typings/anim_types'; -import { ReplResult } from '../../typings/type_helpers'; -import { Curve, CurveDrawn } from './curves_webgl'; - -/** A function that takes in CurveFunction and returns a tranformed CurveFunction. */ -export type CurveTransformer = (c: Curve) => Curve; - -export type DrawMode = 'lines' | 'points'; -export type ScaleMode = 'none' | 'stretch' | 'fit'; -export type CurveSpace = '2D' | '3D'; - -/** - * A function that takes in a timestamp and returns a Curve - */ -export type CurveAnimation = (t: number) => Curve; - -/** - * A function that specifies additional rendering information when taking in - * a CurveFunction and returns a ShapeDrawn based on its specifications. - */ -export type RenderFunction = (func: Curve) => CurveDrawn; - -export class AnimatedCurve extends glAnimation implements ReplResult { - constructor( - duration: number, - fps: number, - private readonly func: (timestamp: number) => Curve, - private readonly drawer: RenderFunction, - public readonly is3D: boolean - ) { - super(duration, fps); - this.angle = 0; - } - - public getFrame(timestamp: number): AnimFrame { - const curve = this.func(timestamp); - (curve as any).shouldAppend = false; - const curveDrawn = this.drawer(curve); - - return { - draw: (canvas: HTMLCanvasElement) => { - curveDrawn.init(canvas); - curveDrawn.redraw(this.angle); - }, - }; - } - - public angle: number; - - public toReplString = () => ''; -} - -export class CurveModuleState implements ModuleState { - constructor() { - this.drawnCurves = []; - } - - public drawnCurves: (CurveDrawn | AnimatedCurve)[]; -} +import { ModuleState } from 'js-slang'; +import { glAnimation, AnimFrame } from '../../typings/anim_types'; +import { ReplResult } from '../../typings/type_helpers'; +import { Curve, CurveDrawn } from './curves_webgl'; + +/** A function that takes in CurveFunction and returns a tranformed CurveFunction. */ +export type CurveTransformer = (c: Curve) => Curve; + +export type DrawMode = 'lines' | 'points'; +export type ScaleMode = 'none' | 'stretch' | 'fit'; +export type CurveSpace = '2D' | '3D'; + +/** + * A function that takes in a timestamp and returns a Curve + */ +export type CurveAnimation = (t: number) => Curve; + +/** + * A function that specifies additional rendering information when taking in + * a CurveFunction and returns a ShapeDrawn based on its specifications. + */ +export type RenderFunction = (func: Curve) => CurveDrawn; + +export class AnimatedCurve extends glAnimation implements ReplResult { + constructor( + duration: number, + fps: number, + private readonly func: (timestamp: number) => Curve, + private readonly drawer: RenderFunction, + public readonly is3D: boolean, + ) { + super(duration, fps); + this.angle = 0; + } + + public getFrame(timestamp: number): AnimFrame { + const curve = this.func(timestamp); + (curve as any).shouldAppend = false; + const curveDrawn = this.drawer(curve); + + return { + draw: (canvas: HTMLCanvasElement) => { + curveDrawn.init(canvas); + curveDrawn.redraw(this.angle); + }, + }; + } + + public angle: number; + + public toReplString = () => ''; +} + +export class CurveModuleState implements ModuleState { + constructor() { + this.drawnCurves = []; + } + + public drawnCurves: (CurveDrawn | AnimatedCurve)[]; +} diff --git a/src/bundles/game/functions.ts b/src/bundles/game/functions.ts index 05644cb14..9add76fbc 100644 --- a/src/bundles/game/functions.ts +++ b/src/bundles/game/functions.ts @@ -1,1492 +1,1524 @@ -/** - * Game library that translates Phaser 3 API into Source. - * - * More in-depth explanation of the Phaser 3 API can be found at - * Phaser 3 documentation itself. - * - * For Phaser 3 API Documentation, check: - * https://photonstorm.github.io/phaser3-docs/ - * - * @module game - * @author Anthony Halim - * @author Chi Xu - * @author Chong Sia Tiffany - * @author Gokul Rajiv - */ - -/* eslint-disable consistent-return, @typescript-eslint/default-param-last, @typescript-eslint/no-shadow, @typescript-eslint/no-unused-vars */ -import { - GameModuleParams, - GameObject, - List, - ObjectConfig, - RawContainer, - RawGameElement, - RawGameObject, - RawInputObject, -} from './types'; - -/** @hidden */ -export default function gameFuncs(moduleParams: GameModuleParams) { - const { - scene, - preloadImageMap, - preloadSoundMap, - preloadSpritesheetMap, - remotePath, - screenSize, - createAward, - } = moduleParams.game || {}; - - // Listener ObjectTypes - enum ListenerTypes { - InputPlugin = 'input_plugin', - KeyboardKeyType = 'keyboard_key', - } - - const ListnerTypes = Object.values(ListenerTypes); - - // Object ObjectTypes - enum ObjectTypes { - ImageType = 'image', - TextType = 'text', - RectType = 'rect', - EllipseType = 'ellipse', - ContainerType = 'container', - AwardType = 'award', - } - - const ObjTypes = Object.values(ObjectTypes); - - const nullFn = () => {}; - - // ============================================================================= - // Module's Private Functions - // ============================================================================= - - /** @hidden */ - function get_obj( - obj: GameObject - ): RawGameObject | RawInputObject | RawContainer { - return obj.object!; - } - - /** @hidden */ - function get_game_obj(obj: GameObject): RawGameObject | RawContainer { - return obj.object as RawGameObject | RawContainer; - } - - /** @hidden */ - function get_input_obj(obj: GameObject): RawInputObject { - return obj.object as RawInputObject; - } - - /** @hidden */ - function get_container(obj: GameObject): RawContainer { - return obj.object as RawContainer; - } - - /** - * Checks whether the given game object is of the enquired type. - * If the given obj is undefined, will also return false. - * - * @param obj the game object - * @param type enquired type - * @returns if game object is of enquired type - * @hidden - */ - function is_type(obj: GameObject, type: string): boolean { - return obj !== undefined && obj.type === type && obj.object !== undefined; - } - - /** - * Checks whether the given game object is any of the enquired ObjectTypes - * - * @param obj the game object - * @param ObjectTypes enquired ObjectTypes - * @returns if game object is of any of the enquired ObjectTypes - * @hidden - */ - function is_any_type(obj: GameObject, types: string[]): boolean { - for (let i = 0; i < types.length; ++i) { - if (is_type(obj, types[i])) return true; - } - return false; - } - - /** - * Set a game object to the given type. - * Mutates the object. - * - * @param object the game object - * @param type type to set - * @returns typed game object - * @hidden - */ - function set_type( - object: RawGameObject | RawInputObject | RawContainer, - type: string - ): GameObject { - return { type, object }; - } - - /** - * Throw a console error, including the function caller name. - * - * @param {string} message error message - * @hidden - */ - function throw_error(message: string) { - // eslint-disable-next-line no-caller, @typescript-eslint/no-throw-literal - throw console.error(`${arguments.callee.caller.name}: ${message}`); - } - - // List processing - // Original Author: Martin Henz - - /** - * array test works differently for Rhino and - * the Firefox environment (especially Web Console) - */ - function array_test(x: any): boolean { - if (Array.isArray === undefined) { - return x instanceof Array; - } - return Array.isArray(x); - } - - /** - * pair constructs a pair using a two-element array - * LOW-LEVEL FUNCTION, NOT SOURCE - */ - function pair(x: any, xs: any): [any, any] { - return [x, xs]; - } - - /** - * is_pair returns true iff arg is a two-element array - * LOW-LEVEL FUNCTION, NOT SOURCE - */ - function is_pair(x: any): boolean { - return array_test(x) && x.length === 2; - } - - /** - * head returns the first component of the given pair, - * throws an exception if the argument is not a pair - * LOW-LEVEL FUNCTION, NOT SOURCE - */ - function head(xs: List): any { - if (is_pair(xs)) { - return xs![0]; - } - throw new Error( - `head(xs) expects a pair as argument xs, but encountered ${xs}` - ); - } - - /** - * tail returns the second component of the given pair - * throws an exception if the argument is not a pair - * LOW-LEVEL FUNCTION, NOT SOURCE - */ - function tail(xs: List) { - if (is_pair(xs)) { - return xs![1]; - } - throw new Error( - `tail(xs) expects a pair as argument xs, but encountered ${xs}` - ); - } - - /** - * is_null returns true if arg is exactly null - * LOW-LEVEL FUNCTION, NOT SOURCE - */ - function is_null(xs: any) { - return xs === null; - } - - /** - * map applies first arg f to the elements of the second argument, - * assumed to be a list. - * f is applied element-by-element: - * map(f,[1,[2,[]]]) results in [f(1),[f(2),[]]] - * map throws an exception if the second argument is not a list, - * and if the second argument is a non-empty list and the first - * argument is not a function. - */ - function map(f: (x: any) => any, xs: List) { - return is_null(xs) ? null : pair(f(head(xs)), map(f, tail(xs))); - } - - // ============================================================================= - // Module's Exposed Functions - // ============================================================================= - - // HELPER - - function prepend_remote_url(asset_key: string): string { - return remotePath(asset_key); - } - - function create_config(lst: List): ObjectConfig { - const config = {}; - map((xs: [any, any]) => { - if (!is_pair(xs)) { - throw_error(`xs is not pair!`); - } - config[head(xs)] = tail(xs); - }, lst); - return config; - } - - function create_text_config( - font_family: string = 'Courier', - font_size: string = '16px', - color: string = '#fff', - stroke: string = '#fff', - stroke_thickness: number = 0, - align: string = 'left' - ): ObjectConfig { - return { - fontFamily: font_family, - fontSize: font_size, - color, - stroke, - strokeThickness: stroke_thickness, - align, - }; - } - - function create_interactive_config( - draggable: boolean = false, - use_hand_cursor: boolean = false, - pixel_perfect: boolean = false, - alpha_tolerance: number = 1 - ): ObjectConfig { - return { - draggable, - useHandCursor: use_hand_cursor, - pixelPerfect: pixel_perfect, - alphaTolerance: alpha_tolerance, - }; - } - - function create_sound_config( - mute: boolean = false, - volume: number = 1, - rate: number = 1, - detune: number = 0, - seek: number = 0, - loop: boolean = false, - delay: number = 0 - ): ObjectConfig { - return { - mute, - volume, - rate, - detune, - seek, - loop, - delay, - }; - } - - function create_tween_config( - target_prop: string = 'x', - target_value: string | number = 0, - delay: number = 0, - duration: number = 1000, - ease: Function | string = 'Power0', - on_complete: Function = nullFn, - yoyo: boolean = false, - loop: number = 0, - loop_delay: number = 0, - on_loop: Function = nullFn - ): ObjectConfig { - return { - [target_prop]: target_value, - delay, - duration, - ease, - onComplete: on_complete, - yoyo, - loop, - loopDelay: loop_delay, - onLoop: on_loop, - }; - } - - function create_anim_config( - anims_key: string, - anim_frames: ObjectConfig[], - frame_rate: number = 24, - duration: any = null, - repeat: number = -1, - yoyo: boolean = false, - show_on_start: boolean = true, - hide_on_complete: boolean = false - ): ObjectConfig { - return { - key: anims_key, - frames: anim_frames, - frameRate: frame_rate, - duration, - repeat, - yoyo, - showOnStart: show_on_start, - hideOnComplete: hide_on_complete, - }; - } - - function create_anim_frame_config( - key: string, - duration: number = 0, - visible: boolean = true - ): ObjectConfig { - return { - key, - duration, - visible, - }; - } - - function create_anim_spritesheet_frame_configs( - key: string - ): ObjectConfig[] | undefined { - if (preloadSpritesheetMap.get(key)) { - const configArr = scene.anims.generateFrameNumbers(key, {}); - return configArr; - } - throw_error(`${key} is not associated with any spritesheet`); - } - - function create_spritesheet_config( - frame_width: number, - frame_height: number, - start_frame: number = 0, - margin: number = 0, - spacing: number = 0 - ): ObjectConfig { - return { - frameWidth: frame_width, - frameHeight: frame_height, - startFrame: start_frame, - margin, - spacing, - }; - } - - // SCREEN - - function get_screen_width(): number { - return screenSize.x; - } - - function get_screen_height(): number { - return screenSize.y; - } - - function get_screen_display_width(): number { - return scene.scale.displaySize.width; - } - - function get_screen_display_height(): number { - return scene.scale.displaySize.height; - } - - // LOAD - - function load_image(key: string, url: string) { - preloadImageMap.set(key, url); - } - - function load_sound(key: string, url: string) { - preloadSoundMap.set(key, url); - } - - function load_spritesheet( - key: string, - url: string, - spritesheet_config: ObjectConfig - ) { - preloadSpritesheetMap.set(key, [url, spritesheet_config]); - } - - // ADD - - function add(obj: GameObject): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - scene.add.existing(get_game_obj(obj)); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - // SOUND - - function play_sound(key: string, config: ObjectConfig = {}): void { - if (preloadSoundMap.get(key)) { - scene.sound.play(key, config); - } else { - throw_error(`${key} is not associated with any sound`); - } - } - - // ANIMS - - function create_anim(anim_config: ObjectConfig): boolean { - const anims = scene.anims.create(anim_config); - return typeof anims !== 'boolean'; - } - - function play_anim_on_image( - image: GameObject, - anims_key: string - ): GameObject | undefined { - if (is_type(image, ObjectTypes.ImageType)) { - (get_obj(image) as Phaser.GameObjects.Sprite).play(anims_key); - return image; - } - throw_error(`${image} is not of type ${ObjectTypes.ImageType}`); - } - - // IMAGE - - function create_image( - x: number, - y: number, - asset_key: string - ): GameObject | undefined { - if ( - preloadImageMap.get(asset_key) || - preloadSpritesheetMap.get(asset_key) - ) { - const image = new Phaser.GameObjects.Sprite(scene, x, y, asset_key); - return set_type(image, ObjectTypes.ImageType); - } - throw_error(`${asset_key} is not associated with any image`); - } - - // AWARD - - function create_award(x: number, y: number, award_key: string): GameObject { - return set_type(createAward(x, y, award_key), ObjectTypes.AwardType); - } - - // TEXT - - function create_text( - x: number, - y: number, - text: string, - config: ObjectConfig = {} - ): GameObject { - const txt = new Phaser.GameObjects.Text(scene, x, y, text, config); - return set_type(txt, ObjectTypes.TextType); - } - - // RECTANGLE - - function create_rect( - x: number, - y: number, - width: number, - height: number, - fill: number = 0, - alpha: number = 1 - ): GameObject { - const rect = new Phaser.GameObjects.Rectangle( - scene, - x, - y, - width, - height, - fill, - alpha - ); - return set_type(rect, ObjectTypes.RectType); - } - - // ELLIPSE - - function create_ellipse( - x: number, - y: number, - width: number, - height: number, - fill: number = 0, - alpha: number = 1 - ): GameObject { - const ellipse = new Phaser.GameObjects.Ellipse( - scene, - x, - y, - width, - height, - fill, - alpha - ); - return set_type(ellipse, ObjectTypes.EllipseType); - } - - // CONTAINER - - function create_container(x: number, y: number): GameObject { - const cont = new Phaser.GameObjects.Container(scene, x, y); - return set_type(cont, ObjectTypes.ContainerType); - } - - function add_to_container( - container: GameObject, - obj: GameObject - ): GameObject | undefined { - if ( - is_type(container, ObjectTypes.ContainerType) && - is_any_type(obj, ObjTypes) - ) { - get_container(container).add(get_game_obj(obj)); - return container; - } - throw_error( - `${obj} is not of type ${ObjTypes} or ${container} is not of type ${ObjectTypes.ContainerType}` - ); - } - - // OBJECT - - function destroy_obj(obj: GameObject) { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj).destroy(); - } else { - throw_error(`${obj} is not of type ${ObjTypes}`); - } - } - - function set_display_size( - obj: GameObject, - x: number, - y: number - ): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj).setDisplaySize(x, y); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - function set_alpha(obj: GameObject, alpha: number): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj).setAlpha(alpha); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - function set_interactive( - obj: GameObject, - config: ObjectConfig = {} - ): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj).setInteractive(config); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - function set_origin( - obj: GameObject, - x: number, - y: number - ): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - (get_game_obj(obj) as RawGameObject).setOrigin(x, y); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - function set_position( - obj: GameObject, - x: number, - y: number - ): GameObject | undefined { - if (obj && is_any_type(obj, ObjTypes)) { - get_game_obj(obj).setPosition(x, y); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - function set_scale( - obj: GameObject, - x: number, - y: number - ): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj).setScale(x, y); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - function set_rotation(obj: GameObject, rad: number): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - get_game_obj(obj).setRotation(rad); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - function set_flip( - obj: GameObject, - x: boolean, - y: boolean - ): GameObject | undefined { - const GameElementType = [ObjectTypes.ImageType, ObjectTypes.TextType]; - if (is_any_type(obj, GameElementType)) { - (get_obj(obj) as RawGameElement).setFlip(x, y); - return obj; - } - throw_error(`${obj} is not of type ${GameElementType}`); - } - - async function add_tween( - obj: GameObject, - config: ObjectConfig = {} - ): Promise { - if (is_any_type(obj, ObjTypes)) { - scene.tweens.add({ - targets: get_game_obj(obj), - ...config, - }); - return obj; - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - // LISTENER - - function add_listener( - obj: GameObject, - event: string, - callback: Function - ): GameObject | undefined { - if (is_any_type(obj, ObjTypes)) { - const listener = get_game_obj(obj).addListener(event, callback); - return set_type(listener, ListenerTypes.InputPlugin); - } - throw_error(`${obj} is not of type ${ObjTypes}`); - } - - function add_keyboard_listener( - key: string | number, - event: string, - callback: Function - ): GameObject { - const keyObj = scene.input.keyboard.addKey(key); - const keyboardListener = keyObj.addListener(event, callback); - return set_type(keyboardListener, ListenerTypes.KeyboardKeyType); - } - - function remove_listener(listener: GameObject): boolean { - if (is_any_type(listener, ListnerTypes)) { - get_input_obj(listener).removeAllListeners(); - return true; - } - return false; - } - - const functions = { - add, - add_listener, - add_keyboard_listener, - add_to_container, - add_tween, - create_anim, - create_anim_config, - create_anim_frame_config, - create_anim_spritesheet_frame_configs, - create_award, - create_config, - create_container, - create_ellipse, - create_image, - create_interactive_config, - create_rect, - create_text, - create_text_config, - create_tween_config, - create_sound_config, - create_spritesheet_config, - destroy_obj, - get_screen_width, - get_screen_height, - get_screen_display_width, - get_screen_display_height, - load_image, - load_sound, - load_spritesheet, - play_anim_on_image, - play_sound, - prepend_remote_url, - remove_listener, - set_alpha, - set_display_size, - set_flip, - set_interactive, - set_origin, - set_position, - set_rotation, - set_scale, - }; - - const finalFunctions = {}; - - Object.entries(functions).forEach(([key, fn]) => { - finalFunctions[key] = !scene ? nullFn : fn; - }); - - return finalFunctions; -} - -// ============================================================================= -// Dummy functions for TypeDoc -// -// Refer to functions of matching signature in `gameFuncs` -// for implementation details -// ============================================================================= - -/** - * Prepend the given asset key with the remote path (S3 path). - * - * @param asset_key - * @returns prepended path - */ -export function prepend_remote_url(asset_key: string): string { - return ''; -} - -/** - * Transforms the given list into an object config. The list follows - * the format of list([key1, value1], [key2, value2]). - * - * e.g list(["alpha", 0], ["duration", 1000]) - * - * @param lst the list to be turned into object config. - * @returns object config - */ -export function create_config(lst: List): ObjectConfig { - return {}; -} - -/** - * Create text config object, can be used to stylise text object. - * - * font_family: for available font_family, see: - * https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#Valid_family_names - * - * align: must be either 'left', 'right', 'center', or 'justify' - * - * For more details about text config, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.GameObjects.Text.html#.TextStyle - * - * @param font_family font to be used - * @param font_size size of font, must be appended with 'px' e.g. '16px' - * @param color colour of font, in hex e.g. '#fff' - * @param stroke colour of stroke, in hex e.g. '#fff' - * @param stroke_thickness thickness of stroke - * @param align text alignment - * @returns text config - */ -export function create_text_config( - font_family: string = 'Courier', - font_size: string = '16px', - color: string = '#fff', - stroke: string = '#fff', - stroke_thickness: number = 0, - align: string = 'left' -): ObjectConfig { - return {}; -} - -/** - * Create interactive config object, can be used to configure interactive settings. - * - * For more details about interactive config object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Input.html#.InputConfiguration - * - * @param draggable object will be set draggable - * @param use_hand_cursor if true, pointer will be set to 'pointer' when a pointer is over it - * @param pixel_perfect pixel perfect function will be set for the hit area. Only works for texture based object - * @param alpha_tolerance if pixel_perfect is set, this is the alpha tolerance threshold value used in the callback - * @returns interactive config - */ -export function create_interactive_config( - draggable: boolean = false, - use_hand_cursor: boolean = false, - pixel_perfect: boolean = false, - alpha_tolerance: number = 1 -): ObjectConfig { - return {}; -} - -/** - * Create sound config object, can be used to configure sound settings. - * - * For more details about sound config object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Sound.html#.SoundConfig - * - * @param mute whether the sound should be muted or not - * @param volume value between 0(silence) and 1(full volume) - * @param rate the speed at which the sound is played - * @param detune detuning of the sound, in cents - * @param seek position of playback for the sound, in seconds - * @param loop whether or not the sound should loop - * @param delay time, in seconds, that elapse before the sound actually starts - * @returns sound config - */ -export function create_sound_config( - mute: boolean = false, - volume: number = 1, - rate: number = 1, - detune: number = 0, - seek: number = 0, - loop: boolean = false, - delay: number = 0 -): ObjectConfig { - return {}; -} - -/** - * Create tween config object, can be used to configure tween settings. - * - * For more details about tween config object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Tweens.html#.TweenBuilderConfig - * - * @param target_prop target to tween, e.g. x, y, alpha - * @param target_value the property value to tween to - * @param delay time in ms/frames before tween will start - * @param duration duration of tween in ms/frames, exclude yoyos or repeats - * @param ease ease function to use, e.g. 'Power0', 'Power1', 'Power2' - * @param on_complete function to execute when tween completes - * @param yoyo if set to true, once tween complete, reverses the values incrementally to get back to the starting tween values - * @param loop number of times the tween should loop, or -1 to loop indefinitely - * @param loop_delay The time the tween will pause before starting either a yoyo or returning to the start for a repeat - * @param on_loop function to execute each time the tween loops - * @returns tween config - */ -export function create_tween_config( - target_prop: string = 'x', - target_value: string | number = 0, - delay: number = 0, - duration: number = 1000, - ease: Function | string = 'Power0', - on_complete: Function, - yoyo: boolean = false, - loop: number = 0, - loop_delay: number = 0, - on_loop: Function -): ObjectConfig { - return {}; -} - -/** - * Create anims config, can be used to configure anims - * - * For more details about the config object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Animations.html#.Animation - * - * @param anims_key key that the animation will be associated with - * @param anim_frames data used to generate the frames for animation - * @param frame_rate frame rate of playback in frames per second - * @param duration how long the animation should play in seconds. - * If null, will be derived from frame_rate - * @param repeat number of times to repeat the animation, -1 for infinity - * @param yoyo should the animation yoyo (reverse back down to the start) - * @param show_on_start should the sprite be visible when the anims start? - * @param hide_on_complete should the sprite be not visible when the anims finish? - * @returns animation config - */ -export function create_anim_config( - anims_key: string, - anim_frames: ObjectConfig[], - frame_rate: number = 24, - duration: any = null, - repeat: number = -1, - yoyo: boolean = false, - show_on_start: boolean = true, - hide_on_complete: boolean = false -): ObjectConfig { - return {}; -} - -/** - * Create animation frame config, can be used to configure a specific frame - * within an animation. - * - * The key should refer to an image that is already loaded. - * To make frame_config from spritesheet based on its frames, - * use create_anim_spritesheet_frame_configs instead. - * - * @param key key that is associated with the sprite at this frame - * @param duration duration, in ms, of this frame of the animation - * @param visible should the parent object be visible during this frame? - * @returns animation frame config - */ -export function create_anim_frame_config( - key: string, - duration: number = 0, - visible: boolean = true -): ObjectConfig { - return {}; -} - -/** - * Create list of animation frame config, can be used directly as part of - * anim_config's `frames` parameter. - * - * This function will generate list of frame configs based on the - * spritesheet_config attached to the associated spritesheet. - * This function requires that the given key is a spritesheet key - * i.e. a key associated with loaded spritesheet, loaded in using - * load_spritesheet function. - * - * Will return empty frame configs if key is not associated with - * a spritesheet. - * - * @param key key associated with spritesheet - * @returns animation frame configs - */ -export function create_anim_spritesheet_frame_configs( - key: string -): ObjectConfig[] | undefined { - return undefined; -} - -/** - * Create spritesheet config, can be used to configure the frames within the - * spritesheet. Can be used as config at load_spritesheet. - * - * @param frame_width width of frame in pixels - * @param frame_height height of frame in pixels - * @param start_frame first frame to start parsing from - * @param margin margin in the image; this is the space around the edge of the frames - * @param spacing the spacing between each frame in the image - * @returns spritesheet config - */ -export function create_spritesheet_config( - frame_width: number, - frame_height: number, - start_frame: number = 0, - margin: number = 0, - spacing: number = 0 -): ObjectConfig { - return {}; -} - -// SCREEN - -/** - * Get in-game screen width. - * - * @return screen width - */ -export function get_screen_width(): number { - return -1; -} - -/** - * Get in-game screen height. - * - * @return screen height - */ -export function get_screen_height(): number { - return -1; -} - -/** - * Get game screen display width (accounting window size). - * - * @return screen display width - */ -export function get_screen_display_width(): number { - return -1; -} - -/** - * Get game screen display height (accounting window size). - * - * @return screen display height - */ -export function get_screen_display_height(): number { - return -1; -} - -// LOAD - -/** - * Load the image asset into the scene for use. All images - * must be loaded before used in create_image. - * - * @param key key to be associated with the image - * @param url path to the image - */ -export function load_image(key: string, url: string) {} - -/** - * Load the sound asset into the scene for use. All sound - * must be loaded before used in play_sound. - * - * @param key key to be associated with the sound - * @param url path to the sound - */ -export function load_sound(key: string, url: string) {} - -/** - * Load the spritesheet into the scene for use. All spritesheet must - * be loaded before used in create_image. - * - * @param key key associated with the spritesheet - * @param url path to the sound - * @param spritesheet_config config to determines frames within the spritesheet - */ -export function load_spritesheet( - key: string, - url: string, - spritesheet_config: ObjectConfig -) { - return {}; -} - -// ADD - -/** - * Add the object to the scene. Only objects added to the scene - * will appear. - * - * @param obj game object to be added - */ -export function add(obj: GameObject): GameObject | undefined { - return undefined; -} - -// SOUND - -/** - * Play the sound associated with the key. - * Throws error if key is non-existent. - * - * @param key key to the sound to be played - * @param config sound config to be used - */ -export function play_sound(key: string, config: ObjectConfig = {}): void {} - -// ANIMS - -/** - * Create a new animation and add it to the available animations. - * Animations are global i.e. once created, it can be used anytime, anywhere. - * - * NOTE: Anims DO NOT need to be added into the scene to be used. - * It is automatically added to the scene when it is created. - * - * Will return true if the animation key is valid - * (key is specified within the anim_config); false if the key - * is already in use. - * - * @param anim_config - * @returns true if animation is successfully created, false otherwise - */ -export function create_anim(anim_config: ObjectConfig): boolean { - return false; -} - -/** - * Start playing the given animation on image game object. - * - * @param image image game object - * @param anims_key key associated with an animation - */ -export function play_anim_on_image( - image: GameObject, - anims_key: string -): GameObject | undefined { - return undefined; -} - -// IMAGE - -/** - * Create an image using the key associated with a loaded image. - * If key is not associated with any loaded image, throws error. - * - * 0, 0 is located at the top, left hand side. - * - * @param x x position of the image. 0 is at the left side - * @param y y position of the image. 0 is at the top side - * @param asset_key key to loaded image - * @returns image game object - */ -export function create_image( - x: number, - y: number, - asset_key: string -): GameObject | undefined { - return undefined; -} - -// AWARD - -/** - * Create an award using the key associated with the award. - * The award key can be obtained from the Awards Hall or - * Awards menu, after attaining the award. - * - * Valid award will have an on-hover VERIFIED tag to distinguish - * it from images created by create_image. - * - * If student does not possess the award, this function will - * return a untagged, default image. - * - * @param x x position of the image. 0 is at the left side - * @param y y position of the image. 0 is at the top side - * @param award_key key for award - * @returns award game object - */ -export function create_award( - x: number, - y: number, - award_key: string -): GameObject { - return { type: 'null', object: undefined }; -} - -// TEXT - -/** - * Create a text object. - * - * 0, 0 is located at the top, left hand side. - * - * @param x x position of the text - * @param y y position of the text - * @param text text to be shown - * @param config text configuration to be used - * @returns text game object - */ -export function create_text( - x: number, - y: number, - text: string, - config: ObjectConfig = {} -): GameObject { - return { type: 'null', object: undefined }; -} - -// RECTANGLE - -/** - * Create a rectangle object. - * - * 0, 0 is located at the top, left hand side. - * - * @param x x coordinate of the top, left corner posiiton - * @param y y coordinate of the top, left corner position - * @param width width of rectangle - * @param height height of rectangle - * @param fill colour fill, in hext e.g 0xffffff - * @param alpha value between 0 and 1 to denote alpha - * @returns rectangle object - */ -export function create_rect( - x: number, - y: number, - width: number, - height: number, - fill: number = 0, - alpha: number = 1 -): GameObject { - return { type: 'null', object: undefined }; -} - -// ELLIPSE - -/** - * Create an ellipse object. - * - * @param x x coordinate of the centre of ellipse - * @param y y coordinate of the centre of ellipse - * @param width width of ellipse - * @param height height of ellipse - * @param fill colour fill, in hext e.g 0xffffff - * @param alpha value between 0 and 1 to denote alpha - * @returns ellipse object - */ -export function create_ellipse( - x: number, - y: number, - width: number, - height: number, - fill: number = 0, - alpha: number = 1 -): GameObject { - return { type: 'null', object: undefined }; -} - -// CONTAINER - -/** - * Create a container object. Container is able to contain any other game object, - * and the positions of contained game object will be relative to the container. - * - * Rendering the container as visible or invisible will also affect the contained - * game object. - * - * Container can also contain another container. - * - * 0, 0 is located at the top, left hand side. - * - * For more details about container object, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.GameObjects.Container.html - * - * @param x x position of the container - * @param y y position of the container - * @returns container object - */ -export function create_container(x: number, y: number): GameObject { - return { type: 'container', object: undefined }; -} - -/** - * Add the given game object to the container. - * Mutates the container. - * - * @param container container object - * @param obj game object to add to the container - * @returns container object - */ -export function add_to_container( - container: GameObject, - obj: GameObject -): GameObject | undefined { - return undefined; -} - -// OBJECT - -/** - * Destroy the given game object. Destroyed game object - * is removed from the scene, and all of its listeners - * is also removed. - * - * @param obj game object itself - */ -export function destroy_obj(obj: GameObject) {} - -/** - * Set the display size of the object. - * Mutate the object. - * - * @param obj object to be set - * @param x new display width size - * @param y new display height size - * @returns game object itself - */ -export function set_display_size( - obj: GameObject, - x: number, - y: number -): GameObject | undefined { - return undefined; -} - -/** - * Set the alpha of the object. - * Mutate the object. - * - * @param obj object to be set - * @param alpha new alpha - * @returns game object itself - */ -export function set_alpha( - obj: GameObject, - alpha: number -): GameObject | undefined { - return undefined; -} - -/** - * Set the interactivity of the object. - * Mutate the object. - * - * Rectangle and Ellipse are not able to receive configs, only boolean - * i.e. set_interactive(rect, true); set_interactive(ellipse, false) - * - * @param obj object to be set - * @param config interactive config to be used - * @returns game object itself - */ -export function set_interactive( - obj: GameObject, - config: ObjectConfig = {} -): GameObject | undefined { - return undefined; -} - -/** - * Set the origin in which all position related will be relative to. - * In other words, the anchor of the object. - * Mutate the object. - * - * @param obj object to be set - * @param x new anchor x coordinate, between value 0 to 1. - * @param y new anchor y coordinate, between value 0 to 1. - * @returns game object itself - */ -export function set_origin( - obj: GameObject, - x: number, - y: number -): GameObject | undefined { - return undefined; -} - -/** - * Set the position of the game object - * Mutate the object - * - * @param obj object to be set - * @param x new x position - * @param y new y position - * @returns game object itself - */ -export function set_position( - obj: GameObject, - x: number, - y: number -): GameObject | undefined { - return undefined; -} - -/** - * Set the scale of the object. - * Mutate the object. - * - * @param obj object to be set - * @param x new x scale - * @param y new y scale - * @returns game object itself - */ -export function set_scale( - obj: GameObject, - x: number, - y: number -): GameObject | undefined { - return undefined; -} - -/** - * Set the rotation of the object. - * Mutate the object. - * - * @param obj object to be set - * @param rad the rotation, in radians - * @returns game object itself - */ -export function set_rotation( - obj: GameObject, - rad: number -): GameObject | undefined { - return undefined; -} - -/** - * Sets the horizontal and flipped state of the object. - * Mutate the object. - * - * @param obj game object itself - * @param x to flip in the horizontal state - * @param y to flip in the vertical state - * @returns game object itself - */ -export function set_flip( - obj: GameObject, - x: boolean, - y: boolean -): GameObject | undefined { - return undefined; -} - -/** - * Creates a tween to the object and plays it. - * Mutate the object. - * - * @param obj object to be added to - * @param config tween config - * @returns game object itself - */ -export async function add_tween( - obj: GameObject, - config: ObjectConfig = {} -): Promise { - return undefined; -} - -// LISTENER - -/** - * Attach a listener to the object. The callback will be executed - * when the event is emitted. - * Mutate the object. - * - * For all available events, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Input.Events.html - * - * @param obj object to be added to - * @param event the event name - * @param callback listener function, executed on event - * @returns listener game object - */ -export function add_listener( - obj: GameObject, - event: string, - callback: Function -): GameObject | undefined { - return undefined; -} - -/** - * Attach a listener to the object. The callback will be executed - * when the event is emitted. - * Mutate the object. - * - * For all available events, see: - * https://photonstorm.github.io/phaser3-docs/Phaser.Input.Events.html - * - * For list of keycodes, see: - * https://github.com/photonstorm/phaser/blob/v3.22.0/src/input/keyboard/keys/KeyCodes.js - * - * @param key keyboard key to trigger listener - * @param event the event name - * @param callback listener function, executed on event - * @returns listener game object - */ -export function add_keyboard_listener( - key: string | number, - event: string, - callback: Function -): GameObject { - return { type: 'null', object: undefined }; -} - -/** - * Deactivate and remove listener. - * - * @param listener - * @returns if successful - */ -export function remove_listener(listener: GameObject): boolean { - return false; -} +/** + * Game library that translates Phaser 3 API into Source. + * + * More in-depth explanation of the Phaser 3 API can be found at + * Phaser 3 documentation itself. + * + * For Phaser 3 API Documentation, check: + * https://photonstorm.github.io/phaser3-docs/ + * + * @module game + * @author Anthony Halim + * @author Chi Xu + * @author Chong Sia Tiffany + * @author Gokul Rajiv + */ + +/* eslint-disable consistent-return, @typescript-eslint/default-param-last, @typescript-eslint/no-shadow, @typescript-eslint/no-unused-vars */ +import { + GameModuleParams, + GameObject, + List, + ObjectConfig, + RawContainer, + RawGameElement, + RawGameObject, + RawInputObject, +} from './types'; + +/** @hidden */ +export default function gameFuncs(moduleParams: GameModuleParams) { + const { + scene, + preloadImageMap, + preloadSoundMap, + preloadSpritesheetMap, + remotePath, + screenSize, + createAward, + } = moduleParams.game || {}; + + // Listener ObjectTypes + enum ListenerTypes { + InputPlugin = 'input_plugin', + KeyboardKeyType = 'keyboard_key', + } + + const ListnerTypes = Object.values(ListenerTypes); + + // Object ObjectTypes + enum ObjectTypes { + ImageType = 'image', + TextType = 'text', + RectType = 'rect', + EllipseType = 'ellipse', + ContainerType = 'container', + AwardType = 'award', + } + + const ObjTypes = Object.values(ObjectTypes); + + const nullFn = () => {}; + + // ============================================================================= + // Module's Private Functions + // ============================================================================= + + /** @hidden */ + function get_obj( + obj: GameObject, + ): RawGameObject | RawInputObject | RawContainer { + return obj.object!; + } + + /** @hidden */ + function get_game_obj(obj: GameObject): RawGameObject | RawContainer { + return obj.object as RawGameObject | RawContainer; + } + + /** @hidden */ + function get_input_obj(obj: GameObject): RawInputObject { + return obj.object as RawInputObject; + } + + /** @hidden */ + function get_container(obj: GameObject): RawContainer { + return obj.object as RawContainer; + } + + /** + * Checks whether the given game object is of the enquired type. + * If the given obj is undefined, will also return false. + * + * @param obj the game object + * @param type enquired type + * @returns if game object is of enquired type + * @hidden + */ + function is_type(obj: GameObject, type: string): boolean { + return obj !== undefined && obj.type === type && obj.object !== undefined; + } + + /** + * Checks whether the given game object is any of the enquired ObjectTypes + * + * @param obj the game object + * @param ObjectTypes enquired ObjectTypes + * @returns if game object is of any of the enquired ObjectTypes + * @hidden + */ + function is_any_type(obj: GameObject, types: string[]): boolean { + for (let i = 0; i < types.length; ++i) { + if (is_type(obj, types[i])) return true; + } + return false; + } + + /** + * Set a game object to the given type. + * Mutates the object. + * + * @param object the game object + * @param type type to set + * @returns typed game object + * @hidden + */ + function set_type( + object: RawGameObject | RawInputObject | RawContainer, + type: string, + ): GameObject { + return { + type, + object, + }; + } + + /** + * Throw a console error, including the function caller name. + * + * @param {string} message error message + * @hidden + */ + function throw_error(message: string) { + // eslint-disable-next-line no-caller, @typescript-eslint/no-throw-literal + throw console.error(`${arguments.callee.caller.name}: ${message}`); + } + + // List processing + // Original Author: Martin Henz + + /** + * array test works differently for Rhino and + * the Firefox environment (especially Web Console) + */ + function array_test(x: any): boolean { + if (Array.isArray === undefined) { + return x instanceof Array; + } + return Array.isArray(x); + } + + /** + * pair constructs a pair using a two-element array + * LOW-LEVEL FUNCTION, NOT SOURCE + */ + function pair(x: any, xs: any): [any, any] { + return [x, xs]; + } + + /** + * is_pair returns true iff arg is a two-element array + * LOW-LEVEL FUNCTION, NOT SOURCE + */ + function is_pair(x: any): boolean { + return array_test(x) && x.length === 2; + } + + /** + * head returns the first component of the given pair, + * throws an exception if the argument is not a pair + * LOW-LEVEL FUNCTION, NOT SOURCE + */ + function head(xs: List): any { + if (is_pair(xs)) { + return xs![0]; + } + throw new Error( + `head(xs) expects a pair as argument xs, but encountered ${xs}`, + ); + } + + /** + * tail returns the second component of the given pair + * throws an exception if the argument is not a pair + * LOW-LEVEL FUNCTION, NOT SOURCE + */ + function tail(xs: List) { + if (is_pair(xs)) { + return xs![1]; + } + throw new Error( + `tail(xs) expects a pair as argument xs, but encountered ${xs}`, + ); + } + + /** + * is_null returns true if arg is exactly null + * LOW-LEVEL FUNCTION, NOT SOURCE + */ + function is_null(xs: any) { + return xs === null; + } + + /** + * map applies first arg f to the elements of the second argument, + * assumed to be a list. + * f is applied element-by-element: + * map(f,[1,[2,[]]]) results in [f(1),[f(2),[]]] + * map throws an exception if the second argument is not a list, + * and if the second argument is a non-empty list and the first + * argument is not a function. + */ + function map(f: (x: any) => any, xs: List) { + return is_null(xs) ? null : pair(f(head(xs)), map(f, tail(xs))); + } + + // ============================================================================= + // Module's Exposed Functions + // ============================================================================= + + // HELPER + + function prepend_remote_url(asset_key: string): string { + return remotePath(asset_key); + } + + function create_config(lst: List): ObjectConfig { + const config = {}; + map((xs: [any, any]) => { + if (!is_pair(xs)) { + throw_error('xs is not pair!'); + } + config[head(xs)] = tail(xs); + }, lst); + return config; + } + + function create_text_config( + font_family: string = 'Courier', + font_size: string = '16px', + color: string = '#fff', + stroke: string = '#fff', + stroke_thickness: number = 0, + align: string = 'left', + ): ObjectConfig { + return { + fontFamily: font_family, + fontSize: font_size, + color, + stroke, + strokeThickness: stroke_thickness, + align, + }; + } + + function create_interactive_config( + draggable: boolean = false, + use_hand_cursor: boolean = false, + pixel_perfect: boolean = false, + alpha_tolerance: number = 1, + ): ObjectConfig { + return { + draggable, + useHandCursor: use_hand_cursor, + pixelPerfect: pixel_perfect, + alphaTolerance: alpha_tolerance, + }; + } + + function create_sound_config( + mute: boolean = false, + volume: number = 1, + rate: number = 1, + detune: number = 0, + seek: number = 0, + loop: boolean = false, + delay: number = 0, + ): ObjectConfig { + return { + mute, + volume, + rate, + detune, + seek, + loop, + delay, + }; + } + + function create_tween_config( + target_prop: string = 'x', + target_value: string | number = 0, + delay: number = 0, + duration: number = 1000, + ease: Function | string = 'Power0', + on_complete: Function = nullFn, + yoyo: boolean = false, + loop: number = 0, + loop_delay: number = 0, + on_loop: Function = nullFn, + ): ObjectConfig { + return { + [target_prop]: target_value, + delay, + duration, + ease, + onComplete: on_complete, + yoyo, + loop, + loopDelay: loop_delay, + onLoop: on_loop, + }; + } + + function create_anim_config( + anims_key: string, + anim_frames: ObjectConfig[], + frame_rate: number = 24, + duration: any = null, + repeat: number = -1, + yoyo: boolean = false, + show_on_start: boolean = true, + hide_on_complete: boolean = false, + ): ObjectConfig { + return { + key: anims_key, + frames: anim_frames, + frameRate: frame_rate, + duration, + repeat, + yoyo, + showOnStart: show_on_start, + hideOnComplete: hide_on_complete, + }; + } + + function create_anim_frame_config( + key: string, + duration: number = 0, + visible: boolean = true, + ): ObjectConfig { + return { + key, + duration, + visible, + }; + } + + function create_anim_spritesheet_frame_configs( + key: string, + ): ObjectConfig[] | undefined { + if (preloadSpritesheetMap.get(key)) { + const configArr = scene.anims.generateFrameNumbers(key, {}); + return configArr; + } + throw_error(`${key} is not associated with any spritesheet`); + } + + function create_spritesheet_config( + frame_width: number, + frame_height: number, + start_frame: number = 0, + margin: number = 0, + spacing: number = 0, + ): ObjectConfig { + return { + frameWidth: frame_width, + frameHeight: frame_height, + startFrame: start_frame, + margin, + spacing, + }; + } + + // SCREEN + + function get_screen_width(): number { + return screenSize.x; + } + + function get_screen_height(): number { + return screenSize.y; + } + + function get_screen_display_width(): number { + return scene.scale.displaySize.width; + } + + function get_screen_display_height(): number { + return scene.scale.displaySize.height; + } + + // LOAD + + function load_image(key: string, url: string) { + preloadImageMap.set(key, url); + } + + function load_sound(key: string, url: string) { + preloadSoundMap.set(key, url); + } + + function load_spritesheet( + key: string, + url: string, + spritesheet_config: ObjectConfig, + ) { + preloadSpritesheetMap.set(key, [url, spritesheet_config]); + } + + // ADD + + function add(obj: GameObject): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + scene.add.existing(get_game_obj(obj)); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + // SOUND + + function play_sound(key: string, config: ObjectConfig = {}): void { + if (preloadSoundMap.get(key)) { + scene.sound.play(key, config); + } else { + throw_error(`${key} is not associated with any sound`); + } + } + + // ANIMS + + function create_anim(anim_config: ObjectConfig): boolean { + const anims = scene.anims.create(anim_config); + return typeof anims !== 'boolean'; + } + + function play_anim_on_image( + image: GameObject, + anims_key: string, + ): GameObject | undefined { + if (is_type(image, ObjectTypes.ImageType)) { + (get_obj(image) as Phaser.GameObjects.Sprite).play(anims_key); + return image; + } + throw_error(`${image} is not of type ${ObjectTypes.ImageType}`); + } + + // IMAGE + + function create_image( + x: number, + y: number, + asset_key: string, + ): GameObject | undefined { + if ( + preloadImageMap.get(asset_key) + || preloadSpritesheetMap.get(asset_key) + ) { + const image = new Phaser.GameObjects.Sprite(scene, x, y, asset_key); + return set_type(image, ObjectTypes.ImageType); + } + throw_error(`${asset_key} is not associated with any image`); + } + + // AWARD + + function create_award(x: number, y: number, award_key: string): GameObject { + return set_type(createAward(x, y, award_key), ObjectTypes.AwardType); + } + + // TEXT + + function create_text( + x: number, + y: number, + text: string, + config: ObjectConfig = {}, + ): GameObject { + const txt = new Phaser.GameObjects.Text(scene, x, y, text, config); + return set_type(txt, ObjectTypes.TextType); + } + + // RECTANGLE + + function create_rect( + x: number, + y: number, + width: number, + height: number, + fill: number = 0, + alpha: number = 1, + ): GameObject { + const rect = new Phaser.GameObjects.Rectangle( + scene, + x, + y, + width, + height, + fill, + alpha, + ); + return set_type(rect, ObjectTypes.RectType); + } + + // ELLIPSE + + function create_ellipse( + x: number, + y: number, + width: number, + height: number, + fill: number = 0, + alpha: number = 1, + ): GameObject { + const ellipse = new Phaser.GameObjects.Ellipse( + scene, + x, + y, + width, + height, + fill, + alpha, + ); + return set_type(ellipse, ObjectTypes.EllipseType); + } + + // CONTAINER + + function create_container(x: number, y: number): GameObject { + const cont = new Phaser.GameObjects.Container(scene, x, y); + return set_type(cont, ObjectTypes.ContainerType); + } + + function add_to_container( + container: GameObject, + obj: GameObject, + ): GameObject | undefined { + if ( + is_type(container, ObjectTypes.ContainerType) + && is_any_type(obj, ObjTypes) + ) { + get_container(container) + .add(get_game_obj(obj)); + return container; + } + throw_error( + `${obj} is not of type ${ObjTypes} or ${container} is not of type ${ObjectTypes.ContainerType}`, + ); + } + + // OBJECT + + function destroy_obj(obj: GameObject) { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .destroy(); + } else { + throw_error(`${obj} is not of type ${ObjTypes}`); + } + } + + function set_display_size( + obj: GameObject, + x: number, + y: number, + ): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setDisplaySize(x, y); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + function set_alpha(obj: GameObject, alpha: number): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setAlpha(alpha); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + function set_interactive( + obj: GameObject, + config: ObjectConfig = {}, + ): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setInteractive(config); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + function set_origin( + obj: GameObject, + x: number, + y: number, + ): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + (get_game_obj(obj) as RawGameObject).setOrigin(x, y); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + function set_position( + obj: GameObject, + x: number, + y: number, + ): GameObject | undefined { + if (obj && is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setPosition(x, y); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + function set_scale( + obj: GameObject, + x: number, + y: number, + ): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setScale(x, y); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + function set_rotation(obj: GameObject, rad: number): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + get_game_obj(obj) + .setRotation(rad); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + function set_flip( + obj: GameObject, + x: boolean, + y: boolean, + ): GameObject | undefined { + const GameElementType = [ObjectTypes.ImageType, ObjectTypes.TextType]; + if (is_any_type(obj, GameElementType)) { + (get_obj(obj) as RawGameElement).setFlip(x, y); + return obj; + } + throw_error(`${obj} is not of type ${GameElementType}`); + } + + async function add_tween( + obj: GameObject, + config: ObjectConfig = {}, + ): Promise { + if (is_any_type(obj, ObjTypes)) { + scene.tweens.add({ + targets: get_game_obj(obj), + ...config, + }); + return obj; + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + // LISTENER + + function add_listener( + obj: GameObject, + event: string, + callback: Function, + ): GameObject | undefined { + if (is_any_type(obj, ObjTypes)) { + const listener = get_game_obj(obj) + .addListener(event, callback); + return set_type(listener, ListenerTypes.InputPlugin); + } + throw_error(`${obj} is not of type ${ObjTypes}`); + } + + function add_keyboard_listener( + key: string | number, + event: string, + callback: Function, + ): GameObject { + const keyObj = scene.input.keyboard.addKey(key); + const keyboardListener = keyObj.addListener(event, callback); + return set_type(keyboardListener, ListenerTypes.KeyboardKeyType); + } + + function remove_listener(listener: GameObject): boolean { + if (is_any_type(listener, ListnerTypes)) { + get_input_obj(listener) + .removeAllListeners(); + return true; + } + return false; + } + + const functions = { + add, + add_listener, + add_keyboard_listener, + add_to_container, + add_tween, + create_anim, + create_anim_config, + create_anim_frame_config, + create_anim_spritesheet_frame_configs, + create_award, + create_config, + create_container, + create_ellipse, + create_image, + create_interactive_config, + create_rect, + create_text, + create_text_config, + create_tween_config, + create_sound_config, + create_spritesheet_config, + destroy_obj, + get_screen_width, + get_screen_height, + get_screen_display_width, + get_screen_display_height, + load_image, + load_sound, + load_spritesheet, + play_anim_on_image, + play_sound, + prepend_remote_url, + remove_listener, + set_alpha, + set_display_size, + set_flip, + set_interactive, + set_origin, + set_position, + set_rotation, + set_scale, + }; + + const finalFunctions = {}; + + Object.entries(functions) + .forEach(([key, fn]) => { + finalFunctions[key] = !scene ? nullFn : fn; + }); + + return finalFunctions; +} + +// ============================================================================= +// Dummy functions for TypeDoc +// +// Refer to functions of matching signature in `gameFuncs` +// for implementation details +// ============================================================================= + +/** + * Prepend the given asset key with the remote path (S3 path). + * + * @param asset_key + * @returns prepended path + */ +export function prepend_remote_url(asset_key: string): string { + return ''; +} + +/** + * Transforms the given list into an object config. The list follows + * the format of list([key1, value1], [key2, value2]). + * + * e.g list(["alpha", 0], ["duration", 1000]) + * + * @param lst the list to be turned into object config. + * @returns object config + */ +export function create_config(lst: List): ObjectConfig { + return {}; +} + +/** + * Create text config object, can be used to stylise text object. + * + * font_family: for available font_family, see: + * https://developer.mozilla.org/en-US/docs/Web/CSS/font-family#Valid_family_names + * + * align: must be either 'left', 'right', 'center', or 'justify' + * + * For more details about text config, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.GameObjects.Text.html#.TextStyle + * + * @param font_family font to be used + * @param font_size size of font, must be appended with 'px' e.g. '16px' + * @param color colour of font, in hex e.g. '#fff' + * @param stroke colour of stroke, in hex e.g. '#fff' + * @param stroke_thickness thickness of stroke + * @param align text alignment + * @returns text config + */ +export function create_text_config( + font_family: string = 'Courier', + font_size: string = '16px', + color: string = '#fff', + stroke: string = '#fff', + stroke_thickness: number = 0, + align: string = 'left', +): ObjectConfig { + return {}; +} + +/** + * Create interactive config object, can be used to configure interactive settings. + * + * For more details about interactive config object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Input.html#.InputConfiguration + * + * @param draggable object will be set draggable + * @param use_hand_cursor if true, pointer will be set to 'pointer' when a pointer is over it + * @param pixel_perfect pixel perfect function will be set for the hit area. Only works for texture based object + * @param alpha_tolerance if pixel_perfect is set, this is the alpha tolerance threshold value used in the callback + * @returns interactive config + */ +export function create_interactive_config( + draggable: boolean = false, + use_hand_cursor: boolean = false, + pixel_perfect: boolean = false, + alpha_tolerance: number = 1, +): ObjectConfig { + return {}; +} + +/** + * Create sound config object, can be used to configure sound settings. + * + * For more details about sound config object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Sound.html#.SoundConfig + * + * @param mute whether the sound should be muted or not + * @param volume value between 0(silence) and 1(full volume) + * @param rate the speed at which the sound is played + * @param detune detuning of the sound, in cents + * @param seek position of playback for the sound, in seconds + * @param loop whether or not the sound should loop + * @param delay time, in seconds, that elapse before the sound actually starts + * @returns sound config + */ +export function create_sound_config( + mute: boolean = false, + volume: number = 1, + rate: number = 1, + detune: number = 0, + seek: number = 0, + loop: boolean = false, + delay: number = 0, +): ObjectConfig { + return {}; +} + +/** + * Create tween config object, can be used to configure tween settings. + * + * For more details about tween config object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Tweens.html#.TweenBuilderConfig + * + * @param target_prop target to tween, e.g. x, y, alpha + * @param target_value the property value to tween to + * @param delay time in ms/frames before tween will start + * @param duration duration of tween in ms/frames, exclude yoyos or repeats + * @param ease ease function to use, e.g. 'Power0', 'Power1', 'Power2' + * @param on_complete function to execute when tween completes + * @param yoyo if set to true, once tween complete, reverses the values incrementally to get back to the starting tween values + * @param loop number of times the tween should loop, or -1 to loop indefinitely + * @param loop_delay The time the tween will pause before starting either a yoyo or returning to the start for a repeat + * @param on_loop function to execute each time the tween loops + * @returns tween config + */ +export function create_tween_config( + target_prop: string = 'x', + target_value: string | number = 0, + delay: number = 0, + duration: number = 1000, + ease: Function | string = 'Power0', + on_complete: Function, + yoyo: boolean = false, + loop: number = 0, + loop_delay: number = 0, + on_loop: Function, +): ObjectConfig { + return {}; +} + +/** + * Create anims config, can be used to configure anims + * + * For more details about the config object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Types.Animations.html#.Animation + * + * @param anims_key key that the animation will be associated with + * @param anim_frames data used to generate the frames for animation + * @param frame_rate frame rate of playback in frames per second + * @param duration how long the animation should play in seconds. + * If null, will be derived from frame_rate + * @param repeat number of times to repeat the animation, -1 for infinity + * @param yoyo should the animation yoyo (reverse back down to the start) + * @param show_on_start should the sprite be visible when the anims start? + * @param hide_on_complete should the sprite be not visible when the anims finish? + * @returns animation config + */ +export function create_anim_config( + anims_key: string, + anim_frames: ObjectConfig[], + frame_rate: number = 24, + duration: any = null, + repeat: number = -1, + yoyo: boolean = false, + show_on_start: boolean = true, + hide_on_complete: boolean = false, +): ObjectConfig { + return {}; +} + +/** + * Create animation frame config, can be used to configure a specific frame + * within an animation. + * + * The key should refer to an image that is already loaded. + * To make frame_config from spritesheet based on its frames, + * use create_anim_spritesheet_frame_configs instead. + * + * @param key key that is associated with the sprite at this frame + * @param duration duration, in ms, of this frame of the animation + * @param visible should the parent object be visible during this frame? + * @returns animation frame config + */ +export function create_anim_frame_config( + key: string, + duration: number = 0, + visible: boolean = true, +): ObjectConfig { + return {}; +} + +/** + * Create list of animation frame config, can be used directly as part of + * anim_config's `frames` parameter. + * + * This function will generate list of frame configs based on the + * spritesheet_config attached to the associated spritesheet. + * This function requires that the given key is a spritesheet key + * i.e. a key associated with loaded spritesheet, loaded in using + * load_spritesheet function. + * + * Will return empty frame configs if key is not associated with + * a spritesheet. + * + * @param key key associated with spritesheet + * @returns animation frame configs + */ +export function create_anim_spritesheet_frame_configs( + key: string, +): ObjectConfig[] | undefined { + return undefined; +} + +/** + * Create spritesheet config, can be used to configure the frames within the + * spritesheet. Can be used as config at load_spritesheet. + * + * @param frame_width width of frame in pixels + * @param frame_height height of frame in pixels + * @param start_frame first frame to start parsing from + * @param margin margin in the image; this is the space around the edge of the frames + * @param spacing the spacing between each frame in the image + * @returns spritesheet config + */ +export function create_spritesheet_config( + frame_width: number, + frame_height: number, + start_frame: number = 0, + margin: number = 0, + spacing: number = 0, +): ObjectConfig { + return {}; +} + +// SCREEN + +/** + * Get in-game screen width. + * + * @return screen width + */ +export function get_screen_width(): number { + return -1; +} + +/** + * Get in-game screen height. + * + * @return screen height + */ +export function get_screen_height(): number { + return -1; +} + +/** + * Get game screen display width (accounting window size). + * + * @return screen display width + */ +export function get_screen_display_width(): number { + return -1; +} + +/** + * Get game screen display height (accounting window size). + * + * @return screen display height + */ +export function get_screen_display_height(): number { + return -1; +} + +// LOAD + +/** + * Load the image asset into the scene for use. All images + * must be loaded before used in create_image. + * + * @param key key to be associated with the image + * @param url path to the image + */ +export function load_image(key: string, url: string) {} + +/** + * Load the sound asset into the scene for use. All sound + * must be loaded before used in play_sound. + * + * @param key key to be associated with the sound + * @param url path to the sound + */ +export function load_sound(key: string, url: string) {} + +/** + * Load the spritesheet into the scene for use. All spritesheet must + * be loaded before used in create_image. + * + * @param key key associated with the spritesheet + * @param url path to the sound + * @param spritesheet_config config to determines frames within the spritesheet + */ +export function load_spritesheet( + key: string, + url: string, + spritesheet_config: ObjectConfig, +) { + return {}; +} + +// ADD + +/** + * Add the object to the scene. Only objects added to the scene + * will appear. + * + * @param obj game object to be added + */ +export function add(obj: GameObject): GameObject | undefined { + return undefined; +} + +// SOUND + +/** + * Play the sound associated with the key. + * Throws error if key is non-existent. + * + * @param key key to the sound to be played + * @param config sound config to be used + */ +export function play_sound(key: string, config: ObjectConfig = {}): void {} + +// ANIMS + +/** + * Create a new animation and add it to the available animations. + * Animations are global i.e. once created, it can be used anytime, anywhere. + * + * NOTE: Anims DO NOT need to be added into the scene to be used. + * It is automatically added to the scene when it is created. + * + * Will return true if the animation key is valid + * (key is specified within the anim_config); false if the key + * is already in use. + * + * @param anim_config + * @returns true if animation is successfully created, false otherwise + */ +export function create_anim(anim_config: ObjectConfig): boolean { + return false; +} + +/** + * Start playing the given animation on image game object. + * + * @param image image game object + * @param anims_key key associated with an animation + */ +export function play_anim_on_image( + image: GameObject, + anims_key: string, +): GameObject | undefined { + return undefined; +} + +// IMAGE + +/** + * Create an image using the key associated with a loaded image. + * If key is not associated with any loaded image, throws error. + * + * 0, 0 is located at the top, left hand side. + * + * @param x x position of the image. 0 is at the left side + * @param y y position of the image. 0 is at the top side + * @param asset_key key to loaded image + * @returns image game object + */ +export function create_image( + x: number, + y: number, + asset_key: string, +): GameObject | undefined { + return undefined; +} + +// AWARD + +/** + * Create an award using the key associated with the award. + * The award key can be obtained from the Awards Hall or + * Awards menu, after attaining the award. + * + * Valid award will have an on-hover VERIFIED tag to distinguish + * it from images created by create_image. + * + * If student does not possess the award, this function will + * return a untagged, default image. + * + * @param x x position of the image. 0 is at the left side + * @param y y position of the image. 0 is at the top side + * @param award_key key for award + * @returns award game object + */ +export function create_award( + x: number, + y: number, + award_key: string, +): GameObject { + return { + type: 'null', + object: undefined, + }; +} + +// TEXT + +/** + * Create a text object. + * + * 0, 0 is located at the top, left hand side. + * + * @param x x position of the text + * @param y y position of the text + * @param text text to be shown + * @param config text configuration to be used + * @returns text game object + */ +export function create_text( + x: number, + y: number, + text: string, + config: ObjectConfig = {}, +): GameObject { + return { + type: 'null', + object: undefined, + }; +} + +// RECTANGLE + +/** + * Create a rectangle object. + * + * 0, 0 is located at the top, left hand side. + * + * @param x x coordinate of the top, left corner posiiton + * @param y y coordinate of the top, left corner position + * @param width width of rectangle + * @param height height of rectangle + * @param fill colour fill, in hext e.g 0xffffff + * @param alpha value between 0 and 1 to denote alpha + * @returns rectangle object + */ +export function create_rect( + x: number, + y: number, + width: number, + height: number, + fill: number = 0, + alpha: number = 1, +): GameObject { + return { + type: 'null', + object: undefined, + }; +} + +// ELLIPSE + +/** + * Create an ellipse object. + * + * @param x x coordinate of the centre of ellipse + * @param y y coordinate of the centre of ellipse + * @param width width of ellipse + * @param height height of ellipse + * @param fill colour fill, in hext e.g 0xffffff + * @param alpha value between 0 and 1 to denote alpha + * @returns ellipse object + */ +export function create_ellipse( + x: number, + y: number, + width: number, + height: number, + fill: number = 0, + alpha: number = 1, +): GameObject { + return { + type: 'null', + object: undefined, + }; +} + +// CONTAINER + +/** + * Create a container object. Container is able to contain any other game object, + * and the positions of contained game object will be relative to the container. + * + * Rendering the container as visible or invisible will also affect the contained + * game object. + * + * Container can also contain another container. + * + * 0, 0 is located at the top, left hand side. + * + * For more details about container object, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.GameObjects.Container.html + * + * @param x x position of the container + * @param y y position of the container + * @returns container object + */ +export function create_container(x: number, y: number): GameObject { + return { + type: 'container', + object: undefined, + }; +} + +/** + * Add the given game object to the container. + * Mutates the container. + * + * @param container container object + * @param obj game object to add to the container + * @returns container object + */ +export function add_to_container( + container: GameObject, + obj: GameObject, +): GameObject | undefined { + return undefined; +} + +// OBJECT + +/** + * Destroy the given game object. Destroyed game object + * is removed from the scene, and all of its listeners + * is also removed. + * + * @param obj game object itself + */ +export function destroy_obj(obj: GameObject) {} + +/** + * Set the display size of the object. + * Mutate the object. + * + * @param obj object to be set + * @param x new display width size + * @param y new display height size + * @returns game object itself + */ +export function set_display_size( + obj: GameObject, + x: number, + y: number, +): GameObject | undefined { + return undefined; +} + +/** + * Set the alpha of the object. + * Mutate the object. + * + * @param obj object to be set + * @param alpha new alpha + * @returns game object itself + */ +export function set_alpha( + obj: GameObject, + alpha: number, +): GameObject | undefined { + return undefined; +} + +/** + * Set the interactivity of the object. + * Mutate the object. + * + * Rectangle and Ellipse are not able to receive configs, only boolean + * i.e. set_interactive(rect, true); set_interactive(ellipse, false) + * + * @param obj object to be set + * @param config interactive config to be used + * @returns game object itself + */ +export function set_interactive( + obj: GameObject, + config: ObjectConfig = {}, +): GameObject | undefined { + return undefined; +} + +/** + * Set the origin in which all position related will be relative to. + * In other words, the anchor of the object. + * Mutate the object. + * + * @param obj object to be set + * @param x new anchor x coordinate, between value 0 to 1. + * @param y new anchor y coordinate, between value 0 to 1. + * @returns game object itself + */ +export function set_origin( + obj: GameObject, + x: number, + y: number, +): GameObject | undefined { + return undefined; +} + +/** + * Set the position of the game object + * Mutate the object + * + * @param obj object to be set + * @param x new x position + * @param y new y position + * @returns game object itself + */ +export function set_position( + obj: GameObject, + x: number, + y: number, +): GameObject | undefined { + return undefined; +} + +/** + * Set the scale of the object. + * Mutate the object. + * + * @param obj object to be set + * @param x new x scale + * @param y new y scale + * @returns game object itself + */ +export function set_scale( + obj: GameObject, + x: number, + y: number, +): GameObject | undefined { + return undefined; +} + +/** + * Set the rotation of the object. + * Mutate the object. + * + * @param obj object to be set + * @param rad the rotation, in radians + * @returns game object itself + */ +export function set_rotation( + obj: GameObject, + rad: number, +): GameObject | undefined { + return undefined; +} + +/** + * Sets the horizontal and flipped state of the object. + * Mutate the object. + * + * @param obj game object itself + * @param x to flip in the horizontal state + * @param y to flip in the vertical state + * @returns game object itself + */ +export function set_flip( + obj: GameObject, + x: boolean, + y: boolean, +): GameObject | undefined { + return undefined; +} + +/** + * Creates a tween to the object and plays it. + * Mutate the object. + * + * @param obj object to be added to + * @param config tween config + * @returns game object itself + */ +export async function add_tween( + obj: GameObject, + config: ObjectConfig = {}, +): Promise { + return undefined; +} + +// LISTENER + +/** + * Attach a listener to the object. The callback will be executed + * when the event is emitted. + * Mutate the object. + * + * For all available events, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Input.Events.html + * + * @param obj object to be added to + * @param event the event name + * @param callback listener function, executed on event + * @returns listener game object + */ +export function add_listener( + obj: GameObject, + event: string, + callback: Function, +): GameObject | undefined { + return undefined; +} + +/** + * Attach a listener to the object. The callback will be executed + * when the event is emitted. + * Mutate the object. + * + * For all available events, see: + * https://photonstorm.github.io/phaser3-docs/Phaser.Input.Events.html + * + * For list of keycodes, see: + * https://github.com/photonstorm/phaser/blob/v3.22.0/src/input/keyboard/keys/KeyCodes.js + * + * @param key keyboard key to trigger listener + * @param event the event name + * @param callback listener function, executed on event + * @returns listener game object + */ +export function add_keyboard_listener( + key: string | number, + event: string, + callback: Function, +): GameObject { + return { + type: 'null', + object: undefined, + }; +} + +/** + * Deactivate and remove listener. + * + * @param listener + * @returns if successful + */ +export function remove_listener(listener: GameObject): boolean { + return false; +} diff --git a/src/bundles/game/index.ts b/src/bundles/game/index.ts index 9474b6573..d7d35f992 100644 --- a/src/bundles/game/index.ts +++ b/src/bundles/game/index.ts @@ -1,8 +1,8 @@ -import { ModuleContexts } from '../../typings/type_helpers.js'; -import gameFuncs from './functions'; -import { GameModuleParams } from './types.js'; - -export default ( - moduleParams: GameModuleParams, - _moduleContexts: ModuleContexts -) => gameFuncs(moduleParams); +import { ModuleContexts } from '../../typings/type_helpers.js'; +import gameFuncs from './functions'; +import { GameModuleParams } from './types.js'; + +export default ( + moduleParams: GameModuleParams, + _moduleContexts: ModuleContexts, +) => gameFuncs(moduleParams); diff --git a/src/bundles/game/types.ts b/src/bundles/game/types.ts index 36362f48c..968a6a75a 100644 --- a/src/bundles/game/types.ts +++ b/src/bundles/game/types.ts @@ -1,41 +1,41 @@ -import * as Phaser from 'phaser'; -import { ModuleParams } from '../../typings/type_helpers.js'; - -export type List = [any, List] | null; - -export type ObjectConfig = { [attr: string]: any }; - -export type RawGameElement = - | Phaser.GameObjects.Sprite - | Phaser.GameObjects.Text; - -export type RawGameShape = - | Phaser.GameObjects.Rectangle - | Phaser.GameObjects.Ellipse; - -export type RawGameObject = RawGameElement | RawGameShape; - -export type RawContainer = Phaser.GameObjects.Container; - -export type RawInputObject = - | Phaser.Input.InputPlugin - | Phaser.Input.Keyboard.Key; - -export type GameObject = { - type: string; - object: RawGameObject | RawInputObject | RawContainer | undefined; -}; - -export type GameModuleParams = ModuleParams & { - game: GameParams; -}; - -export type GameParams = { - scene: Phaser.Scene; - preloadImageMap: Map; - preloadSoundMap: Map; - preloadSpritesheetMap: Map; - remotePath: (path: string) => string; - screenSize: { x: number; y: number }; - createAward: (x: number, y: number, key: string) => Phaser.GameObjects.Sprite; -}; +import * as Phaser from 'phaser'; +import { ModuleParams } from '../../typings/type_helpers.js'; + +export type List = [any, List] | null; + +export type ObjectConfig = { [attr: string]: any }; + +export type RawGameElement = + | Phaser.GameObjects.Sprite + | Phaser.GameObjects.Text; + +export type RawGameShape = + | Phaser.GameObjects.Rectangle + | Phaser.GameObjects.Ellipse; + +export type RawGameObject = RawGameElement | RawGameShape; + +export type RawContainer = Phaser.GameObjects.Container; + +export type RawInputObject = + | Phaser.Input.InputPlugin + | Phaser.Input.Keyboard.Key; + +export type GameObject = { + type: string; + object: RawGameObject | RawInputObject | RawContainer | undefined; +}; + +export type GameModuleParams = ModuleParams & { + game: GameParams; +}; + +export type GameParams = { + scene: Phaser.Scene; + preloadImageMap: Map; + preloadSoundMap: Map; + preloadSpritesheetMap: Map; + remotePath: (path: string) => string; + screenSize: { x: number; y: number }; + createAward: (x: number, y: number, key: string) => Phaser.GameObjects.Sprite; +}; diff --git a/src/bundles/mark_sweep/index.ts b/src/bundles/mark_sweep/index.ts index afff55611..dae23adbb 100644 --- a/src/bundles/mark_sweep/index.ts +++ b/src/bundles/mark_sweep/index.ts @@ -56,7 +56,7 @@ function initialize_memory( memorySize: number, nodeSize, marked, - unmarked + unmarked, ): void { MEMORY_SIZE = memorySize; NODE_SIZE = nodeSize; @@ -91,7 +91,7 @@ function newCommand( description, firstDesc, lastDesc, - queue = [] + queue = [], ): void { const newType = type; const newLeft = left; @@ -139,7 +139,7 @@ function newSweep(left, heap): void { heap, desc, 'freed node', - '' + '', ); } @@ -156,7 +156,7 @@ function newMark(left, heap, queue): void { desc, 'marked node', '', - queue + queue, ); } @@ -167,7 +167,7 @@ function addRoots(arr): void { } function showRoot(heap): void { - const desc = `All root nodes are marked`; + const desc = 'All root nodes are marked'; newCommand(COMMAND.SHOW_MARKED, -1, -1, 0, 0, heap, desc, '', ''); } @@ -189,7 +189,7 @@ function newUpdateSweep(right, heap): void { heap, desc, 'free node', - '' + '', ); } @@ -204,7 +204,7 @@ function newPush(left, right, heap): void { heap, desc, 'last child address slot', - 'new child pushed' + 'new child pushed', ); } @@ -220,7 +220,7 @@ function newPop(res, left, right, heap): void { heap, desc, 'popped memory', - 'last child address slot' + 'last child address slot', ); } @@ -242,18 +242,18 @@ function newNew(left, heap): void { heap, desc, 'new memory allocated', - '' + '', ); } function newGC(heap): void { - const desc = `Memory exhausted, start Mark and Sweep Algorithm`; + const desc = 'Memory exhausted, start Mark and Sweep Algorithm'; newCommand(COMMAND.START, -1, -1, 0, 0, heap, desc, '', ''); updateFlip(); } function endGC(heap): void { - const desc = `Result of free memory`; + const desc = 'Result of free memory'; newCommand(COMMAND.END, -1, -1, 0, 0, heap, desc, '', ''); updateFlip(); } @@ -262,7 +262,7 @@ function updateSlotSegment( tag: number, size: number, first: number, - last: number + last: number, ): void { if (tag >= 0) { TAG_SLOT = tag; diff --git a/src/bundles/mark_sweep/types.ts b/src/bundles/mark_sweep/types.ts index 56c71389d..8d30ca24a 100644 --- a/src/bundles/mark_sweep/types.ts +++ b/src/bundles/mark_sweep/types.ts @@ -1,32 +1,32 @@ -export type Memory = number[]; -export type MemoryHeaps = Memory[]; -export type Tag = number; - -export enum COMMAND { - FLIP = 'Flip', - PUSH = 'Push', - POP = 'Pop', - COPY = 'Copy', - ASSIGN = 'Assign', - NEW = 'New', - START = 'Mark and Sweep Start', - END = 'End of Garbage Collector', - RESET = 'Sweep Reset', - SHOW_MARKED = 'Marked Roots', - MARK = 'Mark', - SWEEP = 'Sweep', - INIT = 'Initialize Memory', -} - -export type CommandHeapObject = { - type: String; - heap: number[]; - left: number; - right: number; - sizeLeft: number; - sizeRight: number; - desc: String; - leftDesc: String; - rightDesc: String; - queue: number[]; -}; +export type Memory = number[]; +export type MemoryHeaps = Memory[]; +export type Tag = number; + +export enum COMMAND { + FLIP = 'Flip', + PUSH = 'Push', + POP = 'Pop', + COPY = 'Copy', + ASSIGN = 'Assign', + NEW = 'New', + START = 'Mark and Sweep Start', + END = 'End of Garbage Collector', + RESET = 'Sweep Reset', + SHOW_MARKED = 'Marked Roots', + MARK = 'Mark', + SWEEP = 'Sweep', + INIT = 'Initialize Memory', +} + +export type CommandHeapObject = { + type: String; + heap: number[]; + left: number; + right: number; + sizeLeft: number; + sizeRight: number; + desc: String; + leftDesc: String; + rightDesc: String; + queue: number[]; +}; diff --git a/src/bundles/pix_n_flix/constants.ts b/src/bundles/pix_n_flix/constants.ts index d34117f26..1245489a7 100644 --- a/src/bundles/pix_n_flix/constants.ts +++ b/src/bundles/pix_n_flix/constants.ts @@ -1,14 +1,14 @@ -// Default values of video -export const DEFAULT_WIDTH: number = 400; -export const DEFAULT_HEIGHT: number = 300; -export const DEFAULT_FPS: number = 10; -export const DEFAULT_VOLUME: number = 0.5; -export const DEFAULT_LOOP: number = Infinity; - -// Maximum values allowed for video -export const MAX_HEIGHT: number = 1024; -export const MIN_HEIGHT: number = 1; -export const MAX_WIDTH: number = 1024; -export const MIN_WIDTH: number = 1; -export const MAX_FPS: number = 60; -export const MIN_FPS: number = 1; +// Default values of video +export const DEFAULT_WIDTH: number = 400; +export const DEFAULT_HEIGHT: number = 300; +export const DEFAULT_FPS: number = 10; +export const DEFAULT_VOLUME: number = 0.5; +export const DEFAULT_LOOP: number = Infinity; + +// Maximum values allowed for video +export const MAX_HEIGHT: number = 1024; +export const MIN_HEIGHT: number = 1; +export const MAX_WIDTH: number = 1024; +export const MIN_WIDTH: number = 1; +export const MAX_FPS: number = 60; +export const MIN_FPS: number = 1; diff --git a/src/bundles/pix_n_flix/functions.ts b/src/bundles/pix_n_flix/functions.ts index 25ec6f4d0..4ac702433 100644 --- a/src/bundles/pix_n_flix/functions.ts +++ b/src/bundles/pix_n_flix/functions.ts @@ -1,773 +1,779 @@ -/** - * The pix_n_flix module allows us to process still images and videos. - * - * An Image (which is a still image or a frame of a video) is a - * two-dimensional array of Pixels, and a Pixel consists of red, blue and green color - * values, each ranging from 0 to 255. To access these color values of a Pixel, we - * provide the functions red_of, blue_of and green_of. - * - * A central element of pix_n_flix is the notion of a Filter, a function that is applied - * to two Images: the source Image and the destination Image. When a Filter is installed - * (using the function install_filter), it transforms each source Image from the live camera - * or from a local/remote file to a destination Image that is then displayed on screen - * in the Source Academy "Pix N Flix" tab (with a camera icon). - * - * The dimensions (i.e. width and height) of the displayed images can be set by the user using - * the function set_dimensions, and all source and destination Images of the Filters will - * also be set to the same dimensions. To access the current dimensions of the Images, the user - * can use the functions image_width and image_height. - * - * @module pix_n_flix - */ - -/* eslint-disable @typescript-eslint/no-shadow */ -import { - CanvasElement, - VideoElement, - ErrorLogger, - StartPacket, - Pixel, - Pixels, - Filter, - Queue, - TabsPacket, - BundlePacket, - InputFeed, - ImageElement, -} from './types'; - -import { - DEFAULT_WIDTH, - DEFAULT_HEIGHT, - DEFAULT_FPS, - DEFAULT_VOLUME, - MAX_HEIGHT, - MIN_HEIGHT, - MAX_WIDTH, - MIN_WIDTH, - MAX_FPS, - MIN_FPS, - DEFAULT_LOOP, -} from './constants'; - -// Global Variables -let WIDTH: number = DEFAULT_WIDTH; -let HEIGHT: number = DEFAULT_HEIGHT; -let FPS: number = DEFAULT_FPS; -let VOLUME: number = DEFAULT_VOLUME; -let LOOP_COUNT: number = DEFAULT_LOOP; - -let imageElement: ImageElement; -let videoElement: VideoElement; -let canvasElement: CanvasElement; -let canvasRenderingContext: CanvasRenderingContext2D; -let errorLogger: ErrorLogger; -let tabsPackage: TabsPacket; - -const pixels: Pixels = []; -const temporaryPixels: Pixels = []; -let filter: Filter = copy_image; - -let toRunLateQueue: boolean = false; -let videoIsPlaying: boolean = false; - -let requestId: number; -let prevTime: number | null = null; -let totalElapsedTime: number = 0; -let playCount: number = 0; - -let inputFeed: InputFeed = InputFeed.Camera; -let url: string = ''; - -// Images dont aspect ratio correctly -let keepAspectRatio: boolean = true; -let intrinsicWidth: number = WIDTH; -let intrinsicHeight: number = HEIGHT; -let displayWidth: number = WIDTH; -let displayHeight: number = HEIGHT; - -// ============================================================================= -// Module's Private Functions -// ============================================================================= - -/** @hidden */ -function setupData(): void { - for (let i = 0; i < HEIGHT; i += 1) { - pixels[i] = []; - temporaryPixels[i] = []; - for (let j = 0; j < WIDTH; j += 1) { - pixels[i][j] = [0, 0, 0, 255]; - temporaryPixels[i][j] = [0, 0, 0, 255]; - } - } -} - -/** @hidden */ -function isPixelFilled(pixel: Pixel): boolean { - let ok = true; - for (let i = 0; i < 4; i += 1) { - if (pixel[i] >= 0 && pixel[i] <= 255) { - continue; - } - ok = false; - pixel[i] = 0; - } - return ok; -} - -/** @hidden */ -function writeToBuffer(buffer: Uint8ClampedArray, data: Pixels) { - let ok: boolean = true; - - for (let i = 0; i < HEIGHT; i += 1) { - for (let j = 0; j < WIDTH; j += 1) { - const p = i * WIDTH * 4 + j * 4; - if (isPixelFilled(data[i][j]) === false) { - ok = false; - } - buffer[p] = data[i][j][0]; - buffer[p + 1] = data[i][j][1]; - buffer[p + 2] = data[i][j][2]; - buffer[p + 3] = data[i][j][3]; - } - } - - if (!ok) { - const warningMessage = - 'You have invalid values for some pixels! Reseting them to default (0)'; - console.warn(warningMessage); - errorLogger(warningMessage, false); - } -} - -/** @hidden */ -function readFromBuffer(pixelData: Uint8ClampedArray, src: Pixels) { - for (let i = 0; i < HEIGHT; i += 1) { - for (let j = 0; j < WIDTH; j += 1) { - const p = i * WIDTH * 4 + j * 4; - src[i][j] = [ - pixelData[p], - pixelData[p + 1], - pixelData[p + 2], - pixelData[p + 3], - ]; - } - } -} - -/** @hidden */ -function drawImage(source: VideoElement | ImageElement): void { - if (keepAspectRatio) { - canvasRenderingContext.rect(0, 0, WIDTH, HEIGHT); - canvasRenderingContext.fill(); - canvasRenderingContext.drawImage( - source, - 0, - 0, - intrinsicWidth, - intrinsicHeight, - (WIDTH - displayWidth) / 2, - (HEIGHT - displayHeight) / 2, - displayWidth, - displayHeight - ); - } else canvasRenderingContext.drawImage(source, 0, 0, WIDTH, HEIGHT); - - const pixelObj = canvasRenderingContext.getImageData(0, 0, WIDTH, HEIGHT); - readFromBuffer(pixelObj.data, pixels); - - // Runtime checks to guard against crashes - try { - filter(pixels, temporaryPixels); - writeToBuffer(pixelObj.data, temporaryPixels); - } catch (e: any) { - console.error(JSON.stringify(e)); - const errMsg = `There is an error with filter function, filter will be reset to default. ${e.name}: ${e.message}`; - console.error(errMsg); - - if (!e.name) { - errorLogger( - 'There is an error with filter function (error shown below). Filter will be reset back to the default. If you are facing an infinite loop error, you can consider increasing the timeout period (clock icon) at the top / reducing the frame dimensions.' - ); - - errorLogger([e], true); - } else { - errorLogger(errMsg, false); - } - - filter = copy_image; - filter(pixels, temporaryPixels); - } - - canvasRenderingContext.putImageData(pixelObj, 0, 0); -} - -/** @hidden */ -function draw(timestamp: number): void { - requestId = window.requestAnimationFrame(draw); - - if (prevTime === null) prevTime = timestamp; - - const elapsed = timestamp - prevTime; - if (elapsed > 1000 / FPS && videoIsPlaying) { - drawImage(videoElement); - prevTime = timestamp; - totalElapsedTime += elapsed; - if (toRunLateQueue) { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - lateQueue(); - toRunLateQueue = false; - } - } -} - -/** @hidden */ -function playVideoElement() { - if (!videoIsPlaying) { - videoElement - .play() - .then(() => { - videoIsPlaying = true; - }) - .catch((err) => { - console.warn(err); - }); - } -} - -/** @hidden */ -function pauseVideoElement() { - if (videoIsPlaying) { - videoElement.pause(); - videoIsPlaying = false; - } -} - -/** @hidden */ -function startVideo(): void { - if (videoIsPlaying) return; - if (inputFeed === InputFeed.Camera) videoIsPlaying = true; - else playVideoElement(); - requestId = window.requestAnimationFrame(draw); -} - -/** - * Stops the loop that is drawing on image. - * - * @hidden - */ -function stopVideo(): void { - if (!videoIsPlaying) return; - if (inputFeed === InputFeed.Camera) videoIsPlaying = false; - else pauseVideoElement(); - window.cancelAnimationFrame(requestId); - prevTime = null; -} - -/** @hidden */ -function setAspectRatioDimensions(w: number, h: number): void { - intrinsicHeight = h; - intrinsicWidth = w; - const scale = Math.min(WIDTH / w, HEIGHT / h); - displayWidth = scale * w; - displayHeight = scale * h; -} - -/** @hidden */ -function loadMedia(): void { - if (!navigator.mediaDevices.getUserMedia) { - const errMsg = 'The browser you are using does not support getUserMedia'; - console.error(errMsg); - errorLogger(errMsg, false); - } - - // If video is already part of bundle state - if (videoElement.srcObject) return; - - navigator.mediaDevices - .getUserMedia({ video: true }) - .then((stream) => { - videoElement.srcObject = stream; - videoElement.onloadedmetadata = () => - setAspectRatioDimensions( - videoElement.videoWidth, - videoElement.videoHeight - ); - toRunLateQueue = true; - }) - .catch((error) => { - const errorMessage = `${error.name}: ${error.message}`; - console.error(errorMessage); - errorLogger(errorMessage, false); - }); - - startVideo(); -} - -/** @hidden */ -function loadAlternative(): void { - try { - if (inputFeed === InputFeed.VideoURL) { - videoElement.src = url; - startVideo(); - } else if (inputFeed === InputFeed.ImageURL) { - imageElement.src = url; - } - } catch (e: any) { - console.error(JSON.stringify(e)); - const errMsg = `There is an error loading the URL. ${e.name}: ${e.message}`; - console.error(errMsg); - loadMedia(); - return; - } - toRunLateQueue = true; - - /** Setting Up videoElement */ - videoElement.crossOrigin = 'anonymous'; - videoElement.onended = () => { - playCount++; - if (playCount == LOOP_COUNT) { - tabsPackage.onClickStill() - playCount = 0; - } else if (playCount < LOOP_COUNT) { - stopVideo(); - startVideo(); - } else { - playCount = 0; - } - }; - videoElement.onloadedmetadata = () => { - setAspectRatioDimensions(videoElement.videoWidth, videoElement.videoHeight); - }; - - /** Setting Up imageElement */ - imageElement.crossOrigin = 'anonymous'; - imageElement.onload = () => { - setAspectRatioDimensions( - imageElement.naturalWidth, - imageElement.naturalHeight - ); - drawImage(imageElement); - }; -} - -/** - * Update the FPS - * - * @hidden - */ -function updateFPS(fps: number): void { - if (fps < MIN_FPS || fps > MAX_FPS) return; - FPS = fps; -} - -/** - * Update the image dimensions. - * - * @hidden - */ -function updateDimensions(w: number, h: number): void { - // ignore if no change or bad inputs - if ( - (w === WIDTH && h === HEIGHT) || - w > MAX_WIDTH || - w < MIN_WIDTH || - h > MAX_HEIGHT || - h < MIN_HEIGHT - ) { - return; - } - - const status = videoIsPlaying; - stopVideo(); - - WIDTH = w; - HEIGHT = h; - - imageElement.width = w; - imageElement.height = h; - videoElement.width = w; - videoElement.height = h; - canvasElement.width = w; - canvasElement.height = h; - - setupData(); - - if (!status) { - setTimeout(() => stopVideo(), 50); - return; - } - - startVideo(); -} - -/** - * Updates the volume of the local video - * - * @hidden - */ -function updateVolume(v: number): void { - VOLUME = Math.max(0.0, Math.min(1.0, v)); - videoElement.volume = VOLUME; -} - -// queue is run when init is called -let queue: Queue = () => {}; - -/** - * Adds function to the queue - * - * @hidden - */ -function enqueue(funcToAdd: Queue): void { - const funcToRunFirst: Queue = queue; - queue = () => { - funcToRunFirst(); - funcToAdd(); - }; -} - -// lateQueue is run after media has properly loaded -let lateQueue: Queue = () => {}; - -/** - * Adds function to the lateQueue - * - * @hidden - */ -function lateEnqueue(funcToAdd: Queue): void { - const funcToRunFirst: Queue = lateQueue; - lateQueue = () => { - funcToRunFirst(); - funcToAdd(); - }; -} - -/** - * Used to initialise the video library. - * - * @returns a BundlePackage object containing Video's properties - * and other miscellaneous information relevant to tabs. - * @hidden - */ -function init( - image: ImageElement, - video: VideoElement, - canvas: CanvasElement, - _errorLogger: ErrorLogger, - _tabsPackage: TabsPacket -): BundlePacket { - imageElement = image; - videoElement = video; - canvasElement = canvas; - errorLogger = _errorLogger; - tabsPackage = _tabsPackage; - const context = canvasElement.getContext('2d'); - if (!context) throw new Error('Canvas context should not be null.'); - canvasRenderingContext = context; - setupData(); - if (inputFeed === InputFeed.Camera) { - loadMedia(); - } else { - loadAlternative(); - } - queue(); - return { HEIGHT, WIDTH, FPS, VOLUME, inputFeed }; -} - -/** - * Destructor that does necessary cleanup. - * - * @hidden - */ -function deinit(): void { - stopVideo(); - const stream = videoElement.srcObject; - if (!stream) { - return; - } - stream.getTracks().forEach((track) => { - track.stop(); - }); -} - -// ============================================================================= -// Module's Exposed Functions -// ============================================================================= - -/** - * Starts processing the image or video using the installed filter. - */ -export function start(): StartPacket { - return { - toReplString: () => '[Pix N Flix]', - init, - deinit, - startVideo, - stopVideo, - updateFPS, - updateVolume, - updateDimensions, - }; -} - -/** - * Returns the red component of the given pixel. - * - * @param pixel The given pixel - * @returns The red component as a number between 0 and 255 - */ -export function red_of(pixel: Pixel): number { - // returns the red value of pixel respectively - return pixel[0]; -} - -/** - * Returns the green component of the given pixel. - * - * @param pixel The given pixel - * @returns The green component as a number between 0 and 255 - */ -export function green_of(pixel: Pixel): number { - // returns the green value of pixel respectively - return pixel[1]; -} - -/** - * Returns the blue component of the given pixel. - * - * @param pixel The given pixel - * @returns The blue component as a number between 0 and 255 - */ -export function blue_of(pixel: Pixel): number { - // returns the blue value of pixel respectively - return pixel[2]; -} - -/** - * Returns the alpha component of the given pixel. - * - * @param pixel The given pixel - * @returns The alpha component as a number between 0 and 255 - */ -export function alpha_of(pixel: Pixel): number { - // returns the alpha value of pixel respectively - return pixel[3]; -} - -/** - * Assigns the given red, green, blue and alpha component values to - * the given pixel. - * - * @param pixel The given pixel - * @param r The red component as a number between 0 and 255 - * @param g The green component as a number between 0 and 255 - * @param b The blue component as a number between 0 and 255 - * @param a The alpha component as a number between 0 and 255 - */ -export function set_rgba( - pixel: Pixel, - r: number, - g: number, - b: number, - a: number -): void { - // assigns the r,g,b values to this pixel - pixel[0] = r; - pixel[1] = g; - pixel[2] = b; - pixel[3] = a; -} - -/** - * Returns the current height of the displayed images in - * pixels, i.e. the number of pixels in the vertical dimension. - * - * @returns The height of the displayed images (in pixels) - */ -export function image_height(): number { - return HEIGHT; -} - -/** - * Returns the current width of the displayed images in - * pixels, i.e. the number of pixels in the horizontal dimension. - * - * @returns The width of the displayed images (in pixels) - */ -export function image_width(): number { - return WIDTH; -} - -/** - * The default filter that just copies the source image to the - * destination image. - * - * @param src Source image - * @param dest Destination image - */ -export function copy_image(src: Pixels, dest: Pixels): void { - for (let i = 0; i < HEIGHT; i += 1) { - for (let j = 0; j < WIDTH; j += 1) { - dest[i][j] = src[i][j]; - } - } -} - -/** - * Installs the given filter to be used to transform each source image from - * the live camera or from a local/remote file to a destination image that - * is then displayed on screen. - * - * A filter is a function that is applied to two - * two-dimensional arrays of Pixels: - * the source image and the destination image. - * - * @param filter The filter to be installed - */ -export function install_filter(_filter: Filter): void { - filter = _filter; -} - -/** - * Resets the installed filter to the default filter. - */ -export function reset_filter(): void { - install_filter(copy_image); -} - -/** - * Creates a black image. - * - * @hidden - */ -function new_image(): Pixels { - const img: Pixels = []; - for (let i = 0; i < HEIGHT; i += 1) { - img[i] = []; - for (let j = 0; j < WIDTH; j += 1) { - img[i][j] = [0, 0, 0, 255]; - } - } - return img; -} - -/** - * Returns a new filter that is equivalent to applying - * filter1 and then filter2. - * - * @param filter1 The first filter - * @param filter2 The second filter - * @returns The filter equivalent to applying filter1 and then filter2 - */ -export function compose_filter(filter1: Filter, filter2: Filter): Filter { - return (src, dest) => { - const temp = new_image(); - filter1(src, temp); - filter2(temp, dest); - }; -} - -/** - * Pauses the video at a set time after the video starts. - * - * @param pause_time Time in ms after the video starts. - */ -export function pause_at(pause_time: number): void { - // prevent negative pause_time - lateEnqueue(() => { - setTimeout( - tabsPackage.onClickStill, - pause_time >= 0 ? pause_time : -pause_time - ); - }); -} - -/** - * Sets the diemsions of the displayed images. - * Note: Only accepts width and height values within the range of 1 to 500. - * - * @param width The width of the displayed images (default value: 300) - * @param height The height of the displayed images (default value: 400) - */ -export function set_dimensions(width: number, height: number): void { - enqueue(() => updateDimensions(width, height)); -} - -/** - * Sets the framerate (i.e. frames per second (FPS)) of the video. - * Note: Only accepts FPS values within the range of 2 to 30. - * - * @param fps FPS of video (default value: 10) - */ -export function set_fps(fps: number): void { - enqueue(() => updateFPS(fps)); -} - -/** - * Sets the audio volume of the local video file played. - * Note: Only accepts volume value within the range of 0 to 100. - * - * @param volume Volume of video (Default value of 50) - */ -export function set_volume(volume: number): void { - enqueue(() => updateVolume(Math.max(0, Math.min(100, volume) / 100.0))); -} - -/** - * Sets pix_n_flix to use video or image feed from a local file - * instead of using the default live camera feed. - */ -export function use_local_file(): void { - inputFeed = InputFeed.Local; -} - -/** - * Sets pix_n_flix to use the image from the given URL as the image feed - * instead of using the default live camera feed. - * - * @param URL URL of the image - */ -export function use_image_url(URL: string): void { - inputFeed = InputFeed.ImageURL; - url = URL; -} - -/** - * Sets pix_n_flix to use the video from the given URL as the video feed - * instead of using the default live camera feed. - * - * @param URL URL of the video - */ -export function use_video_url(URL: string): void { - inputFeed = InputFeed.VideoURL; - url = URL; -} - -/** - * Returns the elapsed time in milliseconds since the start of the video. - * - * @returns The elapsed time in milliseconds since the start of the video - */ -export function get_video_time(): number { - return totalElapsedTime; -} - -/** - * Sets pix_n_flix to preserve the aspect ratio of the video or image - * - * @param keepAspectRatio to keep aspect ratio. (Default value of true) - */ -export function keep_aspect_ratio(_keepAspectRatio: boolean): void { - keepAspectRatio = _keepAspectRatio; -} - -/** - * Sets the number of times the video is played. - * If the number of times the video repeats is negative, the video will loop forever. - * - * @param n number of times the video repeats after the first iteration. If n < 1, n will be taken to be 1. (Default value of Infinity) - */ -export function set_loop_count(n: number): void { - LOOP_COUNT = n; -} +/** + * The pix_n_flix module allows us to process still images and videos. + * + * An Image (which is a still image or a frame of a video) is a + * two-dimensional array of Pixels, and a Pixel consists of red, blue and green color + * values, each ranging from 0 to 255. To access these color values of a Pixel, we + * provide the functions red_of, blue_of and green_of. + * + * A central element of pix_n_flix is the notion of a Filter, a function that is applied + * to two Images: the source Image and the destination Image. When a Filter is installed + * (using the function install_filter), it transforms each source Image from the live camera + * or from a local/remote file to a destination Image that is then displayed on screen + * in the Source Academy "Pix N Flix" tab (with a camera icon). + * + * The dimensions (i.e. width and height) of the displayed images can be set by the user using + * the function set_dimensions, and all source and destination Images of the Filters will + * also be set to the same dimensions. To access the current dimensions of the Images, the user + * can use the functions image_width and image_height. + * + * @module pix_n_flix + */ + +/* eslint-disable @typescript-eslint/no-shadow */ +import { + CanvasElement, + VideoElement, + ErrorLogger, + StartPacket, + Pixel, + Pixels, + Filter, + Queue, + TabsPacket, + BundlePacket, + InputFeed, + ImageElement, +} from './types'; + +import { + DEFAULT_WIDTH, + DEFAULT_HEIGHT, + DEFAULT_FPS, + DEFAULT_VOLUME, + MAX_HEIGHT, + MIN_HEIGHT, + MAX_WIDTH, + MIN_WIDTH, + MAX_FPS, + MIN_FPS, + DEFAULT_LOOP, +} from './constants'; + +// Global Variables +let WIDTH: number = DEFAULT_WIDTH; +let HEIGHT: number = DEFAULT_HEIGHT; +let FPS: number = DEFAULT_FPS; +let VOLUME: number = DEFAULT_VOLUME; +let LOOP_COUNT: number = DEFAULT_LOOP; + +let imageElement: ImageElement; +let videoElement: VideoElement; +let canvasElement: CanvasElement; +let canvasRenderingContext: CanvasRenderingContext2D; +let errorLogger: ErrorLogger; +let tabsPackage: TabsPacket; + +const pixels: Pixels = []; +const temporaryPixels: Pixels = []; +let filter: Filter = copy_image; + +let toRunLateQueue: boolean = false; +let videoIsPlaying: boolean = false; + +let requestId: number; +let prevTime: number | null = null; +let totalElapsedTime: number = 0; +let playCount: number = 0; + +let inputFeed: InputFeed = InputFeed.Camera; +let url: string = ''; + +// Images dont aspect ratio correctly +let keepAspectRatio: boolean = true; +let intrinsicWidth: number = WIDTH; +let intrinsicHeight: number = HEIGHT; +let displayWidth: number = WIDTH; +let displayHeight: number = HEIGHT; + +// ============================================================================= +// Module's Private Functions +// ============================================================================= + +/** @hidden */ +function setupData(): void { + for (let i = 0; i < HEIGHT; i += 1) { + pixels[i] = []; + temporaryPixels[i] = []; + for (let j = 0; j < WIDTH; j += 1) { + pixels[i][j] = [0, 0, 0, 255]; + temporaryPixels[i][j] = [0, 0, 0, 255]; + } + } +} + +/** @hidden */ +function isPixelFilled(pixel: Pixel): boolean { + let ok = true; + for (let i = 0; i < 4; i += 1) { + if (pixel[i] >= 0 && pixel[i] <= 255) { + continue; + } + ok = false; + pixel[i] = 0; + } + return ok; +} + +/** @hidden */ +function writeToBuffer(buffer: Uint8ClampedArray, data: Pixels) { + let ok: boolean = true; + + for (let i = 0; i < HEIGHT; i += 1) { + for (let j = 0; j < WIDTH; j += 1) { + const p = i * WIDTH * 4 + j * 4; + if (isPixelFilled(data[i][j]) === false) { + ok = false; + } + buffer[p] = data[i][j][0]; + buffer[p + 1] = data[i][j][1]; + buffer[p + 2] = data[i][j][2]; + buffer[p + 3] = data[i][j][3]; + } + } + + if (!ok) { + const warningMessage + = 'You have invalid values for some pixels! Reseting them to default (0)'; + console.warn(warningMessage); + errorLogger(warningMessage, false); + } +} + +/** @hidden */ +function readFromBuffer(pixelData: Uint8ClampedArray, src: Pixels) { + for (let i = 0; i < HEIGHT; i += 1) { + for (let j = 0; j < WIDTH; j += 1) { + const p = i * WIDTH * 4 + j * 4; + src[i][j] = [ + pixelData[p], + pixelData[p + 1], + pixelData[p + 2], + pixelData[p + 3], + ]; + } + } +} + +/** @hidden */ +function drawImage(source: VideoElement | ImageElement): void { + if (keepAspectRatio) { + canvasRenderingContext.rect(0, 0, WIDTH, HEIGHT); + canvasRenderingContext.fill(); + canvasRenderingContext.drawImage( + source, + 0, + 0, + intrinsicWidth, + intrinsicHeight, + (WIDTH - displayWidth) / 2, + (HEIGHT - displayHeight) / 2, + displayWidth, + displayHeight, + ); + } else canvasRenderingContext.drawImage(source, 0, 0, WIDTH, HEIGHT); + + const pixelObj = canvasRenderingContext.getImageData(0, 0, WIDTH, HEIGHT); + readFromBuffer(pixelObj.data, pixels); + + // Runtime checks to guard against crashes + try { + filter(pixels, temporaryPixels); + writeToBuffer(pixelObj.data, temporaryPixels); + } catch (e: any) { + console.error(JSON.stringify(e)); + const errMsg = `There is an error with filter function, filter will be reset to default. ${e.name}: ${e.message}`; + console.error(errMsg); + + if (!e.name) { + errorLogger( + 'There is an error with filter function (error shown below). Filter will be reset back to the default. If you are facing an infinite loop error, you can consider increasing the timeout period (clock icon) at the top / reducing the frame dimensions.', + ); + + errorLogger([e], true); + } else { + errorLogger(errMsg, false); + } + + filter = copy_image; + filter(pixels, temporaryPixels); + } + + canvasRenderingContext.putImageData(pixelObj, 0, 0); +} + +/** @hidden */ +function draw(timestamp: number): void { + requestId = window.requestAnimationFrame(draw); + + if (prevTime === null) prevTime = timestamp; + + const elapsed = timestamp - prevTime; + if (elapsed > 1000 / FPS && videoIsPlaying) { + drawImage(videoElement); + prevTime = timestamp; + totalElapsedTime += elapsed; + if (toRunLateQueue) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + lateQueue(); + toRunLateQueue = false; + } + } +} + +/** @hidden */ +function playVideoElement() { + if (!videoIsPlaying) { + videoElement + .play() + .then(() => { + videoIsPlaying = true; + }) + .catch((err) => { + console.warn(err); + }); + } +} + +/** @hidden */ +function pauseVideoElement() { + if (videoIsPlaying) { + videoElement.pause(); + videoIsPlaying = false; + } +} + +/** @hidden */ +function startVideo(): void { + if (videoIsPlaying) return; + if (inputFeed === InputFeed.Camera) videoIsPlaying = true; + else playVideoElement(); + requestId = window.requestAnimationFrame(draw); +} + +/** + * Stops the loop that is drawing on image. + * + * @hidden + */ +function stopVideo(): void { + if (!videoIsPlaying) return; + if (inputFeed === InputFeed.Camera) videoIsPlaying = false; + else pauseVideoElement(); + window.cancelAnimationFrame(requestId); + prevTime = null; +} + +/** @hidden */ +function setAspectRatioDimensions(w: number, h: number): void { + intrinsicHeight = h; + intrinsicWidth = w; + const scale = Math.min(WIDTH / w, HEIGHT / h); + displayWidth = scale * w; + displayHeight = scale * h; +} + +/** @hidden */ +function loadMedia(): void { + if (!navigator.mediaDevices.getUserMedia) { + const errMsg = 'The browser you are using does not support getUserMedia'; + console.error(errMsg); + errorLogger(errMsg, false); + } + + // If video is already part of bundle state + if (videoElement.srcObject) return; + + navigator.mediaDevices + .getUserMedia({ video: true }) + .then((stream) => { + videoElement.srcObject = stream; + videoElement.onloadedmetadata = () => setAspectRatioDimensions( + videoElement.videoWidth, + videoElement.videoHeight, + ); + toRunLateQueue = true; + }) + .catch((error) => { + const errorMessage = `${error.name}: ${error.message}`; + console.error(errorMessage); + errorLogger(errorMessage, false); + }); + + startVideo(); +} + +/** @hidden */ +function loadAlternative(): void { + try { + if (inputFeed === InputFeed.VideoURL) { + videoElement.src = url; + startVideo(); + } else if (inputFeed === InputFeed.ImageURL) { + imageElement.src = url; + } + } catch (e: any) { + console.error(JSON.stringify(e)); + const errMsg = `There is an error loading the URL. ${e.name}: ${e.message}`; + console.error(errMsg); + loadMedia(); + return; + } + toRunLateQueue = true; + + /** Setting Up videoElement */ + videoElement.crossOrigin = 'anonymous'; + videoElement.onended = () => { + playCount++; + if (playCount == LOOP_COUNT) { + tabsPackage.onClickStill(); + playCount = 0; + } else if (playCount < LOOP_COUNT) { + stopVideo(); + startVideo(); + } else { + playCount = 0; + } + }; + videoElement.onloadedmetadata = () => { + setAspectRatioDimensions(videoElement.videoWidth, videoElement.videoHeight); + }; + + /** Setting Up imageElement */ + imageElement.crossOrigin = 'anonymous'; + imageElement.onload = () => { + setAspectRatioDimensions( + imageElement.naturalWidth, + imageElement.naturalHeight, + ); + drawImage(imageElement); + }; +} + +/** + * Update the FPS + * + * @hidden + */ +function updateFPS(fps: number): void { + if (fps < MIN_FPS || fps > MAX_FPS) return; + FPS = fps; +} + +/** + * Update the image dimensions. + * + * @hidden + */ +function updateDimensions(w: number, h: number): void { + // ignore if no change or bad inputs + if ( + (w === WIDTH && h === HEIGHT) + || w > MAX_WIDTH + || w < MIN_WIDTH + || h > MAX_HEIGHT + || h < MIN_HEIGHT + ) { + return; + } + + const status = videoIsPlaying; + stopVideo(); + + WIDTH = w; + HEIGHT = h; + + imageElement.width = w; + imageElement.height = h; + videoElement.width = w; + videoElement.height = h; + canvasElement.width = w; + canvasElement.height = h; + + setupData(); + + if (!status) { + setTimeout(() => stopVideo(), 50); + return; + } + + startVideo(); +} + +/** + * Updates the volume of the local video + * + * @hidden + */ +function updateVolume(v: number): void { + VOLUME = Math.max(0.0, Math.min(1.0, v)); + videoElement.volume = VOLUME; +} + +// queue is run when init is called +let queue: Queue = () => {}; + +/** + * Adds function to the queue + * + * @hidden + */ +function enqueue(funcToAdd: Queue): void { + const funcToRunFirst: Queue = queue; + queue = () => { + funcToRunFirst(); + funcToAdd(); + }; +} + +// lateQueue is run after media has properly loaded +let lateQueue: Queue = () => {}; + +/** + * Adds function to the lateQueue + * + * @hidden + */ +function lateEnqueue(funcToAdd: Queue): void { + const funcToRunFirst: Queue = lateQueue; + lateQueue = () => { + funcToRunFirst(); + funcToAdd(); + }; +} + +/** + * Used to initialise the video library. + * + * @returns a BundlePackage object containing Video's properties + * and other miscellaneous information relevant to tabs. + * @hidden + */ +function init( + image: ImageElement, + video: VideoElement, + canvas: CanvasElement, + _errorLogger: ErrorLogger, + _tabsPackage: TabsPacket, +): BundlePacket { + imageElement = image; + videoElement = video; + canvasElement = canvas; + errorLogger = _errorLogger; + tabsPackage = _tabsPackage; + const context = canvasElement.getContext('2d'); + if (!context) throw new Error('Canvas context should not be null.'); + canvasRenderingContext = context; + setupData(); + if (inputFeed === InputFeed.Camera) { + loadMedia(); + } else { + loadAlternative(); + } + queue(); + return { + HEIGHT, + WIDTH, + FPS, + VOLUME, + inputFeed, + }; +} + +/** + * Destructor that does necessary cleanup. + * + * @hidden + */ +function deinit(): void { + stopVideo(); + const stream = videoElement.srcObject; + if (!stream) { + return; + } + stream.getTracks() + .forEach((track) => { + track.stop(); + }); +} + +// ============================================================================= +// Module's Exposed Functions +// ============================================================================= + +/** + * Starts processing the image or video using the installed filter. + */ +export function start(): StartPacket { + return { + toReplString: () => '[Pix N Flix]', + init, + deinit, + startVideo, + stopVideo, + updateFPS, + updateVolume, + updateDimensions, + }; +} + +/** + * Returns the red component of the given pixel. + * + * @param pixel The given pixel + * @returns The red component as a number between 0 and 255 + */ +export function red_of(pixel: Pixel): number { + // returns the red value of pixel respectively + return pixel[0]; +} + +/** + * Returns the green component of the given pixel. + * + * @param pixel The given pixel + * @returns The green component as a number between 0 and 255 + */ +export function green_of(pixel: Pixel): number { + // returns the green value of pixel respectively + return pixel[1]; +} + +/** + * Returns the blue component of the given pixel. + * + * @param pixel The given pixel + * @returns The blue component as a number between 0 and 255 + */ +export function blue_of(pixel: Pixel): number { + // returns the blue value of pixel respectively + return pixel[2]; +} + +/** + * Returns the alpha component of the given pixel. + * + * @param pixel The given pixel + * @returns The alpha component as a number between 0 and 255 + */ +export function alpha_of(pixel: Pixel): number { + // returns the alpha value of pixel respectively + return pixel[3]; +} + +/** + * Assigns the given red, green, blue and alpha component values to + * the given pixel. + * + * @param pixel The given pixel + * @param r The red component as a number between 0 and 255 + * @param g The green component as a number between 0 and 255 + * @param b The blue component as a number between 0 and 255 + * @param a The alpha component as a number between 0 and 255 + */ +export function set_rgba( + pixel: Pixel, + r: number, + g: number, + b: number, + a: number, +): void { + // assigns the r,g,b values to this pixel + pixel[0] = r; + pixel[1] = g; + pixel[2] = b; + pixel[3] = a; +} + +/** + * Returns the current height of the displayed images in + * pixels, i.e. the number of pixels in the vertical dimension. + * + * @returns The height of the displayed images (in pixels) + */ +export function image_height(): number { + return HEIGHT; +} + +/** + * Returns the current width of the displayed images in + * pixels, i.e. the number of pixels in the horizontal dimension. + * + * @returns The width of the displayed images (in pixels) + */ +export function image_width(): number { + return WIDTH; +} + +/** + * The default filter that just copies the source image to the + * destination image. + * + * @param src Source image + * @param dest Destination image + */ +export function copy_image(src: Pixels, dest: Pixels): void { + for (let i = 0; i < HEIGHT; i += 1) { + for (let j = 0; j < WIDTH; j += 1) { + dest[i][j] = src[i][j]; + } + } +} + +/** + * Installs the given filter to be used to transform each source image from + * the live camera or from a local/remote file to a destination image that + * is then displayed on screen. + * + * A filter is a function that is applied to two + * two-dimensional arrays of Pixels: + * the source image and the destination image. + * + * @param filter The filter to be installed + */ +export function install_filter(_filter: Filter): void { + filter = _filter; +} + +/** + * Resets the installed filter to the default filter. + */ +export function reset_filter(): void { + install_filter(copy_image); +} + +/** + * Creates a black image. + * + * @hidden + */ +function new_image(): Pixels { + const img: Pixels = []; + for (let i = 0; i < HEIGHT; i += 1) { + img[i] = []; + for (let j = 0; j < WIDTH; j += 1) { + img[i][j] = [0, 0, 0, 255]; + } + } + return img; +} + +/** + * Returns a new filter that is equivalent to applying + * filter1 and then filter2. + * + * @param filter1 The first filter + * @param filter2 The second filter + * @returns The filter equivalent to applying filter1 and then filter2 + */ +export function compose_filter(filter1: Filter, filter2: Filter): Filter { + return (src, dest) => { + const temp = new_image(); + filter1(src, temp); + filter2(temp, dest); + }; +} + +/** + * Pauses the video at a set time after the video starts. + * + * @param pause_time Time in ms after the video starts. + */ +export function pause_at(pause_time: number): void { + // prevent negative pause_time + lateEnqueue(() => { + setTimeout( + tabsPackage.onClickStill, + pause_time >= 0 ? pause_time : -pause_time, + ); + }); +} + +/** + * Sets the diemsions of the displayed images. + * Note: Only accepts width and height values within the range of 1 to 500. + * + * @param width The width of the displayed images (default value: 300) + * @param height The height of the displayed images (default value: 400) + */ +export function set_dimensions(width: number, height: number): void { + enqueue(() => updateDimensions(width, height)); +} + +/** + * Sets the framerate (i.e. frames per second (FPS)) of the video. + * Note: Only accepts FPS values within the range of 2 to 30. + * + * @param fps FPS of video (default value: 10) + */ +export function set_fps(fps: number): void { + enqueue(() => updateFPS(fps)); +} + +/** + * Sets the audio volume of the local video file played. + * Note: Only accepts volume value within the range of 0 to 100. + * + * @param volume Volume of video (Default value of 50) + */ +export function set_volume(volume: number): void { + enqueue(() => updateVolume(Math.max(0, Math.min(100, volume) / 100.0))); +} + +/** + * Sets pix_n_flix to use video or image feed from a local file + * instead of using the default live camera feed. + */ +export function use_local_file(): void { + inputFeed = InputFeed.Local; +} + +/** + * Sets pix_n_flix to use the image from the given URL as the image feed + * instead of using the default live camera feed. + * + * @param URL URL of the image + */ +export function use_image_url(URL: string): void { + inputFeed = InputFeed.ImageURL; + url = URL; +} + +/** + * Sets pix_n_flix to use the video from the given URL as the video feed + * instead of using the default live camera feed. + * + * @param URL URL of the video + */ +export function use_video_url(URL: string): void { + inputFeed = InputFeed.VideoURL; + url = URL; +} + +/** + * Returns the elapsed time in milliseconds since the start of the video. + * + * @returns The elapsed time in milliseconds since the start of the video + */ +export function get_video_time(): number { + return totalElapsedTime; +} + +/** + * Sets pix_n_flix to preserve the aspect ratio of the video or image + * + * @param keepAspectRatio to keep aspect ratio. (Default value of true) + */ +export function keep_aspect_ratio(_keepAspectRatio: boolean): void { + keepAspectRatio = _keepAspectRatio; +} + +/** + * Sets the number of times the video is played. + * If the number of times the video repeats is negative, the video will loop forever. + * + * @param n number of times the video repeats after the first iteration. If n < 1, n will be taken to be 1. (Default value of Infinity) + */ +export function set_loop_count(n: number): void { + LOOP_COUNT = n; +} diff --git a/src/bundles/repeat/__tests__/index.ts b/src/bundles/repeat/__tests__/index.ts index 603df598b..571eddf2b 100644 --- a/src/bundles/repeat/__tests__/index.ts +++ b/src/bundles/repeat/__tests__/index.ts @@ -1,14 +1,17 @@ -import { repeat, twice, thrice } from '../functions'; - -// Test functions -test('repeat works correctly and repeats function n times', () => { - expect(repeat((x: number) => x + 1, 5)(1)).toBe(6); -}); - -test('twice works correctly and repeats function twice', () => { - expect(twice((x: number) => x + 1)(1)).toBe(3); -}); - -test('thrice works correctly and repeats function thrice', () => { - expect(thrice((x: number) => x + 1)(1)).toBe(4); -}); +import { repeat, twice, thrice } from '../functions'; + +// Test functions +test('repeat works correctly and repeats function n times', () => { + expect(repeat((x: number) => x + 1, 5)(1)) + .toBe(6); +}); + +test('twice works correctly and repeats function twice', () => { + expect(twice((x: number) => x + 1)(1)) + .toBe(3); +}); + +test('thrice works correctly and repeats function thrice', () => { + expect(thrice((x: number) => x + 1)(1)) + .toBe(4); +}); diff --git a/src/bundles/rune/functions.ts b/src/bundles/rune/functions.ts index 9ecba2d31..637474f15 100644 --- a/src/bundles/rune/functions.ts +++ b/src/bundles/rune/functions.ts @@ -1,1023 +1,1023 @@ -/** - * 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. - * @module rune - */ -import { mat4, vec3 } from 'gl-matrix'; -import { - Rune, - NormalRune, - RuneAnimation, - DrawnRune, - drawRunesToFrameBuffer, - AnimatedRune, -} from './rune'; -import { - getSquare, - getBlank, - getRcross, - getSail, - getTriangle, - getCorner, - getNova, - getCircle, - getHeart, - getPentagram, - getRibbon, - throwIfNotRune, - addColorFromHex, - colorPalette, - hexToColor, -} from './runes_ops'; -import { - FrameBufferWithTexture, - getWebGlFromCanvas, - initFramebufferObject, - initShaderProgram, -} from './runes_webgl'; - -/** @hidden */ -export const drawnRunes: (DrawnRune | AnimatedRune)[] = []; - -// ============================================================================= -// Basic Runes -// ============================================================================= - -/** - * Rune with the shape of a full square - * - * @category Primitive - */ -export const square: Rune = getSquare(); -/** - * Rune with the shape of a blank square - * - * @category Primitive - */ -export const blank: Rune = getBlank(); -/** - * 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: Rune = getRcross(); -/** - * Rune with the shape of a sail - * - * @category Primitive - */ -export const sail: Rune = getSail(); -/** - * Rune with the shape of a triangle - * - * @category Primitive - */ -export const triangle: Rune = getTriangle(); -/** - * Rune with black triangle, - * filling upper right corner - * - * @category Primitive - */ -export const corner: Rune = getCorner(); -/** - * Rune with the shape of two overlapping - * triangles, residing in the upper half - * of the shape - * - * @category Primitive - */ -export const nova: Rune = getNova(); -/** - * Rune with the shape of a circle - * - * @category Primitive - */ -export const circle: Rune = getCircle(); -/** - * Rune with the shape of a heart - * - * @category Primitive - */ -export const heart: Rune = getHeart(); -/** - * Rune with the shape of a pentagram - * - * @category Primitive - */ -export const pentagram: Rune = getPentagram(); -/** - * Rune with the shape of a ribbon - * winding outwards in an anticlockwise spiral - * - * @category Primitive - */ -export const ribbon: Rune = getRibbon(); - -// ============================================================================= -// Textured Runes -// ============================================================================= -/** - * 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. - * - * @category Main - */ -export function from_url(imageUrl: string): Rune { - const rune = getSquare(); - rune.texture = new Image(); - rune.texture.crossOrigin = 'anonymous'; - rune.texture.src = imageUrl; - return rune; -} - -// ============================================================================= -// XY-axis Transformation functions -// ============================================================================= - -/** - * 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 - * @return {Rune} Resulting scaled Rune - * - * @category Main - */ -export function scale_independent( - ratio_x: number, - ratio_y: number, - rune: Rune -): Rune { - throwIfNotRune('scale_independent', rune); - const scaleVec = vec3.fromValues(ratio_x, ratio_y, 1); - const scaleMat = mat4.create(); - mat4.scale(scaleMat, scaleMat, scaleVec); - - const wrapperMat = mat4.create(); - mat4.multiply(wrapperMat, scaleMat, wrapperMat); - return Rune.of({ - subRunes: [rune], - transformMatrix: wrapperMat, - }); -} - -/** - * Scales a given Rune by a given factor in both x and y direction - * @param {number} ratio - Scaling factor - * @param {Rune} rune - Given Rune - * @return {Rune} Resulting scaled Rune - * - * @category Main - */ -export function scale(ratio: number, rune: Rune): Rune { - throwIfNotRune('scale', rune); - return scale_independent(ratio, ratio, rune); -} - -/** - * 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 - * @return {Rune} Resulting translated Rune - * - * @category Main - */ -export function translate(x: number, y: number, rune: Rune): Rune { - throwIfNotRune('translate', rune); - const translateVec = vec3.fromValues(x, -y, 0); - const translateMat = mat4.create(); - mat4.translate(translateMat, translateMat, translateVec); - - const wrapperMat = mat4.create(); - mat4.multiply(wrapperMat, translateMat, wrapperMat); - return Rune.of({ - subRunes: [rune], - transformMatrix: wrapperMat, - }); -} - -/** - * 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 - * @return {Rune} Rotated Rune - * - * @category Main - */ -export function rotate(rad: number, rune: Rune): Rune { - throwIfNotRune('rotate', rune); - const rotateMat = mat4.create(); - mat4.rotateZ(rotateMat, rotateMat, rad); - - const wrapperMat = mat4.create(); - mat4.multiply(wrapperMat, rotateMat, wrapperMat); - return Rune.of({ - subRunes: [rune], - transformMatrix: wrapperMat, - }); -} - -/** - * 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 - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function stack_frac(frac: number, rune1: Rune, rune2: Rune): Rune { - throwIfNotRune('stack_frac', rune1); - throwIfNotRune('stack_frac', rune2); - - if (!(frac >= 0 && frac <= 1)) { - throw Error('stack_frac can only take fraction in [0,1].'); - } - - const upper = translate(0, -(1 - frac), scale_independent(1, frac, rune1)); - const lower = translate(0, frac, scale_independent(1, 1 - frac, rune2)); - return Rune.of({ - subRunes: [upper, lower], - }); -} - -/** - * 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 - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function stack(rune1: Rune, rune2: Rune): Rune { - throwIfNotRune('stack', rune1, rune2); - return stack_frac(1 / 2, rune1, rune2); -} - -/** - * 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 - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function stackn(n: number, rune: Rune): Rune { - throwIfNotRune('stackn', rune); - if (n === 1) { - return rune; - } - return stack_frac(1 / n, rune, stackn(n - 1, rune)); -} - -/** - * 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 - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function quarter_turn_right(rune: Rune): Rune { - throwIfNotRune('quarter_turn_right', rune); - return rotate(-Math.PI / 2, rune); -} - -/** - * Makes a new Rune from a given Rune - * by turning it a quarter-turn in - * anti-clockwise direction. - * @param {Rune} rune - Given Rune - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function quarter_turn_left(rune: Rune): Rune { - throwIfNotRune('quarter_turn_left', rune); - return rotate(Math.PI / 2, rune); -} - -/** - * Makes a new Rune from a given Rune - * by turning it upside-down - * @param {Rune} rune - Given Rune - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function turn_upside_down(rune: Rune): Rune { - throwIfNotRune('turn_upside_down', rune); - return rotate(Math.PI, rune); -} - -/** - * 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 - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function beside_frac(frac: number, rune1: Rune, rune2: Rune): Rune { - throwIfNotRune('beside_frac', rune1, rune2); - - if (!(frac >= 0 && frac <= 1)) { - throw Error('beside_frac can only take fraction in [0,1].'); - } - - const left = translate(-(1 - frac), 0, scale_independent(frac, 1, rune1)); - const right = translate(frac, 0, scale_independent(1 - frac, 1, rune2)); - return Rune.of({ - subRunes: [left, right], - }); -} - -/** - * 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 - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function beside(rune1: Rune, rune2: Rune): Rune { - throwIfNotRune('beside', rune1, rune2); - return beside_frac(1 / 2, rune1, rune2); -} - -/** - * Makes a new Rune from a given Rune by - * flipping it around a horizontal axis, - * turning it upside down - * @param {Rune} rune - Given Rune - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function flip_vert(rune: Rune): Rune { - throwIfNotRune('flip_vert', rune); - return scale_independent(1, -1, rune); -} - -/** - * Makes a new Rune from a given Rune by - * flipping it around a vertical axis, - * creating a mirror image - * @param {Rune} rune - Given Rune - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function flip_horiz(rune: Rune): Rune { - throwIfNotRune('flip_horiz', rune); - return scale_independent(-1, 1, rune); -} - -/** - * 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 - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function make_cross(rune: Rune): Rune { - throwIfNotRune('make_cross', rune); - return stack( - beside(quarter_turn_right(rune), rotate(Math.PI, rune)), - beside(rune, rotate(Math.PI / 2, rune)) - ); -} - -/** - * 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 - * @return {Rune} - Result of n times application of pattern to initial: - * pattern(pattern(...pattern(pattern(initial))...)) - * - * @category Main - */ -export function repeat_pattern( - n: number, - pattern: (a: Rune) => Rune, - initial: Rune -): Rune { - if (n === 0) { - return initial; - } - return pattern(repeat_pattern(n - 1, pattern, initial)); -} - -// ============================================================================= -// Z-axis Transformation functions -// ============================================================================= - -/** - * 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 - * @return {Rune} Resulting Rune - * - * @category Main - */ -export function 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. - throwIfNotRune('overlay_frac', rune1); - throwIfNotRune('overlay_frac', rune2); - if (!(frac >= 0 && frac <= 1)) { - throw Error('overlay_frac can only take fraction in [0,1].'); - } - // by definition, when frac == 0 or 1, the back rune will overlap with the front rune. - // however, this would cause graphical glitch because overlapping is physically impossible - // we hack this problem by clipping the frac input from [0,1] to [1E-6, 1-1E-6] - // this should not be graphically noticable - let useFrac = frac; - const minFrac = 0.000001; - const maxFrac = 1 - minFrac; - if (useFrac < minFrac) { - useFrac = minFrac; - } - if (useFrac > maxFrac) { - useFrac = maxFrac; - } - - const frontMat = mat4.create(); - // z: scale by frac - mat4.scale(frontMat, frontMat, vec3.fromValues(1, 1, useFrac)); - const front = Rune.of({ - subRunes: [rune1], - transformMatrix: frontMat, - }); - - const backMat = mat4.create(); - // need to apply transformation in backwards order! - mat4.translate(backMat, backMat, vec3.fromValues(0, 0, -useFrac)); - mat4.scale(backMat, backMat, vec3.fromValues(1, 1, 1 - useFrac)); - const back = Rune.of({ - subRunes: [rune2], - transformMatrix: backMat, - }); - - return Rune.of({ - subRunes: [front, back], // render front first to avoid redrawing - }); -} - -/** - * 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 - * @return {Rune} Resulting Runes - * - * @category Main - */ -export function overlay(rune1: Rune, rune2: Rune): Rune { - throwIfNotRune('overlay', rune1); - throwIfNotRune('overlay', rune2); - return overlay_frac(0.5, rune1, rune2); -} - -// ============================================================================= -// Color functions -// ============================================================================= - -/** - * 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 - * - * @category Color - */ -export function color(rune: Rune, r: number, g: number, b: number): Rune { - throwIfNotRune('color', rune); - - const colorVector = [r, g, b, 1]; - return Rune.of({ - colors: new Float32Array(colorVector), - subRunes: [rune], - }); -} - -/** - * 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 - * - * @category Color - */ -export function random_color(rune: Rune): Rune { - throwIfNotRune('random_color', rune); - const randomColor = hexToColor( - colorPalette[Math.floor(Math.random() * colorPalette.length)] - ); - - return Rune.of({ - colors: new Float32Array(randomColor), - subRunes: [rune], - }); -} - -/** - * Colors the given rune red (#F44336). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function red(rune: Rune): Rune { - throwIfNotRune('red', rune); - return addColorFromHex(rune, '#F44336'); -} - -/** - * Colors the given rune pink (#E91E63s). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function pink(rune: Rune): Rune { - throwIfNotRune('pink', rune); - return addColorFromHex(rune, '#E91E63'); -} - -/** - * Colors the given rune purple (#AA00FF). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function purple(rune: Rune): Rune { - throwIfNotRune('purple', rune); - return addColorFromHex(rune, '#AA00FF'); -} - -/** - * Colors the given rune indigo (#3F51B5). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function indigo(rune: Rune): Rune { - throwIfNotRune('indigo', rune); - return addColorFromHex(rune, '#3F51B5'); -} - -/** - * Colors the given rune blue (#2196F3). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function blue(rune: Rune): Rune { - throwIfNotRune('blue', rune); - return addColorFromHex(rune, '#2196F3'); -} - -/** - * Colors the given rune green (#4CAF50). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function green(rune: Rune): Rune { - throwIfNotRune('green', rune); - return addColorFromHex(rune, '#4CAF50'); -} - -/** - * Colors the given rune yellow (#FFEB3B). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function yellow(rune: Rune): Rune { - throwIfNotRune('yellow', rune); - return addColorFromHex(rune, '#FFEB3B'); -} - -/** - * Colors the given rune orange (#FF9800). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function orange(rune: Rune): Rune { - throwIfNotRune('orange', rune); - return addColorFromHex(rune, '#FF9800'); -} - -/** - * Colors the given rune brown. - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function brown(rune: Rune): Rune { - throwIfNotRune('brown', rune); - return addColorFromHex(rune, '#795548'); -} - -/** - * Colors the given rune black (#000000). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function black(rune: Rune): Rune { - throwIfNotRune('black', rune); - return addColorFromHex(rune, '#000000'); -} - -/** - * Colors the given rune white (#FFFFFF). - * @param {Rune} rune - The rune to color - * @returns {Rune} The colored Rune - * - * @category Color - */ -export function white(rune: Rune): Rune { - throwIfNotRune('white', rune); - return addColorFromHex(rune, '#FFFFFF'); -} - -// ============================================================================= -// Drawing functions -// ============================================================================= - -/** - * Renders the specified Rune in a tab as a basic drawing. - * @param rune - The Rune to render - * @return {Rune} The specified Rune - * - * @category Main - */ -export function show(rune: Rune): Rune { - throwIfNotRune('show', rune); - drawnRunes.push(new NormalRune(rune)); - return rune; -} - -/** @hidden */ -export class AnaglyphRune extends DrawnRune { - private static readonly anaglyphVertexShader = ` - precision mediump float; - attribute vec4 a_position; - varying highp vec2 v_texturePosition; - void main() { - gl_Position = a_position; - // texture position is in [0,1], vertex position is in [-1,1] - v_texturePosition.x = (a_position.x + 1.0) / 2.0; - v_texturePosition.y = (a_position.y + 1.0) / 2.0; - } - `; - - private static readonly anaglyphFragmentShader = ` - precision mediump float; - uniform sampler2D u_sampler_red; - uniform sampler2D u_sampler_cyan; - varying highp vec2 v_texturePosition; - void main() { - gl_FragColor = texture2D(u_sampler_red, v_texturePosition) - + texture2D(u_sampler_cyan, v_texturePosition) - 1.0; - gl_FragColor.a = 1.0; - } - `; - - constructor(rune: Rune) { - super(rune, false); - } - - public draw = (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 - const runes = white(overlay_frac(0.999999999, blank, scale(2.2, square))) - .flatten() - .concat(this.rune.flatten()); - - // calculate the left and right camera matrices - const halfEyeDistance = 0.03; - const leftCameraMatrix = mat4.create(); - mat4.lookAt( - leftCameraMatrix, - vec3.fromValues(-halfEyeDistance, 0, 0), - vec3.fromValues(0, 0, -0.4), - vec3.fromValues(0, 1, 0) - ); - const rightCameraMatrix = mat4.create(); - mat4.lookAt( - rightCameraMatrix, - vec3.fromValues(halfEyeDistance, 0, 0), - vec3.fromValues(0, 0, -0.4), - vec3.fromValues(0, 1, 0) - ); - - // left/right eye images are drawn into respective framebuffers - const leftBuffer = initFramebufferObject(gl); - const rightBuffer = initFramebufferObject(gl); - drawRunesToFrameBuffer( - gl, - runes, - leftCameraMatrix, - new Float32Array([1, 0, 0, 1]), - leftBuffer.framebuffer, - true - ); - drawRunesToFrameBuffer( - gl, - runes, - rightCameraMatrix, - new Float32Array([0, 1, 1, 1]), - rightBuffer.framebuffer, - true - ); - - // prepare to draw to screen by setting framebuffer to null - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - // prepare the shader program to combine the left/right eye images - const shaderProgram = initShaderProgram( - gl, - AnaglyphRune.anaglyphVertexShader, - AnaglyphRune.anaglyphFragmentShader - ); - gl.useProgram(shaderProgram); - const reduPt = gl.getUniformLocation(shaderProgram, 'u_sampler_red'); - const cyanuPt = gl.getUniformLocation(shaderProgram, 'u_sampler_cyan'); - const vertexPositionPointer = gl.getAttribLocation( - shaderProgram, - 'a_position' - ); - - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, leftBuffer.texture); - gl.uniform1i(cyanuPt, 0); - - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, rightBuffer.texture); - gl.uniform1i(reduPt, 1); - - // draw a square, which will allow the texture to be used - // load position buffer - const positionBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); - gl.bufferData(gl.ARRAY_BUFFER, square.vertices, gl.STATIC_DRAW); - gl.vertexAttribPointer(vertexPositionPointer, 4, gl.FLOAT, false, 0, 0); - gl.enableVertexAttribArray(vertexPositionPointer); - gl.drawArrays(gl.TRIANGLES, 0, 6); - }; -} - -/** - * Renders the specified Rune in a tab as an anaglyph. Use 3D glasses to view the - * anaglyph. - * @param rune - The Rune to render - * @return {Rune} The specified Rune - * - * @category Main - */ -export function anaglyph(rune: Rune): Rune { - throwIfNotRune('anaglyph', rune); - drawnRunes.push(new AnaglyphRune(rune)); - return rune; -} - -/** @hidden */ -export class HollusionRune extends DrawnRune { - constructor(rune: Rune, magnitude: number) { - super(rune, true); - this.rune.hollusionDistance = magnitude; - } - - private static readonly copyVertexShader = ` - precision mediump float; - attribute vec4 a_position; - varying highp vec2 v_texturePosition; - void main() { - gl_Position = a_position; - // texture position is in [0,1], vertex position is in [-1,1] - v_texturePosition.x = (a_position.x + 1.0) / 2.0; - v_texturePosition.y = (a_position.y + 1.0) / 2.0; - } - `; - - private static readonly copyFragmentShader = ` - precision mediump float; - uniform sampler2D uTexture; - varying highp vec2 v_texturePosition; - void main() { - gl_FragColor = texture2D(uTexture, v_texturePosition); - } - `; - - public draw = (canvas: HTMLCanvasElement) => { - const gl = getWebGlFromCanvas(canvas); - - const runes = white(overlay_frac(0.999999999, blank, scale(2.2, square))) - .flatten() - .concat(this.rune.flatten()); - - // first render all the frames into a framebuffer - const xshiftMax = runes[0].hollusionDistance; - const period = 2000; // animations loops every 2 seconds - const frameCount = 50; // in total 50 frames, gives rise to 25 fps - const frameBuffer: FrameBufferWithTexture[] = []; - - const renderFrame = (framePos: number): FrameBufferWithTexture => { - const fb = initFramebufferObject(gl); - // prepare camera projection array - const cameraMatrix = mat4.create(); - // let the object shift in the x direction - // the following calculation will let x oscillate in (-xshiftMax, xshiftMax) with time - let xshift = (framePos * (period / frameCount)) % period; - if (xshift > period / 2) { - xshift = period - xshift; - } - xshift = xshiftMax * (2 * ((2 * xshift) / period) - 1); - mat4.lookAt( - cameraMatrix, - vec3.fromValues(xshift, 0, 0), - vec3.fromValues(0, 0, -0.4), - vec3.fromValues(0, 1, 0) - ); - - drawRunesToFrameBuffer( - gl, - runes, - cameraMatrix, - new Float32Array([1, 1, 1, 1]), - fb.framebuffer, - true - ); - return fb; - }; - - for (let i = 0; i < frameCount; i += 1) { - frameBuffer.push(renderFrame(i)); - } - - // Then, draw a frame from framebuffer for each update - const copyShaderProgram = initShaderProgram( - gl, - HollusionRune.copyVertexShader, - HollusionRune.copyFragmentShader - ); - gl.useProgram(copyShaderProgram); - const texturePt = gl.getUniformLocation(copyShaderProgram, 'uTexture'); - const vertexPositionPointer = gl.getAttribLocation( - copyShaderProgram, - 'a_position' - ); - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - const positionBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); - gl.bufferData(gl.ARRAY_BUFFER, square.vertices, gl.STATIC_DRAW); - gl.vertexAttribPointer(vertexPositionPointer, 4, gl.FLOAT, false, 0, 0); - gl.enableVertexAttribArray(vertexPositionPointer); - - let lastTime = 0; - function render(timeInMs: number) { - if (timeInMs - lastTime < period / frameCount) return; - - lastTime = timeInMs; - - const framePos = - Math.floor(timeInMs / (period / frameCount)) % frameCount; - const fbObject = frameBuffer[framePos]; - gl.clearColor(1.0, 1.0, 1.0, 1.0); // Set clear color to white, fully opaque - // eslint-disable-next-line no-bitwise - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // Clear the viewport - - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, fbObject.texture); - gl.uniform1i(texturePt, 0); - - gl.drawArrays(gl.TRIANGLES, 0, 6); - } - - return render; - }; -} - -/** - * Renders the specified Rune in a tab as a hollusion, using the specified - * magnitude. - * @param rune - The Rune to render - * @param {number} magnitude - The hollusion's magnitude - * @return {Rune} The specified Rune - * - * @category Main - */ -export function hollusion_magnitude(rune: Rune, magnitude: number): Rune { - throwIfNotRune('hollusion_magnitude', rune); - drawnRunes.push(new HollusionRune(rune, magnitude)); - return rune; -} - -/** - * Renders the specified Rune in a tab as a hollusion, with a default magnitude - * of 0.1. - * @param rune - The Rune to render - * @return {Rune} The specified Rune - * - * @category Main - */ -export function hollusion(rune: Rune): Rune { - throwIfNotRune('hollusion', rune); - return hollusion_magnitude(rune, 0.1); -} - -/** - * Create an animation of runes - * @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 function animate_rune( - duration: number, - fps: number, - func: RuneAnimation -) { - const anim = new AnimatedRune(duration, fps, (n) => { - const rune = func(n); - throwIfNotRune('animate_rune', rune); - return new NormalRune(rune); - }); - drawnRunes.push(anim); - return anim; -} - -/** - * Create an animation of anaglyph runes - * @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 function animate_anaglyph( - duration: number, - fps: number, - func: RuneAnimation -) { - const anim = new AnimatedRune(duration, fps, (n) => { - const rune = func(n); - throwIfNotRune('animate_anaglyph', rune); - return new AnaglyphRune(rune); - }); - drawnRunes.push(anim); - return anim; -} +/** + * 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. + * @module rune + */ +import { mat4, vec3 } from 'gl-matrix'; +import { + Rune, + NormalRune, + RuneAnimation, + DrawnRune, + drawRunesToFrameBuffer, + AnimatedRune, +} from './rune'; +import { + getSquare, + getBlank, + getRcross, + getSail, + getTriangle, + getCorner, + getNova, + getCircle, + getHeart, + getPentagram, + getRibbon, + throwIfNotRune, + addColorFromHex, + colorPalette, + hexToColor, +} from './runes_ops'; +import { + FrameBufferWithTexture, + getWebGlFromCanvas, + initFramebufferObject, + initShaderProgram, +} from './runes_webgl'; + +/** @hidden */ +export const drawnRunes: (DrawnRune | AnimatedRune)[] = []; + +// ============================================================================= +// Basic Runes +// ============================================================================= + +/** + * Rune with the shape of a full square + * + * @category Primitive + */ +export const square: Rune = getSquare(); +/** + * Rune with the shape of a blank square + * + * @category Primitive + */ +export const blank: Rune = getBlank(); +/** + * 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: Rune = getRcross(); +/** + * Rune with the shape of a sail + * + * @category Primitive + */ +export const sail: Rune = getSail(); +/** + * Rune with the shape of a triangle + * + * @category Primitive + */ +export const triangle: Rune = getTriangle(); +/** + * Rune with black triangle, + * filling upper right corner + * + * @category Primitive + */ +export const corner: Rune = getCorner(); +/** + * Rune with the shape of two overlapping + * triangles, residing in the upper half + * of the shape + * + * @category Primitive + */ +export const nova: Rune = getNova(); +/** + * Rune with the shape of a circle + * + * @category Primitive + */ +export const circle: Rune = getCircle(); +/** + * Rune with the shape of a heart + * + * @category Primitive + */ +export const heart: Rune = getHeart(); +/** + * Rune with the shape of a pentagram + * + * @category Primitive + */ +export const pentagram: Rune = getPentagram(); +/** + * Rune with the shape of a ribbon + * winding outwards in an anticlockwise spiral + * + * @category Primitive + */ +export const ribbon: Rune = getRibbon(); + +// ============================================================================= +// Textured Runes +// ============================================================================= +/** + * 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. + * + * @category Main + */ +export function from_url(imageUrl: string): Rune { + const rune = getSquare(); + rune.texture = new Image(); + rune.texture.crossOrigin = 'anonymous'; + rune.texture.src = imageUrl; + return rune; +} + +// ============================================================================= +// XY-axis Transformation functions +// ============================================================================= + +/** + * 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 + * @return {Rune} Resulting scaled Rune + * + * @category Main + */ +export function scale_independent( + ratio_x: number, + ratio_y: number, + rune: Rune, +): Rune { + throwIfNotRune('scale_independent', rune); + const scaleVec = vec3.fromValues(ratio_x, ratio_y, 1); + const scaleMat = mat4.create(); + mat4.scale(scaleMat, scaleMat, scaleVec); + + const wrapperMat = mat4.create(); + mat4.multiply(wrapperMat, scaleMat, wrapperMat); + return Rune.of({ + subRunes: [rune], + transformMatrix: wrapperMat, + }); +} + +/** + * Scales a given Rune by a given factor in both x and y direction + * @param {number} ratio - Scaling factor + * @param {Rune} rune - Given Rune + * @return {Rune} Resulting scaled Rune + * + * @category Main + */ +export function scale(ratio: number, rune: Rune): Rune { + throwIfNotRune('scale', rune); + return scale_independent(ratio, ratio, rune); +} + +/** + * 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 + * @return {Rune} Resulting translated Rune + * + * @category Main + */ +export function translate(x: number, y: number, rune: Rune): Rune { + throwIfNotRune('translate', rune); + const translateVec = vec3.fromValues(x, -y, 0); + const translateMat = mat4.create(); + mat4.translate(translateMat, translateMat, translateVec); + + const wrapperMat = mat4.create(); + mat4.multiply(wrapperMat, translateMat, wrapperMat); + return Rune.of({ + subRunes: [rune], + transformMatrix: wrapperMat, + }); +} + +/** + * 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 + * @return {Rune} Rotated Rune + * + * @category Main + */ +export function rotate(rad: number, rune: Rune): Rune { + throwIfNotRune('rotate', rune); + const rotateMat = mat4.create(); + mat4.rotateZ(rotateMat, rotateMat, rad); + + const wrapperMat = mat4.create(); + mat4.multiply(wrapperMat, rotateMat, wrapperMat); + return Rune.of({ + subRunes: [rune], + transformMatrix: wrapperMat, + }); +} + +/** + * 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 + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function stack_frac(frac: number, rune1: Rune, rune2: Rune): Rune { + throwIfNotRune('stack_frac', rune1); + throwIfNotRune('stack_frac', rune2); + + if (!(frac >= 0 && frac <= 1)) { + throw Error('stack_frac can only take fraction in [0,1].'); + } + + const upper = translate(0, -(1 - frac), scale_independent(1, frac, rune1)); + const lower = translate(0, frac, scale_independent(1, 1 - frac, rune2)); + return Rune.of({ + subRunes: [upper, lower], + }); +} + +/** + * 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 + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function stack(rune1: Rune, rune2: Rune): Rune { + throwIfNotRune('stack', rune1, rune2); + return stack_frac(1 / 2, rune1, rune2); +} + +/** + * 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 + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function stackn(n: number, rune: Rune): Rune { + throwIfNotRune('stackn', rune); + if (n === 1) { + return rune; + } + return stack_frac(1 / n, rune, stackn(n - 1, rune)); +} + +/** + * 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 + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function quarter_turn_right(rune: Rune): Rune { + throwIfNotRune('quarter_turn_right', rune); + return rotate(-Math.PI / 2, rune); +} + +/** + * Makes a new Rune from a given Rune + * by turning it a quarter-turn in + * anti-clockwise direction. + * @param {Rune} rune - Given Rune + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function quarter_turn_left(rune: Rune): Rune { + throwIfNotRune('quarter_turn_left', rune); + return rotate(Math.PI / 2, rune); +} + +/** + * Makes a new Rune from a given Rune + * by turning it upside-down + * @param {Rune} rune - Given Rune + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function turn_upside_down(rune: Rune): Rune { + throwIfNotRune('turn_upside_down', rune); + return rotate(Math.PI, rune); +} + +/** + * 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 + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function beside_frac(frac: number, rune1: Rune, rune2: Rune): Rune { + throwIfNotRune('beside_frac', rune1, rune2); + + if (!(frac >= 0 && frac <= 1)) { + throw Error('beside_frac can only take fraction in [0,1].'); + } + + const left = translate(-(1 - frac), 0, scale_independent(frac, 1, rune1)); + const right = translate(frac, 0, scale_independent(1 - frac, 1, rune2)); + return Rune.of({ + subRunes: [left, right], + }); +} + +/** + * 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 + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function beside(rune1: Rune, rune2: Rune): Rune { + throwIfNotRune('beside', rune1, rune2); + return beside_frac(1 / 2, rune1, rune2); +} + +/** + * Makes a new Rune from a given Rune by + * flipping it around a horizontal axis, + * turning it upside down + * @param {Rune} rune - Given Rune + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function flip_vert(rune: Rune): Rune { + throwIfNotRune('flip_vert', rune); + return scale_independent(1, -1, rune); +} + +/** + * Makes a new Rune from a given Rune by + * flipping it around a vertical axis, + * creating a mirror image + * @param {Rune} rune - Given Rune + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function flip_horiz(rune: Rune): Rune { + throwIfNotRune('flip_horiz', rune); + return scale_independent(-1, 1, rune); +} + +/** + * 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 + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function make_cross(rune: Rune): Rune { + throwIfNotRune('make_cross', rune); + return stack( + beside(quarter_turn_right(rune), rotate(Math.PI, rune)), + beside(rune, rotate(Math.PI / 2, rune)), + ); +} + +/** + * 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 + * @return {Rune} - Result of n times application of pattern to initial: + * pattern(pattern(...pattern(pattern(initial))...)) + * + * @category Main + */ +export function repeat_pattern( + n: number, + pattern: (a: Rune) => Rune, + initial: Rune, +): Rune { + if (n === 0) { + return initial; + } + return pattern(repeat_pattern(n - 1, pattern, initial)); +} + +// ============================================================================= +// Z-axis Transformation functions +// ============================================================================= + +/** + * 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 + * @return {Rune} Resulting Rune + * + * @category Main + */ +export function 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. + throwIfNotRune('overlay_frac', rune1); + throwIfNotRune('overlay_frac', rune2); + if (!(frac >= 0 && frac <= 1)) { + throw Error('overlay_frac can only take fraction in [0,1].'); + } + // by definition, when frac == 0 or 1, the back rune will overlap with the front rune. + // however, this would cause graphical glitch because overlapping is physically impossible + // we hack this problem by clipping the frac input from [0,1] to [1E-6, 1-1E-6] + // this should not be graphically noticable + let useFrac = frac; + const minFrac = 0.000001; + const maxFrac = 1 - minFrac; + if (useFrac < minFrac) { + useFrac = minFrac; + } + if (useFrac > maxFrac) { + useFrac = maxFrac; + } + + const frontMat = mat4.create(); + // z: scale by frac + mat4.scale(frontMat, frontMat, vec3.fromValues(1, 1, useFrac)); + const front = Rune.of({ + subRunes: [rune1], + transformMatrix: frontMat, + }); + + const backMat = mat4.create(); + // need to apply transformation in backwards order! + mat4.translate(backMat, backMat, vec3.fromValues(0, 0, -useFrac)); + mat4.scale(backMat, backMat, vec3.fromValues(1, 1, 1 - useFrac)); + const back = Rune.of({ + subRunes: [rune2], + transformMatrix: backMat, + }); + + return Rune.of({ + subRunes: [front, back], // render front first to avoid redrawing + }); +} + +/** + * 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 + * @return {Rune} Resulting Runes + * + * @category Main + */ +export function overlay(rune1: Rune, rune2: Rune): Rune { + throwIfNotRune('overlay', rune1); + throwIfNotRune('overlay', rune2); + return overlay_frac(0.5, rune1, rune2); +} + +// ============================================================================= +// Color functions +// ============================================================================= + +/** + * 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 + * + * @category Color + */ +export function color(rune: Rune, r: number, g: number, b: number): Rune { + throwIfNotRune('color', rune); + + const colorVector = [r, g, b, 1]; + return Rune.of({ + colors: new Float32Array(colorVector), + subRunes: [rune], + }); +} + +/** + * 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 + * + * @category Color + */ +export function random_color(rune: Rune): Rune { + throwIfNotRune('random_color', rune); + const randomColor = hexToColor( + colorPalette[Math.floor(Math.random() * colorPalette.length)], + ); + + return Rune.of({ + colors: new Float32Array(randomColor), + subRunes: [rune], + }); +} + +/** + * Colors the given rune red (#F44336). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function red(rune: Rune): Rune { + throwIfNotRune('red', rune); + return addColorFromHex(rune, '#F44336'); +} + +/** + * Colors the given rune pink (#E91E63s). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function pink(rune: Rune): Rune { + throwIfNotRune('pink', rune); + return addColorFromHex(rune, '#E91E63'); +} + +/** + * Colors the given rune purple (#AA00FF). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function purple(rune: Rune): Rune { + throwIfNotRune('purple', rune); + return addColorFromHex(rune, '#AA00FF'); +} + +/** + * Colors the given rune indigo (#3F51B5). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function indigo(rune: Rune): Rune { + throwIfNotRune('indigo', rune); + return addColorFromHex(rune, '#3F51B5'); +} + +/** + * Colors the given rune blue (#2196F3). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function blue(rune: Rune): Rune { + throwIfNotRune('blue', rune); + return addColorFromHex(rune, '#2196F3'); +} + +/** + * Colors the given rune green (#4CAF50). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function green(rune: Rune): Rune { + throwIfNotRune('green', rune); + return addColorFromHex(rune, '#4CAF50'); +} + +/** + * Colors the given rune yellow (#FFEB3B). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function yellow(rune: Rune): Rune { + throwIfNotRune('yellow', rune); + return addColorFromHex(rune, '#FFEB3B'); +} + +/** + * Colors the given rune orange (#FF9800). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function orange(rune: Rune): Rune { + throwIfNotRune('orange', rune); + return addColorFromHex(rune, '#FF9800'); +} + +/** + * Colors the given rune brown. + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function brown(rune: Rune): Rune { + throwIfNotRune('brown', rune); + return addColorFromHex(rune, '#795548'); +} + +/** + * Colors the given rune black (#000000). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function black(rune: Rune): Rune { + throwIfNotRune('black', rune); + return addColorFromHex(rune, '#000000'); +} + +/** + * Colors the given rune white (#FFFFFF). + * @param {Rune} rune - The rune to color + * @returns {Rune} The colored Rune + * + * @category Color + */ +export function white(rune: Rune): Rune { + throwIfNotRune('white', rune); + return addColorFromHex(rune, '#FFFFFF'); +} + +// ============================================================================= +// Drawing functions +// ============================================================================= + +/** + * Renders the specified Rune in a tab as a basic drawing. + * @param rune - The Rune to render + * @return {Rune} The specified Rune + * + * @category Main + */ +export function show(rune: Rune): Rune { + throwIfNotRune('show', rune); + drawnRunes.push(new NormalRune(rune)); + return rune; +} + +/** @hidden */ +export class AnaglyphRune extends DrawnRune { + private static readonly anaglyphVertexShader = ` + precision mediump float; + attribute vec4 a_position; + varying highp vec2 v_texturePosition; + void main() { + gl_Position = a_position; + // texture position is in [0,1], vertex position is in [-1,1] + v_texturePosition.x = (a_position.x + 1.0) / 2.0; + v_texturePosition.y = (a_position.y + 1.0) / 2.0; + } + `; + + private static readonly anaglyphFragmentShader = ` + precision mediump float; + uniform sampler2D u_sampler_red; + uniform sampler2D u_sampler_cyan; + varying highp vec2 v_texturePosition; + void main() { + gl_FragColor = texture2D(u_sampler_red, v_texturePosition) + + texture2D(u_sampler_cyan, v_texturePosition) - 1.0; + gl_FragColor.a = 1.0; + } + `; + + constructor(rune: Rune) { + super(rune, false); + } + + public draw = (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 + const runes = white(overlay_frac(0.999999999, blank, scale(2.2, square))) + .flatten() + .concat(this.rune.flatten()); + + // calculate the left and right camera matrices + const halfEyeDistance = 0.03; + const leftCameraMatrix = mat4.create(); + mat4.lookAt( + leftCameraMatrix, + vec3.fromValues(-halfEyeDistance, 0, 0), + vec3.fromValues(0, 0, -0.4), + vec3.fromValues(0, 1, 0), + ); + const rightCameraMatrix = mat4.create(); + mat4.lookAt( + rightCameraMatrix, + vec3.fromValues(halfEyeDistance, 0, 0), + vec3.fromValues(0, 0, -0.4), + vec3.fromValues(0, 1, 0), + ); + + // left/right eye images are drawn into respective framebuffers + const leftBuffer = initFramebufferObject(gl); + const rightBuffer = initFramebufferObject(gl); + drawRunesToFrameBuffer( + gl, + runes, + leftCameraMatrix, + new Float32Array([1, 0, 0, 1]), + leftBuffer.framebuffer, + true, + ); + drawRunesToFrameBuffer( + gl, + runes, + rightCameraMatrix, + new Float32Array([0, 1, 1, 1]), + rightBuffer.framebuffer, + true, + ); + + // prepare to draw to screen by setting framebuffer to null + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + // prepare the shader program to combine the left/right eye images + const shaderProgram = initShaderProgram( + gl, + AnaglyphRune.anaglyphVertexShader, + AnaglyphRune.anaglyphFragmentShader, + ); + gl.useProgram(shaderProgram); + const reduPt = gl.getUniformLocation(shaderProgram, 'u_sampler_red'); + const cyanuPt = gl.getUniformLocation(shaderProgram, 'u_sampler_cyan'); + const vertexPositionPointer = gl.getAttribLocation( + shaderProgram, + 'a_position', + ); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, leftBuffer.texture); + gl.uniform1i(cyanuPt, 0); + + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, rightBuffer.texture); + gl.uniform1i(reduPt, 1); + + // draw a square, which will allow the texture to be used + // load position buffer + const positionBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, square.vertices, gl.STATIC_DRAW); + gl.vertexAttribPointer(vertexPositionPointer, 4, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(vertexPositionPointer); + gl.drawArrays(gl.TRIANGLES, 0, 6); + }; +} + +/** + * Renders the specified Rune in a tab as an anaglyph. Use 3D glasses to view the + * anaglyph. + * @param rune - The Rune to render + * @return {Rune} The specified Rune + * + * @category Main + */ +export function anaglyph(rune: Rune): Rune { + throwIfNotRune('anaglyph', rune); + drawnRunes.push(new AnaglyphRune(rune)); + return rune; +} + +/** @hidden */ +export class HollusionRune extends DrawnRune { + constructor(rune: Rune, magnitude: number) { + super(rune, true); + this.rune.hollusionDistance = magnitude; + } + + private static readonly copyVertexShader = ` + precision mediump float; + attribute vec4 a_position; + varying highp vec2 v_texturePosition; + void main() { + gl_Position = a_position; + // texture position is in [0,1], vertex position is in [-1,1] + v_texturePosition.x = (a_position.x + 1.0) / 2.0; + v_texturePosition.y = (a_position.y + 1.0) / 2.0; + } + `; + + private static readonly copyFragmentShader = ` + precision mediump float; + uniform sampler2D uTexture; + varying highp vec2 v_texturePosition; + void main() { + gl_FragColor = texture2D(uTexture, v_texturePosition); + } + `; + + public draw = (canvas: HTMLCanvasElement) => { + const gl = getWebGlFromCanvas(canvas); + + const runes = white(overlay_frac(0.999999999, blank, scale(2.2, square))) + .flatten() + .concat(this.rune.flatten()); + + // first render all the frames into a framebuffer + const xshiftMax = runes[0].hollusionDistance; + const period = 2000; // animations loops every 2 seconds + const frameCount = 50; // in total 50 frames, gives rise to 25 fps + const frameBuffer: FrameBufferWithTexture[] = []; + + const renderFrame = (framePos: number): FrameBufferWithTexture => { + const fb = initFramebufferObject(gl); + // prepare camera projection array + const cameraMatrix = mat4.create(); + // let the object shift in the x direction + // the following calculation will let x oscillate in (-xshiftMax, xshiftMax) with time + let xshift = (framePos * (period / frameCount)) % period; + if (xshift > period / 2) { + xshift = period - xshift; + } + xshift = xshiftMax * (2 * ((2 * xshift) / period) - 1); + mat4.lookAt( + cameraMatrix, + vec3.fromValues(xshift, 0, 0), + vec3.fromValues(0, 0, -0.4), + vec3.fromValues(0, 1, 0), + ); + + drawRunesToFrameBuffer( + gl, + runes, + cameraMatrix, + new Float32Array([1, 1, 1, 1]), + fb.framebuffer, + true, + ); + return fb; + }; + + for (let i = 0; i < frameCount; i += 1) { + frameBuffer.push(renderFrame(i)); + } + + // Then, draw a frame from framebuffer for each update + const copyShaderProgram = initShaderProgram( + gl, + HollusionRune.copyVertexShader, + HollusionRune.copyFragmentShader, + ); + gl.useProgram(copyShaderProgram); + const texturePt = gl.getUniformLocation(copyShaderProgram, 'uTexture'); + const vertexPositionPointer = gl.getAttribLocation( + copyShaderProgram, + 'a_position', + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + const positionBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, square.vertices, gl.STATIC_DRAW); + gl.vertexAttribPointer(vertexPositionPointer, 4, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(vertexPositionPointer); + + let lastTime = 0; + function render(timeInMs: number) { + if (timeInMs - lastTime < period / frameCount) return; + + lastTime = timeInMs; + + const framePos + = Math.floor(timeInMs / (period / frameCount)) % frameCount; + const fbObject = frameBuffer[framePos]; + gl.clearColor(1.0, 1.0, 1.0, 1.0); // Set clear color to white, fully opaque + // eslint-disable-next-line no-bitwise + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // Clear the viewport + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, fbObject.texture); + gl.uniform1i(texturePt, 0); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + return render; + }; +} + +/** + * Renders the specified Rune in a tab as a hollusion, using the specified + * magnitude. + * @param rune - The Rune to render + * @param {number} magnitude - The hollusion's magnitude + * @return {Rune} The specified Rune + * + * @category Main + */ +export function hollusion_magnitude(rune: Rune, magnitude: number): Rune { + throwIfNotRune('hollusion_magnitude', rune); + drawnRunes.push(new HollusionRune(rune, magnitude)); + return rune; +} + +/** + * Renders the specified Rune in a tab as a hollusion, with a default magnitude + * of 0.1. + * @param rune - The Rune to render + * @return {Rune} The specified Rune + * + * @category Main + */ +export function hollusion(rune: Rune): Rune { + throwIfNotRune('hollusion', rune); + return hollusion_magnitude(rune, 0.1); +} + +/** + * Create an animation of runes + * @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 function animate_rune( + duration: number, + fps: number, + func: RuneAnimation, +) { + const anim = new AnimatedRune(duration, fps, (n) => { + const rune = func(n); + throwIfNotRune('animate_rune', rune); + return new NormalRune(rune); + }); + drawnRunes.push(anim); + return anim; +} + +/** + * Create an animation of anaglyph runes + * @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 function animate_anaglyph( + duration: number, + fps: number, + func: RuneAnimation, +) { + const anim = new AnimatedRune(duration, fps, (n) => { + const rune = func(n); + throwIfNotRune('animate_anaglyph', rune); + return new AnaglyphRune(rune); + }); + drawnRunes.push(anim); + return anim; +} diff --git a/src/bundles/rune/index.ts b/src/bundles/rune/index.ts index e7d7465d9..65ce5d928 100644 --- a/src/bundles/rune/index.ts +++ b/src/bundles/rune/index.ts @@ -1,141 +1,141 @@ -import { ModuleContexts, ModuleParams } from '../../typings/type_helpers.js'; -import { - anaglyph, - animate_anaglyph, - animate_rune, - beside, - beside_frac, - black, - blank, - blue, - brown, - circle, - color, - corner, - drawnRunes, - flip_horiz, - flip_vert, - from_url, - green, - heart, - hollusion, - hollusion_magnitude, - 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, - show, - square, - stack, - stackn, - stack_frac, - translate, - triangle, - turn_upside_down, - white, - yellow, -} from './functions'; -import { RunesModuleState } from './rune'; - -/** - * Bundle for Source Academy Runes module - * @author Hou Ruomu - */ - -export default function runes( - moduleParams: ModuleParams, - moduleContexts: ModuleContexts -) { - // Update the module's global context - let moduleContext = moduleContexts.get('rune'); - - if (!moduleContext) { - moduleContext = { - tabs: [], - state: { - drawnRunes, - }, - }; - - moduleContexts.set('rune', moduleContext); - } else if (!moduleContext.state) { - moduleContext.state = { - drawnRunes, - }; - } else { - (moduleContext.state as RunesModuleState).drawnRunes = drawnRunes; - } - - return { - square, - blank, - rcross, - sail, - triangle, - corner, - nova, - circle, - heart, - pentagram, - ribbon, - - from_url, - - scale_independent, - scale, - translate, - rotate, - stack_frac, - stack, - stackn, - quarter_turn_left, - quarter_turn_right, - turn_upside_down, - beside_frac, - beside, - flip_vert, - flip_horiz, - make_cross, - repeat_pattern, - - overlay_frac, - overlay, - - color, - random_color, - red, - pink, - purple, - indigo, - blue, - green, - yellow, - orange, - brown, - black, - white, - - show, - anaglyph, - hollusion_magnitude, - hollusion, - animate_rune, - animate_anaglyph, - }; -} +import { ModuleContexts, ModuleParams } from '../../typings/type_helpers.js'; +import { + anaglyph, + animate_anaglyph, + animate_rune, + beside, + beside_frac, + black, + blank, + blue, + brown, + circle, + color, + corner, + drawnRunes, + flip_horiz, + flip_vert, + from_url, + green, + heart, + hollusion, + hollusion_magnitude, + 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, + show, + square, + stack, + stackn, + stack_frac, + translate, + triangle, + turn_upside_down, + white, + yellow, +} from './functions'; +import { RunesModuleState } from './rune'; + +/** + * Bundle for Source Academy Runes module + * @author Hou Ruomu + */ + +export default function runes( + moduleParams: ModuleParams, + moduleContexts: ModuleContexts, +) { + // Update the module's global context + let moduleContext = moduleContexts.get('rune'); + + if (!moduleContext) { + moduleContext = { + tabs: [], + state: { + drawnRunes, + }, + }; + + moduleContexts.set('rune', moduleContext); + } else if (!moduleContext.state) { + moduleContext.state = { + drawnRunes, + }; + } else { + (moduleContext.state as RunesModuleState).drawnRunes = drawnRunes; + } + + return { + square, + blank, + rcross, + sail, + triangle, + corner, + nova, + circle, + heart, + pentagram, + ribbon, + + from_url, + + scale_independent, + scale, + translate, + rotate, + stack_frac, + stack, + stackn, + quarter_turn_left, + quarter_turn_right, + turn_upside_down, + beside_frac, + beside, + flip_vert, + flip_horiz, + make_cross, + repeat_pattern, + + overlay_frac, + overlay, + + color, + random_color, + red, + pink, + purple, + indigo, + blue, + green, + yellow, + orange, + brown, + black, + white, + + show, + anaglyph, + hollusion_magnitude, + hollusion, + animate_rune, + animate_anaglyph, + }; +} diff --git a/src/bundles/rune/rune.ts b/src/bundles/rune/rune.ts index 922a66949..ec6851ed7 100644 --- a/src/bundles/rune/rune.ts +++ b/src/bundles/rune/rune.ts @@ -1,419 +1,417 @@ -import { mat4 } from 'gl-matrix'; -import { ModuleState } from 'js-slang'; -import { AnimFrame, glAnimation } from '../../typings/anim_types'; -import { ReplResult } from '../../typings/type_helpers'; -import { getWebGlFromCanvas, initShaderProgram } from './runes_webgl'; - -const normalVertexShader = ` -attribute vec4 aVertexPosition; -uniform vec4 uVertexColor; -uniform mat4 uModelViewMatrix; -uniform mat4 uProjectionMatrix; -uniform mat4 uCameraMatrix; - -varying lowp vec4 vColor; -varying highp vec2 vTexturePosition; -varying lowp float colorFactor; -void main(void) { - gl_Position = uProjectionMatrix * uCameraMatrix * uModelViewMatrix * aVertexPosition; - vColor = uVertexColor; - - // texture position is in [0,1], vertex position is in [-1,1] - vTexturePosition.x = (aVertexPosition.x + 1.0) / 2.0; - vTexturePosition.y = 1.0 - (aVertexPosition.y + 1.0) / 2.0; - - colorFactor = gl_Position.z; -} -`; - -const normalFragmentShader = ` -precision mediump float; -uniform bool uRenderWithTexture; -uniform bool uRenderWithDepthColor; -uniform sampler2D uTexture; -varying lowp float colorFactor; -uniform vec4 uColorFilter; - - -varying lowp vec4 vColor; -varying highp vec2 vTexturePosition; -void main(void) { - if (uRenderWithTexture){ - gl_FragColor = texture2D(uTexture, vTexturePosition); - } else { - gl_FragColor = vColor; - } - if (uRenderWithDepthColor){ - gl_FragColor += (colorFactor + 0.5) * (1.0 - gl_FragColor); - gl_FragColor.a = 1.0; - } - gl_FragColor = uColorFilter * gl_FragColor + 1.0 - uColorFilter; - gl_FragColor.a = 1.0; -} -`; -/** - * The basic data-representation of a Rune. When the Rune is drawn, every 3 consecutive vertex will form a triangle. - * @field vertices - A list of vertex coordinates, each vertex has 4 coordiante (x,y,z,t). - * @field colors - A list of vertex colors, each vertex has a color (r,g,b,a). - * @field transformMatrix - A mat4 that is applied to all the vertices and the sub runes - * @field subRune - A (potentially empty) list of Runes - */ -export class Rune { - constructor( - public vertices: Float32Array, - public colors: Float32Array | null, - public transformMatrix: mat4, - public subRunes: Rune[], - public texture: HTMLImageElement | null, - public hollusionDistance: number - ) {} - - public copy = () => - new Rune( - this.vertices, - this.colors, - mat4.clone(this.transformMatrix), - this.subRunes, - this.texture, - this.hollusionDistance - ); - - /** - * Flatten the subrunes to return a list of runes - * @return Rune[], a list of runes - */ - public flatten = () => { - const runeList: Rune[] = []; - const runeTodoList: Rune[] = [this.copy()]; - - while (runeTodoList.length !== 0) { - const runeToExpand: Rune = runeTodoList.pop()!; // ! claims that the pop() will not return undefined. - runeToExpand.subRunes.forEach((subRune: Rune) => { - const subRuneCopy = subRune.copy(); - - mat4.multiply( - subRuneCopy.transformMatrix, - runeToExpand.transformMatrix, - subRuneCopy.transformMatrix - ); - subRuneCopy.hollusionDistance = runeToExpand.hollusionDistance; - if (runeToExpand.colors !== null) { - subRuneCopy.colors = runeToExpand.colors; - } - runeTodoList.push(subRuneCopy); - }); - runeToExpand.subRunes = []; - if (runeToExpand.vertices.length > 0) { - runeList.push(runeToExpand); - } - } - return runeList; - }; - - public static of = ( - params: { - vertices?: Float32Array; - colors?: Float32Array | null; - transformMatrix?: mat4; - subRunes?: Rune[]; - texture?: HTMLImageElement | null; - hollusionDistance?: number; - } = {} - ) => { - const paramGetter = (name: string, defaultValue: () => any) => - params[name] === undefined ? defaultValue() : params[name]; - - return new Rune( - paramGetter('vertices', () => new Float32Array()), - paramGetter('colors', () => null), - paramGetter('transformMatrix', mat4.create), - paramGetter('subRunes', () => []), - paramGetter('texture', () => null), - paramGetter('hollusionDistance', () => 0.1) - ); - }; - - public toReplString = () => ''; -} - -/** - * Draws the list of runes with the prepared WebGLRenderingContext, with each rune overlapping each other onto a given framebuffer. if the framebuffer is null, draw to the default canvas. - * - * @param gl a prepared WebGLRenderingContext with shader program linked - * @param runes a list of rune (Rune[]) to be drawn sequentially - */ -export function drawRunesToFrameBuffer( - gl: WebGLRenderingContext, - runes: Rune[], - cameraMatrix: mat4, - colorFilter: Float32Array, - framebuffer: WebGLFramebuffer | null = null, - depthSwitch: boolean = false -) { - // step 1: initiate the WebGLRenderingContext - gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); - // step 2: initiate the shaderProgram - const shaderProgram = initShaderProgram( - gl, - normalVertexShader, - normalFragmentShader - ); - gl.useProgram(shaderProgram); - if (gl === null) { - throw Error('Rendering Context not initialized for drawRune.'); - } - - // create pointers to the data-entries of the shader program - const vertexPositionPointer = gl.getAttribLocation( - shaderProgram, - 'aVertexPosition' - ); - const vertexColorPointer = gl.getUniformLocation( - shaderProgram, - 'uVertexColor' - ); - const vertexColorFilterPt = gl.getUniformLocation( - shaderProgram, - 'uColorFilter' - ); - const projectionMatrixPointer = gl.getUniformLocation( - shaderProgram, - 'uProjectionMatrix' - ); - const cameraMatrixPointer = gl.getUniformLocation( - shaderProgram, - 'uCameraMatrix' - ); - const modelViewMatrixPointer = gl.getUniformLocation( - shaderProgram, - 'uModelViewMatrix' - ); - const textureSwitchPointer = gl.getUniformLocation( - shaderProgram, - 'uRenderWithTexture' - ); - const depthSwitchPointer = gl.getUniformLocation( - shaderProgram, - 'uRenderWithDepthColor' - ); - const texturePointer = gl.getUniformLocation(shaderProgram, 'uTexture'); - - // load depth - gl.uniform1i(depthSwitchPointer, depthSwitch ? 1 : 0); - - // 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); - - // load colorfilter - gl.uniform4fv(vertexColorFilterPt, colorFilter); - - // 3. draw each Rune using the shader program - /** - * Credit to: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Tutorial/Using_textures_in_WebGL - * Initialize a texture and load an image. - * When the image finished loading copy it into the texture. - */ - const loadTexture = (image: HTMLImageElement): WebGLTexture | null => { - const texture = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, texture); - function isPowerOf2(value) { - // eslint-disable-next-line no-bitwise - return (value & (value - 1)) === 0; - } - // Because images have to be downloaded over the internet - // they might take a moment until they are ready. - // Until then put a single pixel in the texture so we can - // use it immediately. When the image has finished downloading - // we'll update the texture with the contents of the image. - const level = 0; - const internalFormat = gl.RGBA; - const width = 1; - const height = 1; - const border = 0; - const srcFormat = gl.RGBA; - const srcType = gl.UNSIGNED_BYTE; - const pixel = new Uint8Array([0, 0, 255, 255]); // opaque blue - gl.texImage2D( - gl.TEXTURE_2D, - level, - internalFormat, - width, - height, - border, - srcFormat, - srcType, - pixel - ); - - gl.bindTexture(gl.TEXTURE_2D, texture); - gl.texImage2D( - gl.TEXTURE_2D, - level, - internalFormat, - srcFormat, - srcType, - image - ); - - // WebGL1 has different requirements for power of 2 images - // vs non power of 2 images so check if the image is a - // power of 2 in both dimensions. - if (isPowerOf2(image.width) && isPowerOf2(image.height)) { - // Yes, it's a power of 2. Generate mips. - gl.generateMipmap(gl.TEXTURE_2D); - } else { - // No, it's not a power of 2. Turn off mips and set - // wrapping to clamp to edge - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - } - - return texture; - }; - - runes.forEach((rune: Rune) => { - // load position buffer - const positionBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); - gl.bufferData(gl.ARRAY_BUFFER, rune.vertices, gl.STATIC_DRAW); - gl.vertexAttribPointer(vertexPositionPointer, 4, gl.FLOAT, false, 0, 0); - gl.enableVertexAttribArray(vertexPositionPointer); - - // load color/texture - if (rune.texture === null) { - gl.uniform4fv( - vertexColorPointer, - rune.colors || new Float32Array([0, 0, 0, 1]) - ); - gl.uniform1i(textureSwitchPointer, 0); - } else { - const texture = loadTexture(rune.texture); - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, texture); - gl.uniform1i(texturePointer, 0); - gl.uniform1i(textureSwitchPointer, 1); - } - - // load transformation matrix - gl.uniformMatrix4fv(modelViewMatrixPointer, false, rune.transformMatrix); - - // draw - const vertexCount = rune.vertices.length / 4; - gl.drawArrays(gl.TRIANGLES, 0, vertexCount); - }); -} - -/** - * Represents runes with a draw method attached - */ -export abstract class DrawnRune implements ReplResult { - private static readonly normalVertexShader = ` - attribute vec4 aVertexPosition; - uniform vec4 uVertexColor; - uniform mat4 uModelViewMatrix; - uniform mat4 uProjectionMatrix; - uniform mat4 uCameraMatrix; - - varying lowp vec4 vColor; - varying highp vec2 vTexturePosition; - varying lowp float colorFactor; - void main(void) { - gl_Position = uProjectionMatrix * uCameraMatrix * uModelViewMatrix * aVertexPosition; - vColor = uVertexColor; - - // texture position is in [0,1], vertex position is in [-1,1] - vTexturePosition.x = (aVertexPosition.x + 1.0) / 2.0; - vTexturePosition.y = 1.0 - (aVertexPosition.y + 1.0) / 2.0; - - colorFactor = gl_Position.z; - } - `; - - private static readonly normalFragmentShader = ` - precision mediump float; - uniform bool uRenderWithTexture; - uniform bool uRenderWithDepthColor; - uniform sampler2D uTexture; - varying lowp float colorFactor; - uniform vec4 uColorFilter; - - - varying lowp vec4 vColor; - varying highp vec2 vTexturePosition; - void main(void) { - if (uRenderWithTexture){ - gl_FragColor = texture2D(uTexture, vTexturePosition); - } else { - gl_FragColor = vColor; - } - if (uRenderWithDepthColor){ - gl_FragColor += (colorFactor + 0.5) * (1.0 - gl_FragColor); - gl_FragColor.a = 1.0; - } - gl_FragColor = uColorFilter * gl_FragColor + 1.0 - uColorFilter; - gl_FragColor.a = 1.0; - } - `; - - constructor( - protected readonly rune: Rune, - public readonly isHollusion: boolean - ) {} - - public toReplString = () => ''; - - public abstract draw: (canvas: HTMLCanvasElement) => void; -} - -export class NormalRune extends DrawnRune { - constructor(rune: Rune) { - super(rune, false); - } - - public draw = (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( - gl, - this.rune.flatten(), - cameraMatrix, - new Float32Array([1, 1, 1, 1]), - null, - true - ); - }; -} - -/** A function that takes in a timestamp and returns a Rune */ -export type RuneAnimation = (time: number) => Rune; - -export class AnimatedRune extends glAnimation implements ReplResult { - constructor( - duration: number, - fps: number, - private readonly func: (frame: number) => DrawnRune - ) { - super(duration, fps); - } - - public getFrame(num: number): AnimFrame { - const rune = this.func(num); - return { - draw: rune.draw, - }; - } - - public toReplString = () => ''; -} - -export class RunesModuleState implements ModuleState { - constructor(public drawnRunes: (DrawnRune | AnimatedRune)[] = []) {} -} +import { mat4 } from 'gl-matrix'; +import { ModuleState } from 'js-slang'; +import { AnimFrame, glAnimation } from '../../typings/anim_types'; +import { ReplResult } from '../../typings/type_helpers'; +import { getWebGlFromCanvas, initShaderProgram } from './runes_webgl'; + +const normalVertexShader = ` +attribute vec4 aVertexPosition; +uniform vec4 uVertexColor; +uniform mat4 uModelViewMatrix; +uniform mat4 uProjectionMatrix; +uniform mat4 uCameraMatrix; + +varying lowp vec4 vColor; +varying highp vec2 vTexturePosition; +varying lowp float colorFactor; +void main(void) { + gl_Position = uProjectionMatrix * uCameraMatrix * uModelViewMatrix * aVertexPosition; + vColor = uVertexColor; + + // texture position is in [0,1], vertex position is in [-1,1] + vTexturePosition.x = (aVertexPosition.x + 1.0) / 2.0; + vTexturePosition.y = 1.0 - (aVertexPosition.y + 1.0) / 2.0; + + colorFactor = gl_Position.z; +} +`; + +const normalFragmentShader = ` +precision mediump float; +uniform bool uRenderWithTexture; +uniform bool uRenderWithDepthColor; +uniform sampler2D uTexture; +varying lowp float colorFactor; +uniform vec4 uColorFilter; + + +varying lowp vec4 vColor; +varying highp vec2 vTexturePosition; +void main(void) { + if (uRenderWithTexture){ + gl_FragColor = texture2D(uTexture, vTexturePosition); + } else { + gl_FragColor = vColor; + } + if (uRenderWithDepthColor){ + gl_FragColor += (colorFactor + 0.5) * (1.0 - gl_FragColor); + gl_FragColor.a = 1.0; + } + gl_FragColor = uColorFilter * gl_FragColor + 1.0 - uColorFilter; + gl_FragColor.a = 1.0; +} +`; +/** + * The basic data-representation of a Rune. When the Rune is drawn, every 3 consecutive vertex will form a triangle. + * @field vertices - A list of vertex coordinates, each vertex has 4 coordiante (x,y,z,t). + * @field colors - A list of vertex colors, each vertex has a color (r,g,b,a). + * @field transformMatrix - A mat4 that is applied to all the vertices and the sub runes + * @field subRune - A (potentially empty) list of Runes + */ +export class Rune { + constructor( + public vertices: Float32Array, + public colors: Float32Array | null, + public transformMatrix: mat4, + public subRunes: Rune[], + public texture: HTMLImageElement | null, + public hollusionDistance: number, + ) {} + + public copy = () => new Rune( + this.vertices, + this.colors, + mat4.clone(this.transformMatrix), + this.subRunes, + this.texture, + this.hollusionDistance, + ); + + /** + * Flatten the subrunes to return a list of runes + * @return Rune[], a list of runes + */ + public flatten = () => { + const runeList: Rune[] = []; + const runeTodoList: Rune[] = [this.copy()]; + + while (runeTodoList.length !== 0) { + const runeToExpand: Rune = runeTodoList.pop()!; // ! claims that the pop() will not return undefined. + runeToExpand.subRunes.forEach((subRune: Rune) => { + const subRuneCopy = subRune.copy(); + + mat4.multiply( + subRuneCopy.transformMatrix, + runeToExpand.transformMatrix, + subRuneCopy.transformMatrix, + ); + subRuneCopy.hollusionDistance = runeToExpand.hollusionDistance; + if (runeToExpand.colors !== null) { + subRuneCopy.colors = runeToExpand.colors; + } + runeTodoList.push(subRuneCopy); + }); + runeToExpand.subRunes = []; + if (runeToExpand.vertices.length > 0) { + runeList.push(runeToExpand); + } + } + return runeList; + }; + + public static of = ( + params: { + vertices?: Float32Array; + colors?: Float32Array | null; + transformMatrix?: mat4; + subRunes?: Rune[]; + texture?: HTMLImageElement | null; + hollusionDistance?: number; + } = {}, + ) => { + const paramGetter = (name: string, defaultValue: () => any) => (params[name] === undefined ? defaultValue() : params[name]); + + return new Rune( + paramGetter('vertices', () => new Float32Array()), + paramGetter('colors', () => null), + paramGetter('transformMatrix', mat4.create), + paramGetter('subRunes', () => []), + paramGetter('texture', () => null), + paramGetter('hollusionDistance', () => 0.1), + ); + }; + + public toReplString = () => ''; +} + +/** + * Draws the list of runes with the prepared WebGLRenderingContext, with each rune overlapping each other onto a given framebuffer. if the framebuffer is null, draw to the default canvas. + * + * @param gl a prepared WebGLRenderingContext with shader program linked + * @param runes a list of rune (Rune[]) to be drawn sequentially + */ +export function drawRunesToFrameBuffer( + gl: WebGLRenderingContext, + runes: Rune[], + cameraMatrix: mat4, + colorFilter: Float32Array, + framebuffer: WebGLFramebuffer | null = null, + depthSwitch: boolean = false, +) { + // step 1: initiate the WebGLRenderingContext + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); + // step 2: initiate the shaderProgram + const shaderProgram = initShaderProgram( + gl, + normalVertexShader, + normalFragmentShader, + ); + gl.useProgram(shaderProgram); + if (gl === null) { + throw Error('Rendering Context not initialized for drawRune.'); + } + + // create pointers to the data-entries of the shader program + const vertexPositionPointer = gl.getAttribLocation( + shaderProgram, + 'aVertexPosition', + ); + const vertexColorPointer = gl.getUniformLocation( + shaderProgram, + 'uVertexColor', + ); + const vertexColorFilterPt = gl.getUniformLocation( + shaderProgram, + 'uColorFilter', + ); + const projectionMatrixPointer = gl.getUniformLocation( + shaderProgram, + 'uProjectionMatrix', + ); + const cameraMatrixPointer = gl.getUniformLocation( + shaderProgram, + 'uCameraMatrix', + ); + const modelViewMatrixPointer = gl.getUniformLocation( + shaderProgram, + 'uModelViewMatrix', + ); + const textureSwitchPointer = gl.getUniformLocation( + shaderProgram, + 'uRenderWithTexture', + ); + const depthSwitchPointer = gl.getUniformLocation( + shaderProgram, + 'uRenderWithDepthColor', + ); + const texturePointer = gl.getUniformLocation(shaderProgram, 'uTexture'); + + // load depth + gl.uniform1i(depthSwitchPointer, depthSwitch ? 1 : 0); + + // 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); + + // load colorfilter + gl.uniform4fv(vertexColorFilterPt, colorFilter); + + // 3. draw each Rune using the shader program + /** + * Credit to: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Tutorial/Using_textures_in_WebGL + * Initialize a texture and load an image. + * When the image finished loading copy it into the texture. + */ + const loadTexture = (image: HTMLImageElement): WebGLTexture | null => { + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + function isPowerOf2(value) { + // eslint-disable-next-line no-bitwise + return (value & (value - 1)) === 0; + } + // Because images have to be downloaded over the internet + // they might take a moment until they are ready. + // Until then put a single pixel in the texture so we can + // use it immediately. When the image has finished downloading + // we'll update the texture with the contents of the image. + const level = 0; + const internalFormat = gl.RGBA; + const width = 1; + const height = 1; + const border = 0; + const srcFormat = gl.RGBA; + const srcType = gl.UNSIGNED_BYTE; + const pixel = new Uint8Array([0, 0, 255, 255]); // opaque blue + gl.texImage2D( + gl.TEXTURE_2D, + level, + internalFormat, + width, + height, + border, + srcFormat, + srcType, + pixel, + ); + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + level, + internalFormat, + srcFormat, + srcType, + image, + ); + + // WebGL1 has different requirements for power of 2 images + // vs non power of 2 images so check if the image is a + // power of 2 in both dimensions. + if (isPowerOf2(image.width) && isPowerOf2(image.height)) { + // Yes, it's a power of 2. Generate mips. + gl.generateMipmap(gl.TEXTURE_2D); + } else { + // No, it's not a power of 2. Turn off mips and set + // wrapping to clamp to edge + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + } + + return texture; + }; + + runes.forEach((rune: Rune) => { + // load position buffer + const positionBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, rune.vertices, gl.STATIC_DRAW); + gl.vertexAttribPointer(vertexPositionPointer, 4, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(vertexPositionPointer); + + // load color/texture + if (rune.texture === null) { + gl.uniform4fv( + vertexColorPointer, + rune.colors || new Float32Array([0, 0, 0, 1]), + ); + gl.uniform1i(textureSwitchPointer, 0); + } else { + const texture = loadTexture(rune.texture); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(texturePointer, 0); + gl.uniform1i(textureSwitchPointer, 1); + } + + // load transformation matrix + gl.uniformMatrix4fv(modelViewMatrixPointer, false, rune.transformMatrix); + + // draw + const vertexCount = rune.vertices.length / 4; + gl.drawArrays(gl.TRIANGLES, 0, vertexCount); + }); +} + +/** + * Represents runes with a draw method attached + */ +export abstract class DrawnRune implements ReplResult { + private static readonly normalVertexShader = ` + attribute vec4 aVertexPosition; + uniform vec4 uVertexColor; + uniform mat4 uModelViewMatrix; + uniform mat4 uProjectionMatrix; + uniform mat4 uCameraMatrix; + + varying lowp vec4 vColor; + varying highp vec2 vTexturePosition; + varying lowp float colorFactor; + void main(void) { + gl_Position = uProjectionMatrix * uCameraMatrix * uModelViewMatrix * aVertexPosition; + vColor = uVertexColor; + + // texture position is in [0,1], vertex position is in [-1,1] + vTexturePosition.x = (aVertexPosition.x + 1.0) / 2.0; + vTexturePosition.y = 1.0 - (aVertexPosition.y + 1.0) / 2.0; + + colorFactor = gl_Position.z; + } + `; + + private static readonly normalFragmentShader = ` + precision mediump float; + uniform bool uRenderWithTexture; + uniform bool uRenderWithDepthColor; + uniform sampler2D uTexture; + varying lowp float colorFactor; + uniform vec4 uColorFilter; + + + varying lowp vec4 vColor; + varying highp vec2 vTexturePosition; + void main(void) { + if (uRenderWithTexture){ + gl_FragColor = texture2D(uTexture, vTexturePosition); + } else { + gl_FragColor = vColor; + } + if (uRenderWithDepthColor){ + gl_FragColor += (colorFactor + 0.5) * (1.0 - gl_FragColor); + gl_FragColor.a = 1.0; + } + gl_FragColor = uColorFilter * gl_FragColor + 1.0 - uColorFilter; + gl_FragColor.a = 1.0; + } + `; + + constructor( + protected readonly rune: Rune, + public readonly isHollusion: boolean, + ) {} + + public toReplString = () => ''; + + public abstract draw: (canvas: HTMLCanvasElement) => void; +} + +export class NormalRune extends DrawnRune { + constructor(rune: Rune) { + super(rune, false); + } + + public draw = (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( + gl, + this.rune.flatten(), + cameraMatrix, + new Float32Array([1, 1, 1, 1]), + null, + true, + ); + }; +} + +/** A function that takes in a timestamp and returns a Rune */ +export type RuneAnimation = (time: number) => Rune; + +export class AnimatedRune extends glAnimation implements ReplResult { + constructor( + duration: number, + fps: number, + private readonly func: (frame: number) => DrawnRune, + ) { + super(duration, fps); + } + + public getFrame(num: number): AnimFrame { + const rune = this.func(num); + return { + draw: rune.draw, + }; + } + + public toReplString = () => ''; +} + +export class RunesModuleState implements ModuleState { + constructor(public drawnRunes: (DrawnRune | AnimatedRune)[] = []) {} +} diff --git a/src/bundles/rune/runes_ops.ts b/src/bundles/rune/runes_ops.ts index 601c2489a..995095f59 100644 --- a/src/bundles/rune/runes_ops.ts +++ b/src/bundles/rune/runes_ops.ts @@ -1,361 +1,361 @@ -/** - * This file contains the bundle's private functions for runes. - */ -import { Rune } from './rune'; - -// ============================================================================= -// Utility Functions -// ============================================================================= -export function throwIfNotRune(name, ...runes) { - runes.forEach((rune) => { - if (!(rune instanceof Rune)) { - throw Error(`${name} expects a rune as argument.`); - } - }); -} - -// ============================================================================= -// Basic Runes -// ============================================================================= - -/** - * primitive Rune in the rune of a full square - * */ -export const getSquare: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - - vertexList.push(-1, 1, 0, 1); - vertexList.push(-1, -1, 0, 1); - vertexList.push(1, -1, 0, 1); - vertexList.push(1, -1, 0, 1); - vertexList.push(-1, 1, 0, 1); - vertexList.push(1, 1, 0, 1); - - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -export const getBlank: () => Rune = () => Rune.of(); - -/** - * primitive Rune in the rune of a - * smallsquare inside a large square, - * each diagonally split into a - * black and white half - * */ -export const getRcross: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - // lower small triangle - vertexList.push(-0.5, 0.5, 0, 1); - vertexList.push(-0.5, -0.5, 0, 1); - vertexList.push(0.5, -0.5, 0, 1); - - // upper shape, starting from left-top corner - vertexList.push(-1, 1, 0, 1); - vertexList.push(-0.5, 0.5, 0, 1); - vertexList.push(1, 1, 0, 1); - - vertexList.push(-0.5, 0.5, 0, 1); - vertexList.push(1, 1, 0, 1); - vertexList.push(0.5, 0.5, 0, 1); - - vertexList.push(1, 1, 0, 1); - vertexList.push(0.5, 0.5, 0, 1); - vertexList.push(1, -1, 0, 1); - - vertexList.push(0.5, 0.5, 0, 1); - vertexList.push(1, -1, 0, 1); - vertexList.push(0.5, -0.5, 0, 1); - - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -/** - * primitive Rune in the rune of a sail - * */ -export const getSail: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - - vertexList.push(0.5, -1, 0, 1); - vertexList.push(0, -1, 0, 1); - vertexList.push(0, 1, 0, 1); - - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -/** - * primitive Rune in the rune of a triangle - * */ -export const getTriangle: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - - vertexList.push(1, -1, 0, 1); - vertexList.push(0, -1, 0, 1); - vertexList.push(0, 1, 0, 1); - - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -/** - * primitive Rune with black triangle, - * filling upper right corner - * */ -export const getCorner: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - vertexList.push(1, 0, 0, 1); - vertexList.push(1, 1, 0, 1); - vertexList.push(0, 1, 0, 1); - - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -/** - * primitive Rune in the rune of two overlapping - * triangles, residing in the upper half - * of - * */ -export const getNova: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - vertexList.push(0, 1, 0, 1); - vertexList.push(-0.5, 0, 0, 1); - vertexList.push(0, 0.5, 0, 1); - - vertexList.push(-0.5, 0, 0, 1); - vertexList.push(0, 0.5, 0, 1); - vertexList.push(1, 0, 0, 1); - - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -/** - * primitive Rune in the rune of a circle - * */ -export const getCircle: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - const circleDiv = 60; - for (let i = 0; i < circleDiv; i += 1) { - const angle1 = ((2 * Math.PI) / circleDiv) * i; - const angle2 = ((2 * Math.PI) / circleDiv) * (i + 1); - vertexList.push(Math.cos(angle1), Math.sin(angle1), 0, 1); - vertexList.push(Math.cos(angle2), Math.sin(angle2), 0, 1); - vertexList.push(0, 0, 0, 1); - } - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -/** - * primitive Rune in the rune of a heart - * */ -export const getHeart: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - - const root2 = Math.sqrt(2); - const r = 4 / (2 + 3 * root2); - const scaleX = 1 / (r * (1 + root2 / 2)); - const numPoints = 10; - - // right semi-circle - const rightCenterX = r / root2; - const rightCenterY = 1 - r; - for (let i = 0; i < numPoints; i += 1) { - const angle1 = Math.PI * (-1 / 4 + i / numPoints); - const angle2 = Math.PI * (-1 / 4 + (i + 1) / numPoints); - vertexList.push( - (Math.cos(angle1) * r + rightCenterX) * scaleX, - Math.sin(angle1) * r + rightCenterY, - 0, - 1 - ); - vertexList.push( - (Math.cos(angle2) * r + rightCenterX) * scaleX, - Math.sin(angle2) * r + rightCenterY, - 0, - 1 - ); - vertexList.push(0, -1, 0, 1); - } - // left semi-circle - const leftCenterX = -r / root2; - const leftCenterY = 1 - r; - for (let i = 0; i <= numPoints; i += 1) { - const angle1 = Math.PI * (1 / 4 + i / numPoints); - const angle2 = Math.PI * (1 / 4 + (i + 1) / numPoints); - vertexList.push( - (Math.cos(angle1) * r + leftCenterX) * scaleX, - Math.sin(angle1) * r + leftCenterY, - 0, - 1 - ); - vertexList.push( - (Math.cos(angle2) * r + leftCenterX) * scaleX, - Math.sin(angle2) * r + leftCenterY, - 0, - 1 - ); - vertexList.push(0, -1, 0, 1); - } - - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -/** - * primitive Rune in the rune of a pentagram - * */ -export const getPentagram: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - - const v1 = Math.sin(Math.PI / 10); - const v2 = Math.cos(Math.PI / 10); - - const w1 = Math.sin((3 * Math.PI) / 10); - const w2 = Math.cos((3 * Math.PI) / 10); - - const vertices: number[][] = []; - vertices.push([v2, v1, 0, 1]); - vertices.push([w2, -w1, 0, 1]); - vertices.push([-w2, -w1, 0, 1]); - vertices.push([-v2, v1, 0, 1]); - vertices.push([0, 1, 0, 1]); - - for (let i = 0; i < 5; i += 1) { - vertexList.push(0, 0, 0, 1); - vertexList.push(...vertices[i]); - vertexList.push(...vertices[(i + 2) % 5]); - } - - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -/** - * primitive Rune in the rune of a ribbon - * winding outwards in an anticlockwise spiral - * */ -export const getRibbon: () => Rune = () => { - const vertexList: number[] = []; - const colorList: number[] = []; - - const thetaMax = 30; - const thickness = -1 / thetaMax; - const unit = 0.1; - - const vertices: number[][] = []; - for (let i = 0; i < thetaMax; i += unit) { - vertices.push([ - (i / thetaMax) * Math.cos(i), - (i / thetaMax) * Math.sin(i), - 0, - 1, - ]); - vertices.push([ - Math.abs(Math.cos(i) * thickness) + (i / thetaMax) * Math.cos(i), - Math.abs(Math.sin(i) * thickness) + (i / thetaMax) * Math.sin(i), - 0, - 1, - ]); - } - for (let i = 0; i < vertices.length - 2; i += 1) { - vertexList.push(...vertices[i]); - vertexList.push(...vertices[i + 1]); - vertexList.push(...vertices[i + 2]); - } - - colorList.push(0, 0, 0, 1); - - return Rune.of({ - vertices: new Float32Array(vertexList), - colors: new Float32Array(colorList), - }); -}; - -// ============================================================================= -// Coloring Functions -// ============================================================================= -// black and white not included because they are boring colors -// colorPalette is used in generateFlattenedRuneList to generate a random color -export const colorPalette = [ - '#F44336', - '#E91E63', - '#AA00FF', - '#3F51B5', - '#2196F3', - '#4CAF50', - '#FFEB3B', - '#FF9800', - '#795548', -]; - -export function hexToColor(hex): number[] { - const result = /^#?(?[a-f\d]{2})(?[a-f\d]{2})(?[a-f\d]{2})$/iu.exec( - hex - ); - if (result === null || result.length < 4) { - return [0, 0, 0]; - } - return [ - parseInt(result[1], 16) / 255, - parseInt(result[2], 16) / 255, - parseInt(result[3], 16) / 255, - 1, - ]; -} - -export function addColorFromHex(rune, hex) { - throwIfNotRune('addColorFromHex', rune); - return Rune.of({ - subRunes: [rune], - colors: new Float32Array(hexToColor(hex)), - }); -} +/** + * This file contains the bundle's private functions for runes. + */ +import { Rune } from './rune'; + +// ============================================================================= +// Utility Functions +// ============================================================================= +export function throwIfNotRune(name, ...runes) { + runes.forEach((rune) => { + if (!(rune instanceof Rune)) { + throw Error(`${name} expects a rune as argument.`); + } + }); +} + +// ============================================================================= +// Basic Runes +// ============================================================================= + +/** + * primitive Rune in the rune of a full square + * */ +export const getSquare: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + + vertexList.push(-1, 1, 0, 1); + vertexList.push(-1, -1, 0, 1); + vertexList.push(1, -1, 0, 1); + vertexList.push(1, -1, 0, 1); + vertexList.push(-1, 1, 0, 1); + vertexList.push(1, 1, 0, 1); + + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +export const getBlank: () => Rune = () => Rune.of(); + +/** + * primitive Rune in the rune of a + * smallsquare inside a large square, + * each diagonally split into a + * black and white half + * */ +export const getRcross: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + // lower small triangle + vertexList.push(-0.5, 0.5, 0, 1); + vertexList.push(-0.5, -0.5, 0, 1); + vertexList.push(0.5, -0.5, 0, 1); + + // upper shape, starting from left-top corner + vertexList.push(-1, 1, 0, 1); + vertexList.push(-0.5, 0.5, 0, 1); + vertexList.push(1, 1, 0, 1); + + vertexList.push(-0.5, 0.5, 0, 1); + vertexList.push(1, 1, 0, 1); + vertexList.push(0.5, 0.5, 0, 1); + + vertexList.push(1, 1, 0, 1); + vertexList.push(0.5, 0.5, 0, 1); + vertexList.push(1, -1, 0, 1); + + vertexList.push(0.5, 0.5, 0, 1); + vertexList.push(1, -1, 0, 1); + vertexList.push(0.5, -0.5, 0, 1); + + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +/** + * primitive Rune in the rune of a sail + * */ +export const getSail: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + + vertexList.push(0.5, -1, 0, 1); + vertexList.push(0, -1, 0, 1); + vertexList.push(0, 1, 0, 1); + + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +/** + * primitive Rune in the rune of a triangle + * */ +export const getTriangle: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + + vertexList.push(1, -1, 0, 1); + vertexList.push(0, -1, 0, 1); + vertexList.push(0, 1, 0, 1); + + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +/** + * primitive Rune with black triangle, + * filling upper right corner + * */ +export const getCorner: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + vertexList.push(1, 0, 0, 1); + vertexList.push(1, 1, 0, 1); + vertexList.push(0, 1, 0, 1); + + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +/** + * primitive Rune in the rune of two overlapping + * triangles, residing in the upper half + * of + * */ +export const getNova: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + vertexList.push(0, 1, 0, 1); + vertexList.push(-0.5, 0, 0, 1); + vertexList.push(0, 0.5, 0, 1); + + vertexList.push(-0.5, 0, 0, 1); + vertexList.push(0, 0.5, 0, 1); + vertexList.push(1, 0, 0, 1); + + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +/** + * primitive Rune in the rune of a circle + * */ +export const getCircle: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + const circleDiv = 60; + for (let i = 0; i < circleDiv; i += 1) { + const angle1 = ((2 * Math.PI) / circleDiv) * i; + const angle2 = ((2 * Math.PI) / circleDiv) * (i + 1); + vertexList.push(Math.cos(angle1), Math.sin(angle1), 0, 1); + vertexList.push(Math.cos(angle2), Math.sin(angle2), 0, 1); + vertexList.push(0, 0, 0, 1); + } + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +/** + * primitive Rune in the rune of a heart + * */ +export const getHeart: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + + const root2 = Math.sqrt(2); + const r = 4 / (2 + 3 * root2); + const scaleX = 1 / (r * (1 + root2 / 2)); + const numPoints = 10; + + // right semi-circle + const rightCenterX = r / root2; + const rightCenterY = 1 - r; + for (let i = 0; i < numPoints; i += 1) { + const angle1 = Math.PI * (-1 / 4 + i / numPoints); + const angle2 = Math.PI * (-1 / 4 + (i + 1) / numPoints); + vertexList.push( + (Math.cos(angle1) * r + rightCenterX) * scaleX, + Math.sin(angle1) * r + rightCenterY, + 0, + 1, + ); + vertexList.push( + (Math.cos(angle2) * r + rightCenterX) * scaleX, + Math.sin(angle2) * r + rightCenterY, + 0, + 1, + ); + vertexList.push(0, -1, 0, 1); + } + // left semi-circle + const leftCenterX = -r / root2; + const leftCenterY = 1 - r; + for (let i = 0; i <= numPoints; i += 1) { + const angle1 = Math.PI * (1 / 4 + i / numPoints); + const angle2 = Math.PI * (1 / 4 + (i + 1) / numPoints); + vertexList.push( + (Math.cos(angle1) * r + leftCenterX) * scaleX, + Math.sin(angle1) * r + leftCenterY, + 0, + 1, + ); + vertexList.push( + (Math.cos(angle2) * r + leftCenterX) * scaleX, + Math.sin(angle2) * r + leftCenterY, + 0, + 1, + ); + vertexList.push(0, -1, 0, 1); + } + + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +/** + * primitive Rune in the rune of a pentagram + * */ +export const getPentagram: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + + const v1 = Math.sin(Math.PI / 10); + const v2 = Math.cos(Math.PI / 10); + + const w1 = Math.sin((3 * Math.PI) / 10); + const w2 = Math.cos((3 * Math.PI) / 10); + + const vertices: number[][] = []; + vertices.push([v2, v1, 0, 1]); + vertices.push([w2, -w1, 0, 1]); + vertices.push([-w2, -w1, 0, 1]); + vertices.push([-v2, v1, 0, 1]); + vertices.push([0, 1, 0, 1]); + + for (let i = 0; i < 5; i += 1) { + vertexList.push(0, 0, 0, 1); + vertexList.push(...vertices[i]); + vertexList.push(...vertices[(i + 2) % 5]); + } + + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +/** + * primitive Rune in the rune of a ribbon + * winding outwards in an anticlockwise spiral + * */ +export const getRibbon: () => Rune = () => { + const vertexList: number[] = []; + const colorList: number[] = []; + + const thetaMax = 30; + const thickness = -1 / thetaMax; + const unit = 0.1; + + const vertices: number[][] = []; + for (let i = 0; i < thetaMax; i += unit) { + vertices.push([ + (i / thetaMax) * Math.cos(i), + (i / thetaMax) * Math.sin(i), + 0, + 1, + ]); + vertices.push([ + Math.abs(Math.cos(i) * thickness) + (i / thetaMax) * Math.cos(i), + Math.abs(Math.sin(i) * thickness) + (i / thetaMax) * Math.sin(i), + 0, + 1, + ]); + } + for (let i = 0; i < vertices.length - 2; i += 1) { + vertexList.push(...vertices[i]); + vertexList.push(...vertices[i + 1]); + vertexList.push(...vertices[i + 2]); + } + + colorList.push(0, 0, 0, 1); + + return Rune.of({ + vertices: new Float32Array(vertexList), + colors: new Float32Array(colorList), + }); +}; + +// ============================================================================= +// Coloring Functions +// ============================================================================= +// black and white not included because they are boring colors +// colorPalette is used in generateFlattenedRuneList to generate a random color +export const colorPalette = [ + '#F44336', + '#E91E63', + '#AA00FF', + '#3F51B5', + '#2196F3', + '#4CAF50', + '#FFEB3B', + '#FF9800', + '#795548', +]; + +export function hexToColor(hex): number[] { + const result = /^#?(?[a-f\d]{2})(?[a-f\d]{2})(?[a-f\d]{2})$/iu.exec( + hex, + ); + if (result === null || result.length < 4) { + return [0, 0, 0]; + } + return [ + parseInt(result[1], 16) / 255, + parseInt(result[2], 16) / 255, + parseInt(result[3], 16) / 255, + 1, + ]; +} + +export function addColorFromHex(rune, hex) { + throwIfNotRune('addColorFromHex', rune); + return Rune.of({ + subRunes: [rune], + colors: new Float32Array(hexToColor(hex)), + }); +} diff --git a/src/bundles/rune/runes_webgl.ts b/src/bundles/rune/runes_webgl.ts index 28d788538..5a7640697 100644 --- a/src/bundles/rune/runes_webgl.ts +++ b/src/bundles/rune/runes_webgl.ts @@ -1,163 +1,163 @@ -/** - * This file contains the module's private functions that handles various webgl operations. - */ - -export type FrameBufferWithTexture = { - framebuffer: WebGLFramebuffer; - texture: WebGLTexture; -}; - -// The following 2 functions loadShader and initShaderProgram are copied from the curve library, 26 Jul 2021 with no change. This unfortunately violated DIY priciple but I have no choice as those functions are not exported. -/** - * Gets shader based on given shader program code. - * - * @param gl - WebGL's rendering context - * @param type - Constant describing the type of shader to load - * @param source - Source code of the shader - * @returns WebGLShader used to initialize shader program - */ -function loadShader( - gl: WebGLRenderingContext, - type: number, - source: string -): WebGLShader { - const shader = gl.createShader(type); - if (!shader) { - throw new Error('WebGLShader not available.'); - } - gl.shaderSource(shader, source); - gl.compileShader(shader); - const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS); - if (!compiled) { - const compilationLog = gl.getShaderInfoLog(shader); - throw Error(`Shader compilation failed: ${compilationLog}`); - } - return shader; -} - -/** - * Initializes the shader program used by WebGL. - * - * @param gl - WebGL's rendering context - * @param vsSource - Vertex shader program code - * @param fsSource - Fragment shader program code - * @returns WebGLProgram used for getting AttribLocation and UniformLocation - */ -export function initShaderProgram( - gl: WebGLRenderingContext, - vsSource: string, - fsSource: string -): WebGLProgram { - const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); - const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); - const shaderProgram = gl.createProgram(); - if (!shaderProgram) { - throw new Error('Unable to initialize the shader program.'); - } - gl.attachShader(shaderProgram, vertexShader); - gl.attachShader(shaderProgram, fragmentShader); - gl.linkProgram(shaderProgram); - return shaderProgram; -} - -/** - * Get a WebGLRenderingContext from Canvas input - * @param canvas WebGLRenderingContext - * @returns - */ -export function getWebGlFromCanvas( - canvas: HTMLCanvasElement -): WebGLRenderingContext { - const gl: WebGLRenderingContext | null = canvas.getContext('webgl'); - if (!gl) { - throw Error('Unable to initialize WebGL.'); - } - gl.clearColor(1.0, 1.0, 1.0, 1.0); // Set clear color to white, fully opaque - gl.enable(gl.DEPTH_TEST); // Enable depth testing - gl.depthFunc(gl.LESS); // Near things obscure far things (this is default setting can omit) - // eslint-disable-next-line no-bitwise - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // Clear the viewport - return gl; -} - -/** - * creates a framebuffer - * @param gl WebGLRenderingContext - * @returns FrameBufferWithTexture - */ -export function initFramebufferObject( - gl: WebGLRenderingContext -): FrameBufferWithTexture { - // create a framebuffer object - const framebuffer = gl.createFramebuffer(); - if (!framebuffer) { - throw Error('Failed to create frame buffer object'); - } - - // create a texture object and set its size and parameters - const texture = gl.createTexture(); - if (!texture) { - throw Error('Failed to create texture object'); - } - gl.bindTexture(gl.TEXTURE_2D, texture); - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA, - gl.drawingBufferWidth, - gl.drawingBufferHeight, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - null - ); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - - // create a renderbuffer for depth buffer - const depthBuffer = gl.createRenderbuffer(); - if (!depthBuffer) { - throw Error('Failed to create renderbuffer object'); - } - - // bind renderbuffer object to target and set size - gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer); - gl.renderbufferStorage( - gl.RENDERBUFFER, - gl.DEPTH_COMPONENT16, - gl.drawingBufferWidth, - gl.drawingBufferHeight - ); - - // set the texture object to the framebuffer object - gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); // bind to target - gl.framebufferTexture2D( - gl.FRAMEBUFFER, - gl.COLOR_ATTACHMENT0, - gl.TEXTURE_2D, - texture, - 0 - ); - // set the renderbuffer object to the framebuffer object - gl.framebufferRenderbuffer( - gl.FRAMEBUFFER, - gl.DEPTH_ATTACHMENT, - gl.RENDERBUFFER, - depthBuffer - ); - - // check whether the framebuffer is configured correctly - const e = gl.checkFramebufferStatus(gl.FRAMEBUFFER); - if (gl.FRAMEBUFFER_COMPLETE !== e) { - throw Error(`Frame buffer object is incomplete:${e.toString()}`); - } - - // Unbind the buffer object - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - gl.bindTexture(gl.TEXTURE_2D, null); - gl.bindRenderbuffer(gl.RENDERBUFFER, null); - - return { - framebuffer, - texture, - }; -} +/** + * This file contains the module's private functions that handles various webgl operations. + */ + +export type FrameBufferWithTexture = { + framebuffer: WebGLFramebuffer; + texture: WebGLTexture; +}; + +// The following 2 functions loadShader and initShaderProgram are copied from the curve library, 26 Jul 2021 with no change. This unfortunately violated DIY priciple but I have no choice as those functions are not exported. +/** + * Gets shader based on given shader program code. + * + * @param gl - WebGL's rendering context + * @param type - Constant describing the type of shader to load + * @param source - Source code of the shader + * @returns WebGLShader used to initialize shader program + */ +function loadShader( + gl: WebGLRenderingContext, + type: number, + source: string, +): WebGLShader { + const shader = gl.createShader(type); + if (!shader) { + throw new Error('WebGLShader not available.'); + } + gl.shaderSource(shader, source); + gl.compileShader(shader); + const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS); + if (!compiled) { + const compilationLog = gl.getShaderInfoLog(shader); + throw Error(`Shader compilation failed: ${compilationLog}`); + } + return shader; +} + +/** + * Initializes the shader program used by WebGL. + * + * @param gl - WebGL's rendering context + * @param vsSource - Vertex shader program code + * @param fsSource - Fragment shader program code + * @returns WebGLProgram used for getting AttribLocation and UniformLocation + */ +export function initShaderProgram( + gl: WebGLRenderingContext, + vsSource: string, + fsSource: string, +): WebGLProgram { + const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); + const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); + const shaderProgram = gl.createProgram(); + if (!shaderProgram) { + throw new Error('Unable to initialize the shader program.'); + } + gl.attachShader(shaderProgram, vertexShader); + gl.attachShader(shaderProgram, fragmentShader); + gl.linkProgram(shaderProgram); + return shaderProgram; +} + +/** + * Get a WebGLRenderingContext from Canvas input + * @param canvas WebGLRenderingContext + * @returns + */ +export function getWebGlFromCanvas( + canvas: HTMLCanvasElement, +): WebGLRenderingContext { + const gl: WebGLRenderingContext | null = canvas.getContext('webgl'); + if (!gl) { + throw Error('Unable to initialize WebGL.'); + } + gl.clearColor(1.0, 1.0, 1.0, 1.0); // Set clear color to white, fully opaque + gl.enable(gl.DEPTH_TEST); // Enable depth testing + gl.depthFunc(gl.LESS); // Near things obscure far things (this is default setting can omit) + // eslint-disable-next-line no-bitwise + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // Clear the viewport + return gl; +} + +/** + * creates a framebuffer + * @param gl WebGLRenderingContext + * @returns FrameBufferWithTexture + */ +export function initFramebufferObject( + gl: WebGLRenderingContext, +): FrameBufferWithTexture { + // create a framebuffer object + const framebuffer = gl.createFramebuffer(); + if (!framebuffer) { + throw Error('Failed to create frame buffer object'); + } + + // create a texture object and set its size and parameters + const texture = gl.createTexture(); + if (!texture) { + throw Error('Failed to create texture object'); + } + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.drawingBufferWidth, + gl.drawingBufferHeight, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + + // create a renderbuffer for depth buffer + const depthBuffer = gl.createRenderbuffer(); + if (!depthBuffer) { + throw Error('Failed to create renderbuffer object'); + } + + // bind renderbuffer object to target and set size + gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer); + gl.renderbufferStorage( + gl.RENDERBUFFER, + gl.DEPTH_COMPONENT16, + gl.drawingBufferWidth, + gl.drawingBufferHeight, + ); + + // set the texture object to the framebuffer object + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); // bind to target + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + texture, + 0, + ); + // set the renderbuffer object to the framebuffer object + gl.framebufferRenderbuffer( + gl.FRAMEBUFFER, + gl.DEPTH_ATTACHMENT, + gl.RENDERBUFFER, + depthBuffer, + ); + + // check whether the framebuffer is configured correctly + const e = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + if (gl.FRAMEBUFFER_COMPLETE !== e) { + throw Error(`Frame buffer object is incomplete:${e.toString()}`); + } + + // Unbind the buffer object + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.bindTexture(gl.TEXTURE_2D, null); + gl.bindRenderbuffer(gl.RENDERBUFFER, null); + + return { + framebuffer, + texture, + }; +} diff --git a/src/bundles/scrabble/__tests__/index.ts b/src/bundles/scrabble/__tests__/index.ts index e1ef9230f..ba9db69f3 100644 --- a/src/bundles/scrabble/__tests__/index.ts +++ b/src/bundles/scrabble/__tests__/index.ts @@ -1,15 +1,17 @@ -import { scrabble_array, scrabble_list } from '../functions'; - -function list_ref(list, n) { - return n === 0 ? list[0] : list_ref(list[1], n - 1); -} - -// Test functions - -test('get the first word in the array', () => { - expect(scrabble_array[0]).toBe('aardwolves'); -}); - -test('get the first word in the list', () => { - expect(list_ref(scrabble_list, 0)).toBe('aardwolves'); -}); +import { scrabble_array, scrabble_list } from '../functions'; + +function list_ref(list, n) { + return n === 0 ? list[0] : list_ref(list[1], n - 1); +} + +// Test functions + +test('get the first word in the array', () => { + expect(scrabble_array[0]) + .toBe('aardwolves'); +}); + +test('get the first word in the list', () => { + expect(list_ref(scrabble_list, 0)) + .toBe('aardwolves'); +}); diff --git a/src/bundles/scrabble/functions.ts b/src/bundles/scrabble/functions.ts index 115c1c7df..1699dba9e 100644 --- a/src/bundles/scrabble/functions.ts +++ b/src/bundles/scrabble/functions.ts @@ -67610,7 +67610,7 @@ export const scrabble_list = current_list; export function charAt(s: string, i: number): any { const result = s.charAt(i); - return result === `` ? undefined : result; + return result === '' ? undefined : result; } export function arrayLength(x: any): number { diff --git a/src/bundles/sound/functions.ts b/src/bundles/sound/functions.ts index 2df6cd9b1..3727a3720 100644 --- a/src/bundles/sound/functions.ts +++ b/src/bundles/sound/functions.ts @@ -1,939 +1,937 @@ -/** - * The sounds library provides functions for constructing and playing sounds. - * - * A wave is a function that takes in a number `t` and returns - * a number representing the amplitude at time `t`. - * The amplitude should fall within the range of [-1, 1]. - * - * A Sound is a pair(wave, duration) where duration is the length of the sound in seconds. - * The constructor make_sound and accessors get_wave and get_duration are provided. - * - * Sound Discipline: - * For all sounds, the wave function applied to and time `t` beyond its duration returns 0, that is: - * `(get_wave(sound))(get_duration(sound) + x) === 0` for any x >= 0. - * - * Two functions which combine Sounds, `consecutively` and `simultaneously` are given. - * Additionally, we provide sound transformation functions `adsr` and `phase_mod` - * which take in a Sound and return a Sound. - * - * Finally, the provided `play` function takes in a Sound and plays it using your - * computer's sound system. - * - * @module sound - * @author Koh Shang Hui - * @author Samyukta Sounderraman - */ - -/* eslint-disable new-cap, @typescript-eslint/naming-convention */ -import { - Wave, - Sound, - SoundProducer, - SoundTransformer, - List, - AudioPlayed, -} from './types'; -import { - pair, - head, - tail, - list, - length, - is_null, - is_pair, - accumulate, -} from './list'; -import { RIFFWAVE } from './riffwave'; - -// Global Constants and Variables -const FS: number = 44100; // Output sample rate -const fourier_expansion_level: number = 5; // fourier expansion level - -/** @hidden */ -export const audioPlayed: AudioPlayed[] = []; - -// Singular audio context for all playback functions -let audioplayer: AudioContext; - -// Track if a sound is currently playing -let isPlaying: boolean; - -// Instantiates new audio context -function init_audioCtx(): void { - audioplayer = new window.AudioContext(); - // audioplayer = new (window.AudioContext || window.webkitAudioContext)(); -} - -// linear decay from 1 to 0 over decay_period -function linear_decay(decay_period: number): (t: number) => number { - return (t) => { - if (t > decay_period || t < 0) { - return 0; - } - return 1 - t / decay_period; - }; -} - -// // --------------------------------------------- -// // Microphone Functionality -// // --------------------------------------------- - -// permission initially undefined -// set to true by granting microphone permission -// set to false by denying microphone permission -let permission: boolean | undefined; - -let recorded_sound: Sound | undefined; - -// check_permission is called whenever we try -// to record a sound -function check_permission() { - if (permission === undefined) { - throw new Error( - `Call init_record(); to obtain permission to use microphone` - ); - } else if (permission === false) { - throw new Error(`Permission has been denied.\n - Re-start browser and call init_record();\n - to obtain permission to use microphone.`); - } // (permission === true): do nothing -} - -let globalStream: any; - -function rememberStream(stream: any) { - permission = true; - globalStream = stream; -} - -function setPermissionToFalse() { - permission = false; -} - -function start_recording(mediaRecorder: MediaRecorder) { - const data: any[] = []; - mediaRecorder.ondataavailable = (e) => e.data.size && data.push(e.data); - mediaRecorder.start(); - mediaRecorder.onstop = () => process(data); -} - -// duration of recording signal in milliseconds -const recording_signal_ms = 100; - -// duration of pause after "run" before recording signal is played -const pre_recording_signal_pause_ms = 200; - -function play_recording_signal() { - play(sine_sound(1200, recording_signal_ms / 1000)); -} - -// eslint-disable-next-line @typescript-eslint/no-shadow -function process(data) { - const audioContext = new AudioContext(); - const blob = new Blob(data); - - convertToArrayBuffer(blob) - .then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer)) - .then(save); -} - -// Converts input microphone sound (blob) into array format. -function convertToArrayBuffer(blob: Blob): Promise { - const url = URL.createObjectURL(blob); - return fetch(url).then((response) => response.arrayBuffer()); -} - -function save(audioBuffer: AudioBuffer) { - const array = audioBuffer.getChannelData(0); - const duration = array.length / FS; - recorded_sound = make_sound((t) => { - const index = t * FS; - const lowerIndex = Math.floor(index); - const upperIndex = lowerIndex + 1; - const ratio = index - lowerIndex; - const upper = array[upperIndex] ? array[upperIndex] : 0; - const lower = array[lowerIndex] ? array[lowerIndex] : 0; - return lower * (1 - ratio) + upper * ratio; - }, duration); -} - -/** - * Initialize recording by obtaining permission - * to use the default device microphone - * - * @returns string "obtaining recording permission" - */ -export function init_record(): string { - navigator.mediaDevices - .getUserMedia({ audio: true }) - .then(rememberStream, setPermissionToFalse); - return 'obtaining recording permission'; -} - -/** - * takes a buffer duration (in seconds) as argument, and - * returns a nullary stop function stop. A call - * stop() returns a sound promise: a nullary function - * that returns a sound. Example:
    init_record();
    - * const stop = record(0.5);
    - * // record after 0.5 seconds. Then in next query:
    - * const promise = stop();
    - * // In next query, you can play the promised sound, by
    - * // applying the promise:
    - * play(promise());
    - * @param buffer - pause before recording, in seconds - * @returns nullary stop function; - * stop() stops the recording and - * returns a sound promise: a nullary function that returns the recorded sound - */ -export function record(buffer: number): () => () => Sound { - check_permission(); - const mediaRecorder = new MediaRecorder(globalStream); - setTimeout(() => { - play_recording_signal(); - start_recording(mediaRecorder); - }, recording_signal_ms + buffer * 1000); - return () => { - mediaRecorder.stop(); - play_recording_signal(); - return () => { - if (recorded_sound === undefined) { - throw new Error('recording still being processed'); - } else { - return recorded_sound; - } - }; - }; -} - -/** - * Records a sound of given duration in seconds, after - * a buffer also in seconds, and - * returns a sound promise: a nullary function - * that returns a sound. Example:
    init_record();
    - * const promise = record_for(2, 0.5);
    - * // In next query, you can play the promised sound, by
    - * // applying the promise:
    - * play(promise());
    - * @param duration duration in seconds - * @param buffer pause before recording, in seconds - * @return promise: nullary function which returns recorded sound - */ -export function record_for(duration: number, buffer: number): () => Sound { - recorded_sound = undefined; - const recording_ms = duration * 1000; - const pre_recording_pause_ms = buffer * 1000; - check_permission(); - const mediaRecorder = new MediaRecorder(globalStream); - - // order of events for record_for: - // pre-recording-signal pause | recording signal | - // pre-recording pause | recording | recording signal - - setTimeout(() => { - play_recording_signal(); - setTimeout(() => { - start_recording(mediaRecorder); - setTimeout(() => { - mediaRecorder.stop(); - play_recording_signal(); - }, recording_ms); - }, recording_signal_ms + pre_recording_pause_ms); - }, pre_recording_signal_pause_ms); - - return () => { - if (recorded_sound === undefined) { - throw new Error('recording still being processed'); - } else { - return recorded_sound; - } - }; -} - -// ============================================================================= -// Module's Exposed Functions -// -// This file only includes the implementation and documentation of exposed -// functions of the module. For private functions dealing with the browser's -// graphics library context, see './webGL_curves.ts'. -// ============================================================================= - -// Core functions - -/** - * Makes a Sound with given wave function and duration. - * The wave function is a function: number -> number - * that takes in a non-negative input time and returns an amplitude - * between -1 and 1. - * - * @param wave wave function of the sound - * @param duration duration of the sound - * @return with wave as wave function and duration as duration - * @example const s = make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5); - */ -export function make_sound(wave: Wave, duration: number): Sound { - return pair((t: number) => (t >= duration ? 0 : wave(t)), duration); -} - -/** - * Accesses the wave function of a given Sound. - * - * @param sound given Sound - * @return the wave function of the Sound - * @example get_wave(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns t => Math_sin(2 * Math_PI * 440 * t) - */ -export function get_wave(sound: Sound): Wave { - return head(sound); -} - -/** - * Accesses the duration of a given Sound. - * - * @param sound given Sound - * @return the duration of the Sound - * @example get_duration(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns 5 - */ -export function get_duration(sound: Sound): number { - return tail(sound); -} - -/** - * Checks if the argument is a Sound - * - * @param x input to be checked - * @return true if x is a Sound, false otherwise - * @example is_sound(make_sound(t => 0, 2)); // Returns true - */ -export function is_sound(x: any): boolean { - return ( - is_pair(x) && - typeof get_wave(x) === 'function' && - typeof get_duration(x) === 'number' - ); -} - -/** - * Plays the given Wave using the computer’s sound device, for the duration - * given in seconds. - * The sound is only played if no other sounds are currently being played. - * - * @param wave the wave function to play, starting at 0 - * @return the given sound - * @example play_wave(t => math_sin(t * 3000), 5); - */ -export function play_wave(wave: Wave, duration: number): AudioPlayed { - return play(make_sound(wave, duration)); -} - -/** - * Plays the given Sound using the computer’s sound device. - * The sound is only played if no other sounds are currently being played. - * - * @param sound the sound to play - * @return the given sound - * @example play(sine_sound(440, 5)); - */ -export function play(sound: Sound): AudioPlayed { - // Type-check sound - if (!is_sound(sound)) { - throw new Error(`play is expecting sound, but encountered ${sound}`); - // If a sound is already playing, terminate execution. - } else if (isPlaying) { - throw new Error('play: audio system still playing previous sound'); - } else if (get_duration(sound) < 0) { - throw new Error('play: duration of sound is negative'); - } else { - // Instantiate audio context if it has not been instantiated. - if (!audioplayer) { - init_audioCtx(); - } - - // Create mono buffer - const channel: number[] = []; - const len = Math.ceil(FS * get_duration(sound)); - - let temp: number; - let prev_value = 0; - - const wave = get_wave(sound); - for (let i = 0; i < len; i += 1) { - temp = wave(i / FS); - // clip amplitude - // channel[i] = temp > 1 ? 1 : temp < -1 ? -1 : temp; - if (temp > 1) { - channel[i] = 1; - } else if (temp < -1) { - channel[i] = -1; - } else { - channel[i] = temp; - } - - // smoothen out sudden cut-outs - if (channel[i] === 0 && Math.abs(channel[i] - prev_value) > 0.01) { - channel[i] = prev_value * 0.999; - } - - prev_value = channel[i]; - } - - // quantize - for (let i = 0; i < channel.length; i += 1) { - channel[i] = Math.floor(channel[i] * 32767.999); - } - - const riffwave = new RIFFWAVE([]); - riffwave.header.sampleRate = FS; - riffwave.header.numChannels = 1; - riffwave.header.bitsPerSample = 16; - riffwave.Make(channel); - - /* - const audio = new Audio(riffwave.dataURI); - const source2 = audioplayer.createMediaElementSource(audio); - source2.connect(audioplayer.destination); - - // Connect data to output destination - isPlaying = true; - audio.play(); - audio.onended = () => { - source2.disconnect(audioplayer.destination); - isPlaying = false; - }; */ - - const soundToPlay = { - toReplString: () => ``, - dataUri: riffwave.dataURI, - }; - audioPlayed.push(soundToPlay); - return soundToPlay; - } -} - -/** - * Plays the given Sound using the computer’s sound device - * on top of any sounds that are currently playing. - * - * @param sound the sound to play - * @example play_concurrently(sine_sound(440, 5)); - */ -export function play_concurrently(sound: Sound): void { - // Type-check sound - if (!is_sound(sound)) { - throw new Error( - `play_concurrently is expecting sound, but encountered ${sound}` - ); - } else if (get_duration(sound) <= 0) { - // Do nothing - } else { - // Instantiate audio context if it has not been instantiated. - if (!audioplayer) { - init_audioCtx(); - } - - // Create mono buffer - const theBuffer = audioplayer.createBuffer( - 1, - Math.ceil(FS * get_duration(sound)), - FS - ); - const channel = theBuffer.getChannelData(0); - - let temp: number; - let prev_value = 0; - - const wave = get_wave(sound); - for (let i = 0; i < channel.length; i += 1) { - temp = wave(i / FS); - // clip amplitude - if (temp > 1) { - channel[i] = 1; - } else if (temp < -1) { - channel[i] = -1; - } else { - channel[i] = temp; - } - - // smoothen out sudden cut-outs - if (channel[i] === 0 && Math.abs(channel[i] - prev_value) > 0.01) { - channel[i] = prev_value * 0.999; - } - - prev_value = channel[i]; - } - - // Connect data to output destination - const source = audioplayer.createBufferSource(); - source.buffer = theBuffer; - source.connect(audioplayer.destination); - isPlaying = true; - source.start(); - source.onended = () => { - source.disconnect(audioplayer.destination); - isPlaying = false; - }; - } -} - -/** - * Stops all currently playing sounds. - */ -export function stop(): void { - audioplayer.close(); - isPlaying = false; -} - -// Primitive sounds - -/** - * Makes a noise sound with given duration - * - * @param duration the duration of the noise sound - * @return resulting noise sound - * @example noise_sound(5); - */ -export function noise_sound(duration: number): Sound { - return make_sound((_t) => Math.random() * 2 - 1, duration); -} - -/** - * Makes a silence sound with given duration - * - * @param duration the duration of the silence sound - * @return resulting silence sound - * @example silence_sound(5); - */ -export function silence_sound(duration: number): Sound { - return make_sound((_t) => 0, duration); -} - -/** - * Makes a sine wave sound with given frequency and duration - * - * @param freq the frequency of the sine wave sound - * @param duration the duration of the sine wave sound - * @return resulting sine wave sound - * @example sine_sound(440, 5); - */ -export function sine_sound(freq: number, duration: number): Sound { - return make_sound((t) => Math.sin(2 * Math.PI * t * freq), duration); -} - -/** - * Makes a square wave sound with given frequency and duration - * - * @param freq the frequency of the square wave sound - * @param duration the duration of the square wave sound - * @return resulting square wave sound - * @example square_sound(440, 5); - */ -export function square_sound(f: number, duration: number): Sound { - function fourier_expansion_square(t: number) { - let answer = 0; - for (let i = 1; i <= fourier_expansion_level; i += 1) { - answer += Math.sin(2 * Math.PI * (2 * i - 1) * f * t) / (2 * i - 1); - } - return answer; - } - return make_sound( - (t) => (4 / Math.PI) * fourier_expansion_square(t), - duration - ); -} - -/** - * Makes a triangle wave sound with given frequency and duration - * - * @param freq the frequency of the triangle wave sound - * @param duration the duration of the triangle wave sound - * @return resulting triangle wave sound - * @example triangle_sound(440, 5); - */ -export function triangle_sound(freq: number, duration: number): Sound { - function fourier_expansion_triangle(t: number) { - let answer = 0; - for (let i = 0; i < fourier_expansion_level; i += 1) { - answer += - ((-1) ** i * Math.sin((2 * i + 1) * t * freq * Math.PI * 2)) / - (2 * i + 1) ** 2; - } - return answer; - } - return make_sound( - (t) => (8 / Math.PI / Math.PI) * fourier_expansion_triangle(t), - duration - ); -} - -/** - * Makes a sawtooth wave sound with given frequency and duration - * - * @param freq the frequency of the sawtooth wave sound - * @param duration the duration of the sawtooth wave sound - * @return resulting sawtooth wave sound - * @example sawtooth_sound(440, 5); - */ -export function sawtooth_sound(freq: number, duration: number): Sound { - function fourier_expansion_sawtooth(t: number) { - let answer = 0; - for (let i = 1; i <= fourier_expansion_level; i += 1) { - answer += Math.sin(2 * Math.PI * i * freq * t) / i; - } - return answer; - } - return make_sound( - (t) => 1 / 2 - (1 / Math.PI) * fourier_expansion_sawtooth(t), - duration - ); -} - -// Composition Operators - -/** - * Makes a new Sound by combining the sounds in a given list - * where the second sound is appended to the end of the first sound, - * the third sound is appended to the end of the second sound, and - * so on. The effect is that the sounds in the list are joined end-to-end - * - * @param list_of_sounds given list of sounds - * @return the combined Sound - * @example consecutively(list(sine_sound(200, 2), sine_sound(400, 3))); - */ -export function consecutively(list_of_sounds: List): Sound { - function consec_two(ss1: Sound, ss2: Sound) { - const wave1 = get_wave(ss1); - const wave2 = get_wave(ss2); - const dur1 = get_duration(ss1); - const dur2 = get_duration(ss2); - const new_wave = (t: number) => (t < dur1 ? wave1(t) : wave2(t - dur1)); - return make_sound(new_wave, dur1 + dur2); - } - return accumulate(consec_two, silence_sound(0), list_of_sounds); -} - -/** - * Makes a new Sound by combining the sounds in a given list - * where all the sounds are overlapped on top of each other. - * - * @param list_of_sounds given list of sounds - * @return the combined Sound - * @example simultaneously(list(sine_sound(200, 2), sine_sound(400, 3))) - */ -export function simultaneously(list_of_sounds: List): Sound { - function simul_two(ss1: Sound, ss2: Sound) { - const wave1 = get_wave(ss1); - const wave2 = get_wave(ss2); - const dur1 = get_duration(ss1); - const dur2 = get_duration(ss2); - // new_wave assumes sound discipline (ie, wave(t) = 0 after t > dur) - const new_wave = (t: number) => wave1(t) + wave2(t); - // new_dur is higher of the two dur - const new_dur = dur1 < dur2 ? dur2 : dur1; - return make_sound(new_wave, new_dur); - } - - const mushed_sounds = accumulate(simul_two, silence_sound(0), list_of_sounds); - const normalised_wave = (t: number) => - head(mushed_sounds)(t) / length(list_of_sounds); - const highest_duration = tail(mushed_sounds); - return make_sound(normalised_wave, highest_duration); -} - -/** - * Returns an envelope: a function from Sound to Sound. - * When the adsr envelope is applied to a Sound, it returns - * a new Sound with its amplitude modified according to parameters - * The relative amplitude increases from 0 to 1 linearly over the - * attack proportion, then decreases from 1 to sustain level over the - * decay proportion, and remains at that level until the release - * proportion when it decays back to 0. - * @param attack_ratio proportion of Sound in attack phase - * @param decay_ratio proportion of Sound decay phase - * @param sustain_level sustain level between 0 and 1 - * @param release_ratio proportion of Sound in release phase - * @return Envelope a function from Sound to Sound - * @example adsr(0.2, 0.3, 0.3, 0.1)(sound); - */ -export function adsr( - attack_ratio: number, - decay_ratio: number, - sustain_level: number, - release_ratio: number -): SoundTransformer { - return (sound) => { - const wave = get_wave(sound); - const duration = get_duration(sound); - const attack_time = duration * attack_ratio; - const decay_time = duration * decay_ratio; - const release_time = duration * release_ratio; - return make_sound((x) => { - if (x < attack_time) { - return wave(x) * (x / attack_time); - } - if (x < attack_time + decay_time) { - return ( - ((1 - sustain_level) * linear_decay(decay_time)(x - attack_time) + - sustain_level) * - wave(x) - ); - } - if (x < duration - release_time) { - return wave(x) * sustain_level; - } - return ( - wave(x) * - sustain_level * - linear_decay(release_time)(x - (duration - release_time)) - ); - }, duration); - }; -} - -/** - * Returns a Sound that results from applying a list of envelopes - * to a given wave form. The wave form is a Sound generator that - * takes a frequency and a duration as arguments and produces a - * Sound with the given frequency and duration. Each envelope is - * applied to a harmonic: the first harmonic has the given frequency, - * the second has twice the frequency, the third three times the - * frequency etc. The harmonics are then layered simultaneously to - * produce the resulting Sound. - * @param waveform function from pair(frequency, duration) to Sound - * @param base_frequency frequency of the first harmonic - * @param duration duration of the produced Sound, in seconds - * @param envelopes – list of envelopes, which are functions from Sound to Sound - * @return Sound resulting Sound - * @example stacking_adsr(sine_sound, 300, 5, list(adsr(0.1, 0.3, 0.2, 0.5), adsr(0.2, 0.5, 0.6, 0.1), adsr(0.3, 0.1, 0.7, 0.3))); - */ -export function stacking_adsr( - waveform: SoundProducer, - base_frequency: number, - duration: number, - envelopes: List -): Sound { - function zip(lst: List, n: number) { - if (is_null(lst)) { - return lst; - } - return pair(pair(n, head(lst)), zip(tail(lst), n + 1)); - } - - return simultaneously( - accumulate( - (x: any, y: any) => - pair(tail(x)(waveform(base_frequency * head(x), duration)), y), - null, - zip(envelopes, 1) - ) - ); -} - -/** - * Returns a SoundTransformer which uses its argument - * to modulate the phase of a (carrier) sine wave - * of given frequency and duration with a given Sound. - * Modulating with a low frequency Sound results in a vibrato effect. - * Modulating with a Sound with frequencies comparable to - * the sine wave frequency results in more complex wave forms. - * - * @param freq the frequency of the sine wave to be modulated - * @param duration the duration of the output soud - * @param amount the amount of modulation to apply to the carrier sine wave - * @return function which takes in a Sound and returns a Sound - * @example phase_mod(440, 5, 1)(sine_sound(220, 5)); - */ -export function phase_mod( - freq: number, - duration: number, - amount: number -): SoundTransformer { - return (modulator: Sound) => - make_sound( - (t) => Math.sin(2 * Math.PI * t * freq + amount * get_wave(modulator)(t)), - duration - ); -} - -// MIDI conversion functions - -/** - * Converts a letter name to its corresponding MIDI note. - * The letter name is represented in standard pitch notation. - * Examples are "A5", "Db3", "C#7". - * Refer to this mapping from - * letter name to midi notes. - * - * @param letter_name given letter name - * @return the corresponding midi note - * @example letter_name_to_midi_note("C4"); // Returns 60 - */ -export function letter_name_to_midi_note(note: string): number { - let res = 12; // C0 is midi note 12 - const n = note[0].toUpperCase(); - switch (n) { - case 'D': - res += 2; - break; - - case 'E': - res += 4; - break; - - case 'F': - res += 5; - break; - - case 'G': - res += 7; - break; - - case 'A': - res += 9; - break; - - case 'B': - res += 11; - break; - - default: - break; - } - - if (note.length === 2) { - res += parseInt(note[1]) * 12; - } else if (note.length === 3) { - switch (note[1]) { - case '#': - res += 1; - break; - - case 'b': - res -= 1; - break; - - default: - break; - } - res += parseInt(note[2]) * 12; - } - return res; -} - -/** - * Converts a MIDI note to its corresponding frequency. - * - * @param note given MIDI note - * @return the frequency of the MIDI note - * @example midi_note_to_frequency(69); // Returns 440 - */ -export function midi_note_to_frequency(note: number): number { - // A4 = 440Hz = midi note 69 - return 440 * 2 ** ((note - 69) / 12); -} - -/** - * Converts a letter name to its corresponding frequency. - * - * @param letter_name given letter name - * @return the corresponding frequency - * @example letter_name_to_frequency("A4"); // Returns 440 - */ -export function letter_name_to_frequency(note: string): number { - return midi_note_to_frequency(letter_name_to_midi_note(note)); -} - -// Instruments - -/** - * returns a Sound reminiscent of a bell, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting bell Sound with given pitch and duration - * @example bell(40, 1); - */ -export function bell(note: number, duration: number): Sound { - return stacking_adsr( - square_sound, - midi_note_to_frequency(note), - duration, - list( - adsr(0, 0.6, 0, 0.05), - adsr(0, 0.6618, 0, 0.05), - adsr(0, 0.7618, 0, 0.05), - adsr(0, 0.9071, 0, 0.05) - ) - ); -} - -/** - * returns a Sound reminiscent of a cello, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting cello Sound with given pitch and duration - * @example cello(36, 5); - */ -export function cello(note: number, duration: number): Sound { - return stacking_adsr( - square_sound, - midi_note_to_frequency(note), - duration, - list(adsr(0.05, 0, 1, 0.1), adsr(0.05, 0, 1, 0.15), adsr(0, 0, 0.2, 0.15)) - ); -} - -/** - * returns a Sound reminiscent of a piano, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting piano Sound with given pitch and duration - * @example piano(48, 5); - */ -export function piano(note: number, duration: number): Sound { - return stacking_adsr( - triangle_sound, - midi_note_to_frequency(note), - duration, - list(adsr(0, 0.515, 0, 0.05), adsr(0, 0.32, 0, 0.05), adsr(0, 0.2, 0, 0.05)) - ); -} - -/** - * returns a Sound reminiscent of a trombone, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting trombone Sound with given pitch and duration - * @example trombone(60, 2); - */ -export function trombone(note: number, duration: number): Sound { - return stacking_adsr( - square_sound, - midi_note_to_frequency(note), - duration, - list(adsr(0.2, 0, 1, 0.1), adsr(0.3236, 0.6, 0, 0.1)) - ); -} - -/** - * returns a Sound reminiscent of a violin, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting violin Sound with given pitch and duration - * @example violin(53, 4); - */ -export function violin(note: number, duration: number): Sound { - return stacking_adsr( - sawtooth_sound, - midi_note_to_frequency(note), - duration, - list( - adsr(0.35, 0, 1, 0.15), - adsr(0.35, 0, 1, 0.15), - adsr(0.45, 0, 1, 0.15), - adsr(0.45, 0, 1, 0.15) - ) - ); -} +/** + * The sounds library provides functions for constructing and playing sounds. + * + * A wave is a function that takes in a number `t` and returns + * a number representing the amplitude at time `t`. + * The amplitude should fall within the range of [-1, 1]. + * + * A Sound is a pair(wave, duration) where duration is the length of the sound in seconds. + * The constructor make_sound and accessors get_wave and get_duration are provided. + * + * Sound Discipline: + * For all sounds, the wave function applied to and time `t` beyond its duration returns 0, that is: + * `(get_wave(sound))(get_duration(sound) + x) === 0` for any x >= 0. + * + * Two functions which combine Sounds, `consecutively` and `simultaneously` are given. + * Additionally, we provide sound transformation functions `adsr` and `phase_mod` + * which take in a Sound and return a Sound. + * + * Finally, the provided `play` function takes in a Sound and plays it using your + * computer's sound system. + * + * @module sound + * @author Koh Shang Hui + * @author Samyukta Sounderraman + */ + +/* eslint-disable new-cap, @typescript-eslint/naming-convention */ +import { + Wave, + Sound, + SoundProducer, + SoundTransformer, + List, + AudioPlayed, +} from './types'; +import { + pair, + head, + tail, + list, + length, + is_null, + is_pair, + accumulate, +} from './list'; +import { RIFFWAVE } from './riffwave'; + +// Global Constants and Variables +const FS: number = 44100; // Output sample rate +const fourier_expansion_level: number = 5; // fourier expansion level + +/** @hidden */ +export const audioPlayed: AudioPlayed[] = []; + +// Singular audio context for all playback functions +let audioplayer: AudioContext; + +// Track if a sound is currently playing +let isPlaying: boolean; + +// Instantiates new audio context +function init_audioCtx(): void { + audioplayer = new window.AudioContext(); + // audioplayer = new (window.AudioContext || window.webkitAudioContext)(); +} + +// linear decay from 1 to 0 over decay_period +function linear_decay(decay_period: number): (t: number) => number { + return (t) => { + if (t > decay_period || t < 0) { + return 0; + } + return 1 - t / decay_period; + }; +} + +// // --------------------------------------------- +// // Microphone Functionality +// // --------------------------------------------- + +// permission initially undefined +// set to true by granting microphone permission +// set to false by denying microphone permission +let permission: boolean | undefined; + +let recorded_sound: Sound | undefined; + +// check_permission is called whenever we try +// to record a sound +function check_permission() { + if (permission === undefined) { + throw new Error( + 'Call init_record(); to obtain permission to use microphone', + ); + } else if (permission === false) { + throw new Error(`Permission has been denied.\n + Re-start browser and call init_record();\n + to obtain permission to use microphone.`); + } // (permission === true): do nothing +} + +let globalStream: any; + +function rememberStream(stream: any) { + permission = true; + globalStream = stream; +} + +function setPermissionToFalse() { + permission = false; +} + +function start_recording(mediaRecorder: MediaRecorder) { + const data: any[] = []; + mediaRecorder.ondataavailable = (e) => e.data.size && data.push(e.data); + mediaRecorder.start(); + mediaRecorder.onstop = () => process(data); +} + +// duration of recording signal in milliseconds +const recording_signal_ms = 100; + +// duration of pause after "run" before recording signal is played +const pre_recording_signal_pause_ms = 200; + +function play_recording_signal() { + play(sine_sound(1200, recording_signal_ms / 1000)); +} + +// eslint-disable-next-line @typescript-eslint/no-shadow +function process(data) { + const audioContext = new AudioContext(); + const blob = new Blob(data); + + convertToArrayBuffer(blob) + .then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer)) + .then(save); +} + +// Converts input microphone sound (blob) into array format. +function convertToArrayBuffer(blob: Blob): Promise { + const url = URL.createObjectURL(blob); + return fetch(url) + .then((response) => response.arrayBuffer()); +} + +function save(audioBuffer: AudioBuffer) { + const array = audioBuffer.getChannelData(0); + const duration = array.length / FS; + recorded_sound = make_sound((t) => { + const index = t * FS; + const lowerIndex = Math.floor(index); + const upperIndex = lowerIndex + 1; + const ratio = index - lowerIndex; + const upper = array[upperIndex] ? array[upperIndex] : 0; + const lower = array[lowerIndex] ? array[lowerIndex] : 0; + return lower * (1 - ratio) + upper * ratio; + }, duration); +} + +/** + * Initialize recording by obtaining permission + * to use the default device microphone + * + * @returns string "obtaining recording permission" + */ +export function init_record(): string { + navigator.mediaDevices + .getUserMedia({ audio: true }) + .then(rememberStream, setPermissionToFalse); + return 'obtaining recording permission'; +} + +/** + * takes a buffer duration (in seconds) as argument, and + * returns a nullary stop function stop. A call + * stop() returns a sound promise: a nullary function + * that returns a sound. Example:
    init_record();
    + * const stop = record(0.5);
    + * // record after 0.5 seconds. Then in next query:
    + * const promise = stop();
    + * // In next query, you can play the promised sound, by
    + * // applying the promise:
    + * play(promise());
    + * @param buffer - pause before recording, in seconds + * @returns nullary stop function; + * stop() stops the recording and + * returns a sound promise: a nullary function that returns the recorded sound + */ +export function record(buffer: number): () => () => Sound { + check_permission(); + const mediaRecorder = new MediaRecorder(globalStream); + setTimeout(() => { + play_recording_signal(); + start_recording(mediaRecorder); + }, recording_signal_ms + buffer * 1000); + return () => { + mediaRecorder.stop(); + play_recording_signal(); + return () => { + if (recorded_sound === undefined) { + throw new Error('recording still being processed'); + } else { + return recorded_sound; + } + }; + }; +} + +/** + * Records a sound of given duration in seconds, after + * a buffer also in seconds, and + * returns a sound promise: a nullary function + * that returns a sound. Example:
    init_record();
    + * const promise = record_for(2, 0.5);
    + * // In next query, you can play the promised sound, by
    + * // applying the promise:
    + * play(promise());
    + * @param duration duration in seconds + * @param buffer pause before recording, in seconds + * @return promise: nullary function which returns recorded sound + */ +export function record_for(duration: number, buffer: number): () => Sound { + recorded_sound = undefined; + const recording_ms = duration * 1000; + const pre_recording_pause_ms = buffer * 1000; + check_permission(); + const mediaRecorder = new MediaRecorder(globalStream); + + // order of events for record_for: + // pre-recording-signal pause | recording signal | + // pre-recording pause | recording | recording signal + + setTimeout(() => { + play_recording_signal(); + setTimeout(() => { + start_recording(mediaRecorder); + setTimeout(() => { + mediaRecorder.stop(); + play_recording_signal(); + }, recording_ms); + }, recording_signal_ms + pre_recording_pause_ms); + }, pre_recording_signal_pause_ms); + + return () => { + if (recorded_sound === undefined) { + throw new Error('recording still being processed'); + } else { + return recorded_sound; + } + }; +} + +// ============================================================================= +// Module's Exposed Functions +// +// This file only includes the implementation and documentation of exposed +// functions of the module. For private functions dealing with the browser's +// graphics library context, see './webGL_curves.ts'. +// ============================================================================= + +// Core functions + +/** + * Makes a Sound with given wave function and duration. + * The wave function is a function: number -> number + * that takes in a non-negative input time and returns an amplitude + * between -1 and 1. + * + * @param wave wave function of the sound + * @param duration duration of the sound + * @return with wave as wave function and duration as duration + * @example const s = make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5); + */ +export function make_sound(wave: Wave, duration: number): Sound { + return pair((t: number) => (t >= duration ? 0 : wave(t)), duration); +} + +/** + * Accesses the wave function of a given Sound. + * + * @param sound given Sound + * @return the wave function of the Sound + * @example get_wave(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns t => Math_sin(2 * Math_PI * 440 * t) + */ +export function get_wave(sound: Sound): Wave { + return head(sound); +} + +/** + * Accesses the duration of a given Sound. + * + * @param sound given Sound + * @return the duration of the Sound + * @example get_duration(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns 5 + */ +export function get_duration(sound: Sound): number { + return tail(sound); +} + +/** + * Checks if the argument is a Sound + * + * @param x input to be checked + * @return true if x is a Sound, false otherwise + * @example is_sound(make_sound(t => 0, 2)); // Returns true + */ +export function is_sound(x: any): boolean { + return ( + is_pair(x) + && typeof get_wave(x) === 'function' + && typeof get_duration(x) === 'number' + ); +} + +/** + * Plays the given Wave using the computer’s sound device, for the duration + * given in seconds. + * The sound is only played if no other sounds are currently being played. + * + * @param wave the wave function to play, starting at 0 + * @return the given sound + * @example play_wave(t => math_sin(t * 3000), 5); + */ +export function play_wave(wave: Wave, duration: number): AudioPlayed { + return play(make_sound(wave, duration)); +} + +/** + * Plays the given Sound using the computer’s sound device. + * The sound is only played if no other sounds are currently being played. + * + * @param sound the sound to play + * @return the given sound + * @example play(sine_sound(440, 5)); + */ +export function play(sound: Sound): AudioPlayed { + // Type-check sound + if (!is_sound(sound)) { + throw new Error(`play is expecting sound, but encountered ${sound}`); + // If a sound is already playing, terminate execution. + } else if (isPlaying) { + throw new Error('play: audio system still playing previous sound'); + } else if (get_duration(sound) < 0) { + throw new Error('play: duration of sound is negative'); + } else { + // Instantiate audio context if it has not been instantiated. + if (!audioplayer) { + init_audioCtx(); + } + + // Create mono buffer + const channel: number[] = []; + const len = Math.ceil(FS * get_duration(sound)); + + let temp: number; + let prev_value = 0; + + const wave = get_wave(sound); + for (let i = 0; i < len; i += 1) { + temp = wave(i / FS); + // clip amplitude + // channel[i] = temp > 1 ? 1 : temp < -1 ? -1 : temp; + if (temp > 1) { + channel[i] = 1; + } else if (temp < -1) { + channel[i] = -1; + } else { + channel[i] = temp; + } + + // smoothen out sudden cut-outs + if (channel[i] === 0 && Math.abs(channel[i] - prev_value) > 0.01) { + channel[i] = prev_value * 0.999; + } + + prev_value = channel[i]; + } + + // quantize + for (let i = 0; i < channel.length; i += 1) { + channel[i] = Math.floor(channel[i] * 32767.999); + } + + const riffwave = new RIFFWAVE([]); + riffwave.header.sampleRate = FS; + riffwave.header.numChannels = 1; + riffwave.header.bitsPerSample = 16; + riffwave.Make(channel); + + /* + const audio = new Audio(riffwave.dataURI); + const source2 = audioplayer.createMediaElementSource(audio); + source2.connect(audioplayer.destination); + + // Connect data to output destination + isPlaying = true; + audio.play(); + audio.onended = () => { + source2.disconnect(audioplayer.destination); + isPlaying = false; + }; */ + + const soundToPlay = { + toReplString: () => '', + dataUri: riffwave.dataURI, + }; + audioPlayed.push(soundToPlay); + return soundToPlay; + } +} + +/** + * Plays the given Sound using the computer’s sound device + * on top of any sounds that are currently playing. + * + * @param sound the sound to play + * @example play_concurrently(sine_sound(440, 5)); + */ +export function play_concurrently(sound: Sound): void { + // Type-check sound + if (!is_sound(sound)) { + throw new Error( + `play_concurrently is expecting sound, but encountered ${sound}`, + ); + } else if (get_duration(sound) <= 0) { + // Do nothing + } else { + // Instantiate audio context if it has not been instantiated. + if (!audioplayer) { + init_audioCtx(); + } + + // Create mono buffer + const theBuffer = audioplayer.createBuffer( + 1, + Math.ceil(FS * get_duration(sound)), + FS, + ); + const channel = theBuffer.getChannelData(0); + + let temp: number; + let prev_value = 0; + + const wave = get_wave(sound); + for (let i = 0; i < channel.length; i += 1) { + temp = wave(i / FS); + // clip amplitude + if (temp > 1) { + channel[i] = 1; + } else if (temp < -1) { + channel[i] = -1; + } else { + channel[i] = temp; + } + + // smoothen out sudden cut-outs + if (channel[i] === 0 && Math.abs(channel[i] - prev_value) > 0.01) { + channel[i] = prev_value * 0.999; + } + + prev_value = channel[i]; + } + + // Connect data to output destination + const source = audioplayer.createBufferSource(); + source.buffer = theBuffer; + source.connect(audioplayer.destination); + isPlaying = true; + source.start(); + source.onended = () => { + source.disconnect(audioplayer.destination); + isPlaying = false; + }; + } +} + +/** + * Stops all currently playing sounds. + */ +export function stop(): void { + audioplayer.close(); + isPlaying = false; +} + +// Primitive sounds + +/** + * Makes a noise sound with given duration + * + * @param duration the duration of the noise sound + * @return resulting noise sound + * @example noise_sound(5); + */ +export function noise_sound(duration: number): Sound { + return make_sound((_t) => Math.random() * 2 - 1, duration); +} + +/** + * Makes a silence sound with given duration + * + * @param duration the duration of the silence sound + * @return resulting silence sound + * @example silence_sound(5); + */ +export function silence_sound(duration: number): Sound { + return make_sound((_t) => 0, duration); +} + +/** + * Makes a sine wave sound with given frequency and duration + * + * @param freq the frequency of the sine wave sound + * @param duration the duration of the sine wave sound + * @return resulting sine wave sound + * @example sine_sound(440, 5); + */ +export function sine_sound(freq: number, duration: number): Sound { + return make_sound((t) => Math.sin(2 * Math.PI * t * freq), duration); +} + +/** + * Makes a square wave sound with given frequency and duration + * + * @param freq the frequency of the square wave sound + * @param duration the duration of the square wave sound + * @return resulting square wave sound + * @example square_sound(440, 5); + */ +export function square_sound(f: number, duration: number): Sound { + function fourier_expansion_square(t: number) { + let answer = 0; + for (let i = 1; i <= fourier_expansion_level; i += 1) { + answer += Math.sin(2 * Math.PI * (2 * i - 1) * f * t) / (2 * i - 1); + } + return answer; + } + return make_sound( + (t) => (4 / Math.PI) * fourier_expansion_square(t), + duration, + ); +} + +/** + * Makes a triangle wave sound with given frequency and duration + * + * @param freq the frequency of the triangle wave sound + * @param duration the duration of the triangle wave sound + * @return resulting triangle wave sound + * @example triangle_sound(440, 5); + */ +export function triangle_sound(freq: number, duration: number): Sound { + function fourier_expansion_triangle(t: number) { + let answer = 0; + for (let i = 0; i < fourier_expansion_level; i += 1) { + answer + += ((-1) ** i * Math.sin((2 * i + 1) * t * freq * Math.PI * 2)) + / (2 * i + 1) ** 2; + } + return answer; + } + return make_sound( + (t) => (8 / Math.PI / Math.PI) * fourier_expansion_triangle(t), + duration, + ); +} + +/** + * Makes a sawtooth wave sound with given frequency and duration + * + * @param freq the frequency of the sawtooth wave sound + * @param duration the duration of the sawtooth wave sound + * @return resulting sawtooth wave sound + * @example sawtooth_sound(440, 5); + */ +export function sawtooth_sound(freq: number, duration: number): Sound { + function fourier_expansion_sawtooth(t: number) { + let answer = 0; + for (let i = 1; i <= fourier_expansion_level; i += 1) { + answer += Math.sin(2 * Math.PI * i * freq * t) / i; + } + return answer; + } + return make_sound( + (t) => 1 / 2 - (1 / Math.PI) * fourier_expansion_sawtooth(t), + duration, + ); +} + +// Composition Operators + +/** + * Makes a new Sound by combining the sounds in a given list + * where the second sound is appended to the end of the first sound, + * the third sound is appended to the end of the second sound, and + * so on. The effect is that the sounds in the list are joined end-to-end + * + * @param list_of_sounds given list of sounds + * @return the combined Sound + * @example consecutively(list(sine_sound(200, 2), sine_sound(400, 3))); + */ +export function consecutively(list_of_sounds: List): Sound { + function consec_two(ss1: Sound, ss2: Sound) { + const wave1 = get_wave(ss1); + const wave2 = get_wave(ss2); + const dur1 = get_duration(ss1); + const dur2 = get_duration(ss2); + const new_wave = (t: number) => (t < dur1 ? wave1(t) : wave2(t - dur1)); + return make_sound(new_wave, dur1 + dur2); + } + return accumulate(consec_two, silence_sound(0), list_of_sounds); +} + +/** + * Makes a new Sound by combining the sounds in a given list + * where all the sounds are overlapped on top of each other. + * + * @param list_of_sounds given list of sounds + * @return the combined Sound + * @example simultaneously(list(sine_sound(200, 2), sine_sound(400, 3))) + */ +export function simultaneously(list_of_sounds: List): Sound { + function simul_two(ss1: Sound, ss2: Sound) { + const wave1 = get_wave(ss1); + const wave2 = get_wave(ss2); + const dur1 = get_duration(ss1); + const dur2 = get_duration(ss2); + // new_wave assumes sound discipline (ie, wave(t) = 0 after t > dur) + const new_wave = (t: number) => wave1(t) + wave2(t); + // new_dur is higher of the two dur + const new_dur = dur1 < dur2 ? dur2 : dur1; + return make_sound(new_wave, new_dur); + } + + const mushed_sounds = accumulate(simul_two, silence_sound(0), list_of_sounds); + const normalised_wave = (t: number) => head(mushed_sounds)(t) / length(list_of_sounds); + const highest_duration = tail(mushed_sounds); + return make_sound(normalised_wave, highest_duration); +} + +/** + * Returns an envelope: a function from Sound to Sound. + * When the adsr envelope is applied to a Sound, it returns + * a new Sound with its amplitude modified according to parameters + * The relative amplitude increases from 0 to 1 linearly over the + * attack proportion, then decreases from 1 to sustain level over the + * decay proportion, and remains at that level until the release + * proportion when it decays back to 0. + * @param attack_ratio proportion of Sound in attack phase + * @param decay_ratio proportion of Sound decay phase + * @param sustain_level sustain level between 0 and 1 + * @param release_ratio proportion of Sound in release phase + * @return Envelope a function from Sound to Sound + * @example adsr(0.2, 0.3, 0.3, 0.1)(sound); + */ +export function adsr( + attack_ratio: number, + decay_ratio: number, + sustain_level: number, + release_ratio: number, +): SoundTransformer { + return (sound) => { + const wave = get_wave(sound); + const duration = get_duration(sound); + const attack_time = duration * attack_ratio; + const decay_time = duration * decay_ratio; + const release_time = duration * release_ratio; + return make_sound((x) => { + if (x < attack_time) { + return wave(x) * (x / attack_time); + } + if (x < attack_time + decay_time) { + return ( + ((1 - sustain_level) * linear_decay(decay_time)(x - attack_time) + + sustain_level) + * wave(x) + ); + } + if (x < duration - release_time) { + return wave(x) * sustain_level; + } + return ( + wave(x) + * sustain_level + * linear_decay(release_time)(x - (duration - release_time)) + ); + }, duration); + }; +} + +/** + * Returns a Sound that results from applying a list of envelopes + * to a given wave form. The wave form is a Sound generator that + * takes a frequency and a duration as arguments and produces a + * Sound with the given frequency and duration. Each envelope is + * applied to a harmonic: the first harmonic has the given frequency, + * the second has twice the frequency, the third three times the + * frequency etc. The harmonics are then layered simultaneously to + * produce the resulting Sound. + * @param waveform function from pair(frequency, duration) to Sound + * @param base_frequency frequency of the first harmonic + * @param duration duration of the produced Sound, in seconds + * @param envelopes – list of envelopes, which are functions from Sound to Sound + * @return Sound resulting Sound + * @example stacking_adsr(sine_sound, 300, 5, list(adsr(0.1, 0.3, 0.2, 0.5), adsr(0.2, 0.5, 0.6, 0.1), adsr(0.3, 0.1, 0.7, 0.3))); + */ +export function stacking_adsr( + waveform: SoundProducer, + base_frequency: number, + duration: number, + envelopes: List, +): Sound { + function zip(lst: List, n: number) { + if (is_null(lst)) { + return lst; + } + return pair(pair(n, head(lst)), zip(tail(lst), n + 1)); + } + + return simultaneously( + accumulate( + (x: any, y: any) => pair(tail(x)(waveform(base_frequency * head(x), duration)), y), + null, + zip(envelopes, 1), + ), + ); +} + +/** + * Returns a SoundTransformer which uses its argument + * to modulate the phase of a (carrier) sine wave + * of given frequency and duration with a given Sound. + * Modulating with a low frequency Sound results in a vibrato effect. + * Modulating with a Sound with frequencies comparable to + * the sine wave frequency results in more complex wave forms. + * + * @param freq the frequency of the sine wave to be modulated + * @param duration the duration of the output soud + * @param amount the amount of modulation to apply to the carrier sine wave + * @return function which takes in a Sound and returns a Sound + * @example phase_mod(440, 5, 1)(sine_sound(220, 5)); + */ +export function phase_mod( + freq: number, + duration: number, + amount: number, +): SoundTransformer { + return (modulator: Sound) => make_sound( + (t) => Math.sin(2 * Math.PI * t * freq + amount * get_wave(modulator)(t)), + duration, + ); +} + +// MIDI conversion functions + +/** + * Converts a letter name to its corresponding MIDI note. + * The letter name is represented in standard pitch notation. + * Examples are "A5", "Db3", "C#7". + * Refer to
    this mapping from + * letter name to midi notes. + * + * @param letter_name given letter name + * @return the corresponding midi note + * @example letter_name_to_midi_note("C4"); // Returns 60 + */ +export function letter_name_to_midi_note(note: string): number { + let res = 12; // C0 is midi note 12 + const n = note[0].toUpperCase(); + switch (n) { + case 'D': + res += 2; + break; + + case 'E': + res += 4; + break; + + case 'F': + res += 5; + break; + + case 'G': + res += 7; + break; + + case 'A': + res += 9; + break; + + case 'B': + res += 11; + break; + + default: + break; + } + + if (note.length === 2) { + res += parseInt(note[1]) * 12; + } else if (note.length === 3) { + switch (note[1]) { + case '#': + res += 1; + break; + + case 'b': + res -= 1; + break; + + default: + break; + } + res += parseInt(note[2]) * 12; + } + return res; +} + +/** + * Converts a MIDI note to its corresponding frequency. + * + * @param note given MIDI note + * @return the frequency of the MIDI note + * @example midi_note_to_frequency(69); // Returns 440 + */ +export function midi_note_to_frequency(note: number): number { + // A4 = 440Hz = midi note 69 + return 440 * 2 ** ((note - 69) / 12); +} + +/** + * Converts a letter name to its corresponding frequency. + * + * @param letter_name given letter name + * @return the corresponding frequency + * @example letter_name_to_frequency("A4"); // Returns 440 + */ +export function letter_name_to_frequency(note: string): number { + return midi_note_to_frequency(letter_name_to_midi_note(note)); +} + +// Instruments + +/** + * returns a Sound reminiscent of a bell, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting bell Sound with given pitch and duration + * @example bell(40, 1); + */ +export function bell(note: number, duration: number): Sound { + return stacking_adsr( + square_sound, + midi_note_to_frequency(note), + duration, + list( + adsr(0, 0.6, 0, 0.05), + adsr(0, 0.6618, 0, 0.05), + adsr(0, 0.7618, 0, 0.05), + adsr(0, 0.9071, 0, 0.05), + ), + ); +} + +/** + * returns a Sound reminiscent of a cello, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting cello Sound with given pitch and duration + * @example cello(36, 5); + */ +export function cello(note: number, duration: number): Sound { + return stacking_adsr( + square_sound, + midi_note_to_frequency(note), + duration, + list(adsr(0.05, 0, 1, 0.1), adsr(0.05, 0, 1, 0.15), adsr(0, 0, 0.2, 0.15)), + ); +} + +/** + * returns a Sound reminiscent of a piano, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting piano Sound with given pitch and duration + * @example piano(48, 5); + */ +export function piano(note: number, duration: number): Sound { + return stacking_adsr( + triangle_sound, + midi_note_to_frequency(note), + duration, + list(adsr(0, 0.515, 0, 0.05), adsr(0, 0.32, 0, 0.05), adsr(0, 0.2, 0, 0.05)), + ); +} + +/** + * returns a Sound reminiscent of a trombone, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting trombone Sound with given pitch and duration + * @example trombone(60, 2); + */ +export function trombone(note: number, duration: number): Sound { + return stacking_adsr( + square_sound, + midi_note_to_frequency(note), + duration, + list(adsr(0.2, 0, 1, 0.1), adsr(0.3236, 0.6, 0, 0.1)), + ); +} + +/** + * returns a Sound reminiscent of a violin, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting violin Sound with given pitch and duration + * @example violin(53, 4); + */ +export function violin(note: number, duration: number): Sound { + return stacking_adsr( + sawtooth_sound, + midi_note_to_frequency(note), + duration, + list( + adsr(0.35, 0, 1, 0.15), + adsr(0.35, 0, 1, 0.15), + adsr(0.45, 0, 1, 0.15), + adsr(0.45, 0, 1, 0.15), + ), + ); +} diff --git a/src/bundles/sound/index.ts b/src/bundles/sound/index.ts index 1d8027477..1a57a7d0b 100644 --- a/src/bundles/sound/index.ts +++ b/src/bundles/sound/index.ts @@ -1,107 +1,107 @@ -import { ModuleContexts, ModuleParams } from '../../typings/type_helpers.js'; -import { - adsr, - audioPlayed, - // Instruments - bell, - cello, - // Composition and Envelopes - consecutively, - get_duration, - get_wave, - // Recording - init_record, - is_sound, - letter_name_to_frequency, - // MIDI - letter_name_to_midi_note, - // Constructor/Accessors/Typecheck - make_sound, - midi_note_to_frequency, - // Basic waveforms - noise_sound, - phase_mod, - piano, - // Play-related - play, - play_concurrently, - play_wave, - record, - record_for, - sawtooth_sound, - silence_sound, - simultaneously, - sine_sound, - square_sound, - stacking_adsr, - stop, - triangle_sound, - trombone, - violin, -} from './functions'; -import { SoundsModuleState } from './types'; - -export default function sounds( - moduleParams: ModuleParams, - moduleContexts: ModuleContexts -) { - // Update the module's global context - let moduleContext = moduleContexts.get('sound'); - - if (!moduleContext) { - moduleContext = { - tabs: [], - state: { - audioPlayed, - }, - }; - - moduleContexts.set('sound', moduleContext); - } else if (!moduleContext.state) { - moduleContext.state = { - audioPlayed, - }; - } else { - (moduleContext.state as SoundsModuleState).audioPlayed = audioPlayed; - } - - return { - // Constructor/Accessors/Typecheck - make_sound, - get_wave, - get_duration, - is_sound, - // Play-related - play, - play_wave, - play_concurrently, - stop, - // Recording - init_record, - record, - record_for, - // Composition and Envelopes - consecutively, - simultaneously, - phase_mod, - adsr, - stacking_adsr, - // Basic waveforms - noise_sound, - silence_sound, - sine_sound, - sawtooth_sound, - triangle_sound, - square_sound, - // MIDI - letter_name_to_midi_note, - midi_note_to_frequency, - letter_name_to_frequency, - // Instruments - bell, - cello, - piano, - trombone, - violin, - }; -} +import { ModuleContexts, ModuleParams } from '../../typings/type_helpers.js'; +import { + adsr, + audioPlayed, + // Instruments + bell, + cello, + // Composition and Envelopes + consecutively, + get_duration, + get_wave, + // Recording + init_record, + is_sound, + letter_name_to_frequency, + // MIDI + letter_name_to_midi_note, + // Constructor/Accessors/Typecheck + make_sound, + midi_note_to_frequency, + // Basic waveforms + noise_sound, + phase_mod, + piano, + // Play-related + play, + play_concurrently, + play_wave, + record, + record_for, + sawtooth_sound, + silence_sound, + simultaneously, + sine_sound, + square_sound, + stacking_adsr, + stop, + triangle_sound, + trombone, + violin, +} from './functions'; +import { SoundsModuleState } from './types'; + +export default function sounds( + moduleParams: ModuleParams, + moduleContexts: ModuleContexts, +) { + // Update the module's global context + let moduleContext = moduleContexts.get('sound'); + + if (!moduleContext) { + moduleContext = { + tabs: [], + state: { + audioPlayed, + }, + }; + + moduleContexts.set('sound', moduleContext); + } else if (!moduleContext.state) { + moduleContext.state = { + audioPlayed, + }; + } else { + (moduleContext.state as SoundsModuleState).audioPlayed = audioPlayed; + } + + return { + // Constructor/Accessors/Typecheck + make_sound, + get_wave, + get_duration, + is_sound, + // Play-related + play, + play_wave, + play_concurrently, + stop, + // Recording + init_record, + record, + record_for, + // Composition and Envelopes + consecutively, + simultaneously, + phase_mod, + adsr, + stacking_adsr, + // Basic waveforms + noise_sound, + silence_sound, + sine_sound, + sawtooth_sound, + triangle_sound, + square_sound, + // MIDI + letter_name_to_midi_note, + midi_note_to_frequency, + letter_name_to_frequency, + // Instruments + bell, + cello, + piano, + trombone, + violin, + }; +} diff --git a/src/bundles/sound/list.ts b/src/bundles/sound/list.ts index 4b0c22596..13f48cfdd 100644 --- a/src/bundles/sound/list.ts +++ b/src/bundles/sound/list.ts @@ -1,372 +1,372 @@ -/* eslint-disable no-else-return, no-lonely-if, operator-assignment, prefer-template */ - -// list.js: Supporting lists in the Scheme style, using pairs made -// up of two-element JavaScript array (vector) - -// Author: Martin Henz - -// Note: this library is used in the externalLibs of cadet-frontend. -// It is distinct from the LISTS library of Source §2, which contains -// primitive and predeclared functions from Chapter 2 of SICP JS. - -// array test works differently for Rhino and -// the Firefox environment (especially Web Console) -export function array_test(x): boolean { - if (Array.isArray === undefined) { - return x instanceof Array; - } else { - return Array.isArray(x); - } -} - -// pair constructs a pair using a two-element array -// LOW-LEVEL FUNCTION, NOT SOURCE -export function pair(x, xs): [any, any] { - return [x, xs]; -} - -// is_pair returns true iff arg is a two-element array -// LOW-LEVEL FUNCTION, NOT SOURCE -export function is_pair(x): boolean { - return array_test(x) && x.length === 2; -} - -// head returns the first component of the given pair, -// throws an exception if the argument is not a pair -// LOW-LEVEL FUNCTION, NOT SOURCE -export function head(xs): any { - if (is_pair(xs)) { - return xs[0]; - } else { - throw new Error( - 'head(xs) expects a pair as argument xs, but encountered ' + xs - ); - } -} - -// tail returns the second component of the given pair -// throws an exception if the argument is not a pair -// LOW-LEVEL FUNCTION, NOT SOURCE -export function tail(xs) { - if (is_pair(xs)) { - return xs[1]; - } else { - throw new Error( - 'tail(xs) expects a pair as argument xs, but encountered ' + xs - ); - } -} - -// is_null returns true if arg is exactly null -// LOW-LEVEL FUNCTION, NOT SOURCE -export function is_null(xs) { - return xs === null; -} - -// is_list recurses down the list and checks that it ends with the empty list [] -// does not throw Value exceptions -// LOW-LEVEL FUNCTION, NOT SOURCE -export function is_list(xs) { - for (; ; xs = tail(xs)) { - if (is_null(xs)) { - return true; - } else if (!is_pair(xs)) { - return false; - } - } -} - -// list makes a list out of its arguments -// LOW-LEVEL FUNCTION, NOT SOURCE -export function list(...args) { - let the_list: any = null; - for (let i = args.length - 1; i >= 0; i--) { - the_list = pair(args[i], the_list); - } - return the_list; -} - -// list_to_vector returns vector that contains the elements of the argument list -// in the given order. -// list_to_vector throws an exception if the argument is not a list -// LOW-LEVEL FUNCTION, NOT SOURCE -export function list_to_vector(lst) { - const vector: any[] = []; - while (!is_null(lst)) { - vector.push(head(lst)); - lst = tail(lst); - } - return vector; -} - -// vector_to_list returns a list that contains the elements of the argument vector -// in the given order. -// vector_to_list throws an exception if the argument is not a vector -// LOW-LEVEL FUNCTION, NOT SOURCE -export function vector_to_list(vector) { - let result: any = null; - for (let i = vector.length - 1; i >= 0; i = i - 1) { - result = pair(vector[i], result); - } - return result; -} - -// returns the length of a given argument list -// throws an exception if the argument is not a list -export function length(xs) { - let i = 0; - while (!is_null(xs)) { - i += 1; - xs = tail(xs); - } - return i; -} - -// map applies first arg f to the elements of the second argument, -// assumed to be a list. -// f is applied element-by-element: -// map(f,[1,[2,[]]]) results in [f(1),[f(2),[]]] -// map throws an exception if the second argument is not a list, -// and if the second argument is a non-empty list and the first -// argument is not a function. -// tslint:disable-next-line:ban-types -export function map(f, xs) { - return is_null(xs) ? null : pair(f(head(xs)), map(f, tail(xs))); -} - -// build_list takes a non-negative integer n as first argument, -// and a function fun as second argument. -// build_list returns a list of n elements, that results from -// applying fun to the numbers from 0 to n-1. -// tslint:disable-next-line:ban-types -export function build_list(n, fun) { - if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { - throw new Error( - 'build_list(n, fun) expects a positive integer as ' + - 'argument n, but encountered ' + - n - ); - } - - // tslint:disable-next-line:ban-types - function build(i, alreadyBuilt) { - if (i < 0) { - return alreadyBuilt; - } else { - return build(i - 1, pair(fun(i), alreadyBuilt)); - } - } - - return build(n - 1, null); -} - -// for_each applies first arg fun to the elements of the list passed as -// second argument. fun is applied element-by-element: -// for_each(fun,[1,[2,[]]]) results in the calls fun(1) and fun(2). -// for_each returns true. -// for_each throws an exception if the second argument is not a list, -// and if the second argument is a non-empty list and the -// first argument is not a function. -// tslint:disable-next-line:ban-types -export function for_each(fun, xs) { - if (!is_list(xs)) { - throw new Error( - 'for_each expects a list as argument xs, but encountered ' + xs - ); - } - for (; !is_null(xs); xs = tail(xs)) { - fun(head(xs)); - } - return true; -} - -// reverse reverses the argument list -// reverse throws an exception if the argument is not a list. -export function reverse(xs) { - if (!is_list(xs)) { - throw new Error( - 'reverse(xs) expects a list as argument xs, but encountered ' + xs - ); - } - let result: any = null; - for (; !is_null(xs); xs = tail(xs)) { - result = pair(head(xs), result); - } - return result; -} - -// append first argument list and second argument list. -// In the result, the [] at the end of the first argument list -// is replaced by the second argument list -// append throws an exception if the first argument is not a list -export function append(xs, ys) { - if (is_null(xs)) { - return ys; - } else { - return pair(head(xs), append(tail(xs), ys)); - } -} - -// member looks for a given first-argument element in a given -// second argument list. It returns the first postfix sublist -// that starts with the given element. It returns [] if the -// element does not occur in the list -export function member(v, xs) { - for (; !is_null(xs); xs = tail(xs)) { - if (head(xs) === v) { - return xs; - } - } - return null; -} - -// removes the first occurrence of a given first-argument element -// in a given second-argument list. Returns the original list -// if there is no occurrence. -export function remove(v, xs) { - if (is_null(xs)) { - return null; - } else { - if (v === head(xs)) { - return tail(xs); - } else { - return pair(head(xs), remove(v, tail(xs))); - } - } -} - -// Similar to remove. But removes all instances of v instead of just the first -export function remove_all(v, xs) { - if (is_null(xs)) { - return null; - } else { - if (v === head(xs)) { - return remove_all(v, tail(xs)); - } else { - return pair(head(xs), remove_all(v, tail(xs))); - } - } -} - -// for backwards-compatibility -// equal computes the structural equality -// over its arguments -export function equal(item1, item2) { - if (is_pair(item1) && is_pair(item2)) { - return equal(head(item1), head(item2)) && equal(tail(item1), tail(item2)); - } else { - return item1 === item2; - } -} - -// assoc treats the second argument as an association, -// a list of (index,value) pairs. -// assoc returns the first (index,value) pair whose -// index equal (using structural equality) to the given -// first argument v. Returns false if there is no such -// pair -export function assoc(v, xs) { - if (is_null(xs)) { - return false; - } else if (equal(v, head(head(xs)))) { - return head(xs); - } else { - return assoc(v, tail(xs)); - } -} - -// filter returns the sublist of elements of given list xs -// for which the given predicate function returns true. -// tslint:disable-next-line:ban-types -export function filter(pred, xs) { - if (is_null(xs)) { - return xs; - } else { - if (pred(head(xs))) { - return pair(head(xs), filter(pred, tail(xs))); - } else { - return filter(pred, tail(xs)); - } - } -} - -// enumerates numbers starting from start, -// using a step size of 1, until the number -// exceeds end. -export function enum_list(start, end) { - if (typeof start !== 'number') { - throw new Error( - 'enum_list(start, end) expects a number as argument start, but encountered ' + - start - ); - } - if (typeof end !== 'number') { - throw new Error( - 'enum_list(start, end) expects a number as argument start, but encountered ' + - end - ); - } - if (start > end) { - return null; - } else { - return pair(start, enum_list(start + 1, end)); - } -} - -// Returns the item in list lst at index n (the first item is at position 0) -export function list_ref(xs, n) { - if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { - throw new Error( - 'list_ref(xs, n) expects a positive integer as argument n, but encountered ' + - n - ); - } - for (; n > 0; --n) { - xs = tail(xs); - } - return head(xs); -} - -// accumulate applies given operation op to elements of a list -// in a right-to-left order, first apply op to the last element -// and an initial element, resulting in r1, then to the -// second-last element and r1, resulting in r2, etc, and finally -// to the first element and r_n-1, where n is the length of the -// list. -// accumulate(op,zero,list(1,2,3)) results in -// op(1, op(2, op(3, zero))) -export function accumulate(op, initial, sequence) { - if (is_null(sequence)) { - return initial; - } else { - return op(head(sequence), accumulate(op, initial, tail(sequence))); - } -} - -// set_head(xs,x) changes the head of given pair xs to be x, -// throws an exception if the argument is not a pair -// LOW-LEVEL FUNCTION, NOT SOURCE -export function set_head(xs, x) { - if (is_pair(xs)) { - xs[0] = x; - return undefined; - } else { - throw new Error( - 'set_head(xs,x) expects a pair as argument xs, but encountered ' + xs - ); - } -} - -// set_tail(xs,x) changes the tail of given pair xs to be x, -// throws an exception if the argument is not a pair -// LOW-LEVEL FUNCTION, NOT SOURCE -export function set_tail(xs, x) { - if (is_pair(xs)) { - xs[1] = x; - return undefined; - } else { - throw new Error( - 'set_tail(xs,x) expects a pair as argument xs, but encountered ' + xs - ); - } -} +/* eslint-disable no-else-return, no-lonely-if, operator-assignment, prefer-template */ + +// list.js: Supporting lists in the Scheme style, using pairs made +// up of two-element JavaScript array (vector) + +// Author: Martin Henz + +// Note: this library is used in the externalLibs of cadet-frontend. +// It is distinct from the LISTS library of Source §2, which contains +// primitive and predeclared functions from Chapter 2 of SICP JS. + +// array test works differently for Rhino and +// the Firefox environment (especially Web Console) +export function array_test(x): boolean { + if (Array.isArray === undefined) { + return x instanceof Array; + } else { + return Array.isArray(x); + } +} + +// pair constructs a pair using a two-element array +// LOW-LEVEL FUNCTION, NOT SOURCE +export function pair(x, xs): [any, any] { + return [x, xs]; +} + +// is_pair returns true iff arg is a two-element array +// LOW-LEVEL FUNCTION, NOT SOURCE +export function is_pair(x): boolean { + return array_test(x) && x.length === 2; +} + +// head returns the first component of the given pair, +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function head(xs): any { + if (is_pair(xs)) { + return xs[0]; + } else { + throw new Error( + 'head(xs) expects a pair as argument xs, but encountered ' + xs, + ); + } +} + +// tail returns the second component of the given pair +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function tail(xs) { + if (is_pair(xs)) { + return xs[1]; + } else { + throw new Error( + 'tail(xs) expects a pair as argument xs, but encountered ' + xs, + ); + } +} + +// is_null returns true if arg is exactly null +// LOW-LEVEL FUNCTION, NOT SOURCE +export function is_null(xs) { + return xs === null; +} + +// is_list recurses down the list and checks that it ends with the empty list [] +// does not throw Value exceptions +// LOW-LEVEL FUNCTION, NOT SOURCE +export function is_list(xs) { + for (; ; xs = tail(xs)) { + if (is_null(xs)) { + return true; + } else if (!is_pair(xs)) { + return false; + } + } +} + +// list makes a list out of its arguments +// LOW-LEVEL FUNCTION, NOT SOURCE +export function list(...args) { + let the_list: any = null; + for (let i = args.length - 1; i >= 0; i--) { + the_list = pair(args[i], the_list); + } + return the_list; +} + +// list_to_vector returns vector that contains the elements of the argument list +// in the given order. +// list_to_vector throws an exception if the argument is not a list +// LOW-LEVEL FUNCTION, NOT SOURCE +export function list_to_vector(lst) { + const vector: any[] = []; + while (!is_null(lst)) { + vector.push(head(lst)); + lst = tail(lst); + } + return vector; +} + +// vector_to_list returns a list that contains the elements of the argument vector +// in the given order. +// vector_to_list throws an exception if the argument is not a vector +// LOW-LEVEL FUNCTION, NOT SOURCE +export function vector_to_list(vector) { + let result: any = null; + for (let i = vector.length - 1; i >= 0; i = i - 1) { + result = pair(vector[i], result); + } + return result; +} + +// returns the length of a given argument list +// throws an exception if the argument is not a list +export function length(xs) { + let i = 0; + while (!is_null(xs)) { + i += 1; + xs = tail(xs); + } + return i; +} + +// map applies first arg f to the elements of the second argument, +// assumed to be a list. +// f is applied element-by-element: +// map(f,[1,[2,[]]]) results in [f(1),[f(2),[]]] +// map throws an exception if the second argument is not a list, +// and if the second argument is a non-empty list and the first +// argument is not a function. +// tslint:disable-next-line:ban-types +export function map(f, xs) { + return is_null(xs) ? null : pair(f(head(xs)), map(f, tail(xs))); +} + +// build_list takes a non-negative integer n as first argument, +// and a function fun as second argument. +// build_list returns a list of n elements, that results from +// applying fun to the numbers from 0 to n-1. +// tslint:disable-next-line:ban-types +export function build_list(n, fun) { + if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { + throw new Error( + 'build_list(n, fun) expects a positive integer as ' + + 'argument n, but encountered ' + + n, + ); + } + + // tslint:disable-next-line:ban-types + function build(i, alreadyBuilt) { + if (i < 0) { + return alreadyBuilt; + } else { + return build(i - 1, pair(fun(i), alreadyBuilt)); + } + } + + return build(n - 1, null); +} + +// for_each applies first arg fun to the elements of the list passed as +// second argument. fun is applied element-by-element: +// for_each(fun,[1,[2,[]]]) results in the calls fun(1) and fun(2). +// for_each returns true. +// for_each throws an exception if the second argument is not a list, +// and if the second argument is a non-empty list and the +// first argument is not a function. +// tslint:disable-next-line:ban-types +export function for_each(fun, xs) { + if (!is_list(xs)) { + throw new Error( + 'for_each expects a list as argument xs, but encountered ' + xs, + ); + } + for (; !is_null(xs); xs = tail(xs)) { + fun(head(xs)); + } + return true; +} + +// reverse reverses the argument list +// reverse throws an exception if the argument is not a list. +export function reverse(xs) { + if (!is_list(xs)) { + throw new Error( + 'reverse(xs) expects a list as argument xs, but encountered ' + xs, + ); + } + let result: any = null; + for (; !is_null(xs); xs = tail(xs)) { + result = pair(head(xs), result); + } + return result; +} + +// append first argument list and second argument list. +// In the result, the [] at the end of the first argument list +// is replaced by the second argument list +// append throws an exception if the first argument is not a list +export function append(xs, ys) { + if (is_null(xs)) { + return ys; + } else { + return pair(head(xs), append(tail(xs), ys)); + } +} + +// member looks for a given first-argument element in a given +// second argument list. It returns the first postfix sublist +// that starts with the given element. It returns [] if the +// element does not occur in the list +export function member(v, xs) { + for (; !is_null(xs); xs = tail(xs)) { + if (head(xs) === v) { + return xs; + } + } + return null; +} + +// removes the first occurrence of a given first-argument element +// in a given second-argument list. Returns the original list +// if there is no occurrence. +export function remove(v, xs) { + if (is_null(xs)) { + return null; + } else { + if (v === head(xs)) { + return tail(xs); + } else { + return pair(head(xs), remove(v, tail(xs))); + } + } +} + +// Similar to remove. But removes all instances of v instead of just the first +export function remove_all(v, xs) { + if (is_null(xs)) { + return null; + } else { + if (v === head(xs)) { + return remove_all(v, tail(xs)); + } else { + return pair(head(xs), remove_all(v, tail(xs))); + } + } +} + +// for backwards-compatibility +// equal computes the structural equality +// over its arguments +export function equal(item1, item2) { + if (is_pair(item1) && is_pair(item2)) { + return equal(head(item1), head(item2)) && equal(tail(item1), tail(item2)); + } else { + return item1 === item2; + } +} + +// assoc treats the second argument as an association, +// a list of (index,value) pairs. +// assoc returns the first (index,value) pair whose +// index equal (using structural equality) to the given +// first argument v. Returns false if there is no such +// pair +export function assoc(v, xs) { + if (is_null(xs)) { + return false; + } else if (equal(v, head(head(xs)))) { + return head(xs); + } else { + return assoc(v, tail(xs)); + } +} + +// filter returns the sublist of elements of given list xs +// for which the given predicate function returns true. +// tslint:disable-next-line:ban-types +export function filter(pred, xs) { + if (is_null(xs)) { + return xs; + } else { + if (pred(head(xs))) { + return pair(head(xs), filter(pred, tail(xs))); + } else { + return filter(pred, tail(xs)); + } + } +} + +// enumerates numbers starting from start, +// using a step size of 1, until the number +// exceeds end. +export function enum_list(start, end) { + if (typeof start !== 'number') { + throw new Error( + 'enum_list(start, end) expects a number as argument start, but encountered ' + + start, + ); + } + if (typeof end !== 'number') { + throw new Error( + 'enum_list(start, end) expects a number as argument start, but encountered ' + + end, + ); + } + if (start > end) { + return null; + } else { + return pair(start, enum_list(start + 1, end)); + } +} + +// Returns the item in list lst at index n (the first item is at position 0) +export function list_ref(xs, n) { + if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { + throw new Error( + 'list_ref(xs, n) expects a positive integer as argument n, but encountered ' + + n, + ); + } + for (; n > 0; --n) { + xs = tail(xs); + } + return head(xs); +} + +// accumulate applies given operation op to elements of a list +// in a right-to-left order, first apply op to the last element +// and an initial element, resulting in r1, then to the +// second-last element and r1, resulting in r2, etc, and finally +// to the first element and r_n-1, where n is the length of the +// list. +// accumulate(op,zero,list(1,2,3)) results in +// op(1, op(2, op(3, zero))) +export function accumulate(op, initial, sequence) { + if (is_null(sequence)) { + return initial; + } else { + return op(head(sequence), accumulate(op, initial, tail(sequence))); + } +} + +// set_head(xs,x) changes the head of given pair xs to be x, +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function set_head(xs, x) { + if (is_pair(xs)) { + xs[0] = x; + return undefined; + } else { + throw new Error( + 'set_head(xs,x) expects a pair as argument xs, but encountered ' + xs, + ); + } +} + +// set_tail(xs,x) changes the tail of given pair xs to be x, +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function set_tail(xs, x) { + if (is_pair(xs)) { + xs[1] = x; + return undefined; + } else { + throw new Error( + 'set_tail(xs,x) expects a pair as argument xs, but encountered ' + xs, + ); + } +} diff --git a/src/bundles/sound/riffwave.ts b/src/bundles/sound/riffwave.ts index 70a1af751..478588146 100644 --- a/src/bundles/sound/riffwave.ts +++ b/src/bundles/sound/riffwave.ts @@ -1,22 +1,22 @@ -/* - * RIFFWAVE.js v0.03 - Audio encoder for HTML5 this mapping from - * letter name to midi notes. - * - * @param letter_name given letter name - * @return the corresponding midi note - * @example letter_name_to_midi_note("C4"); // Returns 60 - */ -export function letter_name_to_midi_note(note: string): number { - let res = 12; // C0 is midi note 12 - const n = note[0].toUpperCase(); - switch (n) { - case 'D': - res += 2; - break; - - case 'E': - res += 4; - break; - - case 'F': - res += 5; - break; - - case 'G': - res += 7; - break; - - case 'A': - res += 9; - break; - - case 'B': - res += 11; - break; - - default: - break; - } - - if (note.length === 2) { - res += parseInt(note[1]) * 12; - } else if (note.length === 3) { - switch (note[1]) { - case '#': - res += 1; - break; - - case 'b': - res -= 1; - break; - - default: - break; - } - res += parseInt(note[2]) * 12; - } - return res; -} - -/** - * Converts a MIDI note to its corresponding frequency. - * - * @param note given MIDI note - * @return the frequency of the MIDI note - * @example midi_note_to_frequency(69); // Returns 440 - */ -export function midi_note_to_frequency(note: number): number { - // A4 = 440Hz = midi note 69 - return 440 * 2 ** ((note - 69) / 12); -} - -/** - * Converts a letter name to its corresponding frequency. - * - * @param letter_name given letter name - * @return the corresponding frequency - * @example letter_name_to_frequency("A4"); // Returns 440 - */ -export function letter_name_to_frequency(note: string): number { - return midi_note_to_frequency(letter_name_to_midi_note(note)); -} - -// Instruments - -/** - * returns a Sound reminiscent of a bell, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting bell Sound with given pitch and duration - * @example bell(40, 1); - */ -export function bell(note: number, duration: number): Sound { - return stacking_adsr( - square_sound, - midi_note_to_frequency(note), - duration, - list( - adsr(0, 0.6, 0, 0.05), - adsr(0, 0.6618, 0, 0.05), - adsr(0, 0.7618, 0, 0.05), - adsr(0, 0.9071, 0, 0.05) - ) - ); -} - -/** - * returns a Sound reminiscent of a cello, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting cello Sound with given pitch and duration - * @example cello(36, 5); - */ -export function cello(note: number, duration: number): Sound { - return stacking_adsr( - square_sound, - midi_note_to_frequency(note), - duration, - list(adsr(0.05, 0, 1, 0.1), adsr(0.05, 0, 1, 0.15), adsr(0, 0, 0.2, 0.15)) - ); -} - -/** - * returns a Sound reminiscent of a piano, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting piano Sound with given pitch and duration - * @example piano(48, 5); - */ -export function piano(note: number, duration: number): Sound { - return stacking_adsr( - triangle_sound, - midi_note_to_frequency(note), - duration, - list(adsr(0, 0.515, 0, 0.05), adsr(0, 0.32, 0, 0.05), adsr(0, 0.2, 0, 0.05)) - ); -} - -/** - * returns a Sound reminiscent of a trombone, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting trombone Sound with given pitch and duration - * @example trombone(60, 2); - */ -export function trombone(note: number, duration: number): Sound { - return stacking_adsr( - square_sound, - midi_note_to_frequency(note), - duration, - list(adsr(0.2, 0, 1, 0.1), adsr(0.3236, 0.6, 0, 0.1)) - ); -} - -/** - * returns a Sound reminiscent of a violin, playing - * a given note for a given duration - * @param note MIDI note - * @param duration duration in seconds - * @return Sound resulting violin Sound with given pitch and duration - * @example violin(53, 4); - */ -export function violin(note: number, duration: number): Sound { - return stacking_adsr( - sawtooth_sound, - midi_note_to_frequency(note), - duration, - list( - adsr(0.35, 0, 1, 0.15), - adsr(0.35, 0, 1, 0.15), - adsr(0.45, 0, 1, 0.15), - adsr(0.45, 0, 1, 0.15) - ) - ); -} +/** + * + * The stereo sounds library build on the sounds library by accommodating stereo sounds. + * Within this library, all sounds are represented in stereo, with two waves, left and right. + * + * A Stereo Sound is a `pair(pair(left_wave, right_wave), duration)` where duration is the length of the sound in seconds. + * The constructor `make_stereo_sound` and accessors `get_left_wave`, `get_right_wave`, and `get_duration` are provided. + * The `make_sound` constructor from sounds is syntatic sugar for `make_stereo_sounds` with equal waves. + * + * @module stereo_sound + * @author Koh Shang Hui + * @author Samyukta Sounderraman + */ + +/* eslint-disable new-cap, @typescript-eslint/naming-convention */ +import { + accumulate, + head, + is_null, + is_pair, + length, + list, + pair, + tail, +} from './list'; +import { RIFFWAVE } from './riffwave'; +import { + AudioPlayed, + List, + Sound, + SoundProducer, + SoundTransformer, + Wave, +} from './types'; + +// Global Constants and Variables + +const FS: number = 44100; // Output sample rate +const fourier_expansion_level: number = 5; // fourier expansion level + +/** @hidden */ +export const audioPlayed: AudioPlayed[] = []; + +// Singular audio context for all playback functions +let audioplayer: AudioContext; + +// Track if a sound is currently playing +let isPlaying: boolean; + +// Instantiates new audio context +function init_audioCtx(): void { + audioplayer = new window.AudioContext(); + // audioplayer = new (window.AudioContext || window.webkitAudioContext)(); +} + +// linear decay from 1 to 0 over decay_period +function linear_decay(decay_period: number): (t: number) => number { + return (t) => { + if (t > decay_period || t < 0) { + return 0; + } + return 1 - t / decay_period; + }; +} + +// // --------------------------------------------- +// // Microphone Functionality +// // --------------------------------------------- + +// permission initially undefined +// set to true by granting microphone permission +// set to false by denying microphone permission +let permission: boolean | undefined; + +let recorded_sound: Sound | undefined; + +// check_permission is called whenever we try +// to record a sound +function check_permission() { + if (permission === undefined) { + throw new Error( + 'Call init_record(); to obtain permission to use microphone', + ); + } else if (permission === false) { + throw new Error(`Permission has been denied.\n + Re-start browser and call init_record();\n + to obtain permission to use microphone.`); + } // (permission === true): do nothing +} + +let globalStream: any; + +function rememberStream(stream: any) { + permission = true; + globalStream = stream; +} + +function setPermissionToFalse() { + permission = false; +} + +function start_recording(mediaRecorder: MediaRecorder) { + const data: any[] = []; + mediaRecorder.ondataavailable = (e) => e.data.size && data.push(e.data); + mediaRecorder.start(); + mediaRecorder.onstop = () => process(data); +} + +// there is a beep signal at the beginning and end +// of each recording +const recording_signal_duration_ms = 100; + +function play_recording_signal() { + play(sine_sound(1200, recording_signal_duration_ms / 1000)); +} + +// eslint-disable-next-line @typescript-eslint/no-shadow +function process(data: any[] | undefined) { + const audioContext = new AudioContext(); + const blob = new Blob(data); + + convertToArrayBuffer(blob) + .then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer)) + .then(save); +} + +// Converts input microphone sound (blob) into array format. +function convertToArrayBuffer(blob: Blob): Promise { + const url = URL.createObjectURL(blob); + return fetch(url) + .then((response) => response.arrayBuffer()); +} + +function save(audioBuffer: AudioBuffer) { + const array = audioBuffer.getChannelData(0); + const duration = array.length / FS; + recorded_sound = make_sound((t) => { + const index = t * FS; + const lowerIndex = Math.floor(index); + const upperIndex = lowerIndex + 1; + const ratio = index - lowerIndex; + const upper = array[upperIndex] ? array[upperIndex] : 0; + const lower = array[lowerIndex] ? array[lowerIndex] : 0; + return lower * (1 - ratio) + upper * ratio; + }, duration); +} + +/** + * Initialize recording by obtaining permission + * to use the default device microphone + * + * @returns string "obtaining recording permission" + */ +export function init_record(): string { + navigator.mediaDevices + .getUserMedia({ audio: true }) + .then(rememberStream, setPermissionToFalse); + return 'obtaining recording permission'; +} + +/** + * takes a buffer duration (in seconds) as argument, and + * returns a nullary stop function stop. A call + * stop() returns a sound promise: a nullary function + * that returns a sound. Example:
    init_record();
    + * const stop = record(0.5);
    + * // record after 0.5 seconds. Then in next query:
    + * const promise = stop();
    + * // In next query, you can play the promised sound, by
    + * // applying the promise:
    + * play(promise());
    + * @param buffer - pause before recording, in seconds + * @returns nullary stop function; + * stop() stops the recording and + * returns a sound promise: a nullary function that returns the recorded sound + */ +export function record(buffer: number): () => () => Sound { + check_permission(); + const mediaRecorder = new MediaRecorder(globalStream); + setTimeout(() => { + play_recording_signal(); + start_recording(mediaRecorder); + }, recording_signal_duration_ms + buffer * 1000); + return () => { + mediaRecorder.stop(); + play_recording_signal(); + return () => { + if (recorded_sound === undefined) { + throw new Error('recording still being processed'); + } else { + return recorded_sound; + } + }; + }; +} + +/** + * Records a sound of given duration in seconds, after + * a buffer also in seconds, and + * returns a sound promise: a nullary function + * that returns a sound. Example:
    init_record();
    + * const promise = record_for(2, 0.5);
    + * // In next query, you can play the promised sound, by
    + * // applying the promise:
    + * play(promise());
    + * @param duration duration in seconds + * @param buffer pause before recording, in seconds + * @return promise: nullary function which returns the recorded sound + */ +export function record_for(duration: number, buffer: number): () => Sound { + recorded_sound = undefined; + const duration_ms = duration * 1000; + check_permission(); + const mediaRecorder = new MediaRecorder(globalStream); + setTimeout(() => { + play_recording_signal(); + start_recording(mediaRecorder); + setTimeout(() => { + mediaRecorder.stop(); + play_recording_signal(); + }, duration_ms); + }, recording_signal_duration_ms + buffer * 1000); + return () => { + if (recorded_sound === undefined) { + throw new Error('recording still being processed'); + } else { + return recorded_sound; + } + }; +} + +// ============================================================================= +// Module's Exposed Functions +// +// This file only includes the implementation and documentation of exposed +// functions of the module. For private functions dealing with the browser's +// graphics library context, see './webGL_curves.ts'. +// ============================================================================= + +// Core functions + +/** + * Makes a Stereo Sound with given wave function and duration. + * The wave function is a function: number -> number + * that takes in a non-negative input time and returns an amplitude + * between -1 and 1. + * + * @param left_wave wave function of the left channel of the sound + * @param right_wave wave function of the right channel of the sound + * @param duration duration of the sound + * @return resulting stereo sound + * @example const s = make_stereo_sound(t => Math_sin(2 * Math_PI * 440 * t), t => Math_sin(2 * Math_PI * 300 * t), 5); + */ +export function make_stereo_sound( + left_wave: Wave, + right_wave: Wave, + duration: number, +): Sound { + return pair( + pair( + (t: number) => (t >= duration ? 0 : left_wave(t)), + (t: number) => (t >= duration ? 0 : right_wave(t)), + ), + duration, + ); +} + +/** + * Makes a Sound with given wave function and duration. + * The wave function is a function: number -> number + * that takes in a non-negative input time and returns an amplitude + * between -1 and 1. + * + * @param wave wave function of the sound + * @param duration duration of the sound + * @return with wave as wave function and duration as duration + * @example const s = make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5); + */ +export function make_sound(wave: Wave, duration: number): Sound { + return make_stereo_sound(wave, wave, duration); +} + +/** + * Accesses the left wave function of a given Sound. + * + * @param sound given Sound + * @return the wave function of the Sound + * @example get_wave(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns t => Math_sin(2 * Math_PI * 440 * t) + */ +export function get_left_wave(sound: Sound): Wave { + return head(head(sound)); +} + +/** + * Accesses the left wave function of a given Sound. + * + * @param sound given Sound + * @return the wave function of the Sound + * @example get_wave(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns t => Math_sin(2 * Math_PI * 440 * t) + */ +export function get_right_wave(sound: Sound): Wave { + return tail(head(sound)); +} + +/** + * Accesses the duration of a given Sound. + * + * @param sound given Sound + * @return the duration of the Sound + * @example get_duration(make_sound(t => Math_sin(2 * Math_PI * 440 * t), 5)); // Returns 5 + */ +export function get_duration(sound: Sound): number { + return tail(sound); +} + +/** + * Checks if the argument is a Sound + * + * @param x input to be checked + * @return true if x is a Sound, false otherwise + * @example is_sound(make_sound(t => 0, 2)); // Returns true + */ +export function is_sound(x: any): boolean { + return ( + is_pair(x) + && typeof get_left_wave(x) === 'function' + && typeof get_right_wave(x) === 'function' + && typeof get_duration(x) === 'number' + ); +} + +/** + * Plays the given Wave using the computer’s sound device, for the duration + * given in seconds. + * The sound is only played if no other sounds are currently being played. + * + * @param wave the wave function to play, starting at 0 + * @return the given sound + * @example play_wave(t => math_sin(t * 3000), 5); + */ +export function play_wave(wave: Wave, duration: number): AudioPlayed { + return play(make_sound(wave, duration)); +} + +/** + * Plays the given two Waves using the computer’s sound device, for the duration + * given in seconds. The first Wave is for the left channel, the second for the + * right channel. + * The sound is only played if no other sounds are currently being played. + * + * @param wave1 the wave function to play on the left channel, starting at 0 + * @param wave2 the wave function to play on the right channel, starting at 0 + * @return the given sound + * @example play_waves(t => math_sin(t * 3000), t => math_sin(t * 6000), 5); + */ +export function play_waves( + wave1: Wave, + wave2: Wave, + duration: number, +): AudioPlayed { + return play(make_stereo_sound(wave1, wave2, duration)); +} + +/** + * Plays the given Sound using the computer’s sound device. + * The sound is only played if no other sounds are currently being played. + * + * @param sound the sound to play + * @return the given sound + * @example play(sine_sound(440, 5)); + */ +export function play(sound: Sound): AudioPlayed { + // Type-check sound + if (!is_sound(sound)) { + throw new Error(`play is expecting sound, but encountered ${sound}`); + // If a sound is already playing, terminate execution. + } else if (isPlaying) { + throw new Error('play: audio system still playing previous sound'); + } else if (get_duration(sound) < 0) { + throw new Error('play: duration of sound is negative'); + } else { + // Instantiate audio context if it has not been instantiated. + if (!audioplayer) { + init_audioCtx(); + } + + const channel: number[] = []; + const len = Math.ceil(FS * get_duration(sound)); + + let Ltemp: number; + let Rtemp: number; + let Lprev_value = 0; + let Rprev_value = 0; + + const left_wave = get_left_wave(sound); + const right_wave = get_right_wave(sound); + for (let i = 0; i < len; i += 1) { + Ltemp = left_wave(i / FS); + // clip amplitude + if (Ltemp > 1) { + channel[2 * i] = 1; + } else if (Ltemp < -1) { + channel[2 * i] = -1; + } else { + channel[2 * i] = Ltemp; + } + + // smoothen out sudden cut-outs + if ( + channel[2 * i] === 0 + && Math.abs(channel[2 * i] - Lprev_value) > 0.01 + ) { + channel[2 * i] = Lprev_value * 0.999; + } + + Lprev_value = channel[2 * i]; + + Rtemp = right_wave(i / FS); + // clip amplitude + if (Rtemp > 1) { + channel[2 * i + 1] = 1; + } else if (Rtemp < -1) { + channel[2 * i + 1] = -1; + } else { + channel[2 * i + 1] = Rtemp; + } + + // smoothen out sudden cut-outs + if ( + channel[2 * i + 1] === 0 + && Math.abs(channel[2 * i] - Rprev_value) > 0.01 + ) { + channel[2 * i + 1] = Rprev_value * 0.999; + } + + Rprev_value = channel[2 * i + 1]; + } + + // quantize + for (let i = 0; i < channel.length; i += 1) { + channel[i] = Math.floor(channel[i] * 32767.999); + } + + const riffwave = new RIFFWAVE([]); + riffwave.header.sampleRate = FS; + riffwave.header.numChannels = 2; + riffwave.header.bitsPerSample = 16; + riffwave.Make(channel); + + /* + const audio = new Audio(riffwave.dataURI); + const source2 = audioplayer.createMediaElementSource(audio); + source2.connect(audioplayer.destination); + + // Connect data to output destination + isPlaying = true; + audio.play(); + audio.onended = () => { + source2.disconnect(audioplayer.destination); + isPlaying = false; + }; */ + + const audio = { + toReplString: () => '', + dataUri: riffwave.dataURI, + }; + + audioPlayed.push(audio); + return audio; + } +} + +/** + * Plays the given Sound using the computer’s sound device + * on top of any sounds that are currently playing. + * + * @param sound the sound to play + * @example play_concurrently(sine_sound(440, 5)); + */ +export function play_concurrently(sound: Sound): void { + // Type-check sound + if (!is_sound(sound)) { + throw new Error( + `play_concurrently is expecting sound, but encountered ${sound}`, + ); + } else if (get_duration(sound) <= 0) { + // Do nothing + } else { + // Instantiate audio context if it has not been instantiated. + if (!audioplayer) { + init_audioCtx(); + } + + const channel: number[] = Array[2 * Math.ceil(FS * get_duration(sound))]; + + let Ltemp: number; + let Rtemp: number; + let prev_value = 0; + + const left_wave = get_left_wave(sound); + + for (let i = 0; i < channel.length; i += 2) { + Ltemp = left_wave(i / FS); + // clip amplitude + if (Ltemp > 1) { + channel[i] = 1; + } else if (Ltemp < -1) { + channel[i] = -1; + } else { + channel[i] = Ltemp; + } + + // smoothen out sudden cut-outs + if (channel[i] === 0 && Math.abs(channel[i] - prev_value) > 0.01) { + channel[i] = prev_value * 0.999; + } + + prev_value = channel[i]; + } + + prev_value = 0; + const right_wave = get_right_wave(sound); + for (let i = 1; i < channel.length; i += 2) { + Rtemp = right_wave(i / FS); + // clip amplitude + if (Rtemp > 1) { + channel[i] = 1; + } else if (Rtemp < -1) { + channel[i] = -1; + } else { + channel[i] = Rtemp; + } + + // smoothen out sudden cut-outs + if (channel[i] === 0 && Math.abs(channel[i] - prev_value) > 0.01) { + channel[i] = prev_value * 0.999; + } + + prev_value = channel[i]; + } + + // quantize + for (let i = 0; i < channel.length; i += 1) { + channel[i] = Math.floor(channel[i] * 32767.999); + } + + const riffwave = new RIFFWAVE([]); + riffwave.header.sampleRate = FS; + riffwave.header.numChannels = 2; + riffwave.header.bitsPerSample = 16; + riffwave.Make(channel); + const audio = new Audio(riffwave.dataURI); + const source2 = audioplayer.createMediaElementSource(audio); + source2.connect(audioplayer.destination); + + // Connect data to output destination + audio.play(); + isPlaying = true; + audio.onended = () => { + source2.disconnect(audioplayer.destination); + isPlaying = false; + }; + } +} + +/** + * Stops all currently playing sounds. + */ +export function stop(): void { + audioplayer.close(); + isPlaying = false; +} + +// Stereo only functions + +/** + * Centers a Sound by averaging its left and right channels, + * resulting in an effectively mono sound. + * + * @param sound the sound to be squashed + * @return a new sound with the left and right channels averaged + */ +export function squash(sound: Sound): Sound { + const left = get_left_wave(sound); + const right = get_right_wave(sound); + return make_sound((t) => 0.5 * (left(t) + right(t)), get_duration(sound)); +} + +/** + * Returns a Sound Transformer that pans a sound based on the pan amount. + * The input sound is first squashed to mono. + * An amount of `-1` is a hard left pan, `0` is balanced, `1` is hard right pan. + * + * @param amount the pan amount, from -1 to 1 + * @return a Sound Transformer that pans a Sound + */ +export function pan(amount: number): SoundTransformer { + return (sound) => { + if (amount > 1) { + amount = 1; + } + if (amount < -1) { + amount = -1; + } + sound = squash(sound); + return make_stereo_sound( + (t) => ((1 - amount) / 2) * get_left_wave(sound)(t), + (t) => ((1 + amount) / 2) * get_right_wave(sound)(t), + get_duration(sound), + ); + }; +} + +/** + * Returns a Sound Transformer that uses a Sound to pan another Sound. + * The modulator is treated as a mono sound and its output is used to pan + * an input Sound. + * `-1` is a hard left pan, `0` is balanced, `1` is hard right pan. + * + * @param modulator the Sound used to modulate the pan of another sound + * @return a Sound Transformer that pans a Sound + */ +export function pan_mod(modulator: Sound): SoundTransformer { + const amount = (t: number) => { + let output = get_left_wave(modulator)(t) + get_right_wave(modulator)(t); + if (output > 1) { + output = 1; + } + if (output < -1) { + output = -1; + } + return output; + }; + return (sound) => { + sound = squash(sound); + return make_stereo_sound( + (t) => ((1 - amount(t)) / 2) * get_left_wave(sound)(t), + (t) => ((1 + amount(t)) / 2) * get_right_wave(sound)(t), + get_duration(sound), + ); + }; +} + +// Primitive sounds + +/** + * Makes a noise sound with given duration + * + * @param duration the duration of the noise sound + * @return resulting noise sound + * @example noise_sound(5); + */ +export function noise_sound(duration: number): Sound { + return make_sound((_t) => Math.random() * 2 - 1, duration); +} + +/** + * Makes a silence sound with given duration + * + * @param duration the duration of the silence sound + * @return resulting silence sound + * @example silence_sound(5); + */ +export function silence_sound(duration: number): Sound { + return make_sound((_t) => 0, duration); +} + +/** + * Makes a sine wave sound with given frequency and duration + * + * @param freq the frequency of the sine wave sound + * @param duration the duration of the sine wave sound + * @return resulting sine wave sound + * @example sine_sound(440, 5); + */ +export function sine_sound(freq: number, duration: number): Sound { + return make_sound((t) => Math.sin(2 * Math.PI * t * freq), duration); +} + +/** + * Makes a square wave sound with given frequency and duration + * + * @param freq the frequency of the square wave sound + * @param duration the duration of the square wave sound + * @return resulting square wave sound + * @example square_sound(440, 5); + */ +export function square_sound(f: number, duration: number): Sound { + function fourier_expansion_square(t: number) { + let answer = 0; + for (let i = 1; i <= fourier_expansion_level; i += 1) { + answer += Math.sin(2 * Math.PI * (2 * i - 1) * f * t) / (2 * i - 1); + } + return answer; + } + return make_sound( + (t) => (4 / Math.PI) * fourier_expansion_square(t), + duration, + ); +} + +/** + * Makes a triangle wave sound with given frequency and duration + * + * @param freq the frequency of the triangle wave sound + * @param duration the duration of the triangle wave sound + * @return resulting triangle wave sound + * @example triangle_sound(440, 5); + */ +export function triangle_sound(freq: number, duration: number): Sound { + function fourier_expansion_triangle(t: number) { + let answer = 0; + for (let i = 0; i < fourier_expansion_level; i += 1) { + answer + += ((-1) ** i * Math.sin((2 * i + 1) * t * freq * Math.PI * 2)) + / (2 * i + 1) ** 2; + } + return answer; + } + return make_sound( + (t) => (8 / Math.PI / Math.PI) * fourier_expansion_triangle(t), + duration, + ); +} + +/** + * Makes a sawtooth wave sound with given frequency and duration + * + * @param freq the frequency of the sawtooth wave sound + * @param duration the duration of the sawtooth wave sound + * @return resulting sawtooth wave sound + * @example sawtooth_sound(440, 5); + */ +export function sawtooth_sound(freq: number, duration: number): Sound { + function fourier_expansion_sawtooth(t: number) { + let answer = 0; + for (let i = 1; i <= fourier_expansion_level; i += 1) { + answer += Math.sin(2 * Math.PI * i * freq * t) / i; + } + return answer; + } + return make_sound( + (t) => 1 / 2 - (1 / Math.PI) * fourier_expansion_sawtooth(t), + duration, + ); +} + +// Composition Operators + +/** + * Makes a new Sound by combining the sounds in a given list + * where the second sound is appended to the end of the first sound, + * the third sound is appended to the end of the second sound, and + * so on. The effect is that the sounds in the list are joined end-to-end + * + * @param list_of_sounds given list of sounds + * @return the combined Sound + * @example consecutively(list(sine_sound(200, 2), sine_sound(400, 3))); + */ +export function consecutively(list_of_sounds: List): Sound { + function stereo_cons_two(sound1: Sound, sound2: Sound) { + const Lwave1 = get_left_wave(sound1); + const Rwave1 = get_right_wave(sound1); + const Lwave2 = get_left_wave(sound2); + const Rwave2 = get_right_wave(sound2); + const dur1 = get_duration(sound1); + const dur2 = get_duration(sound2); + const new_left = (t: number) => (t < dur1 ? Lwave1(t) : Lwave2(t - dur1)); + const new_right = (t: number) => (t < dur1 ? Rwave1(t) : Rwave2(t - dur1)); + return make_stereo_sound(new_left, new_right, dur1 + dur2); + } + return accumulate(stereo_cons_two, silence_sound(0), list_of_sounds); +} + +/** + * Makes a new Sound by combining the sounds in a given list + * where all the sounds are overlapped on top of each other. + * + * @param list_of_sounds given list of sounds + * @return the combined Sound + * @example simultaneously(list(sine_sound(200, 2), sine_sound(400, 3))) + */ +export function simultaneously(list_of_sounds: List): Sound { + function stereo_simul_two(sound1: Sound, sound2: Sound) { + const Lwave1 = get_left_wave(sound1); + const Rwave1 = get_right_wave(sound1); + const Lwave2 = get_left_wave(sound2); + const Rwave2 = get_right_wave(sound2); + const dur1 = get_duration(sound1); + const dur2 = get_duration(sound2); + const new_left = (t: number) => Lwave1(t) + Lwave2(t); + const new_right = (t: number) => Rwave1(t) + Rwave2(t); + const new_dur = dur1 < dur2 ? dur2 : dur1; + return make_stereo_sound(new_left, new_right, new_dur); + } + + const unnormed = accumulate( + stereo_simul_two, + silence_sound(0), + list_of_sounds, + ); + const sounds_length = length(list_of_sounds); + const normalised_left = (t: number) => head(head(unnormed))(t) / sounds_length; + const normalised_right = (t: number) => tail(head(unnormed))(t) / sounds_length; + const highest_duration = tail(unnormed); + return make_stereo_sound(normalised_left, normalised_right, highest_duration); +} + +/** + * Returns an envelope: a function from Sound to Sound. + * When the adsr envelope is applied to a Sound, it returns + * a new Sound with its amplitude modified according to parameters + * The relative amplitude increases from 0 to 1 linearly over the + * attack proportion, then decreases from 1 to sustain level over the + * decay proportion, and remains at that level until the release + * proportion when it decays back to 0. + * @param attack_ratio proportion of Sound in attack phase + * @param decay_ratio proportion of Sound decay phase + * @param sustain_level sustain level between 0 and 1 + * @param release_ratio proportion of Sound in release phase + * @return Envelope a function from Sound to Sound + * @example adsr(0.2, 0.3, 0.3, 0.1)(sound); + */ +export function adsr( + attack_ratio: number, + decay_ratio: number, + sustain_level: number, + release_ratio: number, +): SoundTransformer { + return (sound) => { + const Lwave = get_left_wave(sound); + const Rwave = get_right_wave(sound); + const duration = get_duration(sound); + const attack_time = duration * attack_ratio; + const decay_time = duration * decay_ratio; + const release_time = duration * release_ratio; + + function adsrHelper(wave: Wave) { + return (x: number) => { + if (x < attack_time) { + return wave(x) * (x / attack_time); + } + if (x < attack_time + decay_time) { + return ( + ((1 - sustain_level) * linear_decay(decay_time)(x - attack_time) + + sustain_level) + * wave(x) + ); + } + if (x < duration - release_time) { + return wave(x) * sustain_level; + } + return ( + wave(x) + * sustain_level + * linear_decay(release_time)(x - (duration - release_time)) + ); + }; + } + return make_stereo_sound(adsrHelper(Lwave), adsrHelper(Rwave), duration); + }; +} + +/** + * Returns a Sound that results from applying a list of envelopes + * to a given wave form. The wave form is a Sound generator that + * takes a frequency and a duration as arguments and produces a + * Sound with the given frequency and duration. Each envelope is + * applied to a harmonic: the first harmonic has the given frequency, + * the second has twice the frequency, the third three times the + * frequency etc. The harmonics are then layered simultaneously to + * produce the resulting Sound. + * @param waveform function from pair(frequency, duration) to Sound + * @param base_frequency frequency of the first harmonic + * @param duration duration of the produced Sound, in seconds + * @param envelopes – list of envelopes, which are functions from Sound to Sound + * @return Sound resulting Sound + * @example stacking_adsr(sine_sound, 300, 5, list(adsr(0.1, 0.3, 0.2, 0.5), adsr(0.2, 0.5, 0.6, 0.1), adsr(0.3, 0.1, 0.7, 0.3))); + */ +export function stacking_adsr( + waveform: SoundProducer, + base_frequency: number, + duration: number, + envelopes: List, +): Sound { + function zip(lst: List, n: number) { + if (is_null(lst)) { + return lst; + } + return pair(pair(n, head(lst)), zip(tail(lst), n + 1)); + } + + return simultaneously( + accumulate( + (x: any, y: any) => pair(tail(x)(waveform(base_frequency * head(x), duration)), y), + null, + zip(envelopes, 1), + ), + ); +} + +/** + * Returns a SoundTransformer which uses its argument + * to modulate the phase of a (carrier) sine wave + * of given frequency and duration with a given Sound. + * Modulating with a low frequency Sound results in a vibrato effect. + * Modulating with a Sound with frequencies comparable to + * the sine wave frequency results in more complex wave forms. + * + * @param freq the frequency of the sine wave to be modulated + * @param duration the duration of the output soud + * @param amount the amount of modulation to apply to the carrier sine wave + * @return function which takes in a Sound and returns a Sound + * @example phase_mod(440, 5, 1)(sine_sound(220, 5)); + */ +export function phase_mod( + freq: number, + duration: number, + amount: number, +): SoundTransformer { + return (modulator: Sound) => make_stereo_sound( + (t) => Math.sin(2 * Math.PI * t * freq + amount * get_left_wave(modulator)(t)), + (t) => Math.sin( + 2 * Math.PI * t * freq + amount * get_right_wave(modulator)(t), + ), + duration, + ); +} + +// MIDI conversion functions + +/** + * Converts a letter name to its corresponding MIDI note. + * The letter name is represented in standard pitch notation. + * Examples are "A5", "Db3", "C#7". + * Refer to
    this mapping from + * letter name to midi notes. + * + * @param letter_name given letter name + * @return the corresponding midi note + * @example letter_name_to_midi_note("C4"); // Returns 60 + */ +export function letter_name_to_midi_note(note: string): number { + let res = 12; // C0 is midi note 12 + const n = note[0].toUpperCase(); + switch (n) { + case 'D': + res += 2; + break; + + case 'E': + res += 4; + break; + + case 'F': + res += 5; + break; + + case 'G': + res += 7; + break; + + case 'A': + res += 9; + break; + + case 'B': + res += 11; + break; + + default: + break; + } + + if (note.length === 2) { + res += parseInt(note[1]) * 12; + } else if (note.length === 3) { + switch (note[1]) { + case '#': + res += 1; + break; + + case 'b': + res -= 1; + break; + + default: + break; + } + res += parseInt(note[2]) * 12; + } + return res; +} + +/** + * Converts a MIDI note to its corresponding frequency. + * + * @param note given MIDI note + * @return the frequency of the MIDI note + * @example midi_note_to_frequency(69); // Returns 440 + */ +export function midi_note_to_frequency(note: number): number { + // A4 = 440Hz = midi note 69 + return 440 * 2 ** ((note - 69) / 12); +} + +/** + * Converts a letter name to its corresponding frequency. + * + * @param letter_name given letter name + * @return the corresponding frequency + * @example letter_name_to_frequency("A4"); // Returns 440 + */ +export function letter_name_to_frequency(note: string): number { + return midi_note_to_frequency(letter_name_to_midi_note(note)); +} + +// Instruments + +/** + * returns a Sound reminiscent of a bell, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting bell Sound with given pitch and duration + * @example bell(40, 1); + */ +export function bell(note: number, duration: number): Sound { + return stacking_adsr( + square_sound, + midi_note_to_frequency(note), + duration, + list( + adsr(0, 0.6, 0, 0.05), + adsr(0, 0.6618, 0, 0.05), + adsr(0, 0.7618, 0, 0.05), + adsr(0, 0.9071, 0, 0.05), + ), + ); +} + +/** + * returns a Sound reminiscent of a cello, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting cello Sound with given pitch and duration + * @example cello(36, 5); + */ +export function cello(note: number, duration: number): Sound { + return stacking_adsr( + square_sound, + midi_note_to_frequency(note), + duration, + list(adsr(0.05, 0, 1, 0.1), adsr(0.05, 0, 1, 0.15), adsr(0, 0, 0.2, 0.15)), + ); +} + +/** + * returns a Sound reminiscent of a piano, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting piano Sound with given pitch and duration + * @example piano(48, 5); + */ +export function piano(note: number, duration: number): Sound { + return stacking_adsr( + triangle_sound, + midi_note_to_frequency(note), + duration, + list(adsr(0, 0.515, 0, 0.05), adsr(0, 0.32, 0, 0.05), adsr(0, 0.2, 0, 0.05)), + ); +} + +/** + * returns a Sound reminiscent of a trombone, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting trombone Sound with given pitch and duration + * @example trombone(60, 2); + */ +export function trombone(note: number, duration: number): Sound { + return stacking_adsr( + square_sound, + midi_note_to_frequency(note), + duration, + list(adsr(0.2, 0, 1, 0.1), adsr(0.3236, 0.6, 0, 0.1)), + ); +} + +/** + * returns a Sound reminiscent of a violin, playing + * a given note for a given duration + * @param note MIDI note + * @param duration duration in seconds + * @return Sound resulting violin Sound with given pitch and duration + * @example violin(53, 4); + */ +export function violin(note: number, duration: number): Sound { + return stacking_adsr( + sawtooth_sound, + midi_note_to_frequency(note), + duration, + list( + adsr(0.35, 0, 1, 0.15), + adsr(0.35, 0, 1, 0.15), + adsr(0.45, 0, 1, 0.15), + adsr(0.45, 0, 1, 0.15), + ), + ); +} diff --git a/src/bundles/stereo_sound/index.ts b/src/bundles/stereo_sound/index.ts index 720d4bd94..7f5c1ed59 100644 --- a/src/bundles/stereo_sound/index.ts +++ b/src/bundles/stereo_sound/index.ts @@ -1,116 +1,116 @@ -import { ModuleContext } from 'js-slang'; -import { - // Constructor/Accessors/Typecheck - make_stereo_sound, - make_sound, - get_left_wave, - get_right_wave, - get_duration, - is_sound, - squash, - pan, - pan_mod, - // Play-related - play, - play_wave, - play_waves, - play_concurrently, - stop, - // Recording - init_record, - record, - record_for, - // Composition and Envelopes - consecutively, - simultaneously, - phase_mod, - adsr, - stacking_adsr, - // Basic waveforms - noise_sound, - silence_sound, - sine_sound, - sawtooth_sound, - triangle_sound, - square_sound, - // MIDI - letter_name_to_midi_note, - midi_note_to_frequency, - letter_name_to_frequency, - // Instruments - bell, - cello, - piano, - trombone, - violin, - audioPlayed, -} from './functions'; -import { StereoSoundsModuleState } from './types'; - -export default function sounds(params, contexts: Map) { - // Update the module's global context - let moduleContext = contexts.get('stereo_sound'); - - if (!moduleContext) { - moduleContext = { - tabs: [], - state: { - audioPlayed, - }, - }; - - contexts.set('stereo_sound', moduleContext); - } else if (!moduleContext.state) { - moduleContext.state = { - audioPlayed, - }; - } else { - (moduleContext.state as StereoSoundsModuleState).audioPlayed = audioPlayed; - } - - return { - // Constructor/Accessors/Typecheck - make_stereo_sound, - make_sound, - get_left_wave, - get_right_wave, - get_duration, - is_sound, - squash, - pan, - pan_mod, - // Play-related - play, - play_wave, - play_waves, - play_concurrently, - stop, - // Recording - init_record, - record, - record_for, - // Composition and Envelopes - consecutively, - simultaneously, - phase_mod, - adsr, - stacking_adsr, - // Basic waveforms - noise_sound, - silence_sound, - sine_sound, - sawtooth_sound, - triangle_sound, - square_sound, - // MIDI - letter_name_to_midi_note, - midi_note_to_frequency, - letter_name_to_frequency, - // Instruments - bell, - cello, - piano, - trombone, - violin, - }; -} +import { ModuleContext } from 'js-slang'; +import { + // Constructor/Accessors/Typecheck + make_stereo_sound, + make_sound, + get_left_wave, + get_right_wave, + get_duration, + is_sound, + squash, + pan, + pan_mod, + // Play-related + play, + play_wave, + play_waves, + play_concurrently, + stop, + // Recording + init_record, + record, + record_for, + // Composition and Envelopes + consecutively, + simultaneously, + phase_mod, + adsr, + stacking_adsr, + // Basic waveforms + noise_sound, + silence_sound, + sine_sound, + sawtooth_sound, + triangle_sound, + square_sound, + // MIDI + letter_name_to_midi_note, + midi_note_to_frequency, + letter_name_to_frequency, + // Instruments + bell, + cello, + piano, + trombone, + violin, + audioPlayed, +} from './functions'; +import { StereoSoundsModuleState } from './types'; + +export default function sounds(params, contexts: Map) { + // Update the module's global context + let moduleContext = contexts.get('stereo_sound'); + + if (!moduleContext) { + moduleContext = { + tabs: [], + state: { + audioPlayed, + }, + }; + + contexts.set('stereo_sound', moduleContext); + } else if (!moduleContext.state) { + moduleContext.state = { + audioPlayed, + }; + } else { + (moduleContext.state as StereoSoundsModuleState).audioPlayed = audioPlayed; + } + + return { + // Constructor/Accessors/Typecheck + make_stereo_sound, + make_sound, + get_left_wave, + get_right_wave, + get_duration, + is_sound, + squash, + pan, + pan_mod, + // Play-related + play, + play_wave, + play_waves, + play_concurrently, + stop, + // Recording + init_record, + record, + record_for, + // Composition and Envelopes + consecutively, + simultaneously, + phase_mod, + adsr, + stacking_adsr, + // Basic waveforms + noise_sound, + silence_sound, + sine_sound, + sawtooth_sound, + triangle_sound, + square_sound, + // MIDI + letter_name_to_midi_note, + midi_note_to_frequency, + letter_name_to_frequency, + // Instruments + bell, + cello, + piano, + trombone, + violin, + }; +} diff --git a/src/bundles/stereo_sound/list.ts b/src/bundles/stereo_sound/list.ts index 4b0c22596..13f48cfdd 100644 --- a/src/bundles/stereo_sound/list.ts +++ b/src/bundles/stereo_sound/list.ts @@ -1,372 +1,372 @@ -/* eslint-disable no-else-return, no-lonely-if, operator-assignment, prefer-template */ - -// list.js: Supporting lists in the Scheme style, using pairs made -// up of two-element JavaScript array (vector) - -// Author: Martin Henz - -// Note: this library is used in the externalLibs of cadet-frontend. -// It is distinct from the LISTS library of Source §2, which contains -// primitive and predeclared functions from Chapter 2 of SICP JS. - -// array test works differently for Rhino and -// the Firefox environment (especially Web Console) -export function array_test(x): boolean { - if (Array.isArray === undefined) { - return x instanceof Array; - } else { - return Array.isArray(x); - } -} - -// pair constructs a pair using a two-element array -// LOW-LEVEL FUNCTION, NOT SOURCE -export function pair(x, xs): [any, any] { - return [x, xs]; -} - -// is_pair returns true iff arg is a two-element array -// LOW-LEVEL FUNCTION, NOT SOURCE -export function is_pair(x): boolean { - return array_test(x) && x.length === 2; -} - -// head returns the first component of the given pair, -// throws an exception if the argument is not a pair -// LOW-LEVEL FUNCTION, NOT SOURCE -export function head(xs): any { - if (is_pair(xs)) { - return xs[0]; - } else { - throw new Error( - 'head(xs) expects a pair as argument xs, but encountered ' + xs - ); - } -} - -// tail returns the second component of the given pair -// throws an exception if the argument is not a pair -// LOW-LEVEL FUNCTION, NOT SOURCE -export function tail(xs) { - if (is_pair(xs)) { - return xs[1]; - } else { - throw new Error( - 'tail(xs) expects a pair as argument xs, but encountered ' + xs - ); - } -} - -// is_null returns true if arg is exactly null -// LOW-LEVEL FUNCTION, NOT SOURCE -export function is_null(xs) { - return xs === null; -} - -// is_list recurses down the list and checks that it ends with the empty list [] -// does not throw Value exceptions -// LOW-LEVEL FUNCTION, NOT SOURCE -export function is_list(xs) { - for (; ; xs = tail(xs)) { - if (is_null(xs)) { - return true; - } else if (!is_pair(xs)) { - return false; - } - } -} - -// list makes a list out of its arguments -// LOW-LEVEL FUNCTION, NOT SOURCE -export function list(...args) { - let the_list: any = null; - for (let i = args.length - 1; i >= 0; i--) { - the_list = pair(args[i], the_list); - } - return the_list; -} - -// list_to_vector returns vector that contains the elements of the argument list -// in the given order. -// list_to_vector throws an exception if the argument is not a list -// LOW-LEVEL FUNCTION, NOT SOURCE -export function list_to_vector(lst) { - const vector: any[] = []; - while (!is_null(lst)) { - vector.push(head(lst)); - lst = tail(lst); - } - return vector; -} - -// vector_to_list returns a list that contains the elements of the argument vector -// in the given order. -// vector_to_list throws an exception if the argument is not a vector -// LOW-LEVEL FUNCTION, NOT SOURCE -export function vector_to_list(vector) { - let result: any = null; - for (let i = vector.length - 1; i >= 0; i = i - 1) { - result = pair(vector[i], result); - } - return result; -} - -// returns the length of a given argument list -// throws an exception if the argument is not a list -export function length(xs) { - let i = 0; - while (!is_null(xs)) { - i += 1; - xs = tail(xs); - } - return i; -} - -// map applies first arg f to the elements of the second argument, -// assumed to be a list. -// f is applied element-by-element: -// map(f,[1,[2,[]]]) results in [f(1),[f(2),[]]] -// map throws an exception if the second argument is not a list, -// and if the second argument is a non-empty list and the first -// argument is not a function. -// tslint:disable-next-line:ban-types -export function map(f, xs) { - return is_null(xs) ? null : pair(f(head(xs)), map(f, tail(xs))); -} - -// build_list takes a non-negative integer n as first argument, -// and a function fun as second argument. -// build_list returns a list of n elements, that results from -// applying fun to the numbers from 0 to n-1. -// tslint:disable-next-line:ban-types -export function build_list(n, fun) { - if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { - throw new Error( - 'build_list(n, fun) expects a positive integer as ' + - 'argument n, but encountered ' + - n - ); - } - - // tslint:disable-next-line:ban-types - function build(i, alreadyBuilt) { - if (i < 0) { - return alreadyBuilt; - } else { - return build(i - 1, pair(fun(i), alreadyBuilt)); - } - } - - return build(n - 1, null); -} - -// for_each applies first arg fun to the elements of the list passed as -// second argument. fun is applied element-by-element: -// for_each(fun,[1,[2,[]]]) results in the calls fun(1) and fun(2). -// for_each returns true. -// for_each throws an exception if the second argument is not a list, -// and if the second argument is a non-empty list and the -// first argument is not a function. -// tslint:disable-next-line:ban-types -export function for_each(fun, xs) { - if (!is_list(xs)) { - throw new Error( - 'for_each expects a list as argument xs, but encountered ' + xs - ); - } - for (; !is_null(xs); xs = tail(xs)) { - fun(head(xs)); - } - return true; -} - -// reverse reverses the argument list -// reverse throws an exception if the argument is not a list. -export function reverse(xs) { - if (!is_list(xs)) { - throw new Error( - 'reverse(xs) expects a list as argument xs, but encountered ' + xs - ); - } - let result: any = null; - for (; !is_null(xs); xs = tail(xs)) { - result = pair(head(xs), result); - } - return result; -} - -// append first argument list and second argument list. -// In the result, the [] at the end of the first argument list -// is replaced by the second argument list -// append throws an exception if the first argument is not a list -export function append(xs, ys) { - if (is_null(xs)) { - return ys; - } else { - return pair(head(xs), append(tail(xs), ys)); - } -} - -// member looks for a given first-argument element in a given -// second argument list. It returns the first postfix sublist -// that starts with the given element. It returns [] if the -// element does not occur in the list -export function member(v, xs) { - for (; !is_null(xs); xs = tail(xs)) { - if (head(xs) === v) { - return xs; - } - } - return null; -} - -// removes the first occurrence of a given first-argument element -// in a given second-argument list. Returns the original list -// if there is no occurrence. -export function remove(v, xs) { - if (is_null(xs)) { - return null; - } else { - if (v === head(xs)) { - return tail(xs); - } else { - return pair(head(xs), remove(v, tail(xs))); - } - } -} - -// Similar to remove. But removes all instances of v instead of just the first -export function remove_all(v, xs) { - if (is_null(xs)) { - return null; - } else { - if (v === head(xs)) { - return remove_all(v, tail(xs)); - } else { - return pair(head(xs), remove_all(v, tail(xs))); - } - } -} - -// for backwards-compatibility -// equal computes the structural equality -// over its arguments -export function equal(item1, item2) { - if (is_pair(item1) && is_pair(item2)) { - return equal(head(item1), head(item2)) && equal(tail(item1), tail(item2)); - } else { - return item1 === item2; - } -} - -// assoc treats the second argument as an association, -// a list of (index,value) pairs. -// assoc returns the first (index,value) pair whose -// index equal (using structural equality) to the given -// first argument v. Returns false if there is no such -// pair -export function assoc(v, xs) { - if (is_null(xs)) { - return false; - } else if (equal(v, head(head(xs)))) { - return head(xs); - } else { - return assoc(v, tail(xs)); - } -} - -// filter returns the sublist of elements of given list xs -// for which the given predicate function returns true. -// tslint:disable-next-line:ban-types -export function filter(pred, xs) { - if (is_null(xs)) { - return xs; - } else { - if (pred(head(xs))) { - return pair(head(xs), filter(pred, tail(xs))); - } else { - return filter(pred, tail(xs)); - } - } -} - -// enumerates numbers starting from start, -// using a step size of 1, until the number -// exceeds end. -export function enum_list(start, end) { - if (typeof start !== 'number') { - throw new Error( - 'enum_list(start, end) expects a number as argument start, but encountered ' + - start - ); - } - if (typeof end !== 'number') { - throw new Error( - 'enum_list(start, end) expects a number as argument start, but encountered ' + - end - ); - } - if (start > end) { - return null; - } else { - return pair(start, enum_list(start + 1, end)); - } -} - -// Returns the item in list lst at index n (the first item is at position 0) -export function list_ref(xs, n) { - if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { - throw new Error( - 'list_ref(xs, n) expects a positive integer as argument n, but encountered ' + - n - ); - } - for (; n > 0; --n) { - xs = tail(xs); - } - return head(xs); -} - -// accumulate applies given operation op to elements of a list -// in a right-to-left order, first apply op to the last element -// and an initial element, resulting in r1, then to the -// second-last element and r1, resulting in r2, etc, and finally -// to the first element and r_n-1, where n is the length of the -// list. -// accumulate(op,zero,list(1,2,3)) results in -// op(1, op(2, op(3, zero))) -export function accumulate(op, initial, sequence) { - if (is_null(sequence)) { - return initial; - } else { - return op(head(sequence), accumulate(op, initial, tail(sequence))); - } -} - -// set_head(xs,x) changes the head of given pair xs to be x, -// throws an exception if the argument is not a pair -// LOW-LEVEL FUNCTION, NOT SOURCE -export function set_head(xs, x) { - if (is_pair(xs)) { - xs[0] = x; - return undefined; - } else { - throw new Error( - 'set_head(xs,x) expects a pair as argument xs, but encountered ' + xs - ); - } -} - -// set_tail(xs,x) changes the tail of given pair xs to be x, -// throws an exception if the argument is not a pair -// LOW-LEVEL FUNCTION, NOT SOURCE -export function set_tail(xs, x) { - if (is_pair(xs)) { - xs[1] = x; - return undefined; - } else { - throw new Error( - 'set_tail(xs,x) expects a pair as argument xs, but encountered ' + xs - ); - } -} +/* eslint-disable no-else-return, no-lonely-if, operator-assignment, prefer-template */ + +// list.js: Supporting lists in the Scheme style, using pairs made +// up of two-element JavaScript array (vector) + +// Author: Martin Henz + +// Note: this library is used in the externalLibs of cadet-frontend. +// It is distinct from the LISTS library of Source §2, which contains +// primitive and predeclared functions from Chapter 2 of SICP JS. + +// array test works differently for Rhino and +// the Firefox environment (especially Web Console) +export function array_test(x): boolean { + if (Array.isArray === undefined) { + return x instanceof Array; + } else { + return Array.isArray(x); + } +} + +// pair constructs a pair using a two-element array +// LOW-LEVEL FUNCTION, NOT SOURCE +export function pair(x, xs): [any, any] { + return [x, xs]; +} + +// is_pair returns true iff arg is a two-element array +// LOW-LEVEL FUNCTION, NOT SOURCE +export function is_pair(x): boolean { + return array_test(x) && x.length === 2; +} + +// head returns the first component of the given pair, +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function head(xs): any { + if (is_pair(xs)) { + return xs[0]; + } else { + throw new Error( + 'head(xs) expects a pair as argument xs, but encountered ' + xs, + ); + } +} + +// tail returns the second component of the given pair +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function tail(xs) { + if (is_pair(xs)) { + return xs[1]; + } else { + throw new Error( + 'tail(xs) expects a pair as argument xs, but encountered ' + xs, + ); + } +} + +// is_null returns true if arg is exactly null +// LOW-LEVEL FUNCTION, NOT SOURCE +export function is_null(xs) { + return xs === null; +} + +// is_list recurses down the list and checks that it ends with the empty list [] +// does not throw Value exceptions +// LOW-LEVEL FUNCTION, NOT SOURCE +export function is_list(xs) { + for (; ; xs = tail(xs)) { + if (is_null(xs)) { + return true; + } else if (!is_pair(xs)) { + return false; + } + } +} + +// list makes a list out of its arguments +// LOW-LEVEL FUNCTION, NOT SOURCE +export function list(...args) { + let the_list: any = null; + for (let i = args.length - 1; i >= 0; i--) { + the_list = pair(args[i], the_list); + } + return the_list; +} + +// list_to_vector returns vector that contains the elements of the argument list +// in the given order. +// list_to_vector throws an exception if the argument is not a list +// LOW-LEVEL FUNCTION, NOT SOURCE +export function list_to_vector(lst) { + const vector: any[] = []; + while (!is_null(lst)) { + vector.push(head(lst)); + lst = tail(lst); + } + return vector; +} + +// vector_to_list returns a list that contains the elements of the argument vector +// in the given order. +// vector_to_list throws an exception if the argument is not a vector +// LOW-LEVEL FUNCTION, NOT SOURCE +export function vector_to_list(vector) { + let result: any = null; + for (let i = vector.length - 1; i >= 0; i = i - 1) { + result = pair(vector[i], result); + } + return result; +} + +// returns the length of a given argument list +// throws an exception if the argument is not a list +export function length(xs) { + let i = 0; + while (!is_null(xs)) { + i += 1; + xs = tail(xs); + } + return i; +} + +// map applies first arg f to the elements of the second argument, +// assumed to be a list. +// f is applied element-by-element: +// map(f,[1,[2,[]]]) results in [f(1),[f(2),[]]] +// map throws an exception if the second argument is not a list, +// and if the second argument is a non-empty list and the first +// argument is not a function. +// tslint:disable-next-line:ban-types +export function map(f, xs) { + return is_null(xs) ? null : pair(f(head(xs)), map(f, tail(xs))); +} + +// build_list takes a non-negative integer n as first argument, +// and a function fun as second argument. +// build_list returns a list of n elements, that results from +// applying fun to the numbers from 0 to n-1. +// tslint:disable-next-line:ban-types +export function build_list(n, fun) { + if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { + throw new Error( + 'build_list(n, fun) expects a positive integer as ' + + 'argument n, but encountered ' + + n, + ); + } + + // tslint:disable-next-line:ban-types + function build(i, alreadyBuilt) { + if (i < 0) { + return alreadyBuilt; + } else { + return build(i - 1, pair(fun(i), alreadyBuilt)); + } + } + + return build(n - 1, null); +} + +// for_each applies first arg fun to the elements of the list passed as +// second argument. fun is applied element-by-element: +// for_each(fun,[1,[2,[]]]) results in the calls fun(1) and fun(2). +// for_each returns true. +// for_each throws an exception if the second argument is not a list, +// and if the second argument is a non-empty list and the +// first argument is not a function. +// tslint:disable-next-line:ban-types +export function for_each(fun, xs) { + if (!is_list(xs)) { + throw new Error( + 'for_each expects a list as argument xs, but encountered ' + xs, + ); + } + for (; !is_null(xs); xs = tail(xs)) { + fun(head(xs)); + } + return true; +} + +// reverse reverses the argument list +// reverse throws an exception if the argument is not a list. +export function reverse(xs) { + if (!is_list(xs)) { + throw new Error( + 'reverse(xs) expects a list as argument xs, but encountered ' + xs, + ); + } + let result: any = null; + for (; !is_null(xs); xs = tail(xs)) { + result = pair(head(xs), result); + } + return result; +} + +// append first argument list and second argument list. +// In the result, the [] at the end of the first argument list +// is replaced by the second argument list +// append throws an exception if the first argument is not a list +export function append(xs, ys) { + if (is_null(xs)) { + return ys; + } else { + return pair(head(xs), append(tail(xs), ys)); + } +} + +// member looks for a given first-argument element in a given +// second argument list. It returns the first postfix sublist +// that starts with the given element. It returns [] if the +// element does not occur in the list +export function member(v, xs) { + for (; !is_null(xs); xs = tail(xs)) { + if (head(xs) === v) { + return xs; + } + } + return null; +} + +// removes the first occurrence of a given first-argument element +// in a given second-argument list. Returns the original list +// if there is no occurrence. +export function remove(v, xs) { + if (is_null(xs)) { + return null; + } else { + if (v === head(xs)) { + return tail(xs); + } else { + return pair(head(xs), remove(v, tail(xs))); + } + } +} + +// Similar to remove. But removes all instances of v instead of just the first +export function remove_all(v, xs) { + if (is_null(xs)) { + return null; + } else { + if (v === head(xs)) { + return remove_all(v, tail(xs)); + } else { + return pair(head(xs), remove_all(v, tail(xs))); + } + } +} + +// for backwards-compatibility +// equal computes the structural equality +// over its arguments +export function equal(item1, item2) { + if (is_pair(item1) && is_pair(item2)) { + return equal(head(item1), head(item2)) && equal(tail(item1), tail(item2)); + } else { + return item1 === item2; + } +} + +// assoc treats the second argument as an association, +// a list of (index,value) pairs. +// assoc returns the first (index,value) pair whose +// index equal (using structural equality) to the given +// first argument v. Returns false if there is no such +// pair +export function assoc(v, xs) { + if (is_null(xs)) { + return false; + } else if (equal(v, head(head(xs)))) { + return head(xs); + } else { + return assoc(v, tail(xs)); + } +} + +// filter returns the sublist of elements of given list xs +// for which the given predicate function returns true. +// tslint:disable-next-line:ban-types +export function filter(pred, xs) { + if (is_null(xs)) { + return xs; + } else { + if (pred(head(xs))) { + return pair(head(xs), filter(pred, tail(xs))); + } else { + return filter(pred, tail(xs)); + } + } +} + +// enumerates numbers starting from start, +// using a step size of 1, until the number +// exceeds end. +export function enum_list(start, end) { + if (typeof start !== 'number') { + throw new Error( + 'enum_list(start, end) expects a number as argument start, but encountered ' + + start, + ); + } + if (typeof end !== 'number') { + throw new Error( + 'enum_list(start, end) expects a number as argument start, but encountered ' + + end, + ); + } + if (start > end) { + return null; + } else { + return pair(start, enum_list(start + 1, end)); + } +} + +// Returns the item in list lst at index n (the first item is at position 0) +export function list_ref(xs, n) { + if (typeof n !== 'number' || n < 0 || Math.floor(n) !== n) { + throw new Error( + 'list_ref(xs, n) expects a positive integer as argument n, but encountered ' + + n, + ); + } + for (; n > 0; --n) { + xs = tail(xs); + } + return head(xs); +} + +// accumulate applies given operation op to elements of a list +// in a right-to-left order, first apply op to the last element +// and an initial element, resulting in r1, then to the +// second-last element and r1, resulting in r2, etc, and finally +// to the first element and r_n-1, where n is the length of the +// list. +// accumulate(op,zero,list(1,2,3)) results in +// op(1, op(2, op(3, zero))) +export function accumulate(op, initial, sequence) { + if (is_null(sequence)) { + return initial; + } else { + return op(head(sequence), accumulate(op, initial, tail(sequence))); + } +} + +// set_head(xs,x) changes the head of given pair xs to be x, +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function set_head(xs, x) { + if (is_pair(xs)) { + xs[0] = x; + return undefined; + } else { + throw new Error( + 'set_head(xs,x) expects a pair as argument xs, but encountered ' + xs, + ); + } +} + +// set_tail(xs,x) changes the tail of given pair xs to be x, +// throws an exception if the argument is not a pair +// LOW-LEVEL FUNCTION, NOT SOURCE +export function set_tail(xs, x) { + if (is_pair(xs)) { + xs[1] = x; + return undefined; + } else { + throw new Error( + 'set_tail(xs,x) expects a pair as argument xs, but encountered ' + xs, + ); + } +} diff --git a/src/bundles/stereo_sound/riffwave.ts b/src/bundles/stereo_sound/riffwave.ts index 70a1af751..478588146 100644 --- a/src/bundles/stereo_sound/riffwave.ts +++ b/src/bundles/stereo_sound/riffwave.ts @@ -1,22 +1,22 @@ -/* - * RIFFWAVE.js v0.03 - Audio encoder for HTML5

    This is a visualiser for stop and copy garbage collector. Check the guide{' '} - + here . @@ -258,46 +258,54 @@ class CopyGC extends React.Component {

    {state.command}

    {state.description}

    - {state.leftDesc ? ( -
    - - {state.leftDesc} -
    - ) : ( - false - )} - {state.rightDesc ? ( -
    - - {state.rightDesc} -
    - ) : ( - false - )} + {state.leftDesc + ? ( +
    + + {state.leftDesc} +
    + ) + : ( + false + )} + {state.rightDesc + ? ( +
    + + {state.rightDesc} +
    + ) + : ( + false + )}

    Current step: {' '} - + {' '} {state.value} {' '} - +

    {

    {state.toSpace === 0 ? 'To Space' : 'From Space'}

    - {toMemoryMatrix && - toMemoryMatrix.length > 0 && - toMemoryMatrix.map((item, row) => ( -
    + {toMemoryMatrix + && toMemoryMatrix.length > 0 + && toMemoryMatrix.map((item, row) => ( +
    {row * state.column} - {item && - item.length > 0 && - item.map((content) => { + {item + && item.length > 0 + && item.map((content) => { const color = this.getMemoryColor(content); const bgColor = this.getBackgroundColor(content); return ( @@ -349,41 +360,48 @@ class CopyGC extends React.Component {

    {state.toSpace > 0 ? 'To Space' : 'From Space'}

    - {fromMemoryMatrix && - fromMemoryMatrix.length > 0 && - fromMemoryMatrix.map((item, row) => ( -
    + {fromMemoryMatrix + && fromMemoryMatrix.length > 0 + && fromMemoryMatrix.map((item, row) => ( +
    {row * state.column + state.memorySize / 2} {item && item.length > 0 ? item.map((content) => { - const color = this.getMemoryColor(content); - const bgColor = this.getBackgroundColor(content); - return ( -
    + - -
    - ); - }) + /> +
    + ); + }) : false}
    ))}
    -
    +
    {

    This is a visualiser for stop and copy garbage collector. Check the guide{' '} - + here . diff --git a/src/tabs/CopyGc/style.tsx b/src/tabs/CopyGc/style.tsx index aa890ff50..454735823 100644 --- a/src/tabs/CopyGc/style.tsx +++ b/src/tabs/CopyGc/style.tsx @@ -1,13 +1,13 @@ -export enum ThemeColor { - BLUE = 'lightblue', - PINK = 'salmon', - GREY = '#707070', - GREEN = '#42a870', - YELLOW = '#f0d60e', - RED = 'red', - BLACK = 'black', -} - -export const FONT = { - SMALL: 10, -}; +export enum ThemeColor { + BLUE = 'lightblue', + PINK = 'salmon', + GREY = '#707070', + GREEN = '#42a870', + YELLOW = '#f0d60e', + RED = 'red', + BLACK = 'black', +} + +export const FONT = { + SMALL: 10, +}; diff --git a/src/tabs/Csg/canvas_holder.tsx b/src/tabs/Csg/canvas_holder.tsx index b30744ed0..4dd5ddd96 100644 --- a/src/tabs/Csg/canvas_holder.tsx +++ b/src/tabs/Csg/canvas_holder.tsx @@ -1,169 +1,169 @@ -/* [Imports] */ -// import { Spinner, SpinnerSize } from '@blueprintjs/core'; -import { Spinner, SpinnerSize } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import React from 'react'; -import { - BP_BORDER_RADIUS, - BP_TAB_BUTTON_MARGIN, - BP_TAB_PANEL_MARGIN, - STANDARD_MARGIN, -} from '../../bundles/csg/constants.js'; -import StatefulRenderer from '../../bundles/csg/stateful_renderer.js'; -import { RenderGroup } from '../../bundles/csg/utilities.js'; -import HoverControlHint from './hover_control_hint'; -import { CanvasHolderProps, CanvasHolderState } from './types'; - -/* [Main] */ -export default class CanvasHolder extends React.Component< - CanvasHolderProps, - CanvasHolderState -> { - private readonly canvasReference: React.RefObject = React.createRef(); - - private statefulRenderer: StatefulRenderer | null = null; - - constructor(props: CanvasHolderProps) { - super(props); - - this.state = { - contextLost: false, - }; - } - - componentDidMount() { - console.debug(`>>> MOUNT #${this.props.componentNumber}`); - - let { current: canvas } = this.canvasReference; - if (canvas === null) return; - - let renderGroups: RenderGroup[] = this.props.moduleState.renderGroupManager.getGroupsToRender(); - //TODO Issue #35 - let lastRenderGroup: RenderGroup = renderGroups.at(-1) as RenderGroup; - - this.statefulRenderer = new StatefulRenderer( - canvas, - lastRenderGroup, - this.props.componentNumber, - - () => this.setState({ contextLost: true }), - () => this.setState({ contextLost: false }) - ); - this.statefulRenderer.start(true); - } - - componentWillUnmount() { - console.debug(`>>> UNMOUNT #${this.props.componentNumber}`); - - this.statefulRenderer?.stop(true); - } - - // Only required method of a React Component. Returns a React Element created - // via JSX to instruct React to render a DOM node. Also attaches the - // canvasReference via the ref attribute, for imperatively modifying the - // canvas - render() { - return ( - <> -

    -
    - - - - - -
    - -
    - -
    -
    -
    -

    - WebGL Context Lost -

    - -

    - Your GPU is probably busy. Attempting to re-establish connection... -

    -
    - - ); - } -} +/* [Imports] */ +// import { Spinner, SpinnerSize } from '@blueprintjs/core'; +import { Spinner, SpinnerSize } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import React from 'react'; +import { + BP_BORDER_RADIUS, + BP_TAB_BUTTON_MARGIN, + BP_TAB_PANEL_MARGIN, + STANDARD_MARGIN, +} from '../../bundles/csg/constants.js'; +import StatefulRenderer from '../../bundles/csg/stateful_renderer.js'; +import { RenderGroup } from '../../bundles/csg/utilities.js'; +import HoverControlHint from './hover_control_hint'; +import { CanvasHolderProps, CanvasHolderState } from './types'; + +/* [Main] */ +export default class CanvasHolder extends React.Component< +CanvasHolderProps, +CanvasHolderState +> { + private readonly canvasReference: React.RefObject = React.createRef(); + + private statefulRenderer: StatefulRenderer | null = null; + + constructor(props: CanvasHolderProps) { + super(props); + + this.state = { + contextLost: false, + }; + } + + componentDidMount() { + console.debug(`>>> MOUNT #${this.props.componentNumber}`); + + let { current: canvas } = this.canvasReference; + if (canvas === null) return; + + let renderGroups: RenderGroup[] = this.props.moduleState.renderGroupManager.getGroupsToRender(); + //TODO Issue #35 + let lastRenderGroup: RenderGroup = renderGroups.at(-1) as RenderGroup; + + this.statefulRenderer = new StatefulRenderer( + canvas, + lastRenderGroup, + this.props.componentNumber, + + () => this.setState({ contextLost: true }), + () => this.setState({ contextLost: false }), + ); + this.statefulRenderer.start(true); + } + + componentWillUnmount() { + console.debug(`>>> UNMOUNT #${this.props.componentNumber}`); + + this.statefulRenderer?.stop(true); + } + + // Only required method of a React Component. Returns a React Element created + // via JSX to instruct React to render a DOM node. Also attaches the + // canvasReference via the ref attribute, for imperatively modifying the + // canvas + render() { + return ( + <> +
    +
    + + + + + +
    + +
    + +
    +
    +
    +

    + WebGL Context Lost +

    + +

    + Your GPU is probably busy. Attempting to re-establish connection... +

    +
    + + ); + } +} diff --git a/src/tabs/Csg/hover_control_hint.tsx b/src/tabs/Csg/hover_control_hint.tsx index d0d56456e..235dc1c8f 100644 --- a/src/tabs/Csg/hover_control_hint.tsx +++ b/src/tabs/Csg/hover_control_hint.tsx @@ -1,68 +1,68 @@ -/* [Imports] */ -import { Icon } from '@blueprintjs/core'; -import React from 'react'; -import { - BP_BORDER_RADIUS, - BP_ICON_COLOR, - BP_TOOLTIP_BACKGROUND_COLOR, - BP_TOOLTIP_PADDING, - BP_TOOLTIP_TEXT_COLOR, - SA_TAB_BUTTON_WIDTH, - SA_TAB_ICON_SIZE, -} from '../../bundles/csg/constants.js'; -import { HintProps, HintState } from './types'; - -/* [Main] */ - -// [CSS Values] -export default class HoverControlHint extends React.Component< - HintProps, - HintState -> { - constructor(props: HintProps) { - super(props); - - this.state = { - showTooltip: false, - }; - } - - render() { - return ( -
    this.setState({ showTooltip: true })} - onMouseLeave={() => this.setState({ showTooltip: false })} - > - - - {this.props.tooltipText} - -
    - ); - } -} +/* [Imports] */ +import { Icon } from '@blueprintjs/core'; +import React from 'react'; +import { + BP_BORDER_RADIUS, + BP_ICON_COLOR, + BP_TOOLTIP_BACKGROUND_COLOR, + BP_TOOLTIP_PADDING, + BP_TOOLTIP_TEXT_COLOR, + SA_TAB_BUTTON_WIDTH, + SA_TAB_ICON_SIZE, +} from '../../bundles/csg/constants.js'; +import { HintProps, HintState } from './types'; + +/* [Main] */ + +// [CSS Values] +export default class HoverControlHint extends React.Component< +HintProps, +HintState +> { + constructor(props: HintProps) { + super(props); + + this.state = { + showTooltip: false, + }; + } + + render() { + return ( +
    this.setState({ showTooltip: true })} + onMouseLeave={() => this.setState({ showTooltip: false })} + > + + + {this.props.tooltipText} + +
    + ); + } +} diff --git a/src/tabs/Csg/index.tsx b/src/tabs/Csg/index.tsx index 0b5544944..1a39c5f03 100644 --- a/src/tabs/Csg/index.tsx +++ b/src/tabs/Csg/index.tsx @@ -1,50 +1,50 @@ -/* [Imports] */ -import { IconNames } from '@blueprintjs/icons'; -import { ModuleContext, ModuleState } from 'js-slang'; -import React, { ReactElement } from 'react'; -import { Core } from '../../bundles/csg/core.js'; -import { - CsgModuleState, - getModuleContext, - looseInstanceof, -} from '../../bundles/csg/utilities.js'; -import { DebuggerContext, ModuleContexts } from '../../typings/type_helpers'; -import CanvasHolder from './canvas_holder'; - -/* [Exports] */ -export default { - // Called by the frontend to decide whether to spawn the CSG tab - toSpawn(_debuggerContext: DebuggerContext): boolean { - return Core.getRenderGroupManager().shouldRender(); - }, - - // Called by the frontend to know what to render in the CSG tab - body(debuggerContext: DebuggerContext): ReactElement { - let moduleContexts: ModuleContexts = debuggerContext.context.moduleContexts; - let potentialModuleContext: ModuleContext | null = getModuleContext( - moduleContexts - ); - if (potentialModuleContext === null) return
    ; - let moduleContext: ModuleContext = potentialModuleContext; - - let potentialModuleState: ModuleState | null | undefined = - moduleContext.state; - if (!looseInstanceof(potentialModuleState, CsgModuleState)) - return
    ; - let moduleState = potentialModuleState as CsgModuleState; - - Core.initialize(moduleState); - return ( - - ); - }, - - // BlueprintJS icon name - iconName: IconNames.SHAPES, - - // Icon tooltip in sidebar - label: 'CSG Tab', -}; +/* [Imports] */ +import { IconNames } from '@blueprintjs/icons'; +import { ModuleContext, ModuleState } from 'js-slang'; +import React, { ReactElement } from 'react'; +import { Core } from '../../bundles/csg/core.js'; +import { + CsgModuleState, + getModuleContext, + looseInstanceof, +} from '../../bundles/csg/utilities.js'; +import { DebuggerContext, ModuleContexts } from '../../typings/type_helpers'; +import CanvasHolder from './canvas_holder'; + +/* [Exports] */ +export default { + // Called by the frontend to decide whether to spawn the CSG tab + toSpawn(_debuggerContext: DebuggerContext): boolean { + return Core.getRenderGroupManager() + .shouldRender(); + }, + + // Called by the frontend to know what to render in the CSG tab + body(debuggerContext: DebuggerContext): ReactElement { + let moduleContexts: ModuleContexts = debuggerContext.context.moduleContexts; + let potentialModuleContext: ModuleContext | null = getModuleContext( + moduleContexts, + ); + if (potentialModuleContext === null) return
    ; + let moduleContext: ModuleContext = potentialModuleContext; + + let potentialModuleState: ModuleState | null | undefined + = moduleContext.state; + if (!looseInstanceof(potentialModuleState, CsgModuleState)) { return
    ; } + let moduleState = potentialModuleState as CsgModuleState; + + Core.initialize(moduleState); + return ( + + ); + }, + + // BlueprintJS icon name + iconName: IconNames.SHAPES, + + // Icon tooltip in sidebar + label: 'CSG Tab', +}; diff --git a/src/tabs/Csg/types.ts b/src/tabs/Csg/types.ts index b2cd0ffff..03d367870 100644 --- a/src/tabs/Csg/types.ts +++ b/src/tabs/Csg/types.ts @@ -1,27 +1,27 @@ -/* [Imports] */ -import { IconName } from '@blueprintjs/icons'; -import { CsgModuleState } from '../../bundles/csg/utilities.js'; - -/* [Exports] */ - -// React Component Props for the CSG canvas holder -export type CanvasHolderProps = { - moduleState: CsgModuleState; - componentNumber: number; -}; - -// React Component State for the CSG canvas holder -export type CanvasHolderState = { - contextLost: boolean; -}; - -// React Component Props for a control hint -export type HintProps = { - tooltipText: string; - iconName: IconName; -}; - -// React Component State for a control hint -export type HintState = { - showTooltip: boolean; -}; +/* [Imports] */ +import { IconName } from '@blueprintjs/icons'; +import { CsgModuleState } from '../../bundles/csg/utilities.js'; + +/* [Exports] */ + +// React Component Props for the CSG canvas holder +export type CanvasHolderProps = { + moduleState: CsgModuleState; + componentNumber: number; +}; + +// React Component State for the CSG canvas holder +export type CanvasHolderState = { + contextLost: boolean; +}; + +// React Component Props for a control hint +export type HintProps = { + tooltipText: string; + iconName: IconName; +}; + +// React Component State for a control hint +export type HintState = { + showTooltip: boolean; +}; diff --git a/src/tabs/Curve/3Dcurve_anim_canvas.tsx b/src/tabs/Curve/3Dcurve_anim_canvas.tsx index 779edb02f..fb565008c 100644 --- a/src/tabs/Curve/3Dcurve_anim_canvas.tsx +++ b/src/tabs/Curve/3Dcurve_anim_canvas.tsx @@ -1,349 +1,349 @@ -import { Button, Icon, Slider, Switch } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import { Tooltip2 } from '@blueprintjs/popover2'; -import React from 'react'; -import { AnimatedCurve } from '../../bundles/curve/types'; -import WebGLCanvas from '../common/webgl_canvas'; - -type Props = { - animation: AnimatedCurve; -}; - -type State = { - /** Timestamp of the animation */ - animTimestamp: number; - - /** Boolean value indicating if the animation is playing */ - isPlaying: boolean; - - /** Previous value of `isPlaying` */ - wasPlaying: boolean; - - /** Boolean value indicating if auto play is selected */ - autoPlay: boolean; - - /** Curve Angle */ - curveAngle: number; -}; - -export default class Curve3DAnimationCanvas extends React.Component< - Props, - State -> { - private canvas: HTMLCanvasElement | null; - - /** - * The duration of one frame in milliseconds - */ - private readonly frameDuration: number; - - /** - * The duration of the entire animation - */ - private readonly animationDuration: number; - - /** - * Last timestamp since the previous `requestAnimationFrame` call - */ - private callbackTimestamp: number | null; - - constructor(props: Props | Readonly) { - super(props); - - this.state = { - animTimestamp: 0, - isPlaying: false, - wasPlaying: false, - autoPlay: true, - curveAngle: 0, - }; - - this.canvas = null; - this.frameDuration = 1000 / props.animation.fps; - this.animationDuration = Math.round(props.animation.duration * 1000); - this.callbackTimestamp = null; - } - - public componentDidMount() { - this.drawFrame(); - } - - /** - * Call this to actually draw a frame onto the canvas - */ - private drawFrame = () => { - if (this.canvas) { - const frame = this.props.animation.getFrame( - this.state.animTimestamp / 1000 - ); - frame.draw(this.canvas); - } - }; - - private reqFrame = () => requestAnimationFrame(this.animationCallback); - - /** - * Callback to use with `requestAnimationFrame` - */ - private animationCallback = (timeInMs: number) => { - if (!this.canvas || !this.state.isPlaying) return; - - if (!this.callbackTimestamp) { - this.callbackTimestamp = timeInMs; - this.drawFrame(); - this.reqFrame(); - return; - } - - const currentFrame = timeInMs - this.callbackTimestamp; - - if (currentFrame < this.frameDuration) { - // Not time to draw a new frame yet - this.reqFrame(); - return; - } - - this.callbackTimestamp = timeInMs; - if (this.state.animTimestamp >= this.animationDuration) { - // Animation has ended - if (this.state.autoPlay) { - // If autoplay is active, reset the animation - this.setState( - { - animTimestamp: 0, - }, - this.reqFrame - ); - } else { - // Otherwise, stop the animation - this.setState( - { - isPlaying: false, - }, - () => { - this.callbackTimestamp = null; - } - ); - } - } else { - // Animation hasn't ended, so just draw the next frame - this.drawFrame(); - this.setState( - (prev) => ({ - animTimestamp: prev.animTimestamp + currentFrame, - }), - this.reqFrame - ); - } - }; - - /** - * Play button click handler - */ - private onPlayButtonClick = () => { - if (this.state.isPlaying) { - this.setState( - { - isPlaying: false, - }, - () => { - this.callbackTimestamp = null; - } - ); - } else { - this.setState( - { - isPlaying: true, - }, - this.reqFrame - ); - } - }; - - /** - * Reset button click handler - */ - private onResetButtonClick = () => { - this.setState( - { - animTimestamp: 0, - }, - () => { - if (this.state.isPlaying) this.reqFrame(); - else this.drawFrame(); - } - ); - }; - - /** - * Slider value change handler - * @param newValue New value of the slider - */ - private onTimeSliderChange = (newValue: number) => { - this.callbackTimestamp = null; - this.setState( - (prev) => ({ - wasPlaying: prev.isPlaying, - isPlaying: false, - animTimestamp: newValue, - }), - this.drawFrame - ); - }; - - /** - * Handler triggered when the slider is clicked off - */ - private onTimeSliderRelease = () => { - this.setState( - (prev) => ({ - isPlaying: prev.wasPlaying, - }), - () => { - if (!this.state.isPlaying) { - this.callbackTimestamp = null; - } else { - this.reqFrame(); - } - } - ); - }; - - private onAngleSliderChange = (newAngle: number) => { - this.setState( - { - curveAngle: newAngle, - }, - () => { - this.props.animation.angle = newAngle; - if (this.state.isPlaying) this.reqFrame(); - else this.drawFrame(); - } - ); - }; - - /** - * Auto play switch handler - */ - private autoPlaySwitchChanged = () => { - this.setState((prev) => ({ - autoPlay: !prev.autoPlay, - })); - }; - - public render() { - const buttons = ( -
    -
    - - - -
    - - - -
    - ); - - const sliders = ( -
    - - -
    - -
    -
    -
    - ); - - return ( - <> -
    - { - this.canvas = r; - }} - /> -
    -
    - {buttons} - {sliders} - -
    - - ); - } -} +import { Button, Icon, Slider, Switch } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { Tooltip2 } from '@blueprintjs/popover2'; +import React from 'react'; +import { AnimatedCurve } from '../../bundles/curve/types'; +import WebGLCanvas from '../common/webgl_canvas'; + +type Props = { + animation: AnimatedCurve; +}; + +type State = { + /** Timestamp of the animation */ + animTimestamp: number; + + /** Boolean value indicating if the animation is playing */ + isPlaying: boolean; + + /** Previous value of `isPlaying` */ + wasPlaying: boolean; + + /** Boolean value indicating if auto play is selected */ + autoPlay: boolean; + + /** Curve Angle */ + curveAngle: number; +}; + +export default class Curve3DAnimationCanvas extends React.Component< +Props, +State +> { + private canvas: HTMLCanvasElement | null; + + /** + * The duration of one frame in milliseconds + */ + private readonly frameDuration: number; + + /** + * The duration of the entire animation + */ + private readonly animationDuration: number; + + /** + * Last timestamp since the previous `requestAnimationFrame` call + */ + private callbackTimestamp: number | null; + + constructor(props: Props | Readonly) { + super(props); + + this.state = { + animTimestamp: 0, + isPlaying: false, + wasPlaying: false, + autoPlay: true, + curveAngle: 0, + }; + + this.canvas = null; + this.frameDuration = 1000 / props.animation.fps; + this.animationDuration = Math.round(props.animation.duration * 1000); + this.callbackTimestamp = null; + } + + public componentDidMount() { + this.drawFrame(); + } + + /** + * Call this to actually draw a frame onto the canvas + */ + private drawFrame = () => { + if (this.canvas) { + const frame = this.props.animation.getFrame( + this.state.animTimestamp / 1000, + ); + frame.draw(this.canvas); + } + }; + + private reqFrame = () => requestAnimationFrame(this.animationCallback); + + /** + * Callback to use with `requestAnimationFrame` + */ + private animationCallback = (timeInMs: number) => { + if (!this.canvas || !this.state.isPlaying) return; + + if (!this.callbackTimestamp) { + this.callbackTimestamp = timeInMs; + this.drawFrame(); + this.reqFrame(); + return; + } + + const currentFrame = timeInMs - this.callbackTimestamp; + + if (currentFrame < this.frameDuration) { + // Not time to draw a new frame yet + this.reqFrame(); + return; + } + + this.callbackTimestamp = timeInMs; + if (this.state.animTimestamp >= this.animationDuration) { + // Animation has ended + if (this.state.autoPlay) { + // If autoplay is active, reset the animation + this.setState( + { + animTimestamp: 0, + }, + this.reqFrame, + ); + } else { + // Otherwise, stop the animation + this.setState( + { + isPlaying: false, + }, + () => { + this.callbackTimestamp = null; + }, + ); + } + } else { + // Animation hasn't ended, so just draw the next frame + this.drawFrame(); + this.setState( + (prev) => ({ + animTimestamp: prev.animTimestamp + currentFrame, + }), + this.reqFrame, + ); + } + }; + + /** + * Play button click handler + */ + private onPlayButtonClick = () => { + if (this.state.isPlaying) { + this.setState( + { + isPlaying: false, + }, + () => { + this.callbackTimestamp = null; + }, + ); + } else { + this.setState( + { + isPlaying: true, + }, + this.reqFrame, + ); + } + }; + + /** + * Reset button click handler + */ + private onResetButtonClick = () => { + this.setState( + { + animTimestamp: 0, + }, + () => { + if (this.state.isPlaying) this.reqFrame(); + else this.drawFrame(); + }, + ); + }; + + /** + * Slider value change handler + * @param newValue New value of the slider + */ + private onTimeSliderChange = (newValue: number) => { + this.callbackTimestamp = null; + this.setState( + (prev) => ({ + wasPlaying: prev.isPlaying, + isPlaying: false, + animTimestamp: newValue, + }), + this.drawFrame, + ); + }; + + /** + * Handler triggered when the slider is clicked off + */ + private onTimeSliderRelease = () => { + this.setState( + (prev) => ({ + isPlaying: prev.wasPlaying, + }), + () => { + if (!this.state.isPlaying) { + this.callbackTimestamp = null; + } else { + this.reqFrame(); + } + }, + ); + }; + + private onAngleSliderChange = (newAngle: number) => { + this.setState( + { + curveAngle: newAngle, + }, + () => { + this.props.animation.angle = newAngle; + if (this.state.isPlaying) this.reqFrame(); + else this.drawFrame(); + }, + ); + }; + + /** + * Auto play switch handler + */ + private autoPlaySwitchChanged = () => { + this.setState((prev) => ({ + autoPlay: !prev.autoPlay, + })); + }; + + public render() { + const buttons = ( +
    +
    + + + +
    + + + +
    + ); + + const sliders = ( +
    + + +
    + +
    +
    +
    + ); + + return ( + <> +
    + { + this.canvas = r; + }} + /> +
    +
    + {buttons} + {sliders} + +
    + + ); + } +} diff --git a/src/tabs/Curve/curve_canvas3d.tsx b/src/tabs/Curve/curve_canvas3d.tsx index 0c646598c..2782e43e2 100644 --- a/src/tabs/Curve/curve_canvas3d.tsx +++ b/src/tabs/Curve/curve_canvas3d.tsx @@ -1,183 +1,183 @@ -import { Slider, Button, Icon } from '@blueprintjs/core'; -import { IconNames } from '@blueprintjs/icons'; -import React from 'react'; -import { CurveDrawn } from '../../bundles/curve/curves_webgl'; -import WebGLCanvas from '../common/webgl_canvas'; - -type State = { - /** - * Slider component reflects this value. This value is also passed in as - * argument to render curves. - */ - rotationAngle: number; - - /** - * Set to true by default. Slider updates this value to false when interacted - * with. Recursive `autoRotate()` checks for this value to decide whether to - * stop recursion. Button checks for this value to decide whether clicking the - * button takes effect, for countering spam-clicking. - */ - isRotating: boolean; - - displayAngle: boolean; -}; - -type Props = { - curve: CurveDrawn; -}; - -/** - * 3D Version of the CurveCanvas to include the rotation angle slider - * and play button - */ -export default class CurveCanvas3D extends React.Component { - private $canvas: HTMLCanvasElement | null; - - constructor(props) { - super(props); - - this.$canvas = null; - this.state = { - rotationAngle: 0, - isRotating: false, - displayAngle: false, - }; - } - - public componentDidMount() { - if (this.$canvas) { - this.props.curve.init(this.$canvas); - this.props.curve.redraw((this.state.rotationAngle / 180) * Math.PI); - } - } - - /** - * Event handler for slider component. Updates the canvas for any change in - * rotation. - * - * @param newValue new rotation angle - */ - private onSliderChangeHandler = (newValue: number) => { - this.setState( - { - rotationAngle: newValue, - isRotating: false, - displayAngle: true, - }, - () => { - if (this.$canvas) { - this.props.curve.redraw((newValue / 180) * Math.PI); - } - } - ); - }; - - /** - * Event handler for play button. Starts automated rotation by calling - * `autoRotate()`. - */ - private onClickHandler = () => { - if (!this.$canvas) return; - - this.setState( - (prevState) => ({ - isRotating: !prevState.isRotating, - }), - () => { - if (this.state.isRotating) { - this.autoRotate(); - } - } - ); - }; - - /** - * Environment where `requestAnimationFrame` is called. - */ - private autoRotate = () => { - if (this.$canvas && this.state.isRotating) { - this.setState( - (prevState) => ({ - ...prevState, - rotationAngle: - prevState.rotationAngle >= 360 ? 0 : prevState.rotationAngle + 2, - }), - () => { - this.props.curve.redraw((this.state.rotationAngle / 180) * Math.PI); - window.requestAnimationFrame(this.autoRotate); - } - ); - } - }; - - private onTextBoxChange = (event) => { - const angle = parseFloat(event.target.value); - this.setState( - () => ({ rotationAngle: angle }), - () => { - if (this.$canvas) { - this.props.curve.redraw((angle / 180) * Math.PI); - } - } - ); - }; - - public render() { - return ( -
    - { - this.$canvas = r; - }} - /> -
    - - - -
    -
    - ); - } -} +import { Slider, Button, Icon } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import React from 'react'; +import { CurveDrawn } from '../../bundles/curve/curves_webgl'; +import WebGLCanvas from '../common/webgl_canvas'; + +type State = { + /** + * Slider component reflects this value. This value is also passed in as + * argument to render curves. + */ + rotationAngle: number; + + /** + * Set to true by default. Slider updates this value to false when interacted + * with. Recursive `autoRotate()` checks for this value to decide whether to + * stop recursion. Button checks for this value to decide whether clicking the + * button takes effect, for countering spam-clicking. + */ + isRotating: boolean; + + displayAngle: boolean; +}; + +type Props = { + curve: CurveDrawn; +}; + +/** + * 3D Version of the CurveCanvas to include the rotation angle slider + * and play button + */ +export default class CurveCanvas3D extends React.Component { + private $canvas: HTMLCanvasElement | null; + + constructor(props) { + super(props); + + this.$canvas = null; + this.state = { + rotationAngle: 0, + isRotating: false, + displayAngle: false, + }; + } + + public componentDidMount() { + if (this.$canvas) { + this.props.curve.init(this.$canvas); + this.props.curve.redraw((this.state.rotationAngle / 180) * Math.PI); + } + } + + /** + * Event handler for slider component. Updates the canvas for any change in + * rotation. + * + * @param newValue new rotation angle + */ + private onSliderChangeHandler = (newValue: number) => { + this.setState( + { + rotationAngle: newValue, + isRotating: false, + displayAngle: true, + }, + () => { + if (this.$canvas) { + this.props.curve.redraw((newValue / 180) * Math.PI); + } + }, + ); + }; + + /** + * Event handler for play button. Starts automated rotation by calling + * `autoRotate()`. + */ + private onClickHandler = () => { + if (!this.$canvas) return; + + this.setState( + (prevState) => ({ + isRotating: !prevState.isRotating, + }), + () => { + if (this.state.isRotating) { + this.autoRotate(); + } + }, + ); + }; + + /** + * Environment where `requestAnimationFrame` is called. + */ + private autoRotate = () => { + if (this.$canvas && this.state.isRotating) { + this.setState( + (prevState) => ({ + ...prevState, + rotationAngle: + prevState.rotationAngle >= 360 ? 0 : prevState.rotationAngle + 2, + }), + () => { + this.props.curve.redraw((this.state.rotationAngle / 180) * Math.PI); + window.requestAnimationFrame(this.autoRotate); + }, + ); + } + }; + + private onTextBoxChange = (event) => { + const angle = parseFloat(event.target.value); + this.setState( + () => ({ rotationAngle: angle }), + () => { + if (this.$canvas) { + this.props.curve.redraw((angle / 180) * Math.PI); + } + }, + ); + }; + + public render() { + return ( +
    + { + this.$canvas = r; + }} + /> +
    + + + +
    +
    + ); + } +} diff --git a/src/tabs/Curve/index.tsx b/src/tabs/Curve/index.tsx index 58851794c..5b85786a6 100644 --- a/src/tabs/Curve/index.tsx +++ b/src/tabs/Curve/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { CurveDrawn } from '../../bundles/curve/curves_webgl'; -import { AnimatedCurve, CurveModuleState } from '../../bundles/curve/types'; +import type { CurveDrawn } from '../../bundles/curve/curves_webgl'; +import type { AnimatedCurve, CurveModuleState } from '../../bundles/curve/types'; import { glAnimation } from '../../typings/anim_types'; import MultiItemDisplay from '../common/multi_item_display'; -import { DebuggerContext } from '../../typings/type_helpers'; +import type { DebuggerContext } from '../../typings/type_helpers'; import Curve3DAnimationCanvas from './3Dcurve_anim_canvas'; import CurveCanvas3D from './curve_canvas3d'; import AnimationCanvas from '../common/animation_canvas'; @@ -32,26 +32,30 @@ export default { if (glAnimation.isAnimation(curve)) { const anim = curve as AnimatedCurve; - return anim.is3D ? ( - - ) : ( - - ); + return anim.is3D + ? ( + + ) + : ( + + ); } const curveDrawn = curve as CurveDrawn; - return curveDrawn.is3D() ? ( - - ) : ( - { - if (r) { - curveDrawn.init(r); - curveDrawn.redraw(0); - } - }} - key={elemKey} - /> - ); + return curveDrawn.is3D() + ? ( + + ) + : ( + { + if (r) { + curveDrawn.init(r); + curveDrawn.redraw(0); + } + }} + key={elemKey} + /> + ); }); return ; diff --git a/src/tabs/Game/constants.ts b/src/tabs/Game/constants.ts index a6afdeac5..b079b6ade 100644 --- a/src/tabs/Game/constants.ts +++ b/src/tabs/Game/constants.ts @@ -1,5 +1,5 @@ -export enum Links { - gameUserGuide = 'https://github.com/source-academy/modules/wiki/%5Bgame%5D-User-Guide', - gameDeveloperDocumentation = 'https://github.com/source-academy/modules/wiki/%5Bgame%5D-Developer-Documentation', - gameAPIDocumentation = 'https://source-academy.github.io/modules/documentation/modules/game.html', -} +export enum Links { + gameUserGuide = 'https://github.com/source-academy/modules/wiki/%5Bgame%5D-User-Guide', + gameDeveloperDocumentation = 'https://github.com/source-academy/modules/wiki/%5Bgame%5D-Developer-Documentation', + gameAPIDocumentation = 'https://source-academy.github.io/modules/documentation/modules/game.html', +} diff --git a/src/tabs/Game/index.tsx b/src/tabs/Game/index.tsx index 47e5998c5..1e9132a04 100644 --- a/src/tabs/Game/index.tsx +++ b/src/tabs/Game/index.tsx @@ -1,41 +1,41 @@ -import React from 'react'; -import { Links } from './constants'; - -type Props = { - children?: never; - className?: string; - debuggerContext?: any; -}; - -class Game extends React.PureComponent { - public render() { - return ( -
    - Info: You need to visit the game to see the effect of your program. - Remember to save your work first! -
    -
    - You may find the game module{' '} - - documentation{' '} - - and{' '} - - user guide{' '} - - useful. -
    - ); - } -} - -export default { - toSpawn: () => true, - body: (debuggerContext: any) => , - label: 'Game Info Tab', - iconName: 'info-sign', -}; +import React from 'react'; +import { Links } from './constants'; + +type Props = { + children?: never; + className?: string; + debuggerContext?: any; +}; + +class Game extends React.PureComponent { + public render() { + return ( +
    + Info: You need to visit the game to see the effect of your program. + Remember to save your work first! +
    +
    + You may find the game module{' '} + + documentation{' '} + + and{' '} + + user guide{' '} + + useful. +
    + ); + } +} + +export default { + toSpawn: () => true, + body: (debuggerContext: any) => , + label: 'Game Info Tab', + iconName: 'info-sign', +}; diff --git a/src/tabs/MarkSweep/index.tsx b/src/tabs/MarkSweep/index.tsx index ef293a4af..c9af72ed5 100644 --- a/src/tabs/MarkSweep/index.tsx +++ b/src/tabs/MarkSweep/index.tsx @@ -57,9 +57,9 @@ class MarkSweep extends React.Component { componentDidMount() { const { debuggerContext } = this.props; if ( - debuggerContext && - debuggerContext.result && - debuggerContext.result.value + debuggerContext + && debuggerContext.result + && debuggerContext.result.value ) { this.initialize_state(); } @@ -178,8 +178,8 @@ class MarkSweep extends React.Component { private getlengthFunction = () => { const { debuggerContext } = this.props; - const commandHeap = - debuggerContext && debuggerContext.result.value + const commandHeap + = debuggerContext && debuggerContext.result.value ? debuggerContext.result.value.get_command() : []; return commandHeap.length; @@ -244,7 +244,7 @@ class MarkSweep extends React.Component { private renderLabel = (val: number) => { const { flips } = this.state; - return flips.includes(val) ? `^` : `${val}`; + return flips.includes(val) ? '^' : `${val}`; }; public render() { @@ -259,7 +259,7 @@ class MarkSweep extends React.Component {

    This is a visualiser for mark and sweep garbage collector. Check the guide{' '} - + here . @@ -267,7 +267,11 @@ class MarkSweep extends React.Component {

    {state.command}

    {state.description}

    {state.leftDesc && (
    @@ -281,30 +285,32 @@ class MarkSweep extends React.Component { {state.leftDesc}
    )} - {state.rightDesc ? ( -
    - - {state.rightDesc} -
    - ) : ( - false - )} + {state.rightDesc + ? ( +
    + + {state.rightDesc} +
    + ) + : ( + false + )}

    Current step: {' '} - + {' '} {state.value} {' '} - +

    {
    - {memoryMatrix && - memoryMatrix.length > 0 && - memoryMatrix.map((item, row) => ( -
    + {memoryMatrix + && memoryMatrix.length > 0 + && memoryMatrix.map((item, row) => ( +
    {row * state.column} - {item && - item.length > 0 && - item.map((content) => { + {item + && item.length > 0 + && item.map((content) => { const color = this.getMemoryColor(content); const bgColor = this.getBackgroundColor(content); return ( @@ -365,7 +374,11 @@ class MarkSweep extends React.Component {
    {
    MARK_SLOT: @@ -435,7 +452,7 @@ class MarkSweep extends React.Component {

    This is a visualiser for mark and sweep garbage collector. Check the guide{' '} - + here . diff --git a/src/tabs/MarkSweep/style.tsx b/src/tabs/MarkSweep/style.tsx index aa890ff50..454735823 100644 --- a/src/tabs/MarkSweep/style.tsx +++ b/src/tabs/MarkSweep/style.tsx @@ -1,13 +1,13 @@ -export enum ThemeColor { - BLUE = 'lightblue', - PINK = 'salmon', - GREY = '#707070', - GREEN = '#42a870', - YELLOW = '#f0d60e', - RED = 'red', - BLACK = 'black', -} - -export const FONT = { - SMALL: 10, -}; +export enum ThemeColor { + BLUE = 'lightblue', + PINK = 'salmon', + GREY = '#707070', + GREEN = '#42a870', + YELLOW = '#f0d60e', + RED = 'red', + BLACK = 'black', +} + +export const FONT = { + SMALL: 10, +}; diff --git a/src/tabs/Pixnflix/index.tsx b/src/tabs/Pixnflix/index.tsx index cfbbbbc22..65aba818e 100644 --- a/src/tabs/Pixnflix/index.tsx +++ b/src/tabs/Pixnflix/index.tsx @@ -109,7 +109,7 @@ class PixNFlix extends React.Component { this.printError, { onClickStill: this.onClickStill, - } + }, ); let mode: VideoMode = VideoMode.Video; if (inputFeed === InputFeed.Local) { @@ -155,7 +155,7 @@ class PixNFlix extends React.Component { () => ({ mode: VideoMode.Still, }), - this.handleStopVideo + this.handleStopVideo, ); } }; @@ -167,7 +167,7 @@ class PixNFlix extends React.Component { () => ({ mode: VideoMode.Video, }), - this.handleStartVideo + this.handleStartVideo, ); } }; @@ -195,10 +195,10 @@ class PixNFlix extends React.Component { public handleUpdateDimensions = (w: number, h: number) => { if ( - w >= MIN_WIDTH && - w <= MAX_WIDTH && - h >= MIN_HEIGHT && - h <= MAX_HEIGHT + w >= MIN_WIDTH + && w <= MAX_WIDTH + && h >= MIN_HEIGHT + && h <= MAX_HEIGHT ) { this.setState({ width: w, @@ -264,9 +264,9 @@ class PixNFlix extends React.Component { */ private isPixNFlix() { return ( - this.pixNFlix && - this.pixNFlix.toReplString && - this.pixNFlix.toReplString() === '[Pix N Flix]' + this.pixNFlix + && this.pixNFlix.toReplString + && this.pixNFlix.toReplString() === '[Pix N Flix]' ); } @@ -277,38 +277,38 @@ class PixNFlix extends React.Component { const isAccepting = mode === VideoMode.Accepting; return (

    -
    +
    -
    +
    {/* */} { /> {/* */}
    -
    +
    {/* */} { /> {/* */}
    -
    +
    {/* */} {
    -
    +
    { this.$image = r; @@ -387,7 +387,7 @@ class PixNFlix extends React.Component {
    Drag file here

    - +

    { > Volume: void>(); - const animId = React.useRef(null); - - const animCallback = (timeInMs: number) => { - renderFuncRef.current!(timeInMs); - animId.current = requestAnimationFrame(animCallback); - }; - - React.useEffect(() => { - if (canvasRef.current) { - renderFuncRef.current = rune.draw(canvasRef.current!); - animCallback(0); - - return () => { - if (animId.current) { - cancelAnimationFrame(animId.current!); - } - }; - } - - return undefined; - }, []); - - return ; -} +import React from 'react'; +import { HollusionRune } from '../../bundles/rune/functions'; +import WebGLCanvas from '../common/webgl_canvas'; + +/** + * Canvas used to display Hollusion runes + */ +export default function HollusionCanvas({ rune }: { rune: HollusionRune }) { + const canvasRef = React.useRef(null); + const renderFuncRef = React.useRef<(time: number) => void>(); + const animId = React.useRef(null); + + const animCallback = (timeInMs: number) => { + renderFuncRef.current!(timeInMs); + animId.current = requestAnimationFrame(animCallback); + }; + + React.useEffect(() => { + if (canvasRef.current) { + renderFuncRef.current = rune.draw(canvasRef.current!); + animCallback(0); + + return () => { + if (animId.current) { + cancelAnimationFrame(animId.current!); + } + }; + } + + return undefined; + }, []); + + return ; +} diff --git a/src/tabs/Rune/index.tsx b/src/tabs/Rune/index.tsx index 2e158cfaf..aa213ef9f 100644 --- a/src/tabs/Rune/index.tsx +++ b/src/tabs/Rune/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { HollusionRune } from '../../bundles/rune/functions'; -import { +import type { AnimatedRune, DrawnRune, RunesModuleState, diff --git a/src/tabs/Sound/index.tsx b/src/tabs/Sound/index.tsx index b8e9758f5..69ba2d770 100644 --- a/src/tabs/Sound/index.tsx +++ b/src/tabs/Sound/index.tsx @@ -1,73 +1,73 @@ -import React from 'react'; -import { SoundsModuleState } from '../../bundles/sound/types'; -import { DebuggerContext } from '../../typings/type_helpers'; -import MultiItemDisplay from '../common/multi_item_display'; - -/** - * Tab for Source Academy Sounds Module - * @author Koh Shang Hui - * @author Samyukta Sounderraman - */ - -export default { - /** - * This function will be called to determine if the component will be - * rendered. - * @returns {boolean} - */ - toSpawn(context: DebuggerContext) { - const moduleContext = context.context?.moduleContexts.get('sound'); - if (!moduleContext) { - return false; - } - - const moduleState = moduleContext.state as SoundsModuleState; - if (!moduleState) { - return false; - } - - return moduleState.audioPlayed.length > 0; - }, - /** - * This function will be called to render the module tab in the side contents - * on Source Academy frontend. - * @param {DebuggerContext} context - */ - body(context: DebuggerContext) { - const moduleContext = context.context?.moduleContexts.get('sound'); - const moduleState = (moduleContext!.state as SoundsModuleState).audioPlayed; - const elements = moduleState.map((audio) => ( -