Compare commits
11 commits
2a473e6b67
...
adfbe04d30
| Author | SHA1 | Date | |
|---|---|---|---|
| adfbe04d30 | |||
| 0192226609 | |||
| 24cbc894de | |||
| c374ba0fc8 | |||
| 3282bcc4ae | |||
| 9c993325f4 | |||
| 461b9cd905 | |||
| 714d00fbb9 | |||
| abdfff92ca | |||
| 3749bad021 | |||
| 3ce0fce53a |
47 changed files with 6323 additions and 5150 deletions
|
|
@ -1,3 +0,0 @@
|
|||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
1
.env
1
.env
|
|
@ -1 +0,0 @@
|
|||
|
||||
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
**/*.ico filter=lfs diff=lfs merge=lfs -text
|
||||
**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
**/*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
|
|
@ -1,25 +1,9 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# Rust compile target directories:
|
||||
target
|
||||
target_ra
|
||||
target_wasm
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
public/content
|
||||
# https://github.com/lycheeverse/lychee
|
||||
.lycheecache
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
stages:
|
||||
- build
|
||||
- deploy
|
||||
|
||||
build site:
|
||||
image: node:lts
|
||||
stage: build
|
||||
script:
|
||||
- npm ci --cache .npm --prefer-offline
|
||||
- npm run build
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- .npm
|
||||
artifacts:
|
||||
paths:
|
||||
- dist
|
||||
|
||||
deploy site:
|
||||
image: instrumentisto/rsync-ssh
|
||||
stage: deploy
|
||||
only:
|
||||
refs:
|
||||
- master
|
||||
script:
|
||||
- mkdir ~/.ssh
|
||||
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||
- chmod 644 ~/.ssh/known_hosts
|
||||
- eval $(ssh-agent -s)
|
||||
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
|
||||
- rsync -vr -e "ssh -p ${SSH_PORT}" dist/ "${DEPLOY_DEST}"
|
||||
3457
Cargo.lock
generated
Normal file
3457
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
243
Cargo.toml
Normal file
243
Cargo.toml
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
[package]
|
||||
name = "las"
|
||||
version = "0.1.0"
|
||||
authors = ["Tomáš Mládek <t@mldk.cz>"]
|
||||
edition = "2024"
|
||||
include = ["LICENSE-APACHE", "LICENSE-MIT", "**/*.rs", "Cargo.toml"]
|
||||
rust-version = "1.85"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"]
|
||||
|
||||
[dependencies]
|
||||
egui = "0.32"
|
||||
eframe = { version = "0.32", default-features = false, features = [
|
||||
# "accesskit", # Make egui compatible with screen readers. NOTE: adds a lot of dependencies.
|
||||
"default_fonts", # Embed the default egui fonts.
|
||||
"glow", # Use the glow rendering backend. Alternative: "wgpu".
|
||||
"persistence", # Enable restoring app state when restarting the app.
|
||||
"wayland", # To support Linux (and CI)
|
||||
"x11", # To support older Linux distributions (restores one of the default features)
|
||||
] }
|
||||
log = "0.4.27"
|
||||
ab_glyph = "0.2"
|
||||
|
||||
# You only need serde if you want app persistence:
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
egui_extras = {version="0.32.0", features=["svg"]}
|
||||
quick-xml = "0.36"
|
||||
|
||||
# native:
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
env_logger = "0.11.8"
|
||||
|
||||
# web:
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4.50"
|
||||
web-sys = "0.3.70" # to access the DOM (to hide the loading text)
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2 # fast and small wasm
|
||||
|
||||
# Optimize all dependencies even in debug builds:
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
|
||||
[patch.crates-io]
|
||||
|
||||
# If you want to use the bleeding edge version of egui and eframe:
|
||||
# egui = { git = "https://github.com/emilk/egui", branch = "main" }
|
||||
# eframe = { git = "https://github.com/emilk/egui", branch = "main" }
|
||||
|
||||
# If you fork https://github.com/emilk/egui you can test with:
|
||||
# egui = { path = "../egui/crates/egui" }
|
||||
# eframe = { path = "../egui/crates/eframe" }
|
||||
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
# Lints:
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
elided_lifetimes_in_paths = "warn"
|
||||
future_incompatible = { level = "warn", priority = -1 }
|
||||
nonstandard_style = { level = "warn", priority = -1 }
|
||||
rust_2018_idioms = { level = "warn", priority = -1 }
|
||||
rust_2021_prelude_collisions = "warn"
|
||||
semicolon_in_expressions_from_macros = "warn"
|
||||
trivial_numeric_casts = "warn"
|
||||
unsafe_op_in_unsafe_fn = "warn" # `unsafe_op_in_unsafe_fn` may become the default in future Rust versions: https://github.com/rust-lang/rust/issues/71668
|
||||
unused_extern_crates = "warn"
|
||||
unused_import_braces = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
|
||||
trivial_casts = "allow"
|
||||
unused_qualifications = "allow"
|
||||
|
||||
|
||||
[workspace.lints.rustdoc]
|
||||
all = "warn"
|
||||
missing_crate_level_docs = "warn"
|
||||
|
||||
|
||||
[workspace.lints.clippy]
|
||||
allow_attributes = "warn"
|
||||
as_ptr_cast_mut = "warn"
|
||||
await_holding_lock = "warn"
|
||||
bool_to_int_with_if = "warn"
|
||||
char_lit_as_u8 = "warn"
|
||||
checked_conversions = "warn"
|
||||
clear_with_drain = "warn"
|
||||
cloned_instead_of_copied = "warn"
|
||||
dbg_macro = "warn"
|
||||
debug_assert_with_mut_call = "warn"
|
||||
derive_partial_eq_without_eq = "warn"
|
||||
disallowed_macros = "warn" # See clippy.toml
|
||||
disallowed_methods = "warn" # See clippy.toml
|
||||
disallowed_names = "warn" # See clippy.toml
|
||||
disallowed_script_idents = "warn" # See clippy.toml
|
||||
disallowed_types = "warn" # See clippy.toml
|
||||
doc_include_without_cfg = "warn"
|
||||
doc_link_with_quotes = "warn"
|
||||
doc_markdown = "warn"
|
||||
empty_enum = "warn"
|
||||
empty_enum_variants_with_brackets = "warn"
|
||||
enum_glob_use = "warn"
|
||||
equatable_if_let = "warn"
|
||||
exit = "warn"
|
||||
expl_impl_clone_on_copy = "warn"
|
||||
explicit_deref_methods = "warn"
|
||||
explicit_into_iter_loop = "warn"
|
||||
explicit_iter_loop = "warn"
|
||||
fallible_impl_from = "warn"
|
||||
filter_map_next = "warn"
|
||||
flat_map_option = "warn"
|
||||
float_cmp_const = "warn"
|
||||
fn_params_excessive_bools = "warn"
|
||||
fn_to_numeric_cast_any = "warn"
|
||||
from_iter_instead_of_collect = "warn"
|
||||
get_unwrap = "warn"
|
||||
implicit_clone = "warn"
|
||||
imprecise_flops = "warn"
|
||||
index_refutable_slice = "warn"
|
||||
inefficient_to_string = "warn"
|
||||
infinite_loop = "warn"
|
||||
into_iter_without_iter = "warn"
|
||||
invalid_upcast_comparisons = "warn"
|
||||
iter_filter_is_ok = "warn"
|
||||
iter_filter_is_some = "warn"
|
||||
iter_not_returning_iterator = "warn"
|
||||
iter_on_empty_collections = "warn"
|
||||
iter_on_single_items = "warn"
|
||||
iter_over_hash_type = "warn"
|
||||
iter_without_into_iter = "warn"
|
||||
large_digit_groups = "warn"
|
||||
large_include_file = "warn"
|
||||
large_stack_arrays = "warn"
|
||||
large_stack_frames = "warn"
|
||||
large_types_passed_by_value = "warn"
|
||||
let_underscore_must_use = "warn"
|
||||
let_underscore_untyped = "warn"
|
||||
let_unit_value = "warn"
|
||||
linkedlist = "warn"
|
||||
literal_string_with_formatting_args = "warn"
|
||||
lossy_float_literal = "warn"
|
||||
macro_use_imports = "warn"
|
||||
manual_assert = "warn"
|
||||
manual_clamp = "warn"
|
||||
manual_instant_elapsed = "warn"
|
||||
manual_is_power_of_two = "warn"
|
||||
manual_is_variant_and = "warn"
|
||||
manual_let_else = "warn"
|
||||
manual_ok_or = "warn"
|
||||
manual_string_new = "warn"
|
||||
map_err_ignore = "warn"
|
||||
map_flatten = "warn"
|
||||
match_bool = "warn"
|
||||
match_on_vec_items = "warn"
|
||||
match_same_arms = "warn"
|
||||
match_wild_err_arm = "warn"
|
||||
match_wildcard_for_single_variants = "warn"
|
||||
mem_forget = "warn"
|
||||
mismatching_type_param_order = "warn"
|
||||
missing_assert_message = "warn"
|
||||
missing_enforced_import_renames = "warn"
|
||||
missing_safety_doc = "warn"
|
||||
mixed_attributes_style = "warn"
|
||||
mut_mut = "warn"
|
||||
mutex_integer = "warn"
|
||||
needless_borrow = "warn"
|
||||
needless_continue = "warn"
|
||||
needless_for_each = "warn"
|
||||
needless_pass_by_ref_mut = "warn"
|
||||
needless_pass_by_value = "warn"
|
||||
negative_feature_names = "warn"
|
||||
non_zero_suggestions = "warn"
|
||||
nonstandard_macro_braces = "warn"
|
||||
option_as_ref_cloned = "warn"
|
||||
option_option = "warn"
|
||||
path_buf_push_overwrite = "warn"
|
||||
pathbuf_init_then_push = "warn"
|
||||
ptr_as_ptr = "warn"
|
||||
ptr_cast_constness = "warn"
|
||||
pub_underscore_fields = "warn"
|
||||
pub_without_shorthand = "warn"
|
||||
rc_mutex = "warn"
|
||||
readonly_write_lock = "warn"
|
||||
redundant_type_annotations = "warn"
|
||||
ref_as_ptr = "warn"
|
||||
ref_option_ref = "warn"
|
||||
rest_pat_in_fully_bound_structs = "warn"
|
||||
same_functions_in_if_condition = "warn"
|
||||
semicolon_if_nothing_returned = "warn"
|
||||
set_contains_or_insert = "warn"
|
||||
should_panic_without_expect = "warn"
|
||||
single_char_pattern = "warn"
|
||||
single_match_else = "warn"
|
||||
str_split_at_newline = "warn"
|
||||
str_to_string = "warn"
|
||||
string_add = "warn"
|
||||
string_add_assign = "warn"
|
||||
string_lit_as_bytes = "warn"
|
||||
string_lit_chars_any = "warn"
|
||||
string_to_string = "warn"
|
||||
suspicious_command_arg_space = "warn"
|
||||
suspicious_xor_used_as_pow = "warn"
|
||||
todo = "warn"
|
||||
too_long_first_doc_paragraph = "warn"
|
||||
too_many_lines = "warn"
|
||||
trailing_empty_array = "warn"
|
||||
trait_duplication_in_bounds = "warn"
|
||||
tuple_array_conversions = "warn"
|
||||
unchecked_duration_subtraction = "warn"
|
||||
undocumented_unsafe_blocks = "warn"
|
||||
unimplemented = "warn"
|
||||
uninhabited_references = "warn"
|
||||
uninlined_format_args = "warn"
|
||||
unnecessary_box_returns = "warn"
|
||||
unnecessary_literal_bound = "warn"
|
||||
unnecessary_safety_doc = "warn"
|
||||
unnecessary_struct_initialization = "warn"
|
||||
unnecessary_wraps = "warn"
|
||||
unnested_or_patterns = "warn"
|
||||
unused_peekable = "warn"
|
||||
unused_rounding = "warn"
|
||||
unused_self = "warn"
|
||||
unused_trait_names = "warn"
|
||||
unwrap_used = "warn"
|
||||
use_self = "warn"
|
||||
useless_transmute = "warn"
|
||||
verbose_file_reads = "warn"
|
||||
wildcard_dependencies = "warn"
|
||||
wildcard_imports = "warn"
|
||||
zero_sized_map_values = "warn"
|
||||
|
||||
manual_range_contains = "allow" # this is better on 'allow'
|
||||
map_unwrap_or = "allow" # this is better on 'allow'
|
||||
2
Trunk.toml
Normal file
2
Trunk.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[build]
|
||||
filehash = false
|
||||
270
asset.svg
Normal file
270
asset.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.7 MiB |
3
assets/NotoSans-Regular.ttf
Normal file
3
assets/NotoSans-Regular.ttf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b85c38ecea8a7cfb39c24e395a4007474fa5a4fc864f6ee33309eb4948d232d5
|
||||
size 569208
|
||||
3
assets/favicon.ico
Executable file
3
assets/favicon.ico
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c61457f43af60ad7cf5fb1420d2e6f484f85e1185c38f351e57531a51ca1a5e
|
||||
size 15406
|
||||
3
assets/icon-1024.png
Normal file
3
assets/icon-1024.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:87c993335fd0b0bf16a276627d205ebd703c2a9a762035973cef8032cd4df6e3
|
||||
size 321266
|
||||
3
assets/icon-256.png
Normal file
3
assets/icon-256.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b33a7d4fdbf76834bcf39551a63b87e7b32cf6d1ccb2d0d3cbcf88bf2ae4828a
|
||||
size 48330
|
||||
3
assets/icon_ios_touch_192.png
Normal file
3
assets/icon_ios_touch_192.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c6ab5ae62953d1093ac273d6cc39a9c9015986bee1aed1f7cf013a0e6e3fe030
|
||||
size 21131
|
||||
28
assets/manifest.json
Normal file
28
assets/manifest.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "egui Template PWA",
|
||||
"short_name": "egui-template-pwa",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./assets/icon-256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./assets/maskable_icon_x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "./assets/icon-1024.png",
|
||||
"sizes": "1024x1024",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"lang": "en-US",
|
||||
"id": "/index.html",
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"background_color": "white",
|
||||
"theme_color": "white"
|
||||
}
|
||||
3
assets/maskable_icon_x512.png
Normal file
3
assets/maskable_icon_x512.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e1a2353165288b7762da7c4fc4511ad409aff46bc2fa05f447bb0cf428d1917c
|
||||
size 130625
|
||||
25
assets/sw.js
Normal file
25
assets/sw.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
var cacheName = 'egui-template-pwa';
|
||||
var filesToCache = [
|
||||
'./',
|
||||
'./index.html',
|
||||
'./las.js',
|
||||
'./las_bg.wasm',
|
||||
];
|
||||
|
||||
/* Start the service worker and cache all of the app's content */
|
||||
self.addEventListener('install', function (e) {
|
||||
e.waitUntil(
|
||||
caches.open(cacheName).then(function (cache) {
|
||||
return cache.addAll(filesToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/* Serve cached content when offline */
|
||||
self.addEventListener('fetch', function (e) {
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(function (response) {
|
||||
return response || fetch(e.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
11
check.sh
Executable file
11
check.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
# This scripts runs various CI-like checks in a convenient way.
|
||||
set -eux
|
||||
|
||||
cargo check --quiet --workspace --all-targets
|
||||
cargo check --quiet --workspace --all-features --lib --target wasm32-unknown-unknown
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --quiet --workspace --all-targets --all-features -- -D warnings -W clippy::all
|
||||
cargo test --quiet --workspace --all-targets --all-features
|
||||
cargo test --quiet --workspace --doc
|
||||
trunk build
|
||||
148
index.html
Normal file
148
index.html
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
|
||||
<!-- Disable zooming: -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
|
||||
<head>
|
||||
<!-- change this to your project name -->
|
||||
<title>las-rs</title>
|
||||
|
||||
<!-- config for our rust wasm binary. go to https://trunkrs.dev/assets/#rust for more customization -->
|
||||
<link data-trunk rel="rust" data-wasm-opt="2" />
|
||||
<!-- this is the base url relative to which other urls will be constructed. trunk will insert this from the public-url option -->
|
||||
<base data-trunk-public-url />
|
||||
|
||||
<link data-trunk rel="icon" href="assets/favicon.ico">
|
||||
|
||||
|
||||
<link data-trunk rel="copy-file" href="assets/sw.js"/>
|
||||
<link data-trunk rel="copy-file" href="assets/manifest.json"/>
|
||||
<link data-trunk rel="copy-file" href="assets/icon-1024.png" data-target-path="assets"/>
|
||||
<link data-trunk rel="copy-file" href="assets/icon-256.png" data-target-path="assets"/>
|
||||
<link data-trunk rel="copy-file" href="assets/icon_ios_touch_192.png" data-target-path="assets"/>
|
||||
<link data-trunk rel="copy-file" href="assets/maskable_icon_x512.png" data-target-path="assets"/>
|
||||
|
||||
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="apple-touch-icon" href="assets/icon_ios_touch_192.png">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#404040">
|
||||
|
||||
<style>
|
||||
html {
|
||||
/* Remove touch delay: */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
body {
|
||||
/* Light mode background color for what is not covered by the egui canvas,
|
||||
or where the egui canvas is translucent. */
|
||||
background: #909090;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
/* Dark mode background color for what is not covered by the egui canvas,
|
||||
or where the egui canvas is translucent. */
|
||||
background: #404040;
|
||||
}
|
||||
}
|
||||
|
||||
/* Allow canvas to fill entire web page: */
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Make canvas fill entire document: */
|
||||
canvas {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.centered {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #f0f0f0;
|
||||
font-size: 24px;
|
||||
font-family: Ubuntu-Light, Helvetica, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------- */
|
||||
/* Loading animation from https://loading.io/css/ */
|
||||
.lds-dual-ring {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.lds-dual-ring:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #fff;
|
||||
border-color: #fff transparent #fff transparent;
|
||||
animation: lds-dual-ring 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes lds-dual-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- The WASM code will resize the canvas dynamically -->
|
||||
<!-- the id is hardcoded in main.rs . so, make sure both match. -->
|
||||
<canvas id="the_canvas_id"></canvas>
|
||||
|
||||
<!-- the loading spinner will be removed in main.rs -->
|
||||
<div class="centered" id="loading_text">
|
||||
<p style="font-size:16px">
|
||||
Loading…
|
||||
</p>
|
||||
<div class="lds-dual-ring"></div>
|
||||
</div>
|
||||
|
||||
<!--Register Service Worker. this will cache the wasm / js scripts for offline use (for PWA functionality). -->
|
||||
<!-- Force refresh (Ctrl + F5) to load the latest files instead of cached files -->
|
||||
<script>
|
||||
// We disable caching during development so that we always view the latest version.
|
||||
if ('serviceWorker' in navigator && window.location.hash !== "#dev") {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('sw.js');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
<!-- Powered by egui: https://github.com/emilk/egui/ -->
|
||||
32
package.json
32
package.json
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"name": "line-and-surface",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service serve",
|
||||
"build": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/cli": "^4.5.12",
|
||||
"core-js": "^3.6.5",
|
||||
"fetch-progress": "^1.3.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"panzoom": "^9.4.1",
|
||||
"stats.js": "^0.17.0",
|
||||
"vue": "^3.0.0",
|
||||
"vuex": "^4.0.0-0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commander-js/extra-typings": "^14.0.0",
|
||||
"@types/stats.js": "^0.17.0",
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-typescript": "~4.5.0",
|
||||
"@vue/cli-plugin-vuex": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/compiler-sfc": "^3.0.0",
|
||||
"@xmldom/xmldom": "^0.9.8",
|
||||
"commander": "^14.0.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~3.9.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="IE=edge" http-equiv="X-UA-Compatible">
|
||||
<meta content="width=device-width,initial-scale=1.0" name="viewport">
|
||||
<link href="<%= BASE_URL %>favicon.ico" rel="icon">
|
||||
<title>Line and Surface</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but Line and Surface doesn't work properly without JavaScript
|
||||
enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
<script data-goatcounter="https://las.goatcounter.com/count"
|
||||
async src="//gc.zgo.at/count.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
rust-toolchain
Normal file
10
rust-toolchain
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# If you see this, run "rustup self update" to get rustup 1.23 or newer.
|
||||
|
||||
# NOTE: above comment is for older `rustup` (before TOML support was added),
|
||||
# which will treat the first line as the toolchain name, and therefore show it
|
||||
# to the user in the error, instead of "error: invalid channel name '[toolchain]'".
|
||||
|
||||
[toolchain]
|
||||
channel = "1.85" # Avoid specifying a patch version here; see https://github.com/emilk/eframe_template/issues/145
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
targets = [ "wasm32-unknown-unknown" ]
|
||||
42
src/App.vue
42
src/App.vue
|
|
@ -1,42 +0,0 @@
|
|||
<template>
|
||||
<SVGContent
|
||||
id="root"
|
||||
url="content/intro.svg"
|
||||
@set-background="setBackground"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import SVGContent from "@/components/SVGContent.vue";
|
||||
import "normalize.css";
|
||||
|
||||
export default defineComponent({
|
||||
name: "App",
|
||||
components: {
|
||||
SVGContent,
|
||||
},
|
||||
methods: {
|
||||
setBackground(background: string) {
|
||||
document.body.style.background = background;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
background: black;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
164
src/app/animation.rs
Normal file
164
src/app/animation.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
use super::LasApp;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ViewAnimation {
|
||||
start_center_x: f32,
|
||||
start_center_y: f32,
|
||||
start_zoom: f32,
|
||||
target_center_x: f32,
|
||||
target_center_y: f32,
|
||||
target_zoom: f32,
|
||||
elapsed: f32,
|
||||
duration: f32,
|
||||
}
|
||||
|
||||
impl LasApp {
|
||||
pub(super) fn cancel_animation(&mut self) {
|
||||
self.view_animation = None;
|
||||
}
|
||||
|
||||
pub(super) fn begin_view_animation(
|
||||
&mut self,
|
||||
target_pan_x: f32,
|
||||
target_pan_y: f32,
|
||||
target_zoom: f32,
|
||||
duration: f32,
|
||||
canvas_rect: &egui::Rect,
|
||||
) {
|
||||
if duration <= 0.0 {
|
||||
self.pan_x = target_pan_x;
|
||||
self.pan_y = target_pan_y;
|
||||
self.zoom = target_zoom;
|
||||
self.view_animation = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let (start_center_x, start_center_y) =
|
||||
view_center(self.pan_x, self.pan_y, self.zoom, canvas_rect);
|
||||
let (target_center_x, target_center_y) =
|
||||
view_center(target_pan_x, target_pan_y, target_zoom, canvas_rect);
|
||||
|
||||
self.view_animation = Some(ViewAnimation {
|
||||
start_center_x,
|
||||
start_center_y,
|
||||
start_zoom: self.zoom,
|
||||
target_center_x,
|
||||
target_center_y,
|
||||
target_zoom,
|
||||
elapsed: 0.0,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn update_animation(&mut self, dt: f32, canvas_rect: &egui::Rect) {
|
||||
if let Some(anim) = &mut self.view_animation {
|
||||
anim.elapsed += dt;
|
||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||
let eased = ease_in_out_cubic(t);
|
||||
|
||||
let zoom = lerp(
|
||||
anim.start_zoom,
|
||||
anim.target_zoom,
|
||||
zoom_ease(t, anim.start_zoom, anim.target_zoom),
|
||||
);
|
||||
let center_x = lerp(anim.start_center_x, anim.target_center_x, eased);
|
||||
let center_y = lerp(anim.start_center_y, anim.target_center_y, eased);
|
||||
|
||||
if canvas_rect.width() > 0.0 && canvas_rect.height() > 0.0 {
|
||||
let visible_w = canvas_rect.width() / zoom;
|
||||
let visible_h = canvas_rect.height() / zoom;
|
||||
self.pan_x = -center_x + visible_w * 0.5;
|
||||
self.pan_y = -center_y + visible_h * 0.5;
|
||||
}
|
||||
|
||||
self.zoom = zoom;
|
||||
|
||||
if t >= 1.0 {
|
||||
self.view_animation = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn request_view_to_rect(
|
||||
&mut self,
|
||||
rect: (f32, f32, f32, f32),
|
||||
canvas_rect: &egui::Rect,
|
||||
duration: f32,
|
||||
) {
|
||||
if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, canvas_rect) {
|
||||
self.begin_view_animation(pan_x, pan_y, zoom, duration, canvas_rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle zoom towards a specific point.
|
||||
pub(super) fn zoom_towards(
|
||||
&mut self,
|
||||
new_zoom: f32,
|
||||
pos: egui::Pos2,
|
||||
canvas_rect: &egui::Rect,
|
||||
) {
|
||||
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
|
||||
let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y;
|
||||
self.pan_x = (pos.x - canvas_rect.left()) / new_zoom - svg_x;
|
||||
self.pan_y = (pos.y - canvas_rect.top()) / new_zoom - svg_y;
|
||||
self.zoom = new_zoom;
|
||||
}
|
||||
}
|
||||
|
||||
fn ease_in_out_cubic(t: f32) -> f32 {
|
||||
if t < 0.5 {
|
||||
4.0 * t * t * t
|
||||
} else {
|
||||
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
fn ease_out_cubic(t: f32) -> f32 {
|
||||
1.0 - (1.0 - t).powi(3)
|
||||
}
|
||||
|
||||
fn zoom_ease(t: f32, start: f32, target: f32) -> f32 {
|
||||
if target > start {
|
||||
// Zooming in: bias progress late but soften landing
|
||||
let biased = t.powf(1.6);
|
||||
ease_in_out_cubic(biased)
|
||||
} else {
|
||||
// Zooming out: shed zoom early
|
||||
ease_out_cubic(t)
|
||||
}
|
||||
}
|
||||
|
||||
fn lerp(a: f32, b: f32, t: f32) -> f32 {
|
||||
a + (b - a) * t
|
||||
}
|
||||
|
||||
pub(super) fn compute_view_for_rect(
|
||||
rect: (f32, f32, f32, f32),
|
||||
canvas_rect: &egui::Rect,
|
||||
) -> Option<(f32, f32, f32)> {
|
||||
if canvas_rect.width() <= 0.0 || canvas_rect.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (sx, sy, sw, sh) = rect;
|
||||
if sw <= 0.0 || sh <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let scale_x = canvas_rect.width() / sw;
|
||||
let scale_y = canvas_rect.height() / sh;
|
||||
let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0);
|
||||
let visible_w = canvas_rect.width() / fit_zoom;
|
||||
let visible_h = canvas_rect.height() / fit_zoom;
|
||||
|
||||
let pan_x = -sx + (visible_w - sw) * 0.5;
|
||||
let pan_y = -sy + (visible_h - sh) * 0.5;
|
||||
|
||||
Some((pan_x, pan_y, fit_zoom))
|
||||
}
|
||||
|
||||
fn view_center(pan_x: f32, pan_y: f32, zoom: f32, canvas_rect: &egui::Rect) -> (f32, f32) {
|
||||
let visible_w = canvas_rect.width() / zoom;
|
||||
let visible_h = canvas_rect.height() / zoom;
|
||||
(-pan_x + visible_w * 0.5, -pan_y + visible_h * 0.5)
|
||||
}
|
||||
34
src/app/input.rs
Normal file
34
src/app/input.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#[derive(Clone, Copy)]
|
||||
pub(super) struct InputSnapshot {
|
||||
pub(super) escape_pressed: bool,
|
||||
pub(super) f3_pressed: bool,
|
||||
pub(super) f_pressed: bool,
|
||||
pub(super) scroll_delta: f32,
|
||||
pub(super) zoom_delta: f32,
|
||||
pub(super) pointer_latest: Option<egui::Pos2>,
|
||||
pub(super) pointer_pos: Option<egui::Pos2>,
|
||||
pub(super) frame_time: f32,
|
||||
pub(super) primary_down: bool,
|
||||
pub(super) primary_pressed: bool,
|
||||
pub(super) primary_released: bool,
|
||||
pub(super) space_pressed: bool,
|
||||
}
|
||||
|
||||
impl InputSnapshot {
|
||||
pub(super) fn collect(ctx: &egui::Context) -> Self {
|
||||
ctx.input(|i| Self {
|
||||
escape_pressed: i.key_pressed(egui::Key::Escape),
|
||||
f3_pressed: i.key_pressed(egui::Key::F3),
|
||||
f_pressed: i.key_pressed(egui::Key::F),
|
||||
scroll_delta: i.smooth_scroll_delta.y,
|
||||
zoom_delta: i.zoom_delta(),
|
||||
pointer_latest: i.pointer.latest_pos(),
|
||||
pointer_pos: i.pointer.hover_pos(),
|
||||
frame_time: i.stable_dt,
|
||||
primary_down: i.pointer.primary_down(),
|
||||
primary_pressed: i.pointer.primary_pressed(),
|
||||
primary_released: i.pointer.primary_released(),
|
||||
space_pressed: i.key_pressed(egui::Key::Space),
|
||||
})
|
||||
}
|
||||
}
|
||||
186
src/app/mod.rs
Normal file
186
src/app/mod.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
use crate::svg::SvgContent;
|
||||
use crate::text_cache::TextCache;
|
||||
|
||||
mod animation;
|
||||
mod input;
|
||||
mod render;
|
||||
|
||||
use input::InputSnapshot;
|
||||
|
||||
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct LasApp {
|
||||
#[serde(skip)]
|
||||
svg_content: Option<SvgContent>,
|
||||
|
||||
/// Pan offset in SVG coordinates.
|
||||
pan_x: f32,
|
||||
pan_y: f32,
|
||||
|
||||
/// Zoom factor (1.0 = 100%).
|
||||
zoom: f32,
|
||||
|
||||
#[serde(skip)]
|
||||
show_menu_bar: bool,
|
||||
|
||||
#[serde(skip)]
|
||||
show_debug: bool,
|
||||
|
||||
#[serde(skip)]
|
||||
render_internal_areas: bool,
|
||||
|
||||
/// Exponential moving average of frame time for stable FPS display.
|
||||
#[serde(skip)]
|
||||
fps_ema: f32,
|
||||
|
||||
/// Whether we've auto-fitted the start viewport.
|
||||
#[serde(skip)]
|
||||
did_fit_start: bool,
|
||||
|
||||
/// Last known cursor position (for edge scrolling even without movement).
|
||||
#[serde(skip)]
|
||||
last_cursor_pos: Option<egui::Pos2>,
|
||||
|
||||
/// Whether a reset-to-start was requested (from UI)
|
||||
#[serde(skip)]
|
||||
reset_view_requested: bool,
|
||||
|
||||
/// Smooth camera animation state.
|
||||
#[serde(skip)]
|
||||
view_animation: Option<animation::ViewAnimation>,
|
||||
|
||||
/// Last pointer position for manual drag tracking (smoother than `Sense::drag`).
|
||||
#[serde(skip)]
|
||||
last_pointer_pos: Option<egui::Pos2>,
|
||||
|
||||
/// Whether we're currently in a drag operation.
|
||||
#[serde(skip)]
|
||||
is_dragging: bool,
|
||||
|
||||
/// Text rendering cache for smooth scaling.
|
||||
#[serde(skip)]
|
||||
text_cache: Option<TextCache>,
|
||||
}
|
||||
|
||||
impl Default for LasApp {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
svg_content: None,
|
||||
pan_x: 0.0,
|
||||
pan_y: 0.0,
|
||||
zoom: 1.0,
|
||||
show_menu_bar: false,
|
||||
show_debug: false,
|
||||
render_internal_areas: false,
|
||||
fps_ema: 60.0,
|
||||
last_pointer_pos: None,
|
||||
is_dragging: false,
|
||||
text_cache: None,
|
||||
did_fit_start: false,
|
||||
last_cursor_pos: None,
|
||||
reset_view_requested: false,
|
||||
view_animation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LasApp {
|
||||
const NAV_DURATION: f32 = 1.5;
|
||||
|
||||
/// Called once before the first frame.
|
||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
log::info!("Initializing application...");
|
||||
|
||||
log::debug!("Installing image loaders...");
|
||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||
|
||||
log::debug!("Loading app state...");
|
||||
let mut app: Self = cc
|
||||
.storage
|
||||
.and_then(|s| eframe::get_value(s, eframe::APP_KEY))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Load SVG content
|
||||
let svg_path = "../line-and-surface/content/intro.svg";
|
||||
log::info!("Loading SVG from: {svg_path}");
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
match SvgContent::from_file(svg_path) {
|
||||
Ok(content) => {
|
||||
let elapsed = start.elapsed();
|
||||
log::info!(
|
||||
"Loaded SVG in {:.2?}: {} video scrolls, {} audio areas, {} anchors, {} texts",
|
||||
elapsed,
|
||||
content.video_scrolls.len(),
|
||||
content.audio_areas.len(),
|
||||
content.anchors.len(),
|
||||
content.texts.len()
|
||||
);
|
||||
if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox {
|
||||
log::debug!("SVG viewbox: ({vb_x}, {vb_y}, {vb_w}, {vb_h})");
|
||||
app.pan_x = -vb_x;
|
||||
app.pan_y = -vb_y;
|
||||
}
|
||||
app.svg_content = Some(content);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load SVG: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Application initialized");
|
||||
app
|
||||
}
|
||||
|
||||
fn update_fps(&mut self, frame_time: f32) {
|
||||
if frame_time > 0.0 {
|
||||
const ALPHA: f32 = 0.1;
|
||||
self.fps_ema = ALPHA * (1.0 / frame_time) + (1.0 - ALPHA) * self.fps_ema;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for LasApp {
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
eframe::set_value(storage, eframe::APP_KEY, self);
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let input = InputSnapshot::collect(ctx);
|
||||
|
||||
if input.primary_pressed
|
||||
|| input.scroll_delta != 0.0
|
||||
|| (input.zoom_delta - 1.0).abs() > f32::EPSILON
|
||||
{
|
||||
self.cancel_animation();
|
||||
}
|
||||
|
||||
if let Some(pos) = input.pointer_latest.or(input.pointer_pos) {
|
||||
self.last_cursor_pos = Some(pos);
|
||||
}
|
||||
|
||||
let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
|
||||
|
||||
self.update_fps(input.frame_time);
|
||||
|
||||
if input.escape_pressed {
|
||||
self.show_menu_bar = !self.show_menu_bar;
|
||||
}
|
||||
if input.f3_pressed {
|
||||
self.show_debug = !self.show_debug;
|
||||
}
|
||||
|
||||
if self.show_menu_bar {
|
||||
self.render_menu_bar(ctx);
|
||||
}
|
||||
|
||||
let rendered_count = egui::CentralPanel::default()
|
||||
.show(ctx, |ui| self.render_canvas(ctx, ui, &input, is_fullscreen))
|
||||
.inner;
|
||||
|
||||
if self.show_debug {
|
||||
self.render_debug_window(ctx, rendered_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
428
src/app/render.rs
Normal file
428
src/app/render.rs
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
use super::LasApp;
|
||||
use super::animation::compute_view_for_rect;
|
||||
use super::input::InputSnapshot;
|
||||
use crate::svg::Renderable as _;
|
||||
|
||||
impl LasApp {
|
||||
pub(super) fn render_menu_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Quit").clicked() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
egui::widgets::global_theme_preference_buttons(ui);
|
||||
ui.separator();
|
||||
|
||||
ui.label(format!("Zoom: {:.0}%", self.zoom * 100.0));
|
||||
if ui.button("Reset View").clicked() {
|
||||
self.reset_view_requested = true;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
let debug_label = if self.show_debug {
|
||||
"Hide Debug (F3)"
|
||||
} else {
|
||||
"Show Debug (F3)"
|
||||
};
|
||||
if ui.button(debug_label).clicked() {
|
||||
self.show_debug = !self.show_debug;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn render_debug_window(&mut self, ctx: &egui::Context, rendered_count: u32) {
|
||||
egui::Window::new("Debug")
|
||||
.anchor(egui::Align2::RIGHT_TOP, [-10.0, 10.0])
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(format!("FPS: {:.1}", self.fps_ema));
|
||||
ui.label(format!("Pixels per point: {:.2}", ctx.pixels_per_point()));
|
||||
ui.separator();
|
||||
ui.label(format!("Pan: ({:.1}, {:.1})", self.pan_x, self.pan_y));
|
||||
ui.label(format!("Zoom: {:.2}x", self.zoom));
|
||||
ui.separator();
|
||||
|
||||
if let Some(ref content) = self.svg_content {
|
||||
let total = content.video_scrolls.len()
|
||||
+ content.audio_areas.len()
|
||||
+ content.anchors.len()
|
||||
+ content.texts.len();
|
||||
ui.label(format!("Total elements: {total}"));
|
||||
ui.label(format!("Rendered: {rendered_count}"));
|
||||
ui.label(format!(
|
||||
"Culled: {}",
|
||||
total.saturating_sub(rendered_count as usize)
|
||||
));
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.checkbox(&mut self.render_internal_areas, "Render internal areas");
|
||||
|
||||
if let Some(ref cache) = self.text_cache {
|
||||
ui.separator();
|
||||
let bytes = cache.cache_memory_bytes();
|
||||
let mb = bytes as f32 / (1024.0 * 1024.0);
|
||||
ui.label(format!(
|
||||
"Text cache: {} entries (~{mb:.2} MB)",
|
||||
cache.cache_size()
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_lines)]
|
||||
pub(super) fn render_canvas(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
ui: &mut egui::Ui,
|
||||
input: &InputSnapshot,
|
||||
is_fullscreen: bool,
|
||||
) -> u32 {
|
||||
let (response, painter) =
|
||||
ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
|
||||
let canvas_rect = response.rect;
|
||||
|
||||
let background_rgb = self
|
||||
.svg_content
|
||||
.as_ref()
|
||||
.and_then(|content| content.background_color)
|
||||
.unwrap_or([0, 0, 0]);
|
||||
let background_color =
|
||||
egui::Color32::from_rgb(background_rgb[0], background_rgb[1], background_rgb[2]);
|
||||
painter.rect_filled(canvas_rect, 0.0, background_color);
|
||||
|
||||
let anim_dt = if input.frame_time > 0.0 {
|
||||
input.frame_time
|
||||
} else {
|
||||
1.0 / 60.0
|
||||
};
|
||||
self.update_animation(anim_dt, &canvas_rect);
|
||||
if self.view_animation.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
if response.double_clicked() || input.f_pressed {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen));
|
||||
}
|
||||
|
||||
if !self.did_fit_start {
|
||||
if let Some(ref content) = self.svg_content {
|
||||
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
||||
if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, &canvas_rect) {
|
||||
self.pan_x = pan_x;
|
||||
self.pan_y = pan_y;
|
||||
self.zoom = zoom;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.did_fit_start = true;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
if input.space_pressed {
|
||||
self.reset_view_requested = true;
|
||||
}
|
||||
|
||||
if self.reset_view_requested {
|
||||
if let Some(ref content) = self.svg_content {
|
||||
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
||||
self.request_view_to_rect(rect, &canvas_rect, Self::NAV_DURATION);
|
||||
}
|
||||
}
|
||||
self.reset_view_requested = false;
|
||||
}
|
||||
|
||||
if input.primary_pressed && response.hovered() {
|
||||
self.is_dragging = true;
|
||||
self.last_pointer_pos = input.pointer_pos;
|
||||
}
|
||||
if input.primary_released {
|
||||
self.is_dragging = false;
|
||||
self.last_pointer_pos = None;
|
||||
}
|
||||
if self.is_dragging && input.primary_down {
|
||||
if let (Some(current), Some(last)) = (input.pointer_pos, self.last_pointer_pos) {
|
||||
let delta = current - last;
|
||||
self.pan_x += delta.x / self.zoom;
|
||||
self.pan_y += delta.y / self.zoom;
|
||||
}
|
||||
self.last_pointer_pos = input.pointer_pos;
|
||||
}
|
||||
|
||||
if response.hovered() {
|
||||
if input.scroll_delta != 0.0 {
|
||||
let factor = 1.0 + input.scroll_delta * 0.001;
|
||||
let new_zoom = (self.zoom * factor).clamp(0.01, 100.0);
|
||||
if let Some(pos) = input.pointer_pos {
|
||||
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
||||
} else {
|
||||
self.zoom = new_zoom;
|
||||
}
|
||||
}
|
||||
if input.zoom_delta != 1.0 {
|
||||
let new_zoom = (self.zoom * input.zoom_delta).clamp(0.01, 100.0);
|
||||
if let Some(pos) = input.pointer_pos {
|
||||
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
||||
} else {
|
||||
self.zoom = new_zoom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let edge_pointer = input.pointer_latest.or(self.last_cursor_pos);
|
||||
if is_fullscreen {
|
||||
if let Some(mouse_pos) = edge_pointer {
|
||||
let dt = if input.frame_time > 0.0 {
|
||||
input.frame_time
|
||||
} else {
|
||||
1.0 / 60.0
|
||||
};
|
||||
let nx = if canvas_rect.width() > 0.0 {
|
||||
((mouse_pos.x - canvas_rect.left()) / canvas_rect.width()).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
let ny = if canvas_rect.height() > 0.0 {
|
||||
((mouse_pos.y - canvas_rect.top()) / canvas_rect.height()).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
|
||||
let edge_threshold = 0.15;
|
||||
let mut vx = 0.0;
|
||||
let mut vy = 0.0;
|
||||
|
||||
if nx < edge_threshold {
|
||||
vx = (edge_threshold - nx) / edge_threshold;
|
||||
} else if nx > 1.0 - edge_threshold {
|
||||
vx = -(nx - (1.0 - edge_threshold)) / edge_threshold;
|
||||
}
|
||||
|
||||
if ny < edge_threshold {
|
||||
vy = (edge_threshold - ny) / edge_threshold;
|
||||
} else if ny > 1.0 - edge_threshold {
|
||||
vy = -(ny - (1.0 - edge_threshold)) / edge_threshold;
|
||||
}
|
||||
|
||||
if vx != 0.0 || vy != 0.0 {
|
||||
let view_w = canvas_rect.width() / self.zoom;
|
||||
let view_h = canvas_rect.height() / self.zoom;
|
||||
let speed_w = view_w;
|
||||
let speed_h = view_h;
|
||||
|
||||
self.pan_x += vx * speed_w * dt;
|
||||
self.pan_y += vy * speed_h * dt;
|
||||
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let internal_alpha = (0.3_f32 * 255.0).round() as u8;
|
||||
let video_scroll_color = egui::Color32::from_rgba_unmultiplied(255, 0, 0, internal_alpha);
|
||||
let audio_area_color = egui::Color32::from_rgba_unmultiplied(0, 0, 255, internal_alpha);
|
||||
let anchor_color = egui::Color32::from_rgba_unmultiplied(64, 255, 64, internal_alpha);
|
||||
let text_color = egui::Color32::from_rgb(255, 255, 255);
|
||||
|
||||
if self.text_cache.is_none() {
|
||||
self.text_cache = Some(crate::text_cache::TextCache::new());
|
||||
}
|
||||
|
||||
let pan_x = self.pan_x;
|
||||
let pan_y = self.pan_y;
|
||||
let zoom = self.zoom;
|
||||
|
||||
let svg_to_screen = |x: f32, y: f32| -> egui::Pos2 {
|
||||
egui::pos2(
|
||||
(x + pan_x) * zoom + canvas_rect.left(),
|
||||
(y + pan_y) * zoom + canvas_rect.top(),
|
||||
)
|
||||
};
|
||||
let screen_to_svg = |pos: egui::Pos2| -> (f32, f32) {
|
||||
(
|
||||
(pos.x - canvas_rect.left()) / zoom - pan_x,
|
||||
(pos.y - canvas_rect.top()) / zoom - pan_y,
|
||||
)
|
||||
};
|
||||
|
||||
let mut pending_navigation: Option<(f32, f32, f32, f32)> = None;
|
||||
let mut rendered_count = 0u32;
|
||||
|
||||
if let Some(ref content) = self.svg_content {
|
||||
let pointer_svg_hover = input.pointer_latest.or(input.pointer_pos).and_then(|p| {
|
||||
if canvas_rect.contains(p) {
|
||||
Some(screen_to_svg(p))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let click_svg_pos = if input.primary_released && !self.is_dragging {
|
||||
input.pointer_latest.or(input.pointer_pos).and_then(|p| {
|
||||
if canvas_rect.contains(p) {
|
||||
Some(screen_to_svg(p))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((svg_x, svg_y)) = click_svg_pos {
|
||||
if let Some(link) = content.anchor_links.iter().find(|link| {
|
||||
svg_x >= link.x
|
||||
&& svg_x <= link.x + link.width
|
||||
&& svg_y >= link.y
|
||||
&& svg_y <= link.y + link.height
|
||||
}) {
|
||||
if let Some(anchor) = content.anchors.iter().find(|a| a.id == link.target_id) {
|
||||
pending_navigation = Some(anchor.bounds());
|
||||
} else {
|
||||
log::warn!("Anchor link target not found: {}", link.target_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((svg_x, svg_y)) = pointer_svg_hover {
|
||||
if content.anchor_links.iter().any(|link| {
|
||||
svg_x >= link.x
|
||||
&& svg_x <= link.x + link.width
|
||||
&& svg_y >= link.y
|
||||
&& svg_y <= link.y + link.height
|
||||
}) {
|
||||
ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
|
||||
}
|
||||
}
|
||||
|
||||
if self.render_internal_areas {
|
||||
let mut hovered_descs: Vec<String> = Vec::new();
|
||||
let pointer_svg = input.pointer_pos.map(screen_to_svg);
|
||||
|
||||
for vs in &content.video_scrolls {
|
||||
let (x, y, w, h) = vs.bounds();
|
||||
let rect =
|
||||
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
||||
if rect.intersects(canvas_rect) {
|
||||
painter.rect_filled(rect, 0.0, video_scroll_color);
|
||||
rendered_count += 1;
|
||||
}
|
||||
|
||||
if let Some((svg_x, svg_y)) = pointer_svg {
|
||||
if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h {
|
||||
hovered_descs.push(format!("Video: {}", vs.desc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for aa in &content.audio_areas {
|
||||
let center = svg_to_screen(aa.cx, aa.cy);
|
||||
let radius = aa.radius * zoom;
|
||||
let rect = egui::Rect::from_min_max(
|
||||
egui::pos2(center.x - radius, center.y - radius),
|
||||
egui::pos2(center.x + radius, center.y + radius),
|
||||
);
|
||||
if rect.intersects(canvas_rect) {
|
||||
painter.circle_filled(center, radius, audio_area_color);
|
||||
rendered_count += 1;
|
||||
}
|
||||
|
||||
if let Some((svg_x, svg_y)) = pointer_svg {
|
||||
let dx = svg_x - aa.cx;
|
||||
let dy = svg_y - aa.cy;
|
||||
if dx * dx + dy * dy <= aa.radius * aa.radius {
|
||||
hovered_descs.push(format!("Audio: {}", aa.desc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for anchor in &content.anchors {
|
||||
let (x, y, w, h) = anchor.bounds();
|
||||
let rect =
|
||||
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
||||
if rect.intersects(canvas_rect) {
|
||||
painter.rect_filled(rect, 0.0, anchor_color);
|
||||
rendered_count += 1;
|
||||
}
|
||||
|
||||
if let Some((svg_x, svg_y)) = pointer_svg {
|
||||
if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h {
|
||||
hovered_descs.push(format!("Anchor: {}", anchor.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hovered_descs.is_empty() {
|
||||
egui::Tooltip::always_open(
|
||||
ctx.clone(),
|
||||
ui.layer_id(),
|
||||
egui::Id::new("internal-area-desc"),
|
||||
egui::PopupAnchor::Pointer,
|
||||
)
|
||||
.gap(12.0)
|
||||
.show(|ui| {
|
||||
for desc in hovered_descs {
|
||||
ui.label(desc);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let text_cache = self.text_cache.as_mut().expect("just initialized");
|
||||
for text_elem in &content.texts {
|
||||
let (x, y, w, h) = text_elem.bounds();
|
||||
let rect =
|
||||
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
||||
|
||||
if !rect.intersects(canvas_rect) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let display_font_size = text_elem.font_size * zoom;
|
||||
if display_font_size >= 0.5 {
|
||||
for line in &text_elem.lines {
|
||||
if line.content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cached =
|
||||
text_cache.get_or_create(ctx, &line.content, text_elem.font_size, zoom);
|
||||
let scale_factor = zoom / cached.render_scale;
|
||||
let display_width = cached.width as f32 * scale_factor;
|
||||
let display_height = cached.height as f32 * scale_factor;
|
||||
|
||||
let y_adjusted = line.y - text_elem.font_size * 0.8;
|
||||
let pos = svg_to_screen(line.x, y_adjusted);
|
||||
|
||||
painter.image(
|
||||
cached.texture.id(),
|
||||
egui::Rect::from_min_size(
|
||||
pos,
|
||||
egui::vec2(display_width, display_height),
|
||||
),
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
text_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
rendered_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rect) = pending_navigation {
|
||||
self.request_view_to_rect(rect, &canvas_rect, Self::NAV_DURATION);
|
||||
}
|
||||
|
||||
rendered_count
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
<template>
|
||||
<audio ref="audio" :src="audioSrc" loop preload="auto" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref, watch } from "vue";
|
||||
import { BoundingBox } from "@/components/SVGContent.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "AudioArea",
|
||||
props: {
|
||||
definition: {
|
||||
type: Object as PropType<AudioAreaDef>,
|
||||
required: true,
|
||||
},
|
||||
bbox: {
|
||||
type: Object as PropType<BoundingBox>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const audio = ref<HTMLAudioElement | null>(null);
|
||||
const audioSrc = ref<string>(""); // Ref to hold audio source after preloading
|
||||
const isPreloaded = ref<boolean>(false);
|
||||
|
||||
console.debug(`[AUDIOAREA] Initializing ${props.definition.src}...`);
|
||||
console.debug(props.definition);
|
||||
|
||||
// Preload the audio file completely to avoid keeping connections open
|
||||
const preloadAudio = async (src: string) => {
|
||||
console.debug(`[AUDIOAREA] Preloading audio: ${src}`);
|
||||
try {
|
||||
// Fetch the entire audio file
|
||||
const response = await fetch(src);
|
||||
if (!response.ok) throw new Error(`Failed to load audio: ${response.statusText}`);
|
||||
|
||||
// Convert to blob to ensure full download
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create a blob URL to use as the audio source
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
audioSrc.value = blobUrl;
|
||||
isPreloaded.value = true;
|
||||
console.debug(`[AUDIOAREA] Successfully preloaded audio: ${src}`);
|
||||
} catch (error) {
|
||||
console.error(`[AUDIOAREA] Error preloading audio: ${error}`);
|
||||
// Fall back to original source if preloading fails
|
||||
audioSrc.value = src;
|
||||
}
|
||||
};
|
||||
|
||||
// Start preloading when component is created
|
||||
preloadAudio(props.definition.src);
|
||||
|
||||
const MIN_SCALE = 0.02;
|
||||
const MIN_VOLUME_MULTIPLIER = 0.33;
|
||||
const vol_x = (1 - MIN_VOLUME_MULTIPLIER) / (1 - MIN_SCALE);
|
||||
const vol_b = 1 - vol_x;
|
||||
|
||||
const onBBoxChange = () => {
|
||||
const x = props.bbox.x + props.bbox.w / 2;
|
||||
const y = props.bbox.y + props.bbox.h / 2;
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(x - props.definition.cx, 2) +
|
||||
Math.pow(y - props.definition.cy, 2)
|
||||
);
|
||||
|
||||
if (distance < props.definition.radius) {
|
||||
if (audio.value!.paused) {
|
||||
console.debug(
|
||||
`[AUDIOAREA] Entered audio area "${props.definition.src}", starting playback...`
|
||||
);
|
||||
audio.value!.play();
|
||||
}
|
||||
const volume =
|
||||
(props.definition.radius - distance) / props.definition.radius;
|
||||
audio.value!.volume =
|
||||
volume * (props.bbox.z < 1 ? props.bbox.z * vol_x + vol_b : 1);
|
||||
} else {
|
||||
if (!audio.value!.paused) {
|
||||
console.debug(
|
||||
`[AUDIOAREA] Left audio area "${props.definition.src}", pausing playback...`
|
||||
);
|
||||
audio.value!.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
watch(props.bbox, onBBoxChange, { deep: true });
|
||||
|
||||
return {
|
||||
audio,
|
||||
audioSrc,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export interface AudioAreaDef {
|
||||
id: string;
|
||||
cx: number;
|
||||
cy: number;
|
||||
radius: number;
|
||||
src: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
|
|
@ -1,656 +0,0 @@
|
|||
<template>
|
||||
<div class="svg-content">
|
||||
<div :class="['loading-screen', { loaded: loadedPercent === 100 }]">
|
||||
<div :style="{ width: `${loadedPercent}%` }" class="loading-bar"></div>
|
||||
</div>
|
||||
<div class="content" ref="root">
|
||||
<div class="video-scrolls">
|
||||
<VideoScroll
|
||||
v-for="scroll in scrolls"
|
||||
:definition="scroll"
|
||||
:key="scroll.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AudioArea
|
||||
v-for="audio in audioAreas"
|
||||
:definition="audio"
|
||||
:bbox="bbox"
|
||||
:key="audio.id"
|
||||
/>
|
||||
<div class="dev devpanel">
|
||||
<div>
|
||||
<span>Current viewport position:</span>
|
||||
<span>{{ Math.round(bbox.x) }}x{{ Math.round(bbox.y) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>Current cursor position:</span>
|
||||
<span
|
||||
>{{ Math.round(mousePosition.x) }}x{{
|
||||
Math.round(mousePosition.y)
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span>Zoom level:</span
|
||||
><span>{{ Math.round(bbox.z * 1000) / 1000 }}</span>
|
||||
</div>
|
||||
<label>
|
||||
<input v-model="showInternal" type="checkbox" />
|
||||
<label>Show internal elements</label>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, onMounted, reactive, ref } from "vue";
|
||||
import createPanZoom, { PanZoom } from "panzoom";
|
||||
import VideoScroll, {
|
||||
VideoScrollDef,
|
||||
VideoScrollDirection,
|
||||
} from "@/components/VideoScroll.vue";
|
||||
import AudioArea, { AudioAreaDef } from "@/components/AudioArea.vue";
|
||||
import Stats from "stats.js";
|
||||
import { rotate } from "@/utils";
|
||||
import fetchProgress from "fetch-progress";
|
||||
|
||||
export interface BoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: "SVGContent",
|
||||
components: { AudioArea, VideoScroll },
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showInternal: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
showInternal(value) {
|
||||
Array.from(this.root!.getElementsByClassName("internal")).forEach(
|
||||
(el) => {
|
||||
(el as SVGElement).style.visibility = value ? "visible" : "hidden";
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const root = ref<HTMLDivElement | null>(null);
|
||||
const loadedPercent = ref(0);
|
||||
const panzoom = ref<null | PanZoom>(null);
|
||||
const anchors = ref<SVGRectElement[]>([]);
|
||||
const scrolls = ref<VideoScrollDef[]>([]);
|
||||
const panToAnchor = ref();
|
||||
const audioAreas = ref<AudioAreaDef[]>([]);
|
||||
const bbox: BoundingBox = reactive({
|
||||
x: ref(0),
|
||||
y: ref(0),
|
||||
w: ref(0),
|
||||
h: ref(0),
|
||||
z: ref(1),
|
||||
});
|
||||
const mousePosition = reactive({
|
||||
x: ref(0),
|
||||
y: ref(0),
|
||||
});
|
||||
const panning = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
const element = (root.value as unknown) as HTMLDivElement;
|
||||
console.info("[SVG] Initializing.");
|
||||
|
||||
// Fetch & load SVG
|
||||
console.info(`[SVG] Fetching "${props.url}..."`);
|
||||
const fetchResult = await fetch(props.url).then(
|
||||
fetchProgress({
|
||||
onProgress(progress) {
|
||||
loadedPercent.value = (progress as any).percentage;
|
||||
},
|
||||
})
|
||||
);
|
||||
const svgParsed = new DOMParser().parseFromString(
|
||||
await fetchResult.text(),
|
||||
"image/svg+xml"
|
||||
) as Document;
|
||||
console.debug("[SVG] Loaded.");
|
||||
loadedPercent.value = 100;
|
||||
const svg = element.appendChild(
|
||||
svgParsed.firstElementChild as Element
|
||||
) as any;
|
||||
|
||||
// Set document background
|
||||
const pageColor = svg
|
||||
.getElementById("base")
|
||||
?.attributes.getNamedItem("pagecolor");
|
||||
if (pageColor) {
|
||||
console.debug(`[SVG] Found pageColor attribute: ${pageColor.value}`);
|
||||
emit("setBackground", pageColor.value);
|
||||
}
|
||||
|
||||
// PanZoom
|
||||
const pz = createPanZoom(element, {
|
||||
smoothScroll: false,
|
||||
minZoom: 0.05,
|
||||
maxZoom: 3637937,
|
||||
zoomSpeed: 0.05,
|
||||
zoomDoubleClickSpeed: 1,
|
||||
beforeMouseDown: () => {
|
||||
return panning.value;
|
||||
},
|
||||
beforeWheel: () => {
|
||||
return panning.value;
|
||||
},
|
||||
onDoubleClick: () => {
|
||||
if (!document.fullscreenElement) {
|
||||
console.debug("[SVG] Fullscreen requested.");
|
||||
document.body.requestFullscreen();
|
||||
} else {
|
||||
console.debug("[SVG] Fullscreen exited.");
|
||||
document.exitFullscreen();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
panzoom.value = pz;
|
||||
|
||||
// Calculate SVG-unit bounding box, update transform
|
||||
pz.on("transform", function (_) {
|
||||
const transform = pz.getTransform();
|
||||
const currentRatio =
|
||||
(svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
|
||||
|
||||
bbox.x = (transform.x / currentRatio) * -1;
|
||||
bbox.y = (transform.y / currentRatio) * -1;
|
||||
bbox.w = window.innerWidth / currentRatio;
|
||||
bbox.h = window.innerHeight / currentRatio;
|
||||
bbox.z = transform.scale;
|
||||
|
||||
window.location.hash = `${Math.round(bbox.x + bbox.w / 2)},${Math.round(
|
||||
bbox.y + bbox.h / 2
|
||||
)},${Math.round(transform.scale * 1000) / 1000}z`;
|
||||
});
|
||||
|
||||
function panToElement(target: SVGRectElement, smooth: boolean) {
|
||||
console.debug(`[SVG] Panning to element: #${target.id}`);
|
||||
const transform = pz.getTransform();
|
||||
const currentRatio =
|
||||
(svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
|
||||
const ratio = svg.clientWidth / svg.viewBox.baseVal.width;
|
||||
const targetScale =
|
||||
window.innerWidth / (target.width.baseVal.value * ratio);
|
||||
|
||||
const svgTargetX =
|
||||
(target.x.baseVal.value + target.width.baseVal.value / 2) *
|
||||
currentRatio;
|
||||
const svgTargetY =
|
||||
(target.y.baseVal.value + target.height.baseVal.value / 2) *
|
||||
currentRatio;
|
||||
|
||||
if (smooth) {
|
||||
panning.value = true;
|
||||
|
||||
pz.smoothMoveTo(
|
||||
svgTargetX * -1 + window.innerWidth / 2,
|
||||
svgTargetY * -1 + window.innerHeight / 2
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
const finalTransform = pz.getTransform();
|
||||
pz.smoothZoomAbs(
|
||||
svgTargetX + finalTransform.x,
|
||||
svgTargetY + finalTransform.y,
|
||||
targetScale
|
||||
);
|
||||
setTimeout(() => {
|
||||
panning.value = false;
|
||||
}, 400);
|
||||
}, 400 * 4);
|
||||
} else {
|
||||
pz.moveTo(
|
||||
svgTargetX * -1 + window.innerWidth / 2,
|
||||
svgTargetY * -1 + window.innerHeight / 2
|
||||
);
|
||||
pz.zoomAbs(
|
||||
window.innerWidth / 2,
|
||||
window.innerHeight / 2,
|
||||
targetScale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
panToAnchor.value = (anchor: SVGRectElement) => {
|
||||
panToElement(anchor, true);
|
||||
};
|
||||
|
||||
// Process start element
|
||||
const start = processStart(svg);
|
||||
if (start) {
|
||||
console.info("[SVG] Found start element.");
|
||||
window.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === " ") {
|
||||
panToElement(start, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pan to start element or location in hash
|
||||
const locationMatch = window.location.href.match(
|
||||
/#([\-0-9.]+),([\-0-9.]+),([0-9.]+)z/
|
||||
);
|
||||
if (locationMatch) {
|
||||
console.debug(`[SVGCONTENT] Got a location match: ${locationMatch}`);
|
||||
const [_, x, y, z] = locationMatch;
|
||||
|
||||
const transform = pz.getTransform();
|
||||
const currentRatio =
|
||||
(svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
|
||||
pz.moveTo(
|
||||
parseFloat(x) * currentRatio * -1 + window.innerWidth / 2,
|
||||
parseFloat(y) * currentRatio * -1 + window.innerHeight / 2
|
||||
);
|
||||
pz.zoomAbs(
|
||||
window.innerWidth / 2,
|
||||
window.innerHeight / 2,
|
||||
parseFloat(z)
|
||||
);
|
||||
} else if (start) {
|
||||
console.debug(`[SVGCONTENT] Panning to start anchor.`);
|
||||
panToElement(start, false);
|
||||
}
|
||||
|
||||
// Anchors
|
||||
console.debug("[SVG] Processing anchors.");
|
||||
anchors.value = processAnchors(svg);
|
||||
console.info(`[SVG] Found ${anchors.value.length} anchors.`);
|
||||
|
||||
// Links
|
||||
console.debug("[SVG] Processing hyperlinks.");
|
||||
const { anchor, hyper } = processHyperlinks(svg);
|
||||
console.info(
|
||||
`[SVG] Found ${anchor.length} anchor links and ${hyper.length} hyperlinks.`
|
||||
);
|
||||
anchor.forEach(([anchorId, element]) => {
|
||||
const anchor = anchors.value.find((a) => a.id == anchorId);
|
||||
if (!anchor) {
|
||||
console.error(`[SVG] Could not find anchor #${anchorId}!`);
|
||||
return;
|
||||
}
|
||||
element.addEventListener("click", () => {
|
||||
panToElement(anchor, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Audio areas
|
||||
console.debug("[SVG] Processing audio areas.");
|
||||
audioAreas.value = processAudio(svg);
|
||||
console.info(`[SVG] Found ${audioAreas.value.length} audio areas.`);
|
||||
|
||||
// Videoscrolls
|
||||
console.debug("[SVG] Processing video scrolls.");
|
||||
scrolls.value = await processScrolls(svg);
|
||||
console.info(`[SVG] Found ${scrolls.value.length} video scrolls.`);
|
||||
|
||||
// Debug Stats
|
||||
let stats: Stats | undefined;
|
||||
if (window.location.search.includes("debug")) {
|
||||
console.info("[SVG] DEBUG mode active, turning on stats & dev panel.");
|
||||
stats = new Stats();
|
||||
document.body.appendChild(stats.dom);
|
||||
|
||||
Array.from(document.body.getElementsByClassName("dev")).forEach(
|
||||
(el) => {
|
||||
(el as HTMLElement).style.display = "block";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Animations: FPS Counter, Edge scrolling
|
||||
let mouse: MouseEvent | undefined;
|
||||
window.addEventListener("mousemove", (ev) => {
|
||||
mouse = ev;
|
||||
const transform = pz.getTransform();
|
||||
const currentRatio =
|
||||
(svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
|
||||
mousePosition.x = (mouse.clientX - transform.x) / currentRatio;
|
||||
mousePosition.y = (mouse.clientY - transform.y) / currentRatio;
|
||||
});
|
||||
|
||||
let gamePadZoomSpeed = 10;
|
||||
|
||||
function animate() {
|
||||
if (stats) {
|
||||
stats.begin();
|
||||
}
|
||||
|
||||
// Edge scrolling
|
||||
const MOVE_EDGE_X = window.innerWidth * 0.25;
|
||||
const MOVE_EDGE_Y = window.innerHeight * 0.25;
|
||||
const MAX_SPEED = 20;
|
||||
|
||||
if (mouse && !panning.value && document.fullscreenElement) {
|
||||
let horizontalShift: number;
|
||||
let verticalShift: number;
|
||||
|
||||
const transform = pz.getTransform();
|
||||
if (
|
||||
mouse.clientX < MOVE_EDGE_X ||
|
||||
mouse.clientX > window.innerWidth - MOVE_EDGE_X
|
||||
) {
|
||||
const horizontalEdgeDistance =
|
||||
mouse.clientX < window.innerWidth / 2
|
||||
? mouse.clientX
|
||||
: mouse.clientX - window.innerWidth;
|
||||
const horizontalRatio =
|
||||
(MOVE_EDGE_X - Math.abs(horizontalEdgeDistance)) / MOVE_EDGE_X;
|
||||
const direction = mouse.clientX < MOVE_EDGE_X ? 1 : -1;
|
||||
horizontalShift = horizontalRatio * direction * MAX_SPEED;
|
||||
} else {
|
||||
horizontalShift = 0;
|
||||
}
|
||||
|
||||
if (
|
||||
mouse.clientY < MOVE_EDGE_Y ||
|
||||
mouse.clientY > window.innerHeight - MOVE_EDGE_Y
|
||||
) {
|
||||
const verticalEdgeDistance =
|
||||
mouse.clientY < window.innerHeight / 2
|
||||
? mouse.clientY
|
||||
: mouse.clientY - window.innerHeight;
|
||||
const verticalRatio =
|
||||
(MOVE_EDGE_Y - Math.abs(verticalEdgeDistance)) / MOVE_EDGE_Y;
|
||||
const direction = mouse.clientY < MOVE_EDGE_Y ? 1 : -1;
|
||||
verticalShift = verticalRatio * direction * MAX_SPEED;
|
||||
} else {
|
||||
verticalShift = 0;
|
||||
}
|
||||
|
||||
if (horizontalShift || verticalShift) {
|
||||
pz.moveTo(
|
||||
transform!.x + horizontalShift,
|
||||
transform!.y + verticalShift
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.getGamepads) {
|
||||
var gamepads = navigator.getGamepads();
|
||||
var gp = gamepads[0];
|
||||
|
||||
if (gp) {
|
||||
if (gp.buttons[7].pressed) {
|
||||
gamePadZoomSpeed += 0.1;
|
||||
}
|
||||
if (gp.buttons[5].pressed) {
|
||||
gamePadZoomSpeed -= 0.1;
|
||||
}
|
||||
if (gamePadZoomSpeed < 1) {
|
||||
gamePadZoomSpeed = 1;
|
||||
}
|
||||
if (gamePadZoomSpeed > 30) {
|
||||
gamePadZoomSpeed = 30;
|
||||
}
|
||||
|
||||
const transform = pz.getTransform();
|
||||
|
||||
const horizontalShift = gp.axes[0] * -1 * gamePadZoomSpeed;
|
||||
const verticalShift = gp.axes[1] * -1 * gamePadZoomSpeed;
|
||||
|
||||
if (horizontalShift || verticalShift) {
|
||||
pz.moveTo(
|
||||
transform!.x + horizontalShift,
|
||||
transform!.y + verticalShift
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
stats.end();
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
});
|
||||
|
||||
return {
|
||||
root,
|
||||
loadedPercent,
|
||||
panzoom,
|
||||
anchors,
|
||||
panToAnchor,
|
||||
scrolls,
|
||||
audioAreas,
|
||||
bbox,
|
||||
mousePosition,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function processAnchors(document: XMLDocument): SVGRectElement[] {
|
||||
const result: SVGRectElement[] = [];
|
||||
Array.from(document.getElementsByTagName("rect"))
|
||||
.filter((el) => el.id.startsWith("anchor"))
|
||||
.forEach((anchor) => {
|
||||
console.debug(`[SVG/ANCHORS] Found anchor #${anchor.id}.`);
|
||||
anchor.classList.add("internal");
|
||||
result.push(anchor);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function processScrolls(svg: XMLDocument): Promise<VideoScrollDef[]> {
|
||||
const ratio = (svg as any).clientWidth / (svg as any).viewBox.baseVal.width;
|
||||
|
||||
return Promise.all(
|
||||
Array.from(svg.getElementsByTagName("image"))
|
||||
.filter((el) =>
|
||||
Array.from(el.children).some((el) => el.tagName == "desc")
|
||||
)
|
||||
.map(async (el) => {
|
||||
const descNode = Array.from(el.children).find(
|
||||
(el) => el.tagName == "desc"
|
||||
);
|
||||
console.debug(
|
||||
`[SVG/VIDEOSCROLLS] Found video scroll #${el.id}: ${descNode?.textContent}`
|
||||
);
|
||||
const [directionString, filesURL] = descNode!.textContent!.split("\n");
|
||||
|
||||
const directions: VideoScrollDirection[] = directionString
|
||||
.split(" ")
|
||||
.map((direction) => {
|
||||
if (
|
||||
!Object.values(VideoScrollDirection).includes(
|
||||
direction as VideoScrollDirection
|
||||
)
|
||||
) {
|
||||
console.error(
|
||||
`Unknown direction definition: "${direction}" (in #${el.id})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return direction as VideoScrollDirection;
|
||||
})
|
||||
.filter((d) => Boolean(d)) as VideoScrollDirection[];
|
||||
|
||||
console.debug(`[SVG/VIDEOSCROLLS] Fetching ${filesURL}...`);
|
||||
const fileFetch = await fetch(`content/${filesURL}`);
|
||||
const preURL = fileFetch.url.replace(/\/files.lst$/, "");
|
||||
const files = (await fileFetch.text())
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((str) => `${preURL}/${str}`);
|
||||
|
||||
let x = el.x.baseVal.value;
|
||||
let y = el.y.baseVal.value;
|
||||
let w = el.width.baseVal.value;
|
||||
let h = el.height.baseVal.value;
|
||||
let angle = 0;
|
||||
|
||||
const transform = el.attributes.getNamedItem("transform");
|
||||
const rotateResult = /rotate\((-?[0-9.]+)\)/.exec(
|
||||
transform?.value || ""
|
||||
);
|
||||
if (rotateResult) {
|
||||
angle = parseFloat(rotateResult[1]);
|
||||
const [ncx, ncy] = rotate(x + w / 2, y + h / 2, 0, 0, angle);
|
||||
x = ncx - w / 2;
|
||||
y = ncy - h / 2;
|
||||
}
|
||||
|
||||
return {
|
||||
id: el.id,
|
||||
top: y * ratio,
|
||||
left: x * ratio,
|
||||
angle,
|
||||
width: w * ratio,
|
||||
height: h * ratio,
|
||||
directions,
|
||||
files,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function processAudio(svg: XMLDocument): AudioAreaDef[] {
|
||||
const circles: (SVGCircleElement | SVGEllipseElement)[] = Array.from(
|
||||
svg.getElementsByTagName("circle")
|
||||
);
|
||||
const ellipses: (SVGCircleElement | SVGEllipseElement)[] = Array.from(
|
||||
svg.getElementsByTagName("ellipse")
|
||||
);
|
||||
return circles
|
||||
.concat(ellipses)
|
||||
.filter((el) => Array.from(el.children).some((el) => el.tagName == "desc"))
|
||||
.map((el) => {
|
||||
const descNode = Array.from(el.children).find(
|
||||
(el) => el.tagName == "desc"
|
||||
);
|
||||
console.debug(
|
||||
`[SVG/AUDIOAREAS] Found audio area #${el.id}: ${descNode?.textContent}`
|
||||
);
|
||||
const audioSrc = descNode!.textContent!.trim();
|
||||
|
||||
const radius = el.hasAttribute("r")
|
||||
? (el as SVGCircleElement).r.baseVal.value
|
||||
: ((el as SVGEllipseElement).rx.baseVal.value +
|
||||
(el as SVGEllipseElement).ry.baseVal.value) /
|
||||
2;
|
||||
|
||||
el.classList.add("internal");
|
||||
|
||||
return {
|
||||
id: el.id,
|
||||
cx: el.cx.baseVal.value,
|
||||
cy: el.cy.baseVal.value,
|
||||
radius,
|
||||
src: `content/${audioSrc}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function processHyperlinks(
|
||||
svg: XMLDocument
|
||||
): { anchor: [string, SVGAElement][]; hyper: SVGAElement[] } {
|
||||
const anchor: [string, SVGAElement][] = [];
|
||||
const hyper: SVGAElement[] = [];
|
||||
Array.from(svg.getElementsByTagName("a")).forEach((el) => {
|
||||
if (el.getAttribute("xlink:href")?.startsWith("anchor")) {
|
||||
anchor.push([
|
||||
el.getAttribute("xlink:href") as string,
|
||||
(el as unknown) as SVGAElement,
|
||||
]);
|
||||
el.setAttribute("xlink:href", "#");
|
||||
} else {
|
||||
el.setAttribute("target", "_blank");
|
||||
hyper.push((el as unknown) as SVGAElement);
|
||||
}
|
||||
});
|
||||
return { anchor, hyper };
|
||||
}
|
||||
|
||||
function processStart(svg: XMLDocument): SVGRectElement | null {
|
||||
const start = svg.getElementById("start");
|
||||
if (start) {
|
||||
start.classList.add("internal");
|
||||
}
|
||||
return start as SVGRectElement | null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style>
|
||||
.svg-content svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.svg-content svg .internal {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.loading-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: black;
|
||||
transition: opacity 0.5s;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.loading-screen.loaded {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
height: 6px;
|
||||
background: white;
|
||||
margin: calc(50vh - 3px) auto;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.dev {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.devpanel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
|
||||
color: white;
|
||||
background: #000000aa;
|
||||
border: 2px solid white;
|
||||
font-family: monospace;
|
||||
padding: 1em 2em;
|
||||
}
|
||||
|
||||
.devpanel div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.devpanel label {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.devpanel div span {
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
<template>
|
||||
<div class="video-scroll" ref="root" v-if="definition.directions.length > 0">
|
||||
<img
|
||||
class="visible displayed loaded"
|
||||
:src="definition.files[0]"
|
||||
:style="{
|
||||
top: `${Math.round(definition.top)}px`,
|
||||
left: `${Math.round(definition.left)}px`,
|
||||
width: isHorizontal ? `${Math.round(definition.width)}px` : 'auto',
|
||||
height: isVertical ? `${Math.round(definition.height)}px` : 'auto',
|
||||
transform: `rotate(${definition.angle}deg)`,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!--suppress RequiredAttributes -->
|
||||
<img
|
||||
v-for="(file, idx) in dynamicFiles"
|
||||
:key="`${idx}_${file.src}`"
|
||||
:data-src="file.src"
|
||||
:style="{
|
||||
top: `${Math.round(file.top)}px`,
|
||||
left: `${Math.round(file.left)}px`,
|
||||
width: `${Math.round(definition.width)}px`,
|
||||
height: `${Math.round(definition.height)}px`,
|
||||
transform: `rotate(${definition.angle}deg)`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
import { rotate } from "@/utils";
|
||||
import { queueImageForLoading } from "@/services/ImageLoader";
|
||||
|
||||
export default defineComponent({
|
||||
name: "VideoScroll",
|
||||
props: {
|
||||
definition: {
|
||||
type: Object as PropType<VideoScrollDef>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
dynamicFiles(): { top: number; left: number; src: string }[] {
|
||||
return this.definition.files.slice(1).map((src: string, idx: number) => {
|
||||
const cy =
|
||||
this.definition.top +
|
||||
(this.isVertical
|
||||
? this.definition.height * (idx + 1) * this.verticalDirection
|
||||
: 0);
|
||||
const cx =
|
||||
this.definition.left +
|
||||
(this.isHorizontal
|
||||
? this.definition.width * (idx + 1) * this.horizontalDirection
|
||||
: 0);
|
||||
const [left, top] = rotate(
|
||||
cx,
|
||||
cy,
|
||||
this.definition.left,
|
||||
this.definition.top,
|
||||
this.definition.angle
|
||||
);
|
||||
return { top, left, src };
|
||||
});
|
||||
},
|
||||
isHorizontal(): boolean {
|
||||
return this.definition.directions.some(
|
||||
(dir: VideoScrollDirection) =>
|
||||
dir === VideoScrollDirection.LEFT ||
|
||||
dir === VideoScrollDirection.RIGHT
|
||||
);
|
||||
},
|
||||
isVertical(): boolean {
|
||||
return this.definition.directions.some(
|
||||
(dir: VideoScrollDirection) =>
|
||||
dir === VideoScrollDirection.UP || dir === VideoScrollDirection.DOWN
|
||||
);
|
||||
},
|
||||
horizontalDirection(): number {
|
||||
return this.definition.directions.includes(VideoScrollDirection.RIGHT)
|
||||
? 1
|
||||
: -1;
|
||||
},
|
||||
verticalDirection(): number {
|
||||
return this.definition.directions.includes(VideoScrollDirection.DOWN)
|
||||
? 1
|
||||
: -1;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleImageLoad(element: HTMLImageElement) {
|
||||
// Setup image display when loaded
|
||||
element.classList.add("displayed");
|
||||
element.classList.add("loaded");
|
||||
|
||||
// Adjust dimensions based on scroll direction
|
||||
if (this.isHorizontal) {
|
||||
element.style.height = "auto";
|
||||
} else {
|
||||
element.style.width = "auto";
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const observer = new IntersectionObserver((entries, _) => {
|
||||
entries.forEach((entry) => {
|
||||
const element = entry.target as HTMLImageElement;
|
||||
if (entry.isIntersecting) {
|
||||
element.classList.add("visible");
|
||||
if (!element.src && element.dataset.src) {
|
||||
// Queue the image for loading through the global service
|
||||
const self = this;
|
||||
queueImageForLoading(element, function() {
|
||||
self.handleImageLoad(element);
|
||||
});
|
||||
|
||||
// Add a fallback to show the image after a timeout even if not fully loaded
|
||||
setTimeout(() => {
|
||||
if (!element.classList.contains("loaded")) {
|
||||
element.classList.add("displayed");
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
} else {
|
||||
element.classList.remove("visible");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (this.$refs.root) {
|
||||
Array.from((this.$refs.root as Element).children).forEach((el) => {
|
||||
observer.observe(el);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export enum VideoScrollDirection {
|
||||
RIGHT = "right",
|
||||
LEFT = "left",
|
||||
UP = "up",
|
||||
DOWN = "down",
|
||||
}
|
||||
|
||||
export interface VideoScrollDef {
|
||||
id: string;
|
||||
top: number;
|
||||
left: number;
|
||||
angle: number;
|
||||
width: number;
|
||||
height: number;
|
||||
directions: VideoScrollDirection[];
|
||||
files: string[];
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style>
|
||||
.video-scroll img {
|
||||
position: absolute;
|
||||
image-rendering: optimizeSpeed;
|
||||
background: grey;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
.video-scroll img.visible {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.video-scroll img.displayed {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.video-scroll img.loaded {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
7
src/lib.rs
Normal file
7
src/lib.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#![warn(clippy::all)]
|
||||
|
||||
mod app;
|
||||
pub mod svg;
|
||||
mod text_cache;
|
||||
|
||||
pub use app::LasApp;
|
||||
78
src/main.rs
Normal file
78
src/main.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#![warn(clippy::all, rust_2018_idioms)]
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
// When compiling natively:
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() -> eframe::Result {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||
.filter_level(LevelFilter::Info)
|
||||
.filter_module("las", LevelFilter::Debug)
|
||||
.init();
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([400.0, 300.0])
|
||||
.with_min_inner_size([300.0, 220.0])
|
||||
.with_icon(
|
||||
// NOTE: Adding an icon is optional
|
||||
eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon-256.png")[..])
|
||||
.expect("Failed to load icon"),
|
||||
),
|
||||
vsync: false, // Disable vsync to test if it affects input smoothness
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"Line and Surface",
|
||||
native_options,
|
||||
Box::new(|cc| Ok(Box::new(las::LasApp::new(cc)))),
|
||||
)
|
||||
}
|
||||
|
||||
// When compiling to web using trunk:
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
use eframe::wasm_bindgen::JsCast as _;
|
||||
|
||||
// Redirect `log` message to `console.log` and friends:
|
||||
eframe::WebLogger::init(log::LevelFilter::Debug).ok();
|
||||
|
||||
let web_options = eframe::WebOptions::default();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let document = web_sys::window()
|
||||
.expect("No window")
|
||||
.document()
|
||||
.expect("No document");
|
||||
|
||||
let canvas = document
|
||||
.get_element_by_id("the_canvas_id")
|
||||
.expect("Failed to find the_canvas_id")
|
||||
.dyn_into::<web_sys::HtmlCanvasElement>()
|
||||
.expect("the_canvas_id was not a HtmlCanvasElement");
|
||||
|
||||
let start_result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
web_options,
|
||||
Box::new(|cc| Ok(Box::new(las::LasApp::new(cc)))),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Remove the loading text and spinner:
|
||||
if let Some(loading_text) = document.get_element_by_id("loading_text") {
|
||||
match start_result {
|
||||
Ok(_) => {
|
||||
loading_text.remove();
|
||||
}
|
||||
Err(e) => {
|
||||
loading_text.set_inner_html(
|
||||
"<p> The app has crashed. See the developer console for details. </p>",
|
||||
);
|
||||
panic!("Failed to start eframe: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import store from './store'
|
||||
|
||||
createApp(App).use(store).mount('#app')
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/**
|
||||
* Global image loading queue service to prevent hitting browser connection limits
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
const MAX_CONCURRENT_LOADS = 5;
|
||||
|
||||
// State
|
||||
let activeLoads = 0;
|
||||
const imageQueue: Array<{
|
||||
element: HTMLImageElement;
|
||||
onComplete: () => void;
|
||||
}> = [];
|
||||
|
||||
/**
|
||||
* Queue an image for loading, respecting the global concurrent loading limit
|
||||
*/
|
||||
export function queueImageForLoading(
|
||||
element: HTMLImageElement,
|
||||
onComplete?: () => void
|
||||
) {
|
||||
if (!element.dataset.src) {
|
||||
console.warn("[ImageLoader] Element has no data-src attribute");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
imageQueue.push({
|
||||
element,
|
||||
onComplete: onComplete || (() => {}),
|
||||
});
|
||||
|
||||
// Try to process queue
|
||||
processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the next items in the queue if we have capacity
|
||||
*/
|
||||
function processQueue() {
|
||||
// Load more images if we have capacity and images in the queue
|
||||
while (activeLoads < MAX_CONCURRENT_LOADS && imageQueue.length > 0) {
|
||||
const next = imageQueue.shift();
|
||||
if (next) {
|
||||
loadImage(next.element, next.onComplete);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to handle the actual image loading
|
||||
*/
|
||||
function loadImage(element: HTMLImageElement, onComplete: () => void) {
|
||||
// Increment active loads counter
|
||||
activeLoads++;
|
||||
|
||||
const src = element.dataset.src;
|
||||
console.debug(`[ImageLoader] Loading ${src}`);
|
||||
|
||||
// Start loading the image
|
||||
element.src = src!;
|
||||
|
||||
// Handle load completion
|
||||
const handleCompletion = () => {
|
||||
activeLoads--;
|
||||
onComplete();
|
||||
processQueue();
|
||||
};
|
||||
|
||||
// Set handlers
|
||||
element.onload = () => {
|
||||
console.debug(`[ImageLoader] Loaded ${src}`);
|
||||
handleCompletion();
|
||||
};
|
||||
|
||||
element.onerror = () => {
|
||||
console.error(`[ImageLoader] Failed to load ${src}`);
|
||||
handleCompletion();
|
||||
};
|
||||
}
|
||||
6
src/shims-vue.d.ts
vendored
6
src/shims-vue.d.ts
vendored
|
|
@ -1,6 +0,0 @@
|
|||
/* eslint-disable */
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { createStore } from 'vuex'
|
||||
|
||||
export default createStore({
|
||||
state: {
|
||||
},
|
||||
mutations: {
|
||||
},
|
||||
actions: {
|
||||
},
|
||||
modules: {
|
||||
}
|
||||
})
|
||||
29
src/svg/color.rs
Normal file
29
src/svg/color.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
pub(super) fn parse_background_color(style: &str) -> Option<[u8; 3]> {
|
||||
for entry in style.split(';') {
|
||||
let entry = entry.trim();
|
||||
if let Some(value) = entry.strip_prefix("background-color:") {
|
||||
let value = value.trim();
|
||||
return parse_hex_color(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_hex_color(value: &str) -> Option<[u8; 3]> {
|
||||
let hex = value.strip_prefix('#')?.trim();
|
||||
match hex.len() {
|
||||
3 => {
|
||||
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
|
||||
Some([r, g, b])
|
||||
}
|
||||
6 => {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
Some([r, g, b])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
12
src/svg/mod.rs
Normal file
12
src/svg/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//! SVG parsing module for extracting special elements from SVG files.
|
||||
|
||||
mod color;
|
||||
mod parser;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use types::{
|
||||
Anchor, AnchorLink, AudioArea, Renderable, SvgContent, TextElement, TextLine, VideoScroll,
|
||||
};
|
||||
477
src/svg/parser.rs
Normal file
477
src/svg/parser.rs
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
use super::Renderable as _;
|
||||
use super::color::parse_background_color;
|
||||
use super::types::{Anchor, AnchorLink, AudioArea, SvgContent, TextElement, TextLine, VideoScroll};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use std::fs;
|
||||
|
||||
/// State for tracking current element during parsing.
|
||||
#[derive(Debug, Clone)]
|
||||
enum PendingElement {
|
||||
Image {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
Circle {
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
r: f32,
|
||||
},
|
||||
Ellipse {
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
rx: f32,
|
||||
ry: f32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LinkAccumulator {
|
||||
target_id: String,
|
||||
min_x: f32,
|
||||
max_x: f32,
|
||||
min_y: f32,
|
||||
max_y: f32,
|
||||
}
|
||||
|
||||
impl LinkAccumulator {
|
||||
fn new(target_id: String) -> Self {
|
||||
Self {
|
||||
target_id,
|
||||
min_x: f32::INFINITY,
|
||||
max_x: f32::NEG_INFINITY,
|
||||
min_y: f32::INFINITY,
|
||||
max_y: f32::NEG_INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_bounds(&mut self, x: f32, y: f32, width: f32, height: f32) {
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
self.min_x = self.min_x.min(x);
|
||||
self.min_y = self.min_y.min(y);
|
||||
self.max_x = self.max_x.max(x + width);
|
||||
self.max_y = self.max_y.max(y + height);
|
||||
}
|
||||
|
||||
fn finalize(&self) -> Option<(f32, f32, f32, f32)> {
|
||||
if self.min_x.is_finite()
|
||||
&& self.min_y.is_finite()
|
||||
&& self.max_x.is_finite()
|
||||
&& self.max_y.is_finite()
|
||||
&& self.max_x > self.min_x
|
||||
&& self.max_y > self.min_y
|
||||
{
|
||||
Some((
|
||||
self.min_x,
|
||||
self.min_y,
|
||||
self.max_x - self.min_x,
|
||||
self.max_y - self.min_y,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SvgContent {
|
||||
/// Parse an SVG file and extract special elements.
|
||||
pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
log::debug!("Reading SVG file: {path}");
|
||||
let content = fs::read_to_string(path)?;
|
||||
log::debug!("SVG file size: {} bytes", content.len());
|
||||
Self::parse(&content)
|
||||
}
|
||||
|
||||
/// Parse SVG content from a string.
|
||||
#[expect(clippy::too_many_lines)]
|
||||
pub fn parse(content: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
log::debug!("Parsing SVG content ({} bytes)...", content.len());
|
||||
let mut reader = Reader::from_str(content);
|
||||
reader.config_mut().trim_text(true);
|
||||
|
||||
let mut svg_content = Self::default();
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// Stack to track nested elements and their pending state
|
||||
let mut pending: Option<PendingElement> = None;
|
||||
let mut in_text = false;
|
||||
let mut text_font_size = 10.5833f32; // Default from SVG
|
||||
let mut text_lines: Vec<TextLine> = Vec::new();
|
||||
let mut in_tspan = false;
|
||||
let mut tspan_x = 0.0f32;
|
||||
let mut tspan_y = 0.0f32;
|
||||
let mut tspan_content = String::new();
|
||||
|
||||
// Track hyperlink bounds targeting anchors
|
||||
let mut current_link: Option<LinkAccumulator> = None;
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(ref e)) => {
|
||||
let name_bytes = e.name();
|
||||
let name = String::from_utf8_lossy(name_bytes.as_ref());
|
||||
match name.as_ref() {
|
||||
"a" => {
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
if key.as_ref().ends_with("href") {
|
||||
let cleaned = value.trim_start_matches('#');
|
||||
if cleaned.starts_with("anchor") {
|
||||
current_link =
|
||||
Some(LinkAccumulator::new(cleaned.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"svg" => {
|
||||
// Extract viewBox
|
||||
for attr in e.attributes().flatten() {
|
||||
if attr.key.as_ref() == b"viewBox" {
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
let parts: Vec<f32> = value
|
||||
.split_whitespace()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
if parts.len() == 4 {
|
||||
svg_content.viewbox =
|
||||
Some((parts[0], parts[1], parts[2], parts[3]));
|
||||
}
|
||||
}
|
||||
if attr.key.as_ref() == b"style" {
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
if svg_content.background_color.is_none() {
|
||||
svg_content.background_color =
|
||||
parse_background_color(&value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
let mut x = 0.0f32;
|
||||
let mut y = 0.0f32;
|
||||
let mut width = 0.0f32;
|
||||
let mut height = 0.0f32;
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
match key.as_ref() {
|
||||
"x" => x = value.parse().unwrap_or(0.0),
|
||||
"y" => y = value.parse().unwrap_or(0.0),
|
||||
"width" => width = value.parse().unwrap_or(0.0),
|
||||
"height" => height = value.parse().unwrap_or(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pending = Some(PendingElement::Image {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
if let Some(ref mut link) = current_link {
|
||||
link.update_bounds(x, y, width, height);
|
||||
}
|
||||
}
|
||||
"circle" => {
|
||||
let mut cx = 0.0f32;
|
||||
let mut cy = 0.0f32;
|
||||
let mut r = 0.0f32;
|
||||
let mut has_id_with_desc_potential = false;
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
match key.as_ref() {
|
||||
"cx" => cx = value.parse().unwrap_or(0.0),
|
||||
"cy" => cy = value.parse().unwrap_or(0.0),
|
||||
"r" => r = value.parse().unwrap_or(0.0),
|
||||
"id" => has_id_with_desc_potential = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _: bool = has_id_with_desc_potential; // May check desc child later
|
||||
pending = Some(PendingElement::Circle { cx, cy, r });
|
||||
|
||||
if let Some(ref mut link) = current_link {
|
||||
link.update_bounds(cx - r, cy - r, r * 2.0, r * 2.0);
|
||||
}
|
||||
}
|
||||
"ellipse" => {
|
||||
let mut cx = 0.0f32;
|
||||
let mut cy = 0.0f32;
|
||||
let mut rx = 0.0f32;
|
||||
let mut ry = 0.0f32;
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
match key.as_ref() {
|
||||
"cx" => cx = value.parse().unwrap_or(0.0),
|
||||
"cy" => cy = value.parse().unwrap_or(0.0),
|
||||
"rx" => rx = value.parse().unwrap_or(0.0),
|
||||
"ry" => ry = value.parse().unwrap_or(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pending = Some(PendingElement::Ellipse { cx, cy, rx, ry });
|
||||
|
||||
if let Some(ref mut link) = current_link {
|
||||
let width = rx * 2.0;
|
||||
let height = ry * 2.0;
|
||||
link.update_bounds(cx - rx, cy - ry, width, height);
|
||||
}
|
||||
}
|
||||
"rect" => {
|
||||
let mut id = String::new();
|
||||
let mut x = 0.0f32;
|
||||
let mut y = 0.0f32;
|
||||
let mut width = 0.0f32;
|
||||
let mut height = 0.0f32;
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
match key.as_ref() {
|
||||
"id" => id = value.to_string(),
|
||||
"x" => x = value.parse().unwrap_or(0.0),
|
||||
"y" => y = value.parse().unwrap_or(0.0),
|
||||
"width" => width = value.parse().unwrap_or(0.0),
|
||||
"height" => height = value.parse().unwrap_or(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if id.starts_with("anchor") {
|
||||
svg_content.anchors.push(Anchor {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
} else if id == "start" {
|
||||
svg_content.start_rect = Some((x, y, width, height));
|
||||
}
|
||||
|
||||
if let Some(ref mut link) = current_link {
|
||||
link.update_bounds(x, y, width, height);
|
||||
}
|
||||
}
|
||||
"text" => {
|
||||
in_text = true;
|
||||
text_lines.clear();
|
||||
text_font_size = 10.5833; // Reset to default
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
if key.as_ref() == "style" {
|
||||
// Parse font-size from style attribute
|
||||
if let Some(size_start) = value.find("font-size:") {
|
||||
let size_str = &value[size_start + 10..];
|
||||
if let Some(size_end) =
|
||||
size_str.find(|c: char| !c.is_numeric() && c != '.')
|
||||
{
|
||||
if let Ok(size) = size_str[..size_end].parse::<f32>() {
|
||||
text_font_size = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"tspan" if in_text => {
|
||||
in_tspan = true;
|
||||
tspan_content.clear();
|
||||
tspan_x = 0.0;
|
||||
tspan_y = 0.0;
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
match key.as_ref() {
|
||||
"x" => tspan_x = value.parse().unwrap_or(0.0),
|
||||
"y" => tspan_y = value.parse().unwrap_or(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// desc content will be captured in Text event
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Empty(ref e)) => {
|
||||
// Self-closing elements
|
||||
let name_bytes = e.name();
|
||||
let name = String::from_utf8_lossy(name_bytes.as_ref());
|
||||
if name == "rect" {
|
||||
let mut id = String::new();
|
||||
let mut x = 0.0f32;
|
||||
let mut y = 0.0f32;
|
||||
let mut width = 0.0f32;
|
||||
let mut height = 0.0f32;
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.as_ref());
|
||||
let value = String::from_utf8_lossy(&attr.value);
|
||||
match key.as_ref() {
|
||||
"id" => id = value.to_string(),
|
||||
"x" => x = value.parse().unwrap_or(0.0),
|
||||
"y" => y = value.parse().unwrap_or(0.0),
|
||||
"width" => width = value.parse().unwrap_or(0.0),
|
||||
"height" => height = value.parse().unwrap_or(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if id.starts_with("anchor") {
|
||||
svg_content.anchors.push(Anchor {
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
} else if id == "start" {
|
||||
svg_content.start_rect = Some((x, y, width, height));
|
||||
}
|
||||
|
||||
if let Some(ref mut link) = current_link {
|
||||
link.update_bounds(x, y, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(ref e)) => {
|
||||
let text = e.unescape().unwrap_or_default();
|
||||
|
||||
if in_tspan {
|
||||
tspan_content.push_str(&text);
|
||||
} else if let Some(ref p) = pending {
|
||||
// This is desc content
|
||||
let desc = text.trim().to_owned();
|
||||
if !desc.is_empty() {
|
||||
match p {
|
||||
PendingElement::Image {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
svg_content.video_scrolls.push(VideoScroll {
|
||||
x: *x,
|
||||
y: *y,
|
||||
width: *width,
|
||||
height: *height,
|
||||
desc,
|
||||
});
|
||||
}
|
||||
PendingElement::Circle { cx, cy, r } => {
|
||||
svg_content.audio_areas.push(AudioArea {
|
||||
cx: *cx,
|
||||
cy: *cy,
|
||||
radius: *r,
|
||||
desc,
|
||||
});
|
||||
}
|
||||
PendingElement::Ellipse { cx, cy, rx, ry } => {
|
||||
svg_content.audio_areas.push(AudioArea {
|
||||
cx: *cx,
|
||||
cy: *cy,
|
||||
radius: (*rx + *ry) / 2.0,
|
||||
desc,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(ref e)) => {
|
||||
let name_bytes = e.name();
|
||||
let name = String::from_utf8_lossy(name_bytes.as_ref());
|
||||
match name.as_ref() {
|
||||
"image" | "circle" | "ellipse" => {
|
||||
pending = None;
|
||||
}
|
||||
"a" => {
|
||||
if let Some(link) = current_link.take() {
|
||||
if let Some((x, y, width, height)) = link.finalize() {
|
||||
svg_content.anchor_links.push(AnchorLink {
|
||||
target_id: link.target_id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"tspan" => {
|
||||
if in_tspan {
|
||||
let content = tspan_content.trim().to_owned();
|
||||
if !content.is_empty() && tspan_y != 0.0 {
|
||||
text_lines.push(TextLine {
|
||||
x: tspan_x,
|
||||
y: tspan_y,
|
||||
content,
|
||||
});
|
||||
}
|
||||
in_tspan = false;
|
||||
tspan_content.clear();
|
||||
}
|
||||
}
|
||||
"text" => {
|
||||
if in_text {
|
||||
if !text_lines.is_empty() {
|
||||
svg_content.texts.push(TextElement {
|
||||
lines: text_lines.clone(),
|
||||
font_size: text_font_size,
|
||||
});
|
||||
|
||||
if let Some(ref mut link) = current_link {
|
||||
let text_elem = TextElement {
|
||||
lines: text_lines.clone(),
|
||||
font_size: text_font_size,
|
||||
};
|
||||
let (x, y, w, h) = text_elem.bounds();
|
||||
link.update_bounds(x, y, w, h);
|
||||
}
|
||||
}
|
||||
in_text = false;
|
||||
text_lines.clear();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Error parsing SVG at position {}: {:?}",
|
||||
reader.error_position(),
|
||||
e
|
||||
);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"SVG parsing complete: {} video scrolls, {} audio areas, {} anchors, {} texts",
|
||||
svg_content.video_scrolls.len(),
|
||||
svg_content.audio_areas.len(),
|
||||
svg_content.anchors.len(),
|
||||
svg_content.texts.len()
|
||||
);
|
||||
|
||||
Ok(svg_content)
|
||||
}
|
||||
}
|
||||
91
src/svg/tests.rs
Normal file
91
src/svg/tests.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
use super::{Renderable, SvgContent};
|
||||
|
||||
#[test]
|
||||
fn test_parse_video_scroll() {
|
||||
let svg = r#"
|
||||
<svg viewBox="0 0 1000 1000">
|
||||
<image x="100" y="200" width="300" height="400">
|
||||
<desc>horizontal video.mp4</desc>
|
||||
</image>
|
||||
</svg>
|
||||
"#;
|
||||
|
||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||
assert_eq!(content.video_scrolls.len(), 1);
|
||||
let vs = &content.video_scrolls[0];
|
||||
assert_eq!(vs.x, 100.0);
|
||||
assert_eq!(vs.y, 200.0);
|
||||
assert_eq!(vs.width, 300.0);
|
||||
assert_eq!(vs.height, 400.0);
|
||||
assert_eq!(vs.desc, "horizontal video.mp4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_audio_area_circle() {
|
||||
let svg = r#"
|
||||
<svg viewBox="0 0 1000 1000">
|
||||
<circle cx="500" cy="500" r="100">
|
||||
<desc>ambient.mp3</desc>
|
||||
</circle>
|
||||
</svg>
|
||||
"#;
|
||||
|
||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||
assert_eq!(content.audio_areas.len(), 1);
|
||||
let aa = &content.audio_areas[0];
|
||||
assert_eq!(aa.cx, 500.0);
|
||||
assert_eq!(aa.cy, 500.0);
|
||||
assert_eq!(aa.radius, 100.0);
|
||||
|
||||
let (x, y, w, h) = aa.bounds();
|
||||
assert_eq!(x, 400.0);
|
||||
assert_eq!(y, 400.0);
|
||||
assert_eq!(w, 200.0);
|
||||
assert_eq!(h, 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_anchor() {
|
||||
let svg = r#"
|
||||
<svg viewBox="0 0 1000 1000">
|
||||
<rect id="anchor-home" x="10" y="20" width="30" height="40" />
|
||||
</svg>
|
||||
"#;
|
||||
|
||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||
assert_eq!(content.anchors.len(), 1);
|
||||
let anchor = &content.anchors[0];
|
||||
assert_eq!(anchor.id, "anchor-home");
|
||||
assert_eq!(anchor.x, 10.0);
|
||||
assert_eq!(anchor.y, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_text() {
|
||||
let svg = r#"
|
||||
<svg viewBox="0 0 1000 1000">
|
||||
<text x="100" y="200" style="font-size:12px">
|
||||
<tspan x="100" y="200">Hello World</tspan>
|
||||
<tspan x="100" y="220">Second Line</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
"#;
|
||||
|
||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||
assert_eq!(content.texts.len(), 1);
|
||||
let text = &content.texts[0];
|
||||
assert_eq!(text.lines.len(), 2);
|
||||
assert_eq!(text.lines[0].x, 100.0);
|
||||
assert_eq!(text.lines[0].y, 200.0);
|
||||
assert_eq!(text.lines[0].content, "Hello World");
|
||||
assert_eq!(text.lines[1].content, "Second Line");
|
||||
assert_eq!(text.font_size, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewbox() {
|
||||
let svg = r#"<svg viewBox="0 0 1920 1080"></svg>"#;
|
||||
|
||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||
assert_eq!(content.viewbox, Some((0.0, 0.0, 1920.0, 1080.0)));
|
||||
}
|
||||
123
src/svg/types.rs
Normal file
123
src/svg/types.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/// Trait for elements that can be rendered with a bounding box.
|
||||
pub trait Renderable {
|
||||
/// Returns the bounding box: (x, y, width, height) in SVG coordinates.
|
||||
fn bounds(&self) -> (f32, f32, f32, f32);
|
||||
}
|
||||
|
||||
/// An `<image>` element with a `<desc>` child (video scroll area).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoScroll {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub desc: String,
|
||||
}
|
||||
|
||||
impl Renderable for VideoScroll {
|
||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
||||
(self.x, self.y, self.width, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `<circle>` or `<ellipse>` element with a `<desc>` child (audio area).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioArea {
|
||||
pub cx: f32,
|
||||
pub cy: f32,
|
||||
pub radius: f32,
|
||||
pub desc: String,
|
||||
}
|
||||
|
||||
impl Renderable for AudioArea {
|
||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
||||
(
|
||||
self.cx - self.radius,
|
||||
self.cy - self.radius,
|
||||
self.radius * 2.0,
|
||||
self.radius * 2.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `<rect>` element with id starting with "anchor".
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Anchor {
|
||||
pub id: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Renderable for Anchor {
|
||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
||||
(self.x, self.y, self.width, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// A single line of text (tspan).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextLine {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// A `<text>` element with multiple lines.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextElement {
|
||||
pub lines: Vec<TextLine>,
|
||||
pub font_size: f32, // Parsed from style attribute
|
||||
}
|
||||
|
||||
/// A hyperlink pointing to an anchor by id.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnchorLink {
|
||||
pub target_id: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Renderable for TextElement {
|
||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
||||
if self.lines.is_empty() {
|
||||
return (0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
let min_x = self.lines.iter().map(|l| l.x).fold(f32::INFINITY, f32::min);
|
||||
let min_y = self
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.y - self.font_size)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
|
||||
let max_x = self
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.x + l.content.len() as f32 * self.font_size * 0.6)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let max_y = self
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.y)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
|
||||
(min_x, min_y, max_x - min_x, max_y - min_y)
|
||||
}
|
||||
}
|
||||
|
||||
/// Container for all parsed SVG content.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SvgContent {
|
||||
pub video_scrolls: Vec<VideoScroll>,
|
||||
pub audio_areas: Vec<AudioArea>,
|
||||
pub anchors: Vec<Anchor>,
|
||||
pub anchor_links: Vec<AnchorLink>,
|
||||
pub texts: Vec<TextElement>,
|
||||
pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height)
|
||||
pub background_color: Option<[u8; 3]>,
|
||||
pub start_rect: Option<(f32, f32, f32, f32)>,
|
||||
}
|
||||
473
src/text_cache.rs
Normal file
473
src/text_cache.rs
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
//! Text rendering cache that pre-renders text to textures for smooth scaling.
|
||||
//!
|
||||
//! Text is rendered at multiple resolutions (mip levels) so zooming in can use
|
||||
//! high-resolution glyphs while zooming out benefits from GPU mipmapping for
|
||||
//! smooth minification.
|
||||
|
||||
use ab_glyph::{Font as _, FontRef, PxScale, ScaleFont as _};
|
||||
use egui::{Color32, ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode};
|
||||
use log::trace;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Available render scales (mip levels) for pre-rendering text.
|
||||
const RENDER_SCALES: [f32; 6] = [1.0, 4.0, 8.0, 16.0, 32.0, 64.0];
|
||||
|
||||
/// Texture filtering mode for text rendering.
|
||||
/// - `LINEAR`: Smooth scaling, slight blur when scaled (good for most cases)
|
||||
/// - `NEAREST`: Sharp/pixelated, no interpolation (good for pixel art style)
|
||||
const TEXTURE_OPTIONS: TextureOptions = TextureOptions {
|
||||
magnification: TextureFilter::Linear,
|
||||
minification: TextureFilter::Linear,
|
||||
wrap_mode: TextureWrapMode::ClampToEdge,
|
||||
mipmap_mode: Some(TextureFilter::Linear),
|
||||
};
|
||||
|
||||
/// Maximum texture dimension to prevent memory issues.
|
||||
const MAX_TEXTURE_DIM: u32 = 16_384;
|
||||
|
||||
/// Maximum pixel count (256M pixels ≈ 1 GiB for RGBA).
|
||||
const MAX_PIXEL_COUNT: usize = 256 * 1024 * 1024;
|
||||
|
||||
/// Default memory cap for the text cache (bytes).
|
||||
const DEFAULT_CACHE_BYTES: usize = 1_024 * 1_024 * 1_024; // 1 GiB
|
||||
|
||||
/// A cached rendered text texture.
|
||||
pub struct CachedText {
|
||||
/// The texture handle for the rendered text.
|
||||
pub texture: TextureHandle,
|
||||
/// Width of the texture in pixels (at render scale).
|
||||
pub width: u32,
|
||||
/// Height of the texture in pixels (at render scale).
|
||||
pub height: u32,
|
||||
/// Render scale that was used to generate the texture.
|
||||
pub render_scale: f32,
|
||||
}
|
||||
|
||||
/// Cache for rendered text textures.
|
||||
pub struct TextCache {
|
||||
/// The font used for rendering.
|
||||
font: FontRef<'static>,
|
||||
/// Cached text textures, keyed by (content, `size_key`, render scale).
|
||||
cache: HashMap<(String, u32, u32), CachedEntry>,
|
||||
max_bytes: usize,
|
||||
total_bytes: usize,
|
||||
usage_clock: u64,
|
||||
}
|
||||
|
||||
struct CachedEntry {
|
||||
text: CachedText,
|
||||
bytes: usize,
|
||||
last_used: u64,
|
||||
}
|
||||
|
||||
impl TextCache {
|
||||
/// Create a new text cache with the embedded Noto Sans font.
|
||||
///
|
||||
/// Text is rendered in white - apply color as a tint when drawing.
|
||||
pub fn new() -> Self {
|
||||
log::debug!("Initializing text cache...");
|
||||
let font_data: &'static [u8] = include_bytes!("../assets/NotoSans-Regular.ttf");
|
||||
let font = FontRef::try_from_slice(font_data).expect("embedded font should be valid");
|
||||
log::info!(
|
||||
"Text cache initialized (font: {} bytes, render scales: {:?}, mipmaps: {:?})",
|
||||
font_data.len(),
|
||||
RENDER_SCALES,
|
||||
TEXTURE_OPTIONS.mipmap_mode
|
||||
);
|
||||
|
||||
Self {
|
||||
font,
|
||||
cache: HashMap::new(),
|
||||
max_bytes: DEFAULT_CACHE_BYTES,
|
||||
total_bytes: 0,
|
||||
usage_clock: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of cached text textures.
|
||||
pub fn cache_size(&self) -> usize {
|
||||
self.cache.len()
|
||||
}
|
||||
|
||||
/// Returns approximate GPU memory used by cached text textures (bytes).
|
||||
pub fn cache_memory_bytes(&self) -> usize {
|
||||
self.total_bytes
|
||||
}
|
||||
|
||||
/// Get or create a cached texture for the given text.
|
||||
///
|
||||
/// The texture is rendered at the smallest available render scale that can
|
||||
/// cover the requested zoom. Higher zoom levels therefore pick a higher
|
||||
/// pre-render scale while lower zooms reuse the mip levels generated by the
|
||||
/// GPU.
|
||||
pub fn get_or_create(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
text: &str,
|
||||
nominal_font_size: f32,
|
||||
zoom: f32,
|
||||
) -> &CachedText {
|
||||
// Round font size to reduce cache entries (0.5px granularity)
|
||||
let size_key = (nominal_font_size * 2.0).round() as u32;
|
||||
let render_scale = Self::pick_render_scale(nominal_font_size, zoom, ctx.pixels_per_point());
|
||||
let render_scale_key = Self::render_scale_key(render_scale);
|
||||
let key = (text.to_owned(), size_key, render_scale_key);
|
||||
|
||||
if self.cache.contains_key(&key) {
|
||||
self.usage_clock = self.usage_clock.wrapping_add(1);
|
||||
if let Some(entry) = self.cache.get_mut(&key) {
|
||||
entry.last_used = self.usage_clock;
|
||||
}
|
||||
return self
|
||||
.cache
|
||||
.get(&key)
|
||||
.map(|entry| &entry.text)
|
||||
.expect("entry should exist");
|
||||
}
|
||||
|
||||
let mut cached = self.render_text(ctx, text, nominal_font_size, size_key, render_scale);
|
||||
let mut bytes = texture_bytes(&cached);
|
||||
|
||||
if bytes > self.max_bytes {
|
||||
log::warn!(
|
||||
"Text texture {} bytes exceeds cache cap {}; using empty texture",
|
||||
bytes,
|
||||
self.max_bytes
|
||||
);
|
||||
cached = Self::create_empty_texture(ctx, size_key, cached.render_scale);
|
||||
bytes = texture_bytes(&cached);
|
||||
}
|
||||
|
||||
// Evict least-recently-used entries until we have room.
|
||||
self.ensure_capacity(bytes);
|
||||
|
||||
self.usage_clock = self.usage_clock.wrapping_add(1);
|
||||
self.cache.insert(
|
||||
key.clone(),
|
||||
CachedEntry {
|
||||
text: cached,
|
||||
bytes,
|
||||
last_used: self.usage_clock,
|
||||
},
|
||||
);
|
||||
self.total_bytes = self.total_bytes.saturating_add(bytes);
|
||||
|
||||
self.cache
|
||||
.get(&key)
|
||||
.map(|entry| &entry.text)
|
||||
.expect("just inserted")
|
||||
}
|
||||
|
||||
fn pick_render_scale(nominal_font_size: f32, zoom: f32, pixels_per_point: f32) -> f32 {
|
||||
if nominal_font_size <= 0.0 {
|
||||
return RENDER_SCALES[0];
|
||||
}
|
||||
|
||||
let effective_zoom = (zoom * pixels_per_point).max(1.0);
|
||||
let max_scale_allowed = (MAX_TEXTURE_DIM as f32 / nominal_font_size).max(1.0);
|
||||
let max_level = RENDER_SCALES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&scale| scale <= max_scale_allowed)
|
||||
.last()
|
||||
.unwrap_or(RENDER_SCALES[0]);
|
||||
|
||||
RENDER_SCALES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&scale| scale >= effective_zoom && scale <= max_level)
|
||||
.unwrap_or(max_level)
|
||||
}
|
||||
|
||||
fn render_scale_key(scale: f32) -> u32 {
|
||||
scale.to_bits()
|
||||
}
|
||||
|
||||
/// Render text to a texture at high resolution.
|
||||
#[expect(clippy::too_many_lines)]
|
||||
fn render_text(
|
||||
&self,
|
||||
ctx: &egui::Context,
|
||||
text: &str,
|
||||
nominal_font_size: f32,
|
||||
size_key: u32,
|
||||
render_scale: f32,
|
||||
) -> CachedText {
|
||||
let start_time = std::time::Instant::now();
|
||||
trace!(
|
||||
"Rendering text '{}' at size {}px (scale {})...",
|
||||
&text[..text.len().min(20)],
|
||||
nominal_font_size,
|
||||
render_scale
|
||||
);
|
||||
|
||||
let mut warned_fallback = false;
|
||||
let mut first_oversize_dims: Option<(u32, u32)> = None;
|
||||
|
||||
let mut selected = None;
|
||||
for candidate_scale in std::iter::once(render_scale).chain(
|
||||
RENDER_SCALES
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.filter(|&s| s < render_scale),
|
||||
) {
|
||||
let render_size = nominal_font_size * candidate_scale;
|
||||
let scale = PxScale::from(render_size);
|
||||
let scaled_font = self.font.as_scaled(scale);
|
||||
|
||||
// Calculate text dimensions
|
||||
let height = scaled_font.height();
|
||||
let ascent = scaled_font.ascent();
|
||||
let width = Self::measure_text_width(text, &scaled_font);
|
||||
|
||||
// Early return for empty/invalid text
|
||||
if width <= 0.0 || height <= 0.0 || text.trim().is_empty() {
|
||||
return Self::create_empty_texture(ctx, size_key, candidate_scale);
|
||||
}
|
||||
|
||||
let padding = 2.0;
|
||||
let img_width = (width + padding * 2.0).ceil() as u32;
|
||||
let img_height = (height + padding * 2.0).ceil() as u32;
|
||||
|
||||
if img_width == 0 || img_height == 0 {
|
||||
return Self::create_empty_texture(ctx, size_key, candidate_scale);
|
||||
}
|
||||
|
||||
if img_width > MAX_TEXTURE_DIM || img_height > MAX_TEXTURE_DIM {
|
||||
if first_oversize_dims.is_none() {
|
||||
first_oversize_dims = Some((img_width, img_height));
|
||||
}
|
||||
warned_fallback = warned_fallback || candidate_scale == render_scale;
|
||||
continue;
|
||||
}
|
||||
|
||||
let pixel_count = img_width as usize * img_height as usize;
|
||||
if pixel_count > MAX_PIXEL_COUNT {
|
||||
log::warn!(
|
||||
"Text texture too large: {img_width}x{img_height} for '{}' at scale {} (pixel limit)",
|
||||
&text[..text.len().min(20)],
|
||||
candidate_scale
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
selected = Some((
|
||||
candidate_scale,
|
||||
scaled_font,
|
||||
scale,
|
||||
ascent,
|
||||
padding,
|
||||
img_width,
|
||||
img_height,
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
let Some((chosen_scale, scaled_font, scale, ascent, padding, img_width, img_height)) =
|
||||
selected
|
||||
else {
|
||||
if let Some((w, h)) = first_oversize_dims {
|
||||
log::error!(
|
||||
"Text texture exceeds MAX_TEXTURE_DIM ({MAX_TEXTURE_DIM}) at all scales; requested '{}' => {w}x{h}",
|
||||
&text[..text.len().min(20)]
|
||||
);
|
||||
} else {
|
||||
log::error!(
|
||||
"Unable to render text '{}' within texture limits; no suitable scale found",
|
||||
&text[..text.len().min(20)]
|
||||
);
|
||||
}
|
||||
return Self::create_empty_texture(ctx, size_key, render_scale);
|
||||
};
|
||||
|
||||
if warned_fallback {
|
||||
if let Some((w, h)) = first_oversize_dims {
|
||||
log::warn!(
|
||||
"Requested text scale {} for '{}' would create texture {}x{} (>{MAX_TEXTURE_DIM}); using scale {} instead",
|
||||
render_scale,
|
||||
&text[..text.len().min(20)],
|
||||
w,
|
||||
h,
|
||||
chosen_scale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render glyphs to pixel buffer
|
||||
let pixels = Self::render_glyphs(
|
||||
text,
|
||||
&scaled_font,
|
||||
scale,
|
||||
ascent,
|
||||
padding,
|
||||
img_width,
|
||||
img_height,
|
||||
);
|
||||
|
||||
// Create egui texture
|
||||
let image = ColorImage {
|
||||
size: [img_width as usize, img_height as usize],
|
||||
pixels,
|
||||
source_size: egui::Vec2::new(img_width as f32, img_height as f32),
|
||||
};
|
||||
|
||||
let texture = ctx.load_texture(
|
||||
format!(
|
||||
"text_{size_key}_{}_{}",
|
||||
text.len(),
|
||||
Self::render_scale_key(render_scale)
|
||||
),
|
||||
image,
|
||||
TEXTURE_OPTIONS,
|
||||
);
|
||||
|
||||
let duration = start_time.elapsed();
|
||||
trace!(
|
||||
"Rendered text '{}' ({}x{} @{}) in {:.2?}",
|
||||
&text[..text.len().min(20)],
|
||||
img_width,
|
||||
img_height,
|
||||
chosen_scale,
|
||||
duration
|
||||
);
|
||||
|
||||
CachedText {
|
||||
texture,
|
||||
width: img_width,
|
||||
height: img_height,
|
||||
render_scale: chosen_scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the width of text in pixels.
|
||||
fn measure_text_width(
|
||||
text: &str,
|
||||
scaled_font: &ab_glyph::PxScaleFont<&FontRef<'static>>,
|
||||
) -> f32 {
|
||||
let mut width = 0.0f32;
|
||||
let mut last_glyph_id = None;
|
||||
|
||||
for c in text.chars() {
|
||||
let glyph_id = scaled_font.glyph_id(c);
|
||||
|
||||
if let Some(last_id) = last_glyph_id {
|
||||
width += scaled_font.kern(last_id, glyph_id);
|
||||
}
|
||||
|
||||
width += scaled_font.h_advance(glyph_id);
|
||||
last_glyph_id = Some(glyph_id);
|
||||
}
|
||||
|
||||
width
|
||||
}
|
||||
|
||||
/// Render glyphs to a pixel buffer.
|
||||
fn render_glyphs(
|
||||
text: &str,
|
||||
scaled_font: &ab_glyph::PxScaleFont<&FontRef<'static>>,
|
||||
scale: PxScale,
|
||||
ascent: f32,
|
||||
padding: f32,
|
||||
img_width: u32,
|
||||
img_height: u32,
|
||||
) -> Vec<Color32> {
|
||||
let mut pixels = vec![Color32::TRANSPARENT; (img_width * img_height) as usize];
|
||||
let mut cursor_x = padding;
|
||||
let mut last_glyph_id = None;
|
||||
|
||||
for c in text.chars() {
|
||||
let glyph_id = scaled_font.glyph_id(c);
|
||||
|
||||
if let Some(last_id) = last_glyph_id {
|
||||
cursor_x += scaled_font.kern(last_id, glyph_id);
|
||||
}
|
||||
|
||||
if let Some(outlined) = scaled_font.outline_glyph(ab_glyph::Glyph {
|
||||
id: glyph_id,
|
||||
scale,
|
||||
position: ab_glyph::point(cursor_x, ascent + padding),
|
||||
}) {
|
||||
let bounds = outlined.px_bounds();
|
||||
|
||||
outlined.draw(|x, y, coverage| {
|
||||
let px = bounds.min.x as i32 + x as i32;
|
||||
let py = bounds.min.y as i32 + y as i32;
|
||||
|
||||
if px >= 0 && py >= 0 {
|
||||
let px = px as u32;
|
||||
let py = py as u32;
|
||||
|
||||
if px < img_width && py < img_height {
|
||||
let idx = (py * img_width + px) as usize;
|
||||
let alpha = (coverage * 255.0) as u8;
|
||||
let existing = pixels[idx];
|
||||
let new_alpha = alpha.saturating_add(existing.a());
|
||||
|
||||
// Render in white - color is applied as tint when drawing
|
||||
pixels[idx] = Color32::from_rgba_unmultiplied(255, 255, 255, new_alpha);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cursor_x += scaled_font.h_advance(glyph_id);
|
||||
last_glyph_id = Some(glyph_id);
|
||||
}
|
||||
|
||||
pixels
|
||||
}
|
||||
|
||||
/// Create a minimal 1x1 transparent texture for empty/invalid text.
|
||||
fn create_empty_texture(ctx: &egui::Context, size_key: u32, render_scale: f32) -> CachedText {
|
||||
let image = ColorImage {
|
||||
size: [1, 1],
|
||||
pixels: vec![Color32::TRANSPARENT],
|
||||
source_size: egui::Vec2::new(1.0, 1.0),
|
||||
};
|
||||
|
||||
let texture = ctx.load_texture(
|
||||
format!(
|
||||
"text_empty_{size_key}_{}",
|
||||
Self::render_scale_key(render_scale)
|
||||
),
|
||||
image,
|
||||
TEXTURE_OPTIONS,
|
||||
);
|
||||
|
||||
CachedText {
|
||||
texture,
|
||||
width: 1,
|
||||
height: 1,
|
||||
render_scale,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_capacity(&mut self, incoming_bytes: usize) {
|
||||
if incoming_bytes > self.max_bytes {
|
||||
log::warn!(
|
||||
"Incoming text texture ({incoming_bytes} bytes) exceeds cache cap ({}) - returning empty texture",
|
||||
self.max_bytes
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
while self.total_bytes.saturating_add(incoming_bytes) > self.max_bytes {
|
||||
if let Some((evict_key, evict_entry)) = self
|
||||
.cache
|
||||
.iter()
|
||||
.min_by_key(|(_, entry)| entry.last_used)
|
||||
.map(|(k, v)| (k.clone(), v.bytes))
|
||||
{
|
||||
self.cache.remove(&evict_key);
|
||||
self.total_bytes = self.total_bytes.saturating_sub(evict_entry);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn texture_bytes(cached: &CachedText) -> usize {
|
||||
cached.width as usize * cached.height as usize * 4
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export function rotate(x: number, y: number, cx: number, cy: number, angleDegrees: number): [number, number] {
|
||||
const angleRad = (Math.PI / 180) * angleDegrees * -1;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const nx = (cos * (x - cx)) + (sin * (y - cy)) + cx;
|
||||
const ny = (cos * (y - cy)) - (sin * (x - cx)) + cy;
|
||||
return [nx, ny];
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = {
|
||||
// publicPath: process.env.VUE_APP_BASE_URL || '/las/',
|
||||
devServer: {
|
||||
hot: false,
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue