From 7d5985fb60c16c7e0bdc2f0ac3371515ee70bb90 Mon Sep 17 00:00:00 2001 From: SoundOfScooting Date: Mon, 2 Jun 2025 17:15:52 -0400 Subject: [PATCH 01/42] [dev-2.0] Fix optional and rest parameters in TypeScript class method declarations. --- package.json | 10 ++++++++-- utils/helper.mjs | 10 ++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3ee0dad7ee..90d523ad7c 100644 --- a/package.json +++ b/package.json @@ -68,8 +68,14 @@ "license": "LGPL-2.1", "browser": "./lib/p5.min.js", "exports": { - ".": "./dist/app.js", - "./core": "./dist/core/main.js", + ".": { + "types": "./types/p5.d.ts", + "default": "./dist/app.js" + }, + "./core": { + "types": "./types/core/main.d.ts", + "default": "./dist/core/main.js" + }, "./shape": "./dist/shape/index.js", "./accessibility": "./dist/accessibility/index.js", "./friendlyErrors": "./dist/core/friendlyErrors/index.js", diff --git a/utils/helper.mjs b/utils/helper.mjs index 6c340bc1df..a266fd38e8 100644 --- a/utils/helper.mjs +++ b/utils/helper.mjs @@ -286,7 +286,8 @@ function generateDeclarationFile(items, organizedData) { params: (entry.params || []).map(param => ({ name: param.name, type: generateTypeFromTag(param), - optional: param.type?.type === 'OptionalType' + optional: param.type?.type === 'OptionalType', + rest: param.type?.type === 'RestType' })), module, submodule, @@ -307,7 +308,8 @@ function generateDeclarationFile(items, organizedData) { params: (entry.params || []).map(param => ({ name: param.name, type: generateTypeFromTag(param), - optional: param.type?.type === 'OptionalType' + optional: param.type?.type === 'OptionalType', + rest: param.type?.type === 'RestType' })), returnType: entry.returns?.[0] ? generateTypeFromTag(entry.returns[0]) : 'void', module, @@ -423,7 +425,7 @@ export function generateTypeFromTag(param) { let type = param.type; let prefix = ''; - const isOptional = param.type?.type === 'OptionalType'; + const isOptional = param.optional || param.type?.type === 'OptionalType'; if (typeof type === 'string') { type = normalizeTypeName(type); } else if (param.type?.type) { @@ -432,7 +434,7 @@ export function generateTypeFromTag(param) { type = 'any'; } - if (param.type?.type === 'RestType') { + if (param.rest || param.type?.type === 'RestType') { prefix = '...'; } From 1b7e8084b97e9d4973430ff5115de6015cd85602 Mon Sep 17 00:00:00 2001 From: SoundOfScooting Date: Sun, 15 Jun 2025 14:18:54 -0400 Subject: [PATCH 02/42] [dev-2.0] Fix `@chainable` in class method declarations --- utils/helper.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/utils/helper.mjs b/utils/helper.mjs index a266fd38e8..bcde260b7f 100644 --- a/utils/helper.mjs +++ b/utils/helper.mjs @@ -311,7 +311,11 @@ function generateDeclarationFile(items, organizedData) { optional: param.type?.type === 'OptionalType', rest: param.type?.type === 'RestType' })), - returnType: entry.returns?.[0] ? generateTypeFromTag(entry.returns[0]) : 'void', + returnType: entry.tags?.find(tag => tag.title === "chainable") + ? "this" + : entry.returns?.[0] + ? generateTypeFromTag(entry.returns[0]) + : 'void', module, submodule, class: className, From 5e3308115884cc45bd2c7422e662b8e722665ff7 Mon Sep 17 00:00:00 2001 From: SoundOfScooting Date: Fri, 11 Jul 2025 12:25:31 -0400 Subject: [PATCH 03/42] Fix doc comments --- docs/parameterData.json | 37 ++++++++++++--------- src/core/constants.js | 2 +- src/core/friendly_errors/param_validator.js | 1 + src/core/transform.js | 2 +- src/dom/p5.MediaElement.js | 6 ++-- src/math/p5.Vector.js | 3 -- src/webgl/light.js | 2 +- src/webgl/p5.Quat.js | 7 ++-- src/webgl/text.js | 13 ++++++-- 9 files changed, 44 insertions(+), 29 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index b99217ebe8..de8cb04fa9 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -475,7 +475,7 @@ "applyMatrix": { "overloads": [ [ - "Array" + "Number[]" ], [ "Number", @@ -1300,9 +1300,6 @@ "Number", "Number", "Number" - ], - [ - "p5.Vector" ] ] }, @@ -2602,7 +2599,7 @@ "imageLight": { "overloads": [ [ - "p5.image" + "p5.Image" ] ] }, @@ -3052,7 +3049,6 @@ }, "createAudio": { "overloads": [ - [], [ "String|String[]?", "Function?" @@ -4128,6 +4124,9 @@ }, "mult": { "overloads": [ + [ + "Number" + ], [ "Number", "Number", @@ -4236,6 +4235,18 @@ ] ] }, + "dist": { + "overloads": [ + [ + "p5.Vector" + ], + [], + [ + "p5.Vector", + "p5.Vector" + ] + ] + }, "normalize": { "overloads": [ [], @@ -4385,6 +4396,11 @@ ] ] }, + "clampToZero": { + "overloads": [ + [] + ] + }, "fromAngle": { "overloads": [ [ @@ -4411,15 +4427,6 @@ "overloads": [ [] ] - }, - "dist": { - "overloads": [ - [], - [ - "p5.Vector", - "p5.Vector" - ] - ] } }, "p5.Font": { diff --git a/src/core/constants.js b/src/core/constants.js index 942b48c9ad..3a03b62799 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -740,7 +740,7 @@ export const POINTS = 0x0000; */ export const LINES = 0x0001; /** - * @property {0x0003} LINE_STRIP + * @typedef {0x0003} LINE_STRIP * @property {LINE_STRIP} LINE_STRIP * @final */ diff --git a/src/core/friendly_errors/param_validator.js b/src/core/friendly_errors/param_validator.js index d4fc78830a..12d5d42dc5 100644 --- a/src/core/friendly_errors/param_validator.js +++ b/src/core/friendly_errors/param_validator.js @@ -137,6 +137,7 @@ function validateParams(p5, fn, lifecycles) { * parameters, and `?` is a shorthand for `Optional`. * * @method generateZodSchemasForFunc + * @private * @param {String} func - Name of the function. Expect global functions like `sin` and class methods like `p5.Vector.add` * @returns {z.ZodSchema} Zod schema */ diff --git a/src/core/transform.js b/src/core/transform.js index 5ba580999c..1499e4e19d 100644 --- a/src/core/transform.js +++ b/src/core/transform.js @@ -56,7 +56,7 @@ function transform(p5, fn){ * cause shapes to transform continuously. * * @method applyMatrix - * @param {Array} arr an array containing the elements of the transformation matrix. Its length should be either 6 (2D) or 16 (3D). + * @param {Array} arr an array containing the elements of the transformation matrix. Its length should be either 6 (2D) or 16 (3D). * @chainable * * @example diff --git a/src/dom/p5.MediaElement.js b/src/dom/p5.MediaElement.js index 019d8cb664..574fbe3cce 100644 --- a/src/dom/p5.MediaElement.js +++ b/src/dom/p5.MediaElement.js @@ -1318,7 +1318,7 @@ function media(p5, fn){ return c; } - /** VIDEO STUFF **/ + /*** VIDEO STUFF ***/ // Helps perform similar tasks for media element methods. function createMedia(pInst, type, src, callback) { @@ -1460,7 +1460,7 @@ function media(p5, fn){ return createMedia(this, 'video', src, callback); }; - /** AUDIO STUFF **/ + /*** AUDIO STUFF ***/ /** * Creates a hidden `<audio>` element for simple audio playback. @@ -1507,7 +1507,7 @@ function media(p5, fn){ return createMedia(this, 'audio', src, callback); }; - /** CAMERA STUFF **/ + /*** CAMERA STUFF ***/ fn.VIDEO = 'video'; diff --git a/src/math/p5.Vector.js b/src/math/p5.Vector.js index 97b72ab61b..3ce3d18b16 100644 --- a/src/math/p5.Vector.js +++ b/src/math/p5.Vector.js @@ -864,7 +864,6 @@ class Vector { * p5.Vector object and doesn't change the * originals. * - * @method mult * @param {Number} n The number to multiply with the vector * @chainable * @example @@ -1591,7 +1590,6 @@ class Vector { * Use dist() to calculate the distance between points * using coordinates as in `dist(x1, y1, x2, y2)`. * - * @method dist * @submodule p5.Vector * @param {p5.Vector} v x, y, and z coordinates of a p5.Vector. * @return {Number} distance. @@ -3060,7 +3058,6 @@ class Vector { * * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/EPSILON * - * @method clampToZero * @return {p5.Vector} with components very close to zero replaced with zero. * @chainable */ diff --git a/src/webgl/light.js b/src/webgl/light.js index 815dfec4d9..378c9a88b5 100644 --- a/src/webgl/light.js +++ b/src/webgl/light.js @@ -895,7 +895,7 @@ function light(p5, fn){ * use as the light source. * * @method imageLight - * @param {p5.image} img image to use as the light source. + * @param {p5.Image} img image to use as the light source. * * @example *
diff --git a/src/webgl/p5.Quat.js b/src/webgl/p5.Quat.js index 7ecb773bff..537417c874 100644 --- a/src/webgl/p5.Quat.js +++ b/src/webgl/p5.Quat.js @@ -15,7 +15,7 @@ class Quat { * Returns a Quaternion for the * axis angle representation of the rotation * - * @method fromAxisAngle + * @private * @param {Number} [angle] Angle with which the points needs to be rotated * @param {Number} [x] x component of the axis vector * @param {Number} [y] y component of the axis vector @@ -34,7 +34,7 @@ class Quat { /** * Multiplies a quaternion with other quaternion. - * @method mult + * @private * @param {p5.Quat} [quat] quaternion to multiply with the quaternion calling the method. * @chainable */ @@ -55,6 +55,7 @@ class Quat { * the multiplication can be simplified to the below formula. * This was taken from the below stackexchange link * https://gamedev.stackexchange.com/questions/28395/rotating-vector3-by-a-quaternion/50545#50545 + * @private * @param {p5.Vector} [p] vector to rotate on the axis quaternion */ rotateVector(p) { @@ -68,7 +69,7 @@ class Quat { * Rotates the Quaternion by the quaternion passed * which contains the axis of roation and angle of rotation * - * @method rotateBy + * @private * @param {p5.Quat} [axesQuat] axis quaternion which contains * the axis of rotation and angle of rotation * @chainable diff --git a/src/webgl/text.js b/src/webgl/text.js index 7418e6cc52..1cd2ec60fe 100644 --- a/src/webgl/text.js +++ b/src/webgl/text.js @@ -132,6 +132,7 @@ function text(p5, fn) { /** * @function setPixel + * @private * @param {Object} imageInfo * @param {Number} r * @param {Number} g @@ -230,6 +231,7 @@ function text(p5, fn) { /** * @function push + * @private * @param {Number[]} xs the x positions of points in the curve * @param {Number[]} ys the y positions of points in the curve * @param {Object} v the curve information @@ -242,6 +244,7 @@ function text(p5, fn) { /** * @function minMax + * @private * @param {Number[]} rg the list of values to compare * @param {Number} min the initial minimum value * @param {Number} max the initial maximum value @@ -291,6 +294,7 @@ function text(p5, fn) { /** * @function clamp + * @private * @param {Number} v the value to clamp * @param {Number} min the minimum value * @param {Number} max the maxmimum value @@ -305,6 +309,7 @@ function text(p5, fn) { /** * @function byte + * @private * @param {Number} v the value to scale * * converts a floating-point number in the range 0-1 to a byte 0-255 @@ -440,6 +445,7 @@ function text(p5, fn) { /** * @function cubicToQuadratics + * @private * @param {Number} x0 * @param {Number} y0 * @param {Number} cx0 @@ -508,6 +514,7 @@ function text(p5, fn) { /** * @function pushLine + * @private * @param {Number} x0 * @param {Number} y0 * @param {Number} x1 @@ -523,6 +530,7 @@ function text(p5, fn) { /** * @function samePoint + * @private * @param {Number} x0 * @param {Number} y0 * @param {Number} x1 @@ -608,9 +616,10 @@ function text(p5, fn) { /** * @function layout + * @private * @param {Number[][]} dim - * @param {ImageInfo[]} dimImageInfos - * @param {ImageInfo[]} cellImageInfos + * @param {ImageInfos} dimImageInfos + * @param {ImageInfos} cellImageInfos * @return {Object} * * lays out the curves in a dimension (row or col) into two From ad44d739b64100b52a5a74f63e1d7e84d4f2f493 Mon Sep 17 00:00:00 2001 From: SoundOfScooting Date: Fri, 11 Jul 2025 13:02:34 -0400 Subject: [PATCH 04/42] Document VIDEO and AUDIO constants --- src/dom/p5.MediaElement.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/dom/p5.MediaElement.js b/src/dom/p5.MediaElement.js index 574fbe3cce..a309ce5e69 100644 --- a/src/dom/p5.MediaElement.js +++ b/src/dom/p5.MediaElement.js @@ -5,6 +5,19 @@ import { Element } from './p5.Element'; +/** + * @typedef {'video'} VIDEO + * @property {VIDEO} VIDEO + * @final + */ +export const VIDEO = 'video'; +/** + * @typedef {'audio'} AUDIO + * @property {AUDIO} AUDIO + * @final + */ +export const AUDIO = 'audio'; + class MediaElement extends Element { constructor(elt, pInst) { super(elt, pInst); @@ -1457,7 +1470,7 @@ function media(p5, fn){ */ fn.createVideo = function (src, callback) { // p5._validateParameters('createVideo', arguments); - return createMedia(this, 'video', src, callback); + return createMedia(this, VIDEO, src, callback); }; /*** AUDIO STUFF ***/ @@ -1504,14 +1517,14 @@ function media(p5, fn){ */ fn.createAudio = function (src, callback) { // p5._validateParameters('createAudio', arguments); - return createMedia(this, 'audio', src, callback); + return createMedia(this, AUDIO, src, callback); }; /*** CAMERA STUFF ***/ - fn.VIDEO = 'video'; + fn.VIDEO = VIDEO; - fn.AUDIO = 'audio'; + fn.AUDIO = AUDIO; // from: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia // Older browsers might not implement mediaDevices at all, so we set an empty object first @@ -1693,7 +1706,7 @@ function media(p5, fn){ const videoConstraints = { video: useVideo, audio: useAudio }; constraints = Object.assign({}, videoConstraints, constraints); - const domElement = document.createElement('video'); + const domElement = document.createElement(VIDEO); // required to work in iOS 11 & up: domElement.setAttribute('playsinline', ''); navigator.mediaDevices.getUserMedia(constraints).then(function (stream) { From 85f59cc5afcdeb02389fb730369b22ddcf7aa957 Mon Sep 17 00:00:00 2001 From: SoundOfScooting Date: Fri, 11 Jul 2025 13:43:46 -0400 Subject: [PATCH 05/42] Fix syntax, accessors, descriptions --- utils/generate-types.mjs | 7 +- utils/helper.mjs | 177 +++++++++++++++++++++++++++------------ 2 files changed, 128 insertions(+), 56 deletions(-) diff --git a/utils/generate-types.mjs b/utils/generate-types.mjs index 51921bf040..722c0a559a 100644 --- a/utils/generate-types.mjs +++ b/utils/generate-types.mjs @@ -2,7 +2,8 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { - generateTypeDefinitions + generateTypeDefinitions, + normalizeIdentifier } from "./helper.mjs"; // Fix for __dirname equivalent in ES modules @@ -53,7 +54,7 @@ export function generateAllDeclarationFiles() { `${parsedPath.name}.d.ts` ); - const exportName = parsedPath.name.replace('.', '_'); + const exportName = normalizeIdentifier(parsedPath.name.replace('.', '_')); const contentWithExport = content + `export default function ${exportName}(p5: any, fn: any): void;\n`; fs.mkdirSync(path.dirname(dtsPath), { recursive: true }); @@ -68,6 +69,8 @@ export function generateAllDeclarationFiles() { // Add references to all other .d.ts files const dtsFiles = findDtsFiles(path.join(__dirname, '..')); for (const file of dtsFiles) { + if (file === 'p5.d.ts') + continue; p5Types += `/// \n`; } p5Types += '\n'; diff --git a/utils/helper.mjs b/utils/helper.mjs index bcde260b7f..dfee3aae3f 100644 --- a/utils/helper.mjs +++ b/utils/helper.mjs @@ -130,6 +130,8 @@ function generateGlobalTypeDefinitions(organizedData) { output += ` interface Window {\n`; instanceItems.forEach(item => { + if (item !== instanceItems.find(x => x.name === item.name)) + return; if (item.kind === 'function') { output += ` ${item.name}: typeof ${item.name};\n`; } @@ -238,10 +240,11 @@ function generateDeclarationFile(items, organizedData) { } items.forEach(item => { - if (item.kind !== 'class' && (!item.memberof || item.memberof !== classDoc?.name)) { + if (item.kind !== 'class' && (!item.memberof || normalizeClassName(item.memberof) !== normalizeClassName(classDoc?.name))) { switch (item.kind) { case 'function': - output += generateFunctionDeclaration(item); + if (!item.name.startsWith("_")) + output += generateFunctionDeclaration(item); break; case 'constant': case 'typedef': @@ -266,6 +269,38 @@ function generateDeclarationFile(items, organizedData) { return output; } + // #todo: repeated in convert.mjs + function getParams(entry) { + // Documentation.js seems to try to grab params from the function itself in + // the code if we don't document all the parameters. This messes with our + // manually-documented overloads. Instead of using the provided entry.params + // array, we'll instead only rely on manually included @param tags. + // + // However, the tags don't include a tree-structured description field, and + // instead convert it to a string. We want a slightly different conversion to + // string, so we match these params to the Documentation.js-provided `params` + // array and grab the description from those. + return (entry.tags || []) + + // Filter out the nested parameters (eg. options.extrude), + // to be treated as part of parent parameters (eg. options) + // and not separate entries + .filter(t => t.title === 'param' && !t.name.includes('.')) + .map(node => { + const param = (entry.params || []).find(param => param.name === node.name); + return { + name: normalizeIdentifier(node.name), + description: extractDescription(param?.description || { + type: 'text', // 'html', + value: node.description + }), + type: node.type, // generateTypeFromTag(node), + optional: node.type?.type === 'OptionalType', + rest: node.type?.type === 'RestType', + }; + }); + } + export function organizeData(data) { const allData = getAllEntries(data); @@ -283,36 +318,32 @@ function generateDeclarationFile(items, organizedData) { organized.classes[className] = { name: entry.name, description: extractDescription(entry.description), - params: (entry.params || []).map(param => ({ - name: param.name, - type: generateTypeFromTag(param), - optional: param.type?.type === 'OptionalType', - rest: param.type?.type === 'RestType' - })), + params: getParams(entry), module, submodule, extends: entry.tags?.find(tag => tag.title === 'extends')?.name || null }; break; case 'function': - case 'property': - const overloads = entry.overloads?.map(overload => ({ + case 'property': // #todo: unreachable + case 'member': + const overloads = entry.overloads?.map(overload => ({ // #todo: unreachable params: overload.params, returns: overload.returns, description: extractDescription(overload.description) })); organized.classitems.push({ - name: entry.name, + // #todo: assumes all members are accessors + memberName: entry.name, + name: entry.kind === 'member' + ? entry.returns?.length > 0 ? `get ${entry.name}` : `set ${entry.name}` + : entry.name, kind: entry.kind, + // #todo: method (and sometimes param) descriptions only present on first overload description: extractDescription(entry.description), - params: (entry.params || []).map(param => ({ - name: param.name, - type: generateTypeFromTag(param), - optional: param.type?.type === 'OptionalType', - rest: param.type?.type === 'RestType' - })), - returnType: entry.tags?.find(tag => tag.title === "chainable") - ? "this" + params: getParams(entry), + returnType: entry.tags?.find(tag => tag.title === 'chainable') + ? 'this' : entry.returns?.[0] ? generateTypeFromTag(entry.returns[0]) : 'void', @@ -366,7 +397,7 @@ export function generateTypeFromTag(param) { case 'NameExpression': return normalizeTypeName(param.type.name); case 'TypeApplication': { - const baseType = normalizeTypeName(param.type.expression.name); + const baseType = normalizeTypeName(param.type.expression.name, { inApplication: true }); if (baseType === 'Array') { const innerType = param.type.applications[0]; @@ -390,9 +421,13 @@ export function generateTypeFromTag(param) { return 'any'; case 'RecordType': return 'object'; + case 'NumericLiteralType': + return `${param.type.value}`; case 'StringLiteralType': return `'${param.type.value}'`; - case 'UndefinedLiteralType': + case 'NullLiteral': + return 'null'; + case 'UndefinedLiteral': return 'undefined'; case 'ArrayType': { const innerTypeStrs = param.type.elements.map(e => generateTypeFromTag({ type: e })); @@ -400,12 +435,28 @@ export function generateTypeFromTag(param) { } case 'RestType': return `${generateTypeFromTag({ type: param.type.expression })}[]`; + case 'FunctionType': + const params = (param.type.params || []) + .map((param2, i) => generateParamDeclaration({ name: `arg${i}`, type: param2 })) + .join(', '); + + const returnType = param.type.result + ? generateTypeFromTag({ type: param.type.result }) + : 'void'; + return `(${params}) => ${returnType}`; default: return 'any'; } } - export function normalizeTypeName(type) { + export function normalizeIdentifier(name) { + return ( + '0123456789'.includes(name[0]) || + name === 'class' + ) ? '$' + name : name; + } + + export function normalizeTypeName(type, { inApplication = false } = {}) { if (!type) return 'any'; if (type === '[object Object]') return 'any'; @@ -417,7 +468,9 @@ export function generateTypeFromTag(param) { 'Boolean': 'boolean', 'Void': 'void', 'Object': 'object', - 'Array': 'Array', + 'Any': 'any', + 'Array': !inApplication && 'any[]', + 'Promise': !inApplication && 'Promise', 'Function': 'Function' }; @@ -427,6 +480,8 @@ export function generateTypeFromTag(param) { export function generateParamDeclaration(param) { if (!param) return 'any'; + const name = normalizeIdentifier(param.name); + let type = param.type; let prefix = ''; const isOptional = param.optional || param.type?.type === 'OptionalType'; @@ -442,32 +497,35 @@ export function generateTypeFromTag(param) { prefix = '...'; } - return `${prefix}${param.name}${isOptional ? '?' : ''}: ${type}`; + return `${prefix}${name}${isOptional ? '?' : ''}: ${type}`; } export function generateFunctionDeclaration(funcDoc) { let output = ''; - if (funcDoc.description || funcDoc.tags?.length > 0) { - output += '/**\n'; + let comment = ''; + if (funcDoc.description) { const description = extractDescription(funcDoc.description); if (description) { - output += formatJSDocComment(description) + '\n'; + comment += (comment === "") ? '/**\n' : ' *\n'; + comment += formatJSDocComment(description) + '\n'; } - if (funcDoc.tags) { - if (description) { - output += ' *\n'; + } + if (funcDoc.tags) { + comment += (comment === "") ? '/**\n' : ' *\n'; + funcDoc.tags.forEach(tag => { + if (tag.description) { + const tagDesc = extractDescription(tag.description); + comment += formatJSDocComment(`@${tag.title} ${tag.name ? tag.name + ' ' : ''}${tagDesc}`, 0) + '\n'; } - funcDoc.tags.forEach(tag => { - if (tag.description) { - const tagDesc = extractDescription(tag.description); - output += formatJSDocComment(`@${tag.title} ${tagDesc}`, 0) + '\n'; - } - }); - } - output += ' */\n'; + }); } + if (comment !== "") + comment += ' */\n'; + output += comment; + + const name = normalizeIdentifier(funcDoc.name); const params = (funcDoc.params || []) .map(param => generateParamDeclaration(param)) @@ -477,33 +535,40 @@ export function generateTypeFromTag(param) { ? generateTypeFromTag(funcDoc.returns[0]) : 'void'; - output += `function ${funcDoc.name}(${params}): ${returnType};\n\n`; + output += `function ${name}(${params}): ${returnType};\n\n`; return output; } export function generateMethodDeclarations(item, isStatic = false, isGlobal = false) { let output = ''; + let comment = ''; if (item.description) { - output += ' /**\n'; const itemDesc = extractDescription(item.description); - output += formatJSDocComment(itemDesc, 2) + '\n'; - if (item.params?.length > 0) { - output += ' *\n'; - item.params.forEach(param => { - const paramDesc = extractDescription(param.description); - output += formatJSDocComment(`@param ${paramDesc}`, 2) + '\n'; - }); + if (itemDesc) { + comment += (comment === "") ? ' /**\n' : ' *\n'; + comment += formatJSDocComment(itemDesc, 2) + '\n'; } - if (item.returns) { - output += ' *\n'; - const returnDesc = extractDescription(item.returns[0]?.description); - output += formatJSDocComment(`@return ${returnDesc}`, 2) + '\n'; + } + if (item.params?.length > 0) { + comment += (comment === "") ? ' /**\n' : ' *\n'; + item.params.forEach(param => { + const paramDesc = extractDescription(param.description); + comment += formatJSDocComment(`@param ${param.name} ${paramDesc}`, 2) + '\n'; + }); + } + if (item.returns) { + const returnDesc = extractDescription(item.returns[0]?.description); + if (returnDesc) { + comment += (comment === "") ? ' /**\n' : ' *\n'; + comment += formatJSDocComment(`@return ${returnDesc}`, 2) + '\n'; } - output += ' */\n'; } + if (comment !== "") + comment += ' */\n'; + output += comment; - if (item.kind === 'function') { + if (item.kind === 'function' || item.kind === 'member') { const staticPrefix = isStatic ? 'static ' : ''; if (item.overloads?.length > 0) { @@ -521,7 +586,11 @@ export function generateTypeFromTag(param) { const params = (item.params || []) .map(param => generateParamDeclaration(param)) .join(', '); - output += ` ${staticPrefix}${item.name}(${params}): ${item.returnType};\n\n`; + if (item.kind === 'member' && item.name.startsWith('set ')) { + output += ` ${staticPrefix}${item.name}(${params});\n\n`; // return type annotation illegal + } else { + output += ` ${staticPrefix}${item.name}(${params}): ${item.returnType};\n\n`; + } } else { const staticPrefix = isStatic ? 'static ' : ''; output += ` ${staticPrefix}${item.name}: ${item.returnType};\n\n`; From a85660a649460789e4768ad856c5782f7c537991 Mon Sep 17 00:00:00 2001 From: SoundOfScooting Date: Fri, 11 Jul 2025 14:01:34 -0400 Subject: [PATCH 06/42] Fix some issues with constants --- utils/helper.mjs | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/utils/helper.mjs b/utils/helper.mjs index dfee3aae3f..e6a0de64d5 100644 --- a/utils/helper.mjs +++ b/utils/helper.mjs @@ -51,10 +51,13 @@ function generateP5TypeDefinitions(organizedData) { if (constData.description) { output += ` /**\n * ${constData.description}\n */\n`; } - if (constData.kind === 'constant') { - output += ` readonly ${constData.name.toUpperCase()}: ${constData.type};\n\n`; - } else { - output += ` static ${constData.name}: ${constData.type};\n\n`; + output += ` readonly ${constData.name}: ${constData.typeName};\n\n`; + + if (constData.name === 'VERSION') { // #todo: hardcoded special case + if (constData.description) { + output += ` /**\n * ${constData.description}\n */\n`; + } + output += ` static readonly ${constData.name}: ${constData.typeName};\n\n`; } } }); @@ -123,7 +126,7 @@ function generateGlobalTypeDefinitions(organizedData) { if (constData.description) { output += ` /**\n${formatJSDocComment(constData.description, 2)}\n */\n`; } - output += ` const ${constData.name.toUpperCase()}: p5.${constData.name.toUpperCase()};\n\n`; + output += ` const ${constData.name}: ${constData.typeName};\n\n`; } }); @@ -142,7 +145,7 @@ function generateGlobalTypeDefinitions(organizedData) { if (constData.description) { output += ` /**\n * ${constData.description}\n */\n`; } - output += ` readonly ${constData.name.toUpperCase()}: typeof ${constData.name.toUpperCase()};\n`; + output += ` readonly ${constData.name}: ${constData.typeName};\n`; } }); @@ -250,14 +253,17 @@ function generateDeclarationFile(items, organizedData) { case 'typedef': const constData = organizedData.consts[item.name]; if (constData) { + if (constData.kind === 'typedef') { + if (constData.description) { + output += ` /**\n * ${constData.description}\n */\n`; + } + output += ` type ${constData.name} = ${constData.type};\n\n`; + } + if (constData.description) { output += ` /**\n * ${constData.description}\n */\n`; } - if (constData.kind === 'constant') { - output += ` const ${constData.name}: ${constData.type};\n\n`; - } else { - output += ` type ${constData.name} = ${constData.type};\n\n`; - } + output += ` const ${constData.name}: ${constData.typeName};\n\n`; } break; } @@ -355,11 +361,21 @@ function generateDeclarationFile(items, organizedData) { }); break; case 'constant': case 'typedef': + let type = generateTypeFromTag(entry); + if (type === 'any') { + // find fallback type from property + const property = entry.properties?.find(x => x.name === entry.name); + const type2 = generateTypeFromTag(property); + if (type2 !== entry.name) { + type = type2; + } + } organized.consts[entry.name] = { name: entry.name, kind: entry.kind, description: extractDescription(entry.description), - type: entry.kind === 'constant' ? `P5.${entry.name.toUpperCase()}` : (entry.type ? generateTypeFromTag(entry) : 'any'), + type, + typeName: entry.kind === 'typedef' ? `p5.${entry.name}` : type, module, submodule, class: forEntry || 'p5' @@ -461,6 +477,10 @@ export function generateTypeFromTag(param) { if (type === '[object Object]') return 'any'; + const constData = organized.consts[type]; + if (constData) + return constData.typeName; + const primitiveTypes = { 'String': 'string', 'Number': 'number', From 49b4eae94723ce173cbcf4cdb5b3e58120e7b910 Mon Sep 17 00:00:00 2001 From: SoundOfScooting Date: Mon, 25 Aug 2025 14:16:38 -0400 Subject: [PATCH 07/42] patch.mjs draft --- utils/patch.mjs | 88 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/utils/patch.mjs b/utils/patch.mjs index d70f983006..e3c9ed9452 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -1,41 +1,79 @@ import fs from 'fs'; +const cache = {}; +const patched = {}; const replace = (path, src, dest) => { - try { - const data = fs - .readFileSync(path, { encoding: 'utf-8' }) - .replace(src, dest); - fs.writeFileSync(path, data); - } catch (err) { - console.error(err); - } + if (Array.isArray(path)) { + path.forEach(path => replace(path, src, dest)); + return; + } + try { + if (!path.startsWith("types/")) + path = "types/" + path; + + const before = patched[path] ?? + (cache[path] ??= fs.readFileSync("./" + path, { encoding: 'utf-8' })); + const after = before.replace(src, dest); + + if (after !== before) + patched[path] = after; + else + console.error(`A patch failed in ${path}:\n -${src}\n +${dest}`); + } catch (err) { + console.error(err); + } }; +// #todo: p5 function doc in structure.d.ts should be merged into the p5 constructor, which then needs to be written to parameterData +replace( + "global.d.ts", + `function p5(sketch: object, node: string | HTMLElement): void;`, + `// function p5(sketch: object, node: string | HTMLElement): void;`, +); +replace( + "global.d.ts", + `p5: typeof p5;`, + `// p5: typeof p5;`, +); +replace( + "p5.d.ts", + "p5(sketch: object, node: string | HTMLElement): void;", + "// p5(sketch: object, node: string | HTMLElement): void;" +); replace( - "./types/core/structure.d.ts", - "function p5(sketch: object, node: string | HTMLElement): void;", - "function p5: typeof p5" + "core/structure.d.ts", + "function p5(sketch: object, node: string | HTMLElement): void;", + "// function p5: typeof p5" ); replace( - "./types/webgl/p5.Geometry.d.ts", - "constructor(detailX?: number, detailY?: number, callback?: function);", - `constructor( - detailX?: number, - detailY?: number, - callback?: (this: { - detailY: number, - detailX: number, - vertices: p5.Vector[], - uvs: number[] - }) => void);` + "webgl/p5.Geometry.d.ts", + "constructor(detailX?: number, detailY?: number, callback?: function);", + `constructor( + detailX?: number, + detailY?: number, + callback?: (this: { + detailY: number, + detailX: number, + vertices: p5.Vector[], + uvs: number[] + }) => void);` ); // https://github.com/p5-types/p5.ts/issues/31 +// #todo: add readonly to appropriate array params, either here or in doc comments replace( - "./types/math/random.d.ts", - "function random(choices: Array): any;", - "function random(choices: T[]): T;" + [ "p5.d.ts", "math/random.d.ts" ], + "random(choices: any[]): any;", + "random(choices: readonly T[]): T;" ); +for (const [path, data] of Object.entries(patched)) { + try { + console.log(`Patched ${path}`); + fs.writeFileSync("./" + path, data); + } catch (err) { + console.error(err); + } +} From 8c8ccd98a248cce0ec50aab45bcc9624402689f2 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 25 Sep 2025 14:45:39 -0400 Subject: [PATCH 08/42] Refactor typescript generation --- docs/parameterData.json | 239 +++++++++---- package.json | 2 +- src/color/p5.Color.js | 6 +- src/core/constants.js | 83 +---- src/dom/p5.MediaElement.js | 15 +- src/math/trigonometry.js | 84 +++++ src/webgl/p5.Camera.js | 2 +- src/webgl/p5.Geometry.js | 2 +- test/types/basic.ts | 61 ++++ utils/convert.mjs | 482 ++----------------------- utils/data-processor.mjs | 265 ++++++++++++++ utils/generate-types.mjs | 87 ----- utils/helper.mjs | 716 ------------------------------------- utils/patch.mjs | 7 +- utils/shared-helpers.mjs | 152 ++++++++ utils/typescript.mjs | 444 +++++++++++++++++++++++ 16 files changed, 1226 insertions(+), 1421 deletions(-) create mode 100644 test/types/basic.ts create mode 100644 utils/data-processor.mjs delete mode 100644 utils/generate-types.mjs delete mode 100644 utils/helper.mjs create mode 100644 utils/shared-helpers.mjs create mode 100644 utils/typescript.mjs diff --git a/docs/parameterData.json b/docs/parameterData.json index 292dd65bee..05550f748a 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -31,6 +31,11 @@ ] ] }, + "remove": { + "overloads": [ + [] + ] + }, "p5": { "overloads": [ [ @@ -295,6 +300,31 @@ ] ] }, + "createVideo": { + "overloads": [ + [ + "String|String[]", + "Function?" + ] + ] + }, + "createAudio": { + "overloads": [ + [ + "String|String[]?", + "Function?" + ] + ] + }, + "createCapture": { + "overloads": [ + [ + "AUDIO|VIDEO|Object?", + "Object?", + "Function?" + ] + ] + }, "cursor": { "overloads": [ [ @@ -1262,6 +1292,13 @@ ] ] }, + "setContent": { + "overloads": [ + [ + "String" + ] + ] + }, "abs": { "overloads": [ [ @@ -1982,6 +2019,55 @@ ] ] }, + "getWorldInputs": { + "overloads": [ + [ + "Function" + ] + ] + }, + "combineColors": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getPixelInputs": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getFinalColor": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getColor": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getObjectInputs": { + "overloads": [ + [ + "Function" + ] + ] + }, + "getCameraInputs": { + "overloads": [ + [ + "Function" + ] + ] + }, "loadFont": { "overloads": [ [ @@ -2939,6 +3025,13 @@ ] ] }, + "roll": { + "overloads": [ + [ + "Number" + ] + ] + }, "camera": { "overloads": [ [ @@ -3023,98 +3116,38 @@ ] ] }, - "setAttributes": { - "overloads": [ - [ - "String", - "Boolean" - ], - [ - "Object" - ] - ] - }, - "remove": { - "overloads": [ - [] - ] - }, - "createVideo": { - "overloads": [ - [ - "String|String[]", - "Function?" - ] - ] - }, - "createAudio": { + "fromAxisAngle": { "overloads": [ [ - "String|String[]?", - "Function?" + "Number?", + "Number?", + "Number?", + "Number?" ] ] }, - "createCapture": { + "mult": { "overloads": [ [ - "AUDIO|VIDEO|Object?", - "Object?", - "Function?" + "p5.Quat?" ] ] - } - }, - "p5.Geometry": { - "flipV": { - "overloads": [ - [] - ] }, - "calculateBoundingBox": { - "overloads": [ - [] - ] - }, - "clearColors": { - "overloads": [ - [] - ] - }, - "flipU": { - "overloads": [ - [] - ] - }, - "computeFaces": { - "overloads": [ - [] - ] - }, - "computeNormals": { + "rotateBy": { "overloads": [ [ - "FLAT|SMOOTH?", - "Object?" + "p5.Quat?" ] ] }, - "makeEdgesFromFaces": { - "overloads": [ - [] - ] - }, - "normalize": { - "overloads": [ - [] - ] - }, - "vertexProperty": { + "setAttributes": { "overloads": [ [ "String", - "Number|Number[]", - "Number?" + "Boolean" + ], + [ + "Object" ] ] } @@ -4649,6 +4682,60 @@ ] } }, + "p5.Geometry": { + "calculateBoundingBox": { + "overloads": [ + [] + ] + }, + "clearColors": { + "overloads": [ + [] + ] + }, + "flipU": { + "overloads": [ + [] + ] + }, + "computeFaces": { + "overloads": [ + [] + ] + }, + "computeNormals": { + "overloads": [ + [ + "FLAT|SMOOTH?", + "Object?" + ] + ] + }, + "makeEdgesFromFaces": { + "overloads": [ + [] + ] + }, + "normalize": { + "overloads": [ + [] + ] + }, + "vertexProperty": { + "overloads": [ + [ + "String", + "Number|Number[]", + "Number?" + ] + ] + }, + "flipV": { + "overloads": [ + [] + ] + } + }, "p5.Shader": { "version": { "overloads": [ @@ -4662,6 +4749,10 @@ }, "modify": { "overloads": [ + [ + "Function", + "Object?" + ], [ "Object?" ] diff --git a/package.json b/package.json index 3c40095f3d..b7c447aff0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test": "vitest", "lint": "eslint .", "lint:fix": "eslint --fix .", - "generate-types": "npm run docs && node utils/generate-types.mjs && node utils/patch.mjs" + "generate-types": "npm run docs && node utils/typescript.mjs" }, "lint-staged": { "src/**/*.js": "eslint", diff --git a/src/color/p5.Color.js b/src/color/p5.Color.js index 888c9606d6..69dab96fb0 100644 --- a/src/color/p5.Color.js +++ b/src/color/p5.Color.js @@ -758,12 +758,16 @@ function color(p5, fn, lifecycles){ * instance of this class. * * @class p5.Color - * @param {p5} [pInst] pointer to p5 instance. + * @param {p5} pInst pointer to p5 instance. * * @param {Number[]|String} vals an array containing the color values * for red, green, blue and alpha channel * or CSS color. */ + /** + * @class p5.Color + * @param {Number[]|String} vals + */ p5.Color = Color; sRGB.fromGray = P3.fromGray = function(val, maxes, clamp){ diff --git a/src/core/constants.js b/src/core/constants.js index 3a03b62799..696dfb185c 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -584,92 +584,15 @@ export const TAU = _PI * 2; export const TWO_PI = _PI * 2; /** - * A `String` constant that's used to set the - * angleMode(). - * - * By default, functions such as rotate() and - * sin() expect angles measured in units of radians. - * Calling `angleMode(DEGREES)` ensures that angles are measured in units of - * degrees. - * - * Note: `TWO_PI` radians equals 360˚. - * - * @typedef {unique symbol} DEGREES - * @property {DEGREES} DEGREES + * @property {Number} DEG_TO_RAD * @final - * - * @example - *
- * - * function setup() { - * createCanvas(100, 100); - * - * background(200); - * - * // Draw a red arc from 0 to HALF_PI radians. - * fill(255, 0, 0); - * arc(50, 50, 80, 80, 0, HALF_PI); - * - * // Use degrees. - * angleMode(DEGREES); - * - * // Draw a blue arc from 90˚ to 180˚. - * fill(0, 0, 255); - * arc(50, 50, 80, 80, 90, 180); - * - * describe('The bottom half of a circle drawn on a gray background. The bottom-right quarter is red. The bottom-left quarter is blue.'); - * } - * - *
*/ -// export const DEGREES = Symbol('degrees'); +export const DEG_TO_RAD = _PI / 180.0; /** - * A `String` constant that's used to set the - * angleMode(). - * - * By default, functions such as rotate() and - * sin() expect angles measured in units of radians. - * Calling `angleMode(RADIANS)` ensures that angles are measured in units of - * radians. Doing so can be useful if the - * angleMode() has been set to - * DEGREES. - * - * Note: `TWO_PI` radians equals 360˚. - * - * @typedef {unique symbol} RADIANS - * @property {RADIANS} RADIANS + * @property {Number} RAD_TO_DEG * @final - * - * @example - *
- * - * function setup() { - * createCanvas(100, 100); - * - * background(200); - * - * // Use degrees. - * angleMode(DEGREES); - * - * // Draw a red arc from 0˚ to 90˚. - * fill(255, 0, 0); - * arc(50, 50, 80, 80, 0, 90); - * - * // Use radians. - * angleMode(RADIANS); - * - * // Draw a blue arc from HALF_PI to PI. - * fill(0, 0, 255); - * arc(50, 50, 80, 80, HALF_PI, PI); - * - * describe('The bottom half of a circle drawn on a gray background. The bottom-right quarter is red. The bottom-left quarter is blue.'); - * } - * - *
*/ -// export const RADIANS = Symbol('radians'); -export const DEG_TO_RAD = _PI / 180.0; export const RAD_TO_DEG = 180.0 / _PI; // SHAPE diff --git a/src/dom/p5.MediaElement.js b/src/dom/p5.MediaElement.js index e24bdf372a..ab851fce06 100644 --- a/src/dom/p5.MediaElement.js +++ b/src/dom/p5.MediaElement.js @@ -5,6 +5,20 @@ import { Element } from './p5.Element'; +/** + * @typedef {'video'} VIDEO + * @property {VIDEO} VIDEO + * @final + */ +const VIDEO = 'video'; + +/** + * @typedef {'audio'} AUDIO + * @property {AUDIO} AUDIO + * @final + */ +const AUDIO = 'audio'; + class Cue { constructor(callback, time, id, val) { this.callback = callback; @@ -1510,7 +1524,6 @@ function media(p5, fn){ /* CAMERA STUFF */ fn.VIDEO = VIDEO; - fn.AUDIO = AUDIO; // from: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia diff --git a/src/math/trigonometry.js b/src/math/trigonometry.js index 47e7e2c773..ba800be5f9 100644 --- a/src/math/trigonometry.js +++ b/src/math/trigonometry.js @@ -9,7 +9,91 @@ import * as constants from '../core/constants'; function trigonometry(p5, fn){ + /** + * A `String` constant that's used to set the + * angleMode(). + * + * By default, functions such as rotate() and + * sin() expect angles measured in units of radians. + * Calling `angleMode(DEGREES)` ensures that angles are measured in units of + * degrees. + * + * Note: `TWO_PI` radians equals 360˚. + * + * @typedef {unique symbol} DEGREES + * @property {DEGREES} DEGREES + * @final + * + * @example + *
+ * + * function setup() { + * createCanvas(100, 100); + * + * background(200); + * + * // Draw a red arc from 0 to HALF_PI radians. + * fill(255, 0, 0); + * arc(50, 50, 80, 80, 0, HALF_PI); + * + * // Use degrees. + * angleMode(DEGREES); + * + * // Draw a blue arc from 90˚ to 180˚. + * fill(0, 0, 255); + * arc(50, 50, 80, 80, 90, 180); + * + * describe('The bottom half of a circle drawn on a gray background. The bottom-right quarter is red. The bottom-left quarter is blue.'); + * } + * + *
+ */ const DEGREES = fn.DEGREES = 'degrees'; + + /** + * A `String` constant that's used to set the + * angleMode(). + * + * By default, functions such as rotate() and + * sin() expect angles measured in units of radians. + * Calling `angleMode(RADIANS)` ensures that angles are measured in units of + * radians. Doing so can be useful if the + * angleMode() has been set to + * DEGREES. + * + * Note: `TWO_PI` radians equals 360˚. + * + * @typedef {unique symbol} RADIANS + * @property {RADIANS} RADIANS + * @final + * + * @example + *
+ * + * function setup() { + * createCanvas(100, 100); + * + * background(200); + * + * // Use degrees. + * angleMode(DEGREES); + * + * // Draw a red arc from 0˚ to 90˚. + * fill(255, 0, 0); + * arc(50, 50, 80, 80, 0, 90); + * + * // Use radians. + * angleMode(RADIANS); + * + * // Draw a blue arc from HALF_PI to PI. + * fill(0, 0, 255); + * arc(50, 50, 80, 80, HALF_PI, PI); + * + * describe('The bottom half of a circle drawn on a gray background. The bottom-right quarter is red. The bottom-left quarter is blue.'); + * } + * + *
+ */ const RADIANS = fn.RADIANS = 'radians'; /* diff --git a/src/webgl/p5.Camera.js b/src/webgl/p5.Camera.js index b687f916c5..4c7b5e8b40 100644 --- a/src/webgl/p5.Camera.js +++ b/src/webgl/p5.Camera.js @@ -3848,7 +3848,7 @@ function camera(p5, fn){ * * @class p5.Camera * @constructor - * @param {rendererGL} rendererGL instance of WebGL renderer + * @param {RendererGL} rendererGL instance of WebGL renderer * * @example *
diff --git a/src/webgl/p5.Geometry.js b/src/webgl/p5.Geometry.js index 1efcc3d991..d3c413873f 100644 --- a/src/webgl/p5.Geometry.js +++ b/src/webgl/p5.Geometry.js @@ -1953,7 +1953,7 @@ function geometry(p5, fn){ * @class p5.Geometry * @param {Integer} [detailX] number of vertices along the x-axis. * @param {Integer} [detailY] number of vertices along the y-axis. - * @param {function} [callback] function to call once the geometry is created. + * @param {Function} [callback] function to call once the geometry is created. * * @example *
diff --git a/test/types/basic.ts b/test/types/basic.ts new file mode 100644 index 0000000000..88a970042d --- /dev/null +++ b/test/types/basic.ts @@ -0,0 +1,61 @@ +import '../../types/global.d.ts' + +let geom: p5.Geometry + +function setup() { + createCanvas(windowWidth, windowHeight, WEBGL) +} + +function regenerate() { + if (geom) { + freeGeometry(geom) + } + geom = buildGeometry(() => { + let n = round(random(5, 20)) + for (let i = 0; i <= n; i++) { + push() + translate( + random(-1, 1)*width*0.05, + map(i, 0, n, height*0.4, -height*0.4) + random(-1,1)*height*0.05 + ) + rotateX(PI/2 + random(-1, 1) * PI * 0.15) + rotateZ(random(-1, 1) * PI * 0.15) + torus( + random(0.1, 0.3) * width, + random(0.01, 0.05) * width, + 50, + 30 + ) + pop() + } + }) + geom.clearColors() +} + +let lastScene = -1 +function draw() { + const period = 8000 + + const ms = millis() + const scene = floor(ms / period) + if (scene !== lastScene) { + regenerate() + lastScene = scene + } + + const t = (ms % period)/period + background(0) + orbitControl() + const s = map(t, 0, 0.2, 0, 1, true) * map(t, 0.8, 1, 1, 0, true) + directionalLight(s*255, s*255, s*255, -0.4, 0, 1) + directionalLight(s*255, s*255, s*255, 0.4, 0, 1) + directionalLight(s*255, s*255, s*255, 0, -0.4, 1) + directionalLight(s*255, s*255, s*255, 0, 0.4, 1) + noStroke() + fill(100) + specularMaterial(255) + shininess(400) + scale(0.8) + rotateY(millis() * 0.0001) + model(geom) +} diff --git a/utils/convert.mjs b/utils/convert.mjs index ba56c59df0..00987eea32 100644 --- a/utils/convert.mjs +++ b/utils/convert.mjs @@ -1,106 +1,37 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import { getAllEntries } from './helper.mjs'; +import { processData } from './data-processor.mjs'; +import { descriptionString, typeObject } from './shared-helpers.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const data = JSON.parse(fs.readFileSync(path.join(__dirname, '../docs/data.json'))); -const allData = getAllEntries(data); +// Strategy for HTML documentation output (maintains exact convert.mjs behavior) +const htmlStrategy = { + shouldSkipEntry: () => false, // Don't skip anything (including Foundation) + + processDescription: (desc) => descriptionString(desc), + + processType: (type) => typeObject(type) +}; + +const processed = processData(data, htmlStrategy); const converted = { project: {}, // Unimplemented, probably not needed files: {}, // Unimplemented, probably not needed - modules: {}, - classes: {}, - classitems: [], + modules: processed.modules, + classes: processed.classes, + classitems: processed.classitems, warnings: [], // Intentionally unimplemented - consts: {} + consts: processed.consts }; -function descriptionString(node, parent) { - if (!node) { - return ''; - } else if (node.type === 'text') { - return node.value; - } else if (node.type === 'paragraph') { - const content = node.children.map(n => descriptionString(n, node)).join(''); - if (parent && parent.children.length === 1) return content; - return '

' + content + '

\n'; - } else if (node.type === 'code') { - let classes = []; - let attrs = ''; - if (node.lang) { - classes.push(`language-${node.lang}`); - } - if (node.meta) { - classes.push(node.meta); - } - if (classes.length > 0) { - attrs=` class="${classes.join(' ')}"`; - } - return `
${node.value}
`; - } else if (node.type === 'inlineCode') { - return '' + node.value + ''; - } else if (node.type === 'list') { - const tag = node.type === 'ordered' ? 'ol' : 'ul'; - return `<${tag}>` + node.children.map(n => descriptionString(n, node)).join('') + ``; - } else if (node.type === 'listItem') { - return '
  • ' + node.children.map(n => descriptionString(n, node)).join('') + '
  • '; - } else if (node.value) { - return node.value; - } else if (node.children) { - return node.children.map(n => descriptionString(n, node)).join(''); - } else { - return ''; - } -} - -function typeObject(node) { - if (!node) return {}; - - if (node.type === 'OptionalType') { - return { optional: 1, ...typeObject(node.expression) }; - } else if (node.type === 'UnionType') { - const names = node.elements.map(n => typeObject(n).type); - return { - type: names.join('|') - }; - } else if (node.type === 'TypeApplication') { - const { type: typeName } = typeObject(node.expression); - if ( - typeName === 'Array' && - node.applications.length === 1 - ) { - return { - type: `${typeObject(node.applications[0]).type}[]` - }; - } - const args = node.applications.map(n => typeObject(n).type); - return { - type: `${typeName}<${args.join(', ')}>` - }; - } else if (node.type === 'UndefinedLiteral') { - return { type: 'undefined' }; - } else if (node.type === 'FunctionType') { - let signature = `function(${node.params.map(p => typeObject(p).type).join(', ')})`; - if (node.result) { - signature += `: ${typeObject(node.result).type}`; - } - return { type: signature }; - } else if (node.type === 'ArrayType') { - return { type: `[${node.elements.map(e => typeObject(e).type).join(', ')}]` }; - } else if (node.type === 'RestType') { - return { type: typeObject(node.expression).type, rest: true }; - } else { - // TODO - // - handle record types - return { type: node.name }; - } -} +// Register constant usage for the original convert.mjs functionality const constUsage = {}; function registerConstantUsage(name, memberof, node) { if (!node) return; @@ -123,375 +54,20 @@ function registerConstantUsage(name, memberof, node) { } } -function locationInfo(node) { - return { - file: node.context.file.slice(node.context.file.indexOf('src/')), - line: node.context.loc.start.line - }; -} - -function deprecationInfo(node) { - if (!node.deprecated) { - return {}; - } - - return { - deprecated: true, - deprecationMessage: descriptionString(node.deprecated) - }; -} - -function getExample(node) { - return node.description; -} - -function getAlt(node) { - return node - .tags - .filter(tag => tag.title === 'alt') - .map(tag => tag.description) - .join('\n') || undefined; -} - -// ============================================================================ -// Modules -// ============================================================================ -const fileModuleInfo = {}; -const modules = {}; -const submodules = {}; -for (const entry of allData) { - if (entry.tags.some(tag => tag.title === 'module')) { - const module = entry.tags.find(tag => tag.title === 'module').name; - - const submoduleTag = entry.tags.find(tag => tag.title === 'submodule'); - const submodule = submoduleTag ? submoduleTag.description : undefined; - - // TODO handle methods in classes that don't have this - const forTag = entry.tags.find(tag => tag.title === 'for'); - const forEntry = forTag ? forTag.description : undefined; - - const file = entry.context.file; - - // Record what module/submodule each file is attached to so that we can - // look this info up for each method based on its file - fileModuleInfo[file] = fileModuleInfo[file] || { - module: undefined, - submodule: undefined, - for: undefined - }; - fileModuleInfo[file].module = module; - fileModuleInfo[file].submodule = - fileModuleInfo[file].submodule || submodule; - fileModuleInfo[file].for = - fileModuleInfo[file].for || forEntry; - - modules[module] = modules[module] || { - name: module, - submodules: {}, - classes: {} - }; - if (submodule) { - modules[module].submodules[submodule] = 1; - submodules[submodule] = submodules[submodule] || { - name: submodule, - module, - is_submodule: 1 - }; - } - } -} -for (const key in modules) { - converted.modules[key] = modules[key]; -} -for (const key in submodules) { - // Some modules also list themselves as submodules as a default category - // of sorts. Skip adding these submodules to not overwrite the module itself. - if (converted.modules[key]) continue; - converted.modules[key] = submodules[key]; -} - -function getModuleInfo(entry) { - const entryForTag = entry.tags.find(tag => tag.title === 'for'); - const entryForTagValue = entryForTag && entryForTag.description; - const file = entry.context.file; - let { module, submodule, for: forEntry } = fileModuleInfo[file] || {}; - let memberof = entry.memberof; - if (memberof === 'fn') memberof = 'p5'; - if (memberof && memberof !== 'p5' && !memberof.startsWith('p5.')) { - memberof = 'p5.' + memberof; +// Register constant usage from processed data +for (const item of converted.classitems) { + if (item.itemtype === 'property' && (item.name in converted.consts || item.kind === 'constant' || item.kind === 'typedef')) { + constUsage[item.name] = constUsage[item.name] || new Set(); } - forEntry = memberof || entryForTagValue || forEntry; - return { module, submodule, forEntry }; -} - -function getParams(entry) { - // Documentation.js seems to try to grab params from the function itself in - // the code if we don't document all the parameters. This messes with our - // manually-documented overloads. Instead of using the provided entry.params - // array, we'll instead only rely on manually included @param tags. - // - // However, the tags don't include a tree-structured description field, and - // instead convert it to a string. We want a slightly different conversion to - // string, so we match these params to the Documentation.js-provided `params` - // array and grab the description from those. - return (entry.tags || []) - - // Filter out the nested parameters (eg. options.extrude), - // to be treated as part of parent parameters (eg. options) - // and not separate entries - .filter(t => t.title === 'param' && !t.name.includes('.')) - .map(node => { - const param = (entry.params || []) - .find(param => param.name === node.name); - return { - ...node, - description: param?.description || { - type: 'html', - value: node.description - } - }; - }); -} - -// ============================================================================ -// Constants -// ============================================================================ -for (const entry of allData) { - if (entry.kind === 'constant' || entry.kind === 'typedef') { - constUsage[entry.name] = constUsage[entry.name] || new Set(); - - const { module, submodule, forEntry } = getModuleInfo(entry); - - const examples = entry.examples.map(getExample); - const item = { - itemtype: 'property', - name: entry.name, - ...locationInfo(entry), - ...typeObject(entry.type), - ...deprecationInfo(entry), - description: descriptionString(entry.description), - example: examples.length > 0 ? examples : undefined, - alt: getAlt(entry), - module, - submodule, - class: forEntry || 'p5' - }; - - converted.classitems.push(item); - } -} - -// ============================================================================ -// Classes -// ============================================================================ -for (const entry of allData) { - if (entry.kind === 'class') { - const { module, submodule } = getModuleInfo(entry); - - const item = { - name: entry.name, - ...locationInfo(entry), - ...deprecationInfo(entry), - extends: entry.augments && entry.augments[0] && entry.augments[0].name, - description: descriptionString(entry.description), - example: entry.examples.map(getExample), - alt: getAlt(entry), - params: getParams(entry).map(p => { - return { - name: p.name, - description: p.description && descriptionString(p.description), - ...typeObject(p.type) - }; - }), - return: entry.returns[0] && { - description: descriptionString(entry.returns[0].description), - ...typeObject(entry.returns[0].type) - }, - is_constructor: 1, - module, - submodule - }; - - // The @private tag doesn't seem to end up in the Documentation.js output. - // However, it also doesn't seem to grab the description in this case, so - // I'm using this as a proxy to let us know that a class should be private. - // This means any public class *must* have a description. - const isPrivate = !item.description; - if (!isPrivate) { - converted.classes[item.name] = item; - } - } -} - -// ============================================================================ -// Class properties -// ============================================================================ -const propDefs = {}; - -// Grab properties out of the class nodes. These should have all the properties -// but very little of their metadata. -for (const entry of allData) { - if (entry.kind !== 'class') continue; - - // Ignore private classes - if (!converted.classes[entry.name]) continue; - - if (!entry.properties) continue; - - const { module, submodule } = getModuleInfo(entry); - const location = locationInfo(entry); - propDefs[entry.name] = propDefs[entry.name] || {}; - - for (const property of entry.properties) { - const item = { - itemtype: 'property', - name: property.name, - ...location, - line: property.lineNumber || location.line, - ...typeObject(property.type), - ...deprecationInfo(entry), - module, - submodule, - class: entry.name - }; - propDefs[entry.name][property.name] = item; - } -} - -// Grab property metadata out of other loose nodes. -for (const entry of allData) { - // These are in a different section - if (entry.kind === 'constant') continue; - - const { module, submodule, forEntry } = getModuleInfo(entry); - const propTag = entry.tags.find(tag => tag.title === 'property'); - const forTag = entry.tags.find(tag => tag.title === 'for'); - let memberof = entry.memberof; - if (memberof === 'fn') memberof = 'p5'; - if (memberof && memberof !== 'p5' && !memberof.startsWith('p5.')) { - memberof = 'p5.' + memberof; - } - if (!propTag || (!forEntry && !forTag && !memberof)) continue; - - const forName = memberof || (forTag && forTag.description) || forEntry; - propDefs[forName] = propDefs[forName] || {}; - const classEntry = propDefs[forName]; - if (!classEntry) continue; - - registerConstantUsage(entry.type); - - const prop = classEntry[propTag.name] || { - itemtype: 'property', - name: propTag.name, - ...locationInfo(entry), - ...typeObject(propTag.type), - ...deprecationInfo(entry), - module, - submodule, - class: forName - }; - - const updated = { - ...prop, - example: entry.examples.map(getExample), - alt: getAlt(entry), - description: descriptionString(entry.description) - }; - classEntry[propTag.name] = updated; -} - -// Add to the list -for (const className in propDefs) { - for (const propName in propDefs[className]) { - converted.classitems.push(propDefs[className][propName]); - } -} - -// ============================================================================ -// Class methods -// ============================================================================ -const classMethods = {}; -for (const entry of allData) { - if (entry.kind === 'function' && entry.properties.length === 0) { - const { module, submodule, forEntry } = getModuleInfo(entry); - - let memberof = entry.memberof; - if (memberof === 'fn') memberof = 'p5'; - if (memberof && memberof !== 'p5' && !memberof.startsWith('p5.')) { - memberof = 'p5.' + memberof; - } - - // Ignore functions that aren't methods - if (entry.tags.some(tag => tag.title === 'function')) continue; - - // If a previous version of this same method exists, then this is probably - // an overload on that method - const prevItem = (classMethods[memberof] || {})[entry.name] || {}; - - const className = memberof || prevItem.class || forEntry; - - // Ignore methods of private classes - if (!converted.classes[className]) continue; - - // Ignore private methods. @private-tagged ones don't show up in the JSON, - // but we also implicitly use this _-prefix convension. - const isPrivate = entry.name.startsWith('_'); - if (isPrivate) continue; - - for (const param of getParams(entry)) { - registerConstantUsage(entry.name, className, param.type); - } - if (entry.returns[0]) { - registerConstantUsage(entry.returns[0].type); + if (item.itemtype === 'method') { + for (const overload of item.overloads || []) { + for (const param of overload.params || []) { + registerConstantUsage(item.name, item.class, param.type); + } + if (overload.return) { + registerConstantUsage(item.name, item.class, overload.return.type); + } } - - const item = { - name: entry.name, - ...locationInfo(entry), - ...deprecationInfo(entry), - itemtype: 'method', - chainable: (prevItem.chainable || entry.tags.some(tag => tag.title === 'chainable')) - ? 1 - : undefined, - description: prevItem.description || descriptionString(entry.description), - example: [ - ...(prevItem.example || []), - ...entry.examples.map(getExample) - ], - alt: getAlt(entry), - overloads: [ - ...(prevItem.overloads || []), - { - params: getParams(entry).map(p => { - return { - name: p.name, - description: p.description && descriptionString(p.description), - ...typeObject(p.type) - }; - }), - return: entry.returns[0] && { - description: descriptionString(entry.returns[0].description), - ...typeObject(entry.returns[0].type) - } - } - ], - return: prevItem.return || entry.returns[0] && { - description: descriptionString(entry.returns[0].description), - ...typeObject(entry.returns[0].type) - }, - class: className, - static: entry.scope === 'static' && 1, - module, - submodule - }; - - classMethods[memberof] = classMethods[memberof] || {}; - classMethods[memberof][entry.name] = item; - } -} -for (const className in classMethods) { - for (const methodName in classMethods[className]) { - converted.classitems.push(classMethods[className][methodName]); } } diff --git a/utils/data-processor.mjs b/utils/data-processor.mjs new file mode 100644 index 0000000000..a3b6bf96aa --- /dev/null +++ b/utils/data-processor.mjs @@ -0,0 +1,265 @@ +import { getAllEntries } from './shared-helpers.mjs'; +import { getParams } from './shared-helpers.mjs'; + +/** + * Common data processing logic that can be used by both convert.mjs and typescript.mjs + * with different strategies for type conversion and output formatting. + */ + +export function processData(rawData, strategy) { + const allData = getAllEntries(rawData); + + const processed = { + modules: {}, + classes: {}, + classitems: [], + consts: {}, + classMethods: {} + }; + + // Build module info lookup (exact same logic as convert.mjs) + const fileModuleInfo = {}; + const modules = {}; + const submodules = {}; + + for (const entry of allData) { + if (entry.tags?.some(tag => tag.title === 'module')) { + const module = entry.tags.find(tag => tag.title === 'module').name; + const submoduleTag = entry.tags.find(tag => tag.title === 'submodule'); + const submodule = submoduleTag ? submoduleTag.description : undefined; + const forTag = entry.tags.find(tag => tag.title === 'for'); + const forEntry = forTag ? forTag.description : undefined; + const file = entry.context.file; + + fileModuleInfo[file] = fileModuleInfo[file] || { + module: undefined, + submodule: undefined, + for: undefined + }; + fileModuleInfo[file].module = module; + fileModuleInfo[file].submodule = fileModuleInfo[file].submodule || submodule; + fileModuleInfo[file].for = fileModuleInfo[file].for || forEntry; + + modules[module] = modules[module] || { + name: module, + submodules: {}, + classes: {} + }; + if (submodule) { + modules[module].submodules[submodule] = 1; + submodules[submodule] = submodules[submodule] || { + name: submodule, + module, + is_submodule: 1 + }; + } + } + } + + // Copy modules to processed data + for (const key in modules) { + processed.modules[key] = modules[key]; + } + for (const key in submodules) { + if (processed.modules[key]) continue; + processed.modules[key] = submodules[key]; + } + + function getModuleInfo(entry) { + const entryForTag = entry.tags?.find(tag => tag.title === 'for'); + const entryForTagValue = entryForTag?.description; + const file = entry.context?.file; + let { module, submodule, for: forEntry } = fileModuleInfo[file] || {}; + let memberof = entry.memberof; + if (memberof === 'fn') memberof = 'p5'; + if (memberof && memberof !== 'p5' && !memberof.startsWith('p5.')) { + memberof = 'p5.' + memberof; + } + forEntry = memberof || entryForTagValue || forEntry; + return { module, submodule, forEntry }; + } + + function locationInfo(entry) { + return { + file: entry.context?.file ? entry.context.file.slice(entry.context.file.indexOf('src/')) : '', + line: entry.context?.loc?.start?.line || 1 + }; + } + + function deprecationInfo(entry) { + if (!entry.deprecated) { + return {}; + } + return { + deprecated: true, + deprecationMessage: strategy.processDescription(entry.deprecated) + }; + } + + function getExample(entry) { + return entry.description; + } + + function getAlt(entry) { + return entry + .tags + ?.filter(tag => tag.title === 'alt') + ?.map(tag => tag.description) + ?.join('\n') || undefined; + } + + // Process constants and typedefs + for (const entry of allData) { + if (entry.kind === 'constant' || entry.kind === 'typedef') { + const { module, submodule, forEntry } = getModuleInfo(entry); + + // Apply strategy filter + if (strategy.shouldSkipEntry && strategy.shouldSkipEntry(entry, { module, submodule, forEntry })) { + continue; + } + + const examples = entry.examples?.map(getExample) || []; + const item = { + itemtype: 'property', + name: entry.name, + ...locationInfo(entry), + ...strategy.processType(entry.type), + ...deprecationInfo(entry), + description: strategy.processDescription(entry.description), + example: examples.length > 0 ? examples : undefined, + alt: getAlt(entry), + module, + submodule, + class: forEntry || 'p5' + }; + + processed.classitems.push(item); + processed.consts[entry.name] = item; + } + } + + // Process classes + for (const entry of allData) { + if (entry.kind === 'class') { + const { module, submodule } = getModuleInfo(entry); + + // Apply strategy filter + if (strategy.shouldSkipEntry && strategy.shouldSkipEntry(entry, { module, submodule })) { + continue; + } + + const item = { + name: entry.name, + ...locationInfo(entry), + ...deprecationInfo(entry), + extends: entry.augments?.[0]?.name, + description: strategy.processDescription(entry.description), + example: entry.examples?.map(getExample) || [], + alt: getAlt(entry), + params: getParams(entry).map(p => ({ + name: p.name, + description: p.description && strategy.processDescription(p.description), + ...strategy.processType(p.type) + })), + return: entry.returns?.[0] && { + description: strategy.processDescription(entry.returns[0].description), + ...strategy.processType(entry.returns[0].type) + }, + is_constructor: 1, + module, + submodule + }; + + // The @private tag doesn't seem to end up in the Documentation.js output. + // However, it also doesn't seem to grab the description in this case, so + // I'm using this as a proxy to let us know that a class should be private. + // This means any public class *must* have a description. + const isPrivate = !item.description; + if (!isPrivate) { + processed.classes[item.name] = item; + } + } + } + + // Process methods and functions + for (const entry of allData) { + if (entry.kind === 'function' && entry.properties?.length === 0) { + const { module, submodule, forEntry } = getModuleInfo(entry); + + // Apply strategy filter + if (strategy.shouldSkipEntry && strategy.shouldSkipEntry(entry, { module, submodule, forEntry })) { + continue; + } + + // Skip functions that aren't methods + if (entry.tags?.some(tag => tag.title === 'function')) continue; + + let memberof = entry.memberof; + if (memberof === 'fn') memberof = 'p5'; + if (memberof && memberof !== 'p5' && !memberof.startsWith('p5.')) { + memberof = 'p5.' + memberof; + } + + const className = memberof || forEntry || 'p5'; + + // Skip private methods + if (entry.name.startsWith('_')) continue; + + // Skip methods of private classes + if (!processed.classes[className] && className !== 'p5') continue; + + // Check for existing method (overloads) + const prevItem = processed.classMethods[className]?.[entry.name]; + + const item = { + name: entry.name, + ...locationInfo(entry), + ...deprecationInfo(entry), + itemtype: 'method', + chainable: (prevItem?.chainable || entry.tags?.some(tag => tag.title === 'chainable')) + ? 1 + : undefined, + description: prevItem?.description || strategy.processDescription(entry.description), + example: [ + ...(prevItem?.example || []), + ...entry.examples?.map(getExample) || [] + ], + alt: getAlt(entry), + overloads: [ + ...(prevItem?.overloads || []), + { + params: getParams(entry).map(p => ({ + name: p.name, + description: p.description && strategy.processDescription(p.description), + ...strategy.processType(p.type) + })), + return: entry.returns?.[0] && { + description: strategy.processDescription(entry.returns[0].description), + ...strategy.processType(entry.returns[0].type) + } + } + ], + return: prevItem?.return || entry.returns?.[0] && { + description: strategy.processDescription(entry.returns[0].description), + ...strategy.processType(entry.returns[0].type) + }, + class: className, + static: entry.scope === 'static' && 1, + module, + submodule + }; + + processed.classMethods[className] = processed.classMethods[className] || {}; + processed.classMethods[className][entry.name] = item; + } + } + + // Add classMethods to classitems for compatibility + for (const className in processed.classMethods) { + for (const methodName in processed.classMethods[className]) { + processed.classitems.push(processed.classMethods[className][methodName]); + } + } + + return processed; +} \ No newline at end of file diff --git a/utils/generate-types.mjs b/utils/generate-types.mjs deleted file mode 100644 index 6927eead4b..0000000000 --- a/utils/generate-types.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { - generateTypeDefinitions, - normalizeIdentifier -} from "./helper.mjs"; - -// Fix for __dirname equivalent in ES modules -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const data = JSON.parse(fs.readFileSync(path.join(__dirname, '../docs/data.json'))); - -function findDtsFiles(dir, files = []) { - // Only search in src directory - const srcDir = path.join(__dirname, '../types'); - if (!dir.startsWith(srcDir)) { - dir = srcDir; - } - - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - findDtsFiles(fullPath, files); - } else if (entry.name.endsWith('.d.ts')) { - // Get path relative to project root and normalize to forward slashes - const relativePath = path.relative(path.join(__dirname, '../types'), fullPath) - .split(path.sep) - .join('/'); - files.push(relativePath); - } - } - return files; -} - -export function generateAllDeclarationFiles() { - const { - p5Types: rawP5Types, - globalTypes, - fileTypes - } = generateTypeDefinitions(data); - const typesDir = path.join(process.cwd(), 'types'); - fs.mkdirSync(typesDir, { recursive: true }); - - // Write file-specific type definitions - fileTypes.forEach((content, filePath) => { - const parsedPath = path.parse(filePath); - const relativePath = path.relative( - path.join(__dirname, '../src'), - filePath - ); - const dtsPath = path.join( - path.relative(process.cwd(), typesDir), - path.dirname(relativePath), - `${parsedPath.name}.d.ts` - ); - - const exportName = normalizeIdentifier(parsedPath.name.replace('.', '_')); - const contentWithExport = content + `export default function ${exportName}(p5: any, fn: any): void;\n`; - - fs.mkdirSync(path.dirname(dtsPath), { recursive: true }); - fs.writeFileSync(dtsPath, contentWithExport, 'utf8'); - console.log(`Generated ${dtsPath}`); - }); - - // Add .d.ts references to p5Types - let p5Types = '// This file is auto-generated from JSDoc documentation\n\n'; - p5Types += '/// \n'; - - // Add references to all other .d.ts files - const dtsFiles = findDtsFiles(path.join(__dirname, '..')); - for (const file of dtsFiles) { - if (file === 'p5.d.ts') - continue; - p5Types += `/// \n`; - } - p5Types += '\n'; - p5Types += rawP5Types; - - fs.writeFileSync(path.join(typesDir, 'p5.d.ts'), p5Types, 'utf8'); - fs.writeFileSync(path.join(typesDir, 'global.d.ts'), globalTypes, 'utf8'); -} - -generateAllDeclarationFiles(); diff --git a/utils/helper.mjs b/utils/helper.mjs deleted file mode 100644 index e6a0de64d5..0000000000 --- a/utils/helper.mjs +++ /dev/null @@ -1,716 +0,0 @@ -function getEntries(entry) { - return [ - entry, - ...getAllEntries(entry.members?.global || []), - ...getAllEntries(entry.members?.inner || []), - ...getAllEntries(entry.members?.instance || []), - ...getAllEntries(entry.members?.events || []), - ...getAllEntries(entry.members?.static || []) - ]; -} - -export function getAllEntries(arr = []) { - return arr.flatMap(entry => entry ? getEntries(entry) : []); -} -export function normalizeClassName(className) { - if (!className || className === 'p5') return 'p5'; - return className.startsWith('p5.') ? className : `p5.${className}`; -} - -export function generateTypeDefinitions(data) { - - const organized = organizeData(data); - - return { - p5Types: generateP5TypeDefinitions(organized), - globalTypes: generateGlobalTypeDefinitions(organized), - fileTypes: generateFileTypeDefinitions(organized, data) - }; -} -function generateP5TypeDefinitions(organizedData) { - let output = '// This file is auto-generated from JSDoc documentation\n\n'; - - output += `declare class p5 {\n`; - output += ` constructor(sketch?: (p: p5) => void, node?: HTMLElement, sync?: boolean);\n\n`; - const instanceItems = organizedData.classitems.filter(item => - item.class === 'p5' && !item.isStatic - ); - instanceItems.forEach(item => { - output += generateMethodDeclarations(item, false); - }); - - const staticItems = organizedData.classitems.filter(item => - item.class === 'p5' && item.isStatic - ); - staticItems.forEach(item => { - output += generateMethodDeclarations(item, true); - }); - - Object.values(organizedData.consts).forEach(constData => { - if (constData.class === 'p5') { - if (constData.description) { - output += ` /**\n * ${constData.description}\n */\n`; - } - output += ` readonly ${constData.name}: ${constData.typeName};\n\n`; - - if (constData.name === 'VERSION') { // #todo: hardcoded special case - if (constData.description) { - output += ` /**\n * ${constData.description}\n */\n`; - } - output += ` static readonly ${constData.name}: ${constData.typeName};\n\n`; - } - } - }); - - output += `}\n\n`; - - output += `declare namespace p5 {\n`; - - Object.values(organizedData.consts).forEach(constData => { - if (constData.kind === 'typedef') { - if (constData.description) { - output += ` /**\n * ${constData.description}\n */\n`; - } - output += ` type ${constData.name} = ${constData.type};\n\n`; - } - }); - - Object.values(organizedData.classes).forEach(classDoc => { - if (classDoc.name !== 'p5') { - output += generateClassDeclaration(classDoc, organizedData); - } - }); - output += `}\n\n`; - - output += `export default p5;\n`; - output += `export as namespace p5;\n`; - - return output; - } - -function generateGlobalTypeDefinitions(organizedData) { - let output = '// This file is auto-generated from JSDoc documentation\n\n'; - output += `import p5 from 'p5';\n\n`; - output += `declare global {\n`; - - const instanceItems = organizedData.classitems.filter(item => - item.class === 'p5' && !item.isStatic - ); - instanceItems.forEach(item => { - if (item.kind === 'function') { - if (item.description) { - output += ` /**\n${formatJSDocComment(item.description, 2)}\n */\n`; - } - - if (item.overloads?.length > 0) { - item.overloads.forEach(overload => { - const params = (overload.params || []) - .map(param => generateParamDeclaration(param)) - .join(', '); - const returnType = overload.returns?.[0]?.type - ? generateTypeFromTag(overload.returns[0]) - : 'void'; - output += ` function ${item.name}(${params}): ${returnType};\n`; - }); - } - - const params = (item.params || []) - .map(param => generateParamDeclaration(param)) - .join(', '); - output += ` function ${item.name}(${params}): ${item.returnType};\n\n`; - } - }); - - Object.values(organizedData.consts).forEach(constData => { - if (constData.kind === 'constant') { - if (constData.description) { - output += ` /**\n${formatJSDocComment(constData.description, 2)}\n */\n`; - } - output += ` const ${constData.name}: ${constData.typeName};\n\n`; - } - }); - - output += ` interface Window {\n`; - - instanceItems.forEach(item => { - if (item !== instanceItems.find(x => x.name === item.name)) - return; - if (item.kind === 'function') { - output += ` ${item.name}: typeof ${item.name};\n`; - } - }); - - Object.values(organizedData.consts).forEach(constData => { - if (constData.kind === 'constant') { - if (constData.description) { - output += ` /**\n * ${constData.description}\n */\n`; - } - output += ` readonly ${constData.name}: ${constData.typeName};\n`; - } - }); - - output += ` }\n`; - output += `}\n\n`; - output += `export {};\n`; - - return output; - } - -function generateFileTypeDefinitions(organizedData, data) { - const fileDefinitions = new Map(); - const fileGroups = groupByFile(getAllEntries(data)); - - fileGroups.forEach((items, filePath) => { - const declarationContent = generateDeclarationFile(items, organizedData); - fileDefinitions.set(filePath, declarationContent); - }); - - return fileDefinitions; - } - const organized = { - modules: {}, - classes: {}, - classitems: [], - consts: {} - }; - -function generateDeclarationFile(items, organizedData) { - let output = '// This file is auto-generated from JSDoc documentation\n\n'; - const imports = new Set([`import p5 from 'p5';`]); - const hasColorDependency = items.some(item => { - const typeName = item.type?.name; - const desc = extractDescription(item.description); - return typeName === 'Color' || (typeof desc === 'string' && desc.includes('Color')); - }); - - const hasVectorDependency = items.some(item => { - const typeName = item.type?.name; - const desc = extractDescription(item.description); - return typeName === 'Vector' || (typeof desc === 'string' && desc.includes('Vector')); - }); - - const hasConstantsDependency = items.some(item => - item.tags?.some(tag => tag.title === 'requires' && tag.description === 'constants') - ); - - if (hasColorDependency) { - imports.add(`import { Color } from '../color/p5.Color';`); - } - if (hasVectorDependency) { - imports.add(`import { Vector } from '../math/p5.Vector';`); - } - if (hasConstantsDependency) { - imports.add(`import * as constants from '../core/constants';`); - } - - output += Array.from(imports).join('\n') + '\n\n'; - output += `declare module 'p5' {\n`; - - const classDoc = items.find(item => item.kind === 'class'); - if (classDoc) { - const fullClassName = normalizeClassName(classDoc.name); - const classDocName = fullClassName.replace('p5.', ''); - let parentClass = classDoc.tags?.find(tag => tag.title === 'extends')?.name; - if (parentClass) { - parentClass = parentClass.replace('p5.', ''); - } - const extendsClause = parentClass ? ` extends ${parentClass}` : ''; - - output += ` class ${classDocName}${extendsClause} {\n`; - - if (classDoc.params?.length > 0) { - output += ' constructor('; - output += classDoc.params - .map(param => generateParamDeclaration(param)) - .join(', '); - output += ');\n\n'; - } - - const classItems = organizedData.classitems.filter(item => - item.class === fullClassName || - item.class === fullClassName.replace('p5.', '') - ); - - const staticItems = classItems.filter(item => item.isStatic); - const instanceItems = classItems.filter(item => !item.isStatic); - staticItems.forEach(item => { - output += generateMethodDeclarations(item, true); - }); - instanceItems.forEach(item => { - output += generateMethodDeclarations(item, false); - }); - output += ' }\n\n'; - } - - items.forEach(item => { - if (item.kind !== 'class' && (!item.memberof || normalizeClassName(item.memberof) !== normalizeClassName(classDoc?.name))) { - switch (item.kind) { - case 'function': - if (!item.name.startsWith("_")) - output += generateFunctionDeclaration(item); - break; - case 'constant': - case 'typedef': - const constData = organizedData.consts[item.name]; - if (constData) { - if (constData.kind === 'typedef') { - if (constData.description) { - output += ` /**\n * ${constData.description}\n */\n`; - } - output += ` type ${constData.name} = ${constData.type};\n\n`; - } - - if (constData.description) { - output += ` /**\n * ${constData.description}\n */\n`; - } - output += ` const ${constData.name}: ${constData.typeName};\n\n`; - } - break; - } - } - }); - - output += '}\n\n'; - - return output; - } - - // #todo: repeated in convert.mjs - function getParams(entry) { - // Documentation.js seems to try to grab params from the function itself in - // the code if we don't document all the parameters. This messes with our - // manually-documented overloads. Instead of using the provided entry.params - // array, we'll instead only rely on manually included @param tags. - // - // However, the tags don't include a tree-structured description field, and - // instead convert it to a string. We want a slightly different conversion to - // string, so we match these params to the Documentation.js-provided `params` - // array and grab the description from those. - return (entry.tags || []) - - // Filter out the nested parameters (eg. options.extrude), - // to be treated as part of parent parameters (eg. options) - // and not separate entries - .filter(t => t.title === 'param' && !t.name.includes('.')) - .map(node => { - const param = (entry.params || []).find(param => param.name === node.name); - return { - name: normalizeIdentifier(node.name), - description: extractDescription(param?.description || { - type: 'text', // 'html', - value: node.description - }), - type: node.type, // generateTypeFromTag(node), - optional: node.type?.type === 'OptionalType', - rest: node.type?.type === 'RestType', - }; - }); - } - - export function organizeData(data) { - const allData = getAllEntries(data); - - organized.modules = {}; - organized.classes = {}; - organized.classitems = []; - organized.consts = {}; - - allData.forEach(entry => { - const { module, submodule, forEntry } = getModuleInfo(entry); - const className = normalizeClassName(forEntry || entry.memberof || 'p5'); - - switch(entry.kind) { - case 'class': - organized.classes[className] = { - name: entry.name, - description: extractDescription(entry.description), - params: getParams(entry), - module, - submodule, - extends: entry.tags?.find(tag => tag.title === 'extends')?.name || null - }; break; - case 'function': - case 'property': // #todo: unreachable - case 'member': - const overloads = entry.overloads?.map(overload => ({ // #todo: unreachable - params: overload.params, - returns: overload.returns, - description: extractDescription(overload.description) - })); - - organized.classitems.push({ - // #todo: assumes all members are accessors - memberName: entry.name, - name: entry.kind === 'member' - ? entry.returns?.length > 0 ? `get ${entry.name}` : `set ${entry.name}` - : entry.name, - kind: entry.kind, - // #todo: method (and sometimes param) descriptions only present on first overload - description: extractDescription(entry.description), - params: getParams(entry), - returnType: entry.tags?.find(tag => tag.title === 'chainable') - ? 'this' - : entry.returns?.[0] - ? generateTypeFromTag(entry.returns[0]) - : 'void', - module, - submodule, - class: className, - isStatic: entry.path?.[0]?.scope === 'static', - overloads - }); break; - case 'constant': - case 'typedef': - let type = generateTypeFromTag(entry); - if (type === 'any') { - // find fallback type from property - const property = entry.properties?.find(x => x.name === entry.name); - const type2 = generateTypeFromTag(property); - if (type2 !== entry.name) { - type = type2; - } - } - organized.consts[entry.name] = { - name: entry.name, - kind: entry.kind, - description: extractDescription(entry.description), - type, - typeName: entry.kind === 'typedef' ? `p5.${entry.name}` : type, - module, - submodule, - class: forEntry || 'p5' - }; break; - } - }); - return organized; - } - - export function getModuleInfo(entry) { - return { - module: entry.tags?.find(tag => tag.title === 'module')?.name || 'p5', - submodule: entry.tags?.find(tag => tag.title === 'submodule')?.description || null, - forEntry: entry.tags?.find(tag => tag.title === 'for')?.description || entry.memberof - }; -} -export function extractDescription(desc) { - if (!desc) return ''; - if (typeof desc === 'string') return desc; - if (desc.children) { - return desc.children.map(child => { - if (child.type === 'text') return child.value; - if (child.type === 'paragraph') return extractDescription(child); - if (child.type === 'inlineCode' || child.type === 'code') return `\`${child.value}\``; - return ''; - }) - .join('').trim().replace(/\n{3,}/g, '\n\n'); - } - return ''; - } -export function generateTypeFromTag(param) { - if (!param || !param.type) return 'any'; - - switch (param.type.type) { - case 'NameExpression': - return normalizeTypeName(param.type.name); - case 'TypeApplication': { - const baseType = normalizeTypeName(param.type.expression.name, { inApplication: true }); - - if (baseType === 'Array') { - const innerType = param.type.applications[0]; - const innerTypeStr = generateTypeFromTag({ type: innerType }); - return `${innerTypeStr}[]`; - } - - const typeParams = param.type.applications - .map(app => generateTypeFromTag({ type: app })) - .join(', '); - return `${baseType}<${typeParams}>`; - } - case 'UnionType': - const unionTypes = param.type.elements - .map(el => generateTypeFromTag({ type: el })) - .join(' | '); - return unionTypes; - case 'OptionalType': - return generateTypeFromTag({ type: param.type.expression }); - case 'AllLiteral': - return 'any'; - case 'RecordType': - return 'object'; - case 'NumericLiteralType': - return `${param.type.value}`; - case 'StringLiteralType': - return `'${param.type.value}'`; - case 'NullLiteral': - return 'null'; - case 'UndefinedLiteral': - return 'undefined'; - case 'ArrayType': { - const innerTypeStrs = param.type.elements.map(e => generateTypeFromTag({ type: e })); - return `[${innerTypeStrs.join(', ')}]`; - } - case 'RestType': - return `${generateTypeFromTag({ type: param.type.expression })}[]`; - case 'FunctionType': - const params = (param.type.params || []) - .map((param2, i) => generateParamDeclaration({ name: `arg${i}`, type: param2 })) - .join(', '); - - const returnType = param.type.result - ? generateTypeFromTag({ type: param.type.result }) - : 'void'; - return `(${params}) => ${returnType}`; - default: - return 'any'; - } - } - - export function normalizeIdentifier(name) { - return ( - '0123456789'.includes(name[0]) || - name === 'class' - ) ? '$' + name : name; - } - - export function normalizeTypeName(type, { inApplication = false } = {}) { - if (!type) return 'any'; - - if (type === '[object Object]') return 'any'; - - const constData = organized.consts[type]; - if (constData) - return constData.typeName; - - const primitiveTypes = { - 'String': 'string', - 'Number': 'number', - 'Integer': 'number', - 'Boolean': 'boolean', - 'Void': 'void', - 'Object': 'object', - 'Any': 'any', - 'Array': !inApplication && 'any[]', - 'Promise': !inApplication && 'Promise', - 'Function': 'Function' - }; - - return primitiveTypes[type] || type; - } - - export function generateParamDeclaration(param) { - if (!param) return 'any'; - - const name = normalizeIdentifier(param.name); - - let type = param.type; - let prefix = ''; - const isOptional = param.optional || param.type?.type === 'OptionalType'; - if (typeof type === 'string') { - type = normalizeTypeName(type); - } else if (param.type?.type) { - type = generateTypeFromTag(param); - } else { - type = 'any'; - } - - if (param.rest || param.type?.type === 'RestType') { - prefix = '...'; - } - - return `${prefix}${name}${isOptional ? '?' : ''}: ${type}`; - } - - export function generateFunctionDeclaration(funcDoc) { - - let output = ''; - - let comment = ''; - if (funcDoc.description) { - const description = extractDescription(funcDoc.description); - if (description) { - comment += (comment === "") ? '/**\n' : ' *\n'; - comment += formatJSDocComment(description) + '\n'; - } - } - if (funcDoc.tags) { - comment += (comment === "") ? '/**\n' : ' *\n'; - funcDoc.tags.forEach(tag => { - if (tag.description) { - const tagDesc = extractDescription(tag.description); - comment += formatJSDocComment(`@${tag.title} ${tag.name ? tag.name + ' ' : ''}${tagDesc}`, 0) + '\n'; - } - }); - } - if (comment !== "") - comment += ' */\n'; - output += comment; - - const name = normalizeIdentifier(funcDoc.name); - - const params = (funcDoc.params || []) - .map(param => generateParamDeclaration(param)) - .join(', '); - - const returnType = funcDoc.returns?.[0]?.type - ? generateTypeFromTag(funcDoc.returns[0]) - : 'void'; - - output += `function ${name}(${params}): ${returnType};\n\n`; - return output; - } - - export function generateMethodDeclarations(item, isStatic = false, isGlobal = false) { - let output = ''; - - let comment = ''; - if (item.description) { - const itemDesc = extractDescription(item.description); - if (itemDesc) { - comment += (comment === "") ? ' /**\n' : ' *\n'; - comment += formatJSDocComment(itemDesc, 2) + '\n'; - } - } - if (item.params?.length > 0) { - comment += (comment === "") ? ' /**\n' : ' *\n'; - item.params.forEach(param => { - const paramDesc = extractDescription(param.description); - comment += formatJSDocComment(`@param ${param.name} ${paramDesc}`, 2) + '\n'; - }); - } - if (item.returns) { - const returnDesc = extractDescription(item.returns[0]?.description); - if (returnDesc) { - comment += (comment === "") ? ' /**\n' : ' *\n'; - comment += formatJSDocComment(`@return ${returnDesc}`, 2) + '\n'; - } - } - if (comment !== "") - comment += ' */\n'; - output += comment; - - if (item.kind === 'function' || item.kind === 'member') { - const staticPrefix = isStatic ? 'static ' : ''; - - if (item.overloads?.length > 0) { - item.overloads.forEach(overload => { - const params = (overload.params || []) - .map(param => generateParamDeclaration(param)) - .join(', '); - const returnType = overload.returns?.[0]?.type - ? generateTypeFromTag(overload.returns[0]) - : 'void'; - output += ` ${staticPrefix}${item.name}(${params}): ${returnType};\n`; - }); - } - - const params = (item.params || []) - .map(param => generateParamDeclaration(param)) - .join(', '); - if (item.kind === 'member' && item.name.startsWith('set ')) { - output += ` ${staticPrefix}${item.name}(${params});\n\n`; // return type annotation illegal - } else { - output += ` ${staticPrefix}${item.name}(${params}): ${item.returnType};\n\n`; - } - } else { - const staticPrefix = isStatic ? 'static ' : ''; - output += ` ${staticPrefix}${item.name}: ${item.returnType};\n\n`; - } - - return output; - } - -export function generateClassDeclaration(classDoc, organizedData) { - - - let output = ''; - - if (classDoc.description || classDoc.tags?.length > 0) { - output += '/**\n'; - const description = extractDescription(classDoc.description); - if (description) { - output += formatJSDocComment(description) + '\n'; - } - if (classDoc.tags) { - if (description) { - output += ' *\n'; - } - classDoc.tags.forEach(tag => { - if (tag.description) { - const tagDesc = extractDescription(tag.description); - output += formatJSDocComment(`@${tag.title} ${tagDesc}`, 0) + '\n'; - } - }); - } - output += ' */\n'; - } - - const parentClass = classDoc.extends; - const extendsClause = parentClass ? ` extends ${parentClass}` : ''; - - const fullClassName = normalizeClassName(classDoc.name); - const classDocName = fullClassName.replace('p5.', ''); - output += `class ${classDocName}${extendsClause} {\n`; - - if (classDoc.params?.length > 0) { - output += ' constructor('; - output += classDoc.params - .map(param => generateParamDeclaration(param)) - .join(', '); - output += ');\n\n'; - } - - const classItems = organizedData.classitems.filter(item => - item.class === fullClassName || - item.class === fullClassName.replace('p5.', '') - ); - const staticItems = classItems.filter(item => item.isStatic); - const instanceItems = classItems.filter(item => !item.isStatic); - - staticItems.forEach(item => { - output += generateMethodDeclarations(item, true); - }); - - instanceItems.forEach(item => { - output += generateMethodDeclarations(item, false); - }); - - output += '}\n\n'; - return output; - } - -function formatJSDocComment(text, indentLevel = 0) { - if (!text) return ''; - const indent = ' '.repeat(indentLevel); - - const lines = text - .split('\n') - .map(line => line.trim()) - .reduce((acc, line) => { - // If we're starting and line is empty, skip it - if (acc.length === 0 && line === '') return acc; - // If we have content and hit an empty line, keep one empty line - if (acc.length > 0 && line === '' && acc[acc.length - 1] === '') return acc; - acc.push(line); - return acc; - }, []) - .filter((line, i, arr) => i < arr.length - 1 || line !== ''); // Remove trailing empty line - - return lines - .map(line => `${indent} * ${line}`) - .join('\n'); - } - function groupByFile(items) { - const fileGroups = new Map(); - - items.forEach(item => { - if (!item.context || !item.context.file) return; - - const filePath = item.context.file; - if (!fileGroups.has(filePath)) { - fileGroups.set(filePath, []); - } - fileGroups.get(filePath).push(item); - }); - - return fileGroups; - } diff --git a/utils/patch.mjs b/utils/patch.mjs index e3c9ed9452..8f33730920 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -52,12 +52,7 @@ replace( `constructor( detailX?: number, detailY?: number, - callback?: (this: { - detailY: number, - detailX: number, - vertices: p5.Vector[], - uvs: number[] - }) => void);` + callback?: (this: Geometry) => void);` ); // https://github.com/p5-types/p5.ts/issues/31 diff --git a/utils/shared-helpers.mjs b/utils/shared-helpers.mjs new file mode 100644 index 0000000000..1b39d4579e --- /dev/null +++ b/utils/shared-helpers.mjs @@ -0,0 +1,152 @@ +// Shared helper functions used by both convert.mjs and typescript.mjs + +function getEntries(entry) { + return [ + entry, + ...getAllEntries(entry.members?.global || []), + ...getAllEntries(entry.members?.inner || []), + ...getAllEntries(entry.members?.instance || []), + ...getAllEntries(entry.members?.events || []), + ...getAllEntries(entry.members?.static || []) + ]; +} + +export function getAllEntries(arr = []) { + return arr.flatMap(entry => entry ? getEntries(entry) : []); +} + +export function descriptionString(node, parent) { + if (!node) { + return ''; + } else if (node.type === 'text') { + return node.value; + } else if (node.type === 'paragraph') { + const content = node.children.map(n => descriptionString(n, node)).join(''); + if (parent && parent.children.length === 1) return content; + return '

    ' + content + '

    \n'; + } else if (node.type === 'code') { + let classes = []; + let attrs = ''; + if (node.lang) { + classes.push(`language-${node.lang}`); + } + if (node.meta) { + classes.push(node.meta); + } + if (classes.length > 0) { + attrs=` class="${classes.join(' ')}"`; + } + return `
    ${node.value}
    `; + } else if (node.type === 'inlineCode') { + return '' + node.value + ''; + } else if (node.type === 'list') { + const tag = node.type === 'ordered' ? 'ol' : 'ul'; + return `<${tag}>` + node.children.map(n => descriptionString(n, node)).join('') + ``; + } else if (node.type === 'listItem') { + return '
  • ' + node.children.map(n => descriptionString(n, node)).join('') + '
  • '; + } else if (node.value) { + return node.value; + } else if (node.children) { + return node.children.map(n => descriptionString(n, node)).join(''); + } else { + return ''; + } +} + +// TypeScript-specific version without HTML tags +export function descriptionStringForTypeScript(node, parent) { + if (!node) { + return ''; + } else if (node.type === 'text') { + return node.value; + } else if (node.type === 'paragraph') { + const content = node.children.map(n => descriptionStringForTypeScript(n, node)).join(''); + return content; // Skip HTML tags for TypeScript + } else if (node.type === 'code') { + return `\`${node.value}\``; + } else if (node.type === 'inlineCode') { + return `\`${node.value}\``; + } else if (node.type === 'list') { + return node.children.map(n => descriptionStringForTypeScript(n, node)).join(''); + } else if (node.type === 'listItem') { + return '- ' + node.children.map(n => descriptionStringForTypeScript(n, node)).join(''); + } else if (node.value) { + return node.value; + } else if (node.children) { + return node.children.map(n => descriptionStringForTypeScript(n, node)).join(''); + } else { + return ''; + } +} + +export function typeObject(node) { + if (!node) return {}; + + if (node.type === 'OptionalType') { + return { optional: 1, ...typeObject(node.expression) }; + } else if (node.type === 'UnionType') { + const names = node.elements.map(n => typeObject(n).type); + return { + type: names.join('|') + }; + } else if (node.type === 'TypeApplication') { + const { type: typeName } = typeObject(node.expression); + if ( + typeName === 'Array' && + node.applications.length === 1 + ) { + return { + type: `${typeObject(node.applications[0]).type}[]` + }; + } + const args = node.applications.map(n => typeObject(n).type); + return { + type: `${typeName}<${args.join(', ')}>` + }; + } else if (node.type === 'UndefinedLiteral') { + return { type: 'undefined' }; + } else if (node.type === 'FunctionType') { + let signature = `function(${node.params.map(p => typeObject(p).type).join(', ')})`; + if (node.result) { + signature += `: ${typeObject(node.result).type}`; + } + return { type: signature }; + } else if (node.type === 'ArrayType') { + return { type: `[${node.elements.map(e => typeObject(e).type).join(', ')}]` }; + } else if (node.type === 'RestType') { + return { type: typeObject(node.expression).type, rest: true }; + } else { + // TODO + // - handle record types + return { type: node.name }; + } +} + +export function getParams(entry) { + // Documentation.js seems to try to grab params from the function itself in + // the code if we don't document all the parameters. This messes with our + // manually-documented overloads. Instead of using the provided entry.params + // array, we'll instead only rely on manually included @param tags. + // + // However, the tags don't include a tree-structured description field, and + // instead convert it to a string. We want a slightly different conversion to + // string, so we match these params to the Documentation.js-provided `params` + // array and grab the description from those. + return (entry.tags || []) + + // Filter out the nested parameters (eg. options.extrude), + // to be treated as part of parent parameters (eg. options) + // and not separate entries + .filter(t => t.title === 'param' && !t.name.includes('.')) + .map(node => { + const param = (entry.params || []) + .find(param => param.name === node.name); + return { + ...node, + description: param?.description || { + type: 'html', + value: node.description + } + }; + }); +} \ No newline at end of file diff --git a/utils/typescript.mjs b/utils/typescript.mjs new file mode 100644 index 0000000000..84f3b40018 --- /dev/null +++ b/utils/typescript.mjs @@ -0,0 +1,444 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { processData } from './data-processor.mjs'; +import { descriptionStringForTypeScript } from './shared-helpers.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Clear existing types directory and recreate it +const typesDir = path.join(__dirname, '../types'); +if (fs.existsSync(typesDir)) { + fs.rmSync(typesDir, { recursive: true, force: true }); +} +fs.mkdirSync(typesDir, { recursive: true }); + +const rawData = JSON.parse(fs.readFileSync(path.join(__dirname, '../docs/data.json'))); + +// Pre-build constants lookup +import { getAllEntries } from './shared-helpers.mjs'; +const allRawData = getAllEntries(rawData); +const constantsLookup = new Set(); +allRawData.forEach(entry => { + if (entry.kind === 'constant' || entry.kind === 'typedef') { + constantsLookup.add(entry.name); + } +}); + +// TypeScript-specific type conversion from raw type objects +function convertTypeToTypeScript(typeNode, options = {}) { + if (!typeNode) return 'any'; + + // Validate that typeNode is always an object + if (typeof typeNode !== 'object' || Array.isArray(typeNode)) { + throw new Error(`convertTypeToTypeScript expects an object, got: ${typeof typeNode} - ${JSON.stringify(typeNode)}`); + } + + const { currentClass = null, isInsideNamespace = false, global = false } = options; + + switch (typeNode.type) { + case 'NameExpression': { + const typeName = typeNode.name; + + // Handle primitive types + const primitiveTypes = { + 'String': 'string', + 'Number': 'number', + 'Integer': 'number', + 'Boolean': 'boolean', + 'Void': 'void', + 'Object': 'object', + 'Any': 'any', + 'Array': 'any[]', + 'Promise': 'Promise', + 'Function': 'Function', + 'HTMLElement': 'HTMLElement', + 'Event': 'Event', + 'Request': 'Request' + }; + + if (primitiveTypes[typeName]) { + return primitiveTypes[typeName]; + } + + // Handle self-referential types within the same class + if (currentClass && (typeName === `p5.${currentClass}` || typeName === currentClass)) { + return currentClass; + } + + // If we're inside the p5 namespace, remove p5. prefix from other p5 classes + if (isInsideNamespace && typeName.startsWith('p5.')) { + if (global) { + return 'P5.' + typeName.substring(3); + } else { + return typeName.substring(3); + } + } + + // Check if this is a p5 constant - use typeof since they're defined as values + if (constantsLookup.has(typeName)) { + if (global) { + return `typeof P5.${typeName}`; + } else { + return `typeof ${typeName}`; + } + } + + return typeName; + } + + case 'TypeApplication': { + const baseTypeName = typeNode.expression.name; + + if (baseTypeName === 'Array' && typeNode.applications.length === 1) { + const innerType = convertTypeToTypeScript(typeNode.applications[0], options); + return `${innerType}[]`; + } + + // For generic types, use the base type name directly to avoid double conversion + const typeParams = typeNode.applications + .map(app => convertTypeToTypeScript(app, options)) + .join(', '); + return `${baseTypeName}<${typeParams}>`; + } + + case 'UnionType': { + const unionTypes = typeNode.elements + .map(el => convertTypeToTypeScript(el, options)) + .join(' | '); + return unionTypes; + } + + case 'OptionalType': + return convertTypeToTypeScript(typeNode.expression, options); + + case 'AllLiteral': + return 'any'; + + case 'RecordType': + return 'object'; + + case 'NumericLiteralType': + return `${typeNode.value}`; + + case 'StringLiteralType': + return `'${typeNode.value}'`; + + case 'NullLiteral': + return 'null'; + + case 'UndefinedLiteral': + return 'undefined'; + + case 'ArrayType': { + const innerTypes = typeNode.elements.map(e => convertTypeToTypeScript(e, options)); + return `[${innerTypes.join(', ')}]`; + } + + case 'RestType': + return `${convertTypeToTypeScript(typeNode.expression, options)}[]`; + + case 'FunctionType': { + const params = (typeNode.params || []) + .map((param, i) => { + const paramType = convertTypeToTypeScript(param, options); + return `arg${i}: ${paramType}`; + }) + .join(', '); + + const returnType = typeNode.result + ? convertTypeToTypeScript(typeNode.result, options) + : 'void'; + return `(${params}) => ${returnType}`; + } + + default: + return 'any'; + } +} + +// Strategy for TypeScript output +const typescriptStrategy = { + shouldSkipEntry: (entry, context) => { + // Skip Foundation module for TypeScript output + return context.module === 'Foundation'; + }, + + processDescription: (desc) => descriptionStringForTypeScript(desc), + + processType: (type) => { + // Return an object with the original type preserved + // This matches the expected data structure from the data processor + return { + type: type, // Keep the original raw type object + originalType: type // Also store it here for clarity + }; + } +}; + +const processed = processData(rawData, typescriptStrategy); + +function normalizeIdentifier(name) { + return ( + '0123456789'.includes(name[0]) || + name === 'class' + ) ? '$' + name : name; +} + +function formatJSDocComment(text, indentLevel = 0) { + if (!text) return ''; + const indent = ' '.repeat(indentLevel); + + const lines = text + .split('\n') + .map(line => line.trim()) + .reduce((acc, line) => { + if (acc.length === 0 && line === '') return acc; + if (acc.length > 0 && line === '' && acc[acc.length - 1] === '') return acc; + acc.push(line); + return acc; + }, []) + .filter((line, i, arr) => i < arr.length - 1 || line !== ''); + + return lines + .map(line => `${indent} * ${line}`) + .join('\n'); +} + +function generateParamDeclaration(param, options = {}) { + if (!param) return ''; + + const name = normalizeIdentifier(param.name); + + // Convert the type - should always be an object + let type = 'any'; + if (param.type) { + type = convertTypeToTypeScript(param.type, options); + } + + const isOptional = param.optional; + + let prefix = ''; + if (param.rest) { + prefix = '...'; + } + + return `${prefix}${name}${isOptional ? '?' : ''}: ${type}`; +} + +function generateMethodDeclaration(method, options = {}) { + let output = ''; + + if (method.description) { + output += ' /**\n'; + output += formatJSDocComment(method.description, 2) + '\n'; + + // Add param docs from first overload + if (method.overloads?.[0]?.params) { + method.overloads[0].params.forEach(param => { + if (param.description) { + output += formatJSDocComment(`@param ${param.name} ${param.description}`, 2) + '\n'; + } + }); + } + + // Add return docs + if (method.return?.description) { + output += formatJSDocComment(`@returns ${method.return.description}`, 2) + '\n'; + } + + output += ' */\n'; + } + + const staticPrefix = method.static ? 'static ' : ''; + + // Generate overload declarations + if (method.overloads && method.overloads.length > 0) { + method.overloads.forEach(overload => { + const params = (overload.params || []) + .map(param => generateParamDeclaration(param, options)) + .join(', '); + + let returnType = 'void'; + if (method.chainable) { + // returnType = currentClass || 'this'; + // TODO: Decide what should be chainable. Many of these are accidental / not thought through + } else if (overload.return && overload.return.type) { + returnType = convertTypeToTypeScript(overload.return.type, options); + } else if (method.return && method.return.type) { + returnType = convertTypeToTypeScript(method.return.type, options); + } + + output += ` ${staticPrefix}${method.name}(${params}): ${returnType};\n`; + }); + } + + output += '\n'; + return output; +} + +function generateClassDeclaration(classData) { + let output = ''; + const className = classData.name.startsWith('p5.') ? classData.name.substring(3) : classData.name; + + if (classData.description) { + output += ' /**\n'; + output += formatJSDocComment(classData.description, 2) + '\n'; + output += ' */\n'; + } + + const extendsClause = classData.extends ? ` extends ${classData.extends}` : ''; + output += ` class ${className}${extendsClause} {\n`; + + // Constructor + if (classData.params?.length > 0) { + output += ' constructor('; + output += classData.params + .map(param => generateParamDeclaration(param, { currentClass: className, isInsideNamespace: true })) + .join(', '); + output += ');\n\n'; + } + + const options = { currentClass: className, isInsideNamespace: true }; + const originalClassName = classData.name; + + // Class methods + const classMethodsList = Object.values(processed.classMethods[originalClassName] || {}); + const staticMethods = classMethodsList.filter(method => method.static); + const instanceMethods = classMethodsList.filter(method => !method.static); + + staticMethods.forEach(method => { + output += generateMethodDeclaration(method, options); + }); + + instanceMethods.forEach(method => { + output += generateMethodDeclaration(method, options); + }); + + output += ' }\n\n'; + return output; +} + +// Generate TypeScript definitions +function generateTypeDefinitions() { + let output = '// This file is auto-generated from JSDoc documentation\n\n'; + + // First, define all constants at the top level with their actual values + const p5Constants = processed.classitems.filter(item => + item.class === 'p5' && item.itemtype === 'property' && item.name in processed.consts + ); + + p5Constants.forEach(constant => { + if (constant.description) { + output += '/**\n'; + output += formatJSDocComment(constant.description, 0) + '\n'; + output += ' */\n'; + } + const type = convertTypeToTypeScript(constant.type, { isInsideNamespace: false }); + output += `declare const ${constant.name}: ${type};\n\n`; + // Duplicate with a private identifier so we can re-export in the namespace later + output += `declare const __${constant.name}: typeof ${constant.name};\n\n`; + }); + + // Generate main p5 class + output += 'declare class p5 {\n'; + output += ' constructor(sketch?: (p: p5) => void, node?: HTMLElement, sync?: boolean);\n\n'; + + const p5Options = { currentClass: 'p5', isInsideNamespace: false }; + + // Generate p5 static methods + const p5StaticMethods = Object.values(processed.classMethods.p5 || {}).filter(method => method.static); + p5StaticMethods.forEach(method => { + output += generateMethodDeclaration(method, p5Options); + }); + + // Generate p5 instance methods + const p5InstanceMethods = Object.values(processed.classMethods.p5 || {}).filter(method => !method.static); + p5InstanceMethods.forEach(method => { + output += generateMethodDeclaration(method, p5Options); + }); + + // Add constants as both instance and static properties (referencing the top-level constants) + p5Constants.forEach(constant => { + output += ` readonly ${constant.name}: typeof ${constant.name};\n`; + // output += ` static readonly ${constant.name}: typeof __${constant.name};\n\n`; + }); + + output += '}\n\n'; + + output += 'declare const __p5: typeof p5;\n\n'; + + // Generate p5 namespace + output += 'declare namespace p5 {\n'; + output += ' const p5: typeof __p5;\n'; + + output += '\n'; + + + p5Constants.forEach(constant => { + output += `const ${constant.name}: typeof __${constant.name};\n`; + }); + + output += '\n'; + + // Generate other classes in namespace + Object.values(processed.classes).forEach(classData => { + if (classData.name !== 'p5') { + output += generateClassDeclaration(classData); + } + }); + + // Generate placeholder types for private classes that we need to be able to + // reference, but have no public APIs + const privateClasses = ['Renderer', 'Renderer2D', 'RendererGL', 'FramebufferTexture', 'Texture', 'Quat']; + for (const className of privateClasses) { + output += ` class ${className} {}\n`; + } + + output += '}\n\n'; + + // Export declarations + output += 'export default p5;\n'; + output += 'export as namespace p5;\n'; + + const instanceDefinitions = output; + + let globalDefinitions = `// This file is auto-generated from JSDoc documentation + +import P5 from './p5'; + +declare global { +interface Window { + +p5: P5; +`; + + p5Constants.forEach(constant => { + if (constant.description) { + globalDefinitions += '/**\n'; + globalDefinitions += formatJSDocComment(constant.description, 0) + '\n'; + globalDefinitions += ' */\n'; + } + globalDefinitions += `${constant.name}: typeof P5.${constant.name};\n\n`; + }); + + const globalP5Methods = Object.values(processed.classMethods.p5 || {}) + .filter(method => !method.static && method.name !== 'p5'); + globalP5Methods.forEach(method => { + globalDefinitions += generateMethodDeclaration(method, { currentClass: 'p5', isInsideNamespace: true, global: true }); + }); + + globalDefinitions += '}\n\n'; + globalDefinitions += '}\n\n'; + + return { instanceDefinitions, globalDefinitions }; +} + +// Generate and write TypeScript definitions +const { instanceDefinitions, globalDefinitions } = generateTypeDefinitions(); + +fs.writeFileSync(path.join(__dirname, '../types/p5.d.ts'), instanceDefinitions); +fs.writeFileSync(path.join(__dirname, '../types/global.d.ts'), globalDefinitions); + +console.log('TypeScript definitions generated successfully!'); \ No newline at end of file From 08322d414992320cc918419567920e6f6162d931 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 25 Sep 2025 16:25:39 -0400 Subject: [PATCH 09/42] Fix more type generation errors --- docs/parameterData.json | 4 +- src/core/constants.js | 6 +- src/image/pixels.js | 272 +++++++++++++++++++-------------------- src/math/trigonometry.js | 4 +- src/webgl/loading.js | 4 +- utils/data-processor.mjs | 10 +- utils/typescript.mjs | 88 ++++++++++--- 7 files changed, 224 insertions(+), 164 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index 05550f748a..650ad7431c 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -2804,7 +2804,7 @@ [ "String|Request", "String?", - "Boolean", + "Boolean?", "function(p5.Geometry)?", "function(Event)?" ], @@ -2833,7 +2833,7 @@ [ "String", "String?", - "Boolean", + "Boolean?", "function(p5.Geometry)?", "function(Event)?" ], diff --git a/src/core/constants.js b/src/core/constants.js index 696dfb185c..ea5b40d32d 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -57,7 +57,7 @@ export const P2DHDR = 'p2d-hdr'; * * To learn more about WEBGL mode, check out all the interactive WEBGL tutorials in the "Tutorials" section of this website, or read the wiki article "Getting started with WebGL in p5". * - * @typedef {unique symbol} WEBGL + * @typedef {'webgl2'} WEBGL * @property {WEBGL} WEBGL * @final */ @@ -66,7 +66,7 @@ export const WEBGL = 'webgl'; * One of the two possible values of a WebGL canvas (either WEBGL or WEBGL2), * which can be used to determine what capabilities the rendering environment * has. - * @typedef {unique symbol} WEBGL2 + * @typedef {'webgl2'} WEBGL2 * @property {WEBGL2} WEBGL2 * @final */ @@ -754,7 +754,7 @@ export const PIE = 'pie'; export const PROJECT = 'square'; // PEND: careful this is counterintuitive /** * @typedef {'butt'} SQUARE - * @property {SQUERE} SQUARE + * @property {SQUARE} SQUARE * @final */ export const SQUARE = 'butt'; diff --git a/src/image/pixels.js b/src/image/pixels.js index ebea101273..f88818faed 100644 --- a/src/image/pixels.js +++ b/src/image/pixels.js @@ -8,142 +8,6 @@ import Filters from './filters'; function pixels(p5, fn){ - /** - * An array containing the color of each pixel on the canvas. - * - * Colors are stored as numbers representing red, green, blue, and alpha - * (RGBA) values. `pixels` is a one-dimensional array for performance reasons. - * - * Each pixel occupies four elements in the `pixels` array, one for each RGBA - * value. For example, the pixel at coordinates (0, 0) stores its RGBA values - * at `pixels[0]`, `pixels[1]`, `pixels[2]`, and `pixels[3]`, respectively. - * The next pixel at coordinates (1, 0) stores its RGBA values at `pixels[4]`, - * `pixels[5]`, `pixels[6]`, and `pixels[7]`. And so on. The `pixels` array - * for a 100×100 canvas has 100 × 100 × 4 = 40,000 elements. - * - * Some displays use several smaller pixels to set the color at a single - * point. The pixelDensity() function returns - * the pixel density of the canvas. High density displays often have a - * pixelDensity() of 2. On such a display, the - * `pixels` array for a 100×100 canvas has 200 × 200 × 4 = - * 160,000 elements. - * - * Accessing the RGBA values for a point on the canvas requires a little math - * as shown below. The loadPixels() function - * must be called before accessing the `pixels` array. The - * updatePixels() function must be called - * after any changes are made. - * - * @property {Number[]} pixels - * - * @example - *
    - * - * function setup() { - * createCanvas(100, 100); - * background(128); - * - * // Load the pixels array. - * loadPixels(); - * - * // Set the dot's coordinates. - * let x = 50; - * let y = 50; - * - * // Get the pixel density. - * let d = pixelDensity(); - * - * // Set the pixel(s) at the center of the canvas black. - * for (let i = 0; i < d; i += 1) { - * for (let j = 0; j < d; j += 1) { - * let index = 4 * ((y * d + j) * width * d + (x * d + i)); - * // Red. - * pixels[index] = 0; - * // Green. - * pixels[index + 1] = 0; - * // Blue. - * pixels[index + 2] = 0; - * // Alpha. - * pixels[index + 3] = 255; - * } - * } - * - * // Update the canvas. - * updatePixels(); - * - * describe('A black dot in the middle of a gray rectangle.'); - * } - * - *
    - * - *
    - * - * function setup() { - * createCanvas(100, 100); - * - * // Load the pixels array. - * loadPixels(); - * - * // Get the pixel density. - * let d = pixelDensity(); - * - * // Calculate the halfway index in the pixels array. - * let halfImage = 4 * (d * width) * (d * height / 2); - * - * // Make the top half of the canvas red. - * for (let i = 0; i < halfImage; i += 4) { - * // Red. - * pixels[i] = 255; - * // Green. - * pixels[i + 1] = 0; - * // Blue. - * pixels[i + 2] = 0; - * // Alpha. - * pixels[i + 3] = 255; - * } - * - * // Update the canvas. - * updatePixels(); - * - * describe('A red rectangle drawn above a gray rectangle.'); - * } - * - *
    - * - *
    - * - * function setup() { - * createCanvas(100, 100); - * - * // Create a p5.Color object. - * let pink = color(255, 102, 204); - * - * // Load the pixels array. - * loadPixels(); - * - * // Get the pixel density. - * let d = pixelDensity(); - * - * // Calculate the halfway index in the pixels array. - * let halfImage = 4 * (d * width) * (d * height / 2); - * - * // Make the top half of the canvas red. - * for (let i = 0; i < halfImage; i += 4) { - * pixels[i] = red(pink); - * pixels[i + 1] = green(pink); - * pixels[i + 2] = blue(pink); - * pixels[i + 3] = alpha(pink); - * } - * - * // Update the canvas. - * updatePixels(); - * - * describe('A pink rectangle drawn above a gray rectangle.'); - * } - * - *
    - */ - /** * Copies a region of pixels from one image to another. * @@ -1116,6 +980,142 @@ function pixels(p5, fn){ } this._renderer.updatePixels(x, y, w, h); }; + + /** + * An array containing the color of each pixel on the canvas. + * + * Colors are stored as numbers representing red, green, blue, and alpha + * (RGBA) values. `pixels` is a one-dimensional array for performance reasons. + * + * Each pixel occupies four elements in the `pixels` array, one for each RGBA + * value. For example, the pixel at coordinates (0, 0) stores its RGBA values + * at `pixels[0]`, `pixels[1]`, `pixels[2]`, and `pixels[3]`, respectively. + * The next pixel at coordinates (1, 0) stores its RGBA values at `pixels[4]`, + * `pixels[5]`, `pixels[6]`, and `pixels[7]`. And so on. The `pixels` array + * for a 100×100 canvas has 100 × 100 × 4 = 40,000 elements. + * + * Some displays use several smaller pixels to set the color at a single + * point. The pixelDensity() function returns + * the pixel density of the canvas. High density displays often have a + * pixelDensity() of 2. On such a display, the + * `pixels` array for a 100×100 canvas has 200 × 200 × 4 = + * 160,000 elements. + * + * Accessing the RGBA values for a point on the canvas requires a little math + * as shown below. The loadPixels() function + * must be called before accessing the `pixels` array. The + * updatePixels() function must be called + * after any changes are made. + * + * @property {Number[]} pixels + * + * @example + *
    + * + * function setup() { + * createCanvas(100, 100); + * background(128); + * + * // Load the pixels array. + * loadPixels(); + * + * // Set the dot's coordinates. + * let x = 50; + * let y = 50; + * + * // Get the pixel density. + * let d = pixelDensity(); + * + * // Set the pixel(s) at the center of the canvas black. + * for (let i = 0; i < d; i += 1) { + * for (let j = 0; j < d; j += 1) { + * let index = 4 * ((y * d + j) * width * d + (x * d + i)); + * // Red. + * pixels[index] = 0; + * // Green. + * pixels[index + 1] = 0; + * // Blue. + * pixels[index + 2] = 0; + * // Alpha. + * pixels[index + 3] = 255; + * } + * } + * + * // Update the canvas. + * updatePixels(); + * + * describe('A black dot in the middle of a gray rectangle.'); + * } + * + *
    + * + *
    + * + * function setup() { + * createCanvas(100, 100); + * + * // Load the pixels array. + * loadPixels(); + * + * // Get the pixel density. + * let d = pixelDensity(); + * + * // Calculate the halfway index in the pixels array. + * let halfImage = 4 * (d * width) * (d * height / 2); + * + * // Make the top half of the canvas red. + * for (let i = 0; i < halfImage; i += 4) { + * // Red. + * pixels[i] = 255; + * // Green. + * pixels[i + 1] = 0; + * // Blue. + * pixels[i + 2] = 0; + * // Alpha. + * pixels[i + 3] = 255; + * } + * + * // Update the canvas. + * updatePixels(); + * + * describe('A red rectangle drawn above a gray rectangle.'); + * } + * + *
    + * + *
    + * + * function setup() { + * createCanvas(100, 100); + * + * // Create a p5.Color object. + * let pink = color(255, 102, 204); + * + * // Load the pixels array. + * loadPixels(); + * + * // Get the pixel density. + * let d = pixelDensity(); + * + * // Calculate the halfway index in the pixels array. + * let halfImage = 4 * (d * width) * (d * height / 2); + * + * // Make the top half of the canvas red. + * for (let i = 0; i < halfImage; i += 4) { + * pixels[i] = red(pink); + * pixels[i + 1] = green(pink); + * pixels[i + 2] = blue(pink); + * pixels[i + 3] = alpha(pink); + * } + * + * // Update the canvas. + * updatePixels(); + * + * describe('A pink rectangle drawn above a gray rectangle.'); + * } + * + *
    + */ } export default pixels; diff --git a/src/math/trigonometry.js b/src/math/trigonometry.js index ba800be5f9..608ac60743 100644 --- a/src/math/trigonometry.js +++ b/src/math/trigonometry.js @@ -20,7 +20,7 @@ function trigonometry(p5, fn){ * * Note: `TWO_PI` radians equals 360˚. * - * @typedef {unique symbol} DEGREES + * @typedef {'degrees'} DEGREES * @property {DEGREES} DEGREES * @final * @@ -63,7 +63,7 @@ function trigonometry(p5, fn){ * * Note: `TWO_PI` radians equals 360˚. * - * @typedef {unique symbol} RADIANS + * @typedef {'radians'} RADIANS * @property {RADIANS} RADIANS * @final * diff --git a/src/webgl/loading.js b/src/webgl/loading.js index e808cd4275..098211f822 100755 --- a/src/webgl/loading.js +++ b/src/webgl/loading.js @@ -99,7 +99,7 @@ function loading(p5, fn){ * @method loadModel * @param {String|Request} path path of the model to be loaded. * @param {String} [fileType] model’s file extension. Either `'.obj'` or `'.stl'`. - * @param {Boolean} normalize if `true`, scale the model to fit the canvas. + * @param {Boolean} [normalize] if `true`, scale the model to fit the canvas. * @param {function(p5.Geometry)} [successCallback] function to call once the model is loaded. Will be passed * the p5.Geometry object. * @param {function(Event)} [failureCallback] function to call if the model fails to load. Will be passed an `Error` event object. @@ -1137,7 +1137,7 @@ function loading(p5, fn){ * @param {String} modelString String of the object to be loaded * @param {String} [fileType] The file extension of the model * (.stl, .obj). - * @param {Boolean} normalize If true, scale the model to a + * @param {Boolean} [normalize] If true, scale the model to a * standardized size when loading * @param {function(p5.Geometry)} [successCallback] Function to be called * once the model is loaded. Will be passed diff --git a/utils/data-processor.mjs b/utils/data-processor.mjs index a3b6bf96aa..8aad475133 100644 --- a/utils/data-processor.mjs +++ b/utils/data-processor.mjs @@ -108,9 +108,10 @@ export function processData(rawData, strategy) { ?.join('\n') || undefined; } - // Process constants and typedefs + // Process constants, typedefs, and properties for (const entry of allData) { - if (entry.kind === 'constant' || entry.kind === 'typedef') { + if (entry.kind === 'constant' || entry.kind === 'typedef' || + (entry.properties && entry.properties.length > 0 && entry.properties[0].title === 'property')) { const { module, submodule, forEntry } = getModuleInfo(entry); // Apply strategy filter @@ -118,12 +119,15 @@ export function processData(rawData, strategy) { continue; } + // For properties, get type from the property definition + const propertyType = entry.properties?.[0]?.type || entry.type; + const examples = entry.examples?.map(getExample) || []; const item = { itemtype: 'property', name: entry.name, ...locationInfo(entry), - ...strategy.processType(entry.type), + ...strategy.processType(propertyType), ...deprecationInfo(entry), description: strategy.processDescription(entry.description), example: examples.length > 0 ? examples : undefined, diff --git a/utils/typescript.mjs b/utils/typescript.mjs index 84f3b40018..dcf86780ed 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -20,9 +20,13 @@ const rawData = JSON.parse(fs.readFileSync(path.join(__dirname, '../docs/data.js import { getAllEntries } from './shared-helpers.mjs'; const allRawData = getAllEntries(rawData); const constantsLookup = new Set(); +const typedefs = {}; allRawData.forEach(entry => { if (entry.kind === 'constant' || entry.kind === 'typedef') { constantsLookup.add(entry.name); + if (entry.kind === 'typedef') { + typedefs[entry.name] = entry.type; + } } }); @@ -35,7 +39,7 @@ function convertTypeToTypeScript(typeNode, options = {}) { throw new Error(`convertTypeToTypeScript expects an object, got: ${typeof typeNode} - ${JSON.stringify(typeNode)}`); } - const { currentClass = null, isInsideNamespace = false, global = false } = options; + const { currentClass = null, isInsideNamespace = false, inGlobalMode = false } = options; switch (typeNode.type) { case 'NameExpression': { @@ -69,7 +73,7 @@ function convertTypeToTypeScript(typeNode, options = {}) { // If we're inside the p5 namespace, remove p5. prefix from other p5 classes if (isInsideNamespace && typeName.startsWith('p5.')) { - if (global) { + if (inGlobalMode) { return 'P5.' + typeName.substring(3); } else { return typeName.substring(3); @@ -78,10 +82,12 @@ function convertTypeToTypeScript(typeNode, options = {}) { // Check if this is a p5 constant - use typeof since they're defined as values if (constantsLookup.has(typeName)) { - if (global) { + if (inGlobalMode) { return `typeof P5.${typeName}`; + } else if (typedefs[typeName]) { + return convertTypeToTypeScript(typedefs[typeName], options); } else { - return `typeof ${typeName}`; + return `Symbol`; } } @@ -170,10 +176,17 @@ const typescriptStrategy = { processType: (type) => { // Return an object with the original type preserved // This matches the expected data structure from the data processor - return { + const result = { type: type, // Keep the original raw type object originalType: type // Also store it here for clarity }; + + // Extract optional flag from OptionalType + if (type?.type === 'OptionalType') { + result.optional = true; + } + + return result; } }; @@ -229,29 +242,34 @@ function generateParamDeclaration(param, options = {}) { function generateMethodDeclaration(method, options = {}) { let output = ''; + const { globalFunction = false } = options; + + const indent = globalFunction ? '' : ' '; + const commentIndent = globalFunction ? 0 : 2; if (method.description) { - output += ' /**\n'; - output += formatJSDocComment(method.description, 2) + '\n'; + output += `${indent}/**\n`; + output += formatJSDocComment(method.description, commentIndent) + '\n'; // Add param docs from first overload if (method.overloads?.[0]?.params) { method.overloads[0].params.forEach(param => { if (param.description) { - output += formatJSDocComment(`@param ${param.name} ${param.description}`, 2) + '\n'; + output += formatJSDocComment(`@param ${param.name} ${param.description}`, commentIndent) + '\n'; } }); } // Add return docs if (method.return?.description) { - output += formatJSDocComment(`@returns ${method.return.description}`, 2) + '\n'; + output += formatJSDocComment(`@returns ${method.return.description}`, commentIndent) + '\n'; } - output += ' */\n'; + output += `${indent} */\n`; } const staticPrefix = method.static ? 'static ' : ''; + const declarationPrefix = globalFunction ? 'function ' : `${indent}${staticPrefix}`; // Generate overload declarations if (method.overloads && method.overloads.length > 0) { @@ -261,7 +279,7 @@ function generateMethodDeclaration(method, options = {}) { .join(', '); let returnType = 'void'; - if (method.chainable) { + if (method.chainable && !globalFunction) { // returnType = currentClass || 'this'; // TODO: Decide what should be chainable. Many of these are accidental / not thought through } else if (overload.return && overload.return.type) { @@ -270,7 +288,7 @@ function generateMethodDeclaration(method, options = {}) { returnType = convertTypeToTypeScript(method.return.type, options); } - output += ` ${staticPrefix}${method.name}(${params}): ${returnType};\n`; + output += `${declarationPrefix}${method.name}(${params}): ${returnType};\n`; }); } @@ -325,9 +343,21 @@ function generateTypeDefinitions() { let output = '// This file is auto-generated from JSDoc documentation\n\n'; // First, define all constants at the top level with their actual values - const p5Constants = processed.classitems.filter(item => - item.class === 'p5' && item.itemtype === 'property' && item.name in processed.consts - ); + const seenConstants = new Set(); + const p5Constants = processed.classitems.filter(item => { + if (item.class === 'p5' && item.itemtype === 'property' && item.name in processed.consts) { + // Skip defineProperty, undefined and avoid duplicates + if (item.name === 'defineProperty' || !item.name) { + return false; + } + if (seenConstants.has(item.name)) { + return false; + } + seenConstants.add(item.name); + return true; + } + return false; + }); p5Constants.forEach(constant => { if (constant.description) { @@ -415,6 +445,7 @@ p5: P5; `; p5Constants.forEach(constant => { + if (constant.name === 'undefined') return; if (constant.description) { globalDefinitions += '/**\n'; globalDefinitions += formatJSDocComment(constant.description, 0) + '\n'; @@ -426,10 +457,35 @@ p5: P5; const globalP5Methods = Object.values(processed.classMethods.p5 || {}) .filter(method => !method.static && method.name !== 'p5'); globalP5Methods.forEach(method => { - globalDefinitions += generateMethodDeclaration(method, { currentClass: 'p5', isInsideNamespace: true, global: true }); + globalDefinitions += generateMethodDeclaration(method, { currentClass: 'p5', isInsideNamespace: true, inGlobalMode: true }); }); globalDefinitions += '}\n\n'; + + // Also declare constants in global scope (deduplicated) + const alreadyDeclaredConstants = new Set(); + p5Constants.forEach(constant => { + if (alreadyDeclaredConstants.has(constant.name)) { + return; // Skip duplicates + } + if (constant.name === 'defineProperty' || !constant.name) { + return; // Skip problematic constants + } + alreadyDeclaredConstants.add(constant.name); + + if (constant.description) { + globalDefinitions += '/**\n'; + globalDefinitions += formatJSDocComment(constant.description, 0) + '\n'; + globalDefinitions += ' */\n'; + } + globalDefinitions += `const ${constant.name}: typeof P5.${constant.name};\n\n`; + }); + + // Also declare functions in global scope + globalP5Methods.forEach(method => { + globalDefinitions += generateMethodDeclaration(method, { currentClass: 'p5', isInsideNamespace: true, inGlobalMode: true, globalFunction: true }); + }); + globalDefinitions += '}\n\n'; return { instanceDefinitions, globalDefinitions }; From a6909319485b1e887f25782669444a943c2580f4 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 25 Sep 2025 16:53:25 -0400 Subject: [PATCH 10/42] Fix some missing entries --- src/core/environment.js | 270 ++++++++++++++++++++------------------- utils/data-processor.mjs | 8 +- 2 files changed, 141 insertions(+), 137 deletions(-) diff --git a/src/core/environment.js b/src/core/environment.js index 007ec18742..a3226aa77b 100644 --- a/src/core/environment.js +++ b/src/core/environment.js @@ -819,146 +819,12 @@ function environment(p5, fn, lifecycles){ this.windowHeight = getWindowHeight(); }; - /** - * A `Number` variable that stores the width of the canvas in pixels. - * - * `width`'s default value is 100. Calling - * createCanvas() or - * resizeCanvas() changes the value of - * `width`. Calling noCanvas() sets its value to - * 0. - * - * @example - *
    - * - * function setup() { - * background(200); - * - * // Display the canvas' width. - * text(width, 42, 54); - * - * describe('The number 100 written in black on a gray square.'); - * } - * - *
    - * - *
    - * - * function setup() { - * createCanvas(50, 100); - * - * background(200); - * - * // Display the canvas' width. - * text(width, 21, 54); - * - * describe('The number 50 written in black on a gray rectangle.'); - * } - * - *
    - * - *
    - * - * function setup() { - * createCanvas(100, 100); - * - * background(200); - * - * // Display the canvas' width. - * text(width, 42, 54); - * - * describe('The number 100 written in black on a gray square. When the mouse is pressed, the square becomes a rectangle and the number becomes 50.'); - * } - * - * // If the mouse is pressed, reisze - * // the canvas and display its new - * // width. - * function mousePressed() { - * if (mouseX > 0 && mouseX < width && mouseY > 0 && mouseY < height) { - * resizeCanvas(50, 100); - * background(200); - * text(width, 21, 54); - * } - * } - * - *
    - * - * @property {Number} width - * @readOnly - */ Object.defineProperty(fn, 'width', { get(){ return this._renderer.width; } }); - /** - * A `Number` variable that stores the height of the canvas in pixels. - * - * `height`'s default value is 100. Calling - * createCanvas() or - * resizeCanvas() changes the value of - * `height`. Calling noCanvas() sets its value to - * 0. - * - * @example - *
    - * - * function setup() { - * background(200); - * - * // Display the canvas' height. - * text(height, 42, 54); - * - * describe('The number 100 written in black on a gray square.'); - * } - * - *
    - * - *
    - * - * function setup() { - * createCanvas(100, 50); - * - * background(200); - * - * // Display the canvas' height. - * text(height, 42, 27); - * - * describe('The number 50 written in black on a gray rectangle.'); - * } - * - *
    - * - *
    - * - * function setup() { - * createCanvas(100, 100); - * - * background(200); - * - * // Display the canvas' height. - * text(height, 42, 54); - * - * describe('The number 100 written in black on a gray square. When the mouse is pressed, the square becomes a rectangle and the number becomes 50.'); - * } - * - * // If the mouse is pressed, reisze - * // the canvas and display its new - * // height. - * function mousePressed() { - * if (mouseX > 0 && mouseX < width && mouseY > 0 && mouseY < height) { - * resizeCanvas(100, 50); - * background(200); - * text(height, 42, 27); - * } - * } - * - *
    - * - * @property {Number} height - * @readOnly - */ Object.defineProperty(fn, 'height', { get(){ return this._renderer.height; @@ -1468,6 +1334,142 @@ function environment(p5, fn, lifecycles){ .multiplyAndNormalizePoint(screenPosition); return worldPosition; }; + + /** + * A `Number` variable that stores the width of the canvas in pixels. + * + * `width`'s default value is 100. Calling + * createCanvas() or + * resizeCanvas() changes the value of + * `width`. Calling noCanvas() sets its value to + * 0. + * + * @example + *
    + * + * function setup() { + * background(200); + * + * // Display the canvas' width. + * text(width, 42, 54); + * + * describe('The number 100 written in black on a gray square.'); + * } + * + *
    + * + *
    + * + * function setup() { + * createCanvas(50, 100); + * + * background(200); + * + * // Display the canvas' width. + * text(width, 21, 54); + * + * describe('The number 50 written in black on a gray rectangle.'); + * } + * + *
    + * + *
    + * + * function setup() { + * createCanvas(100, 100); + * + * background(200); + * + * // Display the canvas' width. + * text(width, 42, 54); + * + * describe('The number 100 written in black on a gray square. When the mouse is pressed, the square becomes a rectangle and the number becomes 50.'); + * } + * + * // If the mouse is pressed, reisze + * // the canvas and display its new + * // width. + * function mousePressed() { + * if (mouseX > 0 && mouseX < width && mouseY > 0 && mouseY < height) { + * resizeCanvas(50, 100); + * background(200); + * text(width, 21, 54); + * } + * } + * + *
    + * + * @property {Number} width + * @readOnly + */ + + /** + * A `Number` variable that stores the height of the canvas in pixels. + * + * `height`'s default value is 100. Calling + * createCanvas() or + * resizeCanvas() changes the value of + * `height`. Calling noCanvas() sets its value to + * 0. + * + * @example + *
    + * + * function setup() { + * background(200); + * + * // Display the canvas' height. + * text(height, 42, 54); + * + * describe('The number 100 written in black on a gray square.'); + * } + * + *
    + * + *
    + * + * function setup() { + * createCanvas(100, 50); + * + * background(200); + * + * // Display the canvas' height. + * text(height, 42, 27); + * + * describe('The number 50 written in black on a gray rectangle.'); + * } + * + *
    + * + *
    + * + * function setup() { + * createCanvas(100, 100); + * + * background(200); + * + * // Display the canvas' height. + * text(height, 42, 54); + * + * describe('The number 100 written in black on a gray square. When the mouse is pressed, the square becomes a rectangle and the number becomes 50.'); + * } + * + * // If the mouse is pressed, reisze + * // the canvas and display its new + * // height. + * function mousePressed() { + * if (mouseX > 0 && mouseX < width && mouseY > 0 && mouseY < height) { + * resizeCanvas(100, 50); + * background(200); + * text(height, 42, 27); + * } + * } + * + *
    + * + * @property {Number} height + * @readOnly + */ } export default environment; diff --git a/utils/data-processor.mjs b/utils/data-processor.mjs index 8aad475133..9ca1a649ee 100644 --- a/utils/data-processor.mjs +++ b/utils/data-processor.mjs @@ -110,7 +110,7 @@ export function processData(rawData, strategy) { // Process constants, typedefs, and properties for (const entry of allData) { - if (entry.kind === 'constant' || entry.kind === 'typedef' || + if (entry.kind === 'constant' || entry.kind === 'typedef' || entry.kind === 'property' || (entry.properties && entry.properties.length > 0 && entry.properties[0].title === 'property')) { const { module, submodule, forEntry } = getModuleInfo(entry); @@ -119,13 +119,15 @@ export function processData(rawData, strategy) { continue; } + const name = entry.name || (entry.properties || [])[0]?.name; + // For properties, get type from the property definition const propertyType = entry.properties?.[0]?.type || entry.type; const examples = entry.examples?.map(getExample) || []; const item = { itemtype: 'property', - name: entry.name, + name, ...locationInfo(entry), ...strategy.processType(propertyType), ...deprecationInfo(entry), @@ -138,7 +140,7 @@ export function processData(rawData, strategy) { }; processed.classitems.push(item); - processed.consts[entry.name] = item; + processed.consts[name] = item; } } From 4c7da343cc864aebaf8eaafba7c3b0e6e5d04b83 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 11:48:08 -0400 Subject: [PATCH 11/42] Add tests for instance mode --- test/types/instance.ts | 18 ++++++++++++++++++ utils/typescript.mjs | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 test/types/instance.ts diff --git a/test/types/instance.ts b/test/types/instance.ts new file mode 100644 index 0000000000..91650fc202 --- /dev/null +++ b/test/types/instance.ts @@ -0,0 +1,18 @@ +import P5 from '../../types/p5' + +const sketch = new P5((p) => { + let g: P5.Graphics + p.setup = function() { + p.createCanvas(200, 200) + g = p.createGraphics(200, 200) + } + + p.mouseMoved = function() { + g.circle(p.mouseX, p.mouseY, 20); + } + + p.draw = function() { + p.clear() + p.image(g, 0, 0) + } +}) diff --git a/utils/typescript.mjs b/utils/typescript.mjs index dcf86780ed..02a2b88300 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -299,6 +299,7 @@ function generateMethodDeclaration(method, options = {}) { function generateClassDeclaration(classData) { let output = ''; const className = classData.name.startsWith('p5.') ? classData.name.substring(3) : classData.name; + const actualClassName = className === 'Graphics' ? '__Graphics' : className; if (classData.description) { output += ' /**\n'; @@ -307,7 +308,7 @@ function generateClassDeclaration(classData) { } const extendsClause = classData.extends ? ` extends ${classData.extends}` : ''; - output += ` class ${className}${extendsClause} {\n`; + output += ` class ${actualClassName}${extendsClause} {\n`; // Constructor if (classData.params?.length > 0) { @@ -335,6 +336,12 @@ function generateClassDeclaration(classData) { }); output += ' }\n\n'; + + // Add type alias for Graphics + if (className === 'Graphics') { + output += ' type Graphics = __Graphics & p5;\n\n'; + } + return output; } From 468d5bace284e74bac9d0931e4f4f0792e7b67fb Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 12:04:16 -0400 Subject: [PATCH 12/42] Fix disableFriendlyErrors --- test/types/basic.ts | 4 +++- test/types/instance.ts | 2 ++ utils/typescript.mjs | 42 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/test/types/basic.ts b/test/types/basic.ts index 88a970042d..eb08ca5971 100644 --- a/test/types/basic.ts +++ b/test/types/basic.ts @@ -1,4 +1,6 @@ -import '../../types/global.d.ts' +import '../../types/global' + +p5.disableFriendlyErrors = true let geom: p5.Geometry diff --git a/test/types/instance.ts b/test/types/instance.ts index 91650fc202..cd643d7ef9 100644 --- a/test/types/instance.ts +++ b/test/types/instance.ts @@ -1,5 +1,7 @@ import P5 from '../../types/p5' +P5.disableFriendlyErrors = true + const sketch = new P5((p) => { let g: P5.Graphics p.setup = function() { diff --git a/utils/typescript.mjs b/utils/typescript.mjs index 02a2b88300..a07d45c725 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -21,6 +21,7 @@ import { getAllEntries } from './shared-helpers.mjs'; const allRawData = getAllEntries(rawData); const constantsLookup = new Set(); const typedefs = {}; +const mutableProperties = new Set(['disableFriendlyErrors']); // Properties that should be mutable, not constants allRawData.forEach(entry => { if (entry.kind === 'constant' || entry.kind === 'typedef') { constantsLookup.add(entry.name); @@ -373,9 +374,11 @@ function generateTypeDefinitions() { output += ' */\n'; } const type = convertTypeToTypeScript(constant.type, { isInsideNamespace: false }); - output += `declare const ${constant.name}: ${type};\n\n`; + const isMutable = mutableProperties.has(constant.name); + const declaration = isMutable ? 'declare let' : 'declare const'; + output += `${declaration} ${constant.name}: ${type};\n\n`; // Duplicate with a private identifier so we can re-export in the namespace later - output += `declare const __${constant.name}: typeof ${constant.name};\n\n`; + output += `${declaration} __${constant.name}: typeof ${constant.name};\n\n`; }); // Generate main p5 class @@ -398,8 +401,9 @@ function generateTypeDefinitions() { // Add constants as both instance and static properties (referencing the top-level constants) p5Constants.forEach(constant => { - output += ` readonly ${constant.name}: typeof ${constant.name};\n`; - // output += ` static readonly ${constant.name}: typeof __${constant.name};\n\n`; + const isMutable = mutableProperties.has(constant.name); + const readonly = isMutable ? '' : 'readonly '; + output += ` ${readonly}${constant.name}: typeof ${constant.name};\n`; }); output += '}\n\n'; @@ -414,7 +418,7 @@ function generateTypeDefinitions() { p5Constants.forEach(constant => { - output += `const ${constant.name}: typeof __${constant.name};\n`; + output += `${mutableProperties.has(constant.name) ? 'let' : 'const'} ${constant.name}: typeof __${constant.name};\n`; }); output += '\n'; @@ -452,7 +456,6 @@ p5: P5; `; p5Constants.forEach(constant => { - if (constant.name === 'undefined') return; if (constant.description) { globalDefinitions += '/**\n'; globalDefinitions += formatJSDocComment(constant.description, 0) + '\n'; @@ -466,6 +469,33 @@ p5: P5; globalP5Methods.forEach(method => { globalDefinitions += generateMethodDeclaration(method, { currentClass: 'p5', isInsideNamespace: true, inGlobalMode: true }); }); + + globalDefinitions += '}\n'; + + // Add global p5 namespace with all class types and constants + globalDefinitions += '\nnamespace p5 {\n'; + + // Add all constants + p5Constants.forEach(constant => { + const isMutable = mutableProperties.has(constant.name); + const declaration = isMutable ? 'let' : 'const'; + globalDefinitions += ` ${declaration} ${constant.name}: typeof P5.${constant.name};\n`; + }); + + globalDefinitions += '\n'; + + // Add all real classes + Object.values(processed.classes).forEach(classData => { + if (classData.name !== 'p5') { + const className = classData.name.startsWith('p5.') ? classData.name.substring(3) : classData.name; + globalDefinitions += ` type ${className} = P5.${className};\n`; + } + }); + + // Add private classes + for (const className of privateClasses) { + globalDefinitions += ` type ${className} = P5.${className};\n`; + } globalDefinitions += '}\n\n'; From 52928da114451d48bd8e0e1643b8da08f61690de Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 13:29:24 -0400 Subject: [PATCH 13/42] Add types to package.json --- .github/workflows/ci-test.yml | 4 ++ package-lock.json | 90 +++++++++++++++++------------------ package.json | 7 ++- 3 files changed, 55 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index bd3ca55ba0..2ee37a6884 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -27,6 +27,10 @@ jobs: run: npm test env: CI: true + - name: test TypeScript types + run: npm run test:types + env: + CI: true - name: report test coverage run: bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json env: diff --git a/package-lock.json b/package-lock.json index a4cea33377..c1c7d53960 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "rollup": "^4.9.6", "rollup-plugin-string": "^3.0.0", "rollup-plugin-visualizer": "^5.12.0", + "typescript": "^5.9.2", "vite": "^5.0.2", "vite-plugin-string": "^1.2.2", "vitest": "^2.1.5", @@ -3144,14 +3145,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.0.tgz", - "integrity": "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", + "integrity": "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.34.0", - "@typescript-eslint/types": "^8.34.0", + "@typescript-eslint/tsconfig-utils": "^8.44.1", + "@typescript-eslint/types": "^8.44.1", "debug": "^4.3.4" }, "engines": { @@ -3162,18 +3163,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.0.tgz", - "integrity": "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz", + "integrity": "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0" + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3184,9 +3185,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.0.tgz", - "integrity": "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz", + "integrity": "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==", "dev": true, "license": "MIT", "engines": { @@ -3197,13 +3198,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.0.tgz", - "integrity": "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", + "integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", "dev": true, "license": "MIT", "engines": { @@ -3215,16 +3216,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.0.tgz", - "integrity": "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz", + "integrity": "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.34.0", - "@typescript-eslint/tsconfig-utils": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/visitor-keys": "8.34.0", + "@typescript-eslint/project-service": "8.44.1", + "@typescript-eslint/tsconfig-utils": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -3240,13 +3241,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3283,16 +3284,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.0.tgz", - "integrity": "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.1.tgz", + "integrity": "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.34.0", - "@typescript-eslint/types": "8.34.0", - "@typescript-eslint/typescript-estree": "8.34.0" + "@typescript-eslint/scope-manager": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/typescript-estree": "8.44.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3303,18 +3304,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.0.tgz", - "integrity": "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz", + "integrity": "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.34.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.44.1", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12755,12 +12756,11 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index b7c447aff0..89c6d77ca4 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "bench": "vitest bench", "bench:report": "vitest bench --reporter=verbose", "test": "vitest", + "test:types": "npx tsc --noEmit test/types/*.ts", "lint": "eslint .", "lint:fix": "eslint --fix .", "generate-types": "npm run docs && node utils/typescript.mjs" @@ -61,6 +62,7 @@ "rollup": "^4.9.6", "rollup-plugin-string": "^3.0.0", "rollup-plugin-visualizer": "^5.12.0", + "typescript": "^5.9.2", "vite": "^5.0.2", "vite-plugin-string": "^1.2.2", "vitest": "^2.1.5", @@ -73,8 +75,11 @@ "types": "./types/p5.d.ts", "default": "./dist/app.js" }, + "./global": { + "types": "./types/global.d.ts", + "default": "./dist/app.js" + }, "./core": { - "types": "./types/core/main.d.ts", "default": "./dist/core/main.js" }, "./shape": "./dist/shape/index.js", From f85b38163bdfac7d1d71ca307ff7ab858b9afb3a Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 14:04:38 -0400 Subject: [PATCH 14/42] Start adding patches, ignore type tests in vitest --- src/core/main.js | 2 +- test/types/generics.ts | 31 +++++++++++ utils/patch.mjs | 120 ++++++++++++++++++----------------------- utils/typescript.mjs | 7 ++- vitest.workspace.mjs | 3 +- 5 files changed, 91 insertions(+), 72 deletions(-) create mode 100644 test/types/generics.ts diff --git a/src/core/main.js b/src/core/main.js index 6cafe4d375..56cf6eeb26 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -27,7 +27,7 @@ import * as constants from './constants'; * @param {function(p5)} sketch a closure that can set optional preload(), * setup(), and/or draw() properties on the * given p5 instance - * @param {HTMLElement} [node] element to attach canvas to + * @param {String|HTMLElement} [node] element to attach canvas to * @return {p5} a p5 instance */ class p5 { diff --git a/test/types/generics.ts b/test/types/generics.ts new file mode 100644 index 0000000000..a86846e3f4 --- /dev/null +++ b/test/types/generics.ts @@ -0,0 +1,31 @@ +import '../../types/global' + +function setup() { + noCanvas() + + const messages = [ + { content: 'Hello, world!' }, + { content: "How's it going?" }, + ] + const message = random(messages) + + // The types should fail if the result of random() is any + logMessage(message); +} + +// From: https://stackoverflow.com/a/50375286/62076 +type UnionToIntersection = + (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never + +// If T is `any` a union of both side of the condition is returned. +type UnionForAny = T extends never ? 'A' : 'B' + +// Returns true if type is any, or false for any other type. +type IsStrictlyAny = + UnionToIntersection> extends never ? true : false + +type NotAny = IsStrictlyAny extends true ? never : T + +function logMessage(message: NotAny<{ content: string }>) { + console.log(message) +} diff --git a/utils/patch.mjs b/utils/patch.mjs index 8f33730920..db90abc803 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -1,74 +1,56 @@ import fs from 'fs'; -const cache = {}; -const patched = {}; -const replace = (path, src, dest) => { - if (Array.isArray(path)) { - path.forEach(path => replace(path, src, dest)); - return; - } - try { - if (!path.startsWith("types/")) - path = "types/" + path; - - const before = patched[path] ?? - (cache[path] ??= fs.readFileSync("./" + path, { encoding: 'utf-8' })); - const after = before.replace(src, dest); - - if (after !== before) - patched[path] = after; - else - console.error(`A patch failed in ${path}:\n -${src}\n +${dest}`); - } catch (err) { - console.error(err); - } -}; - -// #todo: p5 function doc in structure.d.ts should be merged into the p5 constructor, which then needs to be written to parameterData -replace( - "global.d.ts", - `function p5(sketch: object, node: string | HTMLElement): void;`, - `// function p5(sketch: object, node: string | HTMLElement): void;`, -); -replace( - "global.d.ts", - `p5: typeof p5;`, - `// p5: typeof p5;`, -); -replace( - "p5.d.ts", - "p5(sketch: object, node: string | HTMLElement): void;", - "// p5(sketch: object, node: string | HTMLElement): void;" -); -replace( - "core/structure.d.ts", - "function p5(sketch: object, node: string | HTMLElement): void;", - "// function p5: typeof p5" -); - -replace( - "webgl/p5.Geometry.d.ts", - "constructor(detailX?: number, detailY?: number, callback?: function);", - `constructor( - detailX?: number, - detailY?: number, - callback?: (this: Geometry) => void);` -); - -// https://github.com/p5-types/p5.ts/issues/31 -// #todo: add readonly to appropriate array params, either here or in doc comments -replace( - [ "p5.d.ts", "math/random.d.ts" ], - "random(choices: any[]): any;", - "random(choices: readonly T[]): T;" -); - -for (const [path, data] of Object.entries(patched)) { - try { - console.log(`Patched ${path}`); - fs.writeFileSync("./" + path, data); - } catch (err) { - console.error(err); +export function applyPatches() { + const cache = {}; + const patched = {}; + + const replace = (path, src, dest) => { + if (Array.isArray(path)) { + path.forEach(path => replace(path, src, dest)); + return; + } + try { + if (!path.startsWith("types/")) + path = "types/" + path; + + const before = patched[path] ?? + (cache[path] ??= fs.readFileSync("./" + path, { encoding: 'utf-8' })); + const after = before.replaceAll(src, dest); + + if (after !== before) + patched[path] = after; + else + console.error(`A patch failed in ${path}:\n -${src}\n +${dest}`); + } catch (err) { + console.error(err); + } + }; + + // Commented out - trying to handle this better in the docs + // replace( + // "p5.d.ts", + // "constructor(detailX?: number, detailY?: number, callback?: Function);", + // `constructor( + // detailX?: number, + // detailY?: number, + // callback?: (this: Geometry) => void);` + // ); + + // https://github.com/p5-types/p5.ts/issues/31 + // #todo: add readonly to appropriate array params, either here or in doc comments + replace( + ["p5.d.ts", "global.d.ts"], + "random(choices: any[]): any;", + "random(choices: readonly T[]): T;" + ); + + for (const [path, data] of Object.entries(patched)) { + try { + console.log(`Patched ${path}`); + fs.writeFileSync("./" + path, data); + } catch (err) { + console.error(err); + } } } diff --git a/utils/typescript.mjs b/utils/typescript.mjs index a07d45c725..9c6398c049 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -3,6 +3,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { processData } from './data-processor.mjs'; import { descriptionStringForTypeScript } from './shared-helpers.mjs'; +import { applyPatches } from './patch.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -534,4 +535,8 @@ const { instanceDefinitions, globalDefinitions } = generateTypeDefinitions(); fs.writeFileSync(path.join(__dirname, '../types/p5.d.ts'), instanceDefinitions); fs.writeFileSync(path.join(__dirname, '../types/global.d.ts'), globalDefinitions); -console.log('TypeScript definitions generated successfully!'); \ No newline at end of file +console.log('TypeScript definitions generated successfully!'); + +// Apply patches +console.log('Applying TypeScript patches...'); +applyPatches(); \ No newline at end of file diff --git a/vitest.workspace.mjs b/vitest.workspace.mjs index 3b8cc242f7..21d479b2d8 100644 --- a/vitest.workspace.mjs +++ b/vitest.workspace.mjs @@ -29,7 +29,8 @@ export default defineWorkspace([ exclude: [ './test/unit/spec.js', './test/unit/assets/**/*', - './test/unit/visual/visualTest.js' + './test/unit/visual/visualTest.js', + './test/types/**/*' ], testTimeout: 1000, globals: true, From f6a4f081af92e2a9fccc8409b60b1a80a60ea132 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 14:14:53 -0400 Subject: [PATCH 15/42] Remove old generator tests --- test/unit/types/generate-types.js | 423 ------------------------------ 1 file changed, 423 deletions(-) delete mode 100644 test/unit/types/generate-types.js diff --git a/test/unit/types/generate-types.js b/test/unit/types/generate-types.js deleted file mode 100644 index 47fa5b1f81..0000000000 --- a/test/unit/types/generate-types.js +++ /dev/null @@ -1,423 +0,0 @@ -import { suite, test, expect } from 'vitest'; -import { - normalizeClassName, - generateTypeFromTag, - generateParamDeclaration, - generateFunctionDeclaration, - generateClassDeclaration, - generateMethodDeclarations, - generateTypeDefinitions -} from '../../../utils/helper.mjs'; - -// Move absFuncDoc to the top level -const absFuncDoc = { - 'description': { - 'type': 'root', - 'children': [ - { - 'type': 'paragraph', - 'children': [ - { - 'type': 'text', - 'value': 'Calculates the absolute value of a number.' - } - ] - }, - { - 'type': 'paragraph', - 'children': [ - { - 'type': 'text', - 'value': "A number's absolute value is its distance from zero on the number line.\n-5 and 5 are both five units away from zero, so calling " - }, - { - 'type': 'inlineCode', - 'value': 'abs(-5)' - }, - { - 'type': 'text', - 'value': ' and\n' - }, - { - 'type': 'inlineCode', - 'value': 'abs(5)' - }, - { - 'type': 'text', - 'value': ' both return 5. The absolute value of a number is always positive.' - } - ] - } - ] - }, - 'tags': [ - { - 'title': 'method', - 'description': null, - 'lineNumber': 7, - 'name': 'abs' - }, - { - 'title': 'param', - 'description': 'number to compute.', - 'lineNumber': 8, - 'type': { - 'type': 'NameExpression', - 'name': 'Number' - }, - 'name': 'n' - }, - { - 'title': 'return', - 'description': 'absolute value of given number.', - 'lineNumber': 9, - 'type': { - 'type': 'NameExpression', - 'name': 'Number' - } - }, - { - 'title': 'example', - 'description': "
    \n\nfunction setup() {\n createCanvas(100, 100);\n\n describe('A gray square with a vertical black line that divides it in half. A white rectangle gets taller when the user moves the mouse away from the line.');\n}\n\nfunction draw() {\n background(200);\n\n // Divide the canvas.\n line(50, 0, 50, 100);\n\n // Calculate the mouse's distance from the middle.\n let h = abs(mouseX - 50);\n\n // Draw a rectangle based on the mouse's distance\n // from the middle.\n rect(0, 100 - h, 100, h);\n}\n\n
    ", - 'lineNumber': 11 - } - ], - 'loc': { - 'start': { - 'line': 9, - 'column': 2, - 'index': 112 - }, - 'end': { - 'line': 44, - 'column': 5, - 'index': 1167 - } - }, - 'context': { - 'loc': { - 'start': { - 'line': 45, - 'column': 2, - 'index': 1170 - }, - 'end': { - 'line': 45, - 'column': 20, - 'index': 1188 - } - }, - 'file': 'C:\\Users\\diyas\\Documents\\p5.js\\src\\math\\calculation.js' - }, - 'augments': [], - 'examples': [ - { - 'description': "
    \n\nfunction setup() {\n createCanvas(100, 100);\n\n describe('A gray square with a vertical black line that divides it in half. A white rectangle gets taller when the user moves the mouse away from the line.');\n}\n\nfunction draw() {\n background(200);\n\n // Divide the canvas.\n line(50, 0, 50, 100);\n\n // Calculate the mouse's distance from the middle.\n let h = abs(mouseX - 50);\n\n // Draw a rectangle based on the mouse's distance\n // from the middle.\n rect(0, 100 - h, 100, h);\n}\n\n
    " - } - ], - 'implements': [], - 'params': [ - { - 'title': 'param', - 'name': 'n', - 'lineNumber': 8, - 'description': { - 'type': 'root', - 'children': [ - { - 'type': 'paragraph', - 'children': [ - { - 'type': 'text', - 'value': 'number to compute.' - } - ] - } - ] - }, - 'type': { - 'type': 'NameExpression', - 'name': 'Number' - } - } - ], - 'properties': [], - 'returns': [ - { - 'description': { - 'type': 'root', - 'children': [ - { - 'type': 'paragraph', - 'children': [ - { - 'type': 'text', - 'value': 'absolute value of given number.' - } - ] - } - ] - }, - 'title': 'returns', - 'type': { - 'type': 'NameExpression', - 'name': 'Number' - } - } - ], - 'sees': [], - 'throws': [], - 'todos': [], - 'yields': [], - 'kind': 'function', - 'name': 'abs', - 'members': { - 'global': [], - 'inner': [], - 'instance': [], - 'events': [], - 'static': [] - }, - 'path': [ - { - 'name': 'abs', - 'kind': 'function' - } - ], - 'namespace': 'abs' -}; - -suite('normalizeClassName', () => { - test('should handle different class name formats', () => { - expect(normalizeClassName('p5')).toBe('p5'); - expect(normalizeClassName('Vector')).toBe('p5.Vector'); - expect(normalizeClassName('p5.Color')).toBe('p5.Color'); - expect(normalizeClassName()).toBe('p5'); - }); -}); - -suite('generateTypeFromTag', () => { - test('should handle primitive types', () => { - expect(generateTypeFromTag({ - type: { type: 'NameExpression', name: 'Number' } - })).toBe('number'); - expect(generateTypeFromTag({ - type: { type: 'NameExpression', name: 'String' } - })).toBe('string'); - expect(generateTypeFromTag({ - type: { type: 'NameExpression', name: 'Boolean' } - })).toBe('boolean'); - }); - - test('should handle array types', () => { - expect(generateTypeFromTag({ - type: { - type: 'TypeApplication', - expression: { type: 'NameExpression', name: 'Array' }, - applications: [{ type: 'NameExpression', name: 'Number' }] - } - })).toBe('number[]'); - }); - - test('should handle union types', () => { - expect(generateTypeFromTag({ - type: { - type: 'UnionType', - elements: [ - { type: 'NameExpression', name: 'Number' }, - { type: 'NameExpression', name: 'String' } - ] - } - })).toBe('number | string'); - }); -}); - -suite('generateParamDeclaration', () => { - test('should handle required parameters', () => { - expect(generateParamDeclaration({ - name: 'x', - type: { type: 'NameExpression', name: 'Number' } - })).toBe('x: number'); - }); - - test('should handle optional parameters', () => { - expect(generateParamDeclaration({ - name: 'y', - type: { - type: 'OptionalType', - expression: { type: 'NameExpression', name: 'String' } - } - })).toBe('y?: string'); - }); - - test('should handle parameters with no type', () => { - expect(generateParamDeclaration({ - name: 'unknown' - })).toBe('unknown: any'); - }); -}); - -suite('generateFunctionDeclaration', () => { - test('should handle abs() function data', () => { - const declaration = generateFunctionDeclaration(absFuncDoc); - console.log(declaration); - expect(declaration).toContain('function abs(n: number): number;\n\n'); - }); -}); - -suite('generateClassDeclaration', () => { - test('should generate correct class declaration for p5.Shader', () => { - const classDoc = { - name: 'p5.Shader', - description: '', - params: [ - { name: 'renderer', type: { type: 'NameExpression', name: 'p5.RendererGL' } }, - { name: 'vertSrc', type: { type: 'NameExpression', name: 'string' } }, - { name: 'fragSrc', type: { type: 'NameExpression', name: 'string' } }, - { - name: 'options', - type: { - type: 'OptionalType', - expression: { type: 'NameExpression', name: 'object' } - } - } - ], - module: 'p5', - submodule: null, - extends: null - }; - - const organizedData = { - classitems: [] // Empty since we're just testing class declaration - }; - - const declaration = generateClassDeclaration(classDoc, organizedData); - expect(declaration).toContain('class Shader {\n'); - expect(declaration).toContain('constructor(renderer: p5.RendererGL, vertSrc: string, fragSrc: string, options?: object);\n'); - }); -}); - -suite('generateMethodDeclarations', () => { - test('should generate correct method declaration for copyToContext', () => { - const item = { - name: 'copyToContext', - kind: 'function', - description: '', - params: [{ - name: 'context', - type: { - type: 'UnionType', - elements: [ - { type: 'NameExpression', name: 'p5' }, - { type: 'NameExpression', name: 'p5.Graphics', optional: false } - ] - } - }], - returnType: 'p5.Shader', - class: 'p5.Shader', - module: 'p5', - submodule: null, - isStatic: false, - overloads: undefined - }; - - const declaration = generateMethodDeclarations(item); - expect(declaration).toContain('copyToContext(context: p5 | p5.Graphics): p5.Shader;\n\n'); - }); -}); - -suite('generateTypeDefinitions', () => { - test('should generate type definitions from minimal data', () => { - const result = generateTypeDefinitions([absFuncDoc]); - - const expectedContent = '// This file is auto-generated from JSDoc documentation\n\n' + - 'import p5 from \'p5\';\n\n' + - 'declare module \'p5\' {\n' + - '/**\n' + - ' * Calculates the absolute value of a number.A number\'s absolute value is its distance from zero on the number line.\n' + - ' * -5 and 5 are both five units away from zero, so calling `abs(-5)` and\n' + - ' * `abs(5)` both return 5. The absolute value of a number is always positive.\n' + - ' *\n' + - ' * @param number to compute.\n' + - ' * @return absolute value of given number.\n' + - ' * @example
    \n' + - ' * \n' + - ' * function setup() {\n' + - ' * createCanvas(100, 100);\n' + - ' *\n' + - ' * describe(\'A gray square with a vertical black line that divides it in half. A white rectangle gets taller when the user moves the mouse away from the line.\');\n' + - ' * }\n' + - ' *\n' + - ' * function draw() {\n' + - ' * background(200);\n' + - ' *\n' + - ' * // Divide the canvas.\n' + - ' * line(50, 0, 50, 100);\n' + - ' *\n' + - ' * // Calculate the mouse\'s distance from the middle.\n' + - ' * let h = abs(mouseX - 50);\n' + - ' *\n' + - ' * // Draw a rectangle based on the mouse\'s distance\n' + - ' * // from the middle.\n' + - ' * rect(0, 100 - h, 100, h);\n' + - ' * }\n' + - ' * \n' + - ' *
    \n' + - ' */\n' + - 'function abs(n: number): number;\n\n' + - '}\n\n'; - - const filePath = 'C:\\Users\\diyas\\Documents\\p5.js\\src\\math\\calculation.js'; - - // Helper function to normalize whitespace and newlines - const normalizeString = str => - str.replace(/\s+/g, ' ') - .replace(/\n\s*/g, '\n') - .trim(); - - // Compare normalized strings - expect( - normalizeString(result.fileTypes.get(filePath)) - ).toEqual( - normalizeString(expectedContent) - ); - - // Check global type definitions - expect(result.globalTypes).toEqual( - '// This file is auto-generated from JSDoc documentation\n\n' + - 'import p5 from \'p5\';\n\n' + - 'declare global {\n' + - ' /**\n' + - ' * Calculates the absolute value of a number.A number\'s absolute value is its distance from zero on the number line.\n' + - ' * -5 and 5 are both five units away from zero, so calling `abs(-5)` and\n' + - ' * `abs(5)` both return 5. The absolute value of a number is always positive.\n' + - ' */\n' + - ' function abs(n: number): number;\n\n' + - ' interface Window {\n' + - ' abs: typeof abs;\n' + - ' }\n' + - '}\n\n' + - 'export {};\n' - ); - - // Check p5 type definitions - expect(result.p5Types).toEqual( - '// This file is auto-generated from JSDoc documentation\n\n' + - 'declare class p5 {\n' + - ' constructor(sketch?: (p: p5) => void, node?: HTMLElement, sync?: boolean);\n\n' + - ' /**\n' + - ' * Calculates the absolute value of a number.A number\'s absolute value is its distance from zero on the number line.\n' + - ' * -5 and 5 are both five units away from zero, so calling `abs(-5)` and\n' + - ' * `abs(5)` both return 5. The absolute value of a number is always positive.\n' + - ' *\n' + - ' * @param\n' + - ' */\n' + - ' abs(n: number): number;\n\n' + - '}\n\n' + - 'declare namespace p5 {\n' + - '}\n\n' + - 'export default p5;\n' + - 'export as namespace p5;\n' - ); - }); -}); From b40faad465f7574b783251814d20fa469c4aa1ce Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 14:59:04 -0400 Subject: [PATCH 16/42] Add class properties too --- test/types/basic.ts | 8 ++++++++ utils/data-processor.mjs | 12 +++++++++++- utils/patch.mjs | 18 +++++++++--------- utils/typescript.mjs | 33 +++++++++++++++++++++++++++++++-- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/test/types/basic.ts b/test/types/basic.ts index eb08ca5971..5dbf45a536 100644 --- a/test/types/basic.ts +++ b/test/types/basic.ts @@ -3,9 +3,17 @@ import '../../types/global' p5.disableFriendlyErrors = true let geom: p5.Geometry +let geom2: p5.Geometry function setup() { createCanvas(windowWidth, windowHeight, WEBGL) + geom2 = new p5.Geometry(1, 1, function() { + this.vertices.push(createVector(0, 0, 0)) + this.vertices.push(createVector(1, 0, 0)) + this.vertices.push(createVector(1, 1, 0)) + this.faces.push([0, 1, 2]) + this.computeNormals() + }) } function regenerate() { diff --git a/utils/data-processor.mjs b/utils/data-processor.mjs index 9ca1a649ee..0291e41999 100644 --- a/utils/data-processor.mjs +++ b/utils/data-processor.mjs @@ -109,9 +109,11 @@ export function processData(rawData, strategy) { } // Process constants, typedefs, and properties + const processedNames = new Set(); for (const entry of allData) { if (entry.kind === 'constant' || entry.kind === 'typedef' || entry.kind === 'property' || - (entry.properties && entry.properties.length > 0 && entry.properties[0].title === 'property')) { + (entry.properties && entry.properties.length > 0 && entry.properties[0].title === 'property') || + entry.tags?.some(tag => tag.title === 'property')) { const { module, submodule, forEntry } = getModuleInfo(entry); // Apply strategy filter @@ -120,6 +122,14 @@ export function processData(rawData, strategy) { } const name = entry.name || (entry.properties || [])[0]?.name; + + // Skip duplicates based on name + class combination + const key = `${name}:${forEntry || 'p5'}`; + if (processedNames.has(key)) { + continue; + } + processedNames.add(key); + // For properties, get type from the property definition const propertyType = entry.properties?.[0]?.type || entry.type; diff --git a/utils/patch.mjs b/utils/patch.mjs index db90abc803..91fe92d4ed 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -26,15 +26,15 @@ export function applyPatches() { } }; - // Commented out - trying to handle this better in the docs - // replace( - // "p5.d.ts", - // "constructor(detailX?: number, detailY?: number, callback?: Function);", - // `constructor( - // detailX?: number, - // detailY?: number, - // callback?: (this: Geometry) => void);` - // ); + // TODO: Handle this better in the docs instead of patching + replace( + "p5.d.ts", + "constructor(detailX?: number, detailY?: number, callback?: Function);", + `constructor( + detailX?: number, + detailY?: number, + callback?: (this: Geometry) => void);` + ); // https://github.com/p5-types/p5.ts/issues/31 // #todo: add readonly to appropriate array params, either here or in doc comments diff --git a/utils/typescript.mjs b/utils/typescript.mjs index 9c6398c049..ff49d09f7e 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -326,6 +326,27 @@ function generateClassDeclaration(classData) { // Class methods const classMethodsList = Object.values(processed.classMethods[originalClassName] || {}); + const methodNames = new Set(classMethodsList.map(method => method.name)); + + // Class properties + const classProperties = processed.classitems.filter(item => + item.class === originalClassName && item.itemtype === 'property' + ); + + classProperties.forEach(prop => { + // Skip properties that conflict with method names + if (methodNames.has(prop.name)) { + return; + } + + if (prop.description) { + output += ' /**\n'; + output += formatJSDocComment(prop.description, 4) + '\n'; + output += ' */\n'; + } + const type = convertTypeToTypeScript(prop.type, options); + output += ` ${prop.name}: ${type};\n\n`; + }); const staticMethods = classMethodsList.filter(method => method.static); const instanceMethods = classMethodsList.filter(method => !method.static); @@ -485,17 +506,25 @@ p5: P5; globalDefinitions += '\n'; - // Add all real classes + // Add all real classes as both types and constructors Object.values(processed.classes).forEach(classData => { if (classData.name !== 'p5') { const className = classData.name.startsWith('p5.') ? classData.name.substring(3) : classData.name; - globalDefinitions += ` type ${className} = P5.${className};\n`; + // For Graphics, use __Graphics for constructor + if (className === 'Graphics') { + globalDefinitions += ` type ${className} = P5.${className};\n`; + globalDefinitions += ` const ${className}: typeof P5.__${className};\n`; + } else { + globalDefinitions += ` type ${className} = P5.${className};\n`; + globalDefinitions += ` const ${className}: typeof P5.${className};\n`; + } } }); // Add private classes for (const className of privateClasses) { globalDefinitions += ` type ${className} = P5.${className};\n`; + globalDefinitions += ` const ${className}: typeof P5.${className};\n`; } globalDefinitions += '}\n\n'; From 11fef259c030718524528ee40c8dcc75e93f1fd7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 15:06:17 -0400 Subject: [PATCH 17/42] Fix rest types --- utils/typescript.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/utils/typescript.mjs b/utils/typescript.mjs index ff49d09f7e..cf76a117ce 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -188,6 +188,11 @@ const typescriptStrategy = { result.optional = true; } + // Extract rest flag from RestType + if (type?.type === 'RestType') { + result.rest = true; + } + return result; } }; From c88e621d7013395d42bd5eb15790911a44bbb03c Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 15:07:27 -0400 Subject: [PATCH 18/42] Actually generate types before trying to test them --- .github/workflows/ci-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 2ee37a6884..82216dcc4a 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -27,6 +27,10 @@ jobs: run: npm test env: CI: true + - name: generate TypeScript types + run: npm run generate-types + env: + CI: true - name: test TypeScript types run: npm run test:types env: From c449faafc1061a49f9a507c59f25cc03dded5004 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 15:24:47 -0400 Subject: [PATCH 19/42] Add more type tests, fix docs issue --- docs/parameterData.json | 8 +- src/image/p5.Image.js | 12 +- test/types/basic.ts | 2 + test/types/webgl-pixels.ts | 348 +++++++++++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 10 deletions(-) create mode 100644 test/types/webgl-pixels.ts diff --git a/docs/parameterData.json b/docs/parameterData.json index 650ad7431c..393f2eda00 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -3583,10 +3583,10 @@ "updatePixels": { "overloads": [ [ - "Integer", - "Integer", - "Integer", - "Integer" + "Integer?", + "Integer?", + "Integer?", + "Integer?" ] ] }, diff --git a/src/image/p5.Image.js b/src/image/p5.Image.js index 897e1b02d8..20d9dd23b1 100644 --- a/src/image/p5.Image.js +++ b/src/image/p5.Image.js @@ -209,12 +209,12 @@ class Image { * If the image was loaded from a GIF, then calling `img.updatePixels()` * will update the pixels in current frame. * - * @param {Integer} x x-coordinate of the upper-left corner - * of the subsection to update. - * @param {Integer} y y-coordinate of the upper-left corner - * of the subsection to update. - * @param {Integer} w width of the subsection to update. - * @param {Integer} h height of the subsection to update. + * @param {Integer} [x] x-coordinate of the upper-left corner + * of the subsection to update. + * @param {Integer} [y] y-coordinate of the upper-left corner + * of the subsection to update. + * @param {Integer} [w] width of the subsection to update. + * @param {Integer} [h] height of the subsection to update. * * @example *
    diff --git a/test/types/basic.ts b/test/types/basic.ts index 5dbf45a536..37bc4f9f33 100644 --- a/test/types/basic.ts +++ b/test/types/basic.ts @@ -1,3 +1,5 @@ +// Modified from https://openprocessing.org/sketch/2500100 + import '../../types/global' p5.disableFriendlyErrors = true diff --git a/test/types/webgl-pixels.ts b/test/types/webgl-pixels.ts new file mode 100644 index 0000000000..8c60186372 --- /dev/null +++ b/test/types/webgl-pixels.ts @@ -0,0 +1,348 @@ +// From https://openprocessing.org/sketch/2308573 + +import '../../types/global' +// Fake matter.js import +declare const Matter: any + +let engine: any +let blobs: Blob[] = [] +let metaballShader: p5.Shader +let spheremap: p5.Image +let renderer: p5.RendererGL + +async function setup() { + spheremap = await loadImage('https://deckard.openprocessing.org/user67809/visual2181338/h987a85d77bacbc3b232fb87ce6fe440a/dusseldorf_bridge.jpg') + renderer = createCanvas(600, 600, WEBGL) + metaballShader = createShader(vert, frag) + setupScene() + blobs.push(new Blob(random(-1,1)*100, 50, 100, '#f3e17e')) + blobs.push(new Blob(random(-1,1)*100, -150, 100, '#dd483c')) + blobs.push(new Blob(random(-1,1)*100, -350, 50, '#4b8a5f')) + blobs.push(new Blob(random(-1,1)*100, -550, 50, '#0d150b')) +} + +function setupScene() { + engine = Matter.Engine.create() + + const ground = Matter.Bodies.rectangle(0, height / 2 + 30, width, 60, { + isStatic: true, + }) + const wallLeft = Matter.Bodies.rectangle(-width/2 - 30, 0, 60, 3 * height, { + isStatic: true, + }) + const wallRight = Matter.Bodies.rectangle(width/2 + 30, 0, 60, 3 * height, { + isStatic: true, + }) + Matter.World.add(engine.world, [ground, wallLeft, wallRight]) +} + +function draw() { + background('#faf8e2') + // translate(width/2, height/2) + + for (const blob of blobs) { + blob.update() + } + Matter.Engine.update(engine, 1000 / 60) + + for (const blob of blobs) { + blob.drawBlob() + } +} + +const BLOB_NODE_SIZE = 20 +const BLOB_NODE_R = 15 +const BLOB_NODE_AREA = Math.PI * BLOB_NODE_SIZE * BLOB_NODE_SIZE + +class Blob { + c: p5.Color + nodes: any[] + springs: any[] + tex: p5.Image + + constructor(x, y, r, c) { + this.nodes = [] + this.springs = [] + this.c = color(c) + this.tex = createImage(20, 20) + this.tex.loadPixels() + for (let i = 0; i < this.tex.pixels.length; i++) { + this.tex.pixels[i] = 255 + } + // @ts-ignore + renderer.getTexture(this.tex).setInterpolation(NEAREST, NEAREST) + + const a = PI * r * r + const numBlobs = ceil(a / BLOB_NODE_AREA) + + while (this.nodes.length < numBlobs) { + const rx = random(-r, r) + const ry = random(-r, r) + if (Math.hypot(rx, ry) > r) continue + + const vert = Matter.Bodies.circle(x + rx, y + ry, BLOB_NODE_R, { inertia: Infinity, friction: 0.015 }) + this.nodes.push(vert) + } + + Matter.World.add(engine.world, this.nodes) + } + + bin(x: number, y: number) { + return [round(x/80), round(y/80)] + } + + nodeBin(node) { + return this.bin(node.position.x, node.position.y) + } + + adjacentBins(node) { + const [x, y] = this.nodeBin(node) + const bins: [number, number][] = [] + for (const dx of [-1, 0, 1]) { + for (const dy of [-1, 0, 1]) { + bins.push([x + dx, y + dy]) + } + } + return bins + } + + binKey(bin) { + return bin.join(',') + } + + binnedNodes() { + const bins = {} + for (const node of this.nodes) { + const binKey = this.binKey(this.nodeBin(node)) + if (!bins[binKey]) { + bins[binKey] = [] + } + bins[binKey].push(node) + } + return bins + } + + update() { + Matter.World.remove(engine.world, this.springs) + this.springs = [] + const bins = this.binnedNodes() + for (const node of this.nodes) { + const binsToCheck = this.adjacentBins(node) + for (const bin of binsToCheck) { + const key = this.binKey(bin) + if (!bins[key]) continue + for (const other of bins[key]) { + if (other === node) continue + this.springs.push(Matter.Constraint.create({ + bodyA: node, + pointA: { x: 0, y: 0 }, + bodyB: other, + pointB: { x: 0, y: 0 }, + stiffness: map( + Math.hypot(node.position.x - other.position.x, node.position.y - other.position.y), + 0, 12*BLOB_NODE_SIZE, + 0.02, 0.03, + true + ), + damping: 0.001, + // length: 0, + length: max( + 2 * BLOB_NODE_SIZE, + Math.hypot(node.position.x - other.position.x, node.position.y - other.position.y) * 0.975 + ), + })) + } + } + } + Matter.World.add(engine.world, this.springs) + } + + drawBlob() { + const minX = Math.min(...this.nodes.map((n) => n.position.x)) - 4 * BLOB_NODE_SIZE + const maxX = Math.max(...this.nodes.map((n) => n.position.x)) + 4 * BLOB_NODE_SIZE + const minY = Math.min(...this.nodes.map((n) => n.position.y)) - 4 * BLOB_NODE_SIZE + const maxY = Math.max(...this.nodes.map((n) => n.position.y)) + 4 * BLOB_NODE_SIZE + const x = (maxX + minX)/2 + const y = (maxY + minY)/2 + const w = maxX - minX + const h = maxY - minY + + this.nodes.forEach((node, i) => { + this.tex.pixels[i * 4 + 0] = map(node.position.x, minX, maxX, 0, 255, true) + this.tex.pixels[i * 4 + 1] = map(node.position.y, minY, maxY, 0, 255, true) + }) + this.tex.updatePixels() + + push() + translate(x, y) + noStroke() + shader(metaballShader) + metaballShader.setUniform('bbox', [minX, minY, maxX, maxY]) + metaballShader.setUniform('k', BLOB_NODE_SIZE * 3) + metaballShader.setUniform('numNodes', this.nodes.length) + metaballShader.setUniform('data', this.tex) + metaballShader.setUniform('r', BLOB_NODE_R) + // TODO: make this a public API + // @ts-ignore + metaballShader.setUniform('c', this.c.array()) + metaballShader.setUniform('spheremap', spheremap) + plane(w, h) + pop() + } + + draw2D() { + fill(this.c) + stroke(this.c) + strokeWeight(2 * BLOB_NODE_R) + strokeJoin(ROUND) + const hull = convexHull(this.nodes.map(n => n.position)) + beginShape() + for (const { x, y } of hull) vertex(x, y) + endShape(CLOSE) + + noStroke() + fill(0) + for (const node of this.nodes) { + circle(node.position.x, node.position.y, BLOB_NODE_R * 2) + } + } +} + +let vert = `#version 300 es +precision highp float; + +in vec3 aPosition; +in vec2 aTexCoord; + +uniform mat4 uModelViewMatrix; +uniform mat4 uProjectionMatrix; + +out vec2 vTexCoord; + +void main() { + // Apply the camera transform + vec4 viewModelPosition = + uModelViewMatrix * + vec4(aPosition, 1.0); + + // Tell WebGL where the vertex goes + gl_Position = + uProjectionMatrix * + viewModelPosition; + + // Pass along data to the fragment shader + vTexCoord = aTexCoord; +}` + +let frag = `#version 300 es +precision highp float; + +in vec2 vTexCoord; +out vec4 fragColor; + +uniform sampler2D data; +uniform sampler2D spheremap; +uniform vec4 bbox; +uniform int numNodes; +uniform float k; +uniform float r; +uniform vec4 c; + +float opSmoothUnion( float d1, float d2, float k ) +{ + float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 ); + return mix( d2, d1, h ) - k*h*(1.0-h); +} + +vec2 nodeCoord(int i) { + float x = fract(float(i)/20.) + 1./40.; + float y = floor(float(i)/20.)/20. + 1./40.; + vec2 pos = texture(data, vec2(x, y)).xy; + return mix(bbox.xy, bbox.zw, pos); +} + +void main() { + vec2 coord = mix(bbox.xy, bbox.zw, vTexCoord); + + float dist = 100000.; + float avg = 0.; + float total = 0.; + for (int i = 0; i < 400; i++) { + if (i >= numNodes) break; + + float dist2 = length(coord - nodeCoord(i)) - r; + avg += pow(dist2, 2.); + total += 1.; + dist = opSmoothUnion( + dist, + dist2, + k + ); + // dist = min(dist, dist2); + } + avg /= total; + // vec3 pos = vec3(coord, avg * 0.05); + vec3 pos = vec3(coord, -40.*smoothstep(0., 2., pow(-dist, .1))); + + vec3 normal = -normalize(cross(dFdx(pos), dFdy(pos))); + + vec3 fromCam = normalize(pos - vec3(0., 0., -800.0)); + vec3 n = reflect( + fromCam, + normal + ); + float phi = acos( n.y ); + float theta = 0.0; + theta = acos(n.x / sin(phi)); + float sinTheta = n.z / sin(phi); + if (sinTheta < 0.0) { + // Turn it into -theta, but in the 0-2PI range + theta = 2.0 * 3.14159 - theta; + } + theta = theta / (2.0 * 3.14159); + phi = phi / 3.14159 ; + vec2 angles = vec2( fract(theta + 0.25), 1.0 - phi ); + + vec3 lightDir = normalize(vec3(-0.3, 0.9, 0.)); + float l = 0.15 * max(0., dot(lightDir, normal)) + 0.85; + vec3 outColor = c.xyz * l + pow(texture(spheremap, angles).xyz, vec3(4.)); + // outColor = vec3(smoothstep(0., 2., pow(-dist, .15))); + // outColor = normal; + + fragColor = vec4(outColor, 1.) * (1. - smoothstep(0., 0.01, dist)); +}` + +const comparison = (a: p5.Vector, b: p5.Vector) => { + return a.x == b.x ? a.y - b.y : a.x - b.x +} + +const cross = (a: p5.Vector, b: p5.Vector, o: p5.Vector) => { + return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x) +} + +function convexHull(points: p5.Vector[]) { + points.sort(comparison) + const L: p5.Vector[] = [] + for (let i = 0; i < points.length; i++) { + while ( + L.length >= 2 && + cross(L[L.length - 2], L[L.length - 1], points[i]) <= 0 + ) { + L.pop() + } + L.push(points[i]) + } + const U: p5.Vector[] = [] + for (let i = points.length - 1; i >= 0; i--) { + while ( + U.length >= 2 && + cross(U[U.length - 2], U[U.length - 1], points[i]) <= 0 + ) { + U.pop() + } + U.push(points[i]) + } + L.pop() + U.pop() + return L.concat(U) +} From 026bc82d9c7a7b207dacac1e2ff129e0597e72ad Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 15:36:03 -0400 Subject: [PATCH 20/42] Fix chainable methods not on p5 --- test/types/typography.ts | 73 ++++++++++++++++++++++++++++++++++++++++ utils/data-processor.mjs | 8 +++-- utils/patch.mjs | 11 ++++++ utils/typescript.mjs | 4 +-- 4 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 test/types/typography.ts diff --git a/test/types/typography.ts b/test/types/typography.ts new file mode 100644 index 0000000000..972e43d0df --- /dev/null +++ b/test/types/typography.ts @@ -0,0 +1,73 @@ +// From https://openprocessing.org/sketch/2523015 +import '../../types/global' + +let font: p5.Font +const txt = 'p5.js' + +async function setup() { + createCanvas(windowWidth, windowHeight); + font = await loadFont('https://fonts.gstatic.com/s/sniglet/v17/cIf4MaFLtkE3UjaJ_ImHRGEsnIJkWL4.ttf') +} + +let prevTxt = '' +let prevTxtTime = 0 +function draw() { + if (txt !== prevTxt) { + prevTxt = txt + prevTxtTime = millis() + } + const progress = pow(map(millis(), prevTxtTime, prevTxtTime + 2000, 0, 1, true), 0.5) + + const contours = font.textToContours(txt, 0, 0, { sampleFactor: 1 }) + + background(0) + textAlign(CENTER, CENTER) + textSize(120) + textFont(font) + translate(width/2, height/2) + scale(min(width, height)/300) + const w = max(10, fontWidth(txt)) + scale((width * 0.2) / w) + + beginShape() + for (const contour of contours) { + beginContour() + for (const pt of contour) { + vertex(pt.x, pt.y) + } + endContour(CLOSE) + } + endShape() + + push() + strokeWeight(0.5) + stroke('rgb(255,127,228)') + noFill() + + beginShape(LINES) + for (const contour of contours) { + const pts = contour.map((v) => createVector(v.x, v.y)) + if (pts[0].dist(pts.at(-1)) === 0) pts.pop() + const dists = pts.map((pt, i) => max(1e-6, pt.dist(pts[(i+1)%pts.length]))) + + let tangents = pts.map((v, i) => pts[(i+1)%pts.length].copy().sub(v).div(dists[i])) + for (let it = 0; it < 2; it++) { + tangents = tangents.map( + (tangent, i) => + tangent.copy() + .add(tangents[(i-1+pts.length)%pts.length]) + .add(tangents[(i+1)%pts.length]) + .mult(1/3) + ) + } + + const ks = tangents.map((t, i) => tangents[(i+1)%pts.length].copy().sub(t)) + + pts.forEach((pt, i) => { + vertex(pt.x, pt.y) + vertex(pt.x + ks[i].x * -120 * progress, pt.y + ks[i].y * -120 * progress) + }) + } + endShape() + pop() +} diff --git a/utils/data-processor.mjs b/utils/data-processor.mjs index 0291e41999..11023a0b45 100644 --- a/utils/data-processor.mjs +++ b/utils/data-processor.mjs @@ -224,8 +224,10 @@ export function processData(rawData, strategy) { // Skip methods of private classes if (!processed.classes[className] && className !== 'p5') continue; - // Check for existing method (overloads) - const prevItem = processed.classMethods[className]?.[entry.name]; + // Check for existing method (overloads) - distinguish static vs instance + const isStatic = entry.scope === 'static'; + const methodKey = isStatic ? `static_${entry.name}` : entry.name; + const prevItem = processed.classMethods[className]?.[methodKey]; const item = { name: entry.name, @@ -266,7 +268,7 @@ export function processData(rawData, strategy) { }; processed.classMethods[className] = processed.classMethods[className] || {}; - processed.classMethods[className][entry.name] = item; + processed.classMethods[className][methodKey] = item; } } diff --git a/utils/patch.mjs b/utils/patch.mjs index 91fe92d4ed..9129aa7b39 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -44,6 +44,17 @@ export function applyPatches() { "random(choices: readonly T[]): T;" ); + replace( + 'p5.d.ts', + 'textToContours(str: string, x: number, y: number, options?: object): object[][];', + 'textToContours(str: string, x: number, y: number, options?: { sampleFactor?: number; simplifyThreshold?: number }): { x: number; y: number; alpha: number }[][];', + ) + replace( + 'p5.d.ts', + 'textToModel(str: string, x: number, y: number, width: number, height: number, options?: object)', + 'textToModel(str: string, x: number, y: number, width: number, height: number, options?: { sampleFactor?: number; simplifyThreshold?: number; extrude?: number })', + ) + for (const [path, data] of Object.entries(patched)) { try { console.log(`Patched ${path}`); diff --git a/utils/typescript.mjs b/utils/typescript.mjs index cf76a117ce..8ce7783bb9 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -286,8 +286,8 @@ function generateMethodDeclaration(method, options = {}) { .join(', '); let returnType = 'void'; - if (method.chainable && !globalFunction) { - // returnType = currentClass || 'this'; + if (method.chainable && !globalFunction && options.currentClass !== 'p5') { + returnType = options.currentClass || 'this'; // TODO: Decide what should be chainable. Many of these are accidental / not thought through } else if (overload.return && overload.return.type) { returnType = convertTypeToTypeScript(overload.return.type, options); From 60a2729f6ed8d0dd379bff4ac2a0f7f24f5e7753 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Fri, 26 Sep 2025 16:04:50 -0400 Subject: [PATCH 21/42] Handle options objects better --- docs/parameterData.json | 140 ++------------------------------------- utils/data-processor.mjs | 6 +- utils/patch.mjs | 10 +-- utils/shared-helpers.mjs | 3 +- utils/typescript.mjs | 64 ++++++++++++++++-- 5 files changed, 75 insertions(+), 148 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index 393f2eda00..5c84e91c12 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -4099,10 +4099,7 @@ }, "copy": { "overloads": [ - [], - [ - "p5.Vector" - ] + [] ] }, "add": { @@ -4114,11 +4111,6 @@ ], [ "p5.Vector|Number[]" - ], - [ - "p5.Vector", - "p5.Vector", - "p5.Vector?" ] ] }, @@ -4131,10 +4123,6 @@ ], [ "p5.Vector|Number[]" - ], - [ - "p5.Vector", - "p5.Vector" ] ] }, @@ -4147,11 +4135,6 @@ ], [ "p5.Vector|Number[]" - ], - [ - "p5.Vector", - "p5.Vector", - "p5.Vector?" ] ] }, @@ -4170,22 +4153,6 @@ ], [ "p5.Vector" - ], - [], - [ - "p5.Vector", - "Number", - "p5.Vector?" - ], - [ - "p5.Vector", - "p5.Vector", - "p5.Vector?" - ], - [ - "p5.Vector", - "Number[]", - "p5.Vector?" ] ] }, @@ -4204,39 +4171,17 @@ ], [ "p5.Vector" - ], - [], - [ - "p5.Vector", - "Number", - "p5.Vector?" - ], - [ - "p5.Vector", - "p5.Vector", - "p5.Vector?" - ], - [ - "p5.Vector", - "Number[]", - "p5.Vector?" ] ] }, "mag": { "overloads": [ - [], - [ - "p5.Vector" - ] + [] ] }, "magSq": { "overloads": [ - [], - [ - "p5.Vector" - ] + [] ] }, "dot": { @@ -4248,11 +4193,6 @@ ], [ "p5.Vector" - ], - [], - [ - "p5.Vector", - "p5.Vector" ] ] }, @@ -4260,11 +4200,6 @@ "overloads": [ [ "p5.Vector" - ], - [], - [ - "p5.Vector", - "p5.Vector" ] ] }, @@ -4272,33 +4207,18 @@ "overloads": [ [ "p5.Vector" - ], - [], - [ - "p5.Vector", - "p5.Vector" ] ] }, "normalize": { "overloads": [ - [], - [ - "p5.Vector", - "p5.Vector?" - ] + [] ] }, "limit": { "overloads": [ [ "Number" - ], - [], - [ - "p5.Vector", - "Number", - "p5.Vector?" ] ] }, @@ -4306,21 +4226,12 @@ "overloads": [ [ "Number" - ], - [], - [ - "p5.Vector", - "Number", - "p5.Vector?" ] ] }, "heading": { "overloads": [ - [], - [ - "p5.Vector" - ] + [] ] }, "setHeading": { @@ -4334,12 +4245,6 @@ "overloads": [ [ "Number" - ], - [], - [ - "p5.Vector", - "Number", - "p5.Vector?" ] ] }, @@ -4347,11 +4252,6 @@ "overloads": [ [ "p5.Vector" - ], - [], - [ - "p5.Vector", - "p5.Vector" ] ] }, @@ -4366,13 +4266,6 @@ [ "p5.Vector", "Number" - ], - [], - [ - "p5.Vector", - "p5.Vector", - "Number", - "p5.Vector?" ] ] }, @@ -4381,13 +4274,6 @@ [ "p5.Vector", "Number" - ], - [], - [ - "p5.Vector", - "p5.Vector", - "Number", - "p5.Vector?" ] ] }, @@ -4395,21 +4281,12 @@ "overloads": [ [ "p5.Vector" - ], - [], - [ - "p5.Vector", - "p5.Vector", - "p5.Vector?" ] ] }, "array": { "overloads": [ - [], - [ - "p5.Vector" - ] + [] ] }, "equals": { @@ -4421,11 +4298,6 @@ ], [ "p5.Vector|Array" - ], - [], - [ - "p5.Vector|Array", - "p5.Vector|Array" ] ] }, diff --git a/utils/data-processor.mjs b/utils/data-processor.mjs index 11023a0b45..99f3c44d91 100644 --- a/utils/data-processor.mjs +++ b/utils/data-processor.mjs @@ -139,7 +139,7 @@ export function processData(rawData, strategy) { itemtype: 'property', name, ...locationInfo(entry), - ...strategy.processType(propertyType), + ...strategy.processType(propertyType, entry), ...deprecationInfo(entry), description: strategy.processDescription(entry.description), example: examples.length > 0 ? examples : undefined, @@ -175,7 +175,7 @@ export function processData(rawData, strategy) { params: getParams(entry).map(p => ({ name: p.name, description: p.description && strategy.processDescription(p.description), - ...strategy.processType(p.type) + ...strategy.processType(p.type, p) })), return: entry.returns?.[0] && { description: strategy.processDescription(entry.returns[0].description), @@ -249,7 +249,7 @@ export function processData(rawData, strategy) { params: getParams(entry).map(p => ({ name: p.name, description: p.description && strategy.processDescription(p.description), - ...strategy.processType(p.type) + ...strategy.processType(p.type, p) })), return: entry.returns?.[0] && { description: strategy.processDescription(entry.returns[0].description), diff --git a/utils/patch.mjs b/utils/patch.mjs index 9129aa7b39..021f0dd525 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -49,11 +49,11 @@ export function applyPatches() { 'textToContours(str: string, x: number, y: number, options?: object): object[][];', 'textToContours(str: string, x: number, y: number, options?: { sampleFactor?: number; simplifyThreshold?: number }): { x: number; y: number; alpha: number }[][];', ) - replace( - 'p5.d.ts', - 'textToModel(str: string, x: number, y: number, width: number, height: number, options?: object)', - 'textToModel(str: string, x: number, y: number, width: number, height: number, options?: { sampleFactor?: number; simplifyThreshold?: number; extrude?: number })', - ) + // replace( + // 'p5.d.ts', + // 'textToModel(str: string, x: number, y: number, width: number, height: number, options?: object)', + // 'textToModel(str: string, x: number, y: number, width: number, height: number, options?: { sampleFactor?: number; simplifyThreshold?: number; extrude?: number })', + // ) for (const [path, data] of Object.entries(patched)) { try { diff --git a/utils/shared-helpers.mjs b/utils/shared-helpers.mjs index 1b39d4579e..d56b32d03f 100644 --- a/utils/shared-helpers.mjs +++ b/utils/shared-helpers.mjs @@ -146,7 +146,8 @@ export function getParams(entry) { description: param?.description || { type: 'html', value: node.description - } + }, + properties: param?.properties // Preserve properties array for nested object parameters }; }); } \ No newline at end of file diff --git a/utils/typescript.mjs b/utils/typescript.mjs index 8ce7783bb9..0138d9abfe 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -175,7 +175,7 @@ const typescriptStrategy = { processDescription: (desc) => descriptionStringForTypeScript(desc), - processType: (type) => { + processType: (type, param) => { // Return an object with the original type preserved // This matches the expected data structure from the data processor const result = { @@ -193,6 +193,11 @@ const typescriptStrategy = { result.rest = true; } + // Preserve properties array for nested object parameters + if (param && param.properties) { + result.properties = param.properties; + } + return result; } }; @@ -226,14 +231,63 @@ function formatJSDocComment(text, indentLevel = 0) { .join('\n'); } -function generateParamDeclaration(param, options = {}) { +function generateObjectInterface(param, allParams, options = {}) { + // Check if this is an object parameter (either required or optional) + const isObjectParam = param.type && ( + (param.type.type === 'OptionalType' && param.type.expression?.name === 'Object') || + (param.type.type === 'NameExpression' && param.type.name === 'Object') + ); + + if (!isObjectParam || !param.name) { + return null; + } + + let nestedParams = []; + + + // First, check if the parameter has a properties array (JSDoc properties field) + if (param.properties && Array.isArray(param.properties)) { + nestedParams = param.properties.filter(prop => + prop.name && prop.name.startsWith(param.name + '.') + ); + } + + // Fallback: Look for nested parameters with dot notation in allParams + if (nestedParams.length === 0) { + nestedParams = allParams.filter(p => + p.name && p.name.startsWith(param.name + '.') && p.name !== param.name + ); + } + + if (nestedParams.length === 0) { + return null; + } + + // Generate interface properties + const properties = nestedParams.map(nestedParam => { + const propName = nestedParam.name.substring(param.name.length + 1); // Remove 'paramName.' prefix + const propType = nestedParam.type ? convertTypeToTypeScript(nestedParam.type, options) : 'any'; + // Properties are optional if they have a default value or are explicitly marked as optional + const isOptional = nestedParam.optional || nestedParam.type?.type === 'OptionalType' || nestedParam.default !== undefined; + return `${propName}${isOptional ? '?' : ''}: ${propType}`; + }); + + return `{ ${properties.join('; ')} }`; +} + +function generateParamDeclaration(param, options = {}, allParams = []) { if (!param) return ''; const name = normalizeIdentifier(param.name); + // Check if this is an object parameter that we can generate a better interface for + const objectInterface = generateObjectInterface(param, allParams, options); + // Convert the type - should always be an object let type = 'any'; - if (param.type) { + if (objectInterface) { + type = objectInterface; + } else if (param.type) { type = convertTypeToTypeScript(param.type, options); } @@ -282,7 +336,7 @@ function generateMethodDeclaration(method, options = {}) { if (method.overloads && method.overloads.length > 0) { method.overloads.forEach(overload => { const params = (overload.params || []) - .map(param => generateParamDeclaration(param, options)) + .map(param => generateParamDeclaration(param, options, overload.params)) .join(', '); let returnType = 'void'; @@ -321,7 +375,7 @@ function generateClassDeclaration(classData) { if (classData.params?.length > 0) { output += ' constructor('; output += classData.params - .map(param => generateParamDeclaration(param, { currentClass: className, isInsideNamespace: true })) + .map(param => generateParamDeclaration(param, { currentClass: className, isInsideNamespace: true }, classData.params)) .join(', '); output += ');\n\n'; } From ba443df478962feabdb680a3ec4b6d73b68d7976 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 29 Sep 2025 09:55:40 -0400 Subject: [PATCH 22/42] Document more options object properties --- docs/parameterData.json | 3 +-- src/color/setting.js | 2 ++ src/core/p5.Graphics.js | 10 ++++++++++ src/core/rendering.js | 10 ++++++++++ src/image/loading_displaying.js | 11 +++++------ src/type/p5.Font.js | 11 ++++++----- src/webgl/interaction.js | 6 +++--- src/webgl/material.js | 4 ++-- src/webgl/p5.Geometry.js | 4 ++-- utils/patch.mjs | 7 +------ utils/typescript.mjs | 10 +++++++--- 11 files changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index 5c84e91c12..6cb5d3cc56 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -4342,8 +4342,7 @@ "Number", "Number", "Number?", - "Number?", - "Object?" + "Number?" ] ] }, diff --git a/src/color/setting.js b/src/color/setting.js index 23500830db..2581ddb213 100644 --- a/src/color/setting.js +++ b/src/color/setting.js @@ -31,6 +31,7 @@ function setting(p5, fn){ * * @method beginClip * @param {Object} [options] an object containing clip settings. + * @param {Boolean} [options.invert=false] Whether or not to invert the mask. * * @example *
    @@ -241,6 +242,7 @@ function setting(p5, fn){ * @method clip * @param {Function} callback a function that draws the mask shape. * @param {Object} [options] an object containing clip settings. + * @param {Boolean} [options.invert=false] Whether or not to invert the mask. * * @example *
    diff --git a/src/core/p5.Graphics.js b/src/core/p5.Graphics.js index b831595344..bee903c6eb 100644 --- a/src/core/p5.Graphics.js +++ b/src/core/p5.Graphics.js @@ -341,6 +341,16 @@ class Graphics { * automatically match the graphics buffer and must be changed manually. * * @param {Object} [options] configuration options. + * @param {UNSIGNED_BYTE|FLOAT|HALF_FLOAT} [options.format=UNSIGNED_BYTE] The data format of the texture. + * @param {RGB|RGBA} [options.channels=RGBA] What color channels to include in the texture. + * @param {Boolean} [options.depth=true] Whether to store depth information in the framebuffer. + * @param {UNSIGNED_INT|FLOAT} [options.depthFormat=FLOAT] The format to store depth values in. + * @param {Boolean} [options.stencil=true] Whether to include a stencil buffer (required for clipping.) + * @param {Boolean|Number} [options.antialias] Whether to antialias when drawing to this framebuffer. Either a boolean, or the number of antialias samples to use. + * @param {Number} [options.width] The width of the framebuffer. By default, it will match the main canvas. + * @param {Number} [options.height] The height of the framebuffer. By default, it will match the main canvas. + * @param {Number} [options.density] The pixel density of the framebuffer. By default, it will match the main canvas. + * @param {LINEAR|NEAREST} [options.textureFiltering=LINEAR] The strategy used when reading values in the framebuffer in between pixels. * @return {p5.Framebuffer} new framebuffer. * * @example diff --git a/src/core/rendering.js b/src/core/rendering.js index a941c3bd1e..70e7b3afce 100644 --- a/src/core/rendering.js +++ b/src/core/rendering.js @@ -434,6 +434,16 @@ function rendering(p5, fn){ * * @method createFramebuffer * @param {Object} [options] configuration options. + * @param {UNSIGNED_BYTE|FLOAT|HALF_FLOAT} [options.format=UNSIGNED_BYTE] The data format of the texture. + * @param {RGB|RGBA} [options.channels=RGBA] What color channels to include in the texture. + * @param {Boolean} [options.depth=true] Whether to store depth information in the framebuffer. + * @param {UNSIGNED_INT|FLOAT} [options.depthFormat=FLOAT] The format to store depth values in. + * @param {Boolean} [options.stencil=true] Whether to include a stencil buffer (required for clipping.) + * @param {Boolean|Number} [options.antialias] Whether to antialias when drawing to this framebuffer. Either a boolean, or the number of antialias samples to use. + * @param {Number} [options.width] The width of the framebuffer. By default, it will match the main canvas. + * @param {Number} [options.height] The height of the framebuffer. By default, it will match the main canvas. + * @param {Number} [options.density] The pixel density of the framebuffer. By default, it will match the main canvas. + * @param {LINEAR|NEAREST} [options.textureFiltering=LINEAR] The strategy used when reading values in the framebuffer in between pixels. * @return {p5.Framebuffer} new framebuffer. * * @example diff --git a/src/image/loading_displaying.js b/src/image/loading_displaying.js index 3666ac1056..c06b5fc522 100644 --- a/src/image/loading_displaying.js +++ b/src/image/loading_displaying.js @@ -183,12 +183,11 @@ function loadingDisplaying(p5, fn){ * @param {String} filename file name of gif. * @param {Number} duration duration in seconds to capture from the sketch. * @param {Object} [options] an object that can contain five more properties: - * `delay`, a Number specifying how much time to wait before recording; - * `units`, a String that can be either 'seconds' or 'frames'. By default it's 'seconds’; - * `silent`, a Boolean that defines presence of progress notifications. By default it’s `false`; - * `notificationDuration`, a Number that defines how long in seconds the final notification - * will live. By default it's `0`, meaning the notification will never be removed; - * `notificationID`, a String that specifies the id of the notification's DOM element. By default it’s `'progressBar’`. + * @param {Number} [options.delay=0] How much time to wait before recording. + * @param {'seconds'|'frames'} [options.units='seconds'] The units of the duration and delay. + * @param {Boolean} [options.silent=false] Whether to show progress notifications. + * @param {Number} [options.notificationDuration=0] How long in seconds the final notification will live, or 0 for it to remain permanently. + * @param {String} [options.notificationID='progressBar'] The id to give to the notification's DOM element. * * @example *
    diff --git a/src/type/p5.Font.js b/src/type/p5.Font.js index 96e574aa13..1ffa018185 100644 --- a/src/type/p5.Font.js +++ b/src/type/p5.Font.js @@ -109,7 +109,6 @@ export class Font { * @param {Number} y y‐coordinate of the text baseline. * @param {Number} [width] Optional width for text wrapping. * @param {Number} [height] Optional height for text wrapping. - * @param {Object} [options] Configuration object for rendering text. * @return {Array} A flat array of path commands. * * @example @@ -241,8 +240,9 @@ export class Font { * @param {String} str string of text. * @param {Number} x x-coordinate of the text. * @param {Number} y y-coordinate of the text. - * @param {Object} [options] object with sampleFactor and simplifyThreshold - * properties. + * @param {Object} [options] Configuration: + * @param {Number} [options.sampleFactor=0.1] The ratio of the text's path length to the number of samples. + * @param {Number} [options.simplifyThreshold=0] A minmum angle between two segments. Segments with a shallower angle will be merged. * @return {Array} array of point objects, each with `x`, `y`, and `alpha` (path angle) properties. * * @example @@ -312,8 +312,9 @@ export class Font { * @param {String} str string of text. * @param {Number} x x-coordinate of the text. * @param {Number} y y-coordinate of the text. - * @param {Object} [options] object with sampleFactor and simplifyThreshold - * properties. + * @param {Object} [options] Configuration options: + * @param {Number} [options.sampleFactor=0.1] The ratio of the text's path length to the number of samples. + * @param {Number} [options.simplifyThreshold=0] A minmum angle between two segments. Segments with a shallower angle will be merged. * @return {Array>} array of point objects, each with `x`, `y`, and `alpha` (path angle) properties. * * @example diff --git a/src/webgl/interaction.js b/src/webgl/interaction.js index 8fa21dae9c..b8172e876b 100644 --- a/src/webgl/interaction.js +++ b/src/webgl/interaction.js @@ -69,9 +69,9 @@ function interaction(p5, fn){ * @param {Number} [sensitivityX] sensitivity to movement along the x-axis. Defaults to 1. * @param {Number} [sensitivityY] sensitivity to movement along the y-axis. Defaults to 1. * @param {Number} [sensitivityZ] sensitivity to movement along the z-axis. Defaults to 1. - * @param {Object} [options] object with two optional properties, `disableTouchActions` - * and `freeRotation`. Both are `Boolean`s. `disableTouchActions` - * defaults to `true` and `freeRotation` defaults to `false`. + * @param {Object} [options] Settings for orbitControl: + * @param {Boolean} [options.disableTouchActions=true] Prevent accidental interactions with the page while orbiting. + * @param {Boolean} [options.freeRotation=false] Rotate in the drag direction instead of on principal axes. * @chainable * * @example diff --git a/src/webgl/material.js b/src/webgl/material.js index 917402faae..b511257ae4 100644 --- a/src/webgl/material.js +++ b/src/webgl/material.js @@ -214,8 +214,8 @@ function material(p5, fn){ * @param {String} fragSrc source code for the fragment shader. * @param {Object} [options] An optional object describing how this shader can * be augmented with hooks. It can include: - * - `vertex`: An object describing the available vertex shader hooks. - * - `fragment`: An object describing the available frament shader hooks. + * @param {Object} [options.vertex] An object describing the available vertex shader hooks. + * @param {Object} [options.fragment] An object describing the available frament shader hooks. * @returns {p5.Shader} new shader object created from the * vertex and fragment shaders. * diff --git a/src/webgl/p5.Geometry.js b/src/webgl/p5.Geometry.js index d3c413873f..f1c673f117 100644 --- a/src/webgl/p5.Geometry.js +++ b/src/webgl/p5.Geometry.js @@ -432,8 +432,8 @@ class Geometry { * @method saveStl * @param {String} [fileName='model.stl'] The name of the file to save the model as. * If not specified, the default file name will be 'model.stl'. - * @param {Object} [options] Optional settings. Options can include a boolean `binary` property, which - * controls whether or not a binary .stl file is saved. It defaults to false. + * @param {Object} [options] Optional settings. + * @param {Boolean} [options.binary=false] Whether or not a binary .stl file is saved. * @example *
    * diff --git a/utils/patch.mjs b/utils/patch.mjs index 021f0dd525..3dceac7f36 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -46,14 +46,9 @@ export function applyPatches() { replace( 'p5.d.ts', - 'textToContours(str: string, x: number, y: number, options?: object): object[][];', + 'textToContours(str: string, x: number, y: number, options?: { sampleFactor?: number; simplifyThreshold?: number }): object[][];', 'textToContours(str: string, x: number, y: number, options?: { sampleFactor?: number; simplifyThreshold?: number }): { x: number; y: number; alpha: number }[][];', ) - // replace( - // 'p5.d.ts', - // 'textToModel(str: string, x: number, y: number, width: number, height: number, options?: object)', - // 'textToModel(str: string, x: number, y: number, width: number, height: number, options?: { sampleFactor?: number; simplifyThreshold?: number; extrude?: number })', - // ) for (const [path, data] of Object.entries(patched)) { try { diff --git a/utils/typescript.mjs b/utils/typescript.mjs index 0138d9abfe..83dfc65fb5 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -41,7 +41,7 @@ function convertTypeToTypeScript(typeNode, options = {}) { throw new Error(`convertTypeToTypeScript expects an object, got: ${typeof typeNode} - ${JSON.stringify(typeNode)}`); } - const { currentClass = null, isInsideNamespace = false, inGlobalMode = false } = options; + const { currentClass = null, isInsideNamespace = false, inGlobalMode = false, isConstantDef = false } = options; switch (typeNode.type) { case 'NameExpression': { @@ -87,7 +87,11 @@ function convertTypeToTypeScript(typeNode, options = {}) { if (inGlobalMode) { return `typeof P5.${typeName}`; } else if (typedefs[typeName]) { - return convertTypeToTypeScript(typedefs[typeName], options); + if (isConstantDef) { + return convertTypeToTypeScript(typedefs[typeName], options); + } else { + return `typeof p5.${typeName}` + } } else { return `Symbol`; } @@ -454,7 +458,7 @@ function generateTypeDefinitions() { output += formatJSDocComment(constant.description, 0) + '\n'; output += ' */\n'; } - const type = convertTypeToTypeScript(constant.type, { isInsideNamespace: false }); + const type = convertTypeToTypeScript(constant.type, { isInsideNamespace: false, isConstantDef: true }); const isMutable = mutableProperties.has(constant.name); const declaration = isMutable ? 'declare let' : 'declare const'; output += `${declaration} ${constant.name}: ${type};\n\n`; From 12c10aa0a2de192a8503e1d866891f361964f536 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 29 Sep 2025 10:55:20 -0400 Subject: [PATCH 23/42] Make sure drawingContext is exposed --- docs/parameterData.json | 13 +++++-------- src/core/rendering.js | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index 05a98a38f7..5e101d9e74 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -2098,10 +2098,9 @@ "textAlign": { "overloads": [ [ - "LEFT|CENTER|RIGHT", + "LEFT|CENTER|RIGHT?", "TOP|BOTTOM|CENTER|BASELINE?" - ], - [] + ] ] }, "textAscent": { @@ -2122,17 +2121,15 @@ "overloads": [ [ "Number" - ], - [] + ] ] }, "textFont": { "overloads": [ [ - "p5.Font|String|Object", + "p5.Font|String|Object?", "Number?" - ], - [] + ] ] }, "textSize": { diff --git a/src/core/rendering.js b/src/core/rendering.js index 70e7b3afce..5c145b24f1 100644 --- a/src/core/rendering.js +++ b/src/core/rendering.js @@ -650,7 +650,7 @@ function rendering(p5, fn){ * CanvasRenderingContext2D * object. * - * @property drawingContext + * @property {CanvasRenderingContext2D|WebGLRenderingContext|WebGL2RenderingContext} drawingContext * * @example *
    From c8d548d40b1199820c790abbcb8fdc63ce5816f5 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 29 Sep 2025 10:57:08 -0400 Subject: [PATCH 24/42] Fix spacing in ts doc comments --- utils/shared-helpers.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/shared-helpers.mjs b/utils/shared-helpers.mjs index d56b32d03f..a4681c473d 100644 --- a/utils/shared-helpers.mjs +++ b/utils/shared-helpers.mjs @@ -61,15 +61,15 @@ export function descriptionStringForTypeScript(node, parent) { return node.value; } else if (node.type === 'paragraph') { const content = node.children.map(n => descriptionStringForTypeScript(n, node)).join(''); - return content; // Skip HTML tags for TypeScript + return content + '\n\n'; // Skip HTML tags for TypeScript } else if (node.type === 'code') { return `\`${node.value}\``; } else if (node.type === 'inlineCode') { return `\`${node.value}\``; } else if (node.type === 'list') { - return node.children.map(n => descriptionStringForTypeScript(n, node)).join(''); + return node.children.map(n => descriptionStringForTypeScript(n, node)).join('') + '\n'; } else if (node.type === 'listItem') { - return '- ' + node.children.map(n => descriptionStringForTypeScript(n, node)).join(''); + return '- ' + node.children.map(n => descriptionStringForTypeScript(n, node)).join('') + '\n'; } else if (node.value) { return node.value; } else if (node.children) { From 64881d1f684baebd3bbba2a9bf2225189369a355 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 29 Sep 2025 11:44:44 -0400 Subject: [PATCH 25/42] Add type for elt property, add width/height to framebuffer --- src/dom/p5.Element.js | 1 + src/webgl/p5.Framebuffer.js | 16 +++++++++++++++- utils/typescript.mjs | 9 ++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/dom/p5.Element.js b/src/dom/p5.Element.js index 765b7db267..242f80d343 100644 --- a/src/dom/p5.Element.js +++ b/src/dom/p5.Element.js @@ -2534,6 +2534,7 @@ function element(p5, fn){ * *
    * + * @type {HTMLElement} * @property elt * @for p5.Element * @name elt diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index ad481fb586..ace7d6a8f7 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -1107,7 +1107,7 @@ class Framebuffer { /** * Ensure all readable textures are up-to-date. * @private - * @property {'colorTexutre'|'depthTexture'} property The property to update + * @param {'colorTexutre'|'depthTexture'} property The property to update */ _update(property) { if (this.dirty[property] && this.antialias) { @@ -1887,6 +1887,20 @@ function framebuffer(p5, fn){ * *
    */ + + /** + * The current width of the framebuffer. + * + * @property {Number} width + * @for p5.Framebuffer + */ + + /** + * The current width of the framebuffer. + * + * @property {Number} height + * @for p5.Framebuffer + */ } export default framebuffer; diff --git a/utils/typescript.mjs b/utils/typescript.mjs index 83dfc65fb5..317fa530eb 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -518,8 +518,15 @@ function generateTypeDefinitions() { // Generate placeholder types for private classes that we need to be able to // reference, but have no public APIs const privateClasses = ['Renderer', 'Renderer2D', 'RendererGL', 'FramebufferTexture', 'Texture', 'Quat']; + // Define base classes for private classes, if they should extend something + const privateClassBases = { Renderer: 'Element' }; for (const className of privateClasses) { - output += ` class ${className} {}\n`; + const baseClass = privateClassBases[className]; + if (baseClass) { + output += ` class ${className} extends ${baseClass} {}\n`; + } else { + output += ` class ${className} {}\n`; + } } output += '}\n\n'; From 889815ac39dedf9d9bb8ee596a3ddeb9f387d7cd Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 29 Sep 2025 11:52:39 -0400 Subject: [PATCH 26/42] Add getter for textLeading --- docs/parameterData.json | 2 +- src/type/textCore.js | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index 5e101d9e74..978f65f54e 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -2120,7 +2120,7 @@ "textLeading": { "overloads": [ [ - "Number" + "Number?" ] ] }, diff --git a/src/type/textCore.js b/src/type/textCore.js index 2a45168780..cd55559b12 100644 --- a/src/type/textCore.js +++ b/src/type/textCore.js @@ -499,7 +499,7 @@ function textCore(p5, fn) { * * @method textLeading * @for p5 - * @param {Number} leading The new text leading to apply, in pixels + * @param {Number} [leading] The new text leading to apply, in pixels * @returns {Number} If no arguments are provided, the current text leading * * @example @@ -525,10 +525,6 @@ function textCore(p5, fn) { * * */ - /* - * @method textLeading - * @for p5 - */ /** * Sets the font used by the text() function. @@ -761,7 +757,7 @@ function textCore(p5, fn) { * * For example, if the text contains multiple lines due to wrapping or explicit line breaks, textWidth() * will return the width of the longest line. - * + * * **Note:** In p5.js 2.0+, leading and trailing spaces are ignored. * `textWidth(" Hello ")` returns the same width as `textWidth("Hello")`. * From 1c62b208cdb284992ae5ccb40067f01c7dae1383 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 29 Sep 2025 11:57:15 -0400 Subject: [PATCH 27/42] Update camera properties to make them show up --- src/webgl/p5.Camera.js | 6044 ++++++++++++++++++++-------------------- 1 file changed, 3027 insertions(+), 3017 deletions(-) diff --git a/src/webgl/p5.Camera.js b/src/webgl/p5.Camera.js index 4c7b5e8b40..9455d6a26e 100644 --- a/src/webgl/p5.Camera.js +++ b/src/webgl/p5.Camera.js @@ -19,357 +19,712 @@ class Camera { this.projMatrix = new Matrix(4); this.yScale = 1; } + + //////////////////////////////////////////////////////////////////////////////// + // Camera Projection Methods + //////////////////////////////////////////////////////////////////////////////// + /** - * The camera’s x-coordinate. + * Sets a perspective projection for the camera. * - * By default, the camera’s x-coordinate is set to 0 in "world" space. + * In a perspective projection, shapes that are further from the camera appear + * smaller than shapes that are near the camera. This technique, called + * foreshortening, creates realistic 3D scenes. It’s applied by default in new + * `p5.Camera` objects. * - * @property {Number} eyeX - * @readonly + * `myCamera.perspective()` changes the camera’s perspective by changing its + * viewing frustum. The frustum is the volume of space that’s visible to the + * camera. The frustum’s shape is a pyramid with its top cut off. The camera + * is placed where the top of the pyramid should be and points towards the + * base of the pyramid. It views everything within the frustum. + * + * The first parameter, `fovy`, is the camera’s vertical field of view. It’s + * an angle that describes how tall or narrow a view the camera has. For + * example, calling `myCamera.perspective(0.5)` sets the camera’s vertical + * field of view to 0.5 radians. By default, `fovy` is calculated based on the + * sketch’s height and the camera’s default z-coordinate, which is 800. The + * formula for the default `fovy` is `2 * atan(height / 2 / 800)`. + * + * The second parameter, `aspect`, is the camera’s aspect ratio. It’s a number + * that describes the ratio of the top plane’s width to its height. For + * example, calling `myCamera.perspective(0.5, 1.5)` sets the camera’s field + * of view to 0.5 radians and aspect ratio to 1.5, which would make shapes + * appear thinner on a square canvas. By default, `aspect` is set to + * `width / height`. + * + * The third parameter, `near`, is the distance from the camera to the near + * plane. For example, calling `myCamera.perspective(0.5, 1.5, 100)` sets the + * camera’s field of view to 0.5 radians, its aspect ratio to 1.5, and places + * the near plane 100 pixels from the camera. Any shapes drawn less than 100 + * pixels from the camera won’t be visible. By default, `near` is set to + * `0.1 * 800`, which is 1/10th the default distance between the camera and + * the origin. + * + * The fourth parameter, `far`, is the distance from the camera to the far + * plane. For example, calling `myCamera.perspective(0.5, 1.5, 100, 10000)` + * sets the camera’s field of view to 0.5 radians, its aspect ratio to 1.5, + * places the near plane 100 pixels from the camera, and places the far plane + * 10,000 pixels from the camera. Any shapes drawn more than 10,000 pixels + * from the camera won’t be visible. By default, `far` is set to `10 * 800`, + * which is 10 times the default distance between the camera and the origin. + * + * @for p5.Camera + * @param {Number} [fovy] camera frustum vertical field of view. Defaults to + * `2 * atan(height / 2 / 800)`. + * @param {Number} [aspect] camera frustum aspect ratio. Defaults to + * `width / height`. + * @param {Number} [near] distance from the camera to the near clipping plane. + * Defaults to `0.1 * 800`. + * @param {Number} [far] distance from the camera to the far clipping plane. + * Defaults to `10 * 800`. * * @example *
    * - * let cam; - * let font; + * // Double-click to toggle between cameras. * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * let cam1; + * let cam2; + * let isDefaultCamera = true; + * + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); * - * // Set the camera - * setCamera(cam); + * // Create the second camera. + * cam2 = createCamera(); * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); + * // Place it at the top-right. + * cam2.camera(400, -400, 800); * - * // Point the camera at the origin. - * cam.lookAt(0, 0, 0); + * // Set its fovy to 0.2. + * // Set its aspect to 1.5. + * // Set its near to 600. + * // Set its far to 1200. + * cam2.perspective(0.2, 1.5, 600, 1200); * - * describe( - * 'A white cube on a gray background. The text "eyeX: 0" is written in black beneath it.' - * ); + * // Set the current camera to cam1. + * setCamera(cam1); + * + * describe('A white cube on a gray background. The camera toggles between a frontal view and a skewed aerial view when the user double-clicks.'); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); - * * // Draw the box. * box(); + * } * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); - * - * // Display the value of eyeX, rounded to the nearest integer. - * text(`eyeX: ${round(cam.eyeX)}`, 0, 45); + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } * } * *
    * *
    * - * let cam; - * let font; + * // Double-click to toggle between cameras. * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * let cam1; + * let cam2; + * let isDefaultCamera = true; + * + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); + * // Create the second camera. + * cam2 = createCamera(); * - * // Point the camera at the origin. - * cam.lookAt(0, 0, 0); + * // Place it at the top-right. + * cam2.camera(400, -400, 800); * - * describe( - * 'A white cube on a gray background. The cube appears to move left and right as the camera moves. The text "eyeX: X" is written in black beneath the cube. X oscillates between -25 and 25.' - * ); + * // Set its fovy to 0.2. + * // Set its aspect to 1.5. + * // Set its near to 600. + * // Set its far to 1200. + * cam2.perspective(0.2, 1.5, 600, 1200); + * + * // Set the current camera to cam1. + * setCamera(cam1); + * + * describe('A white cube moves left and right on a gray background. The camera toggles between a frontal and a skewed aerial view when the user double-clicks.'); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); + * // Translate the origin left and right. + * let x = 100 * sin(frameCount * 0.01); + * translate(x, 0, 0); * * // Draw the box. * box(); + * } * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); - * - * // Calculate the new x-coordinate. - * let x = 25 * sin(frameCount * 0.01); - * - * // Set the camera's position. - * cam.setPosition(x, -400, 800); - * - * // Display the value of eyeX, rounded to the nearest integer. - * text(`eyeX: ${round(cam.eyeX)}`, 0, 45); + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } * } * *
    */ + perspective(fovy, aspect, near, far) { + this.cameraType = arguments.length > 0 ? 'custom' : 'default'; + if (typeof fovy === 'undefined') { + fovy = this.defaultCameraFOV; + // this avoids issue where setting angleMode(DEGREES) before calling + // perspective leads to a smaller than expected FOV (because + // _computeCameraDefaultSettings computes in radians) + this.cameraFOV = fovy; + } else { + this.cameraFOV = this._renderer._pInst._toRadians(fovy); + } + if (typeof aspect === 'undefined') { + aspect = this.defaultAspectRatio; + } + if (typeof near === 'undefined') { + near = this.defaultCameraNear; + } + if (typeof far === 'undefined') { + far = this.defaultCameraFar; + } + + if (near <= 0.0001) { + near = 0.01; + console.log( + 'Avoid perspective near plane values close to or below 0. ' + + 'Setting value to 0.01.' + ); + } + + if (far < near) { + console.log( + 'Perspective far plane value is less than near plane value. ' + + 'Nothing will be shown.' + ); + } + + this.aspectRatio = aspect; + this.cameraNear = near; + this.cameraFar = far; + + this.projMatrix = new Matrix(4); + + const f = 1.0 / Math.tan(this.cameraFOV / 2); + const nf = 1.0 / (this.cameraNear - this.cameraFar); + + this.projMatrix.set(f / aspect, 0, 0, 0, + 0, -f * this.yScale, 0, 0, + 0, 0, (far + near) * nf, -1, + 0, 0, (2 * far * near) * nf, 0); + + if (this._isActive()) { + this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); + this._renderer.states.uPMatrix.set(this.projMatrix); + } + } /** - * The camera’s y-coordinate. + * Sets an orthographic projection for the camera. * - * By default, the camera’s y-coordinate is set to 0 in "world" space. + * In an orthographic projection, shapes with the same size always appear the + * same size, regardless of whether they are near or far from the camera. * - * @property {Number} eyeY - * @readonly + * `myCamera.ortho()` changes the camera’s perspective by changing its viewing + * frustum from a truncated pyramid to a rectangular prism. The frustum is the + * volume of space that’s visible to the camera. The camera is placed in front + * of the frustum and views everything within the frustum. `myCamera.ortho()` + * has six optional parameters to define the viewing frustum. * - * @example - *
    - * - * let cam; - * let font; + * The first four parameters, `left`, `right`, `bottom`, and `top`, set the + * coordinates of the frustum’s sides, bottom, and top. For example, calling + * `myCamera.ortho(-100, 100, 200, -200)` creates a frustum that’s 200 pixels + * wide and 400 pixels tall. By default, these dimensions are set based on + * the sketch’s width and height, as in + * `myCamera.ortho(-width / 2, width / 2, -height / 2, height / 2)`. * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * The last two parameters, `near` and `far`, set the distance of the + * frustum’s near and far plane from the camera. For example, calling + * `myCamera.ortho(-100, 100, 200, -200, 50, 1000)` creates a frustum that’s + * 200 pixels wide, 400 pixels tall, starts 50 pixels from the camera, and + * ends 1,000 pixels from the camera. By default, `near` and `far` are set to + * 0 and `max(width, height) + 800`, respectively. + * + * @for p5.Camera + * @param {Number} [left] x-coordinate of the frustum’s left plane. Defaults to `-width / 2`. + * @param {Number} [right] x-coordinate of the frustum’s right plane. Defaults to `width / 2`. + * @param {Number} [bottom] y-coordinate of the frustum’s bottom plane. Defaults to `height / 2`. + * @param {Number} [top] y-coordinate of the frustum’s top plane. Defaults to `-height / 2`. + * @param {Number} [near] z-coordinate of the frustum’s near plane. Defaults to 0. + * @param {Number} [far] z-coordinate of the frustum’s far plane. Defaults to `max(width, height) + 800`. + * + * @example + *
    + * + * // Double-click to toggle between cameras. + * + * let cam1; + * let cam2; + * let isDefaultCamera = true; + * + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); + * // Create the second camera. + * cam2 = createCamera(); * - * // Point the camera at the origin. - * cam.lookAt(0, 0, 0); + * // Apply an orthographic projection. + * cam2.ortho(); * - * // Set the camera. - * setCamera(cam); + * // Set the current camera to cam1. + * setCamera(cam1); * - * describe( - * 'A white cube on a gray background. The text "eyeY: -400" is written in black beneath it.' - * ); + * describe('A row of white cubes against a gray background. The camera toggles between a perspective and an orthographic projection when the user double-clicks.'); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); + * // Translate the origin toward the camera. + * translate(-10, 10, 500); * - * // Draw the box. - * box(); + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -40); + * box(10); + * } + * } * - * // Display the value of eyeY, rounded to the nearest integer. - * text(`eyeY: ${round(cam.eyeY)}`, 0, 45); + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } * } * *
    * *
    * - * let cam; - * let font; + * // Double-click to toggle between cameras. * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * let cam1; + * let cam2; + * let isDefaultCamera = true; + * + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); * - * // Set the camera - * setCamera(cam); + * // Create the second camera. + * cam2 = createCamera(); * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); + * // Apply an orthographic projection. + * cam2.ortho(); * - * // Point the camera at the origin. - * cam.lookAt(0, 0, 0); + * // Set the current camera to cam1. + * setCamera(cam1); * - * describe( - * 'A white cube on a gray background. The cube appears to move up and down as the camera moves. The text "eyeY: Y" is written in black beneath the cube. Y oscillates between -374 and -425.' - * ); + * describe('A row of white cubes slither like a snake against a gray background. The camera toggles between a perspective and an orthographic projection when the user double-clicks.'); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); - * - * // Draw the box. - * box(); - * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Translate the origin toward the camera. + * translate(-10, 10, 500); * - * // Calculate the new y-coordinate. - * let y = 25 * sin(frameCount * 0.01) - 400; + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); * - * // Set the camera's position. - * cam.setPosition(0, y, 800); + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * push(); + * // Calculate the box's coordinates. + * let x = 10 * sin(frameCount * 0.02 + i * 0.6); + * let z = -40 * i; + * // Translate the origin. + * translate(x, 0, z); + * // Draw the box. + * box(10); + * pop(); + * } + * } * - * // Display the value of eyeY, rounded to the nearest integer. - * text(`eyeY: ${round(cam.eyeY)}`, 0, 45); + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } * } * *
    */ + ortho(left, right, bottom, top, near, far) { + const source = this.fbo || this._renderer; + if (left === undefined) left = -source.width / 2; + if (right === undefined) right = +source.width / 2; + if (bottom === undefined) bottom = -source.height / 2; + if (top === undefined) top = +source.height / 2; + if (near === undefined) near = 0; + if (far === undefined) far = Math.max(source.width, source.height) + 800; + this.cameraNear = near; + this.cameraFar = far; + const w = right - left; + const h = top - bottom; + const d = far - near; + const x = +2.0 / w; + const y = +2.0 / h * this.yScale; + const z = -2.0 / d; + const tx = -(right + left) / w; + const ty = -(top + bottom) / h; + const tz = -(far + near) / d; + this.projMatrix = new Matrix(4); + + this.projMatrix.set(x, 0, 0, 0, + 0, -y, 0, 0, + 0, 0, z, 0, + tx, ty, tz, 1); + if (this._isActive()) { + this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); + this._renderer.states.uPMatrix.set(this.projMatrix); + } + this.cameraType = 'custom'; + } /** - * The camera’s z-coordinate. + * Sets the camera's frustum. * - * By default, the camera’s z-coordinate is set to 800 in "world" space. + * In a frustum projection, shapes that are further from the camera appear + * smaller than shapes that are near the camera. This technique, called + * foreshortening, creates realistic 3D scenes. * - * @property {Number} eyeZ - * @readonly + * `myCamera.frustum()` changes the camera’s perspective by changing its + * viewing frustum. The frustum is the volume of space that’s visible to the + * camera. The frustum’s shape is a pyramid with its top cut off. The camera + * is placed where the top of the pyramid should be and points towards the + * base of the pyramid. It views everything within the frustum. + * + * The first four parameters, `left`, `right`, `bottom`, and `top`, set the + * coordinates of the frustum’s sides, bottom, and top. For example, calling + * `myCamera.frustum(-100, 100, 200, -200)` creates a frustum that’s 200 + * pixels wide and 400 pixels tall. By default, these coordinates are set + * based on the sketch’s width and height, as in + * `myCamera.frustum(-width / 20, width / 20, height / 20, -height / 20)`. + * + * The last two parameters, `near` and `far`, set the distance of the + * frustum’s near and far plane from the camera. For example, calling + * `myCamera.frustum(-100, 100, 200, -200, 50, 1000)` creates a frustum that’s + * 200 pixels wide, 400 pixels tall, starts 50 pixels from the camera, and ends + * 1,000 pixels from the camera. By default, near is set to `0.1 * 800`, which + * is 1/10th the default distance between the camera and the origin. `far` is + * set to `10 * 800`, which is 10 times the default distance between the + * camera and the origin. + * + * @for p5.Camera + * @param {Number} [left] x-coordinate of the frustum’s left plane. Defaults to `-width / 20`. + * @param {Number} [right] x-coordinate of the frustum’s right plane. Defaults to `width / 20`. + * @param {Number} [bottom] y-coordinate of the frustum’s bottom plane. Defaults to `height / 20`. + * @param {Number} [top] y-coordinate of the frustum’s top plane. Defaults to `-height / 20`. + * @param {Number} [near] z-coordinate of the frustum’s near plane. Defaults to `0.1 * 800`. + * @param {Number} [far] z-coordinate of the frustum’s far plane. Defaults to `10 * 800`. * * @example *
    * - * let cam; - * let font; - * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); - * createCanvas(100, 100, WEBGL); + * // Double-click to toggle between cameras. * - * // Create a p5.Camera object. - * cam = createCamera(); + * let cam1; + * let cam2; + * let isDefaultCamera = true; * - * // Set the camera - * setCamera(cam); + * function setup() { + * createCanvas(100, 100, WEBGL); * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); * - * // Point the camera at the origin. - * cam.lookAt(0, 0, 0); + * // Create the second camera. + * cam2 = createCamera(); + * + * // Adjust the frustum. + * // Center it. + * // Set its width and height to 20 pixels. + * // Place its near plane 300 pixels from the camera. + * // Place its far plane 350 pixels from the camera. + * cam2.frustum(-10, 10, -10, 10, 300, 350); + * + * // Set the current camera to cam1. + * setCamera(cam1); * * describe( - * 'A white cube on a gray background. The text "eyeZ: 800" is written in black beneath it.' + * 'A row of white cubes against a gray background. The camera zooms in on one cube when the user double-clicks.' * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); + * // Translate the origin toward the camera. + * translate(-10, 10, 600); * - * // Draw the box. - * box(); + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -40); + * box(10); + * } + * } * - * // Display the value of eyeZ, rounded to the nearest integer. - * text(`eyeZ: ${round(cam.eyeZ)}`, 0, 45); + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } * } * *
    + */ + frustum(left, right, bottom, top, near, far) { + if (left === undefined) left = -this._renderer.width * 0.05; + if (right === undefined) right = +this._renderer.width * 0.05; + if (bottom === undefined) bottom = +this._renderer.height * 0.05; + if (top === undefined) top = -this._renderer.height * 0.05; + if (near === undefined) near = this.defaultCameraNear; + if (far === undefined) far = this.defaultCameraFar; + + this.cameraNear = near; + this.cameraFar = far; + + const w = right - left; + const h = top - bottom; + const d = far - near; + + const x = +(2.0 * near) / w; + const y = +(2.0 * near) / h * this.yScale; + const z = -(2.0 * far * near) / d; + + const tx = (right + left) / w; + const ty = (top + bottom) / h; + const tz = -(far + near) / d; + + this.projMatrix = new Matrix(4); + + + this.projMatrix.set(x, 0, 0, 0, + 0, -y, 0, 0, + tx, ty, tz, -1, + 0, 0, z, 0); + + + if (this._isActive()) { + this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); + this._renderer.states.uPMatrix.set(this.projMatrix); + } + + this.cameraType = 'custom'; + } + + //////////////////////////////////////////////////////////////////////////////// + // Camera Orientation Methods + //////////////////////////////////////////////////////////////////////////////// + + /** + * Rotate camera view about arbitrary axis defined by x,y,z + * based on http://learnwebgl.brown37.net/07_cameras/camera_rotating_motion.html + * @private + */ + _rotateView(a, x, y, z) { + let centerX = this.centerX; + let centerY = this.centerY; + let centerZ = this.centerZ; + + // move center by eye position such that rotation happens around eye position + centerX -= this.eyeX; + centerY -= this.eyeY; + centerZ -= this.eyeZ; + + const rotation = new Matrix(4); // TODO Maybe pass p5 + rotation.rotate4x4(this._renderer._pInst._toRadians(a), x, y, z); + + const rotatedCenter = [ + centerX * rotation.mat4[0] + centerY * rotation.mat4[4] + centerZ * rotation.mat4[8], + centerX * rotation.mat4[1] + centerY * rotation.mat4[5] + centerZ * rotation.mat4[9], + centerX * rotation.mat4[2] + centerY * rotation.mat4[6] + centerZ * rotation.mat4[10] + ]; + + // add eye position back into center + rotatedCenter[0] += this.eyeX; + rotatedCenter[1] += this.eyeY; + rotatedCenter[2] += this.eyeZ; + + this.camera( + this.eyeX, + this.eyeY, + this.eyeZ, + rotatedCenter[0], + rotatedCenter[1], + rotatedCenter[2], + this.upX, + this.upY, + this.upZ + ); + } + + /** + * Rotates the camera in a clockwise/counter-clockwise direction. + * + * Rolling rotates the camera without changing its orientation. The rotation + * happens in the camera’s "local" space. + * + * The parameter, `angle`, is the angle the camera should rotate. Passing a + * positive angle, as in `myCamera.roll(0.001)`, rotates the camera in counter-clockwise direction. + * Passing a negative angle, as in `myCamera.roll(-0.001)`, rotates the + * camera in clockwise direction. * + * Note: Angles are interpreted based on the current + * angleMode(). + * + * @method roll + * @param {Number} angle amount to rotate camera in current + * angleMode units. + * @example *
    * * let cam; - * let font; + * let delta = 0.01; * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * function setup() { * createCanvas(100, 100, WEBGL); - * + * normalMaterial(); * // Create a p5.Camera object. * cam = createCamera(); * * // Set the camera * setCamera(cam); - * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); - * - * // Point the camera at the origin. - * cam.lookAt(0, 0, 0); - * - * describe( - * 'A white cube on a gray background. The cube appears to move forward and back as the camera moves. The text "eyeZ: Z" is written in black beneath the cube. Z oscillates between 700 and 900.' - * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); - * - * // Draw the box. - * box(); - * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); - * - * // Calculate the new z-coordinate. - * let z = 100 * sin(frameCount * 0.01) + 800; - * - * // Set the camera's position. - * cam.setPosition(0, -400, z); + * // Roll camera according to angle 'delta' + * cam.roll(delta); * - * // Display the value of eyeZ, rounded to the nearest integer. - * text(`eyeZ: ${round(cam.eyeZ)}`, 0, 45); + * translate(0, 0, 0); + * box(20); + * translate(0, 25, 0); + * box(20); + * translate(0, 26, 0); + * box(20); + * translate(0, 27, 0); + * box(20); + * translate(0, 28, 0); + * box(20); + * translate(0,29, 0); + * box(20); + * translate(0, 30, 0); + * box(20); * } * *
    + * + * @alt + * camera view rotates in counter clockwise direction with vertically stacked boxes in front of it. */ + roll(amount) { + const local = this._getLocalAxes(); + const axisQuaternion = Quat.fromAxisAngle( + this._renderer._pInst._toRadians(amount), + local.z[0], local.z[1], local.z[2]); + // const upQuat = new p5.Quat(0, this.upX, this.upY, this.upZ); + const newUpVector = axisQuaternion.rotateVector( + new Vector(this.upX, this.upY, this.upZ)); + this.camera( + this.eyeX, + this.eyeY, + this.eyeZ, + this.centerX, + this.centerY, + this.centerZ, + newUpVector.x, + newUpVector.y, + newUpVector.z + ); + } /** - * The x-coordinate of the place where the camera looks. + * Rotates the camera left and right. * - * By default, the camera looks at the origin `(0, 0, 0)` in "world" space, so - * `myCamera.centerX` is 0. + * Panning rotates the camera without changing its position. The rotation + * happens in the camera’s "local" space. * - * @property {Number} centerX - * @readonly + * The parameter, `angle`, is the angle the camera should rotate. Passing a + * positive angle, as in `myCamera.pan(0.001)`, rotates the camera to the + * right. Passing a negative angle, as in `myCamera.pan(-0.001)`, rotates the + * camera to the left. + * + * Note: Angles are interpreted based on the current + * angleMode(). + * + * @param {Number} angle amount to rotate in the current + * angleMode(). * * @example *
    * * let cam; - * let font; + * let delta = 0.001; * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * function setup() { * createCanvas(100, 100, WEBGL); * * // Create a p5.Camera object. @@ -381,43 +736,60 @@ class Camera { * // Place the camera at the top-center. * cam.setPosition(0, -400, 800); * - * // Point the camera at (10, 20, -30). - * cam.lookAt(10, 20, -30); + * // Point the camera at the origin. + * cam.lookAt(0, 0, 0); * * describe( - * 'A white cube on a gray background. The text "centerX: 10" is written in black beneath it.' + * 'A white cube on a gray background. The cube goes in and out of view as the camera pans left and right.' * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); - * - * // Draw the box. - * box(); + * // Pan with the camera. + * cam.pan(delta); * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Switch directions every 120 frames. + * if (frameCount % 120 === 0) { + * delta *= -1; + * } * - * // Display the value of centerX, rounded to the nearest integer. - * text(`centerX: ${round(cam.centerX)}`, 0, 45); + * // Draw the box. + * box(); * } * *
    + */ + pan(amount) { + const local = this._getLocalAxes(); + this._rotateView(amount, local.y[0], local.y[1], local.y[2]); + } + + /** + * Rotates the camera up and down. + * + * Tilting rotates the camera without changing its position. The rotation + * happens in the camera’s "local" space. + * + * The parameter, `angle`, is the angle the camera should rotate. Passing a + * positive angle, as in `myCamera.tilt(0.001)`, rotates the camera down. + * Passing a negative angle, as in `myCamera.tilt(-0.001)`, rotates the camera + * up. + * + * Note: Angles are interpreted based on the current + * angleMode(). * + * @param {Number} angle amount to rotate in the current + * angleMode(). + * + * @example *
    * * let cam; - * let font; + * let delta = 0.001; * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * function setup() { * createCanvas(100, 100, WEBGL); * * // Create a p5.Camera object. @@ -426,63 +798,64 @@ class Camera { * // Set the camera * setCamera(cam); * - * // Place the camera at the top-right. - * cam.setPosition(100, -400, 800); + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); * - * // Point the camera at (10, 20, -30). - * cam.lookAt(10, 20, -30); + * // Point the camera at the origin. + * cam.lookAt(0, 0, 0); * * describe( - * 'A white cube on a gray background. The cube appears to move left and right as the camera shifts its focus. The text "centerX: X" is written in black beneath the cube. X oscillates between -15 and 35.' + * 'A white cube on a gray background. The cube goes in and out of view as the camera tilts up and down.' * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); + * // Pan with the camera. + * cam.tilt(delta); + * + * // Switch directions every 120 frames. + * if (frameCount % 120 === 0) { + * delta *= -1; + * } * * // Draw the box. * box(); - * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); - * - * // Calculate the new x-coordinate. - * let x = 25 * sin(frameCount * 0.01) + 10; - * - * // Point the camera. - * cam.lookAt(x, 20, -30); - * - * // Display the value of centerX, rounded to the nearest integer. - * text(`centerX: ${round(cam.centerX)}`, 0, 45); * } * *
    */ + tilt(amount) { + const local = this._getLocalAxes(); + this._rotateView(amount, local.x[0], local.x[1], local.x[2]); + } /** - * The y-coordinate of the place where the camera looks. + * Points the camera at a location. * - * By default, the camera looks at the origin `(0, 0, 0)` in "world" space, so - * `myCamera.centerY` is 0. + * `myCamera.lookAt()` changes the camera’s orientation without changing its + * position. * - * @property {Number} centerY - * @readonly + * The parameters, `x`, `y`, and `z`, are the coordinates in "world" space + * where the camera should point. For example, calling + * `myCamera.lookAt(10, 20, 30)` points the camera at the coordinates + * `(10, 20, 30)`. + * + * @for p5.Camera + * @param {Number} x x-coordinate of the position where the camera should look in "world" space. + * @param {Number} y y-coordinate of the position where the camera should look in "world" space. + * @param {Number} z z-coordinate of the position where the camera should look in "world" space. * * @example *
    * + * // Double-click to look at a different cube. + * * let cam; - * let font; + * let isLookingLeft = true; * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * function setup() { * createCanvas(100, 100, WEBGL); * * // Create a p5.Camera object. @@ -494,1267 +867,1326 @@ class Camera { * // Place the camera at the top-center. * cam.setPosition(0, -400, 800); * - * // Point the camera at (10, 20, -30). - * cam.lookAt(10, 20, -30); + * // Point the camera at the origin. + * cam.lookAt(-30, 0, 0); * * describe( - * 'A white cube on a gray background. The text "centerY: 20" is written in black beneath it.' + * 'A red cube and a blue cube on a gray background. The camera switches focus between the cubes when the user double-clicks.' * ); * } * * function draw() { * background(200); * + * // Draw the box on the left. + * push(); + * // Translate the origin to the left. + * translate(-30, 0, 0); * // Style the box. - * fill(255); - * + * fill(255, 0, 0); * // Draw the box. - * box(); + * box(20); + * pop(); * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Draw the box on the right. + * push(); + * // Translate the origin to the right. + * translate(30, 0, 0); + * // Style the box. + * fill(0, 0, 255); + * // Draw the box. + * box(20); + * pop(); + * } * - * // Display the value of centerY, rounded to the nearest integer. - * text(`centerY: ${round(cam.centerY)}`, 0, 45); + * // Change the camera's focus when the user double-clicks. + * function doubleClicked() { + * if (isLookingLeft === true) { + * cam.lookAt(30, 0, 0); + * isLookingLeft = false; + * } else { + * cam.lookAt(-30, 0, 0); + * isLookingLeft = true; + * } * } * *
    + */ + lookAt(x, y, z) { + this.camera( + this.eyeX, + this.eyeY, + this.eyeZ, + x, + y, + z, + this.upX, + this.upY, + this.upZ + ); + } + + //////////////////////////////////////////////////////////////////////////////// + // Camera Position Methods + //////////////////////////////////////////////////////////////////////////////// + + /** + * Sets the position and orientation of the camera. + * + * `myCamera.camera()` allows objects to be viewed from different angles. It + * has nine parameters that are all optional. + * + * The first three parameters, `x`, `y`, and `z`, are the coordinates of the + * camera’s position in "world" space. For example, calling + * `myCamera.camera(0, 0, 0)` places the camera at the origin `(0, 0, 0)`. By + * default, the camera is placed at `(0, 0, 800)`. + * + * The next three parameters, `centerX`, `centerY`, and `centerZ` are the + * coordinates of the point where the camera faces in "world" space. For + * example, calling `myCamera.camera(0, 0, 0, 10, 20, 30)` places the camera + * at the origin `(0, 0, 0)` and points it at `(10, 20, 30)`. By default, the + * camera points at the origin `(0, 0, 0)`. + * + * The last three parameters, `upX`, `upY`, and `upZ` are the components of + * the "up" vector in "local" space. The "up" vector orients the camera’s + * y-axis. For example, calling + * `myCamera.camera(0, 0, 0, 10, 20, 30, 0, -1, 0)` places the camera at the + * origin `(0, 0, 0)`, points it at `(10, 20, 30)`, and sets the "up" vector + * to `(0, -1, 0)` which is like holding it upside-down. By default, the "up" + * vector is `(0, 1, 0)`. + * + * @for p5.Camera + * @param {Number} [x] x-coordinate of the camera. Defaults to 0. + * @param {Number} [y] y-coordinate of the camera. Defaults to 0. + * @param {Number} [z] z-coordinate of the camera. Defaults to 800. + * @param {Number} [centerX] x-coordinate of the point the camera faces. Defaults to 0. + * @param {Number} [centerY] y-coordinate of the point the camera faces. Defaults to 0. + * @param {Number} [centerZ] z-coordinate of the point the camera faces. Defaults to 0. + * @param {Number} [upX] x-component of the camera’s "up" vector. Defaults to 0. + * @param {Number} [upY] x-component of the camera’s "up" vector. Defaults to 1. + * @param {Number} [upZ] z-component of the camera’s "up" vector. Defaults to 0. * + * @example *
    * - * let cam; - * let font; + * // Double-click to toggle between cameras. * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * let cam1; + * let cam2; + * let isDefaultCamera = true; + * + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); * - * // Set the camera - * setCamera(cam); + * // Create the second camera. + * cam2 = createCamera(); * - * // Place the camera at the top-right. - * cam.setPosition(100, -400, 800); + * // Place it at the top-right: (1200, -600, 100) + * // Point it at the row of boxes: (-10, -10, 400) + * // Set its "up" vector to the default: (0, 1, 0) + * cam2.camera(1200, -600, 100, -10, -10, 400, 0, 1, 0); * - * // Point the camera at (10, 20, -30). - * cam.lookAt(10, 20, -30); + * // Set the current camera to cam1. + * setCamera(cam1); * * describe( - * 'A white cube on a gray background. The cube appears to move up and down as the camera shifts its focus. The text "centerY: Y" is written in black beneath the cube. Y oscillates between -5 and 45.' + * 'A row of white cubes against a gray background. The camera toggles between a frontal and an aerial view when the user double-clicks.' * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); - * - * // Draw the box. - * box(); - * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Translate the origin toward the camera. + * translate(-10, 10, 500); * - * // Calculate the new y-coordinate. - * let y = 25 * sin(frameCount * 0.01) + 20; + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); * - * // Point the camera. - * cam.lookAt(10, y, -30); + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -30); + * box(10); + * } + * } * - * // Display the value of centerY, rounded to the nearest integer. - * text(`centerY: ${round(cam.centerY)}`, 0, 45); + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } * } * *
    - */ - - /** - * The y-coordinate of the place where the camera looks. - * - * By default, the camera looks at the origin `(0, 0, 0)` in "world" space, so - * `myCamera.centerZ` is 0. - * - * @property {Number} centerZ - * @readonly * - * @example *
    * - * let cam; - * let font; + * // Double-click to toggle between cameras. * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * let cam1; + * let cam2; + * let isDefaultCamera = true; + * + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); * - * // Set the camera - * setCamera(cam); + * // Create the second camera. + * cam2 = createCamera(); * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); + * // Place it at the right: (1200, 0, 100) + * // Point it at the row of boxes: (-10, -10, 400) + * // Set its "up" vector to the default: (0, 1, 0) + * cam2.camera(1200, 0, 100, -10, -10, 400, 0, 1, 0); * - * // Point the camera at (10, 20, -30). - * cam.lookAt(10, 20, -30); + * // Set the current camera to cam1. + * setCamera(cam1); * * describe( - * 'A white cube on a gray background. The text "centerZ: -30" is written in black beneath it.' + * 'A row of white cubes against a gray background. The camera toggles between a static frontal view and an orbiting view when the user double-clicks.' * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); + * // Update cam2's position. + * let x = 1200 * cos(frameCount * 0.01); + * let y = -600 * sin(frameCount * 0.01); + * cam2.camera(x, y, 100, -10, -10, 400, 0, 1, 0); * - * // Draw the box. - * box(); + * // Translate the origin toward the camera. + * translate(-10, 10, 500); * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); * - * // Display the value of centerZ, rounded to the nearest integer. - * text(`centerZ: ${round(cam.centerZ)}`, 0, 45); + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -30); + * box(10); + * } + * } + * + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } * } * *
    + */ + camera( + eyeX, + eyeY, + eyeZ, + centerX, + centerY, + centerZ, + upX, + upY, + upZ + ) { + if (typeof eyeX === 'undefined') { + eyeX = this.defaultEyeX; + eyeY = this.defaultEyeY; + eyeZ = this.defaultEyeZ; + centerX = eyeX; + centerY = eyeY; + centerZ = 0; + upX = 0; + upY = 1; + upZ = 0; + } + + this.eyeX = eyeX; + this.eyeY = eyeY; + this.eyeZ = eyeZ; + + if (typeof centerX !== 'undefined') { + this.centerX = centerX; + this.centerY = centerY; + this.centerZ = centerZ; + } + + if (typeof upX !== 'undefined') { + this.upX = upX; + this.upY = upY; + this.upZ = upZ; + } + + const local = this._getLocalAxes(); + + // the camera affects the model view matrix, insofar as it + // inverse translates the world to the eye position of the camera + // and rotates it. + + this.cameraMatrix.set(local.x[0], local.y[0], local.z[0], 0, + local.x[1], local.y[1], local.z[1], 0, + local.x[2], local.y[2], local.z[2], 0, + 0, 0, 0, 1); + + + const tx = -eyeX; + const ty = -eyeY; + const tz = -eyeZ; + + this.cameraMatrix.translate([tx, ty, tz]); + + if (this._isActive()) { + this._renderer.states.setValue('uViewMatrix', this._renderer.states.uViewMatrix.clone()); + this._renderer.states.uViewMatrix.set(this.cameraMatrix); + } + return this; + } + + /** + * Moves the camera along its "local" axes without changing its orientation. + * + * The parameters, `x`, `y`, and `z`, are the distances the camera should + * move. For example, calling `myCamera.move(10, 20, 30)` moves the camera 10 + * pixels to the right, 20 pixels down, and 30 pixels backward in its "local" + * space. * + * @param {Number} x distance to move along the camera’s "local" x-axis. + * @param {Number} y distance to move along the camera’s "local" y-axis. + * @param {Number} z distance to move along the camera’s "local" z-axis. + * @example *
    * + * // Click the canvas to begin detecting key presses. + * * let cam; - * let font; * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. + * // Create the first camera. + * // Keep its default settings. * cam = createCamera(); * * // Place the camera at the top-right. - * cam.setPosition(100, -400, 800); + * cam.setPosition(400, -400, 800); * - * // Point the camera at (10, 20, -30). - * cam.lookAt(10, 20, -30); + * // Point it at the origin. + * cam.lookAt(0, 0, 0); + * + * // Set the camera. + * setCamera(cam); * * describe( - * 'A white cube on a gray background. The cube appears to move forward and back as the camera shifts its focus. The text "centerZ: Z" is written in black beneath the cube. Z oscillates between -55 and -25.' + * 'A white cube drawn against a gray background. The cube appears to move when the user presses certain keys.' * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); - * - * // Draw the box. - * box(); + * // Move the camera along its "local" axes + * // when the user presses certain keys. * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Move horizontally. + * if (keyIsDown(LEFT_ARROW)) { + * cam.move(-1, 0, 0); + * } + * if (keyIsDown(RIGHT_ARROW)) { + * cam.move(1, 0, 0); + * } * - * // Calculate the new z-coordinate. - * let z = 25 * sin(frameCount * 0.01) - 30; + * // Move vertically. + * if (keyIsDown(UP_ARROW)) { + * cam.move(0, -1, 0); + * } + * if (keyIsDown(DOWN_ARROW)) { + * cam.move(0, 1, 0); + * } * - * // Point the camera. - * cam.lookAt(10, 20, z); + * // Move in/out of the screen. + * if (keyIsDown('i')) { + * cam.move(0, 0, -1); + * } + * if (keyIsDown('o')) { + * cam.move(0, 0, 1); + * } * - * // Display the value of centerZ, rounded to the nearest integer. - * text(`centerZ: ${round(cam.centerZ)}`, 0, 45); + * // Draw the box. + * box(); * } * *
    */ + move(x, y, z) { + const local = this._getLocalAxes(); - /** - * The x-component of the camera's "up" vector. - * - * The camera's "up" vector orients its y-axis. By default, the "up" vector is - * `(0, 1, 0)`, so its x-component is 0 in "local" space. - * - * @property {Number} upX - * @readonly - * - * @example - *
    - * - * let cam; - * let font; - * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + // scale local axes by movement amounts + // based on http://learnwebgl.brown37.net/07_cameras/camera_linear_motion.html + const dx = [local.x[0] * x, local.x[1] * x, local.x[2] * x]; + const dy = [local.y[0] * y, local.y[1] * y, local.y[2] * y]; + const dz = [local.z[0] * z, local.z[1] * z, local.z[2] * z]; + + this.camera( + this.eyeX + dx[0] + dy[0] + dz[0], + this.eyeY + dx[1] + dy[1] + dz[1], + this.eyeZ + dx[2] + dy[2] + dz[2], + this.centerX + dx[0] + dy[0] + dz[0], + this.centerY + dx[1] + dy[1] + dz[1], + this.centerZ + dx[2] + dy[2] + dz[2], + this.upX, + this.upY, + this.upZ + ); + } + + /** + * Sets the camera’s position in "world" space without changing its + * orientation. + * + * The parameters, `x`, `y`, and `z`, are the coordinates where the camera + * should be placed. For example, calling `myCamera.setPosition(10, 20, 30)` + * places the camera at coordinates `(10, 20, 30)` in "world" space. + * + * @param {Number} x x-coordinate in "world" space. + * @param {Number} y y-coordinate in "world" space. + * @param {Number} z z-coordinate in "world" space. + * + * @example + *
    + * + * // Double-click to toggle between cameras. + * + * let cam1; + * let cam2; + * let isDefaultCamera = true; + * + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); * - * // Set the camera - * setCamera(cam); + * // Create the second camera. + * cam2 = createCamera(); * - * // Place the camera at the top-right: (100, -400, 800) - * // Point it at the origin: (0, 0, 0) - * // Set its "up" vector: (0, 1, 0). - * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); + * // Place it closer to the origin. + * cam2.setPosition(0, 0, 600); + * + * // Set the current camera to cam1. + * setCamera(cam1); * * describe( - * 'A white cube on a gray background. The text "upX: 0" is written in black beneath it.' + * 'A row of white cubes against a gray background. The camera toggles the amount of zoom when the user double-clicks.' * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); + * // Translate the origin toward the camera. + * translate(-10, 10, 500); * - * // Draw the box. - * box(); + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -30); + * box(10); + * } + * } * - * // Display the value of upX, rounded to the nearest tenth. - * text(`upX: ${round(cam.upX, 1)}`, 0, 45); + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } * } * *
    * *
    * - * let cam; - * let font; + * // Double-click to toggle between cameras. * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * let cam1; + * let cam2; + * let isDefaultCamera = true; + * + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); * - * // Set the camera - * setCamera(cam); + * // Create the second camera. + * cam2 = createCamera(); * - * // Place the camera at the top-right: (100, -400, 800) - * // Point it at the origin: (0, 0, 0) - * // Set its "up" vector: (0, 1, 0). - * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); + * // Place it closer to the origin. + * cam2.setPosition(0, 0, 600); + * + * // Set the current camera to cam1. + * setCamera(cam1); * * describe( - * 'A white cube on a gray background. The cube appears to rock back and forth. The text "upX: X" is written in black beneath it. X oscillates between -1 and 1.' + * 'A row of white cubes against a gray background. The camera toggles between a static view and a view that zooms in and out when the user double-clicks.' * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); - * - * // Draw the box. - * box(); + * // Update cam2's z-coordinate. + * let z = 100 * sin(frameCount * 0.01) + 700; + * cam2.setPosition(0, 0, z); * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * // Translate the origin toward the camera. + * translate(-10, 10, 500); * - * // Calculate the x-component. - * let x = sin(frameCount * 0.01); + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); * - * // Update the camera's "up" vector. - * cam.camera(100, -400, 800, 0, 0, 0, x, 1, 0); + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -30); + * box(10); + * } + * } * - * // Display the value of upX, rounded to the nearest tenth. - * text(`upX: ${round(cam.upX, 1)}`, 0, 45); + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } * } * *
    */ + setPosition(x, y, z) { + const diffX = x - this.eyeX; + const diffY = y - this.eyeY; + const diffZ = z - this.eyeZ; + + this.camera( + x, + y, + z, + this.centerX + diffX, + this.centerY + diffY, + this.centerZ + diffZ, + this.upX, + this.upY, + this.upZ + ); + } /** - * The y-component of the camera's "up" vector. + * Sets the camera’s position, orientation, and projection by copying another + * camera. * - * The camera's "up" vector orients its y-axis. By default, the "up" vector is - * `(0, 1, 0)`, so its y-component is 1 in "local" space. + * The parameter, `cam`, is the `p5.Camera` object to copy. For example, calling + * `cam2.set(cam1)` will set `cam2` using `cam1`’s configuration. * - * @property {Number} upY - * @readonly + * @param {p5.Camera} cam camera to copy. * * @example *
    * - * let cam; - * let font; + * // Double-click to "reset" the camera zoom. * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * let cam1; + * let cam2; + * + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Create the first camera. + * cam1 = createCamera(); * - * // Set the camera - * setCamera(cam); + * // Place the camera at the top-right. + * cam1.setPosition(400, -400, 800); * - * // Place the camera at the top-right: (100, -400, 800) - * // Point it at the origin: (0, 0, 0) - * // Set its "up" vector: (0, 1, 0). - * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); + * // Point it at the origin. + * cam1.lookAt(0, 0, 0); + * + * // Create the second camera. + * cam2 = createCamera(); + * + * // Copy cam1's configuration. + * cam2.set(cam1); + * + * // Set the camera. + * setCamera(cam2); * * describe( - * 'A white cube on a gray background. The text "upY: 1" is written in black beneath it.' + * 'A white cube drawn against a gray background. The camera slowly moves forward. The camera resets when the user double-clicks.' * ); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); + * // Update cam2's position. + * cam2.move(0, 0, -1); * * // Draw the box. * box(); - * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); - * - * // Display the value of upY, rounded to the nearest tenth. - * text(`upY: ${round(cam.upY, 1)}`, 0, 45); * } - * - *
    - * - *
    - * - * let cam; - * let font; - * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); - * createCanvas(100, 100, WEBGL); - * - * // Create a p5.Camera object. - * cam = createCamera(); * - * // Set the camera - * setCamera(cam); - * - * // Place the camera at the top-right: (100, -400, 800) - * // Point it at the origin: (0, 0, 0) - * // Set its "up" vector: (0, 1, 0). - * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); - * - * describe( - * 'A white cube on a gray background. The cube flips upside-down periodically. The text "upY: Y" is written in black beneath it. Y oscillates between -1 and 1.' - * ); + * // "Reset" the camera when the user double-clicks. + * function doubleClicked() { + * cam2.set(cam1); * } + */ + set(cam) { + const keyNamesOfThePropToCopy = [ + 'eyeX', 'eyeY', 'eyeZ', + 'centerX', 'centerY', 'centerZ', + 'upX', 'upY', 'upZ', + 'cameraFOV', 'aspectRatio', 'cameraNear', 'cameraFar', 'cameraType', + 'yScale', 'useLinePerspective' + ]; + for (const keyName of keyNamesOfThePropToCopy) { + this[keyName] = cam[keyName]; + } + + this.cameraMatrix = cam.cameraMatrix.copy(); + this.projMatrix = cam.projMatrix.copy(); + + if (this._isActive()) { + this._renderer.states.setValue('uModelMatrix', this._renderer.states.uModelMatrix.clone()); + this._renderer.states.setValue('uViewMatrix', this._renderer.states.uViewMatrix.clone()); + this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); + this._renderer.states.uModelMatrix.reset(); + this._renderer.states.uViewMatrix.set(this.cameraMatrix); + this._renderer.states.uPMatrix.set(this.projMatrix); + } + } + /** + * Sets the camera’s position and orientation to values that are in-between + * those of two other cameras. * - * function draw() { - * background(200); - * - * // Style the box. - * fill(255); - * - * // Draw the box. - * box(); - * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); + * `myCamera.slerp()` uses spherical linear interpolation to calculate a + * position and orientation that’s in-between two other cameras. Doing so is + * helpful for transitioning smoothly between two perspectives. * - * // Calculate the y-component. - * let y = sin(frameCount * 0.01); + * The first two parameters, `cam0` and `cam1`, are the `p5.Camera` objects + * that should be used to set the current camera. * - * // Update the camera's "up" vector. - * cam.camera(100, -400, 800, 0, 0, 0, 0, y, 0); + * The third parameter, `amt`, is the amount to interpolate between `cam0` and + * `cam1`. 0.0 keeps the camera’s position and orientation equal to `cam0`’s, + * 0.5 sets them halfway between `cam0`’s and `cam1`’s , and 1.0 sets the + * position and orientation equal to `cam1`’s. * - * // Display the value of upY, rounded to the nearest tenth. - * text(`upY: ${round(cam.upY, 1)}`, 0, 45); - * } - * - *
    - */ - - /** - * The z-component of the camera's "up" vector. + * For example, calling `myCamera.slerp(cam0, cam1, 0.1)` sets cam’s position + * and orientation very close to `cam0`’s. Calling + * `myCamera.slerp(cam0, cam1, 0.9)` sets cam’s position and orientation very + * close to `cam1`’s. * - * The camera's "up" vector orients its y-axis. By default, the "up" vector is - * `(0, 1, 0)`, so its z-component is 0 in "local" space. + * Note: All of the cameras must use the same projection. * - * @property {Number} upZ - * @readonly + * @param {p5.Camera} cam0 first camera. + * @param {p5.Camera} cam1 second camera. + * @param {Number} amt amount of interpolation between 0.0 (`cam0`) and 1.0 (`cam1`). * * @example *
    * * let cam; - * let font; + * let cam0; + * let cam1; * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); + * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. + * // Create the main camera. + * // Keep its default settings. * cam = createCamera(); * - * // Set the camera - * setCamera(cam); - * - * // Place the camera at the top-right: (100, -400, 800) - * // Point it at the origin: (0, 0, 0) - * // Set its "up" vector: (0, 1, 0). - * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); - * - * describe( - * 'A white cube on a gray background. The text "upZ: 0" is written in black beneath it.' - * ); - * } - * - * function draw() { - * background(200); - * - * // Style the box. - * fill(255); - * - * // Draw the box. - * box(); - * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); - * - * // Display the value of upZ, rounded to the nearest tenth. - * text(`upZ: ${round(cam.upZ, 1)}`, 0, 45); - * } - * - *
    + * // Create the first camera. + * // Keep its default settings. + * cam0 = createCamera(); * - *
    - * - * let cam; - * let font; + * // Create the second camera. + * cam1 = createCamera(); * - * async function setup() { - * // Load a font and create a p5.Font object. - * font = await loadFont('assets/inconsolata.otf'); - * createCanvas(100, 100, WEBGL); + * // Place it at the top-right. + * cam1.setPosition(400, -400, 800); * - * // Create a p5.Camera object. - * cam = createCamera(); + * // Point it at the origin. + * cam1.lookAt(0, 0, 0); * - * // Set the camera + * // Set the current camera to cam. * setCamera(cam); * - * // Place the camera at the top-right: (100, -400, 800) - * // Point it at the origin: (0, 0, 0) - * // Set its "up" vector: (0, 1, 0). - * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); - * - * describe( - * 'A white cube on a gray background. The cube appears to rock back and forth. The text "upZ: Z" is written in black beneath it. Z oscillates between -1 and 1.' - * ); + * describe('A white cube drawn against a gray background. The camera slowly oscillates between a frontal view and an aerial view.'); * } * * function draw() { * background(200); * - * // Style the box. - * fill(255); - * - * // Draw the box. - * box(); - * - * // Style the text. - * textAlign(CENTER); - * textSize(16); - * textFont(font); - * fill(0); - * - * // Calculate the z-component. - * let z = sin(frameCount * 0.01); + * // Calculate the amount to interpolate between cam0 and cam1. + * let amt = 0.5 * sin(frameCount * 0.01) + 0.5; * - * // Update the camera's "up" vector. - * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, z); + * // Update the main camera's position and orientation. + * cam.slerp(cam0, cam1, amt); * - * // Display the value of upZ, rounded to the nearest tenth. - * text(`upZ: ${round(cam.upZ, 1)}`, 0, 45); + * box(); * } * *
    */ + slerp(cam0, cam1, amt) { + // If t is 0 or 1, do not interpolate and set the argument camera. + if (amt === 0) { + this.set(cam0); + return; + } else if (amt === 1) { + this.set(cam1); + return; + } - //////////////////////////////////////////////////////////////////////////////// - // Camera Projection Methods - //////////////////////////////////////////////////////////////////////////////// + // For this cameras is ortho, assume that cam0 and cam1 are also ortho + // and interpolate the elements of the projection matrix. + // Use logarithmic interpolation for interpolation. + if (this.projMatrix.mat4[15] !== 0) { + this.projMatrix.setElement( + 0, + cam0.projMatrix.mat4[0] * + Math.pow(cam1.projMatrix.mat4[0] / cam0.projMatrix.mat4[0], amt) + ); + this.projMatrix.setElement( + 5, + cam0.projMatrix.mat4[5] * + Math.pow(cam1.projMatrix.mat4[5] / cam0.projMatrix.mat4[5], amt) + ); + // If the camera is active, make uPMatrix reflect changes in projMatrix. + if (this._isActive()) { + this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); + this._renderer.states.uPMatrix.mat4 = this.projMatrix.mat4.slice(); + } + } - /** - * Sets a perspective projection for the camera. - * - * In a perspective projection, shapes that are further from the camera appear - * smaller than shapes that are near the camera. This technique, called - * foreshortening, creates realistic 3D scenes. It’s applied by default in new - * `p5.Camera` objects. - * - * `myCamera.perspective()` changes the camera’s perspective by changing its - * viewing frustum. The frustum is the volume of space that’s visible to the - * camera. The frustum’s shape is a pyramid with its top cut off. The camera - * is placed where the top of the pyramid should be and points towards the - * base of the pyramid. It views everything within the frustum. - * - * The first parameter, `fovy`, is the camera’s vertical field of view. It’s - * an angle that describes how tall or narrow a view the camera has. For - * example, calling `myCamera.perspective(0.5)` sets the camera’s vertical - * field of view to 0.5 radians. By default, `fovy` is calculated based on the - * sketch’s height and the camera’s default z-coordinate, which is 800. The - * formula for the default `fovy` is `2 * atan(height / 2 / 800)`. - * - * The second parameter, `aspect`, is the camera’s aspect ratio. It’s a number - * that describes the ratio of the top plane’s width to its height. For - * example, calling `myCamera.perspective(0.5, 1.5)` sets the camera’s field - * of view to 0.5 radians and aspect ratio to 1.5, which would make shapes - * appear thinner on a square canvas. By default, `aspect` is set to - * `width / height`. - * - * The third parameter, `near`, is the distance from the camera to the near - * plane. For example, calling `myCamera.perspective(0.5, 1.5, 100)` sets the - * camera’s field of view to 0.5 radians, its aspect ratio to 1.5, and places - * the near plane 100 pixels from the camera. Any shapes drawn less than 100 - * pixels from the camera won’t be visible. By default, `near` is set to - * `0.1 * 800`, which is 1/10th the default distance between the camera and - * the origin. - * - * The fourth parameter, `far`, is the distance from the camera to the far - * plane. For example, calling `myCamera.perspective(0.5, 1.5, 100, 10000)` - * sets the camera’s field of view to 0.5 radians, its aspect ratio to 1.5, - * places the near plane 100 pixels from the camera, and places the far plane - * 10,000 pixels from the camera. Any shapes drawn more than 10,000 pixels - * from the camera won’t be visible. By default, `far` is set to `10 * 800`, - * which is 10 times the default distance between the camera and the origin. - * - * @for p5.Camera - * @param {Number} [fovy] camera frustum vertical field of view. Defaults to - * `2 * atan(height / 2 / 800)`. - * @param {Number} [aspect] camera frustum aspect ratio. Defaults to - * `width / height`. - * @param {Number} [near] distance from the camera to the near clipping plane. - * Defaults to `0.1 * 800`. - * @param {Number} [far] distance from the camera to the far clipping plane. - * Defaults to `10 * 800`. - * - * @example - *
    - * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Place it at the top-right. - * cam2.camera(400, -400, 800); - * - * // Set its fovy to 0.2. - * // Set its aspect to 1.5. - * // Set its near to 600. - * // Set its far to 1200. - * cam2.perspective(0.2, 1.5, 600, 1200); - * - * // Set the current camera to cam1. - * setCamera(cam1); - * - * describe('A white cube on a gray background. The camera toggles between a frontal view and a skewed aerial view when the user double-clicks.'); - * } - * - * function draw() { - * background(200); - * - * // Draw the box. - * box(); - * } - * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } - * } - * - *
    - * - *
    - * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Place it at the top-right. - * cam2.camera(400, -400, 800); - * - * // Set its fovy to 0.2. - * // Set its aspect to 1.5. - * // Set its near to 600. - * // Set its far to 1200. - * cam2.perspective(0.2, 1.5, 600, 1200); - * - * // Set the current camera to cam1. - * setCamera(cam1); - * - * describe('A white cube moves left and right on a gray background. The camera toggles between a frontal and a skewed aerial view when the user double-clicks.'); - * } - * - * function draw() { - * background(200); - * - * // Translate the origin left and right. - * let x = 100 * sin(frameCount * 0.01); - * translate(x, 0, 0); - * - * // Draw the box. - * box(); - * } - * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } - * } - * - *
    - */ - perspective(fovy, aspect, near, far) { - this.cameraType = arguments.length > 0 ? 'custom' : 'default'; - if (typeof fovy === 'undefined') { - fovy = this.defaultCameraFOV; - // this avoids issue where setting angleMode(DEGREES) before calling - // perspective leads to a smaller than expected FOV (because - // _computeCameraDefaultSettings computes in radians) - this.cameraFOV = fovy; - } else { - this.cameraFOV = this._renderer._pInst._toRadians(fovy); - } - if (typeof aspect === 'undefined') { - aspect = this.defaultAspectRatio; - } - if (typeof near === 'undefined') { - near = this.defaultCameraNear; - } - if (typeof far === 'undefined') { - far = this.defaultCameraFar; - } + // prepare eye vector and center vector of argument cameras. + const eye0 = new Vector(cam0.eyeX, cam0.eyeY, cam0.eyeZ); + const eye1 = new Vector(cam1.eyeX, cam1.eyeY, cam1.eyeZ); + const center0 = new Vector(cam0.centerX, cam0.centerY, cam0.centerZ); + const center1 = new Vector(cam1.centerX, cam1.centerY, cam1.centerZ); - if (near <= 0.0001) { - near = 0.01; - console.log( - 'Avoid perspective near plane values close to or below 0. ' + - 'Setting value to 0.01.' - ); - } + // Calculate the distance between eye and center for each camera. + // Logarithmically interpolate these with amt. + const dist0 = Vector.dist(eye0, center0); + const dist1 = Vector.dist(eye1, center1); + const lerpedDist = dist0 * Math.pow(dist1 / dist0, amt); - if (far < near) { - console.log( - 'Perspective far plane value is less than near plane value. ' + - 'Nothing will be shown.' - ); + // Next, calculate the ratio to interpolate the eye and center by a constant + // ratio for each camera. This ratio is the same for both. Also, with this ratio + // of points, the distance is the minimum distance of the two points of + // the same ratio. + // With this method, if the viewpoint is fixed, linear interpolation is performed + // at the viewpoint, and if the center is fixed, linear interpolation is performed + // at the center, resulting in reasonable interpolation. If both move, the point + // halfway between them is taken. + const eyeDiff = Vector.sub(eye0, eye1); + const diffDiff = eye0.copy().sub(eye1).sub(center0).add(center1); + // Suppose there are two line segments. Consider the distance between the points + // above them as if they were taken in the same ratio. This calculation figures out + // a ratio that minimizes this. + // Each line segment is, a line segment connecting the viewpoint and the center + // for each camera. + const divider = diffDiff.magSq(); + let ratio = 1; // default. + if (divider > 0.000001) { + ratio = Vector.dot(eyeDiff, diffDiff) / divider; + ratio = Math.max(0, Math.min(ratio, 1)); } - this.aspectRatio = aspect; - this.cameraNear = near; - this.cameraFar = far; + // Take the appropriate proportions and work out the points + // that are between the new viewpoint and the new center position. + const lerpedMedium = Vector.lerp( + Vector.lerp(eye0, center0, ratio), + Vector.lerp(eye1, center1, ratio), + amt + ); - this.projMatrix = new Matrix(4); + // Prepare each of rotation matrix from their camera matrix + const rotMat0 = cam0.cameraMatrix.createSubMatrix3x3(); + const rotMat1 = cam1.cameraMatrix.createSubMatrix3x3(); - const f = 1.0 / Math.tan(this.cameraFOV / 2); - const nf = 1.0 / (this.cameraNear - this.cameraFar); + // get front and up vector from local-coordinate-system. + const front0 = rotMat0.row(2); + const front1 = rotMat1.row(2); + const up0 = rotMat0.row(1); + const up1 = rotMat1.row(1); - this.projMatrix.set(f / aspect, 0, 0, 0, - 0, -f * this.yScale, 0, 0, - 0, 0, (far + near) * nf, -1, - 0, 0, (2 * far * near) * nf, 0); + // prepare new vectors. + const newFront = new Vector(); + const newUp = new Vector(); + const newEye = new Vector(); + const newCenter = new Vector(); - if (this._isActive()) { - this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); - this._renderer.states.uPMatrix.set(this.projMatrix); + // Create the inverse matrix of mat0 by transposing mat0, + // and multiply it to mat1 from the right. + // This matrix represents the difference between the two. + // 'deltaRot' means 'difference of rotation matrices'. + const deltaRot = rotMat1.mult(rotMat0.copy().transpose()); // mat1 is 3x3 + + // Calculate the trace and from it the cos value of the angle. + // An orthogonal matrix is just an orthonormal basis. If this is not the identity + // matrix, it is a centered orthonormal basis plus some angle of rotation about + // some axis. That's the angle. Letting this be theta, trace becomes 1+2cos(theta). + // reference: https://en.wikipedia.org/wiki/Rotation_matrix#Determining_the_angle + const diag = deltaRot.diagonal(); + let cosTheta = 0.5 * (diag[0] + diag[1] + diag[2] - 1); + + // If the angle is close to 0, the two matrices are very close, + // so in that case we execute linearly interpolate. + if (1 - cosTheta < 0.0000001) { + // Obtain the front vector and up vector by linear interpolation + // and normalize them. + // calculate newEye, newCenter with newFront vector. + newFront.set(Vector.lerp(front0, front1, amt)).normalize(); + + newEye.set(newFront).mult(ratio * lerpedDist).add(lerpedMedium); + newCenter.set(newFront).mult((ratio - 1) * lerpedDist).add(lerpedMedium); + + newUp.set(Vector.lerp(up0, up1, amt)).normalize(); + + // set the camera + this.camera( + newEye.x, newEye.y, newEye.z, + newCenter.x, newCenter.y, newCenter.z, + newUp.x, newUp.y, newUp.z + ); + return; } - } - /** - * Sets an orthographic projection for the camera. - * - * In an orthographic projection, shapes with the same size always appear the - * same size, regardless of whether they are near or far from the camera. - * - * `myCamera.ortho()` changes the camera’s perspective by changing its viewing - * frustum from a truncated pyramid to a rectangular prism. The frustum is the - * volume of space that’s visible to the camera. The camera is placed in front - * of the frustum and views everything within the frustum. `myCamera.ortho()` - * has six optional parameters to define the viewing frustum. - * - * The first four parameters, `left`, `right`, `bottom`, and `top`, set the - * coordinates of the frustum’s sides, bottom, and top. For example, calling - * `myCamera.ortho(-100, 100, 200, -200)` creates a frustum that’s 200 pixels - * wide and 400 pixels tall. By default, these dimensions are set based on - * the sketch’s width and height, as in - * `myCamera.ortho(-width / 2, width / 2, -height / 2, height / 2)`. - * - * The last two parameters, `near` and `far`, set the distance of the - * frustum’s near and far plane from the camera. For example, calling - * `myCamera.ortho(-100, 100, 200, -200, 50, 1000)` creates a frustum that’s - * 200 pixels wide, 400 pixels tall, starts 50 pixels from the camera, and - * ends 1,000 pixels from the camera. By default, `near` and `far` are set to - * 0 and `max(width, height) + 800`, respectively. - * - * @for p5.Camera - * @param {Number} [left] x-coordinate of the frustum’s left plane. Defaults to `-width / 2`. - * @param {Number} [right] x-coordinate of the frustum’s right plane. Defaults to `width / 2`. - * @param {Number} [bottom] y-coordinate of the frustum’s bottom plane. Defaults to `height / 2`. - * @param {Number} [top] y-coordinate of the frustum’s top plane. Defaults to `-height / 2`. - * @param {Number} [near] z-coordinate of the frustum’s near plane. Defaults to 0. - * @param {Number} [far] z-coordinate of the frustum’s far plane. Defaults to `max(width, height) + 800`. - * - * @example - *
    - * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Apply an orthographic projection. - * cam2.ortho(); - * - * // Set the current camera to cam1. - * setCamera(cam1); - * - * describe('A row of white cubes against a gray background. The camera toggles between a perspective and an orthographic projection when the user double-clicks.'); - * } - * - * function draw() { - * background(200); - * - * // Translate the origin toward the camera. - * translate(-10, 10, 500); - * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); - * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -40); - * box(10); - * } - * } - * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } - * } - * - *
    - * - *
    - * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Apply an orthographic projection. - * cam2.ortho(); - * - * // Set the current camera to cam1. - * setCamera(cam1); - * - * describe('A row of white cubes slither like a snake against a gray background. The camera toggles between a perspective and an orthographic projection when the user double-clicks.'); - * } - * - * function draw() { - * background(200); - * - * // Translate the origin toward the camera. - * translate(-10, 10, 500); - * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); - * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * push(); - * // Calculate the box's coordinates. - * let x = 10 * sin(frameCount * 0.02 + i * 0.6); - * let z = -40 * i; - * // Translate the origin. - * translate(x, 0, z); - * // Draw the box. - * box(10); - * pop(); - * } - * } - * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } - * } - * - *
    - */ - ortho(left, right, bottom, top, near, far) { - const source = this.fbo || this._renderer; - if (left === undefined) left = -source.width / 2; - if (right === undefined) right = +source.width / 2; - if (bottom === undefined) bottom = -source.height / 2; - if (top === undefined) top = +source.height / 2; - if (near === undefined) near = 0; - if (far === undefined) far = Math.max(source.width, source.height) + 800; - this.cameraNear = near; - this.cameraFar = far; - const w = right - left; - const h = top - bottom; - const d = far - near; - const x = +2.0 / w; - const y = +2.0 / h * this.yScale; - const z = -2.0 / d; - const tx = -(right + left) / w; - const ty = -(top + bottom) / h; - const tz = -(far + near) / d; - this.projMatrix = new Matrix(4); + // Calculates the axis vector and the angle of the difference orthogonal matrix. + // The axis vector is what I explained earlier in the comments. + // similar calculation is here: + // https://github.com/mrdoob/three.js/blob/883249620049d1632e8791732808fefd1a98c871/src/math/Quaternion.js#L294 + let a, b, c, sinTheta; + let invOneMinusCosTheta = 1 / (1 - cosTheta); + const maxDiag = Math.max(diag[0], diag[1], diag[2]); + const offDiagSum13 = deltaRot.mat3[1] + deltaRot.mat3[3]; + const offDiagSum26 = deltaRot.mat3[2] + deltaRot.mat3[6]; + const offDiagSum57 = deltaRot.mat3[5] + deltaRot.mat3[7]; - this.projMatrix.set(x, 0, 0, 0, - 0, -y, 0, 0, - 0, 0, z, 0, - tx, ty, tz, 1); + if (maxDiag === diag[0]) { + a = Math.sqrt((diag[0] - cosTheta) * invOneMinusCosTheta); // not zero. + invOneMinusCosTheta /= a; + b = 0.5 * offDiagSum13 * invOneMinusCosTheta; + c = 0.5 * offDiagSum26 * invOneMinusCosTheta; + sinTheta = 0.5 * (deltaRot.mat3[7] - deltaRot.mat3[5]) / a; - if (this._isActive()) { - this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); - this._renderer.states.uPMatrix.set(this.projMatrix); + } else if (maxDiag === diag[1]) { + b = Math.sqrt((diag[1] - cosTheta) * invOneMinusCosTheta); // not zero. + invOneMinusCosTheta /= b; + c = 0.5 * offDiagSum57 * invOneMinusCosTheta; + a = 0.5 * offDiagSum13 * invOneMinusCosTheta; + sinTheta = 0.5 * (deltaRot.mat3[2] - deltaRot.mat3[6]) / b; + + } else { + c = Math.sqrt((diag[2] - cosTheta) * invOneMinusCosTheta); // not zero. + invOneMinusCosTheta /= c; + a = 0.5 * offDiagSum26 * invOneMinusCosTheta; + b = 0.5 * offDiagSum57 * invOneMinusCosTheta; + sinTheta = 0.5 * (deltaRot.mat3[3] - deltaRot.mat3[1]) / c; } - this.cameraType = 'custom'; + + // Constructs a new matrix after interpolating the angles. + // Multiplying mat0 by the first matrix yields mat1, but by creating a state + // in the middle of that matrix, you can obtain a matrix that is + // an intermediate state between mat0 and mat1. + const angle = amt * Math.atan2(sinTheta, cosTheta); + const cosAngle = Math.cos(angle); + const sinAngle = Math.sin(angle); + const oneMinusCosAngle = 1 - cosAngle; + const ab = a * b; + const bc = b * c; + const ca = c * a; + // 3x3 + const lerpedRotMat = new Matrix( [ + cosAngle + oneMinusCosAngle * a * a, + oneMinusCosAngle * ab + sinAngle * c, + oneMinusCosAngle * ca - sinAngle * b, + oneMinusCosAngle * ab - sinAngle * c, + cosAngle + oneMinusCosAngle * b * b, + oneMinusCosAngle * bc + sinAngle * a, + oneMinusCosAngle * ca + sinAngle * b, + oneMinusCosAngle * bc - sinAngle * a, + cosAngle + oneMinusCosAngle * c * c + ]); + + // Multiply this to mat0 from left to get the interpolated front vector. + // calculate newEye, newCenter with newFront vector. + lerpedRotMat.multiplyVec(front0, newFront); // this is vec3 + + newEye.set(newFront).mult(ratio * lerpedDist).add(lerpedMedium); + newCenter.set(newFront).mult((ratio - 1) * lerpedDist).add(lerpedMedium); + + lerpedRotMat.multiplyVec(up0, newUp); // this is vec3 + + // We also get the up vector in the same way and set the camera. + // The eye position and center position are calculated based on the front vector. + this.camera( + newEye.x, newEye.y, newEye.z, + newCenter.x, newCenter.y, newCenter.z, + newUp.x, newUp.y, newUp.z + ); } - /** - * Sets the camera's frustum. - * - * In a frustum projection, shapes that are further from the camera appear - * smaller than shapes that are near the camera. This technique, called - * foreshortening, creates realistic 3D scenes. - * - * `myCamera.frustum()` changes the camera’s perspective by changing its - * viewing frustum. The frustum is the volume of space that’s visible to the - * camera. The frustum’s shape is a pyramid with its top cut off. The camera - * is placed where the top of the pyramid should be and points towards the - * base of the pyramid. It views everything within the frustum. - * - * The first four parameters, `left`, `right`, `bottom`, and `top`, set the - * coordinates of the frustum’s sides, bottom, and top. For example, calling - * `myCamera.frustum(-100, 100, 200, -200)` creates a frustum that’s 200 - * pixels wide and 400 pixels tall. By default, these coordinates are set - * based on the sketch’s width and height, as in - * `myCamera.frustum(-width / 20, width / 20, height / 20, -height / 20)`. - * - * The last two parameters, `near` and `far`, set the distance of the - * frustum’s near and far plane from the camera. For example, calling - * `myCamera.frustum(-100, 100, 200, -200, 50, 1000)` creates a frustum that’s - * 200 pixels wide, 400 pixels tall, starts 50 pixels from the camera, and ends - * 1,000 pixels from the camera. By default, near is set to `0.1 * 800`, which - * is 1/10th the default distance between the camera and the origin. `far` is - * set to `10 * 800`, which is 10 times the default distance between the - * camera and the origin. - * - * @for p5.Camera - * @param {Number} [left] x-coordinate of the frustum’s left plane. Defaults to `-width / 20`. - * @param {Number} [right] x-coordinate of the frustum’s right plane. Defaults to `width / 20`. - * @param {Number} [bottom] y-coordinate of the frustum’s bottom plane. Defaults to `height / 20`. - * @param {Number} [top] y-coordinate of the frustum’s top plane. Defaults to `-height / 20`. - * @param {Number} [near] z-coordinate of the frustum’s near plane. Defaults to `0.1 * 800`. - * @param {Number} [far] z-coordinate of the frustum’s far plane. Defaults to `10 * 800`. - * - * @example - *
    - * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Adjust the frustum. - * // Center it. - * // Set its width and height to 20 pixels. - * // Place its near plane 300 pixels from the camera. - * // Place its far plane 350 pixels from the camera. - * cam2.frustum(-10, 10, -10, 10, 300, 350); - * - * // Set the current camera to cam1. - * setCamera(cam1); - * - * describe( - * 'A row of white cubes against a gray background. The camera zooms in on one cube when the user double-clicks.' - * ); - * } - * - * function draw() { - * background(200); - * - * // Translate the origin toward the camera. - * translate(-10, 10, 600); - * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); - * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -40); - * box(10); - * } - * } - * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } - * } - * - *
    - */ - frustum(left, right, bottom, top, near, far) { - if (left === undefined) left = -this._renderer.width * 0.05; - if (right === undefined) right = +this._renderer.width * 0.05; - if (bottom === undefined) bottom = +this._renderer.height * 0.05; - if (top === undefined) top = -this._renderer.height * 0.05; - if (near === undefined) near = this.defaultCameraNear; - if (far === undefined) far = this.defaultCameraFar; - this.cameraNear = near; - this.cameraFar = far; + //////////////////////////////////////////////////////////////////////////////// + // Camera Helper Methods + //////////////////////////////////////////////////////////////////////////////// - const w = right - left; - const h = top - bottom; - const d = far - near; + // @TODO: combine this function with _setDefaultCamera to compute these values + // as-needed + _computeCameraDefaultSettings() { + this.defaultAspectRatio = this._renderer.width / this._renderer.height; + this.defaultEyeX = 0; + this.defaultEyeY = 0; + this.defaultEyeZ = 800; + this.defaultCameraFOV = + 2 * Math.atan(this._renderer.height / 2 / this.defaultEyeZ); + this.defaultCenterX = 0; + this.defaultCenterY = 0; + this.defaultCenterZ = 0; + this.defaultCameraNear = this.defaultEyeZ * 0.1; + this.defaultCameraFar = this.defaultEyeZ * 10; + } - const x = +(2.0 * near) / w; - const y = +(2.0 * near) / h * this.yScale; - const z = -(2.0 * far * near) / d; + //detect if user didn't set the camera + //then call this function below + _setDefaultCamera() { + this.cameraFOV = this.defaultCameraFOV; + this.aspectRatio = this.defaultAspectRatio; + this.eyeX = this.defaultEyeX; + this.eyeY = this.defaultEyeY; + this.eyeZ = this.defaultEyeZ; + this.centerX = this.defaultCenterX; + this.centerY = this.defaultCenterY; + this.centerZ = this.defaultCenterZ; + this.upX = 0; + this.upY = 1; + this.upZ = 0; + this.cameraNear = this.defaultCameraNear; + this.cameraFar = this.defaultCameraFar; - const tx = (right + left) / w; - const ty = (top + bottom) / h; - const tz = -(far + near) / d; + this.perspective(); + this.camera(); - this.projMatrix = new Matrix(4); + this.cameraType = 'default'; + } + _resize() { + // If we're using the default camera, update the aspect ratio + if (this.cameraType === 'default') { + this._computeCameraDefaultSettings(); + this.cameraFOV = this.defaultCameraFOV; + this.aspectRatio = this.defaultAspectRatio; + this.perspective(); + } + } - this.projMatrix.set(x, 0, 0, 0, - 0, -y, 0, 0, - tx, ty, tz, -1, - 0, 0, z, 0); + /** + * Returns a copy of a camera. + * @private + */ + copy() { + const _cam = new Camera(this._renderer); + _cam.cameraFOV = this.cameraFOV; + _cam.aspectRatio = this.aspectRatio; + _cam.eyeX = this.eyeX; + _cam.eyeY = this.eyeY; + _cam.eyeZ = this.eyeZ; + _cam.centerX = this.centerX; + _cam.centerY = this.centerY; + _cam.centerZ = this.centerZ; + _cam.upX = this.upX; + _cam.upY = this.upY; + _cam.upZ = this.upZ; + _cam.cameraNear = this.cameraNear; + _cam.cameraFar = this.cameraFar; + _cam.cameraType = this.cameraType; + _cam.useLinePerspective = this.useLinePerspective; - if (this._isActive()) { - this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); - this._renderer.states.uPMatrix.set(this.projMatrix); - } + _cam.cameraMatrix = this.cameraMatrix.copy(); + _cam.projMatrix = this.projMatrix.copy(); + _cam.yScale = this.yScale; - this.cameraType = 'custom'; + return _cam; } - //////////////////////////////////////////////////////////////////////////////// - // Camera Orientation Methods - //////////////////////////////////////////////////////////////////////////////// + clone() { + return this.copy(); + } /** - * Rotate camera view about arbitrary axis defined by x,y,z - * based on http://learnwebgl.brown37.net/07_cameras/camera_rotating_motion.html + * Returns a camera's local axes: left-right, up-down, and forward-backward, + * as defined by vectors in world-space. * @private */ - _rotateView(a, x, y, z) { - let centerX = this.centerX; - let centerY = this.centerY; - let centerZ = this.centerZ; + _getLocalAxes() { + // calculate camera local Z vector + let z0 = this.eyeX - this.centerX; + let z1 = this.eyeY - this.centerY; + let z2 = this.eyeZ - this.centerZ; - // move center by eye position such that rotation happens around eye position - centerX -= this.eyeX; - centerY -= this.eyeY; - centerZ -= this.eyeZ; + // normalize camera local Z vector + const eyeDist = Math.sqrt(z0 * z0 + z1 * z1 + z2 * z2); + if (eyeDist !== 0) { + z0 /= eyeDist; + z1 /= eyeDist; + z2 /= eyeDist; + } - const rotation = new Matrix(4); // TODO Maybe pass p5 - rotation.rotate4x4(this._renderer._pInst._toRadians(a), x, y, z); + // calculate camera Y vector + let y0 = this.upX; + let y1 = this.upY; + let y2 = this.upZ; - const rotatedCenter = [ - centerX * rotation.mat4[0] + centerY * rotation.mat4[4] + centerZ * rotation.mat4[8], - centerX * rotation.mat4[1] + centerY * rotation.mat4[5] + centerZ * rotation.mat4[9], - centerX * rotation.mat4[2] + centerY * rotation.mat4[6] + centerZ * rotation.mat4[10] - ]; + // compute camera local X vector as up vector (local Y) cross local Z + let x0 = y1 * z2 - y2 * z1; + let x1 = -y0 * z2 + y2 * z0; + let x2 = y0 * z1 - y1 * z0; - // add eye position back into center - rotatedCenter[0] += this.eyeX; - rotatedCenter[1] += this.eyeY; - rotatedCenter[2] += this.eyeZ; + // recompute y = z cross x + y0 = z1 * x2 - z2 * x1; + y1 = -z0 * x2 + z2 * x0; + y2 = z0 * x1 - z1 * x0; - this.camera( - this.eyeX, - this.eyeY, - this.eyeZ, - rotatedCenter[0], - rotatedCenter[1], - rotatedCenter[2], - this.upX, - this.upY, - this.upZ + // cross product gives area of parallelogram, which is < 1.0 for + // non-perpendicular unit-length vectors; so normalize x, y here: + const xmag = Math.sqrt(x0 * x0 + x1 * x1 + x2 * x2); + if (xmag !== 0) { + x0 /= xmag; + x1 /= xmag; + x2 /= xmag; + } + + const ymag = Math.sqrt(y0 * y0 + y1 * y1 + y2 * y2); + if (ymag !== 0) { + y0 /= ymag; + y1 /= ymag; + y2 /= ymag; + } + + return { + x: [x0, x1, x2], + y: [y0, y1, y2], + z: [z0, z1, z2] + }; + } + + /** + * Orbits the camera about center point. For use with orbitControl(). + * @private + * @param {Number} dTheta change in spherical coordinate theta + * @param {Number} dPhi change in spherical coordinate phi + * @param {Number} dRadius change in radius + */ + _orbit(dTheta, dPhi, dRadius) { + // Calculate the vector and its magnitude from the center to the viewpoint + const diffX = this.eyeX - this.centerX; + const diffY = this.eyeY - this.centerY; + const diffZ = this.eyeZ - this.centerZ; + let camRadius = Math.hypot(diffX, diffY, diffZ); + // front vector. unit vector from center to eye. + const front = new Vector(diffX, diffY, diffZ).normalize(); + // up vector. normalized camera's up vector. + const up = new Vector(this.upX, this.upY, this.upZ).normalize(); // y-axis + // side vector. Right when viewed from the front + const side = Vector.cross(up, front).normalize(); // x-axis + // vertical vector. normalized vector of projection of front vector. + const vertical = Vector.cross(side, up); // z-axis + + // update camRadius + camRadius *= Math.pow(10, dRadius); + // prevent zooming through the center: + if (camRadius < this.cameraNear) { + camRadius = this.cameraNear; + } + if (camRadius > this.cameraFar) { + camRadius = this.cameraFar; + } + + // calculate updated camera angle + // Find the angle between the "up" and the "front", add dPhi to that. + // angleBetween() may return negative value. Since this specification is subject to change + // due to version updates, it cannot be adopted, so here we calculate using a method + // that directly obtains the absolute value. + const camPhi = + Math.acos(Math.max(-1, Math.min(1, Vector.dot(front, up)))) + dPhi; + // Rotate by dTheta in the shortest direction from "vertical" to "side" + const camTheta = dTheta; + + // Invert camera's upX, upY, upZ if dPhi is below 0 or above PI + if (camPhi <= 0 || camPhi >= Math.PI) { + this.upX *= -1; + this.upY *= -1; + this.upZ *= -1; + } + + // update eye vector by calculate new front vector + up.mult(Math.cos(camPhi)); + vertical.mult(Math.cos(camTheta) * Math.sin(camPhi)); + side.mult(Math.sin(camTheta) * Math.sin(camPhi)); + + front.set(up).add(vertical).add(side); + + this.eyeX = camRadius * front.x + this.centerX; + this.eyeY = camRadius * front.y + this.centerY; + this.eyeZ = camRadius * front.z + this.centerZ; + + // update camera + this.camera( + this.eyeX, this.eyeY, this.eyeZ, + this.centerX, this.centerY, this.centerZ, + this.upX, this.upY, this.upZ ); } /** - * Rotates the camera in a clockwise/counter-clockwise direction. + * Orbits the camera about center point. For use with orbitControl(). + * Unlike _orbit(), the direction of rotation always matches the direction of pointer movement. + * @private + * @param {Number} dx the x component of the rotation vector. + * @param {Number} dy the y component of the rotation vector. + * @param {Number} dRadius change in radius + */ + _orbitFree(dx, dy, dRadius) { + // Calculate the vector and its magnitude from the center to the viewpoint + const diffX = this.eyeX - this.centerX; + const diffY = this.eyeY - this.centerY; + const diffZ = this.eyeZ - this.centerZ; + let camRadius = Math.hypot(diffX, diffY, diffZ); + // front vector. unit vector from center to eye. + const front = new Vector(diffX, diffY, diffZ).normalize(); + // up vector. camera's up vector. + const up = new Vector(this.upX, this.upY, this.upZ); + // side vector. Right when viewed from the front. (like x-axis) + const side = Vector.cross(up, front).normalize(); + // down vector. Bottom when viewed from the front. (like y-axis) + const down = Vector.cross(front, side); + + // side vector and down vector are no longer used as-is. + // Create a vector representing the direction of rotation + // in the form cos(direction)*side + sin(direction)*down. + // Make the current side vector into this. + const directionAngle = Math.atan2(dy, dx); + down.mult(Math.sin(directionAngle)); + side.mult(Math.cos(directionAngle)).add(down); + // The amount of rotation is the size of the vector (dx, dy). + const rotAngle = Math.sqrt(dx * dx + dy * dy); + // The vector that is orthogonal to both the front vector and + // the rotation direction vector is the rotation axis vector. + const axis = Vector.cross(front, side); + + // update camRadius + camRadius *= Math.pow(10, dRadius); + // prevent zooming through the center: + if (camRadius < this.cameraNear) { + camRadius = this.cameraNear; + } + if (camRadius > this.cameraFar) { + camRadius = this.cameraFar; + } + + // If the axis vector is likened to the z-axis, the front vector is + // the x-axis and the side vector is the y-axis. Rotate the up and front + // vectors respectively by thinking of them as rotations around the z-axis. + + // Calculate the components by taking the dot product and + // calculate a rotation based on that. + const c = Math.cos(rotAngle); + const s = Math.sin(rotAngle); + const dotFront = up.dot(front); + const dotSide = up.dot(side); + const ux = dotFront * c + dotSide * s; + const uy = -dotFront * s + dotSide * c; + const uz = up.dot(axis); + up.x = ux * front.x + uy * side.x + uz * axis.x; + up.y = ux * front.y + uy * side.y + uz * axis.y; + up.z = ux * front.z + uy * side.z + uz * axis.z; + // We won't be using the side vector and the front vector anymore, + // so let's make the front vector into the vector from the center to the new eye. + side.mult(-s); + front.mult(c).add(side).mult(camRadius); + + // it's complete. let's update camera. + this.camera( + front.x + this.centerX, + front.y + this.centerY, + front.z + this.centerZ, + this.centerX, this.centerY, this.centerZ, + up.x, up.y, up.z + ); + } + + /** + * Returns true if camera is currently attached to renderer. + * @private + */ + _isActive() { + return this === this._renderer.states.curCamera; + } +}; + +function camera(p5, fn){ + //////////////////////////////////////////////////////////////////////////////// + // p5.Prototype Methods + //////////////////////////////////////////////////////////////////////////////// + + /** + * Sets the position and orientation of the current camera in a 3D sketch. * - * Rolling rotates the camera without changing its orientation. The rotation - * happens in the camera’s "local" space. + * `camera()` allows objects to be viewed from different angles. It has nine + * parameters that are all optional. * - * The parameter, `angle`, is the angle the camera should rotate. Passing a - * positive angle, as in `myCamera.roll(0.001)`, rotates the camera in counter-clockwise direction. - * Passing a negative angle, as in `myCamera.roll(-0.001)`, rotates the - * camera in clockwise direction. + * The first three parameters, `x`, `y`, and `z`, are the coordinates of the + * camera’s position. For example, calling `camera(0, 0, 0)` places the camera + * at the origin `(0, 0, 0)`. By default, the camera is placed at + * `(0, 0, 800)`. * - * Note: Angles are interpreted based on the current - * angleMode(). + * The next three parameters, `centerX`, `centerY`, and `centerZ` are the + * coordinates of the point where the camera faces. For example, calling + * `camera(0, 0, 0, 10, 20, 30)` places the camera at the origin `(0, 0, 0)` + * and points it at `(10, 20, 30)`. By default, the camera points at the + * origin `(0, 0, 0)`. + * + * The last three parameters, `upX`, `upY`, and `upZ` are the components of + * the "up" vector. The "up" vector orients the camera’s y-axis. For example, + * calling `camera(0, 0, 0, 10, 20, 30, 0, -1, 0)` places the camera at the + * origin `(0, 0, 0)`, points it at `(10, 20, 30)`, and sets the "up" vector + * to `(0, -1, 0)` which is like holding it upside-down. By default, the "up" + * vector is `(0, 1, 0)`. + * + * Note: `camera()` can only be used in WebGL mode. + * + * @method camera + * @for p5 + * @param {Number} [x] x-coordinate of the camera. Defaults to 0. + * @param {Number} [y] y-coordinate of the camera. Defaults to 0. + * @param {Number} [z] z-coordinate of the camera. Defaults to 800. + * @param {Number} [centerX] x-coordinate of the point the camera faces. Defaults to 0. + * @param {Number} [centerY] y-coordinate of the point the camera faces. Defaults to 0. + * @param {Number} [centerZ] z-coordinate of the point the camera faces. Defaults to 0. + * @param {Number} [upX] x-component of the camera’s "up" vector. Defaults to 0. + * @param {Number} [upY] y-component of the camera’s "up" vector. Defaults to 1. + * @param {Number} [upZ] z-component of the camera’s "up" vector. Defaults to 0. + * @chainable * - * @method roll - * @param {Number} angle amount to rotate camera in current - * angleMode units. * @example *
    * - * let cam; - * let delta = 0.01; - * * function setup() { * createCanvas(100, 100, WEBGL); - * normalMaterial(); - * // Create a p5.Camera object. - * cam = createCamera(); * - * // Set the camera - * setCamera(cam); + * describe('A white cube on a gray background.'); * } * * function draw() { * background(200); * - * // Roll camera according to angle 'delta' - * cam.roll(delta); + * // Move the camera to the top-right. + * camera(200, -400, 800); * - * translate(0, 0, 0); - * box(20); - * translate(0, 25, 0); - * box(20); - * translate(0, 26, 0); - * box(20); - * translate(0, 27, 0); - * box(20); - * translate(0, 28, 0); - * box(20); - * translate(0,29, 0); - * box(20); - * translate(0, 30, 0); - * box(20); - * } + * // Draw the box. + * box(); + * } * *
    * - * @alt - * camera view rotates in counter clockwise direction with vertically stacked boxes in front of it. - */ - roll(amount) { - const local = this._getLocalAxes(); - const axisQuaternion = Quat.fromAxisAngle( - this._renderer._pInst._toRadians(amount), - local.z[0], local.z[1], local.z[2]); - // const upQuat = new p5.Quat(0, this.upX, this.upY, this.upZ); - const newUpVector = axisQuaternion.rotateVector( - new Vector(this.upX, this.upY, this.upZ)); - this.camera( - this.eyeX, - this.eyeY, - this.eyeZ, - this.centerX, - this.centerY, - this.centerZ, - newUpVector.x, - newUpVector.y, - newUpVector.z - ); - } - - /** - * Rotates the camera left and right. + *
    + * + * function setup() { + * createCanvas(100, 100, WEBGL); * - * Panning rotates the camera without changing its position. The rotation - * happens in the camera’s "local" space. + * describe('A white cube apperas to sway left and right on a gray background.'); + * } * - * The parameter, `angle`, is the angle the camera should rotate. Passing a - * positive angle, as in `myCamera.pan(0.001)`, rotates the camera to the - * right. Passing a negative angle, as in `myCamera.pan(-0.001)`, rotates the - * camera to the left. + * function draw() { + * background(200); * - * Note: Angles are interpreted based on the current - * angleMode(). + * // Calculate the camera's x-coordinate. + * let x = 400 * cos(frameCount * 0.01); * - * @param {Number} angle amount to rotate in the current - * angleMode(). + * // Orbit the camera around the box. + * camera(x, -400, 800); + * + * // Draw the box. + * box(); + * } + * + *
    * - * @example *
    * - * let cam; - * let delta = 0.001; + * // Adjust the range sliders to change the camera's position. + * + * let xSlider; + * let ySlider; + * let zSlider; * * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); - * - * // Set the camera - * setCamera(cam); - * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); - * - * // Point the camera at the origin. - * cam.lookAt(0, 0, 0); + * // Create slider objects to set the camera's coordinates. + * xSlider = createSlider(-400, 400, 400); + * xSlider.position(0, 100); + * xSlider.size(100); + * ySlider = createSlider(-400, 400, -200); + * ySlider.position(0, 120); + * ySlider.size(100); + * zSlider = createSlider(0, 1600, 800); + * zSlider.position(0, 140); + * zSlider.size(100); * * describe( - * 'A white cube on a gray background. The cube goes in and out of view as the camera pans left and right.' + * 'A white cube drawn against a gray background. Three range sliders appear beneath the image. The camera position changes when the user moves the sliders.' * ); * } * * function draw() { * background(200); * - * // Pan with the camera. - * cam.pan(delta); + * // Get the camera's coordinates from the sliders. + * let x = xSlider.value(); + * let y = ySlider.value(); + * let z = zSlider.value(); * - * // Switch directions every 120 frames. - * if (frameCount % 120 === 0) { - * delta *= -1; - * } + * // Move the camera. + * camera(x, y, z); * * // Draw the box. * box(); @@ -1762,240 +2194,189 @@ class Camera { * *
    */ - pan(amount) { - const local = this._getLocalAxes(); - this._rotateView(amount, local.y[0], local.y[1], local.y[2]); - } + fn.camera = function (...args) { + this._assert3d('camera'); + // p5._validateParameters('camera', args); + this._renderer.camera(...args); + return this; + }; /** - * Rotates the camera up and down. + * Sets a perspective projection for the current camera in a 3D sketch. * - * Tilting rotates the camera without changing its position. The rotation - * happens in the camera’s "local" space. + * In a perspective projection, shapes that are further from the camera appear + * smaller than shapes that are near the camera. This technique, called + * foreshortening, creates realistic 3D scenes. It’s applied by default in + * WebGL mode. * - * The parameter, `angle`, is the angle the camera should rotate. Passing a - * positive angle, as in `myCamera.tilt(0.001)`, rotates the camera down. - * Passing a negative angle, as in `myCamera.tilt(-0.001)`, rotates the camera - * up. + * `perspective()` changes the camera’s perspective by changing its viewing + * frustum. The frustum is the volume of space that’s visible to the camera. + * Its shape is a pyramid with its top cut off. The camera is placed where + * the top of the pyramid should be and views everything between the frustum’s + * top (near) plane and its bottom (far) plane. * - * Note: Angles are interpreted based on the current - * angleMode(). + * The first parameter, `fovy`, is the camera’s vertical field of view. It’s + * an angle that describes how tall or narrow a view the camera has. For + * example, calling `perspective(0.5)` sets the camera’s vertical field of + * view to 0.5 radians. By default, `fovy` is calculated based on the sketch’s + * height and the camera’s default z-coordinate, which is 800. The formula for + * the default `fovy` is `2 * atan(height / 2 / 800)`. * - * @param {Number} angle amount to rotate in the current - * angleMode(). + * The second parameter, `aspect`, is the camera’s aspect ratio. It’s a number + * that describes the ratio of the top plane’s width to its height. For + * example, calling `perspective(0.5, 1.5)` sets the camera’s field of view to + * 0.5 radians and aspect ratio to 1.5, which would make shapes appear thinner + * on a square canvas. By default, aspect is set to `width / height`. + * + * The third parameter, `near`, is the distance from the camera to the near + * plane. For example, calling `perspective(0.5, 1.5, 100)` sets the camera’s + * field of view to 0.5 radians, its aspect ratio to 1.5, and places the near + * plane 100 pixels from the camera. Any shapes drawn less than 100 pixels + * from the camera won’t be visible. By default, near is set to `0.1 * 800`, + * which is 1/10th the default distance between the camera and the origin. + * + * The fourth parameter, `far`, is the distance from the camera to the far + * plane. For example, calling `perspective(0.5, 1.5, 100, 10000)` sets the + * camera’s field of view to 0.5 radians, its aspect ratio to 1.5, places the + * near plane 100 pixels from the camera, and places the far plane 10,000 + * pixels from the camera. Any shapes drawn more than 10,000 pixels from the + * camera won’t be visible. By default, far is set to `10 * 800`, which is 10 + * times the default distance between the camera and the origin. + * + * Note: `perspective()` can only be used in WebGL mode. + * + * @method perspective + * @for p5 + * @param {Number} [fovy] camera frustum vertical field of view. Defaults to + * `2 * atan(height / 2 / 800)`. + * @param {Number} [aspect] camera frustum aspect ratio. Defaults to + * `width / height`. + * @param {Number} [near] distance from the camera to the near clipping plane. + * Defaults to `0.1 * 800`. + * @param {Number} [far] distance from the camera to the far clipping plane. + * Defaults to `10 * 800`. + * @chainable * * @example *
    * - * let cam; - * let delta = 0.001; + * // Double-click to squeeze the box. + * + * let isSqueezed = false; * * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); - * - * // Set the camera - * setCamera(cam); - * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); - * - * // Point the camera at the origin. - * cam.lookAt(0, 0, 0); - * - * describe( - * 'A white cube on a gray background. The cube goes in and out of view as the camera tilts up and down.' - * ); + * describe('A white rectangular prism on a gray background. The box appears to become thinner when the user double-clicks.'); * } * * function draw() { * background(200); * - * // Pan with the camera. - * cam.tilt(delta); + * // Place the camera at the top-right. + * camera(400, -400, 800); * - * // Switch directions every 120 frames. - * if (frameCount % 120 === 0) { - * delta *= -1; + * if (isSqueezed === true) { + * // Set fovy to 0.2. + * // Set aspect to 1.5. + * perspective(0.2, 1.5); * } * * // Draw the box. * box(); * } + * + * // Change the camera's perspective when the user double-clicks. + * function doubleClicked() { + * isSqueezed = true; + * } * *
    - */ - tilt(amount) { - const local = this._getLocalAxes(); - this._rotateView(amount, local.x[0], local.x[1], local.x[2]); - } - - /** - * Points the camera at a location. - * - * `myCamera.lookAt()` changes the camera’s orientation without changing its - * position. - * - * The parameters, `x`, `y`, and `z`, are the coordinates in "world" space - * where the camera should point. For example, calling - * `myCamera.lookAt(10, 20, 30)` points the camera at the coordinates - * `(10, 20, 30)`. * - * @for p5.Camera - * @param {Number} x x-coordinate of the position where the camera should look in "world" space. - * @param {Number} y y-coordinate of the position where the camera should look in "world" space. - * @param {Number} z z-coordinate of the position where the camera should look in "world" space. - * - * @example *
    * - * // Double-click to look at a different cube. - * - * let cam; - * let isLookingLeft = true; - * * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create a p5.Camera object. - * cam = createCamera(); - * - * // Set the camera - * setCamera(cam); - * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); - * - * // Point the camera at the origin. - * cam.lookAt(-30, 0, 0); - * - * describe( - * 'A red cube and a blue cube on a gray background. The camera switches focus between the cubes when the user double-clicks.' - * ); + * describe('A white rectangular prism on a gray background. The prism moves away from the camera until it disappears.'); * } * * function draw() { * background(200); * - * // Draw the box on the left. - * push(); - * // Translate the origin to the left. - * translate(-30, 0, 0); - * // Style the box. - * fill(255, 0, 0); - * // Draw the box. - * box(20); - * pop(); + * // Place the camera at the top-right. + * camera(400, -400, 800); * - * // Draw the box on the right. - * push(); - * // Translate the origin to the right. - * translate(30, 0, 0); - * // Style the box. - * fill(0, 0, 255); - * // Draw the box. - * box(20); - * pop(); - * } + * // Set fovy to 0.2. + * // Set aspect to 1.5. + * // Set near to 600. + * // Set far to 1200. + * perspective(0.2, 1.5, 600, 1200); * - * // Change the camera's focus when the user double-clicks. - * function doubleClicked() { - * if (isLookingLeft === true) { - * cam.lookAt(30, 0, 0); - * isLookingLeft = false; - * } else { - * cam.lookAt(-30, 0, 0); - * isLookingLeft = true; - * } + * // Move the origin away from the camera. + * let x = -frameCount; + * let y = frameCount; + * let z = -2 * frameCount; + * translate(x, y, z); + * + * // Draw the box. + * box(); * } * *
    */ - lookAt(x, y, z) { - this.camera( - this.eyeX, - this.eyeY, - this.eyeZ, - x, - y, - z, - this.upX, - this.upY, - this.upZ - ); - } + fn.perspective = function (...args) { + this._assert3d('perspective'); + // p5._validateParameters('perspective', args); + this._renderer.perspective(...args); + return this; + }; - //////////////////////////////////////////////////////////////////////////////// - // Camera Position Methods - //////////////////////////////////////////////////////////////////////////////// /** - * Sets the position and orientation of the camera. + * Enables or disables perspective for lines in 3D sketches. * - * `myCamera.camera()` allows objects to be viewed from different angles. It - * has nine parameters that are all optional. + * In WebGL mode, lines can be drawn with a thinner stroke when they’re + * further from the camera. Doing so gives them a more realistic appearance. * - * The first three parameters, `x`, `y`, and `z`, are the coordinates of the - * camera’s position in "world" space. For example, calling - * `myCamera.camera(0, 0, 0)` places the camera at the origin `(0, 0, 0)`. By - * default, the camera is placed at `(0, 0, 800)`. + * By default, lines are drawn differently based on the type of perspective + * being used: + * - `perspective()` and `frustum()` simulate a realistic perspective. In + * these modes, stroke weight is affected by the line’s distance from the + * camera. Doing so results in a more natural appearance. `perspective()` is + * the default mode for 3D sketches. + * - `ortho()` doesn’t simulate a realistic perspective. In this mode, stroke + * weights are consistent regardless of the line’s distance from the camera. + * Doing so results in a more predictable and consistent appearance. * - * The next three parameters, `centerX`, `centerY`, and `centerZ` are the - * coordinates of the point where the camera faces in "world" space. For - * example, calling `myCamera.camera(0, 0, 0, 10, 20, 30)` places the camera - * at the origin `(0, 0, 0)` and points it at `(10, 20, 30)`. By default, the - * camera points at the origin `(0, 0, 0)`. + * `linePerspective()` can override the default line drawing mode. * - * The last three parameters, `upX`, `upY`, and `upZ` are the components of - * the "up" vector in "local" space. The "up" vector orients the camera’s - * y-axis. For example, calling - * `myCamera.camera(0, 0, 0, 10, 20, 30, 0, -1, 0)` places the camera at the - * origin `(0, 0, 0)`, points it at `(10, 20, 30)`, and sets the "up" vector - * to `(0, -1, 0)` which is like holding it upside-down. By default, the "up" - * vector is `(0, 1, 0)`. + * The parameter, `enable`, is optional. It’s a `Boolean` value that sets the + * way lines are drawn. If `true` is passed, as in `linePerspective(true)`, + * then lines will appear thinner when they are further from the camera. If + * `false` is passed, as in `linePerspective(false)`, then lines will have + * consistent stroke weights regardless of their distance from the camera. By + * default, `linePerspective()` is enabled. * - * @for p5.Camera - * @param {Number} [x] x-coordinate of the camera. Defaults to 0. - * @param {Number} [y] y-coordinate of the camera. Defaults to 0. - * @param {Number} [z] z-coordinate of the camera. Defaults to 800. - * @param {Number} [centerX] x-coordinate of the point the camera faces. Defaults to 0. - * @param {Number} [centerY] y-coordinate of the point the camera faces. Defaults to 0. - * @param {Number} [centerZ] z-coordinate of the point the camera faces. Defaults to 0. - * @param {Number} [upX] x-component of the camera’s "up" vector. Defaults to 0. - * @param {Number} [upY] x-component of the camera’s "up" vector. Defaults to 1. - * @param {Number} [upZ] z-component of the camera’s "up" vector. Defaults to 0. + * Calling `linePerspective()` without passing an argument returns `true` if + * it's enabled and `false` if not. + * + * Note: `linePerspective()` can only be used in WebGL mode. + * + * @method linePerspective + * @for p5 + * @param {Boolean} enable whether to enable line perspective. * * @example *
    * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; + * // Double-click the canvas to toggle the line perspective. * * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Place it at the top-right: (1200, -600, 100) - * // Point it at the row of boxes: (-10, -10, 400) - * // Set its "up" vector to the default: (0, 1, 0) - * cam2.camera(1200, -600, 100, -10, -10, 400, 0, 1, 0); - * - * // Set the current camera to cam1. - * setCamera(cam1); - * * describe( - * 'A row of white cubes against a gray background. The camera toggles between a frontal and an aerial view when the user double-clicks.' + * 'A white cube with black edges on a gray background. Its edges toggle between thick and thin when the user double-clicks.' * ); * } * @@ -2003,7 +2384,7 @@ class Camera { * background(200); * * // Translate the origin toward the camera. - * translate(-10, 10, 500); + * translate(-10, 10, 600); * * // Rotate the coordinate system. * rotateY(-0.1); @@ -2011,65 +2392,39 @@ class Camera { * * // Draw the row of boxes. * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -30); + * translate(0, 0, -40); * box(10); * } * } * - * // Toggle the current camera when the user double-clicks. + * // Toggle the line perspective when the user double-clicks. * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } + * let isEnabled = linePerspective(); + * linePerspective(!isEnabled); * } * *
    * *
    * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; + * // Double-click the canvas to toggle the line perspective. * * function setup() { * createCanvas(100, 100, WEBGL); * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Place it at the right: (1200, 0, 100) - * // Point it at the row of boxes: (-10, -10, 400) - * // Set its "up" vector to the default: (0, 1, 0) - * cam2.camera(1200, 0, 100, -10, -10, 400, 0, 1, 0); - * - * // Set the current camera to cam1. - * setCamera(cam1); - * * describe( - * 'A row of white cubes against a gray background. The camera toggles between a static frontal view and an orbiting view when the user double-clicks.' + * 'A row of cubes with black edges on a gray background. Their edges toggle between thick and thin when the user double-clicks.' * ); * } * * function draw() { * background(200); * - * // Update cam2's position. - * let x = 1200 * cos(frameCount * 0.01); - * let y = -600 * sin(frameCount * 0.01); - * cam2.camera(x, y, 100, -10, -10, 400, 0, 1, 0); + * // Use an orthographic projection. + * ortho(); * * // Translate the origin toward the camera. - * translate(-10, 10, 500); + * translate(-10, 10, 600); * * // Rotate the coordinate system. * rotateY(-0.1); @@ -2077,1786 +2432,1481 @@ class Camera { * * // Draw the row of boxes. * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -30); + * translate(0, 0, -40); * box(10); * } * } * - * // Toggle the current camera when the user double-clicks. + * // Toggle the line perspective when the user double-clicks. * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } - * } - * - *
    - */ - camera( - eyeX, - eyeY, - eyeZ, - centerX, - centerY, - centerZ, - upX, - upY, - upZ - ) { - if (typeof eyeX === 'undefined') { - eyeX = this.defaultEyeX; - eyeY = this.defaultEyeY; - eyeZ = this.defaultEyeZ; - centerX = eyeX; - centerY = eyeY; - centerZ = 0; - upX = 0; - upY = 1; - upZ = 0; - } - - this.eyeX = eyeX; - this.eyeY = eyeY; - this.eyeZ = eyeZ; - - if (typeof centerX !== 'undefined') { - this.centerX = centerX; - this.centerY = centerY; - this.centerZ = centerZ; - } - - if (typeof upX !== 'undefined') { - this.upX = upX; - this.upY = upY; - this.upZ = upZ; - } - - const local = this._getLocalAxes(); - - // the camera affects the model view matrix, insofar as it - // inverse translates the world to the eye position of the camera - // and rotates it. - - this.cameraMatrix.set(local.x[0], local.y[0], local.z[0], 0, - local.x[1], local.y[1], local.z[1], 0, - local.x[2], local.y[2], local.z[2], 0, - 0, 0, 0, 1); - - - const tx = -eyeX; - const ty = -eyeY; - const tz = -eyeZ; - - this.cameraMatrix.translate([tx, ty, tz]); - - if (this._isActive()) { - this._renderer.states.setValue('uViewMatrix', this._renderer.states.uViewMatrix.clone()); - this._renderer.states.uViewMatrix.set(this.cameraMatrix); - } - return this; - } - - /** - * Moves the camera along its "local" axes without changing its orientation. - * - * The parameters, `x`, `y`, and `z`, are the distances the camera should - * move. For example, calling `myCamera.move(10, 20, 30)` moves the camera 10 - * pixels to the right, 20 pixels down, and 30 pixels backward in its "local" - * space. - * - * @param {Number} x distance to move along the camera’s "local" x-axis. - * @param {Number} y distance to move along the camera’s "local" y-axis. - * @param {Number} z distance to move along the camera’s "local" z-axis. - * @example - *
    - * - * // Click the canvas to begin detecting key presses. - * - * let cam; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the first camera. - * // Keep its default settings. - * cam = createCamera(); - * - * // Place the camera at the top-right. - * cam.setPosition(400, -400, 800); - * - * // Point it at the origin. - * cam.lookAt(0, 0, 0); - * - * // Set the camera. - * setCamera(cam); - * - * describe( - * 'A white cube drawn against a gray background. The cube appears to move when the user presses certain keys.' - * ); - * } - * - * function draw() { - * background(200); - * - * // Move the camera along its "local" axes - * // when the user presses certain keys. - * - * // Move horizontally. - * if (keyIsDown(LEFT_ARROW)) { - * cam.move(-1, 0, 0); - * } - * if (keyIsDown(RIGHT_ARROW)) { - * cam.move(1, 0, 0); - * } - * - * // Move vertically. - * if (keyIsDown(UP_ARROW)) { - * cam.move(0, -1, 0); - * } - * if (keyIsDown(DOWN_ARROW)) { - * cam.move(0, 1, 0); - * } - * - * // Move in/out of the screen. - * if (keyIsDown('i')) { - * cam.move(0, 0, -1); - * } - * if (keyIsDown('o')) { - * cam.move(0, 0, 1); - * } - * - * // Draw the box. - * box(); - * } - * - *
    - */ - move(x, y, z) { - const local = this._getLocalAxes(); - - // scale local axes by movement amounts - // based on http://learnwebgl.brown37.net/07_cameras/camera_linear_motion.html - const dx = [local.x[0] * x, local.x[1] * x, local.x[2] * x]; - const dy = [local.y[0] * y, local.y[1] * y, local.y[2] * y]; - const dz = [local.z[0] * z, local.z[1] * z, local.z[2] * z]; - - this.camera( - this.eyeX + dx[0] + dy[0] + dz[0], - this.eyeY + dx[1] + dy[1] + dz[1], - this.eyeZ + dx[2] + dy[2] + dz[2], - this.centerX + dx[0] + dy[0] + dz[0], - this.centerY + dx[1] + dy[1] + dz[1], - this.centerZ + dx[2] + dy[2] + dz[2], - this.upX, - this.upY, - this.upZ - ); - } - - /** - * Sets the camera’s position in "world" space without changing its - * orientation. - * - * The parameters, `x`, `y`, and `z`, are the coordinates where the camera - * should be placed. For example, calling `myCamera.setPosition(10, 20, 30)` - * places the camera at coordinates `(10, 20, 30)` in "world" space. - * - * @param {Number} x x-coordinate in "world" space. - * @param {Number} y y-coordinate in "world" space. - * @param {Number} z z-coordinate in "world" space. - * - * @example - *
    - * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Place it closer to the origin. - * cam2.setPosition(0, 0, 600); - * - * // Set the current camera to cam1. - * setCamera(cam1); - * - * describe( - * 'A row of white cubes against a gray background. The camera toggles the amount of zoom when the user double-clicks.' - * ); - * } - * - * function draw() { - * background(200); - * - * // Translate the origin toward the camera. - * translate(-10, 10, 500); - * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); - * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -30); - * box(10); - * } - * } - * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } - * } - * - *
    - * - *
    - * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Place it closer to the origin. - * cam2.setPosition(0, 0, 600); - * - * // Set the current camera to cam1. - * setCamera(cam1); - * - * describe( - * 'A row of white cubes against a gray background. The camera toggles between a static view and a view that zooms in and out when the user double-clicks.' - * ); - * } - * - * function draw() { - * background(200); - * - * // Update cam2's z-coordinate. - * let z = 100 * sin(frameCount * 0.01) + 700; - * cam2.setPosition(0, 0, z); - * - * // Translate the origin toward the camera. - * translate(-10, 10, 500); - * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); - * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -30); - * box(10); - * } - * } - * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } - * } - * - *
    - */ - setPosition(x, y, z) { - const diffX = x - this.eyeX; - const diffY = y - this.eyeY; - const diffZ = z - this.eyeZ; - - this.camera( - x, - y, - z, - this.centerX + diffX, - this.centerY + diffY, - this.centerZ + diffZ, - this.upX, - this.upY, - this.upZ - ); - } - - /** - * Sets the camera’s position, orientation, and projection by copying another - * camera. - * - * The parameter, `cam`, is the `p5.Camera` object to copy. For example, calling - * `cam2.set(cam1)` will set `cam2` using `cam1`’s configuration. - * - * @param {p5.Camera} cam camera to copy. - * - * @example - *
    - * - * // Double-click to "reset" the camera zoom. - * - * let cam1; - * let cam2; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the first camera. - * cam1 = createCamera(); - * - * // Place the camera at the top-right. - * cam1.setPosition(400, -400, 800); - * - * // Point it at the origin. - * cam1.lookAt(0, 0, 0); - * - * // Create the second camera. - * cam2 = createCamera(); - * - * // Copy cam1's configuration. - * cam2.set(cam1); - * - * // Set the camera. - * setCamera(cam2); - * - * describe( - * 'A white cube drawn against a gray background. The camera slowly moves forward. The camera resets when the user double-clicks.' - * ); - * } - * - * function draw() { - * background(200); - * - * // Update cam2's position. - * cam2.move(0, 0, -1); - * - * // Draw the box. - * box(); - * } - * - * // "Reset" the camera when the user double-clicks. - * function doubleClicked() { - * cam2.set(cam1); - * } - */ - set(cam) { - const keyNamesOfThePropToCopy = [ - 'eyeX', 'eyeY', 'eyeZ', - 'centerX', 'centerY', 'centerZ', - 'upX', 'upY', 'upZ', - 'cameraFOV', 'aspectRatio', 'cameraNear', 'cameraFar', 'cameraType', - 'yScale', 'useLinePerspective' - ]; - for (const keyName of keyNamesOfThePropToCopy) { - this[keyName] = cam[keyName]; - } - - this.cameraMatrix = cam.cameraMatrix.copy(); - this.projMatrix = cam.projMatrix.copy(); - - if (this._isActive()) { - this._renderer.states.setValue('uModelMatrix', this._renderer.states.uModelMatrix.clone()); - this._renderer.states.setValue('uViewMatrix', this._renderer.states.uViewMatrix.clone()); - this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); - this._renderer.states.uModelMatrix.reset(); - this._renderer.states.uViewMatrix.set(this.cameraMatrix); - this._renderer.states.uPMatrix.set(this.projMatrix); - } - } - /** - * Sets the camera’s position and orientation to values that are in-between - * those of two other cameras. - * - * `myCamera.slerp()` uses spherical linear interpolation to calculate a - * position and orientation that’s in-between two other cameras. Doing so is - * helpful for transitioning smoothly between two perspectives. - * - * The first two parameters, `cam0` and `cam1`, are the `p5.Camera` objects - * that should be used to set the current camera. - * - * The third parameter, `amt`, is the amount to interpolate between `cam0` and - * `cam1`. 0.0 keeps the camera’s position and orientation equal to `cam0`’s, - * 0.5 sets them halfway between `cam0`’s and `cam1`’s , and 1.0 sets the - * position and orientation equal to `cam1`’s. - * - * For example, calling `myCamera.slerp(cam0, cam1, 0.1)` sets cam’s position - * and orientation very close to `cam0`’s. Calling - * `myCamera.slerp(cam0, cam1, 0.9)` sets cam’s position and orientation very - * close to `cam1`’s. - * - * Note: All of the cameras must use the same projection. - * - * @param {p5.Camera} cam0 first camera. - * @param {p5.Camera} cam1 second camera. - * @param {Number} amt amount of interpolation between 0.0 (`cam0`) and 1.0 (`cam1`). - * - * @example - *
    - * - * let cam; - * let cam0; - * let cam1; - * - * function setup() { - * createCanvas(100, 100, WEBGL); - * - * // Create the main camera. - * // Keep its default settings. - * cam = createCamera(); - * - * // Create the first camera. - * // Keep its default settings. - * cam0 = createCamera(); - * - * // Create the second camera. - * cam1 = createCamera(); - * - * // Place it at the top-right. - * cam1.setPosition(400, -400, 800); - * - * // Point it at the origin. - * cam1.lookAt(0, 0, 0); - * - * // Set the current camera to cam. - * setCamera(cam); - * - * describe('A white cube drawn against a gray background. The camera slowly oscillates between a frontal view and an aerial view.'); - * } - * - * function draw() { - * background(200); - * - * // Calculate the amount to interpolate between cam0 and cam1. - * let amt = 0.5 * sin(frameCount * 0.01) + 0.5; - * - * // Update the main camera's position and orientation. - * cam.slerp(cam0, cam1, amt); - * - * box(); - * } - * - *
    - */ - slerp(cam0, cam1, amt) { - // If t is 0 or 1, do not interpolate and set the argument camera. - if (amt === 0) { - this.set(cam0); - return; - } else if (amt === 1) { - this.set(cam1); - return; - } - - // For this cameras is ortho, assume that cam0 and cam1 are also ortho - // and interpolate the elements of the projection matrix. - // Use logarithmic interpolation for interpolation. - if (this.projMatrix.mat4[15] !== 0) { - this.projMatrix.setElement( - 0, - cam0.projMatrix.mat4[0] * - Math.pow(cam1.projMatrix.mat4[0] / cam0.projMatrix.mat4[0], amt) - ); - this.projMatrix.setElement( - 5, - cam0.projMatrix.mat4[5] * - Math.pow(cam1.projMatrix.mat4[5] / cam0.projMatrix.mat4[5], amt) - ); - // If the camera is active, make uPMatrix reflect changes in projMatrix. - if (this._isActive()) { - this._renderer.states.setValue('uPMatrix', this._renderer.states.uPMatrix.clone()); - this._renderer.states.uPMatrix.mat4 = this.projMatrix.mat4.slice(); - } - } - - // prepare eye vector and center vector of argument cameras. - const eye0 = new Vector(cam0.eyeX, cam0.eyeY, cam0.eyeZ); - const eye1 = new Vector(cam1.eyeX, cam1.eyeY, cam1.eyeZ); - const center0 = new Vector(cam0.centerX, cam0.centerY, cam0.centerZ); - const center1 = new Vector(cam1.centerX, cam1.centerY, cam1.centerZ); - - // Calculate the distance between eye and center for each camera. - // Logarithmically interpolate these with amt. - const dist0 = Vector.dist(eye0, center0); - const dist1 = Vector.dist(eye1, center1); - const lerpedDist = dist0 * Math.pow(dist1 / dist0, amt); - - // Next, calculate the ratio to interpolate the eye and center by a constant - // ratio for each camera. This ratio is the same for both. Also, with this ratio - // of points, the distance is the minimum distance of the two points of - // the same ratio. - // With this method, if the viewpoint is fixed, linear interpolation is performed - // at the viewpoint, and if the center is fixed, linear interpolation is performed - // at the center, resulting in reasonable interpolation. If both move, the point - // halfway between them is taken. - const eyeDiff = Vector.sub(eye0, eye1); - const diffDiff = eye0.copy().sub(eye1).sub(center0).add(center1); - // Suppose there are two line segments. Consider the distance between the points - // above them as if they were taken in the same ratio. This calculation figures out - // a ratio that minimizes this. - // Each line segment is, a line segment connecting the viewpoint and the center - // for each camera. - const divider = diffDiff.magSq(); - let ratio = 1; // default. - if (divider > 0.000001) { - ratio = Vector.dot(eyeDiff, diffDiff) / divider; - ratio = Math.max(0, Math.min(ratio, 1)); - } - - // Take the appropriate proportions and work out the points - // that are between the new viewpoint and the new center position. - const lerpedMedium = Vector.lerp( - Vector.lerp(eye0, center0, ratio), - Vector.lerp(eye1, center1, ratio), - amt - ); - - // Prepare each of rotation matrix from their camera matrix - const rotMat0 = cam0.cameraMatrix.createSubMatrix3x3(); - const rotMat1 = cam1.cameraMatrix.createSubMatrix3x3(); - - // get front and up vector from local-coordinate-system. - const front0 = rotMat0.row(2); - const front1 = rotMat1.row(2); - const up0 = rotMat0.row(1); - const up1 = rotMat1.row(1); - - // prepare new vectors. - const newFront = new Vector(); - const newUp = new Vector(); - const newEye = new Vector(); - const newCenter = new Vector(); - - // Create the inverse matrix of mat0 by transposing mat0, - // and multiply it to mat1 from the right. - // This matrix represents the difference between the two. - // 'deltaRot' means 'difference of rotation matrices'. - const deltaRot = rotMat1.mult(rotMat0.copy().transpose()); // mat1 is 3x3 - - // Calculate the trace and from it the cos value of the angle. - // An orthogonal matrix is just an orthonormal basis. If this is not the identity - // matrix, it is a centered orthonormal basis plus some angle of rotation about - // some axis. That's the angle. Letting this be theta, trace becomes 1+2cos(theta). - // reference: https://en.wikipedia.org/wiki/Rotation_matrix#Determining_the_angle - const diag = deltaRot.diagonal(); - let cosTheta = 0.5 * (diag[0] + diag[1] + diag[2] - 1); - - // If the angle is close to 0, the two matrices are very close, - // so in that case we execute linearly interpolate. - if (1 - cosTheta < 0.0000001) { - // Obtain the front vector and up vector by linear interpolation - // and normalize them. - // calculate newEye, newCenter with newFront vector. - newFront.set(Vector.lerp(front0, front1, amt)).normalize(); - - newEye.set(newFront).mult(ratio * lerpedDist).add(lerpedMedium); - newCenter.set(newFront).mult((ratio - 1) * lerpedDist).add(lerpedMedium); - - newUp.set(Vector.lerp(up0, up1, amt)).normalize(); - - // set the camera - this.camera( - newEye.x, newEye.y, newEye.z, - newCenter.x, newCenter.y, newCenter.z, - newUp.x, newUp.y, newUp.z - ); - return; - } - - // Calculates the axis vector and the angle of the difference orthogonal matrix. - // The axis vector is what I explained earlier in the comments. - // similar calculation is here: - // https://github.com/mrdoob/three.js/blob/883249620049d1632e8791732808fefd1a98c871/src/math/Quaternion.js#L294 - let a, b, c, sinTheta; - let invOneMinusCosTheta = 1 / (1 - cosTheta); - const maxDiag = Math.max(diag[0], diag[1], diag[2]); - const offDiagSum13 = deltaRot.mat3[1] + deltaRot.mat3[3]; - const offDiagSum26 = deltaRot.mat3[2] + deltaRot.mat3[6]; - const offDiagSum57 = deltaRot.mat3[5] + deltaRot.mat3[7]; - - if (maxDiag === diag[0]) { - a = Math.sqrt((diag[0] - cosTheta) * invOneMinusCosTheta); // not zero. - invOneMinusCosTheta /= a; - b = 0.5 * offDiagSum13 * invOneMinusCosTheta; - c = 0.5 * offDiagSum26 * invOneMinusCosTheta; - sinTheta = 0.5 * (deltaRot.mat3[7] - deltaRot.mat3[5]) / a; - - } else if (maxDiag === diag[1]) { - b = Math.sqrt((diag[1] - cosTheta) * invOneMinusCosTheta); // not zero. - invOneMinusCosTheta /= b; - c = 0.5 * offDiagSum57 * invOneMinusCosTheta; - a = 0.5 * offDiagSum13 * invOneMinusCosTheta; - sinTheta = 0.5 * (deltaRot.mat3[2] - deltaRot.mat3[6]) / b; - - } else { - c = Math.sqrt((diag[2] - cosTheta) * invOneMinusCosTheta); // not zero. - invOneMinusCosTheta /= c; - a = 0.5 * offDiagSum26 * invOneMinusCosTheta; - b = 0.5 * offDiagSum57 * invOneMinusCosTheta; - sinTheta = 0.5 * (deltaRot.mat3[3] - deltaRot.mat3[1]) / c; - } - - // Constructs a new matrix after interpolating the angles. - // Multiplying mat0 by the first matrix yields mat1, but by creating a state - // in the middle of that matrix, you can obtain a matrix that is - // an intermediate state between mat0 and mat1. - const angle = amt * Math.atan2(sinTheta, cosTheta); - const cosAngle = Math.cos(angle); - const sinAngle = Math.sin(angle); - const oneMinusCosAngle = 1 - cosAngle; - const ab = a * b; - const bc = b * c; - const ca = c * a; - // 3x3 - const lerpedRotMat = new Matrix( [ - cosAngle + oneMinusCosAngle * a * a, - oneMinusCosAngle * ab + sinAngle * c, - oneMinusCosAngle * ca - sinAngle * b, - oneMinusCosAngle * ab - sinAngle * c, - cosAngle + oneMinusCosAngle * b * b, - oneMinusCosAngle * bc + sinAngle * a, - oneMinusCosAngle * ca + sinAngle * b, - oneMinusCosAngle * bc - sinAngle * a, - cosAngle + oneMinusCosAngle * c * c - ]); - - // Multiply this to mat0 from left to get the interpolated front vector. - // calculate newEye, newCenter with newFront vector. - lerpedRotMat.multiplyVec(front0, newFront); // this is vec3 - - newEye.set(newFront).mult(ratio * lerpedDist).add(lerpedMedium); - newCenter.set(newFront).mult((ratio - 1) * lerpedDist).add(lerpedMedium); - - lerpedRotMat.multiplyVec(up0, newUp); // this is vec3 - - // We also get the up vector in the same way and set the camera. - // The eye position and center position are calculated based on the front vector. - this.camera( - newEye.x, newEye.y, newEye.z, - newCenter.x, newCenter.y, newCenter.z, - newUp.x, newUp.y, newUp.z - ); - } - - //////////////////////////////////////////////////////////////////////////////// - // Camera Helper Methods - //////////////////////////////////////////////////////////////////////////////// - - // @TODO: combine this function with _setDefaultCamera to compute these values - // as-needed - _computeCameraDefaultSettings() { - this.defaultAspectRatio = this._renderer.width / this._renderer.height; - this.defaultEyeX = 0; - this.defaultEyeY = 0; - this.defaultEyeZ = 800; - this.defaultCameraFOV = - 2 * Math.atan(this._renderer.height / 2 / this.defaultEyeZ); - this.defaultCenterX = 0; - this.defaultCenterY = 0; - this.defaultCenterZ = 0; - this.defaultCameraNear = this.defaultEyeZ * 0.1; - this.defaultCameraFar = this.defaultEyeZ * 10; - } - - //detect if user didn't set the camera - //then call this function below - _setDefaultCamera() { - this.cameraFOV = this.defaultCameraFOV; - this.aspectRatio = this.defaultAspectRatio; - this.eyeX = this.defaultEyeX; - this.eyeY = this.defaultEyeY; - this.eyeZ = this.defaultEyeZ; - this.centerX = this.defaultCenterX; - this.centerY = this.defaultCenterY; - this.centerZ = this.defaultCenterZ; - this.upX = 0; - this.upY = 1; - this.upZ = 0; - this.cameraNear = this.defaultCameraNear; - this.cameraFar = this.defaultCameraFar; - - this.perspective(); - this.camera(); - - this.cameraType = 'default'; - } - - _resize() { - // If we're using the default camera, update the aspect ratio - if (this.cameraType === 'default') { - this._computeCameraDefaultSettings(); - this.cameraFOV = this.defaultCameraFOV; - this.aspectRatio = this.defaultAspectRatio; - this.perspective(); - } - } - + * let isEnabled = linePerspective(); + * linePerspective(!isEnabled); + * } + *
    + *
    + */ /** - * Returns a copy of a camera. - * @private + * @method linePerspective + * @return {boolean} whether line perspective is enabled. */ - copy() { - const _cam = new Camera(this._renderer); - _cam.cameraFOV = this.cameraFOV; - _cam.aspectRatio = this.aspectRatio; - _cam.eyeX = this.eyeX; - _cam.eyeY = this.eyeY; - _cam.eyeZ = this.eyeZ; - _cam.centerX = this.centerX; - _cam.centerY = this.centerY; - _cam.centerZ = this.centerZ; - _cam.upX = this.upX; - _cam.upY = this.upY; - _cam.upZ = this.upZ; - _cam.cameraNear = this.cameraNear; - _cam.cameraFar = this.cameraFar; - - _cam.cameraType = this.cameraType; - _cam.useLinePerspective = this.useLinePerspective; + fn.linePerspective = function (enable) { + // p5._validateParameters('linePerspective', arguments); + if (!(this._renderer instanceof RendererGL)) { + throw new Error('linePerspective() must be called in WebGL mode.'); + } + return this._renderer.linePerspective(enable); + }; - _cam.cameraMatrix = this.cameraMatrix.copy(); - _cam.projMatrix = this.projMatrix.copy(); - _cam.yScale = this.yScale; - return _cam; - } + /** + * Sets an orthographic projection for the current camera in a 3D sketch. + * + * In an orthographic projection, shapes with the same size always appear the + * same size, regardless of whether they are near or far from the camera. + * + * `ortho()` changes the camera’s perspective by changing its viewing frustum + * from a truncated pyramid to a rectangular prism. The camera is placed in + * front of the frustum and views everything between the frustum’s near plane + * and its far plane. `ortho()` has six optional parameters to define the + * frustum. + * + * The first four parameters, `left`, `right`, `bottom`, and `top`, set the + * coordinates of the frustum’s sides, bottom, and top. For example, calling + * `ortho(-100, 100, 200, -200)` creates a frustum that’s 200 pixels wide and + * 400 pixels tall. By default, these coordinates are set based on the + * sketch’s width and height, as in + * `ortho(-width / 2, width / 2, -height / 2, height / 2)`. + * + * The last two parameters, `near` and `far`, set the distance of the + * frustum’s near and far plane from the camera. For example, calling + * `ortho(-100, 100, 200, 200, 50, 1000)` creates a frustum that’s 200 pixels + * wide, 400 pixels tall, starts 50 pixels from the camera, and ends 1,000 + * pixels from the camera. By default, `near` and `far` are set to 0 and + * `max(width, height) + 800`, respectively. + * + * Note: `ortho()` can only be used in WebGL mode. + * + * @method ortho + * @for p5 + * @param {Number} [left] x-coordinate of the frustum’s left plane. Defaults to `-width / 2`. + * @param {Number} [right] x-coordinate of the frustum’s right plane. Defaults to `width / 2`. + * @param {Number} [bottom] y-coordinate of the frustum’s bottom plane. Defaults to `height / 2`. + * @param {Number} [top] y-coordinate of the frustum’s top plane. Defaults to `-height / 2`. + * @param {Number} [near] z-coordinate of the frustum’s near plane. Defaults to 0. + * @param {Number} [far] z-coordinate of the frustum’s far plane. Defaults to `max(width, height) + 800`. + * @chainable + * + * @example + *
    + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * + * describe('A row of tiny, white cubes on a gray background. All the cubes appear the same size.'); + * } + * + * function draw() { + * background(200); + * + * // Apply an orthographic projection. + * ortho(); + * + * // Translate the origin toward the camera. + * translate(-10, 10, 600); + * + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); + * + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -40); + * box(10); + * } + * } + * + *
    + * + *
    + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * + * describe('A white cube on a gray background.'); + * } + * + * function draw() { + * background(200); + * + * // Apply an orthographic projection. + * // Center the frustum. + * // Set its width and height to 20. + * // Place its near plane 300 pixels from the camera. + * // Place its far plane 350 pixels from the camera. + * ortho(-10, 10, -10, 10, 300, 350); + * + * // Translate the origin toward the camera. + * translate(-10, 10, 600); + * + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); + * + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -40); + * box(10); + * } + * } + * + *
    + */ + fn.ortho = function (...args) { + this._assert3d('ortho'); + // p5._validateParameters('ortho', args); + this._renderer.ortho(...args); + return this; + }; - clone() { - return this.copy(); - } + /** + * Sets the frustum of the current camera in a 3D sketch. + * + * In a frustum projection, shapes that are further from the camera appear + * smaller than shapes that are near the camera. This technique, called + * foreshortening, creates realistic 3D scenes. + * + * `frustum()` changes the default camera’s perspective by changing its + * viewing frustum. The frustum is the volume of space that’s visible to the + * camera. The frustum’s shape is a pyramid with its top cut off. The camera + * is placed where the top of the pyramid should be and points towards the + * base of the pyramid. It views everything within the frustum. + * + * The first four parameters, `left`, `right`, `bottom`, and `top`, set the + * coordinates of the frustum’s sides, bottom, and top. For example, calling + * `frustum(-100, 100, 200, -200)` creates a frustum that’s 200 pixels wide + * and 400 pixels tall. By default, these coordinates are set based on the + * sketch’s width and height, as in + * `ortho(-width / 20, width / 20, height / 20, -height / 20)`. + * + * The last two parameters, `near` and `far`, set the distance of the + * frustum’s near and far plane from the camera. For example, calling + * `ortho(-100, 100, 200, -200, 50, 1000)` creates a frustum that’s 200 pixels + * wide, 400 pixels tall, starts 50 pixels from the camera, and ends 1,000 + * pixels from the camera. By default, near is set to `0.1 * 800`, which is + * 1/10th the default distance between the camera and the origin. `far` is set + * to `10 * 800`, which is 10 times the default distance between the camera + * and the origin. + * + * Note: `frustum()` can only be used in WebGL mode. + * + * @method frustum + * @for p5 + * @param {Number} [left] x-coordinate of the frustum’s left plane. Defaults to `-width / 20`. + * @param {Number} [right] x-coordinate of the frustum’s right plane. Defaults to `width / 20`. + * @param {Number} [bottom] y-coordinate of the frustum’s bottom plane. Defaults to `height / 20`. + * @param {Number} [top] y-coordinate of the frustum’s top plane. Defaults to `-height / 20`. + * @param {Number} [near] z-coordinate of the frustum’s near plane. Defaults to `0.1 * 800`. + * @param {Number} [far] z-coordinate of the frustum’s far plane. Defaults to `10 * 800`. + * @chainable + * + * @example + *
    + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * + * describe('A row of white cubes on a gray background.'); + * } + * + * function draw() { + * background(200); + * + * // Apply the default frustum projection. + * frustum(); + * + * // Translate the origin toward the camera. + * translate(-10, 10, 600); + * + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); + * + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -40); + * box(10); + * } + * } + * + *
    + * + *
    + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * describe('A white cube on a gray background.'); + * } + * + * function draw() { + * background(200); + * + * // Adjust the frustum. + * // Center it. + * // Set its width and height to 20 pixels. + * // Place its near plane 300 pixels from the camera. + * // Place its far plane 350 pixels from the camera. + * frustum(-10, 10, -10, 10, 300, 350); + * + * // Translate the origin toward the camera. + * translate(-10, 10, 600); + * + * // Rotate the coordinate system. + * rotateY(-0.1); + * rotateX(-0.1); + * + * // Draw the row of boxes. + * for (let i = 0; i < 6; i += 1) { + * translate(0, 0, -40); + * box(10); + * } + * } + * + *
    + */ + fn.frustum = function (...args) { + this._assert3d('frustum'); + // p5._validateParameters('frustum', args); + this._renderer.frustum(...args); + return this; + }; /** - * Returns a camera's local axes: left-right, up-down, and forward-backward, - * as defined by vectors in world-space. - * @private + * Creates a new p5.Camera object. + * + * The new camera is initialized with a default position `(0, 0, 800)` and a + * default perspective projection. Its properties can be controlled with + * p5.Camera methods such as + * `myCamera.lookAt(0, 0, 0)`. + * + * Note: Every 3D sketch starts with a default camera initialized. + * This camera can be controlled with the functions + * camera(), + * perspective(), + * ortho(), and + * frustum() if it's the only camera in the scene. + * + * Note: `createCamera()` can only be used in WebGL mode. + * + * @method createCamera + * @return {p5.Camera} the new camera. + * @for p5 + * + * @example + *
    + * + * // Double-click to toggle between cameras. + * + * let cam1; + * let cam2; + * let usingCam1 = true; + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); + * + * // Create the second camera. + * // Place it at the top-left. + * // Point it at the origin. + * cam2 = createCamera(); + * cam2.setPosition(400, -400, 800); + * cam2.lookAt(0, 0, 0); + * + * // Set the current camera to cam1. + * setCamera(cam1); + * + * describe('A white cube on a gray background. The camera toggles between frontal and aerial views when the user double-clicks.'); + * } + * + * function draw() { + * background(200); + * + * // Draw the box. + * box(); + * } + * + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (usingCam1 === true) { + * setCamera(cam2); + * usingCam1 = false; + * } else { + * setCamera(cam1); + * usingCam1 = true; + * } + * } + * + *
    */ - _getLocalAxes() { - // calculate camera local Z vector - let z0 = this.eyeX - this.centerX; - let z1 = this.eyeY - this.centerY; - let z2 = this.eyeZ - this.centerZ; - - // normalize camera local Z vector - const eyeDist = Math.sqrt(z0 * z0 + z1 * z1 + z2 * z2); - if (eyeDist !== 0) { - z0 /= eyeDist; - z1 /= eyeDist; - z2 /= eyeDist; - } - - // calculate camera Y vector - let y0 = this.upX; - let y1 = this.upY; - let y2 = this.upZ; - - // compute camera local X vector as up vector (local Y) cross local Z - let x0 = y1 * z2 - y2 * z1; - let x1 = -y0 * z2 + y2 * z0; - let x2 = y0 * z1 - y1 * z0; - - // recompute y = z cross x - y0 = z1 * x2 - z2 * x1; - y1 = -z0 * x2 + z2 * x0; - y2 = z0 * x1 - z1 * x0; - - // cross product gives area of parallelogram, which is < 1.0 for - // non-perpendicular unit-length vectors; so normalize x, y here: - const xmag = Math.sqrt(x0 * x0 + x1 * x1 + x2 * x2); - if (xmag !== 0) { - x0 /= xmag; - x1 /= xmag; - x2 /= xmag; - } + fn.createCamera = function () { + this._assert3d('createCamera'); - const ymag = Math.sqrt(y0 * y0 + y1 * y1 + y2 * y2); - if (ymag !== 0) { - y0 /= ymag; - y1 /= ymag; - y2 /= ymag; - } + return this._renderer.createCamera(); + }; - return { - x: [x0, x1, x2], - y: [y0, y1, y2], - z: [z0, z1, z2] - }; - } + /** + * Sets the current (active) camera of a 3D sketch. + * + * `setCamera()` allows for switching between multiple cameras created with + * createCamera(). + * + * Note: `setCamera()` can only be used in WebGL mode. + * + * @method setCamera + * @param {p5.Camera} cam camera that should be made active. + * @for p5 + * + * @example + *
    + * + * // Double-click to toggle between cameras. + * + * let cam1; + * let cam2; + * let usingCam1 = true; + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); + * + * // Create the second camera. + * // Place it at the top-left. + * // Point it at the origin. + * cam2 = createCamera(); + * cam2.setPosition(400, -400, 800); + * cam2.lookAt(0, 0, 0); + * + * // Set the current camera to cam1. + * setCamera(cam1); + * + * describe('A white cube on a gray background. The camera toggles between frontal and aerial views when the user double-clicks.'); + * } + * + * function draw() { + * background(200); + * + * // Draw the box. + * box(); + * } + * + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (usingCam1 === true) { + * setCamera(cam2); + * usingCam1 = false; + * } else { + * setCamera(cam1); + * usingCam1 = true; + * } + * } + * + *
    + */ + fn.setCamera = function (cam) { + this._renderer.setCamera(cam); + }; /** - * Orbits the camera about center point. For use with orbitControl(). - * @private - * @param {Number} dTheta change in spherical coordinate theta - * @param {Number} dPhi change in spherical coordinate phi - * @param {Number} dRadius change in radius + * A class to describe a camera for viewing a 3D sketch. + * + * Each `p5.Camera` object represents a camera that views a section of 3D + * space. It stores information about the camera’s position, orientation, and + * projection. + * + * In WebGL mode, the default camera is a `p5.Camera` object that can be + * controlled with the camera(), + * perspective(), + * ortho(), and + * frustum() functions. Additional cameras can be + * created with createCamera() and activated + * with setCamera(). + * + * Note: `p5.Camera`’s methods operate in two coordinate systems: + * - The “world” coordinate system describes positions in terms of their + * relationship to the origin along the x-, y-, and z-axes. For example, + * calling `myCamera.setPosition()` places the camera in 3D space using + * "world" coordinates. + * - The "local" coordinate system describes positions from the camera's point + * of view: left-right, up-down, and forward-backward. For example, calling + * `myCamera.move()` moves the camera along its own axes. + * + * @class p5.Camera + * @constructor + * @param {RendererGL} rendererGL instance of WebGL renderer + * + * @example + *
    + * + * let cam; + * let delta = 0.001; + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at the origin. + * cam.lookAt(0, 0, 0); + * + * describe( + * 'A white cube on a gray background. The cube goes in and out of view as the camera pans left and right.' + * ); + * } + * + * function draw() { + * background(200); + * + * // Turn the camera left and right, called "panning". + * cam.pan(delta); + * + * // Switch directions every 120 frames. + * if (frameCount % 120 === 0) { + * delta *= -1; + * } + * + * // Draw the box. + * box(); + * } + * + *
    + * + *
    + * + * // Double-click to toggle between cameras. + * + * let cam1; + * let cam2; + * let isDefaultCamera = true; + * + * function setup() { + * createCanvas(100, 100, WEBGL); + * + * // Create the first camera. + * // Keep its default settings. + * cam1 = createCamera(); + * + * // Create the second camera. + * // Place it at the top-left. + * // Point it at the origin. + * cam2 = createCamera(); + * cam2.setPosition(400, -400, 800); + * cam2.lookAt(0, 0, 0); + * + * // Set the current camera to cam1. + * setCamera(cam1); + * + * describe( + * 'A white cube on a gray background. The camera toggles between frontal and aerial views when the user double-clicks.' + * ); + * } + * + * function draw() { + * background(200); + * + * // Draw the box. + * box(); + * } + * + * // Toggle the current camera when the user double-clicks. + * function doubleClicked() { + * if (isDefaultCamera === true) { + * setCamera(cam2); + * isDefaultCamera = false; + * } else { + * setCamera(cam1); + * isDefaultCamera = true; + * } + * } + * + *
    */ - _orbit(dTheta, dPhi, dRadius) { - // Calculate the vector and its magnitude from the center to the viewpoint - const diffX = this.eyeX - this.centerX; - const diffY = this.eyeY - this.centerY; - const diffZ = this.eyeZ - this.centerZ; - let camRadius = Math.hypot(diffX, diffY, diffZ); - // front vector. unit vector from center to eye. - const front = new Vector(diffX, diffY, diffZ).normalize(); - // up vector. normalized camera's up vector. - const up = new Vector(this.upX, this.upY, this.upZ).normalize(); // y-axis - // side vector. Right when viewed from the front - const side = Vector.cross(up, front).normalize(); // x-axis - // vertical vector. normalized vector of projection of front vector. - const vertical = Vector.cross(side, up); // z-axis + p5.Camera = Camera; - // update camRadius - camRadius *= Math.pow(10, dRadius); - // prevent zooming through the center: - if (camRadius < this.cameraNear) { - camRadius = this.cameraNear; - } - if (camRadius > this.cameraFar) { - camRadius = this.cameraFar; - } + RendererGL.prototype.camera = function(...args) { + this.states.curCamera.camera(...args); + }; - // calculate updated camera angle - // Find the angle between the "up" and the "front", add dPhi to that. - // angleBetween() may return negative value. Since this specification is subject to change - // due to version updates, it cannot be adopted, so here we calculate using a method - // that directly obtains the absolute value. - const camPhi = - Math.acos(Math.max(-1, Math.min(1, Vector.dot(front, up)))) + dPhi; - // Rotate by dTheta in the shortest direction from "vertical" to "side" - const camTheta = dTheta; + RendererGL.prototype.perspective = function(...args) { + this.states.curCamera.perspective(...args); + }; - // Invert camera's upX, upY, upZ if dPhi is below 0 or above PI - if (camPhi <= 0 || camPhi >= Math.PI) { - this.upX *= -1; - this.upY *= -1; - this.upZ *= -1; + RendererGL.prototype.linePerspective = function(enable) { + if (enable !== undefined) { + // Set the line perspective if enable is provided + this.states.curCamera.useLinePerspective = enable; + } else { + // If no argument is provided, return the current value + return this.states.curCamera.useLinePerspective; } + }; - // update eye vector by calculate new front vector - up.mult(Math.cos(camPhi)); - vertical.mult(Math.cos(camTheta) * Math.sin(camPhi)); - side.mult(Math.sin(camTheta) * Math.sin(camPhi)); - - front.set(up).add(vertical).add(side); - - this.eyeX = camRadius * front.x + this.centerX; - this.eyeY = camRadius * front.y + this.centerY; - this.eyeZ = camRadius * front.z + this.centerZ; - - // update camera - this.camera( - this.eyeX, this.eyeY, this.eyeZ, - this.centerX, this.centerY, this.centerZ, - this.upX, this.upY, this.upZ - ); - } - - /** - * Orbits the camera about center point. For use with orbitControl(). - * Unlike _orbit(), the direction of rotation always matches the direction of pointer movement. - * @private - * @param {Number} dx the x component of the rotation vector. - * @param {Number} dy the y component of the rotation vector. - * @param {Number} dRadius change in radius - */ - _orbitFree(dx, dy, dRadius) { - // Calculate the vector and its magnitude from the center to the viewpoint - const diffX = this.eyeX - this.centerX; - const diffY = this.eyeY - this.centerY; - const diffZ = this.eyeZ - this.centerZ; - let camRadius = Math.hypot(diffX, diffY, diffZ); - // front vector. unit vector from center to eye. - const front = new Vector(diffX, diffY, diffZ).normalize(); - // up vector. camera's up vector. - const up = new Vector(this.upX, this.upY, this.upZ); - // side vector. Right when viewed from the front. (like x-axis) - const side = Vector.cross(up, front).normalize(); - // down vector. Bottom when viewed from the front. (like y-axis) - const down = Vector.cross(front, side); + RendererGL.prototype.ortho = function(...args) { + this.states.curCamera.ortho(...args); + }; - // side vector and down vector are no longer used as-is. - // Create a vector representing the direction of rotation - // in the form cos(direction)*side + sin(direction)*down. - // Make the current side vector into this. - const directionAngle = Math.atan2(dy, dx); - down.mult(Math.sin(directionAngle)); - side.mult(Math.cos(directionAngle)).add(down); - // The amount of rotation is the size of the vector (dx, dy). - const rotAngle = Math.sqrt(dx * dx + dy * dy); - // The vector that is orthogonal to both the front vector and - // the rotation direction vector is the rotation axis vector. - const axis = Vector.cross(front, side); + RendererGL.prototype.frustum = function(...args) { + this.states.curCamera.frustum(...args); + }; - // update camRadius - camRadius *= Math.pow(10, dRadius); - // prevent zooming through the center: - if (camRadius < this.cameraNear) { - camRadius = this.cameraNear; - } - if (camRadius > this.cameraFar) { - camRadius = this.cameraFar; - } + RendererGL.prototype.createCamera = function() { + // compute default camera settings, then set a default camera + const _cam = new Camera(this); + _cam._computeCameraDefaultSettings(); + _cam._setDefaultCamera(); - // If the axis vector is likened to the z-axis, the front vector is - // the x-axis and the side vector is the y-axis. Rotate the up and front - // vectors respectively by thinking of them as rotations around the z-axis. + return _cam; + }; - // Calculate the components by taking the dot product and - // calculate a rotation based on that. - const c = Math.cos(rotAngle); - const s = Math.sin(rotAngle); - const dotFront = up.dot(front); - const dotSide = up.dot(side); - const ux = dotFront * c + dotSide * s; - const uy = -dotFront * s + dotSide * c; - const uz = up.dot(axis); - up.x = ux * front.x + uy * side.x + uz * axis.x; - up.y = ux * front.y + uy * side.y + uz * axis.y; - up.z = ux * front.z + uy * side.z + uz * axis.z; - // We won't be using the side vector and the front vector anymore, - // so let's make the front vector into the vector from the center to the new eye. - side.mult(-s); - front.mult(c).add(side).mult(camRadius); + RendererGL.prototype.setCamera = function(cam) { + this.states.setValue('curCamera', cam); - // it's complete. let's update camera. - this.camera( - front.x + this.centerX, - front.y + this.centerY, - front.z + this.centerZ, - this.centerX, this.centerY, this.centerZ, - up.x, up.y, up.z - ); - } + // set the projection matrix (which is not normally updated each frame) + this.states.setValue('uPMatrix', this.states.uPMatrix.clone()); + this.states.uPMatrix.set(cam.projMatrix); + this.states.setValue('uViewMatrix', this.states.uViewMatrix.clone()); + this.states.uViewMatrix.set(cam.cameraMatrix); + }; /** - * Returns true if camera is currently attached to renderer. - * @private + * The camera’s x-coordinate. + * + * By default, the camera’s x-coordinate is set to 0 in "world" space. + * + * @property {Number} eyeX + * @for p5.Camera + * @readonly + * + * @example + *
    + * + * let cam; + * let font; + * + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); + * createCanvas(100, 100, WEBGL); + * + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at the origin. + * cam.lookAt(0, 0, 0); + * + * describe( + * 'A white cube on a gray background. The text "eyeX: 0" is written in black beneath it.' + * ); + * } + * + * function draw() { + * background(200); + * + * // Style the box. + * fill(255); + * + * // Draw the box. + * box(); + * + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Display the value of eyeX, rounded to the nearest integer. + * text(`eyeX: ${round(cam.eyeX)}`, 0, 45); + * } + * + *
    + * + *
    + * + * let cam; + * let font; + * + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); + * createCanvas(100, 100, WEBGL); + * + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at the origin. + * cam.lookAt(0, 0, 0); + * + * describe( + * 'A white cube on a gray background. The cube appears to move left and right as the camera moves. The text "eyeX: X" is written in black beneath the cube. X oscillates between -25 and 25.' + * ); + * } + * + * function draw() { + * background(200); + * + * // Style the box. + * fill(255); + * + * // Draw the box. + * box(); + * + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Calculate the new x-coordinate. + * let x = 25 * sin(frameCount * 0.01); + * + * // Set the camera's position. + * cam.setPosition(x, -400, 800); + * + * // Display the value of eyeX, rounded to the nearest integer. + * text(`eyeX: ${round(cam.eyeX)}`, 0, 45); + * } + * + *
    */ - _isActive() { - return this === this._renderer.states.curCamera; - } -}; - -function camera(p5, fn){ - //////////////////////////////////////////////////////////////////////////////// - // p5.Prototype Methods - //////////////////////////////////////////////////////////////////////////////// /** - * Sets the position and orientation of the current camera in a 3D sketch. + * The camera’s y-coordinate. * - * `camera()` allows objects to be viewed from different angles. It has nine - * parameters that are all optional. + * By default, the camera’s y-coordinate is set to 0 in "world" space. * - * The first three parameters, `x`, `y`, and `z`, are the coordinates of the - * camera’s position. For example, calling `camera(0, 0, 0)` places the camera - * at the origin `(0, 0, 0)`. By default, the camera is placed at - * `(0, 0, 800)`. + * @property {Number} eyeY + * @for p5.Camera + * @readonly * - * The next three parameters, `centerX`, `centerY`, and `centerZ` are the - * coordinates of the point where the camera faces. For example, calling - * `camera(0, 0, 0, 10, 20, 30)` places the camera at the origin `(0, 0, 0)` - * and points it at `(10, 20, 30)`. By default, the camera points at the - * origin `(0, 0, 0)`. + * @example + *
    + * + * let cam; + * let font; * - * The last three parameters, `upX`, `upY`, and `upZ` are the components of - * the "up" vector. The "up" vector orients the camera’s y-axis. For example, - * calling `camera(0, 0, 0, 10, 20, 30, 0, -1, 0)` places the camera at the - * origin `(0, 0, 0)`, points it at `(10, 20, 30)`, and sets the "up" vector - * to `(0, -1, 0)` which is like holding it upside-down. By default, the "up" - * vector is `(0, 1, 0)`. + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); + * createCanvas(100, 100, WEBGL); * - * Note: `camera()` can only be used in WebGL mode. + * // Create a p5.Camera object. + * cam = createCamera(); * - * @method camera - * @for p5 - * @param {Number} [x] x-coordinate of the camera. Defaults to 0. - * @param {Number} [y] y-coordinate of the camera. Defaults to 0. - * @param {Number} [z] z-coordinate of the camera. Defaults to 800. - * @param {Number} [centerX] x-coordinate of the point the camera faces. Defaults to 0. - * @param {Number} [centerY] y-coordinate of the point the camera faces. Defaults to 0. - * @param {Number} [centerZ] z-coordinate of the point the camera faces. Defaults to 0. - * @param {Number} [upX] x-component of the camera’s "up" vector. Defaults to 0. - * @param {Number} [upY] y-component of the camera’s "up" vector. Defaults to 1. - * @param {Number} [upZ] z-component of the camera’s "up" vector. Defaults to 0. - * @chainable + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at the origin. + * cam.lookAt(0, 0, 0); + * + * // Set the camera. + * setCamera(cam); + * + * describe( + * 'A white cube on a gray background. The text "eyeY: -400" is written in black beneath it.' + * ); + * } + * + * function draw() { + * background(200); + * + * // Style the box. + * fill(255); + * + * // Draw the box. + * box(); + * + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Display the value of eyeY, rounded to the nearest integer. + * text(`eyeY: ${round(cam.eyeY)}`, 0, 45); + * } + * + *
    * - * @example *
    * - * function setup() { + * let cam; + * let font; + * + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * describe('A white cube on a gray background.'); + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at the origin. + * cam.lookAt(0, 0, 0); + * + * describe( + * 'A white cube on a gray background. The cube appears to move up and down as the camera moves. The text "eyeY: Y" is written in black beneath the cube. Y oscillates between -374 and -425.' + * ); * } * * function draw() { * background(200); * - * // Move the camera to the top-right. - * camera(200, -400, 800); + * // Style the box. + * fill(255); * * // Draw the box. * box(); + * + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Calculate the new y-coordinate. + * let y = 25 * sin(frameCount * 0.01) - 400; + * + * // Set the camera's position. + * cam.setPosition(0, y, 800); + * + * // Display the value of eyeY, rounded to the nearest integer. + * text(`eyeY: ${round(cam.eyeY)}`, 0, 45); * } * *
    + */ + + /** + * The camera’s z-coordinate. + * + * By default, the camera’s z-coordinate is set to 800 in "world" space. + * + * @property {Number} eyeZ + * @for p5.Camera + * @readonly * + * @example *
    * - * function setup() { + * let cam; + * let font; + * + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * describe('A white cube apperas to sway left and right on a gray background.'); + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at the origin. + * cam.lookAt(0, 0, 0); + * + * describe( + * 'A white cube on a gray background. The text "eyeZ: 800" is written in black beneath it.' + * ); * } * * function draw() { * background(200); * - * // Calculate the camera's x-coordinate. - * let x = 400 * cos(frameCount * 0.01); + * // Style the box. + * fill(255); * - * // Orbit the camera around the box. - * camera(x, -400, 800); + * // Draw the box. + * box(); + * + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); * - * // Draw the box. - * box(); + * // Display the value of eyeZ, rounded to the nearest integer. + * text(`eyeZ: ${round(cam.eyeZ)}`, 0, 45); * } * *
    * *
    * - * // Adjust the range sliders to change the camera's position. - * - * let xSlider; - * let ySlider; - * let zSlider; + * let cam; + * let font; * - * function setup() { + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * // Create slider objects to set the camera's coordinates. - * xSlider = createSlider(-400, 400, 400); - * xSlider.position(0, 100); - * xSlider.size(100); - * ySlider = createSlider(-400, 400, -200); - * ySlider.position(0, 120); - * ySlider.size(100); - * zSlider = createSlider(0, 1600, 800); - * zSlider.position(0, 140); - * zSlider.size(100); + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at the origin. + * cam.lookAt(0, 0, 0); * * describe( - * 'A white cube drawn against a gray background. Three range sliders appear beneath the image. The camera position changes when the user moves the sliders.' + * 'A white cube on a gray background. The cube appears to move forward and back as the camera moves. The text "eyeZ: Z" is written in black beneath the cube. Z oscillates between 700 and 900.' * ); * } * * function draw() { * background(200); * - * // Get the camera's coordinates from the sliders. - * let x = xSlider.value(); - * let y = ySlider.value(); - * let z = zSlider.value(); - * - * // Move the camera. - * camera(x, y, z); + * // Style the box. + * fill(255); * * // Draw the box. * box(); + * + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Calculate the new z-coordinate. + * let z = 100 * sin(frameCount * 0.01) + 800; + * + * // Set the camera's position. + * cam.setPosition(0, -400, z); + * + * // Display the value of eyeZ, rounded to the nearest integer. + * text(`eyeZ: ${round(cam.eyeZ)}`, 0, 45); * } * *
    */ - fn.camera = function (...args) { - this._assert3d('camera'); - // p5._validateParameters('camera', args); - this._renderer.camera(...args); - return this; - }; /** - * Sets a perspective projection for the current camera in a 3D sketch. - * - * In a perspective projection, shapes that are further from the camera appear - * smaller than shapes that are near the camera. This technique, called - * foreshortening, creates realistic 3D scenes. It’s applied by default in - * WebGL mode. - * - * `perspective()` changes the camera’s perspective by changing its viewing - * frustum. The frustum is the volume of space that’s visible to the camera. - * Its shape is a pyramid with its top cut off. The camera is placed where - * the top of the pyramid should be and views everything between the frustum’s - * top (near) plane and its bottom (far) plane. - * - * The first parameter, `fovy`, is the camera’s vertical field of view. It’s - * an angle that describes how tall or narrow a view the camera has. For - * example, calling `perspective(0.5)` sets the camera’s vertical field of - * view to 0.5 radians. By default, `fovy` is calculated based on the sketch’s - * height and the camera’s default z-coordinate, which is 800. The formula for - * the default `fovy` is `2 * atan(height / 2 / 800)`. - * - * The second parameter, `aspect`, is the camera’s aspect ratio. It’s a number - * that describes the ratio of the top plane’s width to its height. For - * example, calling `perspective(0.5, 1.5)` sets the camera’s field of view to - * 0.5 radians and aspect ratio to 1.5, which would make shapes appear thinner - * on a square canvas. By default, aspect is set to `width / height`. - * - * The third parameter, `near`, is the distance from the camera to the near - * plane. For example, calling `perspective(0.5, 1.5, 100)` sets the camera’s - * field of view to 0.5 radians, its aspect ratio to 1.5, and places the near - * plane 100 pixels from the camera. Any shapes drawn less than 100 pixels - * from the camera won’t be visible. By default, near is set to `0.1 * 800`, - * which is 1/10th the default distance between the camera and the origin. - * - * The fourth parameter, `far`, is the distance from the camera to the far - * plane. For example, calling `perspective(0.5, 1.5, 100, 10000)` sets the - * camera’s field of view to 0.5 radians, its aspect ratio to 1.5, places the - * near plane 100 pixels from the camera, and places the far plane 10,000 - * pixels from the camera. Any shapes drawn more than 10,000 pixels from the - * camera won’t be visible. By default, far is set to `10 * 800`, which is 10 - * times the default distance between the camera and the origin. + * The x-coordinate of the place where the camera looks. * - * Note: `perspective()` can only be used in WebGL mode. + * By default, the camera looks at the origin `(0, 0, 0)` in "world" space, so + * `myCamera.centerX` is 0. * - * @method perspective - * @for p5 - * @param {Number} [fovy] camera frustum vertical field of view. Defaults to - * `2 * atan(height / 2 / 800)`. - * @param {Number} [aspect] camera frustum aspect ratio. Defaults to - * `width / height`. - * @param {Number} [near] distance from the camera to the near clipping plane. - * Defaults to `0.1 * 800`. - * @param {Number} [far] distance from the camera to the far clipping plane. - * Defaults to `10 * 800`. - * @chainable + * @property {Number} centerX + * @for p5.Camera + * @readonly * * @example *
    * - * // Double-click to squeeze the box. - * - * let isSqueezed = false; + * let cam; + * let font; * - * function setup() { + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * describe('A white rectangular prism on a gray background. The box appears to become thinner when the user double-clicks.'); + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at (10, 20, -30). + * cam.lookAt(10, 20, -30); + * + * describe( + * 'A white cube on a gray background. The text "centerX: 10" is written in black beneath it.' + * ); * } * * function draw() { * background(200); * - * // Place the camera at the top-right. - * camera(400, -400, 800); - * - * if (isSqueezed === true) { - * // Set fovy to 0.2. - * // Set aspect to 1.5. - * perspective(0.2, 1.5); - * } + * // Style the box. + * fill(255); * * // Draw the box. * box(); - * } * - * // Change the camera's perspective when the user double-clicks. - * function doubleClicked() { - * isSqueezed = true; + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Display the value of centerX, rounded to the nearest integer. + * text(`centerX: ${round(cam.centerX)}`, 0, 45); * } * *
    * *
    * - * function setup() { + * let cam; + * let font; + * + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * describe('A white rectangular prism on a gray background. The prism moves away from the camera until it disappears.'); - * } + * // Create a p5.Camera object. + * cam = createCamera(); * - * function draw() { - * background(200); + * // Set the camera + * setCamera(cam); * * // Place the camera at the top-right. - * camera(400, -400, 800); + * cam.setPosition(100, -400, 800); * - * // Set fovy to 0.2. - * // Set aspect to 1.5. - * // Set near to 600. - * // Set far to 1200. - * perspective(0.2, 1.5, 600, 1200); + * // Point the camera at (10, 20, -30). + * cam.lookAt(10, 20, -30); * - * // Move the origin away from the camera. - * let x = -frameCount; - * let y = frameCount; - * let z = -2 * frameCount; - * translate(x, y, z); + * describe( + * 'A white cube on a gray background. The cube appears to move left and right as the camera shifts its focus. The text "centerX: X" is written in black beneath the cube. X oscillates between -15 and 35.' + * ); + * } + * + * function draw() { + * background(200); + * + * // Style the box. + * fill(255); * * // Draw the box. * box(); - * } - * - *
    - */ - fn.perspective = function (...args) { - this._assert3d('perspective'); - // p5._validateParameters('perspective', args); - this._renderer.perspective(...args); - return this; - }; - - - /** - * Enables or disables perspective for lines in 3D sketches. - * - * In WebGL mode, lines can be drawn with a thinner stroke when they’re - * further from the camera. Doing so gives them a more realistic appearance. * - * By default, lines are drawn differently based on the type of perspective - * being used: - * - `perspective()` and `frustum()` simulate a realistic perspective. In - * these modes, stroke weight is affected by the line’s distance from the - * camera. Doing so results in a more natural appearance. `perspective()` is - * the default mode for 3D sketches. - * - `ortho()` doesn’t simulate a realistic perspective. In this mode, stroke - * weights are consistent regardless of the line’s distance from the camera. - * Doing so results in a more predictable and consistent appearance. + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); * - * `linePerspective()` can override the default line drawing mode. + * // Calculate the new x-coordinate. + * let x = 25 * sin(frameCount * 0.01) + 10; * - * The parameter, `enable`, is optional. It’s a `Boolean` value that sets the - * way lines are drawn. If `true` is passed, as in `linePerspective(true)`, - * then lines will appear thinner when they are further from the camera. If - * `false` is passed, as in `linePerspective(false)`, then lines will have - * consistent stroke weights regardless of their distance from the camera. By - * default, `linePerspective()` is enabled. + * // Point the camera. + * cam.lookAt(x, 20, -30); * - * Calling `linePerspective()` without passing an argument returns `true` if - * it's enabled and `false` if not. + * // Display the value of centerX, rounded to the nearest integer. + * text(`centerX: ${round(cam.centerX)}`, 0, 45); + * } + *
    + *
    + */ + + /** + * The y-coordinate of the place where the camera looks. * - * Note: `linePerspective()` can only be used in WebGL mode. + * By default, the camera looks at the origin `(0, 0, 0)` in "world" space, so + * `myCamera.centerY` is 0. * - * @method linePerspective - * @for p5 - * @param {Boolean} enable whether to enable line perspective. + * @property {Number} centerY + * @for p5.Camera + * @readonly * * @example *
    * - * // Double-click the canvas to toggle the line perspective. + * let cam; + * let font; * - * function setup() { + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at (10, 20, -30). + * cam.lookAt(10, 20, -30); + * * describe( - * 'A white cube with black edges on a gray background. Its edges toggle between thick and thin when the user double-clicks.' + * 'A white cube on a gray background. The text "centerY: 20" is written in black beneath it.' * ); * } * * function draw() { * background(200); * - * // Translate the origin toward the camera. - * translate(-10, 10, 600); + * // Style the box. + * fill(255); * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); + * // Draw the box. + * box(); * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -40); - * box(10); - * } - * } + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); * - * // Toggle the line perspective when the user double-clicks. - * function doubleClicked() { - * let isEnabled = linePerspective(); - * linePerspective(!isEnabled); + * // Display the value of centerY, rounded to the nearest integer. + * text(`centerY: ${round(cam.centerY)}`, 0, 45); * } * *
    * *
    * - * // Double-click the canvas to toggle the line perspective. + * let cam; + * let font; * - * function setup() { + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-right. + * cam.setPosition(100, -400, 800); + * + * // Point the camera at (10, 20, -30). + * cam.lookAt(10, 20, -30); + * * describe( - * 'A row of cubes with black edges on a gray background. Their edges toggle between thick and thin when the user double-clicks.' + * 'A white cube on a gray background. The cube appears to move up and down as the camera shifts its focus. The text "centerY: Y" is written in black beneath the cube. Y oscillates between -5 and 45.' * ); * } * * function draw() { * background(200); * - * // Use an orthographic projection. - * ortho(); + * // Style the box. + * fill(255); * - * // Translate the origin toward the camera. - * translate(-10, 10, 600); + * // Draw the box. + * box(); * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -40); - * box(10); - * } - * } + * // Calculate the new y-coordinate. + * let y = 25 * sin(frameCount * 0.01) + 20; * - * // Toggle the line perspective when the user double-clicks. - * function doubleClicked() { - * let isEnabled = linePerspective(); - * linePerspective(!isEnabled); + * // Point the camera. + * cam.lookAt(10, y, -30); + * + * // Display the value of centerY, rounded to the nearest integer. + * text(`centerY: ${round(cam.centerY)}`, 0, 45); * } * *
    */ - /** - * @method linePerspective - * @return {boolean} whether line perspective is enabled. - */ - fn.linePerspective = function (enable) { - // p5._validateParameters('linePerspective', arguments); - if (!(this._renderer instanceof RendererGL)) { - throw new Error('linePerspective() must be called in WebGL mode.'); - } - return this._renderer.linePerspective(enable); - }; - /** - * Sets an orthographic projection for the current camera in a 3D sketch. - * - * In an orthographic projection, shapes with the same size always appear the - * same size, regardless of whether they are near or far from the camera. - * - * `ortho()` changes the camera’s perspective by changing its viewing frustum - * from a truncated pyramid to a rectangular prism. The camera is placed in - * front of the frustum and views everything between the frustum’s near plane - * and its far plane. `ortho()` has six optional parameters to define the - * frustum. - * - * The first four parameters, `left`, `right`, `bottom`, and `top`, set the - * coordinates of the frustum’s sides, bottom, and top. For example, calling - * `ortho(-100, 100, 200, -200)` creates a frustum that’s 200 pixels wide and - * 400 pixels tall. By default, these coordinates are set based on the - * sketch’s width and height, as in - * `ortho(-width / 2, width / 2, -height / 2, height / 2)`. - * - * The last two parameters, `near` and `far`, set the distance of the - * frustum’s near and far plane from the camera. For example, calling - * `ortho(-100, 100, 200, 200, 50, 1000)` creates a frustum that’s 200 pixels - * wide, 400 pixels tall, starts 50 pixels from the camera, and ends 1,000 - * pixels from the camera. By default, `near` and `far` are set to 0 and - * `max(width, height) + 800`, respectively. + * The y-coordinate of the place where the camera looks. * - * Note: `ortho()` can only be used in WebGL mode. + * By default, the camera looks at the origin `(0, 0, 0)` in "world" space, so + * `myCamera.centerZ` is 0. * - * @method ortho - * @for p5 - * @param {Number} [left] x-coordinate of the frustum’s left plane. Defaults to `-width / 2`. - * @param {Number} [right] x-coordinate of the frustum’s right plane. Defaults to `width / 2`. - * @param {Number} [bottom] y-coordinate of the frustum’s bottom plane. Defaults to `height / 2`. - * @param {Number} [top] y-coordinate of the frustum’s top plane. Defaults to `-height / 2`. - * @param {Number} [near] z-coordinate of the frustum’s near plane. Defaults to 0. - * @param {Number} [far] z-coordinate of the frustum’s far plane. Defaults to `max(width, height) + 800`. - * @chainable + * @property {Number} centerZ + * @for p5.Camera + * @readonly * * @example *
    * - * function setup() { + * let cam; + * let font; + * + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * describe('A row of tiny, white cubes on a gray background. All the cubes appear the same size.'); + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-center. + * cam.setPosition(0, -400, 800); + * + * // Point the camera at (10, 20, -30). + * cam.lookAt(10, 20, -30); + * + * describe( + * 'A white cube on a gray background. The text "centerZ: -30" is written in black beneath it.' + * ); * } * * function draw() { * background(200); * - * // Apply an orthographic projection. - * ortho(); + * // Style the box. + * fill(255); * - * // Translate the origin toward the camera. - * translate(-10, 10, 600); + * // Draw the box. + * box(); * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -40); - * box(10); - * } + * // Display the value of centerZ, rounded to the nearest integer. + * text(`centerZ: ${round(cam.centerZ)}`, 0, 45); * } * *
    * *
    * - * function setup() { + * let cam; + * let font; + * + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * describe('A white cube on a gray background.'); + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Place the camera at the top-right. + * cam.setPosition(100, -400, 800); + * + * // Point the camera at (10, 20, -30). + * cam.lookAt(10, 20, -30); + * + * describe( + * 'A white cube on a gray background. The cube appears to move forward and back as the camera shifts its focus. The text "centerZ: Z" is written in black beneath the cube. Z oscillates between -55 and -25.' + * ); * } * * function draw() { * background(200); * - * // Apply an orthographic projection. - * // Center the frustum. - * // Set its width and height to 20. - * // Place its near plane 300 pixels from the camera. - * // Place its far plane 350 pixels from the camera. - * ortho(-10, 10, -10, 10, 300, 350); + * // Style the box. + * fill(255); + * + * // Draw the box. + * box(); + * + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); * - * // Translate the origin toward the camera. - * translate(-10, 10, 600); + * // Calculate the new z-coordinate. + * let z = 25 * sin(frameCount * 0.01) - 30; * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); + * // Point the camera. + * cam.lookAt(10, 20, z); * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -40); - * box(10); - * } + * // Display the value of centerZ, rounded to the nearest integer. + * text(`centerZ: ${round(cam.centerZ)}`, 0, 45); * } * *
    */ - fn.ortho = function (...args) { - this._assert3d('ortho'); - // p5._validateParameters('ortho', args); - this._renderer.ortho(...args); - return this; - }; /** - * Sets the frustum of the current camera in a 3D sketch. - * - * In a frustum projection, shapes that are further from the camera appear - * smaller than shapes that are near the camera. This technique, called - * foreshortening, creates realistic 3D scenes. - * - * `frustum()` changes the default camera’s perspective by changing its - * viewing frustum. The frustum is the volume of space that’s visible to the - * camera. The frustum’s shape is a pyramid with its top cut off. The camera - * is placed where the top of the pyramid should be and points towards the - * base of the pyramid. It views everything within the frustum. - * - * The first four parameters, `left`, `right`, `bottom`, and `top`, set the - * coordinates of the frustum’s sides, bottom, and top. For example, calling - * `frustum(-100, 100, 200, -200)` creates a frustum that’s 200 pixels wide - * and 400 pixels tall. By default, these coordinates are set based on the - * sketch’s width and height, as in - * `ortho(-width / 20, width / 20, height / 20, -height / 20)`. - * - * The last two parameters, `near` and `far`, set the distance of the - * frustum’s near and far plane from the camera. For example, calling - * `ortho(-100, 100, 200, -200, 50, 1000)` creates a frustum that’s 200 pixels - * wide, 400 pixels tall, starts 50 pixels from the camera, and ends 1,000 - * pixels from the camera. By default, near is set to `0.1 * 800`, which is - * 1/10th the default distance between the camera and the origin. `far` is set - * to `10 * 800`, which is 10 times the default distance between the camera - * and the origin. + * The x-component of the camera's "up" vector. * - * Note: `frustum()` can only be used in WebGL mode. + * The camera's "up" vector orients its y-axis. By default, the "up" vector is + * `(0, 1, 0)`, so its x-component is 0 in "local" space. * - * @method frustum - * @for p5 - * @param {Number} [left] x-coordinate of the frustum’s left plane. Defaults to `-width / 20`. - * @param {Number} [right] x-coordinate of the frustum’s right plane. Defaults to `width / 20`. - * @param {Number} [bottom] y-coordinate of the frustum’s bottom plane. Defaults to `height / 20`. - * @param {Number} [top] y-coordinate of the frustum’s top plane. Defaults to `-height / 20`. - * @param {Number} [near] z-coordinate of the frustum’s near plane. Defaults to `0.1 * 800`. - * @param {Number} [far] z-coordinate of the frustum’s far plane. Defaults to `10 * 800`. - * @chainable + * @property {Number} upX + * @for p5.Camera + * @readonly * * @example *
    * - * function setup() { + * let cam; + * let font; + * + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * describe('A row of white cubes on a gray background.'); + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-right: (100, -400, 800) + * // Point it at the origin: (0, 0, 0) + * // Set its "up" vector: (0, 1, 0). + * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); + * + * describe( + * 'A white cube on a gray background. The text "upX: 0" is written in black beneath it.' + * ); * } * * function draw() { * background(200); * - * // Apply the default frustum projection. - * frustum(); + * // Style the box. + * fill(255); * - * // Translate the origin toward the camera. - * translate(-10, 10, 600); + * // Draw the box. + * box(); * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -40); - * box(10); - * } + * // Display the value of upX, rounded to the nearest tenth. + * text(`upX: ${round(cam.upX, 1)}`, 0, 45); * } * *
    * *
    * - * function setup() { + * let cam; + * let font; + * + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); - * describe('A white cube on a gray background.'); + * + * // Create a p5.Camera object. + * cam = createCamera(); + * + * // Set the camera + * setCamera(cam); + * + * // Place the camera at the top-right: (100, -400, 800) + * // Point it at the origin: (0, 0, 0) + * // Set its "up" vector: (0, 1, 0). + * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); + * + * describe( + * 'A white cube on a gray background. The cube appears to rock back and forth. The text "upX: X" is written in black beneath it. X oscillates between -1 and 1.' + * ); * } * * function draw() { * background(200); * - * // Adjust the frustum. - * // Center it. - * // Set its width and height to 20 pixels. - * // Place its near plane 300 pixels from the camera. - * // Place its far plane 350 pixels from the camera. - * frustum(-10, 10, -10, 10, 300, 350); + * // Style the box. + * fill(255); * - * // Translate the origin toward the camera. - * translate(-10, 10, 600); + * // Draw the box. + * box(); * - * // Rotate the coordinate system. - * rotateY(-0.1); - * rotateX(-0.1); + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); * - * // Draw the row of boxes. - * for (let i = 0; i < 6; i += 1) { - * translate(0, 0, -40); - * box(10); - * } + * // Calculate the x-component. + * let x = sin(frameCount * 0.01); + * + * // Update the camera's "up" vector. + * cam.camera(100, -400, 800, 0, 0, 0, x, 1, 0); + * + * // Display the value of upX, rounded to the nearest tenth. + * text(`upX: ${round(cam.upX, 1)}`, 0, 45); * } * *
    */ - fn.frustum = function (...args) { - this._assert3d('frustum'); - // p5._validateParameters('frustum', args); - this._renderer.frustum(...args); - return this; - }; /** - * Creates a new p5.Camera object. - * - * The new camera is initialized with a default position `(0, 0, 800)` and a - * default perspective projection. Its properties can be controlled with - * p5.Camera methods such as - * `myCamera.lookAt(0, 0, 0)`. - * - * Note: Every 3D sketch starts with a default camera initialized. - * This camera can be controlled with the functions - * camera(), - * perspective(), - * ortho(), and - * frustum() if it's the only camera in the scene. + * The y-component of the camera's "up" vector. * - * Note: `createCamera()` can only be used in WebGL mode. + * The camera's "up" vector orients its y-axis. By default, the "up" vector is + * `(0, 1, 0)`, so its y-component is 1 in "local" space. * - * @method createCamera - * @return {p5.Camera} the new camera. - * @for p5 + * @property {Number} upY + * @for p5.Camera + * @readonly * * @example *
    * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let usingCam1 = true; + * let cam; + * let font; * - * function setup() { + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); + * // Create a p5.Camera object. + * cam = createCamera(); * - * // Create the second camera. - * // Place it at the top-left. - * // Point it at the origin. - * cam2 = createCamera(); - * cam2.setPosition(400, -400, 800); - * cam2.lookAt(0, 0, 0); + * // Set the camera + * setCamera(cam); * - * // Set the current camera to cam1. - * setCamera(cam1); + * // Place the camera at the top-right: (100, -400, 800) + * // Point it at the origin: (0, 0, 0) + * // Set its "up" vector: (0, 1, 0). + * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); * - * describe('A white cube on a gray background. The camera toggles between frontal and aerial views when the user double-clicks.'); + * describe( + * 'A white cube on a gray background. The text "upY: 1" is written in black beneath it.' + * ); * } * * function draw() { * background(200); * - * // Draw the box. - * box(); - * } - * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (usingCam1 === true) { - * setCamera(cam2); - * usingCam1 = false; - * } else { - * setCamera(cam1); - * usingCam1 = true; - * } - * } - * - *
    - */ - fn.createCamera = function () { - this._assert3d('createCamera'); - - return this._renderer.createCamera(); - }; - - /** - * Sets the current (active) camera of a 3D sketch. - * - * `setCamera()` allows for switching between multiple cameras created with - * createCamera(). + * // Style the box. + * fill(255); * - * Note: `setCamera()` can only be used in WebGL mode. + * // Draw the box. + * box(); * - * @method setCamera - * @param {p5.Camera} cam camera that should be made active. - * @for p5 + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Display the value of upY, rounded to the nearest tenth. + * text(`upY: ${round(cam.upY, 1)}`, 0, 45); + * } + *
    + *
    * - * @example *
    * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let usingCam1 = true; + * let cam; + * let font; * - * function setup() { + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); + * // Create a p5.Camera object. + * cam = createCamera(); * - * // Create the second camera. - * // Place it at the top-left. - * // Point it at the origin. - * cam2 = createCamera(); - * cam2.setPosition(400, -400, 800); - * cam2.lookAt(0, 0, 0); + * // Set the camera + * setCamera(cam); * - * // Set the current camera to cam1. - * setCamera(cam1); + * // Place the camera at the top-right: (100, -400, 800) + * // Point it at the origin: (0, 0, 0) + * // Set its "up" vector: (0, 1, 0). + * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); * - * describe('A white cube on a gray background. The camera toggles between frontal and aerial views when the user double-clicks.'); + * describe( + * 'A white cube on a gray background. The cube flips upside-down periodically. The text "upY: Y" is written in black beneath it. Y oscillates between -1 and 1.' + * ); * } * * function draw() { * background(200); * + * // Style the box. + * fill(255); + * * // Draw the box. * box(); - * } * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (usingCam1 === true) { - * setCamera(cam2); - * usingCam1 = false; - * } else { - * setCamera(cam1); - * usingCam1 = true; - * } + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Calculate the y-component. + * let y = sin(frameCount * 0.01); + * + * // Update the camera's "up" vector. + * cam.camera(100, -400, 800, 0, 0, 0, 0, y, 0); + * + * // Display the value of upY, rounded to the nearest tenth. + * text(`upY: ${round(cam.upY, 1)}`, 0, 45); * } * *
    */ - fn.setCamera = function (cam) { - this._renderer.setCamera(cam); - }; /** - * A class to describe a camera for viewing a 3D sketch. - * - * Each `p5.Camera` object represents a camera that views a section of 3D - * space. It stores information about the camera’s position, orientation, and - * projection. - * - * In WebGL mode, the default camera is a `p5.Camera` object that can be - * controlled with the camera(), - * perspective(), - * ortho(), and - * frustum() functions. Additional cameras can be - * created with createCamera() and activated - * with setCamera(). + * The z-component of the camera's "up" vector. * - * Note: `p5.Camera`’s methods operate in two coordinate systems: - * - The “world” coordinate system describes positions in terms of their - * relationship to the origin along the x-, y-, and z-axes. For example, - * calling `myCamera.setPosition()` places the camera in 3D space using - * "world" coordinates. - * - The "local" coordinate system describes positions from the camera's point - * of view: left-right, up-down, and forward-backward. For example, calling - * `myCamera.move()` moves the camera along its own axes. + * The camera's "up" vector orients its y-axis. By default, the "up" vector is + * `(0, 1, 0)`, so its z-component is 0 in "local" space. * - * @class p5.Camera - * @constructor - * @param {RendererGL} rendererGL instance of WebGL renderer + * @property {Number} upZ + * @for p5.Camera + * @readonly * * @example *
    * * let cam; - * let delta = 0.001; + * let font; * - * function setup() { + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * * // Create a p5.Camera object. @@ -3865,130 +3915,90 @@ function camera(p5, fn){ * // Set the camera * setCamera(cam); * - * // Place the camera at the top-center. - * cam.setPosition(0, -400, 800); - * - * // Point the camera at the origin. - * cam.lookAt(0, 0, 0); + * // Place the camera at the top-right: (100, -400, 800) + * // Point it at the origin: (0, 0, 0) + * // Set its "up" vector: (0, 1, 0). + * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); * * describe( - * 'A white cube on a gray background. The cube goes in and out of view as the camera pans left and right.' + * 'A white cube on a gray background. The text "upZ: 0" is written in black beneath it.' * ); * } * * function draw() { * background(200); * - * // Turn the camera left and right, called "panning". - * cam.pan(delta); - * - * // Switch directions every 120 frames. - * if (frameCount % 120 === 0) { - * delta *= -1; - * } + * // Style the box. + * fill(255); * * // Draw the box. * box(); + * + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Display the value of upZ, rounded to the nearest tenth. + * text(`upZ: ${round(cam.upZ, 1)}`, 0, 45); * } * *
    * *
    * - * // Double-click to toggle between cameras. - * - * let cam1; - * let cam2; - * let isDefaultCamera = true; + * let cam; + * let font; * - * function setup() { + * async function setup() { + * // Load a font and create a p5.Font object. + * font = await loadFont('assets/inconsolata.otf'); * createCanvas(100, 100, WEBGL); * - * // Create the first camera. - * // Keep its default settings. - * cam1 = createCamera(); + * // Create a p5.Camera object. + * cam = createCamera(); * - * // Create the second camera. - * // Place it at the top-left. - * // Point it at the origin. - * cam2 = createCamera(); - * cam2.setPosition(400, -400, 800); - * cam2.lookAt(0, 0, 0); + * // Set the camera + * setCamera(cam); * - * // Set the current camera to cam1. - * setCamera(cam1); + * // Place the camera at the top-right: (100, -400, 800) + * // Point it at the origin: (0, 0, 0) + * // Set its "up" vector: (0, 1, 0). + * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, 0); * * describe( - * 'A white cube on a gray background. The camera toggles between frontal and aerial views when the user double-clicks.' + * 'A white cube on a gray background. The cube appears to rock back and forth. The text "upZ: Z" is written in black beneath it. Z oscillates between -1 and 1.' * ); * } * * function draw() { * background(200); * + * // Style the box. + * fill(255); + * * // Draw the box. * box(); - * } * - * // Toggle the current camera when the user double-clicks. - * function doubleClicked() { - * if (isDefaultCamera === true) { - * setCamera(cam2); - * isDefaultCamera = false; - * } else { - * setCamera(cam1); - * isDefaultCamera = true; - * } + * // Style the text. + * textAlign(CENTER); + * textSize(16); + * textFont(font); + * fill(0); + * + * // Calculate the z-component. + * let z = sin(frameCount * 0.01); + * + * // Update the camera's "up" vector. + * cam.camera(100, -400, 800, 0, 0, 0, 0, 1, z); + * + * // Display the value of upZ, rounded to the nearest tenth. + * text(`upZ: ${round(cam.upZ, 1)}`, 0, 45); * } * *
    */ - p5.Camera = Camera; - - RendererGL.prototype.camera = function(...args) { - this.states.curCamera.camera(...args); - }; - - RendererGL.prototype.perspective = function(...args) { - this.states.curCamera.perspective(...args); - }; - - RendererGL.prototype.linePerspective = function(enable) { - if (enable !== undefined) { - // Set the line perspective if enable is provided - this.states.curCamera.useLinePerspective = enable; - } else { - // If no argument is provided, return the current value - return this.states.curCamera.useLinePerspective; - } - }; - - RendererGL.prototype.ortho = function(...args) { - this.states.curCamera.ortho(...args); - }; - - RendererGL.prototype.frustum = function(...args) { - this.states.curCamera.frustum(...args); - }; - - RendererGL.prototype.createCamera = function() { - // compute default camera settings, then set a default camera - const _cam = new Camera(this); - _cam._computeCameraDefaultSettings(); - _cam._setDefaultCamera(); - - return _cam; - }; - - RendererGL.prototype.setCamera = function(cam) { - this.states.setValue('curCamera', cam); - - // set the projection matrix (which is not normally updated each frame) - this.states.setValue('uPMatrix', this.states.uPMatrix.clone()); - this.states.uPMatrix.set(cam.projMatrix); - this.states.setValue('uViewMatrix', this.states.uViewMatrix.clone()); - this.states.uViewMatrix.set(cam.cameraMatrix); - }; } export default camera; From b277ae537210ae17966576dae0d1f5a0645bf011 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 29 Sep 2025 12:42:38 -0400 Subject: [PATCH 28/42] Add more specific elt types for renderer and mediaelement --- utils/patch.mjs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/utils/patch.mjs b/utils/patch.mjs index 3dceac7f36..d6b9743463 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -48,7 +48,23 @@ export function applyPatches() { 'p5.d.ts', 'textToContours(str: string, x: number, y: number, options?: { sampleFactor?: number; simplifyThreshold?: number }): object[][];', 'textToContours(str: string, x: number, y: number, options?: { sampleFactor?: number; simplifyThreshold?: number }): { x: number; y: number; alpha: number }[][];', - ) + ); + + replace( + 'p5.d.ts', + 'class Renderer extends Element {}', + `class Renderer extends Element { + elt: HTMLCanvasElement; + }` + ); + + replace( + 'p5.d.ts', + 'class MediaElement extends p5.Element {', + `class MediaElement extends Element { + elt: HTMLAudioElement | HTMLVideoElement; + ` + ); for (const [path, data] of Object.entries(patched)) { try { From 3f6b8ef5ab3f88d3aa9a7db2c89331323b53ad5b Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 29 Sep 2025 12:54:46 -0400 Subject: [PATCH 29/42] Fix missing framebuffer.loadPixels --- docs/parameterData.json | 5 +++++ src/webgl/p5.Framebuffer.js | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index 978f65f54e..30ff1cc6ac 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -4537,6 +4537,11 @@ ] ] }, + "loadPixels": { + "overloads": [ + [] + ] + }, "get": { "overloads": [ [ diff --git a/src/webgl/p5.Framebuffer.js b/src/webgl/p5.Framebuffer.js index ace7d6a8f7..a3d6875068 100644 --- a/src/webgl/p5.Framebuffer.js +++ b/src/webgl/p5.Framebuffer.js @@ -1308,8 +1308,6 @@ class Framebuffer { * `myBuffer.loadPixels()` must be called before reading from or writing to * myBuffer.pixels. * - * @method loadPixels - * * @example *
    * From 446b0700cb80d3944a659378555b9a00c76636e7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Mon, 29 Sep 2025 13:00:58 -0400 Subject: [PATCH 30/42] Also type elt on p5.Graphics --- utils/patch.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/utils/patch.mjs b/utils/patch.mjs index d6b9743463..fed92db9f8 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -66,6 +66,14 @@ export function applyPatches() { ` ); + replace( + 'p5.d.ts', + 'class __Graphics extends p5.Element {', + `class __Graphics extends p5.Element { + elt: HTMLCanvasElement; + `, + ); + for (const [path, data] of Object.entries(patched)) { try { console.log(`Patched ${path}`); From 2b1e0adacb8c108057d5d52aa5c72031e34e810b Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 30 Sep 2025 14:40:09 -0400 Subject: [PATCH 31/42] Fix typo in webgl constant def --- src/core/constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/constants.js b/src/core/constants.js index ea5b40d32d..c203106730 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -57,7 +57,7 @@ export const P2DHDR = 'p2d-hdr'; * * To learn more about WEBGL mode, check out all the interactive WEBGL tutorials in the "Tutorials" section of this website, or read the wiki article "Getting started with WebGL in p5". * - * @typedef {'webgl2'} WEBGL + * @typedef {'webgl'} WEBGL * @property {WEBGL} WEBGL * @final */ From ff2f8afee0e8452e5ec86d609ad33b6785b53511 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Tue, 30 Sep 2025 14:48:35 -0400 Subject: [PATCH 32/42] Add gid property on geometry --- src/webgl/p5.Geometry.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/webgl/p5.Geometry.js b/src/webgl/p5.Geometry.js index f1c673f117..a5691e7854 100644 --- a/src/webgl/p5.Geometry.js +++ b/src/webgl/p5.Geometry.js @@ -2550,6 +2550,13 @@ function geometry(p5, fn){ * *
    */ + + /** + * A unique identifier for this geometry. The renderer will use this to cache resources. + * + * @property {String} gid + * @for p5.Geometry + */ } export default geometry; From eb7b7dfe1da4e1673a89c6b40462d6a29af03153 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Wed, 1 Oct 2025 08:24:16 -0400 Subject: [PATCH 33/42] Fix createVideo,Audio,Capture being treated as static --- docs/parameterData.json | 50 +++++++++++++++++++------------------- src/dom/p5.MediaElement.js | 3 +++ 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index 30ff1cc6ac..be7c64a740 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -300,31 +300,6 @@ ] ] }, - "createVideo": { - "overloads": [ - [ - "String|String[]", - "Function?" - ] - ] - }, - "createAudio": { - "overloads": [ - [ - "String|String[]?", - "Function?" - ] - ] - }, - "createCapture": { - "overloads": [ - [ - "AUDIO|VIDEO|Object?", - "Object?", - "Function?" - ] - ] - }, "cursor": { "overloads": [ [ @@ -795,6 +770,31 @@ ] ] }, + "createVideo": { + "overloads": [ + [ + "String|String[]", + "Function?" + ] + ] + }, + "createAudio": { + "overloads": [ + [ + "String|String[]?", + "Function?" + ] + ] + }, + "createCapture": { + "overloads": [ + [ + "AUDIO|VIDEO|Object?", + "Object?", + "Function?" + ] + ] + }, "setMoveThreshold": { "overloads": [ [ diff --git a/src/dom/p5.MediaElement.js b/src/dom/p5.MediaElement.js index ab851fce06..11bcf6d144 100644 --- a/src/dom/p5.MediaElement.js +++ b/src/dom/p5.MediaElement.js @@ -1399,6 +1399,7 @@ function media(p5, fn){ * The second parameter, `callback`, is optional. It's a function to call once * the video is ready to play. * + * @method createVideo * @param {String|String[]} src path to a video file, or an array of paths for * supporting different browsers. * @param {Function} [callback] function to call once the video is ready to play. @@ -1494,6 +1495,7 @@ function media(p5, fn){ * The second parameter, `callback`, is optional. It's a function to call once * the audio is ready to play. * + * @method createAudio * @param {String|String[]} [src] path to an audio file, or an array of paths * for supporting different browsers. * @param {Function} [callback] function to call once the audio is ready to play. @@ -1587,6 +1589,7 @@ function media(p5, fn){ * here * and here. * + * @method createCapture * @param {(AUDIO|VIDEO|Object)} [type] type of capture, either AUDIO or VIDEO, * or a constraints object. Both video and audio * audio streams are captured by default. From f5b6903de8bde2c5a5ffd5cdd7952dd1f0032d9c Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Wed, 1 Oct 2025 08:37:58 -0400 Subject: [PATCH 34/42] Fix issues in createVideo/Audio docs + types --- docs/parameterData.json | 2 +- src/dom/p5.MediaElement.js | 12 ++++++------ utils/patch.mjs | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index be7c64a740..7a99ea14ae 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -773,7 +773,7 @@ "createVideo": { "overloads": [ [ - "String|String[]", + "String|String[]?", "Function?" ] ] diff --git a/src/dom/p5.MediaElement.js b/src/dom/p5.MediaElement.js index 11bcf6d144..98c148273b 100644 --- a/src/dom/p5.MediaElement.js +++ b/src/dom/p5.MediaElement.js @@ -1400,7 +1400,7 @@ function media(p5, fn){ * the video is ready to play. * * @method createVideo - * @param {String|String[]} src path to a video file, or an array of paths for + * @param {String|String[]} [src] path to a video file, or an array of paths for * supporting different browsers. * @param {Function} [callback] function to call once the video is ready to play. * @return {p5.MediaElement} new p5.MediaElement object. @@ -1483,11 +1483,11 @@ function media(p5, fn){ * `createAudio()` returns a new * p5.MediaElement object. * - * The first parameter, `src`, is the path the video. If a single string is - * passed, as in `'assets/video.mp4'`, a single video is loaded. An array - * of strings can be used to load the same video in different formats. For - * example, `['assets/video.mp4', 'assets/video.ogv', 'assets/video.webm']`. - * This is useful for ensuring that the video can play across different + * The first parameter, `src`, is the path the audio. If a single string is + * passed, as in `'assets/audio.mp3'`, a single audio is loaded. An array + * of strings can be used to load the same audio in different formats. For + * example, `['assets/audio.mp3', 'assets/video.wav']`. + * This is useful for ensuring that the audio can play across different * browsers with different capabilities. See * MDN * for more information about supported formats. diff --git a/utils/patch.mjs b/utils/patch.mjs index fed92db9f8..80ee3ba10d 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -74,6 +74,25 @@ export function applyPatches() { `, ); + // Type .elt more specifically for audio and video elements + replace( + 'p5.d.ts', + `class MediaElement extends Element { + elt: HTMLAudioElement | HTMLVideoElement;`, + `class MediaElement extends Element { + elt: T;`, + ); + replace( + ['p5.d.ts', 'global.d.ts'], + /createAudio\(src\?: string \| string\[\], callback\?: Function\): ([pP]5)\.MediaElement;/g, + 'createAudio(src?: string | string[], callback?: (video: $1.MediaElement) => any): $1.MediaElement;', + ); + replace( + ['p5.d.ts', 'global.d.ts'], + /createVideo\(src\?: string \| string\[\], callback\?: Function\): ([pP]5)\.MediaElement;/g, + 'createVideo(src?: string | string[], callback?: (video: $1.MediaElement) => any): $1.MediaElement;', + ); + for (const [path, data] of Object.entries(patched)) { try { console.log(`Patched ${path}`); From 27c8edd79b975abcc304fd9f0bf29490b34c2f2a Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Wed, 1 Oct 2025 09:02:45 -0400 Subject: [PATCH 35/42] Add typings for returned object properties --- docs/parameterData.json | 3 +-- src/dom/p5.MediaElement.js | 11 +++-------- utils/patch.mjs | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index 7a99ea14ae..69a0d8a38c 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -3505,9 +3505,8 @@ }, "time": { "overloads": [ - [], [ - "Number" + "Number?" ] ] }, diff --git a/src/dom/p5.MediaElement.js b/src/dom/p5.MediaElement.js index 98c148273b..49f09dfa50 100644 --- a/src/dom/p5.MediaElement.js +++ b/src/dom/p5.MediaElement.js @@ -628,6 +628,7 @@ class MediaElement extends Element { * * Note: Time resets to 0 when looping media restarts. * + * @param {Number} [time] time to jump to (in seconds). * @return {Number} current time (in seconds). * * @example @@ -704,17 +705,11 @@ class MediaElement extends Element { * * */ - /** - * @param {Number} time time to jump to (in seconds). - * @chainable - */ time(val) { - if (typeof val === 'undefined') { - return this.elt.currentTime; - } else { + if (typeof val !== 'undefined') { this.elt.currentTime = val; - return this; } + return this.elt.currentTime; } /** diff --git a/utils/patch.mjs b/utils/patch.mjs index 80ee3ba10d..4eb9dd4a1c 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -93,6 +93,23 @@ export function applyPatches() { 'createVideo(src?: string | string[], callback?: (video: $1.MediaElement) => any): $1.MediaElement;', ); + // Type returned objects + replace( + 'p5.d.ts', + 'calculateBoundingBox(): object;', + 'calculateBoundingBox(): { min: p5.Vector; max: p5.Vector; size: p5.Vector; offset: p5.Vector };' + ); + replace( + 'p5.d.ts', + 'fontBounds(str: string, x: number, y: number, width?: number, height?: number): object;', + 'fontBounds(str: string, x: number, y: number, width?: number, height?: number): { x: number; y: number; w: number; h: number };', + ); + replace( + 'p5.d.ts', + 'textBounds(str: string, x: number, y: number, width?: number, height?: number): object;', + 'textBounds(str: string, x: number, y: number, width?: number, height?: number): { x: number; y: number; w: number; h: number };', + ); + for (const [path, data] of Object.entries(patched)) { try { console.log(`Patched ${path}`); From f1afc0df301eda8d6105c4ecd82ffd6a3dd2decc Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Wed, 1 Oct 2025 09:29:19 -0400 Subject: [PATCH 36/42] Fix typing of p5.FileInput and p5.File --- src/dom/dom.js | 2 +- src/dom/p5.File.js | 12 ++++++------ utils/patch.mjs | 7 +++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/dom/dom.js b/src/dom/dom.js index 48144c9198..d9dd5c9e9e 100644 --- a/src/dom/dom.js +++ b/src/dom/dom.js @@ -1871,7 +1871,7 @@ function dom(p5, fn){ * @method createFileInput * @param {Function} callback function to call once the file loads. * @param {Boolean} [multiple] allow multiple files to be selected. - * @return {p5.File} new p5.File object. + * @return {p5.Element} The new input element. * * @example *
    diff --git a/src/dom/p5.File.js b/src/dom/p5.File.js index 7852d4f435..7c3f182635 100644 --- a/src/dom/p5.File.js +++ b/src/dom/p5.File.js @@ -155,7 +155,7 @@ function file(p5, fn){ * object. All `File` properties and methods are accessible. * * @for p5.File - * @property file + * @property {File} file * @example *
    * @@ -200,7 +200,7 @@ function file(p5, fn){ * For example, `'image'` and `'text'` are both MIME types. * * @for p5.File - * @property type + * @property {String} type * @example *
    * @@ -237,7 +237,7 @@ function file(p5, fn){ * MIME type * may have a subtype such as ``png`` or ``jpeg``. * - * @property subtype + * @property {String} subtype * @for p5.File * * @example @@ -273,7 +273,7 @@ function file(p5, fn){ /** * The file name as a string. * - * @property name + * @property {String} name * @for p5.File * * @example @@ -309,7 +309,7 @@ function file(p5, fn){ /** * The number of bytes in the file. * - * @property size + * @property {Number} size * @for p5.File * * @example @@ -347,7 +347,7 @@ function file(p5, fn){ * Data can be either image data, text contents, or a parsed object in the * case of JSON and p5.XML objects. * - * @property data + * @property {any} data * @for p5.File * * @example diff --git a/utils/patch.mjs b/utils/patch.mjs index 4eb9dd4a1c..191cc60654 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -93,6 +93,13 @@ export function applyPatches() { 'createVideo(src?: string | string[], callback?: (video: $1.MediaElement) => any): $1.MediaElement;', ); + // More callback types + replace( + ['p5.d.ts', 'global.d.ts'], + /createFileInput\(callback: Function, multiple\?: boolean\): ([pP]5)\.Element;/g, + 'createFileInput(callback: (input: $1.File) => any, multiple?: boolean): $1.Element;', + ); + // Type returned objects replace( 'p5.d.ts', From d90e3a2a3e4ae5ae052b1d76c9e4cf1b17ec9098 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Wed, 1 Oct 2025 09:38:09 -0400 Subject: [PATCH 37/42] Add type for loadFont callback --- utils/patch.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/utils/patch.mjs b/utils/patch.mjs index 191cc60654..2784a089be 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -99,6 +99,11 @@ export function applyPatches() { /createFileInput\(callback: Function, multiple\?: boolean\): ([pP]5)\.Element;/g, 'createFileInput(callback: (input: $1.File) => any, multiple?: boolean): $1.Element;', ); + replace( + ['p5.d.ts', 'global.d.ts'], + /loadFont\((.+), successCallback\?: Function, (.+)\): Promise\<([pP]5)\.Font\>;/g, + 'loadFont($1, successCallback: (font: $3.Font) => any, $2): Promise<$3.Font>;' + ); // Type returned objects replace( From e36d48108f22417de07b3298a40ddc48ca5cf3c8 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Wed, 1 Oct 2025 09:48:30 -0400 Subject: [PATCH 38/42] Document Typr, fix missing optional --- utils/patch.mjs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/utils/patch.mjs b/utils/patch.mjs index 2784a089be..277d7808a8 100644 --- a/utils/patch.mjs +++ b/utils/patch.mjs @@ -102,7 +102,7 @@ export function applyPatches() { replace( ['p5.d.ts', 'global.d.ts'], /loadFont\((.+), successCallback\?: Function, (.+)\): Promise\<([pP]5)\.Font\>;/g, - 'loadFont($1, successCallback: (font: $3.Font) => any, $2): Promise<$3.Font>;' + 'loadFont($1, successCallback?: (font: $3.Font) => any, $2): Promise<$3.Font>;' ); // Type returned objects @@ -122,6 +122,39 @@ export function applyPatches() { 'textBounds(str: string, x: number, y: number, width?: number, height?: number): { x: number; y: number; w: number; h: number };', ); + // Document Typr + replace( + 'p5.d.ts', + 'class Font {', + `class Font { + /** The CSS name for the font. */ + name: string; + + /** The CSS FontFace definition for the font. */ + face: FontFace; + + /** Typr data for the font. */ + data?: { + _data: Uint8Array; + GSUB: Record; + 'OS/2': Record; + cmap: { + ids: Record; + tables: Array>; + off: number; + }; + glyf: Array; + head: Record; + hhea: Record; + htmx: Record; + loca: Array; + maxp: Record; + name: Record; + post: Record; + }; + ` + ); + for (const [path, data] of Object.entries(patched)) { try { console.log(`Patched ${path}`); From a4254a08ad094eefb1e43c8f514a58ba974cd0a8 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Wed, 1 Oct 2025 10:30:08 -0400 Subject: [PATCH 39/42] Fix colorMode --- docs/parameterData.json | 3 ++- src/color/setting.js | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/parameterData.json b/docs/parameterData.json index 69a0d8a38c..12241d5307 100644 --- a/docs/parameterData.json +++ b/docs/parameterData.json @@ -211,7 +211,8 @@ "Number", "Number", "Number?" - ] + ], + [] ] }, "fill": { diff --git a/src/color/setting.js b/src/color/setting.js index 2581ddb213..e60af75c3f 100644 --- a/src/color/setting.js +++ b/src/color/setting.js @@ -821,7 +821,7 @@ function setting(p5, fn){ * @param {RGB|HSB|HSL|RGBHDR|HWB|LAB|LCH|OKLAB|OKLCH} mode either RGB, HSB, HSL, * or one of the extended modes described above. * @param {Number} [max] range for all values. - * @chainable + * @return {RGB|HSB|HSL|RGBHDR|HWB|LAB|LCH|OKLAB|OKLCH} The current color mode. * * @example *
    @@ -1161,7 +1161,6 @@ function setting(p5, fn){ * *
    */ - /** * @method colorMode * @param {RGB|HSB|HSL|RGBHDR|HWB|LAB|LCH|OKLAB|OKLCH} mode @@ -1173,7 +1172,11 @@ function setting(p5, fn){ * depending on the current color mode. * @param {Number} [maxA] range for the alpha. * - * @return {String} The current color mode. + * @return {RGB|HSB|HSL|RGBHDR|HWB|LAB|LCH|OKLAB|OKLCH} The current color mode. + */ + /** + * @method colorMode + * @return {RGB|HSB|HSL|RGBHDR|HWB|LAB|LCH|OKLAB|OKLCH} The current color mode. */ fn.colorMode = function(mode, max1, max2, max3, maxA) { // p5._validateParameters('colorMode', arguments); From dabf86e9f24360de72dd8b29f6f6d2377cea93ad Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 2 Oct 2025 09:18:43 -0400 Subject: [PATCH 40/42] Add strands methods --- src/strands/strands_builtins.js | 10 +- utils/typescript.mjs | 273 ++++++++++++++++++++++---------- 2 files changed, 198 insertions(+), 85 deletions(-) diff --git a/src/strands/strands_builtins.js b/src/strands/strands_builtins.js index 3eb76c8ff6..eccfc74170 100644 --- a/src/strands/strands_builtins.js +++ b/src/strands/strands_builtins.js @@ -1,4 +1,6 @@ -import { GenType, DataType } from "./ir_types" +// Need the .js extension because we also import this from a Node script. +// Try to keep this file minimal because of that. +import { GenType, DataType } from "./ir_types.js" // GLSL Built in functions // https://docs.gl/el3/abs @@ -83,7 +85,7 @@ const builtInGLSLFunctions = { sqrt: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}], step: [{ params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}], trunc: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}], - + ////////// Vector ////////// cross: [{ params: [DataType.float3, DataType.float3], returnType: DataType.float3, isp5Function: true}], distance: [{ params: [GenType.FLOAT, GenType.FLOAT], returnType:DataType.float1, isp5Function: true}], @@ -103,7 +105,7 @@ const builtInGLSLFunctions = { ], reflect: [{ params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}], refract: [{ params: [GenType.FLOAT, GenType.FLOAT,DataType.float1], returnType: GenType.FLOAT, isp5Function: false}], - + ////////// Texture sampling ////////// texture: [{params: [DataType.sampler2D, DataType.float2], returnType: DataType.float4, isp5Function: true}], getTexture: [{params: [DataType.sampler2D, DataType.float2], returnType: DataType.float4, isp5Function: true}] @@ -111,4 +113,4 @@ const builtInGLSLFunctions = { export const strandsBuiltinFunctions = { ...builtInGLSLFunctions, -} \ No newline at end of file +} diff --git a/utils/typescript.mjs b/utils/typescript.mjs index 317fa530eb..0f99c8b64d 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -4,6 +4,8 @@ import { fileURLToPath } from 'url'; import { processData } from './data-processor.mjs'; import { descriptionStringForTypeScript } from './shared-helpers.mjs'; import { applyPatches } from './patch.mjs'; +import { strandsBuiltinFunctions as builtInGLSLFunctions } from '../src/strands/strands_builtins.js'; +import { DataType } from '../src/strands/ir_types.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -32,21 +34,109 @@ allRawData.forEach(entry => { } }); +// Process strands functions to extract p5 methods +function processStrandsFunctions() { + const strandsMethods = []; + + // Add ALL GLSL builtin functions (both isp5Function: true and false) + for (const [functionName, overloads] of Object.entries(builtInGLSLFunctions)) { + // Create method definition with simplified any types for all overloads + const method = { + name: functionName, + overloads: overloads.map(overload => ({ + params: overload.params.map((paramType, index) => ({ + name: `param${index}`, + type: { type: 'NameExpression', name: 'any' }, // Use 'any' for strands node types + optional: false + })), + return: { + type: { type: 'NameExpression', name: 'any' } // Return 'any' for strands nodes + } + })), + description: `GLSL built-in function ${functionName}`, + static: false + }; + + strandsMethods.push(method); + } + + // Add uniform functions: uniformFloat, uniformVec2, etc. + const uniformMethods = []; + for (const type in DataType) { + if (type === 'defer') { + continue; + } + + const typeInfo = DataType[type]; + let pascalTypeName; + + if (/^[ib]vec/.test(typeInfo.fnName)) { + pascalTypeName = typeInfo.fnName + .slice(0, 2).toUpperCase() + + typeInfo.fnName + .slice(2) + .toLowerCase(); + } else { + pascalTypeName = typeInfo.fnName.charAt(0).toUpperCase() + + typeInfo.fnName.slice(1).toLowerCase(); + } + + const uniformMethodName = `uniform${pascalTypeName}`; + const uniformMethod = { + name: uniformMethodName, + overloads: [{ + params: [ + { + name: 'name', + type: { type: 'NameExpression', name: 'String' }, + optional: false + }, + { + name: 'defaultValue', + type: { type: 'NameExpression', name: 'any' }, + optional: true + } + ], + return: { + type: { type: 'NameExpression', name: 'any' } + } + }], + description: `Create a ${pascalTypeName} uniform variable`, + static: false + }; + + uniformMethods.push(uniformMethod); + + // Add Vector aliases for Vec types + if (pascalTypeName.startsWith('Vec')) { + const vectorMethodName = `uniform${pascalTypeName.replace('Vec', 'Vector')}`; + const vectorMethod = { + ...uniformMethod, + name: vectorMethodName, + description: `Create a ${pascalTypeName.replace('Vec', 'Vector')} uniform variable` + }; + uniformMethods.push(vectorMethod); + } + } + + return [...strandsMethods, ...uniformMethods]; +} + // TypeScript-specific type conversion from raw type objects function convertTypeToTypeScript(typeNode, options = {}) { if (!typeNode) return 'any'; - + // Validate that typeNode is always an object if (typeof typeNode !== 'object' || Array.isArray(typeNode)) { throw new Error(`convertTypeToTypeScript expects an object, got: ${typeof typeNode} - ${JSON.stringify(typeNode)}`); } - + const { currentClass = null, isInsideNamespace = false, inGlobalMode = false, isConstantDef = false } = options; - + switch (typeNode.type) { case 'NameExpression': { const typeName = typeNode.name; - + // Handle primitive types const primitiveTypes = { 'String': 'string', @@ -63,16 +153,16 @@ function convertTypeToTypeScript(typeNode, options = {}) { 'Event': 'Event', 'Request': 'Request' }; - + if (primitiveTypes[typeName]) { return primitiveTypes[typeName]; } - + // Handle self-referential types within the same class if (currentClass && (typeName === `p5.${currentClass}` || typeName === currentClass)) { return currentClass; } - + // If we're inside the p5 namespace, remove p5. prefix from other p5 classes if (isInsideNamespace && typeName.startsWith('p5.')) { if (inGlobalMode) { @@ -81,7 +171,7 @@ function convertTypeToTypeScript(typeNode, options = {}) { return typeName.substring(3); } } - + // Check if this is a p5 constant - use typeof since they're defined as values if (constantsLookup.has(typeName)) { if (inGlobalMode) { @@ -96,61 +186,61 @@ function convertTypeToTypeScript(typeNode, options = {}) { return `Symbol`; } } - + return typeName; } - + case 'TypeApplication': { const baseTypeName = typeNode.expression.name; - + if (baseTypeName === 'Array' && typeNode.applications.length === 1) { const innerType = convertTypeToTypeScript(typeNode.applications[0], options); return `${innerType}[]`; } - + // For generic types, use the base type name directly to avoid double conversion const typeParams = typeNode.applications .map(app => convertTypeToTypeScript(app, options)) .join(', '); return `${baseTypeName}<${typeParams}>`; } - + case 'UnionType': { const unionTypes = typeNode.elements .map(el => convertTypeToTypeScript(el, options)) .join(' | '); return unionTypes; } - + case 'OptionalType': return convertTypeToTypeScript(typeNode.expression, options); - + case 'AllLiteral': return 'any'; - + case 'RecordType': return 'object'; - + case 'NumericLiteralType': return `${typeNode.value}`; - + case 'StringLiteralType': return `'${typeNode.value}'`; - + case 'NullLiteral': return 'null'; - + case 'UndefinedLiteral': return 'undefined'; - + case 'ArrayType': { const innerTypes = typeNode.elements.map(e => convertTypeToTypeScript(e, options)); return `[${innerTypes.join(', ')}]`; } - + case 'RestType': return `${convertTypeToTypeScript(typeNode.expression, options)}[]`; - + case 'FunctionType': { const params = (typeNode.params || []) .map((param, i) => { @@ -158,13 +248,13 @@ function convertTypeToTypeScript(typeNode, options = {}) { return `arg${i}: ${paramType}`; }) .join(', '); - + const returnType = typeNode.result ? convertTypeToTypeScript(typeNode.result, options) : 'void'; return `(${params}) => ${returnType}`; } - + default: return 'any'; } @@ -176,9 +266,9 @@ const typescriptStrategy = { // Skip Foundation module for TypeScript output return context.module === 'Foundation'; }, - + processDescription: (desc) => descriptionStringForTypeScript(desc), - + processType: (type, param) => { // Return an object with the original type preserved // This matches the expected data structure from the data processor @@ -186,22 +276,22 @@ const typescriptStrategy = { type: type, // Keep the original raw type object originalType: type // Also store it here for clarity }; - + // Extract optional flag from OptionalType if (type?.type === 'OptionalType') { result.optional = true; } - + // Extract rest flag from RestType if (type?.type === 'RestType') { result.rest = true; } - + // Preserve properties array for nested object parameters if (param && param.properties) { result.properties = param.properties; } - + return result; } }; @@ -218,7 +308,7 @@ function normalizeIdentifier(name) { function formatJSDocComment(text, indentLevel = 0) { if (!text) return ''; const indent = ' '.repeat(indentLevel); - + const lines = text .split('\n') .map(line => line.trim()) @@ -229,7 +319,7 @@ function formatJSDocComment(text, indentLevel = 0) { return acc; }, []) .filter((line, i, arr) => i < arr.length - 1 || line !== ''); - + return lines .map(line => `${indent} * ${line}`) .join('\n'); @@ -241,7 +331,7 @@ function generateObjectInterface(param, allParams, options = {}) { (param.type.type === 'OptionalType' && param.type.expression?.name === 'Object') || (param.type.type === 'NameExpression' && param.type.name === 'Object') ); - + if (!isObjectParam || !param.name) { return null; } @@ -281,12 +371,12 @@ function generateObjectInterface(param, allParams, options = {}) { function generateParamDeclaration(param, options = {}, allParams = []) { if (!param) return ''; - + const name = normalizeIdentifier(param.name); - + // Check if this is an object parameter that we can generate a better interface for const objectInterface = generateObjectInterface(param, allParams, options); - + // Convert the type - should always be an object let type = 'any'; if (objectInterface) { @@ -294,28 +384,28 @@ function generateParamDeclaration(param, options = {}, allParams = []) { } else if (param.type) { type = convertTypeToTypeScript(param.type, options); } - + const isOptional = param.optional; - + let prefix = ''; if (param.rest) { prefix = '...'; } - + return `${prefix}${name}${isOptional ? '?' : ''}: ${type}`; } function generateMethodDeclaration(method, options = {}) { let output = ''; const { globalFunction = false } = options; - + const indent = globalFunction ? '' : ' '; const commentIndent = globalFunction ? 0 : 2; - + if (method.description) { output += `${indent}/**\n`; output += formatJSDocComment(method.description, commentIndent) + '\n'; - + // Add param docs from first overload if (method.overloads?.[0]?.params) { method.overloads[0].params.forEach(param => { @@ -324,25 +414,25 @@ function generateMethodDeclaration(method, options = {}) { } }); } - + // Add return docs if (method.return?.description) { output += formatJSDocComment(`@returns ${method.return.description}`, commentIndent) + '\n'; } - + output += `${indent} */\n`; } - + const staticPrefix = method.static ? 'static ' : ''; const declarationPrefix = globalFunction ? 'function ' : `${indent}${staticPrefix}`; - + // Generate overload declarations if (method.overloads && method.overloads.length > 0) { method.overloads.forEach(overload => { const params = (overload.params || []) .map(param => generateParamDeclaration(param, options, overload.params)) .join(', '); - + let returnType = 'void'; if (method.chainable && !globalFunction && options.currentClass !== 'p5') { returnType = options.currentClass || 'this'; @@ -352,11 +442,11 @@ function generateMethodDeclaration(method, options = {}) { } else if (method.return && method.return.type) { returnType = convertTypeToTypeScript(method.return.type, options); } - + output += `${declarationPrefix}${method.name}(${params}): ${returnType};\n`; }); } - + output += '\n'; return output; } @@ -365,16 +455,16 @@ function generateClassDeclaration(classData) { let output = ''; const className = classData.name.startsWith('p5.') ? classData.name.substring(3) : classData.name; const actualClassName = className === 'Graphics' ? '__Graphics' : className; - + if (classData.description) { output += ' /**\n'; output += formatJSDocComment(classData.description, 2) + '\n'; output += ' */\n'; } - + const extendsClause = classData.extends ? ` extends ${classData.extends}` : ''; output += ` class ${actualClassName}${extendsClause} {\n`; - + // Constructor if (classData.params?.length > 0) { output += ' constructor('; @@ -383,10 +473,10 @@ function generateClassDeclaration(classData) { .join(', '); output += ');\n\n'; } - + const options = { currentClass: className, isInsideNamespace: true }; const originalClassName = classData.name; - + // Class methods const classMethodsList = Object.values(processed.classMethods[originalClassName] || {}); const methodNames = new Set(classMethodsList.map(method => method.name)); @@ -395,13 +485,13 @@ function generateClassDeclaration(classData) { const classProperties = processed.classitems.filter(item => item.class === originalClassName && item.itemtype === 'property' ); - + classProperties.forEach(prop => { // Skip properties that conflict with method names if (methodNames.has(prop.name)) { return; } - + if (prop.description) { output += ' /**\n'; output += formatJSDocComment(prop.description, 4) + '\n'; @@ -412,29 +502,29 @@ function generateClassDeclaration(classData) { }); const staticMethods = classMethodsList.filter(method => method.static); const instanceMethods = classMethodsList.filter(method => !method.static); - + staticMethods.forEach(method => { output += generateMethodDeclaration(method, options); }); - + instanceMethods.forEach(method => { output += generateMethodDeclaration(method, options); }); - + output += ' }\n\n'; - + // Add type alias for Graphics if (className === 'Graphics') { output += ' type Graphics = __Graphics & p5;\n\n'; } - + return output; } // Generate TypeScript definitions function generateTypeDefinitions() { let output = '// This file is auto-generated from JSDoc documentation\n\n'; - + // First, define all constants at the top level with their actual values const seenConstants = new Set(); const p5Constants = processed.classitems.filter(item => { @@ -451,7 +541,7 @@ function generateTypeDefinitions() { } return false; }); - + p5Constants.forEach(constant => { if (constant.description) { output += '/**\n'; @@ -465,36 +555,42 @@ function generateTypeDefinitions() { // Duplicate with a private identifier so we can re-export in the namespace later output += `${declaration} __${constant.name}: typeof ${constant.name};\n\n`; }); - + // Generate main p5 class output += 'declare class p5 {\n'; output += ' constructor(sketch?: (p: p5) => void, node?: HTMLElement, sync?: boolean);\n\n'; - + const p5Options = { currentClass: 'p5', isInsideNamespace: false }; - + // Generate p5 static methods const p5StaticMethods = Object.values(processed.classMethods.p5 || {}).filter(method => method.static); p5StaticMethods.forEach(method => { output += generateMethodDeclaration(method, p5Options); }); - + // Generate p5 instance methods const p5InstanceMethods = Object.values(processed.classMethods.p5 || {}).filter(method => !method.static); p5InstanceMethods.forEach(method => { output += generateMethodDeclaration(method, p5Options); }); - + + // Add strands functions to p5 instance + const strandsMethods = processStrandsFunctions(); + strandsMethods.forEach(method => { + output += generateMethodDeclaration(method, p5Options); + }); + // Add constants as both instance and static properties (referencing the top-level constants) p5Constants.forEach(constant => { const isMutable = mutableProperties.has(constant.name); const readonly = isMutable ? '' : 'readonly '; output += ` ${readonly}${constant.name}: typeof ${constant.name};\n`; }); - + output += '}\n\n'; output += 'declare const __p5: typeof p5;\n\n'; - + // Generate p5 namespace output += 'declare namespace p5 {\n'; output += ' const p5: typeof __p5;\n'; @@ -507,7 +603,7 @@ function generateTypeDefinitions() { }); output += '\n'; - + // Generate other classes in namespace Object.values(processed.classes).forEach(classData => { if (classData.name !== 'p5') { @@ -528,9 +624,9 @@ function generateTypeDefinitions() { output += ` class ${className} {}\n`; } } - + output += '}\n\n'; - + // Export declarations output += 'export default p5;\n'; output += 'export as namespace p5;\n'; @@ -561,21 +657,29 @@ p5: P5; globalP5Methods.forEach(method => { globalDefinitions += generateMethodDeclaration(method, { currentClass: 'p5', isInsideNamespace: true, inGlobalMode: true }); }); - + + // Add strands functions to global scope + const conflictingDOMFunctions = ['length']; // Add other conflicting function names here as needed + strandsMethods.forEach(method => { + if (!conflictingDOMFunctions.includes(method.name)) { + globalDefinitions += generateMethodDeclaration(method, { currentClass: 'p5', isInsideNamespace: true, inGlobalMode: true }); + } + }); + globalDefinitions += '}\n'; // Add global p5 namespace with all class types and constants globalDefinitions += '\nnamespace p5 {\n'; - + // Add all constants p5Constants.forEach(constant => { const isMutable = mutableProperties.has(constant.name); const declaration = isMutable ? 'let' : 'const'; globalDefinitions += ` ${declaration} ${constant.name}: typeof P5.${constant.name};\n`; }); - + globalDefinitions += '\n'; - + // Add all real classes as both types and constructors Object.values(processed.classes).forEach(classData => { if (classData.name !== 'p5') { @@ -590,7 +694,7 @@ p5: P5; } } }); - + // Add private classes for (const className of privateClasses) { globalDefinitions += ` type ${className} = P5.${className};\n`; @@ -609,7 +713,7 @@ p5: P5; return; // Skip problematic constants } alreadyDeclaredConstants.add(constant.name); - + if (constant.description) { globalDefinitions += '/**\n'; globalDefinitions += formatJSDocComment(constant.description, 0) + '\n'; @@ -623,8 +727,15 @@ p5: P5; globalDefinitions += generateMethodDeclaration(method, { currentClass: 'p5', isInsideNamespace: true, inGlobalMode: true, globalFunction: true }); }); + // Add strands functions as global functions + strandsMethods.forEach(method => { + if (!conflictingDOMFunctions.includes(method.name)) { + globalDefinitions += generateMethodDeclaration(method, { currentClass: 'p5', isInsideNamespace: true, inGlobalMode: true, globalFunction: true }); + } + }); + globalDefinitions += '}\n\n'; - + return { instanceDefinitions, globalDefinitions }; } From 9a80f41936339d69018e7d02ffd79405fac833b7 Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Thu, 2 Oct 2025 09:34:07 -0400 Subject: [PATCH 41/42] Include type casting methods --- utils/typescript.mjs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/utils/typescript.mjs b/utils/typescript.mjs index 0f99c8b64d..9d9841100a 100644 --- a/utils/typescript.mjs +++ b/utils/typescript.mjs @@ -119,7 +119,36 @@ function processStrandsFunctions() { } } - return [...strandsMethods, ...uniformMethods]; + // Add type casting functions (DataType constructor functions) + const typeCastingMethods = []; + for (const type in DataType) { + if (type === 'defer' || !DataType[type].fnName) { + continue; + } + + const typeInfo = DataType[type]; + const castingMethod = { + name: typeInfo.fnName, + overloads: [{ + params: [ + { + name: 'value', + type: { type: 'NameExpression', name: 'any' }, + optional: false + } + ], + return: { + type: { type: 'NameExpression', name: 'any' } + } + }], + description: `GLSL type constructor for ${typeInfo.fnName}`, + static: false + }; + + typeCastingMethods.push(castingMethod); + } + + return [...strandsMethods, ...uniformMethods, ...typeCastingMethods]; } // TypeScript-specific type conversion from raw type objects From 729fd1b983c9ca4982df3b07217d2fa2c5056feb Mon Sep 17 00:00:00 2001 From: Dave Pagurek Date: Sat, 4 Oct 2025 07:58:07 -0400 Subject: [PATCH 42/42] Make sure property docs are at the very bottom so they don't get lost --- src/core/main.js | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/core/main.js b/src/core/main.js index 37da512cc0..6a71cd2417 100644 --- a/src/core/main.js +++ b/src/core/main.js @@ -528,6 +528,24 @@ for (const k in constants) { p5.prototype[k] = constants[k]; } +import transform from './transform'; +import structure from './structure'; +import environment from './environment'; +import rendering from './rendering'; +import renderer from './p5.Renderer'; +import renderer2D from './p5.Renderer2D'; +import graphics from './p5.Graphics'; + +p5.registerAddon(transform); +p5.registerAddon(structure); +p5.registerAddon(environment); +p5.registerAddon(rendering); +p5.registerAddon(renderer); +p5.registerAddon(renderer2D); +p5.registerAddon(graphics); + +export default p5; + ////////////////////////////////////////////// // PUBLIC p5 PROPERTIES AND METHODS ////////////////////////////////////////////// @@ -757,20 +775,3 @@ for (const k in constants) { *
    *
    */ -import transform from './transform'; -import structure from './structure'; -import environment from './environment'; -import rendering from './rendering'; -import renderer from './p5.Renderer'; -import renderer2D from './p5.Renderer2D'; -import graphics from './p5.Graphics'; - -p5.registerAddon(transform); -p5.registerAddon(structure); -p5.registerAddon(environment); -p5.registerAddon(rendering); -p5.registerAddon(renderer); -p5.registerAddon(renderer2D); -p5.registerAddon(graphics); - -export default p5;