├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── eslint.config.mjs ├── images ├── config.png └── demo.png ├── lang ├── de.json ├── en.json └── pt-BR.json ├── module.json ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── scripts ├── _index.mjs ├── _module.mjs ├── apps │ ├── _module.mjs │ └── region-behavior.mjs ├── canvas │ ├── _module.mjs │ ├── geometry │ │ ├── _module.mjs │ │ ├── constraint.mjs │ │ └── quadrants.mjs │ ├── perception │ │ ├── _module.mjs │ │ └── detection-mode.mjs │ └── sources │ │ ├── _module.mjs │ │ ├── caster.mjs │ │ ├── darkness.mjs │ │ ├── light.mjs │ │ ├── sound.mjs │ │ └── vision.mjs ├── const.mjs ├── data │ ├── _module.mjs │ ├── fields │ │ ├── _module.mjs │ │ ├── mode.mjs │ │ ├── priority.mjs │ │ ├── range.mjs │ │ └── sight.mjs │ └── region-behavior.mjs ├── limits.mjs └── raycast │ ├── _module.mjs │ ├── _types.mjs │ ├── boundaries │ ├── _module.mjs │ ├── region.mjs │ └── universe.mjs │ ├── boundary.mjs │ ├── cast.mjs │ ├── geometry.mjs │ ├── hit.mjs │ ├── math.mjs │ ├── mode.mjs │ ├── ray.mjs │ ├── shape.mjs │ ├── shapes │ ├── _module.mjs │ ├── bounds.mjs │ ├── circle.mjs │ ├── ellipse.mjs │ ├── polygon.mjs │ ├── rectangle.mjs │ └── tile.mjs │ ├── space.mjs │ └── volume.mjs ├── style.css ├── tsconfig.json └── typedoc.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | /node_modules 3 | /_docs 4 | /_types 5 | /module.zip 6 | /script.js 7 | /script.js.map 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 dev7355608 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Version](https://img.shields.io/github/v/release/dev7355608/limits?display_name=tag&sort=semver&label=Latest%20Version)](https://github.com/dev7355608/limits/releases/latest) 2 | ![Foundry Version](https://img.shields.io/endpoint?url=https://foundryshields.com/version?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdev7355608%2Flimits%2Fmain%2Fmodule.json) 3 | [![Forge Installs](https://img.shields.io/badge/dynamic/json?label=Forge%20Installs&query=package.installs&suffix=%25&url=https%3A%2F%2Fforge-vtt.com%2Fapi%2Fbazaar%2Fpackage%2Flimits&colorB=blueviolet)](https://forge-vtt.com/bazaar#package=limits) 4 | [![License](https://img.shields.io/github/license/dev7355608/limits?label=License)](LICENSE) 5 | 6 | # Limits (Foundry VTT Module) 7 | 8 | This module allows you to limit the range of sight, light, darkness, and sound within regions. 9 | 10 | 11 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import stylistic from "@stylistic/eslint-plugin"; 4 | 5 | export default [ 6 | { 7 | ignores: ["_docs", "_types", "script.js"], 8 | }, 9 | pluginJs.configs.recommended, 10 | stylistic.configs.customize({ 11 | indent: 4, 12 | quotes: "double", 13 | semi: true, 14 | jsx: false, 15 | arrowParens: "always", 16 | braceStyle: "1tbs", 17 | blockSpacing: true, 18 | quoteProps: "consistent-as-needed", 19 | commaDangle: "always-multiline", 20 | }), 21 | { 22 | languageOptions: { 23 | ecmaVersion: "latest", 24 | globals: { 25 | ...globals.browser, 26 | canvas: "readonly", 27 | CONFIG: "readonly", 28 | CONST: "readonly", 29 | foundry: "readonly", 30 | game: "readonly", 31 | Hooks: "readonly", 32 | PIXI: "readonly", 33 | }, 34 | }, 35 | rules: { 36 | "no-unused-vars": ["error", { 37 | vars: "all", 38 | args: "none", 39 | argsIgnorePattern: "^_", 40 | caughtErrors: "all", 41 | caughtErrorsIgnorePattern: "^_", 42 | destructuredArrayIgnorePattern: "^_", 43 | ignoreRestSiblings: false, 44 | reportUsedIgnorePattern: false, 45 | }], 46 | "@stylistic/padding-line-between-statements": [ 47 | "error", 48 | { blankLine: "always", prev: "*", next: ["block-like", "break", "class", "continue", "function", "return"] }, 49 | { blankLine: "always", prev: ["block-like", "class", "function"], next: "*" }, 50 | { blankLine: "always", prev: "expression", next: ["const", "let", "var"] }, 51 | { blankLine: "always", prev: ["const", "let", "var"], next: "expression" }, 52 | { blankLine: "never", prev: ["break", "continue", "return"], next: "*" }, 53 | { blankLine: "never", prev: "*", next: "case" }, 54 | ], 55 | "@stylistic/no-mixed-operators": "off", 56 | "@stylistic/no-multiple-empty-lines": ["error", { max: 1, maxBOF: 0, maxEOF: 0 }], 57 | }, 58 | }, 59 | ]; 60 | -------------------------------------------------------------------------------- /images/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev7355608/limits/f07d5ccc89328ba0e7349ea5c2a6727270e3507e/images/config.png -------------------------------------------------------------------------------- /images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev7355608/limits/f07d5ccc89328ba0e7349ea5c2a6727270e3507e/images/demo.png -------------------------------------------------------------------------------- /lang/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "LIMITS": { 3 | "label": "Reichweite beschränken", 4 | "FIELDS": { 5 | "sight": { 6 | "label": "Sicht", 7 | "hint": "Lege fest, für welche Entdeckungsmodi diese Beschränkung gilt." 8 | }, 9 | "light": { 10 | "label": "Licht", 11 | "hint": "Lege fest, ob diese Beschränkung für Lichtquellen gilt." 12 | }, 13 | "darkness": { 14 | "label": "Dunkelheit", 15 | "hint": "Lege fest, ob diese Beschränkung für Dunkelheitsquellen gilt." 16 | }, 17 | "sound": { 18 | "label": "Geräusche", 19 | "hint": "Lege fest, ob diese Beschränkung für Geräuschquellen gilt." 20 | }, 21 | "range": { 22 | "label": "Reichweite", 23 | "hint": "Lege die Reichweite dieser Beschränkung fest." 24 | }, 25 | "mode": { 26 | "label": "Modus", 27 | "hint": "Lege fest, wie sich die Reichweite dieser Beschränkung auf die effektive Reichweite auswirkt." 28 | }, 29 | "priority": { 30 | "label": "Priorität", 31 | "hint": "Lege die Reihenfolge fest, in der die Beschränkungen bei der Bestimmung der effektive Reichweite angewendet werden." 32 | } 33 | }, 34 | "MODES": { 35 | "STACK": { 36 | "label": "Vereinigen", 37 | "hint": "Die effektive Reichweite wird durch diese Beschränkung weiter reduziert." 38 | }, 39 | "UPGRADE": { 40 | "label": "Erhöhen", 41 | "hint": "Die effektive Reichweite wird auf die Reichweite dieser Beschränkung angehoben." 42 | }, 43 | "DOWNGRADE": { 44 | "label": "Verringern", 45 | "hint": "Die effektive Reichweite wird auf die Reichweite dieser Beschränkung abgesenkt." 46 | }, 47 | "OVERRIDE": { 48 | "label": "Überschreiben", 49 | "hint": "Die effektive Reichweite wird durch die Reichweite dieser Beschränkung ersetzt." 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "LIMITS": { 3 | "label": "Limit Range", 4 | "FIELDS": { 5 | "sight": { 6 | "label": "Sight", 7 | "hint": "Configure the detection modes that this limit applies to." 8 | }, 9 | "light": { 10 | "label": "Light", 11 | "hint": "Configure whether this limit applies to light sources." 12 | }, 13 | "darkness": { 14 | "label": "Darkness", 15 | "hint": "Configure whether this limit applies to darkness sources." 16 | }, 17 | "sound": { 18 | "label": "Sound", 19 | "hint": "Configure whether this limit applies to sound sources." 20 | }, 21 | "range": { 22 | "label": "Range", 23 | "hint": "Configure the range of this limit." 24 | }, 25 | "mode": { 26 | "label": "Mode", 27 | "hint": "Configure how the range of this limit affects the effective range." 28 | }, 29 | "priority": { 30 | "label": "Priority", 31 | "hint": "Configure the order in which limits are applied to determine the effective range." 32 | } 33 | }, 34 | "MODES": { 35 | "STACK": { 36 | "label": "Stack", 37 | "hint": "The effective range is reduced further by this limit." 38 | }, 39 | "UPGRADE": { 40 | "label": "Upgrade", 41 | "hint": "The effective range is increased to the range of this limit." 42 | }, 43 | "DOWNGRADE": { 44 | "label": "Downgrade", 45 | "hint": "The effective range is decreased to the range of this limit." 46 | }, 47 | "OVERRIDE": { 48 | "label": "Override", 49 | "hint": "The effective range is overridden by the range of this limit." 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lang/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "LIMITS": { 3 | "label": "Alcance Limite", 4 | "FIELDS": { 5 | "sight": { 6 | "label": "Visão", 7 | "hint": "Configura os modos de detecção que este limite se aplica." 8 | }, 9 | "light": { 10 | "label": "Luz", 11 | "hint": "Configura se este limite se aplica às fontes de luz." 12 | }, 13 | "darkness": { 14 | "label": "Escuridão", 15 | "hint": "Configura se este limite se aplica às fontes de escuridão." 16 | }, 17 | "sound": { 18 | "label": "Som", 19 | "hint": "Configura se este limite se aplica às fontes de som." 20 | }, 21 | "range": { 22 | "label": "Alcance", 23 | "hint": "Configura o alcance deste limite." 24 | }, 25 | "mode": { 26 | "label": "Modo", 27 | "hint": "Configura como o alcance deste limite afeta o alcance efetivo." 28 | }, 29 | "priority": { 30 | "label": "Prioridade", 31 | "hint": "Configura a ordem em que os limites são aplicados para determinar o alcance efetivo." 32 | } 33 | }, 34 | "MODES": { 35 | "STACK": { 36 | "label": "Pilha", 37 | "hint": "O alcance efetivo é reduzido ainda mais por este limite." 38 | }, 39 | "UPGRADE": { 40 | "label": "Melhorar", 41 | "hint": "O alcance efetivo é aumentado até o alcance deste limite." 42 | }, 43 | "DOWNGRADE": { 44 | "label": "Piorar", 45 | "hint": "O alcance efetivo é diminuído até o alcance deste limite." 46 | }, 47 | "OVERRIDE": { 48 | "label": "Substituir", 49 | "hint": "O alcance efetivo é substituído pelo alcance deste limite." 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /module.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "limits", 3 | "title": "Limits", 4 | "description": "Limit the range of sight, light, darkness, and sound within regions.", 5 | "authors": [ 6 | { 7 | "name": "dev7355608", 8 | "email": "dev7355608@gmail.com" 9 | } 10 | ], 11 | "version": "2.0.5", 12 | "compatibility": { 13 | "minimum": "12", 14 | "verified": "13" 15 | }, 16 | "documentTypes": { 17 | "RegionBehavior": { 18 | "limitRange": {} 19 | } 20 | }, 21 | "scripts": [ 22 | "script.js" 23 | ], 24 | "styles": [ 25 | "style.css" 26 | ], 27 | "languages": [ 28 | { 29 | "lang": "en", 30 | "name": "English", 31 | "path": "lang/en.json" 32 | }, 33 | { 34 | "lang": "de", 35 | "name": "German", 36 | "path": "lang/de.json" 37 | }, 38 | { 39 | "lang": "pt-BR", 40 | "name": "Portuguese (Brazil)", 41 | "path": "lang/pt-BR.json" 42 | } 43 | ], 44 | "url": "https://github.com/dev7355608/limits", 45 | "manifest": "https://github.com/dev7355608/limits/releases/latest/download/module.json", 46 | "download": "https://github.com/dev7355608/limits/releases/download/v2.0.5/module.zip", 47 | "changelog": "https://github.com/dev7355608/limits/releases/tag/v2.0.5", 48 | "bugs": "https://github.com/dev7355608/limits/issues", 49 | "readme": "https://raw.githubusercontent.com/dev7355608/limits/main/README.md", 50 | "license": "https://raw.githubusercontent.com/dev7355608/limits/main/LICENSE" 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "limits", 3 | "description": "Limits (Foundry VTT Module)", 4 | "author": { 5 | "name": "dev7355608", 6 | "email": "dev7355608@gmail.com", 7 | "url": "https://github.com/dev7355608" 8 | }, 9 | "license": "MIT", 10 | "homepage": "https://github.com/dev7355608/limits", 11 | "bugs": { 12 | "url": "https://github.com/dev7355608/limits/issues", 13 | "email": "dev7355608@gmail.com" 14 | }, 15 | "private": true, 16 | "scripts": { 17 | "build": "rimraf module.zip script.js script.js.map && rollup -c", 18 | "clean": "rimraf _docs _types module.zip script.js script.js.map", 19 | "docs": "rimraf _docs && typedoc", 20 | "lint": "eslint", 21 | "lint:fix": "eslint --fix", 22 | "types": "rimraf _types && tsc", 23 | "watch": "rollup -c -w --environment BUILD:development" 24 | }, 25 | "devDependencies": { 26 | "@eslint/js": "^9.12.0", 27 | "@rollup/plugin-terser": "^0.4.4", 28 | "@stylistic/eslint-plugin": "^4.2.0", 29 | "archiver": "^7.0.1", 30 | "eslint": "^9.12.0", 31 | "globals": "^16.0.0", 32 | "rimraf": "^6.0.1", 33 | "rollup": "^4.36.0", 34 | "typedoc": "^0.28.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import archiver from "archiver"; 2 | import fs from "fs"; 3 | import process from "process"; 4 | import terser from "@rollup/plugin-terser"; 5 | 6 | const isDevelopment = process.env.BUILD === "development"; 7 | 8 | export default { 9 | input: "scripts/_index.mjs", 10 | output: { 11 | file: "script.js", 12 | format: "iife", 13 | sourcemap: true, 14 | generatedCode: "es2015", 15 | plugins: [terser({ 16 | ecma: 2023, 17 | compress: { 18 | booleans: false, 19 | comparisons: true, 20 | conditionals: false, 21 | drop_console: isDevelopment ? false : ["assert"], 22 | drop_debugger: !isDevelopment, 23 | ecma: 2023, 24 | join_vars: !isDevelopment, 25 | keep_classnames: true, 26 | keep_fargs: true, 27 | keep_fnames: isDevelopment, 28 | keep_infinity: true, 29 | lhs_constants: !isDevelopment, 30 | passes: 2, 31 | sequences: false, 32 | typeofs: false, 33 | }, 34 | mangle: isDevelopment ? false : { keep_classnames: true, keep_fnames: false }, 35 | format: { 36 | ascii_only: true, 37 | beautify: isDevelopment, 38 | comments: false, 39 | keep_numbers: true, 40 | }, 41 | keep_classnames: true, 42 | keep_fnames: isDevelopment, 43 | })], 44 | }, 45 | plugins: [{ 46 | closeBundle() { 47 | if (isDevelopment) { 48 | return; 49 | } 50 | 51 | const start = Date.now(); 52 | const output = fs.createWriteStream("module.zip"); 53 | const archive = archiver("zip", { zlib: { level: 9 } }); 54 | 55 | output.on("close", function () { 56 | console.log(`\x1b[32mcreated \x1b[1mmodule.zip\x1b[0m\x1b[32m in \x1b[1m${Date.now() - start}ms\x1b[0m`); 57 | }); 58 | 59 | archive.on("warning", function (error) { 60 | throw error; 61 | }); 62 | 63 | archive.on("error", function (error) { 64 | throw error; 65 | }); 66 | 67 | archive.pipe(output); 68 | 69 | for (const name of ["module.json", "script.js", "script.js.map", "style.css", "LICENSE"]) { 70 | archive.append(fs.createReadStream(name), { name }); 71 | } 72 | 73 | archive.directory("lang", "lang"); 74 | 75 | archive.finalize(); 76 | }, 77 | }], 78 | }; 79 | -------------------------------------------------------------------------------- /scripts/_index.mjs: -------------------------------------------------------------------------------- 1 | import LimitRangeRegionBehaviorConfig from "./apps/region-behavior.mjs"; 2 | import { DetectionModeMixin } from "./canvas/perception/detection-mode.mjs"; 3 | import { PointDarknessSourceMixin, PointLightSourceMixin, PointSoundSourceMixin, PointVisionSourceMixin } from "./canvas/sources/_module.mjs"; 4 | import LimitRangeRegionBehaviorType from "./data/region-behavior.mjs"; 5 | 6 | const TYPE = "limits.limitRange"; 7 | 8 | Hooks.once("init", () => { 9 | CONFIG.RegionBehavior.dataModels[TYPE] = LimitRangeRegionBehaviorType; 10 | CONFIG.RegionBehavior.typeIcons[TYPE] = "fa-solid fa-eye-low-vision"; 11 | CONFIG.RegionBehavior.typeLabels[TYPE] = "LIMITS.label"; 12 | 13 | Hooks.once("setup", () => { 14 | Hooks.once("canvasInit", () => { 15 | mixinDetectionModes(); 16 | mixinPointSources(); 17 | }); 18 | }); 19 | 20 | if (game.release.generation < 13) { 21 | Hooks.once("ready", () => { 22 | CONFIG.RegionBehavior.sheetClasses[TYPE]["core.RegionBehaviorConfig"].cls = LimitRangeRegionBehaviorConfig; 23 | }); 24 | } 25 | }); 26 | 27 | function mixinDetectionModes() { 28 | const cache = new Map(); 29 | 30 | for (const mode of Object.values(CONFIG.Canvas.detectionModes)) { 31 | let prototype = cache.get(mode.constructor); 32 | 33 | if (!prototype) { 34 | prototype = DetectionModeMixin(mode.constructor).prototype; 35 | cache.set(mode.constructor, prototype); 36 | } 37 | 38 | Object.setPrototypeOf(mode, prototype); 39 | } 40 | } 41 | 42 | function mixinPointSources() { 43 | CONFIG.Canvas.visionSourceClass = PointVisionSourceMixin(CONFIG.Canvas.visionSourceClass); 44 | CONFIG.Canvas.lightSourceClass = PointLightSourceMixin(CONFIG.Canvas.lightSourceClass); 45 | CONFIG.Canvas.darknessSourceClass = PointDarknessSourceMixin(CONFIG.Canvas.darknessSourceClass); 46 | CONFIG.Canvas.soundSourceClass = PointSoundSourceMixin(CONFIG.Canvas.soundSourceClass); 47 | } 48 | -------------------------------------------------------------------------------- /scripts/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as Limits } from "./limits.mjs"; 2 | 3 | export * as apps from "./apps/_module.mjs"; 4 | export * as canvas from "./canvas/_module.mjs"; 5 | export * as const from "./const.mjs"; 6 | export * as data from "./data/_module.mjs"; 7 | export * as raycast from "./raycast/_module.mjs"; 8 | -------------------------------------------------------------------------------- /scripts/apps/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as LimitRangeRegionBehaviorConfig } from "./region-behavior.mjs"; 2 | -------------------------------------------------------------------------------- /scripts/apps/region-behavior.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * The "Limit Range" Region Behavior Config. 3 | * @extends {foundry.applications.sheets.RegionBehaviorConfig} 4 | * @sealed 5 | */ 6 | export default class LimitRangeRegionBehaviorConfig extends foundry.applications.sheets.RegionBehaviorConfig { 7 | /** @override */ 8 | static PARTS = foundry.utils.mergeObject(super.PARTS, { form: { scrollable: [""] } }, { inplace: false }); 9 | 10 | /** @override */ 11 | async _renderHTML(context, options) { 12 | const rendered = await super._renderHTML(context, options); 13 | 14 | rendered.form.classList.add("scrollable"); 15 | 16 | return rendered; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /scripts/canvas/_module.mjs: -------------------------------------------------------------------------------- 1 | export * as geometry from "./geometry/_module.mjs"; 2 | export * as perception from "./perception/_module.mjs"; 3 | export * as sources from "./sources/_module.mjs"; 4 | -------------------------------------------------------------------------------- /scripts/canvas/geometry/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as PointSourcePolygonConstraint } from "./constraint.mjs"; 2 | export { default as computeQuadrantBounds } from "./quadrants.mjs"; 3 | -------------------------------------------------------------------------------- /scripts/canvas/geometry/constraint.mjs: -------------------------------------------------------------------------------- 1 | import * as raycast from "../../raycast/_module.mjs"; 2 | import computeQuadrantBounds from "./quadrants.mjs"; 3 | 4 | /** 5 | * The constraint for a polygon given a space. 6 | * @extends {PIXI.Polygon} 7 | */ 8 | export default class PointSourcePolygonConstraint extends PIXI.Polygon { 9 | /** 10 | * Apply the constraint given by the space to the polygon. 11 | * @overload 12 | * @param {foundry.canvas.geometry.PointSourcePolygon} polygon - The polygon that is to be constrained. 13 | * @param {raycast.Space} space - The space. 14 | * @returns {boolean} Was the polygon constrained? 15 | */ 16 | /** 17 | * Apply the constraint given by the space to the polygon. 18 | * @overload 19 | * @param {foundry.canvas.geometry.PointSourcePolygon} polygon - The polygon that is to be constrained. 20 | * @param {raycast.Space} space - The space. 21 | * @param {boolean} clone - Clone before constraining? 22 | * @returns {foundry.canvas.geometry.PointSourcePolygon} The constrained polygon. 23 | */ 24 | static apply(polygon, space, clone) { 25 | const constraint = new this(polygon, space); 26 | 27 | if (!constraint.isEnveloping) { 28 | const intersection = constraint.intersectPolygon(polygon, { scalingFactor: 100 }); 29 | 30 | if (clone) { 31 | const origin = polygon.origin; 32 | const config = { ...polygon.config, boundaryShapes: [...polygon.config.boundaryShapes] }; 33 | 34 | polygon = new polygon.constructor(); 35 | polygon.origin = origin; 36 | polygon.config = config; 37 | } 38 | 39 | polygon.points = intersection.points; 40 | polygon.bounds = polygon.getBounds(); 41 | } 42 | 43 | polygon.config.boundaryShapes.push(constraint); 44 | 45 | return clone === undefined ? !constraint.isEnveloping : polygon; 46 | } 47 | 48 | /** 49 | * @param {foundry.canvas.geometry.PointSourcePolygon} polygon - The polygon that the constraint is computed for. 50 | * @param {raycast.Space} space - The space. 51 | * @protected 52 | */ 53 | constructor(polygon, space) { 54 | super(); 55 | 56 | let { x: originX, y: originY, elevation } = this.#origin = polygon.origin; 57 | 58 | if (game.release.generation < 13) { 59 | elevation = polygon.config.source?.elevation ?? 0.0; 60 | } 61 | 62 | const originZ = elevation * canvas.dimensions.distancePixels; 63 | const externalRadius = this.#externalRadius = polygon.config.externalRadius; 64 | const { left: minX, right: maxX, top: minY, bottom: maxY } = this.#sourceBounds = polygon.bounds; 65 | const { minDistance, maxDistance } = this.#space0 = space.crop(minX, minY, originZ, maxX, maxY, originZ); 66 | 67 | if (minDistance === maxDistance) { 68 | const maxRadius = externalRadius + maxDistance; 69 | 70 | if (maxRadius < polygon.config.radius) { 71 | this.#addCircleSegment(maxRadius, 0.0); 72 | this.#addCircleSegment(maxRadius, Math.PI * 0.5); 73 | this.#addCircleSegment(maxRadius, Math.PI); 74 | this.#addCircleSegment(maxRadius, Math.PI * 1.5); 75 | } 76 | } else { 77 | this.#quadrantBounds = computeQuadrantBounds(originX, originY, polygon.points); 78 | 79 | const ray = raycast.Ray.create() 80 | .setOrigin(originX, originY, originZ) 81 | .setRange(externalRadius, polygon.config.radius); 82 | 83 | this.#computePoints(ray); 84 | } 85 | 86 | if (this.#enveloping) { 87 | this.points.length = 0; 88 | this.points.push( 89 | minX, minY, 90 | maxX, minY, 91 | maxX, maxY, 92 | minX, maxY, 93 | ); 94 | } else { 95 | this.#closePoints(); 96 | } 97 | } 98 | 99 | /** @type {foundry.types.ElevatedPoint} */ 100 | #origin; 101 | 102 | /** @type {number} */ 103 | #externalRadius; 104 | 105 | /** @type {PIXI.Rectangle} */ 106 | #sourceBounds; 107 | 108 | /** @type {[x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number] | null} */ 109 | #quadrantBounds = null; 110 | 111 | /** @type {raycast.Space | null} */ 112 | #space0 = null; 113 | 114 | /** @type {raycast.Space | null} */ 115 | #space1 = null; 116 | 117 | /** @type {raycast.Space | null} */ 118 | #space2 = null; 119 | 120 | /** @type {raycast.Space | null} */ 121 | #space3 = null; 122 | 123 | /** @type {raycast.Space | null} */ 124 | #space4 = null; 125 | 126 | /** @type {boolean} */ 127 | #enveloping = true; 128 | 129 | /** 130 | * Is the constraint enveloping the polygon it was computed for? 131 | * @type {boolean} 132 | */ 133 | get isEnveloping() { 134 | return this.#enveloping; 135 | } 136 | 137 | /** @type {true} */ 138 | get isPositive() { 139 | return true; 140 | } 141 | 142 | /** 143 | * Compute the constraint. 144 | * @param {raycast.Ray} ray 145 | */ 146 | #computePoints(ray) { 147 | const { x, y } = this.#origin; 148 | let [x0, y0, x1, y1, x2, y2, x3, y3] = this.#quadrantBounds; 149 | 150 | if (x < x0 && y < y0) { 151 | const space1 = this.#space1 = this.#space0.crop(x, y, -Infinity, x0, y0, Infinity); 152 | const { minDistance, maxDistance } = space1; 153 | const maxRadius = this.#externalRadius + maxDistance; 154 | 155 | if (minDistance === maxDistance) { 156 | if (maxRadius < Math.hypot(x0 - x, y0 - y)) { 157 | this.#addCircleSegment(maxRadius, 0.0); 158 | } else { 159 | this.#addPoint(x0, y); 160 | this.points.push(x0, y0, x, y0); 161 | } 162 | } else { 163 | x0 = Math.min(x0, x + maxRadius); 164 | y0 = Math.min(y0, y + maxRadius); 165 | 166 | ray.setSpace(space1); 167 | this.#castRays(ray, x0, y, x0, y0); 168 | this.#castRays(ray, x0, y0, x, y0); 169 | } 170 | } else { 171 | this.#addPoint(x0, y); 172 | this.#addPoint(x0, y0); 173 | this.#addPoint(x, y0); 174 | } 175 | 176 | if (x1 < x && y < y1) { 177 | const space2 = this.#space2 = this.#space0.crop(x1, y, -Infinity, x, y1, Infinity); 178 | const { minDistance, maxDistance } = space2; 179 | const maxRadius = this.#externalRadius + maxDistance; 180 | 181 | if (minDistance === maxDistance) { 182 | if (maxRadius < Math.hypot(x - x1, y1 - y)) { 183 | this.#addCircleSegment(maxRadius, Math.PI * 0.5); 184 | } else { 185 | this.#addPoint(x, y1); 186 | this.points.push(x1, y1, x1, y); 187 | } 188 | } else { 189 | x1 = Math.max(x1, x - maxRadius); 190 | y1 = Math.min(y1, y + maxRadius); 191 | 192 | ray.setSpace(space2); 193 | this.#castRays(ray, x, y1, x1, y1); 194 | this.#castRays(ray, x1, y1, x1, y); 195 | } 196 | } else { 197 | this.#addPoint(x, y1); 198 | this.#addPoint(x1, y1); 199 | this.#addPoint(x1, y); 200 | } 201 | 202 | if (x2 < x && y2 < y) { 203 | const space3 = this.#space3 = this.#space0.crop(x2, y2, -Infinity, x, y, Infinity); 204 | const { minDistance, maxDistance } = space3; 205 | const maxRadius = this.#externalRadius + maxDistance; 206 | 207 | if (minDistance === maxDistance) { 208 | if (maxRadius < Math.hypot(x - x2, y - y2)) { 209 | this.#addCircleSegment(maxRadius, Math.PI); 210 | } else { 211 | this.#addPoint(x2, y); 212 | this.points.push(x2, y2, x, y2); 213 | } 214 | } else { 215 | x2 = Math.max(x2, x - maxRadius); 216 | y2 = Math.max(y2, y - maxRadius); 217 | 218 | ray.setSpace(space3); 219 | this.#castRays(ray, x2, y, x2, y2); 220 | this.#castRays(ray, x2, y2, x, y2); 221 | } 222 | } else { 223 | this.#addPoint(x2, y); 224 | this.#addPoint(x2, y2); 225 | this.#addPoint(x, y2); 226 | } 227 | 228 | if (x < x3 && y3 < y) { 229 | const space4 = this.#space4 = this.#space0.crop(x, y3, -Infinity, x3, y, Infinity); 230 | const { minDistance, maxDistance } = space4; 231 | const maxRadius = this.#externalRadius + maxDistance; 232 | 233 | if (minDistance === maxDistance) { 234 | if (maxRadius < Math.hypot(x3 - x, y - y3)) { 235 | this.#addCircleSegment(maxRadius, Math.PI * 1.5); 236 | } else { 237 | this.#addPoint(x, y3); 238 | this.points.push(x3, y3, x3, y); 239 | } 240 | } else { 241 | x3 = Math.min(x3, x + maxRadius); 242 | y3 = Math.max(y3, y - maxRadius); 243 | 244 | ray.setSpace(space4); 245 | this.#castRays(ray, x, y3, x3, y3); 246 | this.#castRays(ray, x3, y3, x3, y); 247 | } 248 | } else { 249 | this.#addPoint(x, y3); 250 | this.#addPoint(x3, y3); 251 | this.#addPoint(x3, y); 252 | } 253 | } 254 | 255 | /** 256 | * Add a circle segment to the constraint. 257 | * @param {number} centerX - The x-coordiante of the origin. 258 | * @param {number} originY - The y-coordinate of the origin. 259 | * @param {number} radius - The radius. 260 | * @param {number} startAngle - The start angle. 261 | */ 262 | #addCircleSegment(radius, startAngle) { 263 | this.#enveloping = false; 264 | 265 | const { x: centerX, y: centerY } = this.#origin; 266 | 267 | if (radius === 0.0) { 268 | this.#addPoint(centerX, centerY); 269 | 270 | return; 271 | } 272 | 273 | this.#addPoint( 274 | centerX + Math.cos(startAngle) * radius, 275 | centerY + Math.sin(startAngle) * radius, 276 | ); 277 | 278 | const deltaAngle = Math.PI * 0.5; 279 | const points = this.points; 280 | 281 | if (radius < canvas.dimensions.maxR) { 282 | const epsilon = 1.0; // PIXI.Circle.approximateVertexDensity 283 | const numSteps = Math.ceil(deltaAngle / Math.sqrt(2.0 * epsilon / radius) - 1e-3); 284 | const angleStep = deltaAngle / numSteps; 285 | 286 | for (let i = 1; i <= numSteps; i++) { 287 | const a = startAngle + angleStep * i; 288 | 289 | points.push( 290 | centerX + Math.cos(a) * radius, 291 | centerY + Math.sin(a) * radius, 292 | ); 293 | } 294 | } else { 295 | const halfDeltaAngle = deltaAngle * 0.5; 296 | const midAngle = startAngle + halfDeltaAngle; 297 | const stopAngle = startAngle + deltaAngle; 298 | const radiusMid = radius / Math.cos(halfDeltaAngle); 299 | 300 | points.push( 301 | centerX + Math.cos(midAngle) * radiusMid, 302 | centerY + Math.sin(midAngle) * radiusMid, 303 | centerX + Math.cos(stopAngle) * radius, 304 | centerY + Math.sin(stopAngle) * radius, 305 | ); 306 | } 307 | } 308 | 309 | /** 310 | * Cast rays in the given quadrant. 311 | * @param {raycast.Ray} ray 312 | * @param {number} c0x 313 | * @param {number} c0y 314 | * @param {number} c1x 315 | * @param {number} c1y 316 | */ 317 | #castRays(ray, c0x, c0y, c1x, c1y) { 318 | const { originX: x, originY: y, originZ: z } = ray; 319 | const precision = canvas.dimensions.size * 0.0825; 320 | const precision2 = precision * precision; 321 | const c0dx = c0x - x; 322 | const c0dy = c0y - y; 323 | const t0 = ray.setTarget(c0x, c0y, z).elapsedTime; 324 | 325 | if (t0 < 1.0) { 326 | this.#enveloping = false; 327 | } 328 | 329 | const r0x = x + t0 * c0dx; 330 | const r0y = y + t0 * c0dy; 331 | 332 | this.#addPoint(r0x, r0y); 333 | 334 | const c1dx = c1x - x; 335 | const c1dy = c1y - y; 336 | const t1 = ray.setTarget(c1x, c1y, z).elapsedTime; 337 | const r1x = x + t1 * c1dx; 338 | const r1y = y + t1 * c1dy; 339 | let cdx = c1x - c0x; 340 | let cdy = c1y - c0y; 341 | const cdd = Math.sqrt(cdx * cdx + cdy * cdy); 342 | 343 | cdx /= cdd; 344 | cdy /= cdd; 345 | 346 | const u0n = cdx * c0dx + cdy * c0dy; 347 | const ndx = c0dx - u0n * cdx; 348 | const ndy = c0dy - u0n * cdy; 349 | let ndd = ndx * ndx + ndy * ndy; 350 | 351 | if (ndd > 1e-6) { 352 | ndd /= Math.sqrt(ndd); 353 | 354 | const pdx = cdx * ndd * 0.5; 355 | const pdy = cdy * ndd * 0.5; 356 | const u1n = cdx * c1dx + cdy * c1dy; 357 | const c0dd = Math.sqrt(c0dx * c0dx + c0dy * c0dy); 358 | const c1dd = Math.sqrt(c1dx * c1dx + c1dy * c1dy); 359 | const fu0 = Math.log((u0n + c0dd) / ndd); // Math.asinh(u0n / ndd) 360 | const fu1 = Math.log((u1n + c1dd) / ndd); // Math.asinh(u1n / ndd) 361 | const dfu = fu1 - fu0; 362 | const fuk = Math.ceil(Math.abs(dfu * (ndd / precision))); // Math.asinh(precision / ndd) 363 | const fud = dfu / fuk; 364 | 365 | const recur = (i0, x0, y0, i2, x2, y2) => { 366 | if (!(i2 - i0 > 1)) { 367 | return; 368 | } 369 | 370 | const dx02 = x0 - x2; 371 | const dy02 = y0 - y2; 372 | const dd02 = dx02 * dx02 + dy02 * dy02; 373 | 374 | if (dd02 <= precision2) { 375 | return; 376 | } 377 | 378 | const i1 = (i0 + i2) >> 1; 379 | let u = Math.exp(fu0 + i1 * fud) - 1.0; 380 | 381 | u += u / (u + 1.0); // Math.sinh(fu0 + i1 * fud) 382 | 383 | const dx = ndx + u * pdx; 384 | const dy = ndy + u * pdy; 385 | const t1 = ray.setTarget(x + dx, y + dy, z).elapsedTime; 386 | const x1 = x + t1 * dx; 387 | const y1 = y + t1 * dy; 388 | 389 | recur(i0, x0, y0, i1, x1, y1); 390 | 391 | if (t1 < 1.0) { 392 | this.#enveloping = false; 393 | } 394 | 395 | this.#addPoint(x1, y1); 396 | 397 | recur(i1, x1, y1, i2, x2, y2); 398 | }; 399 | 400 | recur(0, r0x, r0y, fuk, r1x, r1y); 401 | } 402 | 403 | if (t1 < 1.0) { 404 | this.#enveloping = false; 405 | } 406 | 407 | this.#addPoint(r1x, r1y); 408 | } 409 | 410 | /** 411 | * Add a point to the constraint. 412 | * @param {number} x - The x-coordinate. 413 | * @param {number} y - The y-coordinate. 414 | */ 415 | #addPoint(x, y) { 416 | const points = this.points; 417 | const m = points.length; 418 | 419 | if (m >= 4) { 420 | let x3 = points[m - 4]; 421 | let y3 = points[m - 3]; 422 | let x2 = points[m - 2]; 423 | let y2 = points[m - 1]; 424 | let x1 = x; 425 | let y1 = y; 426 | 427 | if (Math.abs(x1 - x2) > Math.abs(y1 - y2)) { 428 | if ((x1 > x2) !== (x1 < x3)) { 429 | if ((x2 > x1) === (x2 < x3)) { 430 | [x1, y1, x2, y2] = [x2, y2, x1, y1]; 431 | } else { 432 | [x1, y1, x2, y2, x3, y3] = [x3, y3, x1, y1, x2, y2]; 433 | } 434 | } 435 | } else { 436 | if ((y1 > y2) !== (y1 < y3)) { 437 | if ((y2 > y1) === (y2 < y3)) { 438 | [x1, y1, x2, y2] = [x2, y2, x1, y1]; 439 | } else { 440 | [x1, y1, x2, y2, x3, y3] = [x3, y3, x1, y1, x2, y2]; 441 | } 442 | } 443 | } 444 | 445 | const a = y2 - y3; 446 | const b = x3 - x2; 447 | const c = a * (x1 - x2) + b * (y1 - y2); 448 | 449 | if ((c * c) / (a * a + b * b) > 0.0625) { 450 | points.push(x, y); 451 | } else { 452 | const dx = points[m - 4] - x; 453 | const dy = points[m - 3] - y; 454 | 455 | points.length -= 2; 456 | 457 | if (dx * dx + dy * dy > 0.0625) { 458 | points.push(x, y); 459 | } 460 | } 461 | } else if (m === 2) { 462 | const dx = points[m - 2] - x; 463 | const dy = points[m - 1] - y; 464 | 465 | if (dx * dx + dy * dy > 0.0625) { 466 | points.push(x, y); 467 | } 468 | } else { 469 | points.push(x, y); 470 | } 471 | } 472 | 473 | /** 474 | * Close the points of the constraint. 475 | */ 476 | #closePoints() { 477 | const points = this.points; 478 | 479 | if (points.length < 6) { 480 | points.length = 0; 481 | 482 | return; 483 | } 484 | 485 | const [x1, y1, x2, y2] = points; 486 | 487 | this.#addPoint(x1, y1); 488 | this.#addPoint(x2, y2); 489 | 490 | const m = points.length; 491 | 492 | [points[0], points[1], points[2], points[3]] = [points[m - 4], points[m - 3], points[m - 2], points[m - 1]]; 493 | points.length -= 4; 494 | } 495 | 496 | /** 497 | * Visualize the polygon for debugging. 498 | */ 499 | visualize() { 500 | const dg = canvas.controls.debug; 501 | 502 | dg.clear(); 503 | 504 | for (const [i, space] of [this.#space1, this.#space2, this.#space3, this.#space4].entries()) { 505 | if (!space || (space.minDistance < space.maxDistance)) { 506 | continue; 507 | } 508 | 509 | let minX = this.#origin.x; 510 | let minY = this.#origin.y; 511 | let maxX = this.#quadrantBounds[i * 2]; 512 | let maxY = this.#quadrantBounds[i * 2 + 1]; 513 | 514 | if (minX > maxX) { 515 | [minX, maxX] = [maxX, minX]; 516 | } 517 | 518 | if (minY > maxY) { 519 | [minY, maxY] = [maxY, minY]; 520 | } 521 | 522 | dg.lineStyle(0); 523 | dg.beginFill(0x00FF00, 0.2); 524 | dg.drawRect(minX, minY, maxX - minX, maxY - minY); 525 | dg.endFill(); 526 | } 527 | 528 | dg.lineStyle(2, 0x0000FF); 529 | dg.drawPolygon(this.points); 530 | 531 | dg.lineStyle(2, 0xFFFF00, 0.7); 532 | 533 | if (this.#quadrantBounds) { 534 | const { x, y } = this.#origin; 535 | const [q0x, q0y, q1x, q1y, q2x, q2y, q3x, q3y] = this.#quadrantBounds; 536 | 537 | dg.drawPolygon([q0x, q0y, x, q0y, x, q1y, q1x, q1y, q1x, y, q2x, y, q2x, q2y, x, q2y, x, q3y, q3x, q3y, q3x, y, q0x, y]); 538 | } else { 539 | dg.beginFill(0x00FF00, 0.2); 540 | dg.drawShape(this.#sourceBounds); 541 | dg.endFill(); 542 | } 543 | 544 | dg.lineStyle(0); 545 | } 546 | } 547 | -------------------------------------------------------------------------------- /scripts/canvas/geometry/quadrants.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {number} originX - The x-coordinate of the origin. 3 | * @param {number} originY - The y-coordinate of the origin. 4 | * @param {number[]} points - The points of the polygon (`[x0, y0, x1, y1, x2, y2, ...]`). 5 | * @returns {[x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number]} 6 | */ 7 | export default function computeQuadrantBounds(originX, originY, points) { 8 | let q0x = originX; 9 | let q1x = originX; 10 | let q2x = originX; 11 | let q3x = originX; 12 | let q0y = originY; 13 | let q1y = originY; 14 | let q2y = originY; 15 | let q3y = originY; 16 | let x1, y1, q1; 17 | let i = 0; 18 | const m = points.length; 19 | 20 | for (; i < m; i += 2) { 21 | x1 = points[i]; 22 | y1 = points[i + 1]; 23 | 24 | if (y1 > originY) { 25 | q1 = x1 >= originX ? 0 : 1; 26 | 27 | break; 28 | } 29 | 30 | if (y1 < originY) { 31 | q1 = x1 <= originX ? 2 : 3; 32 | 33 | break; 34 | } 35 | 36 | if (x1 !== originX) { 37 | q1 = x1 <= originX ? 1 : 3; 38 | 39 | break; 40 | } 41 | } 42 | 43 | if (i < m) { 44 | const i0 = i = (i + 2) % m; 45 | 46 | for (; ;) { 47 | const x2 = points[i]; 48 | const y2 = points[i + 1]; 49 | let q2; 50 | 51 | if (y2 > originY) { 52 | q2 = x2 >= originX ? 0 : 1; 53 | } else if (y2 < originY) { 54 | q2 = x2 <= originX ? 2 : 3; 55 | } else if (x2 !== originX) { 56 | q2 = x2 <= originX ? 1 : 3; 57 | } else { 58 | q2 = q1; 59 | } 60 | 61 | if (q2 !== q1) { 62 | let s; 63 | 64 | switch (q1) { 65 | case 0: 66 | case 2: 67 | if (x2 !== x1) { 68 | s = (originX - x1) / (x2 - x1); 69 | x1 = originX; 70 | y1 = y1 * (1 - s) + y2 * s; 71 | } else { 72 | s = 0; 73 | x1 = originX; 74 | y1 = originY; 75 | } 76 | 77 | break; 78 | case 1: 79 | case 3: 80 | if (y2 !== y1) { 81 | s = (originY - y1) / (y2 - y1); 82 | x1 = x1 * (1 - s) + x2 * s; 83 | y1 = originY; 84 | } else { 85 | s = 0; 86 | x1 = originX; 87 | y1 = originY; 88 | } 89 | 90 | break; 91 | } 92 | 93 | switch (q1) { 94 | case 0: 95 | if (s !== 0) { 96 | q0x = max(q0x, x1); 97 | q0y = max(q0y, y1); 98 | } 99 | 100 | q1x = min(q1x, x1); 101 | q1y = max(q1y, y1); 102 | 103 | break; 104 | case 1: 105 | if (s !== 0) { 106 | q1x = min(q1x, x1); 107 | q1y = max(q1y, y1); 108 | } 109 | 110 | q2x = min(q2x, x1); 111 | q2y = min(q2y, y1); 112 | 113 | break; 114 | case 2: 115 | if (s !== 0) { 116 | q2x = min(q2x, x1); 117 | q2y = min(q2y, y1); 118 | } 119 | 120 | q3x = max(q3x, x1); 121 | q3y = min(q3y, y1); 122 | 123 | break; 124 | case 3: 125 | if (s !== 0) { 126 | q3x = max(q3x, x1); 127 | q3y = min(q3y, y1); 128 | } 129 | 130 | q0x = max(q0x, x1); 131 | q0y = max(q0y, y1); 132 | 133 | break; 134 | } 135 | 136 | q1 = (q1 + 1) % 4; 137 | } else { 138 | switch (q2) { 139 | case 0: 140 | if (x1 !== originX || x2 !== originX) { 141 | q0x = max(q0x, x2); 142 | q0y = max(q0y, y2); 143 | } 144 | 145 | break; 146 | case 1: 147 | if (y1 !== originY || y2 !== originY) { 148 | q1x = min(q1x, x2); 149 | q1y = max(q1y, y2); 150 | } 151 | 152 | break; 153 | case 2: 154 | if (x1 !== originX || x2 !== originX) { 155 | q2x = min(q2x, x2); 156 | q2y = min(q2y, y2); 157 | } 158 | 159 | break; 160 | case 3: 161 | if (y1 !== originY || y2 !== originY) { 162 | q3x = max(q3x, x2); 163 | q3y = min(q3y, y2); 164 | } 165 | 166 | break; 167 | } 168 | 169 | i = (i + 2) % m; 170 | 171 | if (i === i0) { 172 | break; 173 | } 174 | 175 | x1 = x2; 176 | y1 = y2; 177 | q1 = q2; 178 | } 179 | } 180 | } 181 | 182 | return [q0x, q0y, q1x, q1y, q2x, q2y, q3x, q3y]; 183 | } 184 | 185 | /** 186 | * Minimum. 187 | * @param {number} x 188 | * @param {number} y 189 | * @returns {number} 190 | */ 191 | function min(x, y) { 192 | return x < y ? x : y; 193 | } 194 | 195 | /** 196 | * Maximum. 197 | * @param {number} x 198 | * @param {number} y 199 | * @returns {number} 200 | */ 201 | function max(x, y) { 202 | return x > y ? x : y; 203 | } 204 | -------------------------------------------------------------------------------- /scripts/canvas/perception/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as DetectionModeMixin } from "./detection-mode.mjs"; 2 | -------------------------------------------------------------------------------- /scripts/canvas/perception/detection-mode.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {typeof foundry.canvas.perception.DetectionMode} DetectionMode 3 | * @returns {typeof foundry.canvas.perception.DetectionMode} 4 | */ 5 | export const DetectionModeMixin = (DetectionMode) => class extends DetectionMode { 6 | /** @override */ 7 | _testPoint(visionSource, mode, target, test) { 8 | if (!super._testPoint(visionSource, mode, target, test)) { 9 | return false; 10 | } 11 | 12 | let point; 13 | 14 | if (game.release.generation >= 13) { 15 | point = test.point; 16 | } else { 17 | const { x, y } = test.point; 18 | 19 | point = TEMP_POINT; 20 | point.x = x; 21 | point.y = y; 22 | point.elevation = test.elevation; 23 | } 24 | 25 | return visionSource._testLimit(mode, point); 26 | } 27 | }; 28 | 29 | export default DetectionModeMixin; 30 | 31 | /** @type {foundry.types.ElevatedPoint} */ 32 | const TEMP_POINT = { x: 0.0, y: 0.0, elevation: 0.0 }; 33 | -------------------------------------------------------------------------------- /scripts/canvas/sources/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as PointSourceRayCaster } from "./caster.mjs"; 2 | export { default as PointDarknessSourceMixin } from "./darkness.mjs"; 3 | export { default as PointLightSourceMixin } from "./light.mjs"; 4 | export { default as PointSoundSourceMixin } from "./sound.mjs"; 5 | export { default as PointVisionSourceMixin } from "./vision.mjs"; 6 | -------------------------------------------------------------------------------- /scripts/canvas/sources/caster.mjs: -------------------------------------------------------------------------------- 1 | import * as raycast from "../../raycast/_module.mjs"; 2 | 3 | export default class PointSourceRayCaster { 4 | /** 5 | * @param {raycast.Ray} [ray] - The ray. 6 | */ 7 | constructor(ray) { 8 | /** 9 | * @type {raycast.Ray} 10 | * @readonly 11 | */ 12 | this.ray = ray ?? raycast.Ray.create(); 13 | } 14 | 15 | /** 16 | * @type {raycast.Space} 17 | * @readonly 18 | */ 19 | space = raycast.Space.EMPTY; 20 | 21 | /** 22 | * @type {Readonly<[ 23 | * raycast.Space | null, 24 | * raycast.Space | null, 25 | * raycast.Space | null, 26 | * raycast.Space | null, 27 | * raycast.Space | null, 28 | * raycast.Space | null, 29 | * raycast.Space | null, 30 | * raycast.Space | null 31 | * ]>} 32 | * @readonly 33 | */ 34 | #octants = [null, null, null, null, null, null, null, null]; 35 | 36 | /** 37 | * @type {boolean} 38 | * @readonly 39 | */ 40 | initialized = false; 41 | 42 | /** 43 | * Initialize the caster. 44 | * @param {raycast.Space} space - The space. 45 | * @param {number} originX - The x-coordinate of the origin of the ray. 46 | * @param {number} originY - The y-coordinate of the origin of the ray. 47 | * @param {number} originZ - The z-coordinate of the origin of the ray. 48 | * @param {number} minRange - The minimum range of the ray. 49 | * @param {number} maxRange - The maximum range of the ray. 50 | */ 51 | initialize(space, originX, originY, originZ, minRange, maxRange) { 52 | this.ray.setSpace(raycast.Space.EMPTY).setOrigin(originX, originY, originZ).setRange(minRange, maxRange); 53 | 54 | this.space = space; 55 | 56 | for (let i = 0; i < 8; i++) { 57 | this.#octants[i] = null; 58 | } 59 | 60 | this.initialized = true; 61 | } 62 | 63 | /** 64 | * Reset the caster. 65 | */ 66 | reset() { 67 | this.ray.reset(); 68 | this.space = raycast.Space.EMPTY; 69 | 70 | for (let i = 0; i < 8; i++) { 71 | this.#octants[i] = null; 72 | } 73 | 74 | this.initialized = false; 75 | } 76 | 77 | /** 78 | * Cast the ray. 79 | * @param {number} targetX - The x-coordinate of the target of the ray. 80 | * @param {number} targetY - The y-coordinate of the target of the ray. 81 | * @param {number} targetZ - The z-coordinate of the target of the ray. 82 | * @returns {raycast.Ray} The ray of this instance. 83 | */ 84 | castRay(targetX, targetY, targetZ) { 85 | return this.ray.setSpace(this.#getOctant(targetX, targetY, targetZ)).setTarget(targetX, targetY, targetZ); 86 | } 87 | 88 | /** 89 | * Get the octant. 90 | * @param {number} targetX - The x-coordinate of the target of the ray. 91 | * @param {number} targetY - The y-coordinate of the target of the ray. 92 | * @param {number} targetZ - The z-coordinate of the target of the ray. 93 | * @returns {raycast.Space} The octant. 94 | */ 95 | #getOctant(targetX, targetY, targetZ) { 96 | const { originX, originY, originZ } = this.ray; 97 | const index = (originX < targetX ? 1 : 0) | (originY < targetY ? 2 : 0) | (originZ < targetZ ? 4 : 0); 98 | let octant = this.#octants[index]; 99 | 100 | if (!octant) { 101 | let minX = originX; 102 | let maxX = originX; 103 | let minY = originY; 104 | let maxY = originY; 105 | let minZ = originZ; 106 | let maxZ = originZ; 107 | 108 | switch (index) { 109 | case 0: 110 | minX = -Infinity; 111 | minY = -Infinity; 112 | minZ = -Infinity; 113 | 114 | break; 115 | case 1: 116 | maxX = Infinity; 117 | minY = -Infinity; 118 | minZ = -Infinity; 119 | 120 | break; 121 | case 2: 122 | minX = -Infinity; 123 | maxY = Infinity; 124 | minZ = -Infinity; 125 | 126 | break; 127 | case 3: 128 | maxX = Infinity; 129 | maxY = Infinity; 130 | minZ = -Infinity; 131 | 132 | break; 133 | case 4: 134 | minX = -Infinity; 135 | minY = -Infinity; 136 | maxZ = Infinity; 137 | 138 | break; 139 | case 5: 140 | maxX = Infinity; 141 | minY = -Infinity; 142 | maxZ = Infinity; 143 | 144 | break; 145 | case 6: 146 | minX = -Infinity; 147 | maxY = Infinity; 148 | maxZ = Infinity; 149 | 150 | break; 151 | case 7: 152 | maxX = Infinity; 153 | maxY = Infinity; 154 | maxZ = Infinity; 155 | 156 | break; 157 | } 158 | 159 | octant = this.space.crop(minX, minY, minZ, maxX, maxY, maxZ); 160 | this.#octants[index] = octant; 161 | } 162 | 163 | return octant; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /scripts/canvas/sources/darkness.mjs: -------------------------------------------------------------------------------- 1 | import Limits from "../../limits.mjs"; 2 | import PointSourcePolygonConstraint from "../geometry/constraint.mjs"; 3 | import PointSourceRayCaster from "./caster.mjs"; 4 | 5 | /** 6 | * @param {typeof foundry.canvas.sources.PointDarknessSource} PointDarknessSource 7 | * @returns {typeof foundry.canvas.sources.PointDarknessSource} 8 | */ 9 | export const PointDarknessSourceMixin = (PointDarknessSource) => class extends PointDarknessSource { 10 | /** 11 | * @type {PointSourceRayCaster} 12 | * @readonly 13 | */ 14 | #caster = new PointSourceRayCaster(); 15 | 16 | /** @override */ 17 | _createShapes() { 18 | super._createShapes(); 19 | 20 | if (this._visualShape) { 21 | if (PointSourcePolygonConstraint.apply(this._visualShape, Limits.darkness)) { 22 | const { x, y, radius } = this.data; 23 | const circle = new PIXI.Circle(x, y, radius); 24 | const density = PIXI.Circle.approximateVertexDensity(radius); 25 | 26 | this.shape = this._visualShape.applyConstraint(circle, { density, scalingFactor: 100 }); 27 | } 28 | } else { 29 | PointSourcePolygonConstraint.apply(this.shape, Limits.darkness); 30 | } 31 | 32 | if (game.release.generation >= 13) { 33 | const { x, y, elevation, radius } = this.data; 34 | const z = elevation * canvas.dimensions.distancePixels; 35 | const { left: minX, right: maxX, top: minY, bottom: maxY } = this.shape.bounds; 36 | const space = Limits.darkness.crop(minX, minY, z - radius, maxX, maxY, z + radius); 37 | 38 | this.#caster.initialize(space, x, y, z, 0.0, Infinity); 39 | } 40 | } 41 | 42 | /** @override */ 43 | testPoint(point) { 44 | return super.testPoint(point) && this.#caster.castRay(point.x, point.y, point.elevation * canvas.dimensions.distancePixels).targetHit; 45 | } 46 | }; 47 | 48 | export default PointDarknessSourceMixin; 49 | -------------------------------------------------------------------------------- /scripts/canvas/sources/light.mjs: -------------------------------------------------------------------------------- 1 | import Limits from "../../limits.mjs"; 2 | import PointSourcePolygonConstraint from "../geometry/constraint.mjs"; 3 | import PointSourceRayCaster from "./caster.mjs"; 4 | 5 | /** 6 | * @param {typeof foundry.canvas.sources.PointLightSource} PointLightSource 7 | * @returns {typeof foundry.canvas.sources.PointLightSource} 8 | */ 9 | export const PointLightSourceMixin = (PointLightSource) => class extends PointLightSource { 10 | /** 11 | * @type {PointSourceRayCaster} 12 | * @readonly 13 | */ 14 | #caster = new PointSourceRayCaster(); 15 | 16 | /** @override */ 17 | _createShapes() { 18 | super._createShapes(); 19 | 20 | PointSourcePolygonConstraint.apply(this.shape, Limits.light); 21 | 22 | if (game.release.generation >= 13) { 23 | const { x, y, elevation, radius } = this.data; 24 | const z = elevation * canvas.dimensions.distancePixels; 25 | const { left: minX, right: maxX, top: minY, bottom: maxY } = this.shape.bounds; 26 | const space = Limits.light.crop(minX, minY, z - radius, maxX, maxY, z + radius); 27 | 28 | this.#caster.initialize(space, x, y, z, 0.0, Infinity); 29 | } 30 | } 31 | 32 | /** @override */ 33 | testPoint(point) { 34 | return super.testPoint(point) && this.#caster.castRay(point.x, point.y, point.elevation * canvas.dimensions.distancePixels).targetHit; 35 | } 36 | }; 37 | 38 | export default PointLightSourceMixin; 39 | -------------------------------------------------------------------------------- /scripts/canvas/sources/sound.mjs: -------------------------------------------------------------------------------- 1 | import Limits from "../../limits.mjs"; 2 | import PointSourcePolygonConstraint from "../geometry/constraint.mjs"; 3 | import PointSourceRayCaster from "./caster.mjs"; 4 | 5 | /** 6 | * @param {typeof foundry.canvas.sources.PointSoundSource} PointSoundSource 7 | * @returns {typeof foundry.canvas.sources.PointSoundSource} 8 | */ 9 | export const PointSoundSourceMixin = (PointSoundSource) => class extends PointSoundSource { 10 | /** 11 | * @type {PointSourceRayCaster} 12 | * @readonly 13 | */ 14 | #caster = new PointSourceRayCaster(); 15 | 16 | /** @override */ 17 | _createShapes() { 18 | super._createShapes(); 19 | 20 | PointSourcePolygonConstraint.apply(this.shape, Limits.sound); 21 | 22 | const { x, y, elevation, radius } = this.data; 23 | const z = elevation * canvas.dimensions.distancePixels; 24 | const { left: minX, right: maxX, top: minY, bottom: maxY } = this.shape.bounds; 25 | 26 | let minZ; 27 | let maxZ; 28 | 29 | if (game.release.generation >= 13) { 30 | minZ = z - radius; 31 | maxZ = z + radius; 32 | } else { 33 | minZ = z; 34 | maxZ = z; 35 | } 36 | 37 | const space = Limits.sound.crop(minX, minY, minZ, maxX, maxY, maxZ); 38 | 39 | this.#caster.initialize(space, x, y, z, 0.0, Infinity); 40 | } 41 | 42 | /** @override */ 43 | testPoint(point) { 44 | return super.testPoint(point) && this.#caster.castRay(point.x, point.y, point.elevation * canvas.dimensions.distancePixels).targetHit; 45 | } 46 | 47 | /** @override */ 48 | getVolumeMultiplier(listener, options) { 49 | let volume = super.getVolumeMultiplier(listener, options); 50 | 51 | if (volume > 0.0) { 52 | let z; 53 | 54 | if (game.release.generation >= 13) { 55 | z = listener.elevation * canvas.dimensions.distancePixels; 56 | } else { 57 | z = this.#caster.ray.originZ; 58 | } 59 | 60 | volume *= this.#caster.castRay(listener.x, listener.y, z).remainingEnergy; 61 | } 62 | 63 | return volume; 64 | } 65 | }; 66 | 67 | export default PointSoundSourceMixin; 68 | -------------------------------------------------------------------------------- /scripts/canvas/sources/vision.mjs: -------------------------------------------------------------------------------- 1 | import Limits from "../../limits.mjs"; 2 | import * as raycast from "../../raycast/_module.mjs"; 3 | import PointSourcePolygonConstraint from "../geometry/constraint.mjs"; 4 | import PointSourceRayCaster from "./caster.mjs"; 5 | 6 | /** 7 | * @param {typeof foundry.canvas.sources.PointVisionSource} PointVisionSource 8 | * @returns {typeof foundry.canvas.sources.PointVisionSource} 9 | */ 10 | export const PointVisionSourceMixin = (PointVisionSource) => class extends PointVisionSource { 11 | /** 12 | * @type {{ [mode: string]: PointSourceRayCaster }}} 13 | * @readonly 14 | */ 15 | #casters = ((ray) => Object.fromEntries(Object.keys(Limits.sight).map((mode) => [mode, new PointSourceRayCaster(ray)])))(raycast.Ray.create()); 16 | 17 | /** @override */ 18 | _createShapes() { 19 | super._createShapes(); 20 | 21 | this.shape = PointSourcePolygonConstraint.apply(this.shape, Limits.sight[this.data.detectionMode ?? "basicSight"], this.shape === this.los); 22 | this.light = PointSourcePolygonConstraint.apply(this.light, Limits.sight.lightPerception, this.light === this.los); 23 | 24 | for (const mode in this.#casters) { 25 | this.#casters[mode].reset(); 26 | } 27 | } 28 | 29 | /** @override */ 30 | testPoint(point) { 31 | return super.testPoint(point) && this.#getCaster().castRay(point.x, point.y, point.elevation * canvas.dimensions.distancePixels).targetHit; 32 | } 33 | 34 | /** 35 | * Test whether the ray hits the target. 36 | * @param {foundry.documents.TokenDetectionMode} - The detection mode data. 37 | * @param {foundry.types.ElevatedPoint} point - The target point. 38 | * @returns {boolean} Does the ray hit the target? 39 | * @internal 40 | */ 41 | _testLimit(mode, point) { 42 | return this.#getCaster(mode).castRay(point.x, point.y, point.elevation * canvas.dimensions.distancePixels).targetHit; 43 | } 44 | 45 | /** 46 | * Get the ray caster for this source. 47 | * @overload 48 | * @returns {PointSourceRayCaster} The ray caster. 49 | */ 50 | /** 51 | * Get the ray caster for the given detection mode. 52 | * @overload 53 | * @param {foundry.documents.TokenDetectionMode} - The detection mode data. 54 | * @returns {PointSourceRayCaster} The ray caster. 55 | */ 56 | #getCaster(mode) { 57 | const id = mode ? mode.id : ""; 58 | const caster = this.#casters[id]; 59 | 60 | if (!caster.initialized) { 61 | const { x, y, elevation, externalRadius } = this.data; 62 | const z = elevation * canvas.dimensions.distancePixels; 63 | const radius = mode ? this.object.getLightRadius(mode.range ?? /* V12 */ Infinity) : this.data.radius; 64 | let bounds; 65 | 66 | if (!mode) { 67 | bounds = this.shape.bounds; 68 | } else if (id === "lightPerception") { 69 | bounds = this.los.bounds; 70 | } else { 71 | bounds = this.los.config.useInnerBounds ? canvas.dimensions.sceneRect : canvas.dimensions.rect; 72 | } 73 | 74 | let { left: minX, right: maxX, top: minY, bottom: maxY } = bounds; 75 | 76 | minX = Math.max(minX, x - radius); 77 | minY = Math.max(minY, y - radius); 78 | maxX = Math.min(maxX, x + radius); 79 | maxY = Math.min(maxY, y + radius); 80 | 81 | let minZ; 82 | let maxZ; 83 | 84 | if (game.release.generation >= 13) { 85 | minZ = z - radius; 86 | maxZ = z + radius; 87 | } else { 88 | minZ = -Infinity; 89 | maxZ = Infinity; 90 | } 91 | 92 | const space = Limits.sight[id].crop(minX, minY, minZ, maxX, maxY, maxZ); 93 | 94 | caster.initialize(space, x, y, z, externalRadius, Infinity); 95 | } 96 | 97 | return caster; 98 | } 99 | }; 100 | 101 | export default PointVisionSourceMixin; 102 | -------------------------------------------------------------------------------- /scripts/const.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @enum {number & {}} 3 | */ 4 | export const MODES = Object.freeze({ 5 | /** 6 | * Stack. 7 | */ 8 | STACK: 0, 9 | 10 | /** 11 | * Upgrade. 12 | */ 13 | UPGRADE: 1, 14 | 15 | /** 16 | * Downgrade. 17 | */ 18 | DOWNGRADE: 2, 19 | 20 | /** 21 | * Override. 22 | */ 23 | OVERRIDE: 3, 24 | }); 25 | -------------------------------------------------------------------------------- /scripts/data/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as LimitRangeRegionBehaviorType } from "./region-behavior.mjs"; 2 | 3 | export * as fields from "./fields/_module.mjs"; 4 | -------------------------------------------------------------------------------- /scripts/data/fields/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as ModeField } from "./mode.mjs"; 2 | export { default as PriorityField } from "./priority.mjs"; 3 | export { default as RangeField } from "./range.mjs"; 4 | export { default as SightField } from "./sight.mjs"; 5 | -------------------------------------------------------------------------------- /scripts/data/fields/mode.mjs: -------------------------------------------------------------------------------- 1 | import { MODES } from "../../const.mjs"; 2 | 3 | /** 4 | * @extends {foundry.data.fields.NumberField} 5 | */ 6 | export default class ModeField extends foundry.data.fields.NumberField { 7 | constructor() { 8 | super({ 9 | required: true, 10 | nullable: false, 11 | initial: MODES.DOWNGRADE, 12 | choices: Object.values(MODES), 13 | }); 14 | } 15 | 16 | /** @override */ 17 | _toInput(config) { 18 | if (config.value === undefined) { 19 | config.value = this.getInitialValue({}); 20 | } 21 | 22 | config.options = Object.entries(MODES).map(([key, value]) => ({ value, label: `LIMITS.MODES.${key}.label` })); 23 | config.localize = true; 24 | config.sort = false; 25 | config.dataset ??= {}; 26 | config.dataset.dtype = "Number"; 27 | 28 | const select = foundry.applications.fields.createSelectInput(config); 29 | const modes = foundry.utils.invertObject(MODES); 30 | 31 | select.dataset.tooltip = `LIMITS.MODES.${modes[select.value]}.hint`; 32 | select.dataset.tooltipDirection = "UP"; 33 | select.setAttribute("onchange", `game.tooltip.deactivate(); this.dataset.tooltip = "LIMITS.MODES." + (${JSON.stringify(modes)})[this.value] + ".hint";`); 34 | 35 | return select; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /scripts/data/fields/priority.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @extends {foundry.data.fields.NumberField} 3 | */ 4 | export default class PriorityField extends foundry.data.fields.NumberField { 5 | constructor() { 6 | super({ 7 | required: true, 8 | nullable: false, 9 | integer: true, 10 | min: -2147483648, 11 | max: 2147483647, 12 | initial: 0, 13 | }); 14 | } 15 | 16 | /** @override */ 17 | _toInput(config) { 18 | Object.assign(config, { 19 | min: this.min, 20 | max: this.max, 21 | step: 1, 22 | placeholder: "0", 23 | }); 24 | 25 | return foundry.applications.fields.createNumberInput(config); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /scripts/data/fields/range.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @extends {foundry.data.fields.NumberField} 3 | */ 4 | export default class RangeField extends foundry.data.fields.NumberField { 5 | constructor() { 6 | super({ required: true, nullable: true, min: 0, step: 0.01 }); 7 | } 8 | 9 | /** @override */ 10 | toFormGroup(groupConfig = {}, inputConfig) { 11 | groupConfig.units ??= "GridUnits"; 12 | 13 | return super.toFormGroup(groupConfig, inputConfig); 14 | } 15 | 16 | /** @override */ 17 | _toInput(config) { 18 | Object.assign(config, { 19 | min: this.min, 20 | max: this.max, 21 | step: this.step, 22 | placeholder: "\uF534", 23 | }); 24 | 25 | const input = foundry.applications.fields.createNumberInput(config); 26 | 27 | input.classList.add("placeholder-fa-solid", "limits--placeholder-font-size-12"); 28 | 29 | return input; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /scripts/data/fields/sight.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @extends {foundry.data.fields.NumberField} 3 | */ 4 | export default class SightField extends foundry.data.fields.SetField { 5 | constructor() { 6 | super(new foundry.data.fields.StringField({ required: true, nullable: false, blank: false })); 7 | } 8 | 9 | /** @override */ 10 | _cleanType(value, options) { 11 | value = super._cleanType(value, options); 12 | value.sort(); 13 | 14 | const n = value.length; 15 | let k = 0; 16 | 17 | for (let i = 0; i + 1 < n; i++) { 18 | if (value[i] === value[i + 1]) { 19 | k++; 20 | } else if (k !== 0) { 21 | value[i - k] = value[i]; 22 | } 23 | } 24 | 25 | if (k !== 0) { 26 | value[n - 1 - k] = value[n - 1]; 27 | value.length -= k; 28 | } 29 | 30 | return value; 31 | } 32 | 33 | /** @override */ 34 | _toInput(config) { 35 | config.options = Object.entries(CONFIG.Canvas.detectionModes).map(([value, { label }]) => ({ value, label })); 36 | config.localize = true; 37 | config.sort = true; 38 | 39 | return foundry.applications.fields.createMultiSelectInput(config); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /scripts/data/region-behavior.mjs: -------------------------------------------------------------------------------- 1 | import Limits from "../limits.mjs"; 2 | import ModeField from "./fields/mode.mjs"; 3 | import PriorityField from "./fields/priority.mjs"; 4 | import RangeField from "./fields/range.mjs"; 5 | import SightField from "./fields/sight.mjs"; 6 | 7 | /** 8 | * The "Limit Range" Region Behavior. 9 | * @extends {foundry.data.regionBehaviors.RegionBehaviorType} 10 | * @sealed 11 | */ 12 | export default class LimitRangeRegionBehaviorType extends foundry.data.regionBehaviors.RegionBehaviorType { 13 | /** 14 | * @type {string[]} 15 | * @override 16 | */ 17 | static LOCALIZATION_PREFIXES = ["LIMITS"]; 18 | 19 | /** 20 | * @returns {Record}} 21 | * @override 22 | */ 23 | static defineSchema() { 24 | return { 25 | sight: new SightField(), 26 | light: new foundry.data.fields.BooleanField(), 27 | darkness: new foundry.data.fields.BooleanField(), 28 | sound: new foundry.data.fields.BooleanField(), 29 | range: new RangeField(), 30 | mode: new ModeField(), 31 | priority: new PriorityField(), 32 | }; 33 | } 34 | 35 | /** 36 | * @type {Record Promise>} 37 | * @override 38 | */ 39 | static events = { 40 | [CONST.REGION_EVENTS.BEHAVIOR_STATUS]: onBehaviorStatus, 41 | [CONST.REGION_EVENTS.REGION_BOUNDARY]: onRegionBoundary, 42 | }; 43 | 44 | /** 45 | * @param {object} changed 46 | * @param {object} options 47 | * @param {string} userId 48 | * @override 49 | */ 50 | _onUpdate(changed, options, userId) { 51 | super._onUpdate(changed, options, userId); 52 | 53 | if ("system" in changed && this.parent.viewed) { 54 | Limits._onBehaviorSystemChanged(this.parent); 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * @this LimitRangeRegionBehaviorType 61 | * @param {foundry.types.RegionEvent} event - The Region event. 62 | */ 63 | function onBehaviorStatus(event) { 64 | if (event.data.viewed === true) { 65 | Limits._onBehaviorViewed(this.parent); 66 | } else if (event.data.viewed === false) { 67 | Limits._onBehaviorUnviewed(this.parent); 68 | } 69 | } 70 | 71 | /** 72 | * @this LimitRangeRegionBehaviorType 73 | * @param {foundry.types.RegionEvent} event - The Region event. 74 | */ 75 | function onRegionBoundary(event) { 76 | if (this.parent.viewed) { 77 | Limits._onBehaviorBoundaryChanged(this.parent); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /scripts/limits.mjs: -------------------------------------------------------------------------------- 1 | import * as raycast from "./raycast/_module.mjs"; 2 | 3 | /** 4 | * @sealed 5 | */ 6 | export default class Limits { 7 | /** 8 | * @type {Readonly<{ [mode: string]: raycast.Space }>} 9 | * @readonly 10 | */ 11 | static sight = {}; 12 | 13 | /** @type {Limits} */ 14 | static #light; 15 | 16 | /** 17 | * @type {raycast.Space} 18 | */ 19 | static get light() { 20 | return this.#light.#getSpace(); 21 | } 22 | 23 | /** @type {Limits} */ 24 | static #darkness; 25 | 26 | /** 27 | * @type {raycast.Space} 28 | */ 29 | static get darkness() { 30 | return this.#darkness.#getSpace(); 31 | } 32 | 33 | /** @type {Limits} */ 34 | static #sound; 35 | 36 | /** 37 | * @type {raycast.Space} 38 | */ 39 | static get sound() { 40 | return this.#sound.#getSpace(); 41 | } 42 | 43 | static { 44 | Hooks.once("init", () => { 45 | Hooks.once("setup", () => { 46 | Hooks.once("canvasInit", () => { 47 | this.sight = {}; 48 | 49 | for (const id in CONFIG.Canvas.detectionModes) { 50 | const limit = new SightLimits(id); 51 | 52 | Object.defineProperty(this.sight, id, { 53 | get: limit.#getSpace.bind(limit), 54 | enumerable: true, 55 | }); 56 | } 57 | 58 | Object.freeze(this.sight); 59 | 60 | this.#light = new LightLimits(); 61 | this.#darkness = new DarknessLimits(); 62 | this.#sound = new SoundLimits(); 63 | }); 64 | }); 65 | }); 66 | } 67 | 68 | /** @type {Map} */ 69 | static #geometries = new Map(); 70 | 71 | /** 72 | * Get the geometry of the RegionDocument. 73 | * @param {foundry.documents.RegionDocument} region - The RegionDocument. 74 | * @returns {raycast.Geometry} The geometry. 75 | */ 76 | static #getGeometry(region) { 77 | let geometry = this.#geometries.get(region); 78 | 79 | if (!geometry) { 80 | const distancePixels = canvas.dimensions.distancePixels; 81 | let shape; 82 | 83 | if (region.shapes.length === 1) { 84 | const data = region.shapes[0]; 85 | 86 | switch (data.type) { 87 | case "rectangle": 88 | if (data.rotation === 0) { 89 | shape = raycast.shapes.Bounds.create({ 90 | minX: data.x, 91 | minY: data.y, 92 | maxX: data.x + data.width, 93 | maxY: data.y + data.height, 94 | }); 95 | } else { 96 | shape = raycast.shapes.Rectangle.create({ 97 | centerX: data.x + data.width / 2, 98 | centerY: data.y + data.height / 2, 99 | width: data.width / 2, 100 | height: data.height / 2, 101 | rotation: Math.toRadians(data.rotation), 102 | }); 103 | } 104 | 105 | break; 106 | case "circle": 107 | shape = raycast.shapes.Circle.create({ 108 | centerX: data.x, 109 | centerY: data.y, 110 | radius: data.radius, 111 | }); 112 | 113 | break; 114 | case "ellipse": 115 | if (data.radiusX === data.radiusY) { 116 | shape = raycast.shapes.Circle.create({ 117 | centerX: data.x, 118 | centerY: data.y, 119 | radius: data.radiusX, 120 | }); 121 | } else { 122 | shape = raycast.shapes.Ellipse.create({ 123 | centerX: data.x, 124 | centerY: data.y, 125 | radiusX: data.radiusX, 126 | radiusY: data.radiusY, 127 | rotation: Math.toRadians(data.rotation), 128 | }); 129 | } 130 | 131 | break; 132 | case "polygon": 133 | shape = raycast.shapes.Polygon.create({ points: data.points }); 134 | 135 | break; 136 | } 137 | } 138 | 139 | let polygons; 140 | let bottom; 141 | let top; 142 | 143 | if (game.release.generation >= 13) { 144 | if (!shape) { 145 | polygons = region.polygons; 146 | } 147 | 148 | bottom = region.elevation.bottom; 149 | top = region.elevation.top; 150 | } else { 151 | if (!shape) { 152 | polygons = region.object.polygons; 153 | } 154 | 155 | bottom = region.object.bottom; 156 | top = region.object.top; 157 | } 158 | 159 | geometry = raycast.Geometry.create({ 160 | boundaries: [raycast.boundaries.Region.create({ 161 | shapes: shape ? [shape] : polygons.map((polygon) => raycast.shapes.Polygon.create({ points: polygon.points })), 162 | bottom: bottom * distancePixels - 1e-8, 163 | top: top * distancePixels + 1e-8, 164 | })], 165 | }); 166 | this.#geometries.set(region, geometry); 167 | } 168 | 169 | return geometry; 170 | } 171 | 172 | /** 173 | * Destroy the geometry of the RegionDocument. 174 | * @param {foundry.documents.RegionDocument} region - The RegionDocument. 175 | */ 176 | static #destroyGeometry(region) { 177 | if (this.#geometries.delete(region)) { 178 | for (const behavior of region.behaviors) { 179 | if (this.#volumes.delete(behavior)) { 180 | for (const instance of this.#instances) { 181 | if (instance.#behaviors.has(behavior)) { 182 | instance.#space = null; 183 | instance._updatePerception(); 184 | } 185 | } 186 | } 187 | } 188 | } 189 | } 190 | 191 | static { 192 | Hooks.on("updateRegion", (document, changed) => { 193 | if (document.rendered && ("shapes" in changed || "elevation" in changed)) { 194 | this.#destroyGeometry(document); 195 | } 196 | }); 197 | 198 | Hooks.on("destroyRegion", (region) => { 199 | this.#destroyGeometry(region.document); 200 | }); 201 | } 202 | 203 | /** @type {Map} */ 204 | static #volumes = new Map(); 205 | 206 | /** 207 | * Get the volume of the RegionBehavior. 208 | * @param {foundry.documents.RegionBehavior} behavior - The RegionBehavior. 209 | * @returns {raycast.Volume} The volume. 210 | */ 211 | static #getVolume(behavior) { 212 | let volume = this.#volumes.get(behavior); 213 | 214 | if (!volume) { 215 | const geometry = this.#getGeometry(behavior.parent); 216 | const { priority, mode, range } = behavior.system; 217 | const cost = 1.0 / ((range ?? Infinity) * canvas.dimensions.distancePixels); 218 | 219 | volume = raycast.Volume.create({ geometry, priority, mode, cost }); 220 | this.#volumes.set(behavior, volume); 221 | } 222 | 223 | return volume; 224 | } 225 | 226 | /** @type {Limits[]} */ 227 | static #instances = []; 228 | 229 | /** 230 | * @internal 231 | * @ignore 232 | */ 233 | constructor() { 234 | Limits.#instances.push(this); 235 | } 236 | 237 | /** @type {Set} */ 238 | #behaviors = new Set(); 239 | 240 | /** @type {raycast.Space | null} */ 241 | #space = null; 242 | 243 | /** 244 | * Get the space. 245 | * @returns {raycast.Space} 246 | */ 247 | #getSpace() { 248 | let space = this.#space; 249 | 250 | if (!space) { 251 | const volumes = []; 252 | 253 | for (const behavior of this.#behaviors) { 254 | volumes.push(Limits.#getVolume(behavior)); 255 | } 256 | 257 | space = raycast.Space.create({ volumes }); 258 | this.#space = space; 259 | } 260 | 261 | return space; 262 | } 263 | 264 | /** 265 | * Called when the RegionBehavior is viewed. 266 | * @param {foundry.documents.RegionBehavior} behavior - The RegionBehavior. 267 | * @internal 268 | * @ignore 269 | */ 270 | static _onBehaviorViewed(behavior) { 271 | for (const instance of this.#instances) { 272 | if (instance.#behaviors.has(behavior)) { 273 | continue; 274 | } 275 | 276 | if (instance._hasBehavior(behavior)) { 277 | instance.#behaviors.add(behavior); 278 | instance.#space = null; 279 | instance._updatePerception(); 280 | } 281 | } 282 | } 283 | 284 | /** 285 | * Called when the RegionBehavior is unviewed. 286 | * @param {foundry.documents.RegionBehavior} behavior - The RegionBehavior. 287 | * @internal 288 | * @ignore 289 | */ 290 | static _onBehaviorUnviewed(behavior) { 291 | this.#volumes.delete(behavior); 292 | 293 | for (const instance of this.#instances) { 294 | if (instance.#behaviors.delete(behavior)) { 295 | instance.#space = null; 296 | instance._updatePerception(); 297 | } 298 | } 299 | } 300 | 301 | /** 302 | * Called when the RegionBehavior's Region shape is changed and the RegionBehavior is viewed. 303 | * @param {foundry.documents.RegionBehavior} behavior - The RegionBehavior. 304 | * @internal 305 | * @ignore 306 | */ 307 | static _onBehaviorBoundaryChanged(behavior) { 308 | this.#volumes.delete(behavior); 309 | 310 | for (const instance of this.#instances) { 311 | if (instance.#behaviors.has(behavior)) { 312 | instance.#space = null; 313 | instance._updatePerception(); 314 | } 315 | } 316 | } 317 | 318 | /** 319 | * Called when the RegionBehavior's system data changed and the RegionBehavior is viewed. 320 | * @param {foundry.documents.RegionBehavior} behavior - The RegionBehavior. 321 | * @internal 322 | * @ignore 323 | */ 324 | static _onBehaviorSystemChanged(behavior) { 325 | this.#volumes.delete(behavior); 326 | 327 | for (const instance of this.#instances) { 328 | if (instance._hasBehavior(behavior)) { 329 | instance.#behaviors.add(behavior); 330 | instance.#space = null; 331 | instance._updatePerception(); 332 | } else if (instance.#behaviors.has(behavior)) { 333 | instance.#behaviors.delete(behavior); 334 | instance.#space = null; 335 | instance._updatePerception(); 336 | } 337 | } 338 | } 339 | 340 | /** 341 | * @param {foundry.documents.RegionBehavior} behavior - The RegionBehavior. 342 | * @returns {boolean} 343 | * @protected 344 | * @abstract 345 | */ 346 | _hasBehavior(behavior) { 347 | return false; 348 | } 349 | 350 | /** 351 | * Update perception. 352 | * @protected 353 | * @abstract 354 | */ 355 | _updatePerception() { } 356 | } 357 | 358 | /** 359 | * @internal 360 | * @ignore 361 | */ 362 | class SightLimits extends Limits { 363 | /** 364 | * @param {string} id - The detection mode ID. 365 | */ 366 | constructor(id) { 367 | super(); 368 | 369 | /** 370 | * The detection mode ID. 371 | * @type {string} 372 | * @readonly 373 | */ 374 | this.id = id; 375 | } 376 | 377 | /** @override */ 378 | _hasBehavior(behavior) { 379 | return behavior.system.sight.has(this.id); 380 | } 381 | 382 | /** @override */ 383 | _updatePerception() { 384 | canvas.perception.update({ initializeVision: true }); 385 | } 386 | } 387 | 388 | /** 389 | * @internal 390 | * @ignore 391 | */ 392 | class LightLimits extends Limits { 393 | /** @override */ 394 | _hasBehavior(behavior) { 395 | return behavior.system.light; 396 | } 397 | 398 | /** @override */ 399 | _updatePerception() { 400 | canvas.perception.update({ initializeLightSources: true }); 401 | } 402 | } 403 | 404 | /** 405 | * @internal 406 | * @ignore 407 | */ 408 | class DarknessLimits extends Limits { 409 | /** @override */ 410 | _hasBehavior(behavior) { 411 | return behavior.system.darkness; 412 | } 413 | 414 | /** @override */ 415 | _updatePerception() { 416 | canvas.perception.update({ initializeDarknessSources: true, initializeLightSources: true }); 417 | } 418 | } 419 | 420 | /** 421 | * @internal 422 | * @ignore 423 | */ 424 | class SoundLimits extends Limits { 425 | /** @override */ 426 | _hasBehavior(behavior) { 427 | return behavior.system.sound; 428 | } 429 | 430 | /** @override */ 431 | _updatePerception() { 432 | canvas.perception.update({ initializeSounds: true }); 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /scripts/raycast/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as Boundary } from "./boundary.mjs"; 2 | export { default as Geometry } from "./geometry.mjs"; 3 | export { default as Hit } from "./hit.mjs"; 4 | export { default as Mode } from "./mode.mjs"; 5 | export { default as Ray } from "./ray.mjs"; 6 | export { default as Cast } from "./cast.mjs"; 7 | export { default as Shape } from "./shape.mjs"; 8 | export { default as Space } from "./space.mjs"; 9 | export { default as Volume } from "./volume.mjs"; 10 | 11 | export * as boundaries from "./boundaries/_module.mjs"; 12 | export * as shapes from "./shapes/_module.mjs"; 13 | export * as types from "./_types.mjs"; 14 | -------------------------------------------------------------------------------- /scripts/raycast/_types.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {number & {}} int31 3 | * A 31-bit integer. 4 | */ 5 | 6 | /** 7 | * @typedef {number & {}} int32 8 | * A 32-bit integer. 9 | */ 10 | -------------------------------------------------------------------------------- /scripts/raycast/boundaries/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as Region } from "./region.mjs"; 2 | export { default as Universe } from "./universe.mjs"; 3 | -------------------------------------------------------------------------------- /scripts/raycast/boundaries/region.mjs: -------------------------------------------------------------------------------- 1 | import Boundary from "../boundary.mjs"; 2 | import { max, min } from "../math.mjs"; 3 | import Shape from "../shape.mjs"; 4 | import Universe from "./universe.mjs"; 5 | 6 | /** 7 | * @import { int32 } from "../_types.mjs"; 8 | * @import Cast from "../cast.mjs"; 9 | */ 10 | 11 | /** 12 | * @sealed 13 | */ 14 | export default class Region extends Boundary { 15 | /** 16 | * @param {object} args 17 | * @param {Shape[]} args.shapes - The shapes (nonempty). 18 | * @param {number} [args.bottom=-Infinity] - The bottom (minimum z-coordinate). 19 | * @param {number} [args.top=Infinity] - The top (maximum z-coordinate). 20 | * @param {int32} [args.mask=-1] - The bit mask (nonzero 32-bit). 21 | * @param {int32} [args.state=-1] - The bit state (32-bit). 22 | * @returns {Region} The region. 23 | */ 24 | static create({ shapes, bottom = -Infinity, top = Infinity, mask = -1, state = -1 }) { 25 | console.assert(Array.isArray(shapes)); 26 | console.assert(shapes.every((shape) => shape instanceof Shape && shape.mask !== 0)); 27 | console.assert(shapes.length !== 0); 28 | console.assert(typeof bottom === "number"); 29 | console.assert(typeof top === "number"); 30 | console.assert(bottom <= top); 31 | console.assert(mask === (mask | 0) && mask !== 0); 32 | console.assert(state === (state | 0)); 33 | 34 | return new Region(shapes.toSorted(compareShapesByType), bottom + 0.0, top + 0.0, mask | 0, state | 0); 35 | } 36 | 37 | /** 38 | * @param {Shape[]} shapes - The shapes (nonempty). 39 | * @param {number} bottom - The bottom (minimum z-coordinate). 40 | * @param {number} top - The top (maximum z-coordinate). 41 | * @param {int32} mask - The bit mask (nonzero 32-bit integer). 42 | * @param {int32} state - The bit state (32-bit integer). 43 | * @private 44 | * @ignore 45 | */ 46 | constructor(shapes, bottom, top, mask, state) { 47 | super(mask, state); 48 | 49 | /** 50 | * The shapes. 51 | * @type {ReadonlyArray} 52 | * @readonly 53 | */ 54 | this.shapes = shapes; 55 | 56 | /** 57 | * The bottom (minimum z-coordinate). 58 | * @type {number} 59 | * @readonly 60 | */ 61 | this.bottom = bottom; 62 | 63 | /** 64 | * The top (maximum z-coordinate). 65 | * @type {number} 66 | * @readonly 67 | */ 68 | this.top = top; 69 | } 70 | 71 | /** @inheritDoc */ 72 | get isUnbounded() { 73 | return this.state === 0 && this.shapes.length === 0; 74 | } 75 | 76 | /** 77 | * @param {number} minX - The minimum x-coordinate. 78 | * @param {number} minY - The minimum y-coordinate. 79 | * @param {number} minZ - The minimum z-coordinate. 80 | * @param {number} maxX - The maximum x-coordinate. 81 | * @param {number} maxY - The maximum y-coordinate. 82 | * @param {number} maxZ - The maximum z-coordinate. 83 | * @returns {Boundary} The cropped boundary. 84 | * @inheritDoc 85 | */ 86 | crop(minX, minY, minZ, maxX, maxY, maxZ) { 87 | if (max(this.bottom, minZ) > min(this.top, maxZ)) { 88 | return Universe.EMPTY; 89 | } 90 | 91 | const shapes = this.shapes; 92 | const numShapes = shapes.length; 93 | let croppedState = this.state; 94 | 95 | for (let shapeIndex = 0; shapeIndex < numShapes; shapeIndex++) { 96 | const shape = shapes[shapeIndex]; 97 | const result = shape.testBounds(minX, minY, maxX, maxY); 98 | 99 | if (result < 0) { 100 | continue; 101 | } 102 | 103 | if (result > 0) { 104 | croppedState ^= shape.mask; 105 | 106 | continue; 107 | } 108 | 109 | CROPPED_SHAPES.push(shape); 110 | } 111 | 112 | if (CROPPED_SHAPES.length === numShapes) { 113 | CROPPED_SHAPES.length = 0; 114 | 115 | return this; 116 | } 117 | 118 | const { bottom, top } = this; 119 | 120 | if (CROPPED_SHAPES.length === 0) { 121 | if (bottom <= minZ && maxZ <= top) { 122 | croppedState ^= 1 << 31; 123 | } 124 | 125 | return croppedState === 0 ? Universe.get(this.mask) : Universe.EMPTY; 126 | } 127 | 128 | const croppedShapes = CROPPED_SHAPES.slice(0); 129 | 130 | CROPPED_SHAPES.length = 0; 131 | 132 | return new Region(croppedShapes, bottom, top, this.mask, croppedState); 133 | } 134 | 135 | /** 136 | * @param {Cast} cast - The cast. 137 | * @inheritDoc 138 | */ 139 | computeHits(cast) { 140 | const { originZ, invDirectionZ } = cast; 141 | 142 | if (invDirectionZ !== Infinity) { 143 | const time1 = (this.bottom - originZ) * invDirectionZ; 144 | 145 | if (time1 > 0.0) { 146 | cast.addHit(time1, 1 << 31); 147 | } 148 | 149 | const time2 = (this.top - originZ) * invDirectionZ; 150 | 151 | if (time2 > 0.0) { 152 | cast.addHit(time2, 1 << 31); 153 | } 154 | } else if (this.bottom <= originZ && originZ <= this.top) { 155 | cast.addHit(Infinity, 1 << 31); 156 | } 157 | 158 | const shapes = this.shapes; 159 | const numShapes = shapes.length; 160 | const { directionX, directionY } = cast; 161 | 162 | if (directionX !== 0.0 || directionY !== 0.0) { 163 | for (let shapeIndex = 0; shapeIndex < numShapes; shapeIndex++) { 164 | const shape = shapes[shapeIndex]; 165 | 166 | shape.computeHits(cast); 167 | } 168 | } else { 169 | const { originX, originY } = cast; 170 | 171 | for (let shapeIndex = 0; shapeIndex < numShapes; shapeIndex++) { 172 | const shape = shapes[shapeIndex]; 173 | 174 | if (shape.containsPoint(originX, originY)) { 175 | cast.addHit(Infinity, shape.mask); 176 | } 177 | } 178 | } 179 | } 180 | } 181 | 182 | /** 183 | * The array for cropped shapes. 184 | * @type {Shape[]} 185 | */ 186 | const CROPPED_SHAPES = []; 187 | 188 | /** 189 | * The shape type to ID map. 190 | * @type {Map} 191 | */ 192 | const SHAPE_TYPE_IDS = new Map(); 193 | 194 | /** 195 | * Get the ID of the shape's type. 196 | * @param {Shape} shape - The shape. 197 | * @returns {int32} The shape type ID. 198 | */ 199 | function getShapeTypeID(shape) { 200 | const shapeType = shape.constructor; 201 | let id = SHAPE_TYPE_IDS.get(shapeType); 202 | 203 | if (id === undefined) { 204 | id = SHAPE_TYPE_IDS.size; 205 | SHAPE_TYPE_IDS.set(shapeType, id); 206 | } 207 | 208 | return id; 209 | } 210 | 211 | /** 212 | * Compare two shapes by type. 213 | * @param {Shape} shape1 - The first shape. 214 | * @param {Shape} shape2 - The second shape. 215 | * @returns {number} 216 | */ 217 | function compareShapesByType(shape1, shape2) { 218 | return getShapeTypeID(shape1) - getShapeTypeID(shape2); 219 | } 220 | -------------------------------------------------------------------------------- /scripts/raycast/boundaries/universe.mjs: -------------------------------------------------------------------------------- 1 | import Boundary from "../boundary.mjs"; 2 | 3 | /** 4 | * @import { int32 } from "../_types.mjs"; 5 | * @import Cast from "../cast.mjs"; 6 | */ 7 | 8 | /** 9 | * @sealed 10 | */ 11 | export default class Universe extends Boundary { 12 | /** 13 | * The empty boundless boundary. 14 | * @type {Universe} 15 | * @readonly 16 | */ 17 | static EMPTY = new Universe(0); 18 | 19 | /** 20 | * Get the boundless boundary for the given mask. 21 | * @param {int32} mask - The bit mask (32-bit integer). 22 | * @returns {Universe} The boundless boundary. 23 | */ 24 | static get(mask) { 25 | let boundary = CACHE.get(mask); 26 | 27 | if (!boundary) { 28 | console.assert(mask === (mask | 0)); 29 | 30 | boundary = new Universe(mask); 31 | CACHE.set(mask, boundary); 32 | } 33 | 34 | return boundary; 35 | } 36 | 37 | /** 38 | * @param {int32} mask - The bit mask (32-bit integer). 39 | * @private 40 | * @ignore 41 | */ 42 | constructor(mask) { 43 | super(mask, 0); 44 | } 45 | 46 | /** @inheritDoc */ 47 | get isUnbounded() { 48 | return true; 49 | } 50 | 51 | /** @inheritDoc */ 52 | get isEmpty() { 53 | return this.mask === 0; 54 | } 55 | 56 | /** 57 | * @param {number} minX - The minimum x-coordinate. 58 | * @param {number} minY - The minimum y-coordinate. 59 | * @param {number} minZ - The minimum z-coordinate. 60 | * @param {number} maxX - The maximum x-coordinate. 61 | * @param {number} maxY - The maximum y-coordinate. 62 | * @param {number} maxZ - The maximum z-coordinate. 63 | * @returns {Boundary} The cropped boundary. 64 | * @inheritDoc 65 | */ 66 | crop(minX, minY, minZ, maxX, maxY, maxZ) { 67 | return this; 68 | } 69 | 70 | /** 71 | * @param {Cast} cast - The cast. 72 | * @inheritDoc 73 | */ 74 | computeHits(cast) { } 75 | } 76 | 77 | /** @type {Map} */ 78 | const CACHE = new Map([[0, Universe.EMPTY]]); 79 | -------------------------------------------------------------------------------- /scripts/raycast/boundary.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @import { int32 } from "./_types.mjs"; 3 | * @import Geometry from "./geometry.mjs"; 4 | * @import Cast from "./cast.mjs"; 5 | */ 6 | 7 | /** 8 | * The boundary of a {@link Geometry}. 9 | * @abstract 10 | */ 11 | export default class Boundary { 12 | /** 13 | * @param {int32} mask - The bit mask (32-bit). 14 | * @param {int32} state - The bit state (32-bit). 15 | */ 16 | constructor(mask, state) { 17 | /** 18 | * The bit mask of the boundary (32-bit). 19 | * @type {int32} 20 | * @readonly 21 | */ 22 | this.mask = mask; 23 | 24 | /** 25 | * The initial state of the ray relative to the interior of the boundary. 26 | * @type {int32} 27 | * @readonly 28 | */ 29 | this.state = state; 30 | 31 | /** 32 | * The current state of the ray relative to the interior of the boundary. 33 | * If zero, the ray is currently inside the interior enclosed by the boundary. 34 | * @type {int32} 35 | * @internal 36 | */ 37 | this._state = 0; 38 | } 39 | 40 | /** 41 | * Is this boundary unbounded w.r.t. to the bounding box of the space? 42 | * @type {boolean} 43 | * @virtual 44 | */ 45 | get isUnbounded() { 46 | return false; 47 | } 48 | 49 | /** 50 | * Can this boundary be discarded as it wouldn't affect rays at all? 51 | * @type {boolean} 52 | * @virtual 53 | */ 54 | get isEmpty() { 55 | return this.mask === 0 || this.state !== 0 && this.isUnbounded; 56 | } 57 | 58 | /** 59 | * Crop the boundary w.r.t. to the bounding box of the space. 60 | * @param {number} minX - The minimum x-coordinate. 61 | * @param {number} minY - The minimum y-coordinate. 62 | * @param {number} minZ - The minimum z-coordinate. 63 | * @param {number} maxX - The maximum x-coordinate. 64 | * @param {number} maxY - The maximum y-coordinate. 65 | * @param {number} maxZ - The maximum z-coordinate. 66 | * @returns {Boundary} The cropped boundary. 67 | * @virtual 68 | * @abstract 69 | */ 70 | crop(minX, minY, minZ, maxX, maxY, maxZ) { 71 | return this; 72 | } 73 | 74 | /** 75 | * Compute the hits of the boundary with the ray. 76 | * @param {Cast} cast - The cast. 77 | * @virtual 78 | * @abstract 79 | */ 80 | computeHits(cast) { } 81 | } 82 | -------------------------------------------------------------------------------- /scripts/raycast/cast.mjs: -------------------------------------------------------------------------------- 1 | import Hit from "./hit.mjs"; 2 | 3 | /** 4 | * @import { int32 } from "./_types.mjs"; 5 | * @import Boundary from "./boundary.mjs"; 6 | * @import Geometry from "./geometry.mjs"; 7 | * @import Ray from "./ray.mjs"; 8 | */ 9 | 10 | /** 11 | * @sealed 12 | */ 13 | export default class Cast { 14 | /** 15 | * @returns {Cast} The cast. 16 | */ 17 | static create() { 18 | return new Cast(64); 19 | } 20 | 21 | /** 22 | * @param {number} numHits - Initial number of allocated hits. 23 | * @internal 24 | * @ignore 25 | */ 26 | constructor(numHits) { 27 | const hits = this._hits; 28 | 29 | for (let j = 0; j < numHits; j++) { 30 | hits.push(new Hit()); 31 | } 32 | } 33 | 34 | /** 35 | * The x-coordinate of the origin of the ray. 36 | * @type {number} 37 | * @readonly 38 | */ 39 | originX = 0.0; 40 | 41 | /** 42 | * The y-coordinate of the origin of the ray. 43 | * @type {number} 44 | * @readonly 45 | */ 46 | originY = 0.0; 47 | 48 | /** 49 | * The z-coordinate of the origin of the ray. 50 | * @type {number} 51 | * @readonly 52 | */ 53 | originZ = 0.0; 54 | 55 | /** 56 | * The x-coordinate of the direction of the ray. 57 | * @type {number} 58 | * @readonly 59 | */ 60 | directionX = 0.0; 61 | 62 | /** 63 | * The y-coordinate of the direction of the ray. 64 | * @type {number} 65 | * @readonly 66 | */ 67 | directionY = 0.0; 68 | 69 | /** 70 | * The z-coordinate of the direction of the ray. 71 | * @type {number} 72 | * @readonly 73 | */ 74 | directionZ = 0.0; 75 | 76 | /** 77 | * The inverse x-coordinate of the direction of the ray. 78 | * @type {number} 79 | * @readonly 80 | */ 81 | invDirectionX = Infinity; 82 | 83 | /** 84 | * The inverse y-coordinate of the direction of the ray. 85 | * @type {number} 86 | * @readonly 87 | */ 88 | invDirectionY = Infinity; 89 | 90 | /** 91 | * The inverse z-coordinate of the direction of the ray. 92 | * @type {number} 93 | * @readonly 94 | */ 95 | invDirectionZ = Infinity; 96 | 97 | /** 98 | * The pool of hits. 99 | * @type {Hit[]} 100 | * @readonly 101 | * @private 102 | * @ignore 103 | */ 104 | _hits = []; 105 | 106 | /** 107 | * The number of hits of the pool that are currently used. 108 | * @type {number} 109 | * @private 110 | * @ignore 111 | */ 112 | _hitsUsed = 0; 113 | 114 | /** 115 | * The number of hits remaining in the heap. 116 | * @type {number} 117 | * @private 118 | * @ignore 119 | */ 120 | _hitsRemaining = 0; 121 | 122 | /** 123 | * The current geometry instance. 124 | * @type {Geometry | null} 125 | * @private 126 | * @ignore 127 | */ 128 | _geometry = null; 129 | 130 | /** 131 | * The current boundary instance. 132 | * @type {Boundary | null} 133 | * @private 134 | * @ignore 135 | */ 136 | _boundary = null; 137 | 138 | /** 139 | * Record a hit with the boundary of the geometry. 140 | * This function can be called only during {@link Cast#computeHits}. 141 | * @param {number} time - The time the ray hits the boundary of the geometry (nonnegative). 142 | * @param {int32} mask - The bit mask indicating which parts of the boundary were hit (nonzero 32-bit integer). 143 | */ 144 | addHit(time, mask) { 145 | const boundary = this._boundary; 146 | 147 | boundary._state ^= mask; 148 | 149 | if (time > 1.0) { 150 | return; 151 | } 152 | 153 | const hits = this._hits; 154 | const i = this._hitsUsed++; 155 | 156 | if (i === hits.length) { 157 | for (let j = i; j > 0; j--) { 158 | hits.push(new Hit()); 159 | } 160 | } 161 | 162 | const hit = hits[i]; 163 | 164 | hit.geometry = this._geometry; 165 | hit.boundary = boundary; 166 | hit.time = time; 167 | hit.mask = mask; 168 | } 169 | 170 | /** 171 | * Compute the hits of the ray. 172 | * Resets the cast before computing the hits. 173 | * @param {Ray} ray - The ray. 174 | */ 175 | computeHits(ray) { 176 | this.reset(); 177 | 178 | CAST_ID++; 179 | 180 | const originX = (ray.originX + 6755399441055744.0) - 6755399441055744.0; 181 | const originY = (ray.originY + 6755399441055744.0) - 6755399441055744.0; 182 | const originZ = (ray.originZ + 6755399441055744.0) - 6755399441055744.0; 183 | const targetX = (ray.targetX + 6755399441055744.0) - 6755399441055744.0; 184 | const targetY = (ray.targetY + 6755399441055744.0) - 6755399441055744.0; 185 | const targetZ = (ray.targetZ + 6755399441055744.0) - 6755399441055744.0; 186 | const directionX = targetX - originX; 187 | const directionY = targetY - originY; 188 | const directionZ = targetZ - originZ; 189 | 190 | this.originX = originX; 191 | this.originY = originY; 192 | this.originZ = originZ; 193 | this.directionX = directionX; 194 | this.directionY = directionY; 195 | this.directionZ = directionZ; 196 | this.invDirectionX = 1.0 / directionX; 197 | this.invDirectionY = 1.0 / directionY; 198 | this.invDirectionZ = 1.0 / directionZ; 199 | 200 | const { minRange, maxRange, targetDistance } = ray; 201 | 202 | if (minRange < targetDistance) { 203 | this._hits[this._hitsUsed++].time = minRange / targetDistance; 204 | } 205 | 206 | if (maxRange < targetDistance) { 207 | this._hits[this._hitsUsed++].time = maxRange / targetDistance; 208 | } 209 | 210 | const volumes = ray.space.volumes; 211 | const numVolumes = volumes.length; 212 | 213 | for (let volumeIndex = 0; volumeIndex < numVolumes; volumeIndex++) { 214 | const geometry = volumes[volumeIndex].geometry; 215 | 216 | if (geometry._castId === CAST_ID) { 217 | continue; 218 | } 219 | 220 | geometry._castId = CAST_ID; 221 | 222 | this._geometry = geometry; 223 | 224 | const boundaries = geometry.boundaries; 225 | const numBoundaries = boundaries.length; 226 | let state = geometry.state; 227 | 228 | for (let boundaryIndex = 0; boundaryIndex < numBoundaries; boundaryIndex++) { 229 | const boundary = boundaries[boundaryIndex]; 230 | 231 | this._boundary = boundary; 232 | 233 | boundary._state = boundary.state; 234 | boundary.computeHits(this); 235 | 236 | if (boundary._state === 0) { 237 | state ^= boundary.mask; 238 | } 239 | } 240 | 241 | geometry._state = state; 242 | } 243 | 244 | this._geometry = null; 245 | this._boundary = null; 246 | 247 | const hits = this._hits; 248 | const numHits = this._hitsUsed; 249 | 250 | this._hitsRemaining = numHits; 251 | 252 | for (let i = numHits >> 1; i--;) { 253 | siftDown(hits, numHits, hits[i], i); 254 | } 255 | } 256 | 257 | /** 258 | * Get the next this that needs to be processed. 259 | * @returns {Hit} The next hit if there is still one. The returned hit is owned by the cast and becomes invalid once the cast is reset. 260 | */ 261 | nextHit() { 262 | const hits = this._hits; 263 | let numHits = this._hitsRemaining; 264 | 265 | if (numHits === 0) { 266 | return; 267 | } 268 | 269 | numHits--; 270 | this._hitsRemaining = numHits; 271 | 272 | const nextHit = hits[0]; 273 | 274 | if (numHits !== 0) { 275 | const lastHit = hits[numHits]; 276 | 277 | hits[numHits] = nextHit; 278 | siftDown(hits, numHits, lastHit, 0); 279 | } 280 | 281 | return nextHit; 282 | } 283 | 284 | /** 285 | * Reset the cast. 286 | * Invalidates all hit instances. 287 | */ 288 | reset() { 289 | const hits = this._hits; 290 | const numHits = this._hitsUsed; 291 | 292 | this._hitsUsed = 0; 293 | this._hitsRemaining = 0; 294 | 295 | for (let i = 0; i < numHits; i++) { 296 | const hit = hits[i]; 297 | 298 | hit.geometry = null; 299 | hit.boundary = null; 300 | } 301 | } 302 | } 303 | 304 | /** 305 | * The last cast ID. 306 | * @type {int32} 307 | */ 308 | let CAST_ID = 0; 309 | 310 | /** 311 | * Sift down the hit. 312 | * @param {Hit[]} hits - The hits. 313 | * @param {number} n - The number of hits. 314 | * @param {Hit} hit - The hit. 315 | * @param {number} i - The current index of the hit. 316 | */ 317 | function siftDown(hits, n, hit, i) { 318 | for (; ;) { 319 | const r = i + 1 << 1; 320 | const l = r - 1; 321 | let j = i; 322 | let h = hit; 323 | let temp; 324 | 325 | if (l < n && (temp = hits[l]).time < h.time) { 326 | h = temp; 327 | j = l; 328 | } 329 | 330 | if (r < n && (temp = hits[r]).time < h.time) { 331 | h = temp; 332 | j = r; 333 | } 334 | 335 | if (j === i) { 336 | break; 337 | } 338 | 339 | hits[i] = h; 340 | i = j; 341 | } 342 | 343 | hits[i] = hit; 344 | } 345 | -------------------------------------------------------------------------------- /scripts/raycast/geometry.mjs: -------------------------------------------------------------------------------- 1 | import Boundary from "./boundary.mjs"; 2 | 3 | /** 4 | * @import { int32 } from "./_types.mjs"; 5 | * @import Volume from "./volume.mjs"; 6 | */ 7 | 8 | /** 9 | * The next geometry ID. 10 | * @type {int32} 11 | */ 12 | let ID = 0; 13 | 14 | /** 15 | * The geometry of a {@link Volume}. 16 | * @sealed 17 | */ 18 | export default class Geometry { 19 | /** 20 | * An empty geometry. 21 | * @type {Geometry} 22 | * @readonly 23 | */ 24 | static EMPTY = new Geometry([], -1); 25 | 26 | /** 27 | * An unbounded geometry. 28 | * @type {Geometry} 29 | * @readonly 30 | */ 31 | static UNBOUNDED = new Geometry([], 0); 32 | 33 | /** 34 | * @param {object} args 35 | * @param {Boundary[]} args.boundaries - The boundaries. 36 | * @param {int32} [args.state=-1] - The bit state (32-bit integer). 37 | * @returns {Geometry} The geometry. 38 | */ 39 | static create({ boundaries, state = -1 }) { 40 | console.assert(Array.isArray(boundaries)); 41 | console.assert(boundaries.every((boundary) => boundary instanceof Boundary && boundary.mask !== 0)); 42 | console.assert(state === (state | 0)); 43 | 44 | return new Geometry(boundaries.toSorted(compareBoundariesByType), state); 45 | } 46 | 47 | /** 48 | * @param {Boundary[]} boundaries - The boundaries. 49 | * @param {int32} state - The bit state (32-bit integer). 50 | * @private 51 | * @ignore 52 | */ 53 | constructor(boundaries, state) { 54 | /** 55 | * The boundaries. 56 | * @type {ReadonlyArray} 57 | * @readonly 58 | */ 59 | this.boundaries = boundaries; 60 | 61 | /** 62 | * The initial state of the ray relative to this geometry. 63 | * @type {int32} 64 | * @readonly 65 | */ 66 | this.state = state; 67 | 68 | /** 69 | * The current state of the ray relative to this geometry. 70 | * If zero, the ray is currently inside the geometry. 71 | * @type {int32} 72 | * @internal 73 | * @ignore 74 | */ 75 | this._state = 0; 76 | 77 | /** 78 | * The ID of the geometry. 79 | * @type {int32} 80 | * @readonly 81 | * @internal 82 | * @ignore 83 | */ 84 | this._id = ID++; 85 | 86 | /** 87 | * The ray cast ID, which used to track whether hits were already computed for this geometry. 88 | * Also used to determine whether the cropped geometry of this geometry was already created; 89 | * in this case negative number are used. 90 | * @type {int32} 91 | * @internal 92 | * @ignore 93 | */ 94 | this._castId = 0; 95 | } 96 | 97 | /** 98 | * Is this geometry unbounded w.r.t. the space? 99 | * @type {boolean} 100 | */ 101 | get isUnbounded() { 102 | return this.state === 0 && this.boundaries.length === 0; 103 | } 104 | 105 | /** 106 | * Can this geometry be discarded as it wouldn't affect rays at all? 107 | * @type {boolean} 108 | */ 109 | get isEmpty() { 110 | return this.state !== 0 && this.boundaries.length === 0; 111 | } 112 | 113 | /** 114 | * Crop the geometry w.r.t. to the bounding box of the space. 115 | * @param {number} minX - The minimum x-coordinate. 116 | * @param {number} minY - The minimum y-coordinate. 117 | * @param {number} minZ - The minimum z-coordinate. 118 | * @param {number} maxX - The maximum x-coordinate. 119 | * @param {number} maxY - The maximum y-coordinate. 120 | * @param {number} maxZ - The maximum z-coordinate. 121 | * @returns {Geometry} The cropped geometry. 122 | */ 123 | crop(minX, minY, minZ, maxX, maxY, maxZ) { 124 | const boundaries = this.boundaries; 125 | const numBoundaries = boundaries.length; 126 | let croppedState = this.state; 127 | 128 | for (let boundaryIndex = 0; boundaryIndex < numBoundaries; boundaryIndex++) { 129 | const boundary = boundaries[boundaryIndex]; 130 | const croppedBoundary = boundary.crop(minX, minY, minZ, maxX, maxY, maxZ); 131 | 132 | if (croppedBoundary.isUnbounded) { 133 | if (croppedBoundary.state === 0) { 134 | croppedState ^= croppedBoundary.mask; 135 | } 136 | 137 | continue; 138 | } 139 | 140 | CROPPED_BOUNDARIES.push(croppedBoundary); 141 | } 142 | 143 | if (CROPPED_BOUNDARIES.length === 0) { 144 | return croppedState === 0 ? Geometry.UNBOUNDED : Geometry.EMPTY; 145 | } 146 | 147 | if (CROPPED_BOUNDARIES.length === numBoundaries) { 148 | let cropped = false; 149 | 150 | for (let boundaryIndex = 0; boundaryIndex < numBoundaries; boundaryIndex++) { 151 | if (CROPPED_BOUNDARIES[boundaryIndex] !== boundaries[boundaryIndex]) { 152 | cropped = true; 153 | 154 | break; 155 | } 156 | } 157 | 158 | if (!cropped) { 159 | CROPPED_BOUNDARIES.length = 0; 160 | 161 | return this; 162 | } 163 | } 164 | 165 | const croppedBoundaries = CROPPED_BOUNDARIES.slice(0); 166 | 167 | CROPPED_BOUNDARIES.length = 0; 168 | 169 | return new Geometry(croppedBoundaries, croppedState); 170 | } 171 | } 172 | 173 | /** 174 | * The array for cropped boundaries. 175 | * @type {Boundary[]} 176 | */ 177 | const CROPPED_BOUNDARIES = []; 178 | 179 | /** 180 | * The boundary type ID map. 181 | * @type {Map} 182 | */ 183 | const BOUNDARY_TYPE_IDS = new Map(); 184 | 185 | /** 186 | * Get the ID of the boundary's type. 187 | * @param {Boundary} boundary - The boundary. 188 | * @returns {int32} The boundary type ID. 189 | */ 190 | function getBoundaryTypeID(boundary) { 191 | const boundaryType = boundary.constructor; 192 | let id = BOUNDARY_TYPE_IDS.get(boundaryType); 193 | 194 | if (id === undefined) { 195 | id = BOUNDARY_TYPE_IDS.size; 196 | BOUNDARY_TYPE_IDS.set(boundaryType, id); 197 | } 198 | 199 | return id; 200 | } 201 | 202 | /** 203 | * Compare two boundaries by type. 204 | * @param {Boundary} boundary1 - The first boundary. 205 | * @param {Boundary} boundary2 - The second boundary. 206 | * @returns {number} 207 | */ 208 | function compareBoundariesByType(boundary1, boundary2) { 209 | return getBoundaryTypeID(boundary1) - getBoundaryTypeID(boundary2); 210 | } 211 | -------------------------------------------------------------------------------- /scripts/raycast/hit.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @import { int32 } from "./_types.mjs"; 3 | * @import Boundary from "./boundary.mjs"; 4 | * @import Geometry from "./geometry.mjs"; 5 | */ 6 | 7 | /** 8 | * @sealed 9 | * @hideconstructor 10 | */ 11 | export default class Hit { 12 | /** 13 | * The geometry that was hit. 14 | * @type {Geometry | null} 15 | * @readonly 16 | */ 17 | geometry = null; 18 | 19 | /** 20 | * The boundary that was hit. 21 | * @type {Boundary | null} 22 | * @readonly 23 | */ 24 | boundary = null; 25 | 26 | /** 27 | * The time the ray hits the boundary of the geometry. 28 | * @type {number} 29 | * @readonly 30 | */ 31 | time = 0.0; 32 | 33 | /** 34 | * The bit mask indicating which part of the boundary were hit (32-bit). 35 | * @type {int32} 36 | * @readonly 37 | */ 38 | mask = 0; 39 | } 40 | -------------------------------------------------------------------------------- /scripts/raycast/math.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Minimum. 3 | * @param {number} x 4 | * @param {number} y 5 | * @returns {number} 6 | * @internal 7 | * @ignore 8 | */ 9 | export function min(x, y) { 10 | return x < y ? x : y; 11 | } 12 | 13 | /** 14 | * Maximum. 15 | * @param {number} x 16 | * @param {number} y 17 | * @returns {number} 18 | * @internal 19 | * @ignore 20 | */ 21 | export function max(x, y) { 22 | return x > y ? x : y; 23 | } 24 | -------------------------------------------------------------------------------- /scripts/raycast/mode.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @enum {number & {}} 3 | */ 4 | export const Mode = Object.freeze({ 5 | /** 6 | * Add. 7 | */ 8 | ADD: 0, 9 | 10 | /** 11 | * Minimize. 12 | */ 13 | MINIMIZE: 1, 14 | 15 | /** 16 | * Maximize. 17 | */ 18 | MAXIMIZE: 2, 19 | 20 | /** 21 | * Override. 22 | */ 23 | OVERRIDE: 3, 24 | }); 25 | 26 | export default Mode; 27 | -------------------------------------------------------------------------------- /scripts/raycast/ray.mjs: -------------------------------------------------------------------------------- 1 | import Cast from "./cast.mjs"; 2 | import { max, min } from "./math.mjs"; 3 | import Space from "./space.mjs"; 4 | 5 | /** 6 | * @import { int32 } from "./_types.mjs"; 7 | */ 8 | 9 | /** 10 | * @sealed 11 | * @hideconstructor 12 | */ 13 | export default class Ray { 14 | /** 15 | * @returns {Ray} 16 | */ 17 | static create() { 18 | return new Ray(); 19 | } 20 | 21 | /** 22 | * The space. 23 | * @type {Space} 24 | * @readonly 25 | */ 26 | space = Space.EMPTY; 27 | 28 | /** 29 | * The minimum range. 30 | * @type {number} 31 | * @readonly 32 | */ 33 | minRange = 0.0; 34 | 35 | /** 36 | * The maximum range. 37 | * @type {number} 38 | * @readonly 39 | */ 40 | maxRange = Infinity; 41 | 42 | /** 43 | * The x-coordinate of the current origin. 44 | * @type {number} 45 | * @readonly 46 | */ 47 | originX = 0.0; 48 | 49 | /** 50 | * The y-coordinate of the current origin. 51 | * @type {number} 52 | * @readonly 53 | */ 54 | originY = 0.0; 55 | 56 | /** 57 | * The z-coordinate of the current origin. 58 | * @type {number} 59 | * @readonly 60 | */ 61 | originZ = 0.0; 62 | 63 | /** 64 | * The x-coordinate of the current target. 65 | * @type {number} 66 | * @readonly 67 | */ 68 | targetX = 0.0; 69 | 70 | /** 71 | * The y-coordinate of the current target. 72 | * @type {number} 73 | * @readonly 74 | */ 75 | targetY = 0.0; 76 | 77 | /** 78 | * The z-coordinate of the current target. 79 | * @type {number} 80 | * @readonly 81 | */ 82 | targetZ = 0.0; 83 | 84 | /** 85 | * The x-coordinate of the direction of the ray. 86 | * @type {number} 87 | */ 88 | get directionX() { 89 | return this.targetX - this.originX; 90 | } 91 | 92 | /** 93 | * The y-coordinate of the direction of the ray. 94 | * @type {number} 95 | */ 96 | get directionY() { 97 | return this.targetY - this.originY; 98 | } 99 | 100 | /** 101 | * The z-coordinate of the direction of the ray. 102 | * @type {number} 103 | */ 104 | get directionZ() { 105 | return this.targetZ - this.originZ; 106 | } 107 | 108 | /** 109 | * The distance from the origin to the target. 110 | * @type {number} 111 | */ 112 | get targetDistance() { 113 | let targetDistance = this._targetDistance; 114 | 115 | if (targetDistance < 0.0) { 116 | targetDistance = this._targetDistance = Math.hypot(this.directionX, this.directionY, this.directionZ); 117 | } 118 | 119 | return targetDistance; 120 | } 121 | 122 | /** 123 | * @type {number} 124 | * @private 125 | * @ignore 126 | */ 127 | _targetDistance = 0.0; 128 | 129 | /** 130 | * Did the ray hit the target? 131 | * @type {boolean} 132 | */ 133 | get targetHit() { 134 | if (this._targetHit === 0) { 135 | this._cast(false, false); 136 | } 137 | 138 | return this._targetHit > 0; 139 | } 140 | 141 | /** 142 | * @type {-1|0|1} 143 | * @private 144 | * @ignore 145 | */ 146 | _targetHit = 0; 147 | 148 | /** 149 | * The time that elapsed before the ray reached its destination. 150 | * @type {number} 151 | */ 152 | get elapsedTime() { 153 | if (this._elapsedTime < 0.0) { 154 | this._cast(true, false); 155 | } 156 | 157 | return this._elapsedTime; 158 | } 159 | 160 | /** 161 | * @type {number} 162 | * @private 163 | * @ignore 164 | */ 165 | _elapsedTime = -1.0; 166 | 167 | /** 168 | * The remaining energy of the ray when it reached its destination. 169 | * @type {number} 170 | */ 171 | get remainingEnergy() { 172 | if (this._remainingEnergy < 0.0) { 173 | this._cast(false, true); 174 | } 175 | 176 | return this._remainingEnergy; 177 | } 178 | 179 | /** 180 | * @type {number} 181 | * @private 182 | * @ignore 183 | */ 184 | _remainingEnergy = -1.0; 185 | 186 | /** 187 | * The distance that the ray travelled before it reached its destination. 188 | * @type {number} 189 | */ 190 | get distanceTravelled() { 191 | return this.targetDistance * this.elapsedTime; 192 | } 193 | 194 | /** 195 | * The x-coordinate of the destination of the curreny ray. 196 | * @type {number} 197 | */ 198 | get destinationX() { 199 | return this.originX + this.directionX * this.elapsedTime; 200 | } 201 | 202 | /** 203 | * The y-coordinate of the destination of the curreny ray. 204 | * @type {number} 205 | */ 206 | get destinationY() { 207 | return this.originY + this.directionY * this.elapsedTime; 208 | } 209 | 210 | /** 211 | * The z-coordinate of the destination of the curreny ray. 212 | * @type {number} 213 | */ 214 | get destinationZ() { 215 | return this.originZ + this.directionZ * this.elapsedTime; 216 | } 217 | 218 | /** 219 | * Set the space of the ray. 220 | * @param {Space} space - The space. 221 | * @returns {this} 222 | */ 223 | setSpace(space) { 224 | if (this.space !== space) { 225 | this.space = space; 226 | 227 | this._targetDistance = -1.0; 228 | this._targetHit = 0; 229 | this._elapsedTime = -1.0; 230 | this._remainingEnergy = -1.0; 231 | } 232 | 233 | return this; 234 | } 235 | 236 | /** 237 | * Set the ranges of the ray. 238 | * @param {number} minRange - The minimum range within no energy is consumed. 239 | * @param {number} maxRange - The maximum range that the ray can travel. 240 | * @returns {this} 241 | */ 242 | setRange(minRange, maxRange) { 243 | this.minRange = minRange; 244 | this.maxRange = max(maxRange, minRange); 245 | 246 | this._targetDistance = -1.0; 247 | this._targetHit = 0; 248 | this._elapsedTime = -1.0; 249 | this._remainingEnergy = -1.0; 250 | 251 | return this; 252 | } 253 | 254 | /** 255 | * Set the origin for the ray. 256 | * @param {number} originX - The x-coordinate of the origin. 257 | * @param {number} originY - The y-coordinate of the origin. 258 | * @param {number} originZ - The z-coordinate of the origin. 259 | * @returns {this} 260 | */ 261 | setOrigin(originX, originY, originZ) { 262 | this.originX = originX; 263 | this.originY = originY; 264 | this.originZ = originZ; 265 | 266 | this._targetDistance = -1.0; 267 | this._targetHit = 0; 268 | this._elapsedTime = -1.0; 269 | this._remainingEnergy = -1.0; 270 | 271 | return this; 272 | } 273 | 274 | /** 275 | * Set the target for the next ray casts. 276 | * @param {number} targetX - The x-coordinate of the target. 277 | * @param {number} targetY - The y-coordinate of the target. 278 | * @param {number} targetZ - The z-coordinate of the target. 279 | * @returns {this} 280 | */ 281 | setTarget(targetX, targetY, targetZ) { 282 | this.targetX = targetX; 283 | this.targetY = targetY; 284 | this.targetZ = targetZ; 285 | 286 | this._targetDistance = -1.0; 287 | this._targetHit = 0; 288 | this._elapsedTime = -1.0; 289 | this._remainingEnergy = -1.0; 290 | 291 | return this; 292 | } 293 | 294 | /** 295 | * Reset the ray. 296 | */ 297 | reset() { 298 | this.space = Space.EMPTY; 299 | this.minRange = 0.0; 300 | this.maxRange = Infinity; 301 | this.targetX = 0.0; 302 | this.targetY = 0.0; 303 | this.targetZ = 0.0; 304 | this.originX = 0.0; 305 | this.originY = 0.0; 306 | this.originZ = 0.0; 307 | 308 | this._targetDistance = -1.0; 309 | this._targetHit = 0; 310 | this._elapsedTime = -1.0; 311 | this._remainingEnergy = -1.0; 312 | } 313 | 314 | /** 315 | * Cast the ray from the origin to the target point. 316 | * @param {boolean} computeElapsedTime - Compute the elapsed time of the ray. 317 | * @param {boolean} computeRemainingEnergy - Compute the remaining energy of the ray. 318 | * @private 319 | * @ignore 320 | */ 321 | _cast(computeElapsedTime, computeRemainingEnergy) { 322 | const { space, minRange, targetDistance } = this; 323 | 324 | if (targetDistance - minRange < space.minDistance + 0.5 / 256.0) { 325 | this._targetHit = 1; 326 | this._elapsedTime = 1.0; 327 | 328 | if (targetDistance <= minRange) { 329 | this._remainingEnergy = 1.0; 330 | 331 | return; 332 | } 333 | 334 | if (!computeRemainingEnergy) { 335 | return; 336 | } 337 | } 338 | 339 | if (!computeElapsedTime && targetDistance > space.maxDistance + 0.5 / 256.0) { 340 | this._targetHit = -1; 341 | this._remainingEnergy = 0.0; 342 | 343 | return; 344 | } 345 | 346 | CAST.computeHits(this); 347 | 348 | let stage = 0; 349 | let currentTime = 0.0; 350 | let currentCost = 0.0; 351 | let remainingEnergy = 1.0 / targetDistance; 352 | const almostZeroEnergy = remainingEnergy * 1e-12; 353 | 354 | for (; ;) { 355 | const hit = CAST.nextHit(); 356 | 357 | if (!hit) { 358 | break; 359 | } 360 | 361 | const hitGeometry = hit.geometry; 362 | 363 | if (hitGeometry) { 364 | const hitBoundary = hit.boundary; 365 | const hitBoundaryState = hitBoundary._state; 366 | 367 | if ((hitBoundary._state = hitBoundaryState ^ hit.mask) !== 0 && hitBoundaryState !== 0) { 368 | continue; 369 | } 370 | 371 | const hitGeometryState = hitGeometry._state; 372 | 373 | if ((hitGeometry._state = hitGeometryState ^ hitBoundary.mask) !== 0 && hitGeometryState !== 0) { 374 | continue; 375 | } 376 | } else { 377 | stage++; 378 | } 379 | 380 | const hitTime = hit.time; 381 | const deltaTime = hitTime - currentTime; 382 | const requiredEnergy = deltaTime > 0.0 ? deltaTime * min(currentCost, 256.0) : 0.0; 383 | 384 | if (remainingEnergy <= requiredEnergy) { 385 | break; 386 | } 387 | 388 | remainingEnergy -= requiredEnergy; 389 | 390 | currentTime = hitTime; 391 | 392 | if (stage === 0) { 393 | currentCost = 0.0; 394 | } else if (stage === 1) { 395 | currentCost = calculateCost(this.space); 396 | } else { 397 | currentCost = 0.0; 398 | 399 | if (remainingEnergy <= almostZeroEnergy) { 400 | remainingEnergy = 0.0; 401 | } 402 | 403 | break; 404 | } 405 | 406 | if (remainingEnergy <= almostZeroEnergy) { 407 | remainingEnergy = 0.0; 408 | 409 | break; 410 | } 411 | } 412 | 413 | if (currentCost !== 0) { 414 | const requiredEnergy = currentTime < 1.0 ? (1.0 - currentTime) * currentCost : 0.0; 415 | 416 | currentTime = min(currentTime + remainingEnergy / currentCost, 1.0); 417 | remainingEnergy -= requiredEnergy; 418 | 419 | if (remainingEnergy <= almostZeroEnergy) { 420 | remainingEnergy = 0.0; 421 | } 422 | } else if (remainingEnergy !== 0.0) { 423 | currentTime = 1.0; 424 | } 425 | 426 | if (currentTime * targetDistance > targetDistance - 0.5 / 256.0) { 427 | currentTime = 1.0; 428 | } 429 | 430 | this._targetHit = currentTime === 1.0 ? 1 : -1; 431 | this._elapsedTime = currentTime; 432 | this._remainingEnergy = min(remainingEnergy * targetDistance, 1.0); 433 | 434 | CAST.reset(); 435 | } 436 | } 437 | 438 | /** 439 | * @type {Cast} 440 | * @internal 441 | * @ignore 442 | */ 443 | const CAST = new Cast(1024); 444 | 445 | /** 446 | * Calculate the current energy cost. 447 | * @param {Space} space - The space. 448 | * @returns {number} The current energy cost. 449 | */ 450 | function calculateCost(space) { 451 | let calculatedCost = 0.0; 452 | const volumes = space.volumes; 453 | const numVolumes = volumes.length; 454 | 455 | for (let volumeIndex = 0; volumeIndex < numVolumes; volumeIndex++) { 456 | const volume = volumes[volumeIndex]; 457 | 458 | if (volume.geometry._state !== 0) { 459 | continue; 460 | } 461 | 462 | const cost = volume.cost; 463 | 464 | switch (volume.mode) { 465 | case 0: 466 | calculatedCost = max(calculatedCost + cost, 0.0); 467 | 468 | break; 469 | case 1: 470 | calculatedCost = min(calculatedCost, cost); 471 | 472 | break; 473 | case 2: 474 | calculatedCost = max(calculatedCost, cost); 475 | 476 | break; 477 | case 3: 478 | calculatedCost = cost; 479 | 480 | break; 481 | } 482 | } 483 | 484 | return calculatedCost; 485 | } 486 | -------------------------------------------------------------------------------- /scripts/raycast/shape.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @import { int31 } from "./_types.mjs"; 3 | * @import Cast from "./cast.mjs"; 4 | * @import Region from "./boundaries/region.mjs"; 5 | */ 6 | 7 | /** 8 | * The shape of a {@link Region}. 9 | * @abstract 10 | */ 11 | export default class Shape { 12 | /** 13 | * @param {int31} mask - The mask (nonzero 31-bit integer). 14 | */ 15 | constructor(mask) { 16 | /** 17 | * The bit mask of the shape (31-bit). 18 | * @type {int31} 19 | * @readonly 20 | */ 21 | this.mask = mask; 22 | } 23 | 24 | /** 25 | * Test whether the shape contains (1) or not intersects (-1) the bounding box. 26 | * @param {number} minX - The minimum x-coordinate. 27 | * @param {number} minY - The minimum y-coordinate. 28 | * @param {number} maxX - The maximum x-coordinate. 29 | * @param {number} maxY - The maximum y-coordinate. 30 | * @returns {-1|0|1} If 1, then the bounding box is contained in the shape. If -1, if the bounding box does not intersect with the shape. 31 | * @virtual 32 | */ 33 | testBounds(minX, minY, maxX, maxY) { 34 | return 0; 35 | } 36 | 37 | /** 38 | * Test whether the shape contains the point. 39 | * @param {number} x - The x-coordinate of the point. 40 | * @param {number} y - The y-coordinate of the point. 41 | * @returns {boolean} True if the shape contains the point. 42 | * @virtual 43 | * @abstract 44 | */ 45 | containsPoint(x, y) { 46 | return false; 47 | } 48 | 49 | /** 50 | * Compute the hits of the shape with the ray. 51 | * This function is called only with nonzero x/y-direction. 52 | * @param {Cast} cast - The cast. 53 | * @virtual 54 | * @abstract 55 | */ 56 | computeHits(cast) { } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/raycast/shapes/_module.mjs: -------------------------------------------------------------------------------- 1 | export { default as Bounds } from "./bounds.mjs"; 2 | export { default as Circle } from "./circle.mjs"; 3 | export { default as Ellipse } from "./ellipse.mjs"; 4 | export { default as Polygon } from "./polygon.mjs"; 5 | export { default as Rectangle } from "./rectangle.mjs"; 6 | export { default as Tile } from "./tile.mjs"; 7 | -------------------------------------------------------------------------------- /scripts/raycast/shapes/bounds.mjs: -------------------------------------------------------------------------------- 1 | import Shape from "../shape.mjs"; 2 | import { max, min } from "../math.mjs"; 3 | 4 | /** 5 | * @import { int31 } from "../_types.mjs"; 6 | * @import Cast from "../cast.mjs"; 7 | */ 8 | 9 | /** 10 | * @sealed 11 | * @hideconstructor 12 | */ 13 | export default class Bounds extends Shape { 14 | /** 15 | * @param {object} args 16 | * @param {number} args.minX - The minimum x-coordinate (finite). 17 | * @param {number} args.minY - The minimum y-coordinate (finite). 18 | * @param {number} args.maxX - The maximum x-coordinate (finite). 19 | * @param {number} args.maxY - The maximum y-coordinate (finite). 20 | * @param {int31} [args.mask=0x7FFFFFFF] - The mask (nonzero 31-bit integer). 21 | * @returns {Bounds} The bounds. 22 | */ 23 | static create({ minX, minY, maxX, maxY, mask = 0x7FFFFFFF }) { 24 | console.assert(typeof minX === "number"); 25 | console.assert(typeof minY === "number"); 26 | console.assert(typeof maxX === "number"); 27 | console.assert(typeof maxY === "number"); 28 | console.assert(Number.isFinite(minX)); 29 | console.assert(Number.isFinite(minY)); 30 | console.assert(Number.isFinite(maxX)); 31 | console.assert(Number.isFinite(maxY)); 32 | console.assert(minX < maxX); 33 | console.assert(minY < maxY); 34 | console.assert(mask === (mask & 0x7FFFFFFF) && mask !== 0); 35 | 36 | return new Bounds(mask | 0, minX + 0.0, minY + 0.0, maxX + 0.0, maxY + 0.0); 37 | } 38 | 39 | /** 40 | * @param {int31} mask - The mask (nonzero 31-bit integer). 41 | * @param {number} minX - The minimum x-coordinate (finite). 42 | * @param {number} minY - The minimum y-coordinate (finite). 43 | * @param {number} maxX - The maximum x-coordinate (finite). 44 | * @param {number} maxY - The maximum y-coordinate (finite). 45 | * @private 46 | * @ignore 47 | */ 48 | constructor(mask, minX, minY, maxX, maxY) { 49 | super(mask); 50 | 51 | /** 52 | * The minimum x-coordinate (finite). 53 | * @type {number} 54 | * @readonly 55 | * @private 56 | * @ignore 57 | */ 58 | this._minX = minX; 59 | 60 | /** 61 | * The minimum y-coordinate (finite). 62 | * @type {number} 63 | * @readonly 64 | * @private 65 | * @ignore 66 | */ 67 | this._minY = minY; 68 | 69 | /** 70 | * The maximum x-coordinate (finite). 71 | * @type {number} 72 | * @readonly 73 | * @private 74 | * @ignore 75 | */ 76 | this._maxX = maxX; 77 | 78 | /** 79 | * The maximum y-coordinate (finite). 80 | * @type {number} 81 | * @readonly 82 | * @private 83 | * @ignore 84 | */ 85 | this._maxY = maxY; 86 | } 87 | 88 | /** 89 | * @param {number} minX - The minimum x-coordinate. 90 | * @param {number} minY - The minimum y-coordinate. 91 | * @param {number} maxX - The maximum x-coordinate. 92 | * @param {number} maxY - The maximum y-coordinate. 93 | * @returns {-1|0|1} If 1, then the bounding box is contained in the shape. If -1, if the bounding box does not intersect with the shape. 94 | * @inheritDoc 95 | */ 96 | testBounds(minX, minY, maxX, maxY) { 97 | const x0 = this._minX; 98 | const x1 = this._maxX; 99 | 100 | if (max(x0, minX) > min(x1, maxX)) { 101 | return -1; 102 | } 103 | 104 | const y0 = this._minY; 105 | const y1 = this._maxY; 106 | 107 | if (max(y0, minY) > min(y1, maxY)) { 108 | return -1; 109 | } 110 | 111 | if (x0 > minX || maxX > x1 || y0 > minY || maxY > y1) { 112 | return 0; 113 | } 114 | 115 | return 1; 116 | } 117 | 118 | /** 119 | * @param {number} x - The x-coordinate of the point. 120 | * @param {number} y - The y-coordinate of the point. 121 | * @returns {boolean} True if the shape contains the point. 122 | * @inheritDoc 123 | */ 124 | containsPoint(x, y) { 125 | return this._minX <= x && x <= this._maxX && this._minY <= y && y <= this._maxY; 126 | } 127 | 128 | /** 129 | * @param {Cast} cast - The cast. 130 | * @inheritDoc 131 | */ 132 | computeHits(cast) { 133 | const { originX, originY, invDirectionX, invDirectionY } = cast; 134 | 135 | let t1 = (this._minX - originX) * invDirectionX; 136 | let t2 = (this._maxX - originX) * invDirectionX; 137 | let time1 = min(max(t1, 0.0), max(t2, 0.0)); 138 | let time2 = max(min(t1, Infinity), min(t2, Infinity)); 139 | 140 | t1 = (this._minY - originY) * invDirectionY; 141 | t2 = (this._maxY - originY) * invDirectionY; 142 | time1 = min(max(t1, time1), max(t2, time1)); 143 | time2 = max(min(t1, time2), min(t2, time2)); 144 | 145 | if (time1 <= min(time2, 1.0)) { 146 | const mask = this.mask; 147 | 148 | if (time1 > 0) { 149 | cast.addHit(time1, mask); 150 | } 151 | 152 | cast.addHit(time2, mask); 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /scripts/raycast/shapes/circle.mjs: -------------------------------------------------------------------------------- 1 | import Shape from "../shape.mjs"; 2 | 3 | /** 4 | * @import { int31 } from "../_types.mjs"; 5 | * @import Cast from "../cast.mjs"; 6 | */ 7 | 8 | /** 9 | * @sealed 10 | */ 11 | export default class Circle extends Shape { 12 | /** 13 | * @param {object} args 14 | * @param {number} args.centerX - The x-coordinate of the center. 15 | * @param {number} args.centerY - The y-coordinate of the center. 16 | * @param {number} args.radius - The radius (positive). 17 | * @param {int31} [args.mask=0x7FFFFFFF] - The mask (nonzero 31-bit integer). 18 | * @returns {Circle} The circle. 19 | */ 20 | static create({ centerX, centerY, radius, mask = 0x7FFFFFFF }) { 21 | console.assert(typeof centerX === "number"); 22 | console.assert(typeof centerY === "number"); 23 | console.assert(typeof radius === "number"); 24 | console.assert(Number.isFinite(centerX)); 25 | console.assert(Number.isFinite(centerY)); 26 | console.assert(Number.isFinite(radius)); 27 | console.assert(radius > 0); 28 | console.assert(mask === (mask & 0x7FFFFFFF) && mask !== 0); 29 | 30 | return new Circle(mask | 0, centerX + 0.0, centerY + 0.0, radius + 0.0); 31 | } 32 | 33 | /** 34 | * @param {int31} mask - The mask (nonzero 31-bit integer). 35 | * @param {number} centerX - The x-coordinate of the center. 36 | * @param {number} centerY - The y-coordinate of the center. 37 | * @param {number} radius - The radius (finite, positive). 38 | * @private 39 | * @ignore 40 | */ 41 | constructor(mask, centerX, centerY, radius) { 42 | super(mask); 43 | 44 | /** 45 | * The x-coordinate of the center. 46 | * @type {number} 47 | * @readonly 48 | * @private 49 | * @ignore 50 | */ 51 | this._centerX = centerX; 52 | 53 | /** 54 | * The y-coordinate of the center. 55 | * @type {number} 56 | * @readonly 57 | * @private 58 | * @ignore 59 | */ 60 | this._centerY = centerY; 61 | 62 | /** 63 | * The radius. 64 | * @type {number} 65 | * @readonly 66 | * @private 67 | * @ignore 68 | */ 69 | this._radius = radius; 70 | } 71 | 72 | /** 73 | * @param {number} minX - The minimum x-coordinate. 74 | * @param {number} minY - The minimum y-coordinate. 75 | * @param {number} maxX - The maximum x-coordinate. 76 | * @param {number} maxY - The maximum y-coordinate. 77 | * @returns {-1|0|1} If 1, then the bounding box is contained in the shape. If -1, if the bounding box does not intersect with the shape. 78 | * @inheritDoc 79 | */ 80 | testBounds(minX, minY, maxX, maxY) { 81 | const radius = this._radius; 82 | const centerX = this._centerX; 83 | 84 | if (centerX + radius < minX || maxX < centerX - radius) { 85 | return -1; 86 | } 87 | 88 | const centerY = this._centerY; 89 | 90 | if (centerY + radius < minY || maxY < centerY - radius) { 91 | return -1; 92 | } 93 | 94 | if (centerX - radius > minX || maxX > centerX + radius || centerY - radius > minY || maxY > centerY + radius) { 95 | return 0; 96 | } 97 | 98 | const radiusSquared = radius * radius; 99 | 100 | let x = minX - centerX; 101 | let y = minY - centerY; 102 | 103 | if (x * x + y * y > radiusSquared) { 104 | return 0; 105 | } 106 | 107 | x = maxX - centerX; 108 | y = minY - centerY; 109 | 110 | if (x * x + y * y > radiusSquared) { 111 | return 0; 112 | } 113 | 114 | x = maxX - centerX; 115 | y = maxY - centerY; 116 | 117 | if (x * x + y * y > radiusSquared) { 118 | return 0; 119 | } 120 | 121 | x = minX - centerX; 122 | y = maxY - centerY; 123 | 124 | if (x * x + y * y > radiusSquared) { 125 | return 0; 126 | } 127 | 128 | return 1; 129 | } 130 | 131 | /** 132 | * @param {number} x - The x-coordinate of the point. 133 | * @param {number} y - The y-coordinate of the point. 134 | * @returns {boolean} True if the shape contains the point. 135 | * @inheritDoc 136 | */ 137 | containsPoint(x, y) { 138 | x -= this._centerX; 139 | y -= this._centerY; 140 | 141 | const radius = this._radius; 142 | 143 | return x * x + y * y <= radius * radius; 144 | } 145 | 146 | /** 147 | * @param {Cast} cast - The cast. 148 | * @inheritDoc 149 | */ 150 | computeHits(cast) { 151 | const { originX, originY, directionX, directionY } = cast; 152 | const invRadius = 1.0 / this._radius; 153 | const x = (originX - this._centerX) * invRadius; 154 | const y = (originY - this._centerY) * invRadius; 155 | const dx = directionX * invRadius; 156 | const dy = directionY * invRadius; 157 | const a = dx * dx + dy * dy; 158 | const b = dx * x + dy * y; 159 | const c = x * x + y * y - 1; 160 | 161 | let time1 = 0.0; 162 | let time2 = 0.0; 163 | 164 | if (c !== 0.0) { 165 | const d = b * b - a * c; 166 | 167 | if (d <= 1e-6) { 168 | return; 169 | } 170 | 171 | const f = Math.sqrt(d); 172 | 173 | if (b !== 0.0) { 174 | time1 = (-b - Math.sign(b) * f) / a; 175 | time2 = c / (a * time1); 176 | } else { 177 | time1 = f / a; 178 | time2 = -time1; 179 | } 180 | } else { 181 | time2 = -b / a; 182 | } 183 | 184 | if (time1 > 0.0) { 185 | cast.addHit(time1, this.mask); 186 | } 187 | 188 | if (time2 > 0.0) { 189 | cast.addHit(time2, this.mask); 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /scripts/raycast/shapes/ellipse.mjs: -------------------------------------------------------------------------------- 1 | import Shape from "../shape.mjs"; 2 | import { max, min } from "../math.mjs"; 3 | 4 | /** 5 | * @import { int31 } from "../_types.mjs"; 6 | * @import Cast from "../cast.mjs"; 7 | */ 8 | 9 | /** 10 | * @sealed 11 | */ 12 | export default class Ellipse extends Shape { 13 | /** 14 | * @param {object} args 15 | * @param {number} args.centerX - The x-coordinate of the center. 16 | * @param {number} args.centerY - The y-coordinate of the center. 17 | * @param {number} args.radiusX - The x-radius (finite, positive). 18 | * @param {number} args.radiusY - The y-radius (finite, positive). 19 | * @param {number} [args.rotation=0.0] - The rotation in radians. 20 | * @param {int31} [args.mask=0x7FFFFFFF] - The mask (nonzero 31-bit integer). 21 | * @returns {Ellipse} The ellipse. 22 | */ 23 | static create({ centerX, centerY, radiusX, radiusY, rotation = 0.0, mask = 0x7FFFFFFF }) { 24 | console.assert(typeof centerX === "number"); 25 | console.assert(typeof centerY === "number"); 26 | console.assert(typeof radiusX === "number"); 27 | console.assert(typeof radiusY === "number"); 28 | console.assert(typeof rotation === "number"); 29 | console.assert(Number.isFinite(centerX)); 30 | console.assert(Number.isFinite(centerY)); 31 | console.assert(Number.isFinite(radiusX)); 32 | console.assert(Number.isFinite(radiusY)); 33 | console.assert(Number.isFinite(rotation)); 34 | console.assert(radiusX > 0); 35 | console.assert(radiusY > 0); 36 | console.assert(mask === (mask & 0x7FFFFFFF) && mask !== 0); 37 | 38 | return new Ellipse(mask | 0, centerX + 0.0, centerY + 0.0, radiusX + 0.0, radiusY + 0.0, rotation + 0.0); 39 | } 40 | 41 | /** 42 | * @param {int31} mask - The mask (nonzero 31-bit integer). 43 | * @param {number} centerX - The x-coordinate of the center. 44 | * @param {number} centerY - The y-coordinate of the center. 45 | * @param {number} radiusX - The x-radius (positive). 46 | * @param {number} radiusY - The y-radius (positive). 47 | * @param {number} rotation - The rotation in radians. 48 | * @private 49 | * @ignore 50 | */ 51 | constructor(mask, centerX, centerY, radiusX, radiusY, rotation) { 52 | super(mask); 53 | 54 | const cos = Math.cos(rotation); 55 | const sin = Math.sin(rotation); 56 | const deltaX = Math.hypot(radiusX * cos, radiusY * sin); 57 | const deltaY = Math.hypot(radiusX * sin, radiusY * cos); 58 | const scaleX = cos / radiusX; 59 | const skewX = -sin / radiusY; 60 | const skewY = sin / radiusX; 61 | const scaleY = cos / radiusY; 62 | 63 | /** 64 | * The minimum x-coordinate. 65 | * @type {number} 66 | * @readonly 67 | * @private 68 | * @ignore 69 | */ 70 | this._minX = centerX - deltaX; 71 | 72 | /** 73 | * The minimum y-coordinate. 74 | * @type {number} 75 | * @readonly 76 | * @private 77 | * @ignore 78 | */ 79 | this._minY = centerY - deltaY; 80 | 81 | /** 82 | * The maximum x-coordinate. 83 | * @type {number} 84 | * @readonly 85 | * @private 86 | * @ignore 87 | */ 88 | this._maxX = centerX + deltaX; 89 | 90 | /** 91 | * The maximum y-coordinate. 92 | * @type {number} 93 | * @readonly 94 | * @private 95 | * @ignore 96 | */ 97 | this._maxY = centerY + deltaY; 98 | 99 | /** 100 | * The x-scale of the matrix. 101 | * @type {number} 102 | * @readonly 103 | * @private 104 | * @ignore 105 | */ 106 | this._scaleX = scaleX; 107 | 108 | /** 109 | * The x-skew of the matrix. 110 | * @type {number} 111 | * @readonly 112 | * @private 113 | * @ignore 114 | */ 115 | this._skewX = skewX; 116 | 117 | /** 118 | * The y-skew of the matrix. 119 | * @type {number} 120 | * @readonly 121 | * @private 122 | * @ignore 123 | */ 124 | this._skewY = skewY; 125 | 126 | /** 127 | * The y-scale of the matrix. 128 | * @type {number} 129 | * @readonly 130 | * @private 131 | * @ignore 132 | */ 133 | this._scaleY = scaleY; 134 | 135 | /** 136 | * The x-translation of the matrix. 137 | * @type {number} 138 | * @readonly 139 | * @private 140 | * @ignore 141 | */ 142 | this._translationX = -(centerX * scaleX + centerY * skewY); 143 | 144 | /** 145 | * The y-translation of the matrix. 146 | * @type {number} 147 | * @readonly 148 | * @private 149 | * @ignore 150 | */ 151 | this._translationY = -(centerX * skewX + centerY * scaleY); 152 | } 153 | 154 | /** 155 | * @param {number} minX - The minimum x-coordinate. 156 | * @param {number} minY - The minimum y-coordinate. 157 | * @param {number} maxX - The maximum x-coordinate. 158 | * @param {number} maxY - The maximum y-coordinate. 159 | * @returns {-1|0|1} If 1, then the bounding box is contained in the shape. If -1, if the bounding box does not intersect with the shape. 160 | * @inheritDoc 161 | */ 162 | testBounds(minX, minY, maxX, maxY) { 163 | const x0 = this._minX; 164 | const x1 = this._maxX; 165 | 166 | if (max(x0, minX) > min(x1, maxX)) { 167 | return -1; 168 | } 169 | 170 | const y0 = this._minY; 171 | const y1 = this._maxY; 172 | 173 | if (max(y0, minY) > min(y1, maxY)) { 174 | return -1; 175 | } 176 | 177 | if (x0 > minX || maxX > x1 || y0 > minY || maxY > y1) { 178 | return 0; 179 | } 180 | 181 | const ma = this._scaleX; 182 | const mb = this._skewX; 183 | const mc = this._skewY; 184 | const md = this._scaleY; 185 | const mx = this._translationX; 186 | const my = this._translationY; 187 | 188 | let x = ma * minX + mc * minY + mx; 189 | let y = mb * minX + md * minY + my; 190 | 191 | if (x * x + y * y > 1.0) { 192 | return 0; 193 | } 194 | 195 | x = ma * maxX + mc * minY + mx; 196 | y = mb * maxX + md * minY + my; 197 | 198 | if (x * x + y * y > 1.0) { 199 | return 0; 200 | } 201 | 202 | x = ma * maxX + mc * maxY + mx; 203 | y = mb * maxX + md * maxY + my; 204 | 205 | if (x * x + y * y > 1.0) { 206 | return 0; 207 | } 208 | 209 | x = ma * minX + mc * maxY + mx; 210 | y = mb * minX + md * maxY + my; 211 | 212 | if (x * x + y * y > 1.0) { 213 | return 0; 214 | } 215 | 216 | return 1; 217 | } 218 | 219 | /** 220 | * @param {number} x - The x-coordinate of the point. 221 | * @param {number} y - The y-coordinate of the point. 222 | * @returns {boolean} True if the shape contains the point. 223 | * @inheritDoc 224 | */ 225 | containsPoint(x, y) { 226 | const ma = this._scaleX; 227 | const mb = this._skewX; 228 | const mc = this._skewY; 229 | const md = this._scaleY; 230 | const mx = this._translationX; 231 | const my = this._translationY; 232 | const x0 = ma * x + mc * y + mx; 233 | const y0 = mb * x + md * y + my; 234 | 235 | return x0 * x0 + y0 * y0 <= 1.0; 236 | } 237 | 238 | /** 239 | * @param {Cast} cast - The cast. 240 | * @inheritDoc 241 | */ 242 | computeHits(cast) { 243 | const { originX, originY, directionX, directionY } = cast; 244 | const ma = this._scaleX; 245 | const mb = this._skewX; 246 | const mc = this._skewY; 247 | const md = this._scaleY; 248 | const mx = this._translationX; 249 | const my = this._translationY; 250 | const x = ma * originX + mc * originY + mx; 251 | const y = mb * originX + md * originY + my; 252 | const dx = ma * directionX + mc * directionY; 253 | const dy = mb * directionX + md * directionY; 254 | const a = dx * dx + dy * dy; 255 | const b = dx * x + dy * y; 256 | const c = x * x + y * y - 1.0; 257 | let time1, time2; 258 | 259 | if (c !== 0.0) { 260 | const d = b * b - a * c; 261 | 262 | if (d <= 1e-6) { 263 | return; 264 | } 265 | 266 | const f = Math.sqrt(d); 267 | 268 | if (b !== 0.0) { 269 | time1 = (-b - Math.sign(b) * f) / a; 270 | time2 = c / (a * time1); 271 | } else { 272 | time1 = f / a; 273 | time2 = -time1; 274 | } 275 | } else { 276 | time1 = 0.0; 277 | time2 = -b / a; 278 | } 279 | 280 | if (time1 > 0.0) { 281 | cast.addHit(time1, this.mask); 282 | } 283 | 284 | if (time2 > 0.0) { 285 | cast.addHit(time2, this.mask); 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /scripts/raycast/shapes/polygon.mjs: -------------------------------------------------------------------------------- 1 | import Shape from "../shape.mjs"; 2 | import { max, min } from "../math.mjs"; 3 | 4 | /** 5 | * @import { int31 } from "../_types.mjs"; 6 | * @import Cast from "../cast.mjs"; 7 | */ 8 | 9 | /** 10 | * @sealed 11 | */ 12 | export default class Polygon extends Shape { 13 | /** 14 | * @param {object} args 15 | * @param {number} args.points - The points of the polygon (`[x0, y0, x1, y1, x2, y2, ...]`). 16 | * @param {int31} [args.mask=0x7FFFFFFF] - The mask (nonzero 31-bit integer). 17 | * @returns {Polygon} The polygon. 18 | */ 19 | static create({ points, mask = 0x7FFFFFFF }) { 20 | console.assert(Array.isArray(points)); 21 | console.assert(points.every((v) => typeof v === "number" && Number.isFinite(v))); 22 | console.assert(points.length >= 6); 23 | console.assert(points.length % 2 === 0); 24 | console.assert(points.some((v, i) => i % 2 === 0 && v !== points[0])); 25 | console.assert(points.some((v, i) => i % 2 === 1 && v !== points[1])); 26 | console.assert(mask === (mask & 0x7FFFFFFF) && mask !== 0); 27 | 28 | const n = points.length; 29 | const roundedPoints = new Float64Array(n); 30 | 31 | for (let i = 0; i < n; i++) { 32 | roundedPoints[i] = (points[i] + 6755399441055744.0) - 6755399441055744.0; 33 | } 34 | 35 | return new Polygon(mask | 0, roundedPoints); 36 | } 37 | 38 | /** 39 | * @param {int31} mask - The mask (nonzero 31-bit integer). 40 | * @param {Float64Array} points - The points of the polygon (`[x0, y0, x1, y1, x2, y2, ...]`). 41 | * @private 42 | * @ignore 43 | */ 44 | constructor(mask, points) { 45 | super(mask); 46 | 47 | const n = points.length; 48 | let minX = points[0]; 49 | let minY = points[1]; 50 | let maxX = minX; 51 | let maxY = minY; 52 | 53 | for (let i = 2; i < n; i += 2) { 54 | const x = points[i]; 55 | const y = points[i + 1]; 56 | 57 | minX = min(minX, x); 58 | minY = min(minY, y); 59 | maxX = max(maxX, x); 60 | maxY = max(maxY, y); 61 | } 62 | 63 | /** 64 | * The points of the polygon. 65 | * @type {Float64Array} 66 | * @readonly 67 | * @private 68 | * @ignore 69 | */ 70 | this._points = points; 71 | 72 | /** 73 | * The minimum x-coordinate. 74 | * @type {number} 75 | * @readonly 76 | * @private 77 | * @ignore 78 | */ 79 | this._minX = minX; 80 | 81 | /** 82 | * The minimum y-coordinate. 83 | * @type {number} 84 | * @readonly 85 | * @private 86 | * @ignore 87 | */ 88 | this._minY = minY; 89 | 90 | /** 91 | * The maximum x-coordinate. 92 | * @type {number} 93 | * @readonly 94 | * @private 95 | * @ignore 96 | */ 97 | this._maxX = maxX; 98 | 99 | /** 100 | * The maximum y-coordinate. 101 | * @type {number} 102 | * @readonly 103 | * @private 104 | * @ignore 105 | */ 106 | this._maxY = maxY; 107 | } 108 | 109 | /** 110 | * @param {number} minX - The minimum x-coordinate. 111 | * @param {number} minY - The minimum y-coordinate. 112 | * @param {number} maxX - The maximum x-coordinate. 113 | * @param {number} maxY - The maximum y-coordinate. 114 | * @returns {-1|0|1} If 1, then the bounding box is contained in the shape. If -1, if the bounding box does not intersect with the shape. 115 | * @inheritDoc 116 | */ 117 | testBounds(minX, minY, maxX, maxY) { 118 | const x0 = this._minX; 119 | const x1 = this._maxX; 120 | 121 | if (max(x0, minX) > min(x1, maxX)) { 122 | return -1; 123 | } 124 | 125 | const y0 = this._minY; 126 | const y1 = this._maxY; 127 | 128 | if (max(y0, minY) > min(y1, maxY)) { 129 | return -1; 130 | } 131 | 132 | if (x0 > minX || maxX > x1 || y0 > minY || maxY > y1) { 133 | return 0; 134 | } 135 | 136 | const centerX = (minX + maxX) * 0.5; 137 | const centerY = (minY + maxY) * 0.5; 138 | const points = this._points; 139 | const n = points.length; 140 | let centerInside = false; 141 | 142 | for (let i = 0, x0 = points[n - 2], y0 = points[n - 1]; i < n; i += 2) { 143 | const x1 = points[i]; 144 | const y1 = points[i + 1]; 145 | 146 | if ((y1 > centerY) !== (y0 > centerY) && centerX < (x0 - x1) * ((centerY - y1) / (y0 - y1)) + x1) { 147 | centerInside = !centerInside; 148 | } 149 | 150 | x0 = x1; 151 | y0 = y1; 152 | } 153 | 154 | if (!centerInside) { 155 | return 0; 156 | } 157 | 158 | for (let i = 0, x0 = points[n - 2], y0 = points[n - 1]; i < n; i += 2) { 159 | const x1 = points[i]; 160 | const y1 = points[i + 1]; 161 | const px = 1.0 / (x1 - x0); 162 | const py = 1.0 / (y1 - y0); 163 | 164 | let t1 = (minX - x0) * px; 165 | let t2 = (maxX - x0) * px; 166 | let time1 = min(max(t1, 0.0), max(t2, 0.0)); 167 | let time2 = max(min(t1, Infinity), min(t2, Infinity)); 168 | 169 | t1 = (minY - y0) * py; 170 | t2 = (maxY - y0) * py; 171 | time1 = min(max(t1, time1), max(t2, time1)); 172 | time2 = max(min(t1, time2), min(t2, time2)); 173 | 174 | if (time1 <= min(time2, 1.0)) { 175 | return 0; 176 | } 177 | 178 | x0 = x1; 179 | y0 = y1; 180 | } 181 | 182 | return 1; 183 | } 184 | 185 | /** 186 | * @param {number} x - The x-coordinate of the point. 187 | * @param {number} y - The y-coordinate of the point. 188 | * @returns {boolean} True if the shape contains the point. 189 | * @inheritDoc 190 | */ 191 | containsPoint(x, y) { 192 | if (x < this._minX || x > this._maxX || y < this._minY || y > this._maxY) { 193 | return false; 194 | } 195 | 196 | const points = this._points; 197 | const n = points.length; 198 | let inside = false; 199 | 200 | for (let i = 0, x0 = points[n - 2], y0 = points[n - 1]; i < n; i += 2) { 201 | const x1 = points[i]; 202 | const y1 = points[i + 1]; 203 | 204 | if ((y1 > y) !== (y0 > y) && x < (x0 - x1) * ((y - y1) / (y0 - y1)) + x1) { 205 | inside = !inside; 206 | } 207 | 208 | x0 = x1; 209 | y0 = y1; 210 | } 211 | 212 | return inside; 213 | } 214 | 215 | /** 216 | * @param {Cast} cast - The cast. 217 | * @inheritDoc 218 | */ 219 | computeHits(cast) { 220 | const { originX, originY, invDirectionX, invDirectionY } = cast; 221 | 222 | let t1 = (this._minX - originX) * invDirectionX; 223 | let t2 = (this._maxX - originX) * invDirectionX; 224 | let time1 = min(max(t1, 0.0), max(t2, 0.0)); 225 | let time2 = max(min(t1, Infinity), min(t2, Infinity)); 226 | 227 | t1 = (this._minY - originY) * invDirectionY; 228 | t2 = (this._maxY - originY) * invDirectionY; 229 | time1 = min(max(t1, time1), max(t2, time1)); 230 | time2 = max(min(t1, time2), min(t2, time2)); 231 | 232 | if (time1 > min(time2, 1.0)) { 233 | return; 234 | } 235 | 236 | const { directionX, directionY } = cast; 237 | const points = this._points; 238 | const n = points.length; 239 | let i = 0; 240 | let x0 = points[n - 2]; 241 | let y0 = points[n - 1]; 242 | 243 | do { 244 | const x1 = points[i++]; 245 | const y1 = points[i++]; 246 | const dx = x1 - x0; 247 | const dy = y1 - y0; 248 | const q = directionX * dy - directionY * dx; 249 | 250 | while (q !== 0.0) { 251 | const ox = x0 - originX; 252 | const oy = y0 - originY; 253 | const u = (ox * directionY - oy * directionX) / q; 254 | 255 | if (u < 0.0 || u > 1.0 || u === 0.0 && q > 0.0 || u === 1.0 && q < 0.0) { 256 | break; 257 | } 258 | 259 | const time = (ox * dy - oy * dx) / q; 260 | 261 | if (time <= 0.0) { 262 | break; 263 | } 264 | 265 | cast.addHit(time, this.mask); 266 | 267 | break; 268 | } 269 | 270 | x0 = x1; 271 | y0 = y1; 272 | } while (i !== n); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /scripts/raycast/shapes/rectangle.mjs: -------------------------------------------------------------------------------- 1 | import Shape from "../shape.mjs"; 2 | import { max, min } from "../math.mjs"; 3 | 4 | /** 5 | * @import { int31 } from "../_types.mjs"; 6 | * @import Cast from "../cast.mjs"; 7 | */ 8 | 9 | /** 10 | * @sealed 11 | */ 12 | export default class Rectangle extends Shape { 13 | /** 14 | * @param {object} args 15 | * @param {number} args.centerX - The x-coordinate of the center. 16 | * @param {number} args.centerY - The y-coordinate of the center. 17 | * @param {number} args.width - The width (finite, positive). 18 | * @param {number} args.height - The height (finite, positive). 19 | * @param {number} [args.rotation=0.0] - The rotation in radians. 20 | * @param {int31} [args.mask=0x7FFFFFFF] - The mask (nonzero 31-bit integer). 21 | * @returns {Rectangle} The rectangle. 22 | */ 23 | static create({ centerX, centerY, width, height, rotation = 0.0, mask = 0x7FFFFFFF }) { 24 | console.assert(typeof centerX === "number"); 25 | console.assert(typeof centerY === "number"); 26 | console.assert(typeof width === "number"); 27 | console.assert(typeof height === "number"); 28 | console.assert(typeof rotation === "number"); 29 | console.assert(Number.isFinite(centerX)); 30 | console.assert(Number.isFinite(centerY)); 31 | console.assert(Number.isFinite(width)); 32 | console.assert(Number.isFinite(height)); 33 | console.assert(Number.isFinite(rotation)); 34 | console.assert(width > 0); 35 | console.assert(height > 0); 36 | console.assert(mask === (mask & 0x7FFFFFFF) && mask !== 0); 37 | 38 | return new Rectangle(mask | 0, centerX + 0.0, centerY + 0.0, width + 0.0, height + 0.0, rotation + 0.0); 39 | } 40 | 41 | /** 42 | * @param {int31} mask - The mask (nonzero 31-bit integer). 43 | * @param {number} centerX - The x-coordinate of the center. 44 | * @param {number} centerY - The y-coordinate of the center. 45 | * @param {number} width - The width (finite, positive). 46 | * @param {number} height - The height (finite, positive). 47 | * @param {number} rotation - The rotation in radians. 48 | * @private 49 | * @ignore 50 | */ 51 | constructor(mask, centerX, centerY, width, height, rotation) { 52 | super(mask); 53 | 54 | const cos = Math.cos(rotation); 55 | const sin = Math.sin(rotation); 56 | const l = -width * 0.5; 57 | const r = -l; 58 | const t = -height * 0.5; 59 | const b = -t; 60 | const x0 = cos * l - sin * t; 61 | const x1 = cos * r - sin * t; 62 | const x2 = cos * r - sin * b; 63 | const x3 = cos * l - sin * b; 64 | const minX = Math.min(x0, x1, x2, x3) + centerX; 65 | const maxX = Math.max(x0, x1, x2, x3) + centerX; 66 | const y0 = sin * l + cos * t; 67 | const y1 = sin * r + cos * t; 68 | const y2 = sin * r + cos * b; 69 | const y3 = sin * l + cos * b; 70 | const minY = Math.min(y0, y1, y2, y3) + centerY; 71 | const maxY = Math.max(y0, y1, y2, y3) + centerY; 72 | const scaleX = cos / width; 73 | const skewX = -sin / height; 74 | const skewY = sin / width; 75 | const scaleY = cos / height; 76 | 77 | /** 78 | * The minimum x-coordinate. 79 | * @type {number} 80 | * @readonly 81 | * @private 82 | * @ignore 83 | */ 84 | this._minX = minX; 85 | 86 | /** 87 | * The minimum y-coordinate. 88 | * @type {number} 89 | * @readonly 90 | * @private 91 | * @ignore 92 | */ 93 | this._minY = minY; 94 | 95 | /** 96 | * The maximum x-coordinate. 97 | * @type {number} 98 | * @readonly 99 | * @private 100 | * @ignore 101 | */ 102 | this._maxX = maxX; 103 | 104 | /** 105 | * The maximum y-coordinate. 106 | * @type {number} 107 | * @readonly 108 | * @private 109 | * @ignore 110 | */ 111 | this._maxY = maxY; 112 | 113 | /** 114 | * The x-scale of the matrix. 115 | * @type {number} 116 | * @readonly 117 | * @private 118 | * @ignore 119 | */ 120 | this._scaleX = scaleX; 121 | 122 | /** 123 | * The x-skew of the matrix. 124 | * @type {number} 125 | * @readonly 126 | * @private 127 | * @ignore 128 | */ 129 | this._skewX = skewX; 130 | 131 | /** 132 | * The y-skew of the matrix. 133 | * @type {number} 134 | * @readonly 135 | * @private 136 | * @ignore 137 | */ 138 | this._skewY = skewY; 139 | 140 | /** 141 | * The y-scale of the matrix. 142 | * @type {number} 143 | * @readonly 144 | * @private 145 | * @ignore 146 | */ 147 | this._scaleY = scaleY; 148 | 149 | /** 150 | * The x-translation of the matrix. 151 | * @type {number} 152 | * @readonly 153 | * @private 154 | * @ignore 155 | */ 156 | this._translationX = 0.5 - (centerX * scaleX + centerY * skewY); 157 | 158 | /** 159 | * The y-translation of the matrix. 160 | * @type {number} 161 | * @readonly 162 | * @private 163 | * @ignore 164 | */ 165 | this._translationY = 0.5 - (centerX * skewX + centerY * scaleY); 166 | } 167 | 168 | /** 169 | * @param {number} minX - The minimum x-coordinate. 170 | * @param {number} minY - The minimum y-coordinate. 171 | * @param {number} maxX - The maximum x-coordinate. 172 | * @param {number} maxY - The maximum y-coordinate. 173 | * @returns {-1|0|1} If 1, then the bounding box is contained in the shape. If -1, if the bounding box does not intersect with the shape. 174 | * @inheritDoc 175 | */ 176 | testBounds(minX, minY, maxX, maxY) { 177 | const x0 = this._minX; 178 | const x1 = this._maxX; 179 | 180 | if (max(x0, minX) > min(x1, maxX)) { 181 | return -1; 182 | } 183 | 184 | const y0 = this._minY; 185 | const y1 = this._maxY; 186 | 187 | if (max(y0, minY) > min(y1, maxY)) { 188 | return -1; 189 | } 190 | 191 | if (x0 > minX || maxX > x1 || y0 > minY || maxY > y1) { 192 | return 0; 193 | } 194 | 195 | const ma = this._scaleX; 196 | const mc = this._skewY; 197 | const mx = this._translationX; 198 | let x = ma * minX + mc * minY + mx; 199 | 200 | if (x < 0 || x > 1) { 201 | return 0; 202 | } 203 | 204 | x = ma * maxX + mc * minY + mx; 205 | 206 | if (x < 0 || x > 1) { 207 | return 0; 208 | } 209 | 210 | x = ma * maxX + mc * maxY + mx; 211 | 212 | if (x < 0 || x > 1) { 213 | return 0; 214 | } 215 | 216 | x = ma * minX + mc * maxY + mx; 217 | 218 | if (x < 0 || x > 1) { 219 | return 0; 220 | } 221 | 222 | const mb = this._skewX; 223 | const md = this._scaleY; 224 | const my = this._translationY; 225 | let y = mb * minX + md * minY + my; 226 | 227 | if (y < 0 || y > 1) { 228 | return 0; 229 | } 230 | 231 | y = mb * maxX + md * minY + my; 232 | 233 | if (y < 0 || y > 1) { 234 | return 0; 235 | } 236 | 237 | y = mb * maxX + md * maxY + my; 238 | 239 | if (y < 0 || y > 1) { 240 | return 0; 241 | } 242 | 243 | y = mb * minX + md * maxY + my; 244 | 245 | if (y < 0 || y > 1) { 246 | return 0; 247 | } 248 | 249 | return 1; 250 | } 251 | 252 | /** 253 | * @param {number} x - The x-coordinate of the point. 254 | * @param {number} y - The y-coordinate of the point. 255 | * @returns {boolean} True if the shape contains the point. 256 | * @inheritDoc 257 | */ 258 | containsPoint(x, y) { 259 | const ma = this._scaleX; 260 | const mc = this._skewY; 261 | const mx = this._translationX; 262 | const x0 = ma * x + mc * y + mx; 263 | 264 | if (x0 < 0 || x0 > 1) { 265 | return false; 266 | } 267 | 268 | const mb = this._skewX; 269 | const md = this._scaleY; 270 | const my = this._translationY; 271 | const y0 = mb * x + md * y + my; 272 | 273 | if (y0 < 0 || y0 > 1) { 274 | return false; 275 | } 276 | 277 | return true; 278 | } 279 | 280 | /** 281 | * @param {Cast} cast - The cast. 282 | * @inheritDoc 283 | */ 284 | computeHits(cast) { 285 | const { originX, originY, directionX, directionY } = cast; 286 | const ma = this._scaleX; 287 | const mb = this._skewX; 288 | const mc = this._skewY; 289 | const md = this._scaleY; 290 | const mx = this._translationX; 291 | const my = this._translationY; 292 | const x = ma * originX + mc * originY + mx; 293 | const y = mb * originX + md * originY + my; 294 | const dx = ma * directionX + mc * directionY; 295 | const dy = mb * directionX + md * directionY; 296 | const px = -1.0 / dx; 297 | const py = -1.0 / dy; 298 | 299 | let t1 = x * px; 300 | let t2 = (x - 1.0) * px; 301 | let time1 = min(max(t1, 0.0), max(t2, 0.0)); 302 | let time2 = max(min(t1, Infinity), min(t2, Infinity)); 303 | 304 | t1 = y * py; 305 | t2 = (y - 1.0) * py; 306 | time1 = min(max(t1, time1), max(t2, time1)); 307 | time2 = max(min(t1, time2), min(t2, time2)); 308 | 309 | if (time1 <= min(time2, 1.0)) { 310 | const mask = this.mask; 311 | 312 | if (time1 > 0) { 313 | cast.addHit(time1, mask); 314 | } 315 | 316 | cast.addHit(time2, mask); 317 | } 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /scripts/raycast/shapes/tile.mjs: -------------------------------------------------------------------------------- 1 | import Shape from "../shape.mjs"; 2 | import { max, min } from "../math.mjs"; 3 | 4 | /** 5 | * @import { int31 } from "../_types.mjs"; 6 | * @import Cast from "../cast.mjs"; 7 | */ 8 | 9 | /** 10 | * @sealed 11 | */ 12 | export default class Tile extends Shape { 13 | /** 14 | * @param {object} args 15 | * @param {number} args.centerX - The x-coordinate of the center. 16 | * @param {number} args.centerY - The y-coordinate of the center. 17 | * @param {number} args.width - The width (finite, positive). 18 | * @param {number} args.height - The height (finite, positive). 19 | * @param {number} [args.rotation=0.0] - The rotation in radians. 20 | * @param {{ 21 | * data: (number | boolean)[], 22 | * offset?: number, 23 | * stride?: number, 24 | * width: number, 25 | * height: number, 26 | * minX?: number, 27 | * minY?: number, 28 | * maxX?: number, 29 | * maxY?: number, 30 | * threshold?: number 31 | * }} args.texture - The texture. 32 | * @param {int31} [args.mask=0x7FFFFFFF] - The mask (nonzero 31-bit integer). 33 | * @returns {Tile} The tile. 34 | */ 35 | static create({ centerX, centerY, width, height, rotation = 0.0, texture, mask = 0x7FFFFFFF }) { 36 | console.assert(typeof centerX === "number"); 37 | console.assert(typeof centerY === "number"); 38 | console.assert(typeof width === "number"); 39 | console.assert(typeof height === "number"); 40 | console.assert(typeof rotation === "number"); 41 | console.assert(Number.isFinite(centerX)); 42 | console.assert(Number.isFinite(centerY)); 43 | console.assert(Number.isFinite(width)); 44 | console.assert(Number.isFinite(height)); 45 | console.assert(Number.isFinite(rotation)); 46 | console.assert(width > 0); 47 | console.assert(height > 0); 48 | console.assert(texture !== null && typeof texture === "object"); 49 | console.assert(Array.isArray(texture.data) || ArrayBuffer.isView(texture.data) && !(texture.data instanceof DataView)); 50 | console.assert(texture.data.every((v) => (typeof v === "number" || typeof v === "boolean") && v >= 0)); 51 | console.assert(texture.offset === undefined || typeof texture.offset === "number" && Number.isInteger(texture.offset) && texture.offset >= 0); 52 | console.assert(texture.stride === undefined || typeof texture.stride === "number" && Number.isInteger(texture.stride) && texture.stride > 0); 53 | console.assert(typeof texture.width === "number" && Number.isInteger(texture.width) && texture.width > 0); 54 | console.assert(typeof texture.height === "number" && Number.isInteger(texture.height) && texture.height > 0); 55 | console.assert(texture.minX === undefined || typeof texture.minX === "number" && Number.isInteger(texture.minX) && texture.minX >= 0); 56 | console.assert(texture.minY === undefined || typeof texture.minY === "number" && Number.isInteger(texture.minY) && texture.minY >= 0); 57 | console.assert(texture.maxX === undefined || typeof texture.maxX === "number" && Number.isInteger(texture.maxX) && texture.maxX > (texture.minX ?? 0)); 58 | console.assert(texture.maxY === undefined || typeof texture.maxY === "number" && Number.isInteger(texture.maxY) && texture.maxY > (texture.minY ?? 0)); 59 | console.assert(texture.threshold === undefined || typeof texture.threshold === "number"); 60 | console.assert(mask === (mask & 0x7FFFFFFF) && mask !== 0); 61 | 62 | return new Tile(mask | 0, centerX + 0.0, centerY + 0.0, width + 0.0, height + 0.0, rotation + 0.0, texture); 63 | } 64 | 65 | /** 66 | * @param {int31} mask - The mask (nonzero 31-bit integer). 67 | * @param {number} centerX - The x-coordinate of the center. 68 | * @param {number} centerY - The y-coordinate of the center. 69 | * @param {number} width - The width (finite, positive). 70 | * @param {number} height - The height (finite, positive). 71 | * @param {number} rotation - The rotation in radians. 72 | * @param {{ 73 | * data: (number | boolean)[], 74 | * offset?: number, 75 | * stride?: number, 76 | * width: number, 77 | * height: number, 78 | * minX?: number, 79 | * minY?: number, 80 | * maxX?: number, 81 | * maxY?: number, 82 | * threshold?: number 83 | * }} texture - The texture. 84 | * @private 85 | * @ignore 86 | */ 87 | constructor(mask, centerX, centerY, width, height, rotation, texture) { 88 | super(mask); 89 | 90 | const textureWidth = texture.width; 91 | const textureHeight = texture.height; 92 | const textureMinX = texture.minX ?? 0; 93 | const textureMinY = texture.minY ?? 0; 94 | const textureMaxX = texture.maxX ?? textureWidth; 95 | const textureMaxY = texture.maxY ?? textureHeight; 96 | const textureScaleX = textureWidth / width; 97 | const textureScaleY = textureHeight / height; 98 | const cos = Math.cos(rotation); 99 | const sin = Math.sin(rotation); 100 | const halfWidth = width * 0.5; 101 | const halfHeight = height * 0.5; 102 | const l = textureMinX / textureScaleX - halfWidth; 103 | const r = textureMaxX / textureScaleX - halfWidth; 104 | const t = textureMinY / textureScaleY - halfHeight; 105 | const b = textureMaxY / textureScaleY - halfHeight; 106 | const x0 = cos * l - sin * t; 107 | const x1 = cos * r - sin * t; 108 | const x2 = cos * r - sin * b; 109 | const x3 = cos * l - sin * b; 110 | const minX = Math.min(x0, x1, x2, x3) + centerX; 111 | const maxX = Math.max(x0, x1, x2, x3) + centerX; 112 | const y0 = sin * l + cos * t; 113 | const y1 = sin * r + cos * t; 114 | const y2 = sin * r + cos * b; 115 | const y3 = sin * l + cos * b; 116 | const minY = Math.min(y0, y1, y2, y3) + centerY; 117 | const maxY = Math.max(y0, y1, y2, y3) + centerY; 118 | let scaleX = cos; 119 | let skewX = -sin; 120 | let skewY = sin; 121 | let scaleY = cos; 122 | let translationX = halfWidth - (centerX * scaleX + centerY * skewY); 123 | let translationY = halfHeight - (centerX * skewX + centerY * scaleY); 124 | 125 | scaleX *= textureScaleX; 126 | skewX *= textureScaleY; 127 | skewY *= textureScaleX; 128 | scaleY *= textureScaleY; 129 | translationX *= textureScaleX; 130 | translationY *= textureScaleY; 131 | translationX += 1.0 - textureMinX; 132 | translationY += 1.0 - textureMinY; 133 | 134 | const textureStrideX = texture.stride ?? 1; 135 | const textureStrideY = textureWidth * textureStrideX; 136 | const field = sdf( 137 | texture.data, 138 | texture.offset ?? 0, 139 | textureStrideX, 140 | textureStrideY, 141 | textureMinX, 142 | textureMinY, 143 | textureMaxX, 144 | textureMaxY, 145 | texture.threshold ?? Number.MIN_VALUE, 146 | ); 147 | 148 | for (let i = 0, n = field.length; i < n; i++) { 149 | const signedDistance = field[i]; 150 | 151 | field[i] = Math.sign(signedDistance) * max(Math.abs(signedDistance) - 1.0, 0.5); 152 | } 153 | 154 | /** 155 | * The minimum x-coordinate. 156 | * @type {number} 157 | * @readonly 158 | * @private 159 | * @ignore 160 | */ 161 | this._minX = minX; 162 | 163 | /** 164 | * The minimum y-coordinate. 165 | * @type {number} 166 | * @readonly 167 | * @private 168 | * @ignore 169 | */ 170 | this._minY = minY; 171 | 172 | /** 173 | * The maximum x-coordinate. 174 | * @type {number} 175 | * @readonly 176 | * @private 177 | * @ignore 178 | */ 179 | this._maxX = maxX; 180 | 181 | /** 182 | * The maximum y-coordinate. 183 | * @type {number} 184 | * @readonly 185 | * @private 186 | * @ignore 187 | */ 188 | this._maxY = maxY; 189 | 190 | /** 191 | * The width. 192 | * @type {number} 193 | * @readonly 194 | * @private 195 | * @ignore 196 | */ 197 | this._width = (textureMaxX - textureMinX + 2) | 0; 198 | 199 | /** 200 | * The height. 201 | * @type {number} 202 | * @readonly 203 | * @private 204 | * @ignore 205 | */ 206 | this._height = (textureMaxY - textureMinY + 2) | 0; 207 | 208 | /** 209 | * The x-scale of the matrix. 210 | * @type {number} 211 | * @readonly 212 | * @private 213 | * @ignore 214 | */ 215 | this._scaleX = scaleX; 216 | 217 | /** 218 | * The x-skew of the matrix. 219 | * @type {number} 220 | * @readonly 221 | * @private 222 | * @ignore 223 | */ 224 | this._skewX = skewX; 225 | 226 | /** 227 | * The y-skew of the matrix. 228 | * @type {number} 229 | * @readonly 230 | * @private 231 | * @ignore 232 | */ 233 | this._skewY = skewY; 234 | 235 | /** 236 | * The y-scale of the matrix. 237 | * @type {number} 238 | * @readonly 239 | * @private 240 | * @ignore 241 | */ 242 | this._scaleY = scaleY; 243 | 244 | /** 245 | * The x-translation of the matrix. 246 | * @type {number} 247 | * @readonly 248 | * @private 249 | * @ignore 250 | */ 251 | this._translationX = translationX; 252 | 253 | /** 254 | * The y-translation of the matrix. 255 | * @type {number} 256 | * @readonly 257 | * @private 258 | * @ignore 259 | */ 260 | this._translationY = translationY; 261 | 262 | /** 263 | * The signed distance field. 264 | * @type {Float64Array} 265 | * @readonly 266 | * @private 267 | * @ignore 268 | */ 269 | this._field = field; 270 | } 271 | 272 | /** 273 | * @param {number} minX - The minimum x-coordinate. 274 | * @param {number} minY - The minimum y-coordinate. 275 | * @param {number} maxX - The maximum x-coordinate. 276 | * @param {number} maxY - The maximum y-coordinate. 277 | * @returns {-1|0|1} If 1, then the bounding box is contained in the shape. If -1, if the bounding box does not intersect with the shape. 278 | * @inheritDoc 279 | */ 280 | testBounds(minX, minY, maxX, maxY) { 281 | return max(this._minX, minX) > min(this._maxX, maxX) || max(this._minY, minY) > min(this._maxY, maxY) ? -1 : 0; 282 | } 283 | 284 | /** 285 | * @param {number} x - The x-coordinate of the point. 286 | * @param {number} y - The y-coordinate of the point. 287 | * @returns {boolean} True if the shape contains the point. 288 | * @inheritDoc 289 | */ 290 | containsPoint(x, y) { 291 | const ma = this._scaleX; 292 | const mc = this._skewY; 293 | const mx = this._translationX; 294 | const x0 = ma * x + mc * y + mx; 295 | 296 | if (x0 < 0.0) { 297 | return false; 298 | } 299 | 300 | const w = this._width; 301 | 302 | if (x0 >= w) { 303 | return false; 304 | } 305 | 306 | const mb = this._skewX; 307 | const md = this._scaleY; 308 | const my = this._translationY; 309 | const y0 = mb * x + md * y + my; 310 | 311 | if (y0 < 0.0 || y0 >= this._height) { 312 | return false; 313 | } 314 | 315 | return this._field[(y0 | 0) * w + (x0 | 0)] < 0.0; 316 | } 317 | 318 | /** 319 | * @param {Cast} cast - The cast. 320 | * @inheritDoc 321 | */ 322 | computeHits(cast) { 323 | const { originX, originY, directionX, directionY } = cast; 324 | const w = this._width; 325 | const h = this._height; 326 | const ma = this._scaleX; 327 | const mb = this._skewX; 328 | const mc = this._skewY; 329 | const md = this._scaleY; 330 | const mx = this._translationX; 331 | const my = this._translationY; 332 | let x = ma * originX + mc * originY + mx; 333 | let y = mb * originX + md * originY + my; 334 | const dx = ma * directionX + mc * directionY; 335 | const dy = mb * directionX + md * directionY; 336 | const px = 1.0 / dx; 337 | const py = 1.0 / dy; 338 | 339 | let t1 = (1.0 - x) * px; 340 | let t2 = (w - 1.0 - x) * px; 341 | let time1 = min(max(t1, 0.0), max(t2, 0.0)); 342 | let time2 = max(min(t1, Infinity), min(t2, Infinity)); 343 | 344 | t1 = (1.0 - y) * py; 345 | t2 = (h - 1.0 - y) * py; 346 | time1 = min(max(t1, time1), max(t2, time1)); 347 | time2 = max(min(t1, time2), min(t2, time2)); 348 | 349 | if (time1 <= min(time2, 1.0)) { 350 | const field = this._field; 351 | let inside = false; 352 | 353 | if (time1 <= 0.0) { 354 | time1 = 0.0; 355 | inside = field[(y | 0) * w + (x | 0)] < 0.0; 356 | } 357 | 358 | const invMagnitude = 1.0 / Math.sqrt(dx * dx + dy * dy); 359 | 360 | do { 361 | const signedDistance = field[(y + dy * time1 | 0) * w + (x + dx * time1 | 0)] * invMagnitude; 362 | 363 | if (inside !== signedDistance < 0.0) { 364 | inside = !inside; 365 | 366 | cast.addHit(time1, this.mask); 367 | } 368 | 369 | time1 += Math.abs(signedDistance); 370 | } while (time1 <= time2); 371 | 372 | if (inside) { 373 | cast.addHit(time2, this.mask); 374 | } 375 | } 376 | } 377 | } 378 | 379 | /** 380 | * The value representing infinity. Used by {@link edt}. 381 | * @type {number} 382 | */ 383 | const EDT_INF = 1e20; 384 | 385 | /** 386 | * Generate the 2D Euclidean signed distance field. 387 | * @param {(number | boolean)[]} data - The elements. 388 | * @param {number} offset - The offset of the first element in `data`. 389 | * @param {number} strideX - The distance between consecutive elements in a row of `data`. 390 | * @param {number} strideY - The distance between consecutive elements in a column of `data`. 391 | * @param {number} minX - The minimum x-coordinate of the rectangle. 392 | * @param {number} minY - The minimum y-coordinate of the rectangle. 393 | * @param {number} maxX - The maximum x-coordinate of the rectangle. 394 | * @param {number} maxY - The maximum x-coordinate of the rectangle. 395 | * @param {number} threshold - The threshold that needs to be met or exceeded for a pixel to be inner. 396 | * @returns {Float64Array} - The signed distance field with a 1 pixel padding. 397 | */ 398 | function sdf(data, offset, strideX, strideY, minX, minY, maxX, maxY, threshold) { 399 | const width = maxX - minX + 2; 400 | const height = maxY - minY + 2; 401 | const size = width * height; 402 | const capacity = Math.max(width, height); 403 | const temp = new ArrayBuffer(8 * size + 20 * capacity + 8); 404 | const inner = new Float64Array(temp, 0.0, size); 405 | const outer = new Float64Array(size).fill(EDT_INF); 406 | 407 | for (let y = minY, j = width + 1; y < maxY; y++, j += 2) { 408 | for (let x = minX; x < maxX; x++, j++) { 409 | const a = data[offset + x * strideX + y * strideY]; 410 | 411 | if (a >= threshold) { 412 | inner[j] = EDT_INF; 413 | outer[j] = 0.0; 414 | } 415 | } 416 | } 417 | 418 | const f = new Float64Array(temp, inner.byteLength, capacity); 419 | const z = new Float64Array(temp, f.byteOffset + f.byteLength, capacity + 1); 420 | const v = new Int32Array(temp, z.byteOffset + z.byteLength, capacity); 421 | 422 | edt(inner, width, height, f, v, z); 423 | edt(outer, width, height, f, v, z); 424 | 425 | for (let i = 0; i < size; i++) { 426 | outer[i] = Math.sqrt(outer[i]) - Math.sqrt(inner[i]); 427 | } 428 | 429 | return outer; 430 | } 431 | 432 | /** 433 | * 2D Euclidean squared distance transform by Felzenszwalb & Huttenlocher. 434 | * @param {Float64Array} grid - The grid. 435 | * @param {number} width - The width of the grid. 436 | * @param {number} height - The height of the grid. 437 | * @param {Float64Array} f - The temporary source data, which returns the y of the parabola vertex at x. 438 | * @param {Int32Array} v - The temporary used to store x-coordinates of parabola vertices. 439 | * @param {Float64Array} z - The temporary used to store x-coordinates of parabola intersections. 440 | */ 441 | function edt(grid, width, height, f, v, z) { 442 | for (let x = 0; x < width; x++) { 443 | edt1d(grid, x, width, height, f, v, z); 444 | } 445 | 446 | for (let y = 0; y < height; y++) { 447 | edt1d(grid, y * width, 1, width, f, v, z); 448 | } 449 | } 450 | 451 | /** 452 | * 1D squared distance transform. Used by {@link edt}. 453 | * @param {Float64Array} grid - The grid. 454 | * @param {number} offset - The offset. 455 | * @param {number} stride - The stride. 456 | * @param {number} length - The length. 457 | * @param {Float64Array} f - The temporary source data, which returns the y of the parabola vertex at x. 458 | * @param {Int32Array} v - The temporary used to store x-coordinates of parabola vertices. 459 | * @param {Float64Array} z - The temporary used to store x-coordinates of parabola intersections. 460 | */ 461 | function edt1d(grid, offset, stride, length, f, v, z) { 462 | f[0] = grid[offset]; 463 | v[0] = 0; 464 | z[0] = -EDT_INF; 465 | z[1] = EDT_INF; 466 | 467 | for (let q = 1, k = 0, s = 0; q < length; q++) { 468 | f[q] = grid[offset + q * stride]; 469 | 470 | const q2 = q * q; 471 | 472 | do { 473 | const r = v[k]; 474 | 475 | s = (f[q] - f[r] + q2 - r * r) / (q - r) * 0.5; 476 | } while (s <= z[k] && k--); 477 | 478 | k++; 479 | v[k] = q; 480 | z[k] = s; 481 | z[k + 1] = EDT_INF; 482 | } 483 | 484 | for (let q = 0, k = 0; q < length; q++) { 485 | while (z[k + 1] < q) { 486 | k++; 487 | } 488 | 489 | const r = v[k]; 490 | const qr = q - r; 491 | 492 | grid[offset + q * stride] = f[r] + qr * qr; 493 | } 494 | } 495 | -------------------------------------------------------------------------------- /scripts/raycast/space.mjs: -------------------------------------------------------------------------------- 1 | import { max, min } from "./math.mjs"; 2 | import Volume from "./volume.mjs"; 3 | 4 | /** 5 | * @import Geometry from "./geometry.mjs"; 6 | */ 7 | 8 | /** 9 | * @sealed 10 | */ 11 | export default class Space { 12 | /** 13 | * An empty space. 14 | * @type {Space} 15 | * @readonly 16 | */ 17 | static EMPTY = new Space([]); 18 | 19 | /** 20 | * @param {object} args 21 | * @param {Volume[]} args.volumes - The volumes. 22 | * @param {number} [args.minX=-Infinity] - The minimum x-coordinate. 23 | * @param {number} [args.minY=-Infinity] - The minimum y-coordinate. 24 | * @param {number} [args.minZ=-Infinity] - The minimum z-coordinate. 25 | * @param {number} [args.maxX=Infinity] - The maximum x-coordinate. 26 | * @param {number} [args.maxY=Infinity] - The maximum y-coordinate. 27 | * @param {number} [args.maxZ=Infinity] - The maximum z-coordinate. 28 | * @returns {Space} The space. 29 | */ 30 | static create({ volumes, minX = -Infinity, minY = -Infinity, minZ = -Infinity, maxX = Infinity, maxY = Infinity, maxZ = Infinity }) { 31 | console.assert(Array.isArray(volumes)); 32 | console.assert(volumes.every((volume) => volume instanceof Volume)); 33 | console.assert(typeof minX === "number"); 34 | console.assert(typeof minY === "number"); 35 | console.assert(typeof minZ === "number"); 36 | console.assert(typeof maxX === "number"); 37 | console.assert(typeof maxY === "number"); 38 | console.assert(typeof maxZ === "number"); 39 | console.assert(minX <= maxX); 40 | console.assert(minY <= maxY); 41 | console.assert(minZ <= maxZ); 42 | 43 | return new Space(initializeVolumes(volumes.toSorted(compareVolumesByPriority), minX, minY, minZ, maxX, maxY, maxZ)); 44 | } 45 | 46 | /** 47 | * @param {Volume[]} volumes - The volumes. 48 | * @private 49 | * @ignore 50 | */ 51 | constructor(volumes) { 52 | const [minCost, maxCost] = calculateCostEstimates(volumes); 53 | 54 | /** 55 | * The volumes. 56 | * @type {ReadonlyArray} 57 | * @readonly 58 | */ 59 | this.volumes = volumes; 60 | 61 | /** 62 | * The estimated minimum energy cost anywhere in the space. 63 | * @type {number} 64 | * @readonly 65 | */ 66 | this.minCost = minCost; 67 | 68 | /** 69 | * The estimated maximum energy cost anywhere in the space. 70 | * @type {number} 71 | * @readonly 72 | */ 73 | this.maxCost = maxCost; 74 | 75 | /** 76 | * The estimated minimum distance a ray can travel anywhere in the space. 77 | * @type {number} 78 | * @readonly 79 | */ 80 | this.minDistance = 1.0 / maxCost; 81 | 82 | /** 83 | * The estimated maximum distance a ray can travel anywhere in the space. 84 | * @type {number} 85 | * @readonly 86 | */ 87 | this.maxDistance = 1.0 / minCost; 88 | } 89 | 90 | /** 91 | * Crop the space w.r.t. the given bounding box. 92 | * @param {number} minX - The minimum x-coordinate. 93 | * @param {number} minY - The minimum y-coordinate. 94 | * @param {number} minZ - The minimum z-coordinate. 95 | * @param {number} maxX - The maximum x-coordinate. 96 | * @param {number} maxY - The maximum y-coordinate. 97 | * @param {number} maxZ - The maximum z-coordinate. 98 | * @returns {Space} The cropped space. 99 | */ 100 | crop(minX, minY, minZ, maxX, maxY, maxZ) { 101 | const volumes = initializeVolumes(this.volumes, minX, minY, minZ, maxX, maxY, maxZ); 102 | 103 | if (volumes.length === 0) { 104 | return Space.EMPTY; 105 | } 106 | 107 | if (volumes === this.volumes) { 108 | return this; 109 | } 110 | 111 | return new Space(volumes); 112 | } 113 | } 114 | 115 | /** 116 | * The array for cropped volumes. 117 | * @type {Volume[]} 118 | */ 119 | const CROPPED_VOLUMES = []; 120 | 121 | /** 122 | * The array for cropped geometries. 123 | * @type {Geometry[]} 124 | */ 125 | const CROPPED_GEOMETRIES = []; 126 | 127 | /** 128 | * Compare two volumes by priority. 129 | * @param {Volume} volume1 - The first volume. 130 | * @param {Volume} volume2 - The second volume. 131 | * @returns {number} 132 | */ 133 | function compareVolumesByPriority(volume1, volume2) { 134 | return volume1.priority - volume2.priority || volume1.geometry._id - volume2.geometry._id; 135 | } 136 | 137 | /** 138 | * Initialize this volumes by cropping them to the given the bounding box of the space. 139 | * Discard unnecessary volumes and identify volumes that envelop the bounding box of the space, 140 | * which are marked to be skipped by the ray intersection test. 141 | * @param {Volume[]} volumes - The volumes. 142 | * @param {number} minX - The minimum x-coordinate. 143 | * @param {number} minY - The minimum y-coordinate. 144 | * @param {number} minZ - The minimum z-coordinate. 145 | * @param {number} maxX - The maximum x-coordinate. 146 | * @param {number} maxY - The maximum y-coordinate. 147 | * @param {number} maxZ - The maximum z-coordinate. 148 | * @returns {Volume[]} The cropped volumes that haven't been discarded. 149 | */ 150 | function initializeVolumes(volumes, minX, minY, minZ, maxX, maxY, maxZ) { 151 | const numVolumes = volumes.length; 152 | 153 | for (let volumeIndex = 0; volumeIndex < numVolumes; volumeIndex++) { 154 | const volume = volumes[volumeIndex]; 155 | 156 | volume.geometry._castId = 0; 157 | } 158 | 159 | for (let volumeIndex = 0; volumeIndex < numVolumes; volumeIndex++) { 160 | const volume = volumes[volumeIndex]; 161 | 162 | switch (volume.mode) { 163 | case 0: 164 | if (volume.cost === 0.0) { 165 | continue; 166 | } 167 | 168 | break; 169 | case 1: 170 | if (volume.cost === Infinity) { 171 | continue; 172 | } 173 | 174 | break; 175 | case 2: 176 | if (volume.cost === 0.0) { 177 | continue; 178 | } 179 | 180 | break; 181 | } 182 | 183 | const geometry = volume.geometry; 184 | let croppedGeometry; 185 | 186 | if (geometry._castId === 0) { 187 | croppedGeometry = geometry.crop(minX, minY, minZ, maxX, maxY, maxZ); 188 | geometry._castId = ~CROPPED_VOLUMES.length; 189 | CROPPED_GEOMETRIES.push(croppedGeometry); 190 | } else { 191 | croppedGeometry = CROPPED_GEOMETRIES[~geometry._castId]; 192 | } 193 | 194 | if (croppedGeometry.isEmpty) { 195 | continue; 196 | } 197 | 198 | const croppedVolume = croppedGeometry === geometry ? volume : new Volume(croppedGeometry, volume.priority, volume.mode, volume.cost); 199 | 200 | CROPPED_VOLUMES.push(croppedVolume); 201 | } 202 | 203 | CROPPED_GEOMETRIES.length = 0; 204 | 205 | if (CROPPED_VOLUMES.length === volumes.length) { 206 | let cropped = false; 207 | 208 | for (let volumeIndex = 0; volumeIndex < numVolumes; volumeIndex++) { 209 | if (CROPPED_VOLUMES[volumeIndex] !== volumes[volumeIndex]) { 210 | cropped = true; 211 | 212 | break; 213 | } 214 | 215 | if (!cropped) { 216 | CROPPED_VOLUMES.length = 0; 217 | 218 | return volumes; 219 | } 220 | } 221 | } 222 | 223 | for (let volumeIndex = CROPPED_VOLUMES.length - 1; volumeIndex >= 0; volumeIndex--) { 224 | const croppedVolume = CROPPED_VOLUMES[volumeIndex]; 225 | 226 | if (!croppedVolume.geometry.isUnbounded) { 227 | continue; 228 | } 229 | 230 | const mode = croppedVolume.mode; 231 | 232 | if (mode === 0) { 233 | if (!Number.isFinite(croppedVolume.cost)) { 234 | const croppedVolumes = CROPPED_VOLUMES.slice(0, volumeIndex + (croppedVolume.cost <= 0 ? 1 : 0)); 235 | 236 | CROPPED_VOLUMES.length = 0; 237 | 238 | return croppedVolumes; 239 | } 240 | } else if (mode === 1) { 241 | if (croppedVolume.cost === 0.0) { 242 | const croppedVolumes = CROPPED_VOLUMES.slice(volumeIndex + 1); 243 | 244 | CROPPED_VOLUMES.length = 0; 245 | 246 | return croppedVolumes; 247 | } 248 | } else if (mode === 2) { 249 | if (croppedVolume.cost === Infinity) { 250 | const croppedVolumes = CROPPED_VOLUMES.slice(volumeIndex); 251 | 252 | CROPPED_VOLUMES.length = 0; 253 | 254 | return croppedVolumes; 255 | } 256 | } else if (mode === 3) { 257 | const croppedVolumes = CROPPED_VOLUMES.slice(volumeIndex + (croppedVolume.cost === 0.0 ? 1 : 0)); 258 | 259 | CROPPED_VOLUMES.length = 0; 260 | 261 | return croppedVolumes; 262 | } 263 | } 264 | 265 | const croppedVolumes = CROPPED_VOLUMES.slice(0); 266 | 267 | CROPPED_VOLUMES.length = 0; 268 | 269 | return croppedVolumes; 270 | } 271 | 272 | /** 273 | * Estimate the minimum and maximum energy cost and distance travelled anywhere in the space. 274 | * @param {Volume[]} volumes - The volumes. 275 | * @returns {[minCost: number, maxCost: number]} The estimates of the minimum and maximum cost. 276 | */ 277 | function calculateCostEstimates(volumes) { 278 | let minCost = 0.0; 279 | let maxCost = 0.0; 280 | const numVolumes = volumes.length; 281 | 282 | for (let volumeIndex = 0; volumeIndex < numVolumes; volumeIndex++) { 283 | const volume = volumes[volumeIndex]; 284 | const cost = volume.cost; 285 | 286 | if (volume.geometry.isUnbounded) { 287 | switch (volume.mode) { 288 | case 0: 289 | minCost = max(minCost + cost, 0.0); 290 | maxCost = max(maxCost + cost, 0.0); 291 | 292 | break; 293 | case 1: 294 | minCost = min(minCost, cost); 295 | maxCost = min(maxCost, cost); 296 | 297 | break; 298 | case 2: 299 | minCost = max(minCost, cost); 300 | maxCost = max(maxCost, cost); 301 | 302 | break; 303 | case 3: 304 | minCost = maxCost = cost; 305 | 306 | break; 307 | } 308 | } else { 309 | switch (volume.mode) { 310 | case 0: 311 | if (cost >= 0.0) { 312 | maxCost += cost; 313 | } else { 314 | minCost = max(minCost + cost, 0.0); 315 | } 316 | 317 | break; 318 | case 1: 319 | minCost = min(minCost, cost); 320 | 321 | break; 322 | case 2: 323 | maxCost = max(maxCost, cost); 324 | 325 | break; 326 | case 3: 327 | minCost = min(minCost, cost); 328 | maxCost = max(maxCost, cost); 329 | 330 | break; 331 | } 332 | } 333 | } 334 | 335 | return [minCost, maxCost]; 336 | } 337 | -------------------------------------------------------------------------------- /scripts/raycast/volume.mjs: -------------------------------------------------------------------------------- 1 | import Geometry from "./geometry.mjs"; 2 | import Mode from "./mode.mjs"; 3 | 4 | /** 5 | * @import { int32 } from "./_types.mjs"; 6 | */ 7 | 8 | /** 9 | * @sealed 10 | */ 11 | export default class Volume { 12 | /** 13 | * @param {object} args 14 | * @param {Geometry} args.geometry - The geometry. 15 | * @param {int32} [args.priority=0] - The priority. 16 | * @param {Mode} args.mode - The mode used in the energy calculation. 17 | * @param {number} args.cost - The energy cost. 18 | * @returns {Volume} The volume. 19 | */ 20 | static create({ geometry, priority = 0, mode, cost }) { 21 | console.assert(geometry instanceof Geometry); 22 | console.assert(priority === (priority | 0)); 23 | console.assert(mode === (mode | 0) && Object.values(Mode).includes(mode)); 24 | console.assert(mode === Mode.ADD || cost >= 0.0); 25 | console.assert(typeof cost === "number"); 26 | 27 | return new Volume(geometry, priority | 0, mode | 0, cost + 0.0); 28 | } 29 | 30 | /** 31 | * @param {Geometry} geometry - The geometry. 32 | * @param {int32} priority - The priority. 33 | * @param {Mode} mode - The mode used in the energy calculation. 34 | * @param {number} cost - The energy cost. 35 | * @private 36 | * @ignore 37 | */ 38 | constructor(geometry, priority, mode, cost) { 39 | /** 40 | * The geometry. 41 | * @type {Geometry} 42 | * @readonly 43 | */ 44 | this.geometry = geometry; 45 | 46 | /** 47 | * The priority. 48 | * @type {int32} 49 | * @readonly 50 | */ 51 | this.priority = priority; 52 | 53 | /** 54 | * The mode. 55 | * @type {Mode} 56 | * @readonly 57 | */ 58 | this.mode = mode; 59 | 60 | /** 61 | * The energy cost. 62 | * @type {number} 63 | * @readonly 64 | */ 65 | this.cost = cost; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | input.limits--placeholder-font-size-12::placeholder { 2 | font-size: var(--font-size-12); 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "scripts/**/*.mjs" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "allowJs": true, 8 | "checkJs": false, 9 | "declaration": true, 10 | "emitDeclarationOnly": true, 11 | "lib": [ 12 | "ES2023" 13 | ], 14 | "module": "esnext", 15 | "target": "esnext", 16 | "outDir": "_types", 17 | "noUnusedLocals": false, 18 | "noUnusedParameters": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Limits (Foundry VTT Module)", 3 | "entryPoints": [ 4 | "scripts/_module.mjs" 5 | ], 6 | "out": "_docs", 7 | "disableSources": true, 8 | "excludeExternals": true, 9 | "sort": [ 10 | "kind", 11 | "instance-first", 12 | "visibility", 13 | "enum-member-source-order", 14 | "alphabetical" 15 | ] 16 | } 17 | --------------------------------------------------------------------------------