├── .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 | [](https://github.com/dev7355608/limits/releases/latest)
2 | 
3 | [](https://forge-vtt.com/bazaar#package=limits)
4 | [](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 |
--------------------------------------------------------------------------------