From fb702cd8503718262d2d888a529db1510ecf8b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20V=C3=B6gele?= Date: Tue, 15 Feb 2022 17:56:08 +0100 Subject: [PATCH] Add support for gridless pathfinding --- .gitignore | 2 + build_release.py | 50 +++++ build_wasm.py | 13 ++ install_dev_dependencies.sh | 3 + {src => js}/api.js | 0 {src => js}/compatibility.js | 0 {src => js}/foundry_fixes.js | 0 {src => js}/foundry_imports.js | 0 {src => js}/geometry.js | 0 {src => js}/keybindings.js | 0 {src => js}/libwrapper_shim.js | 0 {src => js}/main.js | 11 + {src => js}/migration.js | 0 {src => js}/movement_tracking.js | 0 {src => js}/pathfinding.js | 70 +++++-- {src => js}/ruler.js | 2 - {src => js}/settings.js | 0 {src => js}/socket.js | 0 {src => js}/speed_provider.js | 0 {src => js}/systems.js | 0 {src => js}/util.js | 0 module.json | 6 +- rust/.gitignore | 1 + rust/Cargo.lock | 154 ++++++++++++++ rust/Cargo.toml | 19 ++ rust/rustfmt.toml | 1 + rust/src/geometry.rs | 187 +++++++++++++++++ rust/src/js_api.rs | 237 ++++++++++++++++++++++ rust/src/lib.rs | 12 ++ rust/src/pathfinder.rs | 334 +++++++++++++++++++++++++++++++ rust/src/ptr_indexed_hash_set.rs | 68 +++++++ 31 files changed, 1146 insertions(+), 24 deletions(-) create mode 100755 build_release.py create mode 100755 build_wasm.py create mode 100755 install_dev_dependencies.sh rename {src => js}/api.js (100%) rename {src => js}/compatibility.js (100%) rename {src => js}/foundry_fixes.js (100%) rename {src => js}/foundry_imports.js (100%) rename {src => js}/geometry.js (100%) rename {src => js}/keybindings.js (100%) rename {src => js}/libwrapper_shim.js (100%) rename {src => js}/main.js (95%) rename {src => js}/migration.js (100%) rename {src => js}/movement_tracking.js (100%) rename {src => js}/pathfinding.js (72%) rename {src => js}/ruler.js (98%) rename {src => js}/settings.js (100%) rename {src => js}/socket.js (100%) rename {src => js}/speed_provider.js (100%) rename {src => js}/systems.js (100%) rename {src => js}/util.js (100%) create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/rustfmt.toml create mode 100644 rust/src/geometry.rs create mode 100644 rust/src/js_api.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/src/pathfinder.rs create mode 100644 rust/src/ptr_indexed_hash_set.rs diff --git a/.gitignore b/.gitignore index 2809123..d606dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ foundry-*.js +artifact/ +wasm/ diff --git a/build_release.py b/build_release.py new file mode 100755 index 0000000..137b94a --- /dev/null +++ b/build_release.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +import json +from pathlib import PurePath, Path +import subprocess +import tempfile +import zipfile + +wasm_pack = Path("~/.cargo/bin/wasm-pack").expanduser() + +root_files = ["module.json", "README.md", "CHANGELOG.md", "LICENSE"] +wasm_files = ["gridless_pathfinding_bg.wasm", "gridless_pathfinding.js"] +output_dir = Path("artifact") +copy_everything_directories = ["js", "lang", "templates", "wasm/snippets"] +wasm_dir = Path("wasm") +root_dir = Path(".") +rust_dir = Path("rust") +build_dir_tmp = tempfile.TemporaryDirectory() +build_dir = Path(build_dir_tmp.name) + +with open("module.json", "r") as file: + manifest = json.load(file) + +zip_root = PurePath(f'{manifest["name"]}') + +filename = f'{manifest["name"]}-{manifest["version"]}.zip' + +result = subprocess.run([wasm_pack, "build", "--target", "web", "--out-dir", build_dir, root_dir / rust_dir]) +if result.returncode != 0: + raise Exception("Wasm build failed") + +output_dir.mkdir(parents=True, exist_ok=True) + +def write_directory(archive, d): + for f in (root_dir / d).iterdir(): + if f.is_dir(): + write_directory(archive, f) + else: + assert(f.is_file()) + archive.write(f, arcname=zip_root / d / f.name) + +with zipfile.ZipFile(output_dir / filename, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for f in root_files: + archive.write(root_dir / f, arcname=zip_root / f) + for d in copy_everything_directories: + write_directory(archive, d) + for f in wasm_files: + archive.write(build_dir / f, arcname=zip_root / wasm_dir / f) + +print(f"Successfully built {output_dir / filename}") diff --git a/build_wasm.py b/build_wasm.py new file mode 100755 index 0000000..33ae107 --- /dev/null +++ b/build_wasm.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 + +import sys +import subprocess +from pathlib import Path + +root_dir = Path(".") +wasm_dir = root_dir / Path("wasm") +rust_dir = root_dir / Path("rust") + +debug = " --debug" if len(sys.argv) >= 2 and sys.argv[1] == "--debug" else "" + +result = subprocess.run(["cargo", "watch", "-C" , rust_dir, "-s", f"wasm-pack build --target web --out-dir {wasm_dir.resolve()}{debug}"]) diff --git a/install_dev_dependencies.sh b/install_dev_dependencies.sh new file mode 100755 index 0000000..d3534f1 --- /dev/null +++ b/install_dev_dependencies.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cargo install cargo-watch +cargo install wasm-pack diff --git a/src/api.js b/js/api.js similarity index 100% rename from src/api.js rename to js/api.js diff --git a/src/compatibility.js b/js/compatibility.js similarity index 100% rename from src/compatibility.js rename to js/compatibility.js diff --git a/src/foundry_fixes.js b/js/foundry_fixes.js similarity index 100% rename from src/foundry_fixes.js rename to js/foundry_fixes.js diff --git a/src/foundry_imports.js b/js/foundry_imports.js similarity index 100% rename from src/foundry_imports.js rename to js/foundry_imports.js diff --git a/src/geometry.js b/js/geometry.js similarity index 100% rename from src/geometry.js rename to js/geometry.js diff --git a/src/keybindings.js b/js/keybindings.js similarity index 100% rename from src/keybindings.js rename to js/keybindings.js diff --git a/src/libwrapper_shim.js b/js/libwrapper_shim.js similarity index 100% rename from src/libwrapper_shim.js rename to js/libwrapper_shim.js diff --git a/src/main.js b/js/main.js similarity index 95% rename from src/main.js rename to js/main.js index ab5d79f..bd963de 100644 --- a/src/main.js +++ b/js/main.js @@ -7,15 +7,26 @@ import {disableSnap, registerKeybindings} from "./keybindings.js"; import {libWrapper} from "./libwrapper_shim.js"; import {performMigrations} from "./migration.js" import {removeLastHistoryEntryIfAt, resetMovementHistory} from "./movement_tracking.js"; +import {wipePathfindingCache} from "./pathfinding.js"; import {extendRuler} from "./ruler.js"; import {registerSettings, RightClickAction, settingsKey} from "./settings.js" import {recalculate} from "./socket.js"; import {SpeedProvider} from "./speed_provider.js" import {setSnapParameterOnOptions} from "./util.js"; +import initGridlessPathfinding, * as GridlessPathfinding from "../wasm/gridless_pathfinding.js" + CONFIG.debug.dragRuler = false; export let debugGraphics = undefined; +initGridlessPathfinding().then(() => { + Hooks.on("canvasInit", wipePathfindingCache); + Hooks.on("canvasReady", wipePathfindingCache); + Hooks.on("createWall", wipePathfindingCache); + Hooks.on("updateWall", wipePathfindingCache); + Hooks.on("deleteWall", wipePathfindingCache); +}); + Hooks.once("init", () => { registerSettings() registerKeybindings() diff --git a/src/migration.js b/js/migration.js similarity index 100% rename from src/migration.js rename to js/migration.js diff --git a/src/movement_tracking.js b/js/movement_tracking.js similarity index 100% rename from src/movement_tracking.js rename to js/movement_tracking.js diff --git a/src/pathfinding.js b/js/pathfinding.js similarity index 72% rename from src/pathfinding.js rename to js/pathfinding.js index 9b94144..46f683c 100644 --- a/src/pathfinding.js +++ b/js/pathfinding.js @@ -4,40 +4,59 @@ import {debugGraphics} from "./main.js"; import {settingsKey} from "./settings.js"; import {getSnapPointForTokenObj, iterPairs} from "./util.js"; +import * as GridlessPathfinding from "../wasm/gridless_pathfinding.js" + let cachedNodes = undefined; let use5105 = false; +let gridlessPathfinders = new Map(); export function isPathfindingEnabled() { if (this.user !== game.user) return false; - if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) - return false; if (!game.user.isGM && !game.settings.get(settingsKey, "allowPathfinding")) return false; return game.settings.get(settingsKey, "autoPathfinding") != togglePathfinding; } export function findPath(from, to, token, previousWaypoints) { - const lastNode = calculatePath(from, to, token, previousWaypoints); - if (!lastNode) - return null; - paintPathfindingDebug(lastNode, token); - const path = []; - let currentNode = lastNode; - while (currentNode) { - // TODO Check if the distance doesn't change - if (path.length >= 2 && !stepCollidesWithWall(currentNode.node, path[path.length - 2], token)) - // Replace last waypoint if the current waypoint leads to a valid path - path[path.length - 1] = {x: currentNode.node.x, y: currentNode.node.y}; - else - path.push({x: currentNode.node.x, y: currentNode.node.y}); - currentNode = currentNode.previous; + if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) { + let tokenSize = Math.max(token.data.width, token.data.height) * canvas.dimensions.size; + let pathfinder = gridlessPathfinders.get(tokenSize); + if (!pathfinder) { + pathfinder = GridlessPathfinding.initialize(canvas.walls.placeables, tokenSize); + gridlessPathfinders.set(tokenSize, pathfinder); + } + paintGridlessPathfindingDebug(pathfinder); + return GridlessPathfinding.findPath(pathfinder, from, to); + } + else { + const lastNode = calculatePath(from, to, token, previousWaypoints); + if (!lastNode) + return null; + paintGriddedPathfindingDebug(lastNode, token); + const path = []; + let currentNode = lastNode; + while (currentNode) { + // TODO Check if the distance doesn't change + if (path.length >= 2 && !stepCollidesWithWall(currentNode.node, path[path.length - 2], token)) + // Replace last waypoint if the current waypoint leads to a valid path + path[path.length - 1] = {x: currentNode.node.x, y: currentNode.node.y}; + else + path.push({x: currentNode.node.x, y: currentNode.node.y}); + currentNode = currentNode.previous; + } + return path; } - return path; } export function wipePathfindingCache() { cachedNodes = undefined; + for (const pathfinder of gridlessPathfinders.values()) { + GridlessPathfinding.free(pathfinder); + } + gridlessPathfinders.clear(); + if (debugGraphics) + debugGraphics.removeChildren().forEach(c => c.destroy()); } function getNode(pos, token, initialize=true) { @@ -137,11 +156,11 @@ function stepCollidesWithWall(from, to, token) { return canvas.walls.checkCollision(new Ray(stepStart, stepEnd)); } -function paintPathfindingDebug(lastNode, token) { +function paintGriddedPathfindingDebug(lastNode, token) { if (!CONFIG.debug.dragRuler) return; - debugGraphics.removeChildren(); + debugGraphics.removeChildren().forEach(c => c.destroy()); let currentNode = lastNode; while (currentNode) { let text = new PIXI.Text(currentNode.cost.toFixed(0)); @@ -153,3 +172,16 @@ function paintPathfindingDebug(lastNode, token) { currentNode = currentNode.previous; } } + +function paintGridlessPathfindingDebug(pathfinder) { + if (!CONFIG.debug.dragRuler) + return; + + debugGraphics.removeChildren().forEach(c => c.destroy()); + let graphic = new PIXI.Graphics(); + graphic.lineStyle(2, 0x440000); + for (const point of GridlessPathfinding.debugGetPathfindingPoints(pathfinder)) { + graphic.drawCircle(point.x, point.y, 5); + } + debugGraphics.addChild(graphic); +} diff --git a/src/ruler.js b/js/ruler.js similarity index 98% rename from src/ruler.js rename to js/ruler.js index 770914b..9bc2e93 100644 --- a/src/ruler.js +++ b/js/ruler.js @@ -2,7 +2,6 @@ import {currentSpeedProvider, getColorForDistanceAndToken, getRangesFromSpeedPro import {getHexSizeSupportTokenGridCenter} from "./compatibility.js"; import {cancelScheduledMeasurement, measure} from "./foundry_imports.js" import {getMovementHistory} from "./movement_tracking.js"; -import {wipePathfindingCache} from "./pathfinding.js"; import {settingsKey} from "./settings.js"; import {getSnapPointForEntity} from "./util.js"; @@ -187,7 +186,6 @@ export function extendRuler() { return; const ruler = canvas.controls.ruler; ruler.clear(); - wipePathfindingCache(); ruler._state = Ruler.STATES.STARTING; let entityCenter; if (isToken && canvas.grid.isHex && game.modules.get("hex-size-support")?.active && CONFIG.hexSizeSupport.getAltSnappingFlag(entity)) diff --git a/src/settings.js b/js/settings.js similarity index 100% rename from src/settings.js rename to js/settings.js diff --git a/src/socket.js b/js/socket.js similarity index 100% rename from src/socket.js rename to js/socket.js diff --git a/src/speed_provider.js b/js/speed_provider.js similarity index 100% rename from src/speed_provider.js rename to js/speed_provider.js diff --git a/src/systems.js b/js/systems.js similarity index 100% rename from src/systems.js rename to js/systems.js diff --git a/src/util.js b/js/util.js similarity index 100% rename from src/util.js rename to js/util.js diff --git a/module.json b/module.json index c686977..d9a40db 100644 --- a/module.json +++ b/module.json @@ -13,9 +13,9 @@ } ], "esmodules": [ - "src/libwrapper_shim.js", - "src/main.js", - "src/socket.js" + "js/libwrapper_shim.js", + "js/main.js", + "js/socket.js" ], "templates": [ "speed_provider_settings.html" diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..ced0395 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,154 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "bumpalo" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "gridless-pathfinding" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "js-sys", + "rustc-hash", + "wasm-bindgen", +] + +[[package]] +name = "js-sys" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "proc-macro2" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "syn" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "wasm-bindgen" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..3e90b97 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "gridless-pathfinding" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib"] + +[profile.release] +lto = true +#debug = true + +[dependencies] +console_error_panic_hook = "0.1.7" +js-sys = "0.3.56" +rustc-hash = "1.1.0" +wasm-bindgen = "0.2.79" diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml new file mode 100644 index 0000000..218e203 --- /dev/null +++ b/rust/rustfmt.toml @@ -0,0 +1 @@ +hard_tabs = true diff --git a/rust/src/geometry.rs b/rust/src/geometry.rs new file mode 100644 index 0000000..0c219d0 --- /dev/null +++ b/rust/src/geometry.rs @@ -0,0 +1,187 @@ +use std::hash::{Hash, Hasher}; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +extern "C" { + pub type JsPoint; + + #[wasm_bindgen(method, getter)] + fn x(this: &JsPoint) -> f64; + + #[wasm_bindgen(method, getter)] + fn y(this: &JsPoint) -> f64; +} + +#[wasm_bindgen] +#[derive(Debug, Copy, Clone)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + pub fn new(x: f64, y: f64) -> Self { + Self { x, y } + } + + pub fn from_line_x(line: &Line, x: f64) -> Self { + let y = line.calc_y(x); + Self { x, y } + } + + pub fn distance_to(&self, to: Point) -> f64 { + (self.y - to.y).hypot(self.x - to.x) + } + + pub fn is_same_as(&self, other: &Self) -> bool { + let e = 0.000001; + (self.x - other.x).abs() < e && (self.y - other.y).abs() < e + } +} + +impl Eq for Point {} + +impl PartialEq for Point { + fn eq(&self, other: &Self) -> bool { + self.x == other.x && self.y == other.y + } +} + +impl Hash for Point { + fn hash(&self, hasher: &mut H) { + self.x.to_bits().hash(hasher); + self.y.to_bits().hash(hasher); + } +} + +impl From<&JsPoint> for Point { + fn from(point: &JsPoint) -> Self { + Self::new(point.x(), point.y()) + } +} + +#[derive(Debug, Copy, Clone)] +pub struct Line { + pub m: f64, + pub b: f64, + pub p1: Point, +} + +impl Line { + pub fn new(m: f64, b: f64, p1: Point) -> Self { + Self { m, b, p1 } + } + + pub fn from_points(p1: Point, p2: Point) -> Self { + let m = (p1.y - p2.y) / (p1.x - p2.x); + let b = p1.y - m * p1.x; + Self { m, b, p1 } + } + + pub fn from_point_and_angle(p1: Point, angle: f64) -> Self { + let p2 = Point { + x: p1.x - angle.cos(), + y: p1.y - angle.sin(), + }; + Line::from_points(p1, p2) + } + + pub fn is_vertical(&self) -> bool { + self.m.is_infinite() + } + + pub fn is_horizontal(&self) -> bool { + self.m == 0.0 + } + + pub fn calc_x(&self, y: f64) -> f64 { + (y - self.b) / self.m + } + + pub fn calc_y(&self, x: f64) -> f64 { + self.m * x + self.b + } + + pub fn intersection(&self, other: &Line) -> Option { + // Are both lines vertical? + if self.is_vertical() && other.is_vertical() { + return None; + } + + // Are the lines paralell? + if (self.m - other.m).abs() < 0.00000005 { + return None; + } + + // Is one of the lines vertical? + if self.is_vertical() || other.is_vertical() { + let vertical; + let regular; + if self.is_vertical() { + vertical = self; + regular = other; + } else { + vertical = other; + regular = self; + } + return Some(Point::from_line_x(regular, vertical.p1.x)); + } + + // Calculate x coordinate of intersection point between both lines + // Find intersection point: x * m1 + b1 = x * m2 + b2 + // Solve for x: x = (b1 - b2) / (m2 - m1) + let x = (self.b - other.b) / (other.m - self.m); + if self.m.abs() < other.m.abs() { + Some(Point::from_line_x(self, x)) + } else { + Some(Point::from_line_x(other, x)) + } + } + + pub fn get_perpendicular_through_point(&self, p: Point) -> Self { + let m = -1.0 / self.m; + let b = p.y - m * p.x; + Self { m, b, p1: p } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct LineSegment { + pub p1: Point, + pub p2: Point, + pub line: Line, +} + +impl LineSegment { + pub fn new(p1: Point, p2: Point) -> Self { + Self { + p1, + p2, + line: Line::from_points(p1, p2), + } + } + + pub fn intersection(&self, other: &LineSegment) -> Option { + let intersection = self.line.intersection(&other.line); + intersection.filter(|intersection| { + self.is_intersection_on_segment(*intersection) + && other.is_intersection_on_segment(*intersection) + }) + } + + fn is_intersection_on_segment(&self, intersection: Point) -> bool { + if intersection.is_same_as(&self.p1) || intersection.is_same_as(&self.p2) { + return true; + } + if self.line.is_vertical() || self.line.m.abs() > 1.0 { + return between(intersection.y, self.p1.y, self.p2.y); + } + between(intersection.x, self.p1.x, self.p2.x) + } +} + +pub fn between(num: T, a: T, b: T) -> bool { + let (min, max) = if a < b { (a, b) } else { (b, a) }; + num >= min && num <= max +} diff --git a/rust/src/js_api.rs b/rust/src/js_api.rs new file mode 100644 index 0000000..2d29d7c --- /dev/null +++ b/rust/src/js_api.rs @@ -0,0 +1,237 @@ +use js_sys::Array; +use wasm_bindgen::prelude::*; + +use crate::{ + geometry::Point, + pathfinder::{DiscoveredNodePtr, Pathfinder}, +}; + +#[allow(unused)] +macro_rules! log { + ( $( $t:tt )* ) => { + log(&format!( $( $t )* )); + }; +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console, js_name=warn)] + pub fn log(s: &str); +} + +#[wasm_bindgen] +extern "C" { + pub type JsWall; + pub type JsWallData; + + #[wasm_bindgen(method, getter)] + fn data(this: &JsWall) -> JsWallData; + + #[wasm_bindgen(method, getter)] + fn c(this: &JsWallData) -> Vec; + + #[wasm_bindgen(method, getter, js_name = "door")] + fn door_type(this: &JsWallData) -> DoorType; + + #[wasm_bindgen(method, getter, js_name = "ds")] + fn door_state(this: &JsWallData) -> DoorState; + + #[wasm_bindgen(method, getter, js_name = "move")] + fn move_type(this: &JsWallData) -> WallSenseType; +} + +#[wasm_bindgen] +extern "C" { + pub type JsPoint; + + #[wasm_bindgen(method, getter)] + fn x(this: &JsPoint) -> f64; + + #[wasm_bindgen(method, getter)] + fn y(this: &JsPoint) -> f64; +} + +impl From for Point { + fn from(point: JsPoint) -> Self { + Point { + x: point.x(), + y: point.y(), + } + } +} + +#[wasm_bindgen] +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum DoorState { + CLOSED = 0, + OPEN = 1, + LOCKED = 2, +} + +impl TryFrom for DoorState { + type Error = (); + fn try_from(value: usize) -> Result { + match value { + x if x == Self::CLOSED as usize => Ok(Self::CLOSED), + x if x == Self::OPEN as usize => Ok(Self::OPEN), + x if x == Self::LOCKED as usize => Ok(Self::LOCKED), + _ => Err(()), + } + } +} + +#[wasm_bindgen] +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum DoorType { + NONE = 0, + DOOR = 1, + SECRET = 2, +} + +impl TryFrom for DoorType { + type Error = (); + fn try_from(value: usize) -> Result { + match value { + x if x == Self::NONE as usize => Ok(Self::NONE), + x if x == Self::DOOR as usize => Ok(Self::DOOR), + x if x == Self::SECRET as usize => Ok(Self::SECRET), + _ => Err(()), + } + } +} + +#[wasm_bindgen] +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum WallSenseType { + NONE = 0, + LIMITED = 10, + NORMAL = 20, +} + +impl TryFrom for WallSenseType { + type Error = (); + fn try_from(value: usize) -> Result { + match value { + x if x == Self::NONE as usize => Ok(Self::NONE), + x if x == Self::LIMITED as usize => Ok(Self::LIMITED), + x if x == Self::NORMAL as usize => Ok(Self::NORMAL), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Wall { + pub p1: Point, + pub p2: Point, + pub door_type: DoorType, + pub door_state: DoorState, + pub move_type: WallSenseType, +} + +impl Wall { + pub fn new( + p1: Point, + p2: Point, + door_type: DoorType, + door_state: DoorState, + move_type: WallSenseType, + ) -> Self { + Self { + p1, + p2, + door_type, + door_state, + move_type, + } + } + + pub fn is_door(&self) -> bool { + self.door_type != DoorType::NONE + } + + pub fn is_open(&self) -> bool { + self.door_state == DoorState::OPEN + } +} + +impl Wall { + fn from_js(wall: &JsWall) -> Self { + let data = wall.data(); + let mut c = data.c(); + c.iter_mut().for_each(|val| *val = val.round()); + Self::new( + Point::new(c[0], c[1]), + Point::new(c[2], c[3]), + data.door_type(), + data.door_state(), + data.move_type(), + ) + } +} + +#[allow(dead_code)] +#[wasm_bindgen] +pub fn initialize(js_walls: Vec, token_size: f64) -> Pathfinder { + let mut walls = Vec::with_capacity(js_walls.len()); + for wall in js_walls { + let wall = JsWall::from(wall); + walls.push(Wall::from_js(&wall)); + } + Pathfinder::initialize(walls, token_size) +} + +#[allow(dead_code)] +#[wasm_bindgen] +pub fn free(pathfinder: Pathfinder) { + drop(pathfinder); +} + +#[allow(dead_code)] +#[wasm_bindgen(js_name=findPath)] +pub fn find_path(pathfinder: &mut Pathfinder, from: JsPoint, to: JsPoint) -> Option { + pathfinder + .find_path(from.into(), to.into()) + .map(|first_node| first_node.iter_path().map(JsValue::from).collect()) +} + +#[allow(dead_code)] +#[wasm_bindgen(js_name=debugGetPathfindingPoints)] +pub fn debug_get_pathfinding_points(pathfinder: &Pathfinder) -> Array { + pathfinder + .nodes + .iter() + .map(|node| node.borrow().point) + .map(JsValue::from) + .collect() +} + +trait IteratePath { + fn iter_path(&self) -> PathIterator; +} + +impl IteratePath for DiscoveredNodePtr { + fn iter_path(&self) -> PathIterator { + PathIterator { + current_node: Some(self.clone()), + } + } +} + +struct PathIterator { + current_node: Option, +} + +impl Iterator for PathIterator { + type Item = Point; + + fn next(&mut self) -> Option { + if let Some(node) = self.current_node.clone() { + let point = node.borrow().node.borrow().point; + self.current_node = node.borrow().previous.clone(); + Some(point) + } else { + None + } + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..0d6ae2b --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,12 @@ +mod geometry; +#[macro_use] +mod js_api; +mod pathfinder; +mod ptr_indexed_hash_set; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(start)] +pub fn main() { + std::panic::set_hook(Box::new(console_error_panic_hook::hook)); +} diff --git a/rust/src/pathfinder.rs b/rust/src/pathfinder.rs new file mode 100644 index 0000000..3a15733 --- /dev/null +++ b/rust/src/pathfinder.rs @@ -0,0 +1,334 @@ +use std::{cell::RefCell, f64::consts::PI, rc::Rc}; + +use wasm_bindgen::prelude::*; + +use rustc_hash::FxHashMap; + +use crate::{ + geometry::{LineSegment, Point}, + js_api::{Wall, WallSenseType}, + ptr_indexed_hash_set::PtrIndexedHashSet, +}; + +pub struct Edge { + target: NodePtr, + cost: f64, +} + +pub struct Node { + pub point: Point, + edges: Option>, + final_edge: Option>, +} + +impl Node { + pub fn new(point: Point) -> Self { + Self { + point, + edges: None, + final_edge: None, + } + } + + fn iter_edges( + &self, + ) -> std::iter::Chain, std::option::Iter<'_, Edge>> { + self.edges + .as_ref() + .unwrap() + .iter() + .chain(self.final_edge.as_ref().unwrap().iter()) + } +} + +type NodePtr = Rc>; + +impl From for NodePtr { + fn from(node: Node) -> Self { + Rc::new(RefCell::new(node)) + } +} + +pub struct DiscoveredNode { + pub node: NodePtr, + cost: f64, + estimated: f64, + pub previous: Option, +} + +pub type DiscoveredNodePtr = Rc>; + +impl From for DiscoveredNodePtr { + fn from(node: DiscoveredNode) -> Self { + Rc::new(RefCell::new(node)) + } +} + +#[derive(Default, Clone)] +pub struct NodeStorage { + regular_nodes: Vec, + final_node: Option, +} + +pub type NodeStorageIterator<'a> = std::iter::Chain< + std::slice::Iter<'a, Rc>>, + std::option::Iter<'a, Rc>>, +>; + +impl NodeStorage { + fn new() -> Self { + Self::default() + } + + fn push(&mut self, node: NodePtr) { + self.regular_nodes.push(node); + } + + fn initialize_edges(&mut self, node: &NodePtr, walls: &[LineSegment]) { + if node.borrow().final_edge.is_none() { + let final_edge = self + .final_node + .as_ref() + .filter(|neighbor| { + !self.collides_with_wall( + &LineSegment::new(node.borrow().point, neighbor.borrow().point), + walls, + ) + }) + .map(|neighbor| Edge { + target: neighbor.clone(), + cost: node.borrow().point.distance_to(neighbor.borrow().point), + }); + node.borrow_mut().final_edge = Some(final_edge); + } + + if node.borrow().edges.is_some() { + return; + } + + let point = node.borrow().point; + let mut edges = Vec::new(); + for neighbor in &self.regular_nodes { + if Rc::ptr_eq(neighbor, node) { + continue; + } + let neighbor_point = neighbor.borrow().point; + if !self.collides_with_wall(&LineSegment::new(point, neighbor_point), walls) { + let cost = point.distance_to(neighbor_point); + edges.push(Edge { + target: neighbor.clone(), + cost, + }); + } + } + node.borrow_mut().edges = Some(edges); + } + + fn collides_with_wall(&self, line: &LineSegment, walls: &[LineSegment]) -> bool { + walls.iter().any(|wall| line.intersection(wall).is_some()) + } + + pub fn cleanup_final_edges(&mut self) { + for node in &self.regular_nodes { + node.borrow_mut().final_edge = None; + } + } + + pub fn iter(&self) -> NodeStorageIterator { + self.regular_nodes.iter().chain(self.final_node.iter()) + } +} + +#[wasm_bindgen] +pub struct Pathfinder { + #[wasm_bindgen(skip)] + pub nodes: NodeStorage, + #[wasm_bindgen(skip)] + pub walls: Vec, +} + +impl Pathfinder { + pub fn initialize(walls: I, token_size: f64) -> Self + where + I: IntoIterator, + { + let distance_from_walls = token_size / 2.0; + let mut endpoints = FxHashMap::>::default(); + let mut line_segments = Vec::new(); + for wall in walls { + if wall.move_type == WallSenseType::NONE { + continue; + } + if wall.is_door() && wall.is_open() { + continue; + } + let x_diff = wall.p2.x - wall.p1.x; + let y_diff = wall.p2.y - wall.p1.y; + let p1_angle = y_diff.atan2(x_diff).rem_euclid(2.0 * PI); + let p2_angle = (p1_angle + PI).rem_euclid(2.0 * PI); + for (point, angle) in [(wall.p1, p1_angle), (wall.p2, p2_angle)] { + let angles = endpoints.entry(point).or_insert_with(Vec::new); + angles.push(angle); + } + line_segments.push(LineSegment::new(wall.p1, wall.p2)); + } + endpoints + .values_mut() + .for_each(|angles| angles.sort_by(|a, b| a.partial_cmp(b).unwrap())); + let mut nodes = NodeStorage::new(); + for (point, angles) in endpoints { + assert!(!angles.is_empty()); + for i in 1..angles.len() { + let angle1 = angles[i - 1]; + let angle2 = angles[i]; + if angle1 == angle2 { + continue; + } + let angle_diff = angle2 - angle1; + if angle_diff <= PI { + continue; + } + { + let angle_between = angle_diff / 2.0 + angle1; + let pathfinding_node = calc_pathfinding_node( + point, + angle_between, + distance_from_walls, + &mut line_segments, + ); + if angle_diff > 1.5 * PI { + nodes.push(pathfinding_node); + } + } + nodes.push(calc_pathfinding_node( + point, + angle1 + 0.5 * PI, + distance_from_walls, + &mut line_segments, + )); + nodes.push(calc_pathfinding_node( + point, + angle2 - 0.5 * PI, + distance_from_walls, + &mut line_segments, + )); + } + let angle1 = angles.last().unwrap(); + let angle2 = angles.first().unwrap() + 2.0 * PI; + let angle_diff = angle2 - angle1; + if angle_diff <= PI { + continue; + } + if angle_diff > 1.5 * PI { + let angle_between = angle_diff / 2.0 + angle1; + nodes.push(calc_pathfinding_node( + point, + angle_between, + distance_from_walls, + &mut line_segments, + )); + } + nodes.push(calc_pathfinding_node( + point, + angle1 + 0.5 * PI, + distance_from_walls, + &mut line_segments, + )); + nodes.push(calc_pathfinding_node( + point, + angle2 - 0.5 * PI, + distance_from_walls, + &mut line_segments, + )); + } + // TODO Eliminating nodes close to each other may improve performance + Self { + nodes, + walls: line_segments, + } + } + + pub fn find_path(&mut self, from: Point, to: Point) -> Option { + self.nodes.cleanup_final_edges(); + let mut nodes = self.nodes.clone(); + nodes.final_node = Some(NodePtr::from(Node::new(from))); + let to_node = NodePtr::from(Node::new(to)); + nodes.initialize_edges(&to_node, &self.walls); + let to = DiscoveredNode { + node: to_node, + cost: 0.0, + estimated: to.distance_to(from), + previous: None, + }; + // TODO Use a sorted set for next_nodes for better performance + let mut next_nodes = vec![DiscoveredNodePtr::from(to)]; + let mut previous_nodes = PtrIndexedHashSet::new(); + while !next_nodes.is_empty() { + // Sort by estimated cost, high to low + // TODO Maybe tere's a faster way to do this than re-sorting every iteration? + next_nodes.sort_by(|a, b| { + b.borrow() + .estimated + .partial_cmp(&a.borrow().estimated) + .unwrap() + }); + + // Get node with cheapest estimate + let current_node = next_nodes.pop().unwrap(); + if current_node.borrow().node.borrow().point.x == from.x + && current_node.borrow().node.borrow().point.y == from.y + { + return Some(current_node); + } + previous_nodes.insert(current_node.borrow().node.clone()); + for edge in current_node.borrow().node.borrow().iter_edges() { + let neighbor = &edge.target; + if previous_nodes.contains(neighbor) { + continue; + } + nodes.initialize_edges(neighbor, &self.walls); + // Add a flat 0.00001 cost per node to discurage creation of unnecessary waypoints + let cost = current_node.borrow().cost + edge.cost + 0.00001; + let discovered_neighbor = DiscoveredNode { + node: neighbor.clone(), + cost, + estimated: cost + neighbor.borrow().point.distance_to(from), + previous: Some(current_node.clone()), + }; + let neighbor_entry = next_nodes + .iter() + .find(|node| Rc::ptr_eq(&node.borrow().node, neighbor)); + if let Some(entry) = neighbor_entry { + // If the neighbor is cheaper to reach via the current route than through previously discovered routes, replace it + if entry.borrow().cost > cost { + *entry.borrow_mut() = discovered_neighbor; + } + } else { + next_nodes.push(discovered_neighbor.into()); + } + } + } + None + } +} + +fn calc_pathfinding_node( + p: Point, + angle: f64, + distance_from_walls: f64, + line_segments: &mut Vec, +) -> NodePtr { + let offset_x = angle.cos() * distance_from_walls; + let offset_y = angle.sin() * distance_from_walls; + line_segments.push(LineSegment::new( + p, + Point { + x: p.x + offset_x * 0.99, + y: p.y + offset_y * 0.99, + }, + )); + NodePtr::from(Node::new(Point { + x: p.x + offset_x, + y: p.y + offset_y, + })) +} diff --git a/rust/src/ptr_indexed_hash_set.rs b/rust/src/ptr_indexed_hash_set.rs new file mode 100644 index 0000000..c12aa2d --- /dev/null +++ b/rust/src/ptr_indexed_hash_set.rs @@ -0,0 +1,68 @@ +use rustc_hash::FxHashSet; +use std::collections::hash_set; +use std::hash::{Hash, Hasher}; +use std::rc::Rc; + +#[derive(Default)] +pub struct PtrIndexedHashSet(FxHashSet>); + +impl PtrIndexedHashSet { + pub fn new() -> Self { + PtrIndexedHashSet(FxHashSet::default()) + } + + pub fn insert(&mut self, value: Rc) -> bool { + self.0.insert(PtrIndexedRc(value)) + } + + pub fn remove(&mut self, value: &Rc) -> bool { + self.0.remove(&PtrIndexedRc(Rc::clone(value))) + } + + pub fn contains(&mut self, value: &Rc) -> bool { + self.0.contains(&PtrIndexedRc(Rc::clone(value))) + } +} + +impl std::fmt::Debug for PtrIndexedHashSet { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_set().entries(self.0.iter().map(|e| &e.0)).finish() + } +} + +struct PtrIndexedRc(Rc); + +impl Hash for PtrIndexedRc { + fn hash(&self, state: &mut H) { + Rc::as_ptr(&self.0).hash(state) + } +} + +impl PartialEq for PtrIndexedRc { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for PtrIndexedRc {} + +pub struct PtrIndexedHashSetIterator<'a, T>(hash_set::Iter<'a, PtrIndexedRc>); + +impl<'a, T> Iterator for PtrIndexedHashSetIterator<'a, T> { + type Item = &'a Rc; + + fn next(&mut self) -> Option { + match self.0.next() { + Some(item) => Some(&item.0), + None => None, + } + } +} + +impl<'a, T> IntoIterator for &'a PtrIndexedHashSet { + type Item = &'a Rc; + type IntoIter = PtrIndexedHashSetIterator<'a, T>; + fn into_iter(self) -> Self::IntoIter { + PtrIndexedHashSetIterator::((&self.0).iter()) + } +}