Compare commits

...

118 Commits
master ... sdf

Author SHA1 Message Date
A.Olokhtonov 8a15093147 The images are in 19 hours ago
A.Olokhtonov 903660adbc Marginally better grid level transitions 1 day ago
Aleksey Olokhtonov 5aa29b18c0 Fix follow mode by separating canvas move and zoom events. Touch devices are TODO! 1 week ago
Aleksey Olokhtonov 01560389af Fix predrawn curves having zero width 1 week ago
Aleksey Olokhtonov 90f0beb4f9 Undo is back in! 1 week ago
Aleksey Olokhtonov e2ba3bb1c2 Make zoom animation use delta time 1 week ago
A.Olokhtonov bf2eace6fe Grid background pattern 1 week ago
A.Olokhtonov eafe860990 Zoom without the mousewheel by holding down Z 2 weeks ago
A.Olokhtonov eb66ffbcad Significantly improve stroke smoothing and pressure handling. "Fix" issue with underallocation of WASM memory by doubling the size 2 weeks ago
A.Olokhtonov 30559b0381 The dots are now zoomable 2 weeks ago
A.Olokhtonov c13e5a6848 Smooth zooming 2 weeks ago
A.Olokhtonov ee42e400c4 Dots pattern with fancy fadeout 2 weeks ago
A.Olokhtonov 807a1d0c03 More betterer picker icon. Hide dom brush when in picker mode 2 weeks ago
A.Olokhtonov 07bb47b6dc Dynamic cursor that shows size and color of the brush. Background pattern scaffolding 2 weeks ago
A.Olokhtonov 5552dc11a3 Make cursor reflect the size of the brush. Doesn't work when the brush size is > max allowed cursor size though 4 weeks ago
A.Olokhtonov e4a10ebf30 Make color picker actually change the active color 1 month ago
A.Olokhtonov 68c892ba5f Color picker: picks colors! They don't get used though 1 month ago
A.Olokhtonov 48e9dd9a3e Remove stupid try catch from webgl_draw 1 month ago
A.Olokhtonov 221e2a5aed Fix bvh fullnodes returning full buffer instead of tv_data. Skip handling of empty strokes 1 month ago
A.Olokhtonov c3f66d966c Determinitstic zooming 1 month ago
A.Olokhtonov acdbf73a26 Add new strokes to fullnodes. Strokes appear one too late though 2 months ago
A.Olokhtonov 6d9791d3e0 Cache full stroke lists in BVH levels (massive speedup!). Remove "line_threshold" array, since it has very little effect. 2 months ago
A.Olokhtonov f6573e7bb9 Correcty complute total point count. Only call glClear once workers have finished LOD. Only allow next draw() call after we finished rendering frame 2 months ago
A.Olokhtonov c5ad40f739 Update README 2 months ago
A.Olokhtonov 4f84509b16 Multithreading works! Kinda sorta (slows down in chrome, out of bounds accesses on phone) 2 months ago
A.Olokhtonov 2c01d315d6 A small test WASM module to demonstrate how to use shared memory and run multiple threads in WASM without Emscripten 2 months ago
A.Olokhtonov e949860279 Change extracts of pressure byutes to a f32x4_make. Change andnot masking to a bitselect. Rename readme.md to readme.txt to hopefully fix gitea formatting 2 months ago
A.Olokhtonov 37ff1e55bc Oops, actually enable SIMD 2 months ago
A.Olokhtonov 84a5859541 SIMD version of rdp_find_max. 16->12ms cpu frametime firefox, 16->9ms chrome 2 months ago
A.Olokhtonov 6f78c0ae21 AoS -> SoA for point coordinates 2 months ago
A.Olokhtonov 58f2a60295 Fix wrong wasm allocation, remove unused js LOD code, take radius of stroke into account when doing LOD, reduce EPS for LOD 2 months ago
A.Olokhtonov 40dd278f21 Improved shader (thanks BYP!) 4 months ago
A.Olokhtonov 6f19e6c954 Uneven capsules (and a very naive shader) to draw getter variable-width lines 4 months ago
aolo2 dfecad2c99 Fix strokes being added to storage even when skipped by SN logic 4 months ago
aolo2 704c833c16 First working draft of pressure hanlding 4 months ago
aolo2 71f1016a40 Store WASM-processed data in WASM memory 4 months ago
A.Olokhtonov 8abe88373f Skip copying out segments and segments_from. Use wasm_points and wasm_ids as is 4 months ago
A.Olokhtonov 3bf812df39 Move point writes to WASM, already a lot faster!!!! 4 months ago
A.Olokhtonov c9d1706b6e Fix final (???) WASM issue 4 months ago
A.Olokhtonov 1960bdebe9 LOD generation has been wassembled! (a little borked for now though) 4 months ago
A.Olokhtonov cf11f6c701 Successfull sum function in wasm :D 4 months ago
A.Olokhtonov 28fec7f306 Redraw HTML on canvas move, first draft of wasm LOD core 4 months ago
A.Olokhtonov 4b5b8db053 Update README 4 months ago
A.Olokhtonov ab7545918b Fix state.me not being set when joining new desk 4 months ago
A.Olokhtonov 7555de10bf Long touch to open color picker details 4 months ago
A.Olokhtonov 08ba8a7687 Fix GC pauses! 4 months ago
A.Olokhtonov cc800c1ffe Fancy-shmancy Open Color (TM) color pallete 4 months ago
A.Olokhtonov 8bbcfb616f JOIN event, send online info for users in init message 4 months ago
A.Olokhtonov 99bd99a465 Follow and unfollow players like in Figma 4 months ago
A.Olokhtonov ee1d1471f4 Pretty(er) player cursor: bright colors and an actual icon 4 months ago
A.Olokhtonov d8a5cd3fca Draw player pointers as colored squares with html 4 months ago
A.Olokhtonov 1f983f3389 Fix multiplayer, add mouse wheel panning 4 months ago
A.Olokhtonov c893a73ec5 You can draw once again! 4 months ago
A.Olokhtonov 847fb70381 Don't repeat points segment points. Introduce "tv" (typedvector) 4 months ago
A.Olokhtonov 9bbab32369 Try to speed up the LOD calculation with a cache (kinda succesfull, but not really) 5 months ago
A.Olokhtonov a60c3d1948 Recompute LODs and instance data on demand - ??? - it works??? 5 months ago
A.Olokhtonov 2d88b57dca Fix off-by-factor-of-2 in the vertex shader 5 months ago
A.Olokhtonov 248501e50f Remove recursive allocations from process_rdp 5 months ago
A.Olokhtonov 31a0b0113a Prototype: separate per-quad data using instanced rendeding and per-stroke data using a texture 5 months ago
A.Olokhtonov 1438b0ad73 LODs work! Need to fix that memory usage though 5 months ago
A.Olokhtonov 60fc48b1e7 Fix dynamic draw to use indexed, and thus the same shader as main 6 months ago
A.Olokhtonov fdca3e6b07 Fix epsilon calculation for RDP 6 months ago
A.Olokhtonov 7b53c7215e Fix predraw artifacts by making the lines too fat at far zoomout 6 months ago
A.Olokhtonov 72eedf9b48 Fix dynamic data 6 months ago
A.Olokhtonov 24222f9565 Fix wrong sort order for indices 6 months ago
A.Olokhtonov cca3b4cd5d Fix the depth prepass artefacts 6 months ago
A.Olokhtonov a991cf3247 BVH construction and modification 6 months ago
aolo2 1691bd07ae Milton blackboard import 6 months ago
A.Olokhtonov 6be8ba7823 Add readme 6 months ago
A.Olokhtonov 7752e41bf5 Separate CPU and GPU timings in debug window 6 months ago
A.Olokhtonov 1bc6f2c3fe No clipping at zoom < 0.3. Really fast 6 months ago
A.Olokhtonov 316d0fedc9 Use correct rendering order.. 6 months ago
A.Olokhtonov e86ffd6508 Depth prepass with simplified opaque shapes 6 months ago
A.Olokhtonov 7e3b6156c0 Render stroke_id to a small texture 6 months ago
A.Olokhtonov 0d8667aa3a Use TypedArray for culling. This is up to 10 times faster! 7 months ago
A.Olokhtonov e41997563f Faster clipping 7 months ago
A.Olokhtonov 46cd48fecd Cull segments, switch to indexed drawing (only for static data for now) 7 months ago
A.Olokhtonov be50af68d4 Delete garbage file 9 months ago
A.Olokhtonov 72deea0b03 Added LICENSE (unlicense) 9 months ago
A.Olokhtonov 9a8854dc90 Handle touchstart with 2 e.changedTouches. This fixes panning sometimes not working on iPhone 9 months ago
A.Olokhtonov 1803dedee2 Black-on-white cursor for ultimate readability 9 months ago
A.Olokhtonov 14faef4146 Better draw->move cancellation. Instead of not registering short strokes, cancel them for all users if we registered a second finger during the time window. 9 months ago
A.Olokhtonov c5928dd5bd Do not upload whole static buffer, instead use glBufferSubData. This commit fixes lag (and skipped iinputs) when other people are drawing 9 months ago
A.Olokhtonov eefbb8ef75 Do not upload buffers each frame (DUH!), re-introduce dynamic data, set headers in caddy to enable precise timings 9 months ago
A.Olokhtonov 605500028a Bump version 9 months ago
A.Olokhtonov c1fd46da53 We have achieved good AA! 9 months ago
A.Olokhtonov 01db70cab0 Good speed (quad per line segment). Need to figure out rounding 9 months ago
A.Olokhtonov e3ae4cf6cd Upload whole point buffer, and align its size to 8K 9 months ago
A.Olokhtonov 46587068e6 Limit max points per quad, fix wrong rounding of texture size 9 months ago
A.Olokhtonov f72cbf5941 SDF bull fixed boxes + passed stroke width to shader 9 months ago
A.Olokhtonov 69feb482a2 sdf bullshit (almost working) 10 months ago
A.Olokhtonov ad9cded350 Sweet juicy svg cursor 1 year ago
A.Olokhtonov 6f446b7d13 Fwidth in the shader for "more gooder" AA 1 year ago
A.Olokhtonov 8b3f28337e SDF town 1 year ago
A.Olokhtonov 8557c5d47e Caps and joints via fragment shader 1 year ago
A.Olokhtonov 0271e38dbc Fix wrong buffered points 1 year ago
A.Olokhtonov 31b18e69a0 Images moving around, paste image from clipboard 1 year ago
A.Olokhtonov 3b8232e196 Image broadcast 1 year ago
A.Olokhtonov 1edcc6725b Moar better color/width sync 1 year ago
A.Olokhtonov ac0d8f7605 Betta websocket reconnect 1 year ago
A.Olokhtonov 33898ab27a Client code cleanup p1 1 year ago
A.Olokhtonov dec07b4edc Remove users from backend, leave only sessions. Simplify storage 1 year ago
A.Olokhtonov cb783db614 Per-user stroke width and color (for dynamic strokes) kinda work 1 year ago
A.Olokhtonov 165d9235ce Wrote what to do NEXT: 1 year ago
A.Olokhtonov 7c2ba5ff72 Do not schedule RAF if it already is scheduled (somewhat fixes event spam from chrome!) 1 year ago
A.Olokhtonov 110afe123f Brush preview 1 year ago
A.Olokhtonov 45c3af9c67 Brush width control. Phone "zen mode" button 1 year ago
A.Olokhtonov bf273a9424 Fix HiDPI support on non-touch devices 1 year ago
A.Olokhtonov 59cb197e58 I don't even know anymore (colors?) 1 year ago
A.Olokhtonov 343008c0af f32 coordinates 1 year ago
A.Olokhtonov f24e8d386b Pictures good 1 year ago
A.Olokhtonov 29f697dceb Texture test 1 year ago
A.Olokhtonov 04c11e23f3 Nice touch! 1 year ago
A.Olokhtonov 5c0d9e1537 The gooder biba 1 year ago
A.Olokhtonov 5593536485 Working rounded lines! 1 year ago
A.Olokhtonov ab152b2d0a Investigating rounded corners (buggy!) 1 year ago
A.Olokhtonov e056d6c698 Round caps! 1 year ago
A.Olokhtonov 7011cc86be Some kind of shitty webgl line renderer 1 year ago
  1. 6
      .gitignore
  2. 7
      Caddyfile
  3. 24
      LICENSE
  4. 76
      README.txt
  5. 240
      client/aux.js
  6. 329
      client/bvh.js
  7. 583
      client/client_recv.js
  8. 398
      client/client_send.js
  9. 20
      client/cursor.js
  10. 409
      client/default.css
  11. 66
      client/draw.js
  12. BIN
      client/favicon2.png
  13. BIN
      client/favicon_old.png
  14. 130
      client/heapify.js
  15. 8
      client/icons/cheeseburga.svg
  16. 281
      client/icons/crosshair.svg
  17. 26
      client/icons/cursor.svg
  18. 54
      client/icons/draw.svg
  19. 79
      client/icons/erase.svg
  20. 54
      client/icons/favicon.svg
  21. 7
      client/icons/pen.svg
  22. 2
      client/icons/perfect-bullet.svg
  23. 105
      client/icons/picker.svg
  24. 54
      client/icons/player-cursor.svg
  25. 57
      client/icons/player.svg
  26. 54
      client/icons/redo.svg
  27. 53
      client/icons/ruler.svg
  28. 59
      client/icons/undo.svg
  29. 275
      client/index.html
  30. 517
      client/index.js
  31. 36
      client/index.log
  32. 47
      client/lod_worker.js
  33. 452
      client/math.js
  34. 345
      client/recv.js
  35. 201
      client/send.js
  36. 231
      client/speed.js
  37. 163
      client/tools.js
  38. 1
      client/wasm/compile_command
  39. 351
      client/wasm/lod.c
  40. BIN
      client/wasm/lod.wasm
  41. 422
      client/webgl_draw.js
  42. 377
      client/webgl_geometry.js
  43. 657
      client/webgl_listeners.js
  44. 556
      client/webgl_shaders.js
  45. 58
      client/websocket.js
  46. 4
      server/config.js
  47. BIN
      server/data-local.sqlite
  48. 84
      server/deserializer.js
  49. 16
      server/enums.js
  50. 12
      server/http.js
  51. 6
      server/math.js
  52. 70
      server/milton.js
  53. 154
      server/recv.js
  54. 146
      server/send.js
  55. 68
      server/serializer.js
  56. 16
      server/server.js
  57. 179
      server/storage.js

6
.gitignore vendored

@ -1,3 +1,9 @@ @@ -1,3 +1,9 @@
server/images
server/server.log
doca.txt
data/
client/*.dot
server/points.txt
server/scripts.js
*.o
*.out

7
Caddyfile

@ -1,4 +1,9 @@ @@ -1,4 +1,9 @@
desk.local {
header {
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Embedder-Policy require-corp
}
redir /ws /ws/
redir /desk /desk/
@ -16,7 +21,7 @@ desk.local { @@ -16,7 +21,7 @@ desk.local {
}
handle_path /desk/* {
root * /code/desk2/client
root * /code/desk2/client
try_files {path} /index.html
file_server
}

24
LICENSE

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org>

76
README.txt

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
Release:
* Engine
+ Benchmark harness
+ Reuse points, pack "nodraw" in high bit of stroke id (probably have at least one more bit, so up to 4 flag configurations)
+ Draw dynamic data (strokes in progress)
+ Webassembly for core LOD generation
+ Webassembly for final buffers
+ Do not copy memory to wasm, instead use wasm memory to store data in the first place
+ SIMD for LOD?
+ Multithreading for LOD
+ Textured quads (pictures, code already written in older version)
- Z-prepass fringe bug (also, when do we enable the prepass?)
- Resize and move pictures (draw handles)
+ Bugs
+ GC stalls!!!
+ Stroke previews get connected when drawn without panning on touch devices
+ Redraw HTML (cursors) on local canvas moves
+ New strokes dissapear on the HMH desk
- Color picker misses on strange line endings
- Debug
- Restore ability to limit event range
* Listeners/events/multiplayer
+ Fix multiplayer LUL
+ Fix blinking own stroke inbetween SYN->server and SYN->client
+ Drag with mouse button 3
+ Investigate skipped inputs on mobile (panning, zooming) [Events were not actually getting skipped. The stroke previews were just not being drawn]
+ Smooth zoom
+ Infinite background pattern
- Be able to have multiple "current" strokes per player. In case of bad internet this can happen!
- Do NOT use session id as player id LUL
- Save events to indexeddb (as some kind of a blob), restore on reconnect and page reload
- Local prediction for tools!
* Missing features I do not consider bonus
+ Player pointers
+ Pretty player pointers
+ Player list
+ Follow player
+ Color picker (or at the very least an Open Color color pallete)
+ EYE DROPPER!
+ Dynamic svg cursor to represent the brush
- Eraser
- Line drawing
+ Undo
- Undo for images
- Redo
* Polish
+ Use typedvector where appropriate
- Show what's happening while the desk is loading (downloading, processing, uploading to gpu)
- Settings panel for config values (including the setting for "offline mode")
- Set up VAOs
- We are calling "geometry_prepare_stroke" twice for some reason
- Presentation / "marketing"
- Title (InfiNotes?)
- Icon
- Product page (github readme, demo videos)
Bonus:
* Handle pressure
+ Add pressure data to quads
+ Account for pressure in quad/bbox calc
+ Adjust curve simplification to include pressure info
+ Migrate old non-pressure desks
- Check out e.pressure on touch devices
- Send pressure in PREDRAW event
- Curve modification
- Select curves (with a lasso?)
- Move whole curve
- Move single point
- Move multiple points
* Customizable background
+ Dots pattern
+ Grid pattern
- Menu option
Bonus-bonus:
- Actually infinite canvas (replace floats with something, some kind of fixed point scheme? chunks? multilevel scheme?)

240
client/aux.js

@ -0,0 +1,240 @@ @@ -0,0 +1,240 @@
function ui_offline() {
document.body.classList.add('offline');
document.querySelector('.offline-toast').classList.remove('hidden');
}
function ui_online() {
document.body.classList.remove('offline');
document.querySelector('.offline-toast').classList.add('hidden');
}
async function insert_image(state, context, file) {
const bitmap = await createImageBitmap(file);
const p = { 'x': state.cursor.x, 'y': state.cursor.y };
const canvasp = screen_to_canvas(state, p);
canvasp.x -= bitmap.width / 2;
canvasp.y -= bitmap.height / 2;
const form_data = new FormData();
form_data.append('file', file);
const resp = await fetch(`/api/image?deskId=${state.desk_id}`, {
method: 'post',
body: form_data,
})
if (resp.ok) {
const image_id = await resp.text();
const event = image_event(image_id, canvasp.x, canvasp.y);
await queue_event(state, event);
}
}
function event_size(event) {
let size = 4; // type
switch (event.type) {
case EVENT.PREDRAW:
case EVENT.MOVE_CURSOR: {
size += 4 * 2;
break;
}
case EVENT.MOVE_CANVAS: {
size += 4 * 2 + 4;
break;
}
case EVENT.ZOOM_CANVAS: {
size += 4 + 4 * 2;
break;
}
case EVENT.USER_JOINED:
case EVENT.LEAVE:
case EVENT.CLEAR: {
break;
}
case EVENT.SET_COLOR: {
size += 4;
break;
}
case EVENT.SET_WIDTH: {
size += 2;
break;
}
case EVENT.STROKE: {
// u32 stroke id + u16 (count) + u16 (width) + u32 (color) + count * (f32, f32) points + count (u8) pressures
size += 4 + 2 + 2 + 4 + event.points.length * 4 * 2 + round_to_pow2(event.points.length, 4);
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
size += 4 + 4 + 4; // file id + x + y
break;
}
case EVENT.ERASER: {
size += 4; // stroke id
break;
}
default: {
console.error('fuck');
}
}
return size;
}
function find_touch(touchlist, id) {
for (const touch of touchlist) {
if (touch.identifier === id) {
return touch;
}
}
return null;
}
function find_image(state, image_id) {
for (let i = state.events.length - 1; i >= 0; --i) {
const event = state.events[i];
if (event.type === EVENT.IMAGE && !event.deleted && event.image_id === image_id) {
return event;
}
}
}
// TODO: move these to a file? TypedVector
function tv_create(class_name, capacity) {
return {
'class_name': class_name,
'data': new class_name(capacity),
'capacity': capacity,
'size': 0,
};
}
function tv_create_on(class_name, capacity, buffer, offset) {
return {
'class_name': class_name,
'data': new class_name(buffer, offset, capacity),
'capacity': capacity,
'size': 0,
};
}
function tv_data(tv) {
return tv.data.subarray(0, tv.size);
}
function tv_bytes(tv) {
return new Uint8Array(tv.data.buffer, 0, tv.size * tv.data.BYTES_PER_ELEMENT);
}
function tv_ensure(tv, capacity) {
if (tv.capacity < capacity) {
const new_data = new tv.class_name(capacity);
new_data.set(tv_data(tv));
tv.capacity = capacity;
tv.data = new_data;
}
}
function tv_ensure_by(tv, by) {
tv_ensure(tv, round_to_pow2(tv.size + by, 4096));
}
function tv_add(tv, item) {
tv.data[tv.size++] = item;
}
function tv_add2(tv, item) {
tv_ensure_by(tv, 1);
tv_add(tv, item);
}
function tv_pop(tv) {
const result = tv.data[tv.size - 1];
tv.size--;
return result;
}
function tv_append(tv, typedarray) {
tv.data.set(typedarray, tv.size);
tv.size += typedarray.length;
}
function tv_clear(tv) {
tv.size = 0;
}
function HTML(html) {
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstChild;
}
function toggle_follow_player(state, player_id) {
document.querySelectorAll('.player-list .player').forEach(p => p.classList.remove('following'));
if (state.following_player === null) {
state.following_player = player_id;
} else {
if (player_id === state.following_player) {
state.following_player = null;
} else {
state.following_player = player_id;
}
}
const player_element = document.querySelector(`.player-list .player[data-player-id="${state.following_player}"]`);
if (player_element) player_element.classList.add('following');
send_follow(state.following_player);
}
function insert_player_cursor(state, player_id) {
const color = random_bright_color_from_seed(parseInt(player_id));
const path_copy = state.cursor_path.cloneNode();
path_copy.style.fill = color;
const cursor = HTML(`<svg viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" class="player-cursor" data-player-id="${player_id}">${path_copy.outerHTML}</svg>`);
const player = HTML(`<div class="player" data-player-id="${player_id}"><img src="icons/player.svg"></div>`);
player.style.background = color;
player.addEventListener('click', () => {
toggle_follow_player(state, player_id);
});
document.querySelector('.html-hud').appendChild(cursor);
document.querySelector('.player-list').appendChild(player);
document.querySelector('.player-list').classList.remove('vhide');
return cursor;
}
async function load_player_cursor_template(state) {
const resp = await fetch('icons/player-cursor.svg');
const text = await resp.text();
const parser = new DOMParser();
const parsed_xml = parser.parseFromString(text, 'image/svg+xml');
const path = parsed_xml.querySelector('path');
state.cursor_path = path;
}

329
client/bvh.js

@ -0,0 +1,329 @@ @@ -0,0 +1,329 @@
function bvh_make_leaf(bvh, index, stroke) {
const leaf = {
'stroke_index': index,
'bbox': stroke.bbox,
'area': stroke.area,
'parent_index': null,
'is_leaf': true,
};
bvh.nodes.push(leaf);
return bvh.nodes.length - 1;
}
function bvh_make_internal(bvh) {
const node = {
'child1': null,
'child2': null,
'parent_index': null,
'is_leaf': false,
};
bvh.nodes.push(node);
return bvh.nodes.length - 1;
}
function bvh_compute_sah(bvh, new_leaf, potential_sibling, only_parent = false) {
let cost = 0;
let union_box;
if (!only_parent) {
union_box = quad_union(new_leaf.bbox, potential_sibling.bbox);
const internal_node_would_be = { 'bbox': union_box };
const new_internal_node_cost = (union_box.x2 - union_box.x1) * (union_box.y2 - union_box.y1);
cost += new_internal_node_cost;
} else {
union_box = new_leaf.bbox;
}
let parent_index = potential_sibling.parent_index;
while (parent_index !== null) {
const current_node = bvh.nodes[parent_index];
const old_cost = current_node.area;
union_box = quad_union(current_node.bbox, union_box);
const new_cost = (union_box.x2 - union_box.x1) * (union_box.y2 - union_box.y1);
cost += new_cost - old_cost;
parent_index = current_node.parent_index;
}
return cost;
}
function bvh_find_best_sibling(bvh, leaf_index) {
// branch and bound
const leaf = bvh.nodes[leaf_index];
const leaf_cost = (leaf.bbox.x2 - leaf.bbox.x1) * (leaf.bbox.y2 - leaf.bbox.y1);
let best_cost = bvh_compute_sah(bvh, leaf, bvh.nodes[bvh.root]);
let best_index = bvh.root;
bvh.pqueue.clear();
bvh.pqueue.push(best_index, best_cost);
while (bvh.pqueue.size > 0) {
const current_index = bvh.pqueue.pop();
const current_node = bvh.nodes[current_index];
const cost = bvh_compute_sah(bvh, current_node, leaf);
if (cost < best_cost) {
best_cost = cost;
best_index = current_index;
}
if (!current_node.is_leaf) {
const child1 = bvh.nodes[current_node.child1];
const lower_bound_for_children = bvh_compute_sah(bvh, child1, leaf, true) + leaf_cost;
if (lower_bound_for_children < best_cost) {
bvh.pqueue.push(current_node.child1, lower_bound_for_children);
bvh.pqueue.push(current_node.child2, lower_bound_for_children);
}
}
}
return best_index;
}
function bvh_add_stroke(state, bvh, index, stroke) {
const leaf_index = bvh_make_leaf(bvh, index, stroke);
stroke.bvh_node = leaf_index;
if (bvh.nodes.length === 1) {
bvh.root = leaf_index;
return;
}
if (bvh.pqueue.capacity < Math.ceil(bvh.nodes.length * 1.2)) {
bvh.pqueue = new MinQueue(bvh.nodes.length * 2);
}
// It's as easy as 1-2-3
// 1. Find best sibling for leaf
const sibling = bvh_find_best_sibling(bvh, leaf_index);
// 2. Create new parent
const old_parent = bvh.nodes[sibling].parent_index;
const new_parent = bvh_make_internal(bvh);
bvh.nodes[new_parent].parent_index = old_parent;
bvh.nodes[new_parent].bbox = quad_union(stroke.bbox, bvh.nodes[sibling].bbox);
if (old_parent !== null) {
// The sibling was not the root
if (bvh.nodes[old_parent].child1 === sibling) {
bvh.nodes[old_parent].child1 = new_parent;
} else {
bvh.nodes[old_parent].child2 = new_parent;
}
bvh.nodes[new_parent].child1 = sibling;
bvh.nodes[new_parent].child2 = leaf_index;
bvh.nodes[sibling].parent_index = new_parent;
bvh.nodes[leaf_index].parent_index = new_parent;
} else {
// The sibling was the root
bvh.nodes[new_parent].child1 = sibling;
bvh.nodes[new_parent].child2 = leaf_index;
bvh.nodes[sibling].parent_index = new_parent;
bvh.nodes[leaf_index].parent_index = new_parent;
bvh.root = new_parent;
}
const new_bbox = quad_union(bvh.nodes[bvh.nodes[new_parent].child1].bbox, bvh.nodes[bvh.nodes[new_parent].child2].bbox);
bvh.nodes[new_parent].bbox = new_bbox;
bvh.nodes[new_parent].area = (new_bbox.x2 - new_bbox.x1) * (new_bbox.y2 - new_bbox.y1);
// 3. Refit and insert in fullnode
let refit_index = bvh.nodes[leaf_index].parent_index;
while (refit_index !== null) {
const child1 = bvh.nodes[refit_index].child1;
const child2 = bvh.nodes[refit_index].child2;
bvh.nodes[refit_index].bbox = quad_union(bvh.nodes[child1].bbox, bvh.nodes[child2].bbox);
if (bvh.nodes[refit_index].is_fullnode) {
tv_add2(bvh.nodes[refit_index].stroke_indices, index);
}
refit_index = bvh.nodes[refit_index].parent_index;
}
}
function bvh_delete_stroke(state, stroke) {
let node = state.bvh.nodes[stroke.bvh_node];
while (node.parent_index !== null) {
if (node.is_fullnode) {
let index_index = node.stroke_indices.data.indexOf(stroke.index);
node.stroke_indices.data[index_index] = node.stroke_indices.data[node.stroke_indices.size - 1];
tv_pop(node.stroke_indices);
break;
}
node = state.bvh.nodes[node.parent_index];
}
}
function bvh_intersect_quad(state, bvh, quad, result_buffer) {
if (bvh.root === null) {
return;
}
tv_clear(bvh.traverse_stack);
tv_add(bvh.traverse_stack, bvh.root);
while (bvh.traverse_stack.size > 0) {
const node_index = tv_pop(bvh.traverse_stack);
const node = bvh.nodes[node_index];
if (!quads_intersect(node.bbox, quad)) {
continue;
}
if (node.is_fullnode) {
if (quad_fully_inside(quad, node.bbox)) {
tv_append(result_buffer, tv_data(node.stroke_indices));
continue;
}
}
if (node.is_leaf) {
if (state.events[node.stroke_index].deleted !== true) {
tv_add(result_buffer, node.stroke_index);
}
} else {
tv_add(bvh.traverse_stack, node.child1);
tv_add(bvh.traverse_stack, node.child2);
}
}
}
function bvh_clip(state, context) {
if (state.stroke_count === 0) {
return;
}
tv_ensure(context.clipped_indices, round_to_pow2(state.stroke_count, 4096))
tv_ensure(state.bvh.traverse_stack, round_to_pow2(state.stroke_count, 4096));
tv_clear(context.clipped_indices);
const screen_topleft = screen_to_canvas(state, {'x': 0, 'y': 0});
const screen_bottomright = screen_to_canvas(state, {'x': context.canvas.width, 'y': context.canvas.height});
const screen_topright = { 'x': screen_bottomright.x, 'y': screen_topleft.y };
const screen_bottomleft = { 'x': screen_topleft.x, 'y': screen_bottomright.y };
const screen = {
'x1': screen_topleft.x,
'y1': screen_topleft.y,
'x2': screen_bottomright.x,
'y2': screen_bottomright.y
};
bvh_intersect_quad(state, state.bvh, screen, context.clipped_indices);
tv_data(context.clipped_indices).sort(); // we need to draw back to front still!
}
function bvh_point(state, p) {
const bvh = state.bvh;
const stack = [];
const indices = [];
if (bvh.root === null) {
return null;
}
stack.push(bvh.root);
while (stack.length > 0) {
const node_index = stack.pop();
const node = bvh.nodes[node_index];
if (!point_in_bbox(p, node.bbox)) {
continue;
}
if (node.is_leaf) {
const stroke = state.events[node.stroke_index];
const xs = state.wasm.buffers['xs'].tv.data.subarray(stroke.coords_from, stroke.coords_to);
const ys = state.wasm.buffers['ys'].tv.data.subarray(stroke.coords_from, stroke.coords_to);
const pressures = state.wasm.buffers['pressures'].tv.data.subarray(stroke.coords_from, stroke.coords_to);
if (stroke.deleted !== true && point_in_stroke(p, xs, ys, pressures, stroke.width)) {
indices.push(node.stroke_index);
}
} else {
stack.push(node.child1);
stack.push(node.child2);
}
}
if (indices.length > 0) {
indices.sort();
return indices[indices.length - 1];
}
return null;
}
function bvh_construct_rec(state, bvh, vertical, strokes, depth) {
if (strokes.length > 1) {
// internal
let sorted_strokes;
if (vertical) {
sorted_strokes = strokes.toSorted((a, b) => a.bbox.cy - b.bbox.cy);
} else {
sorted_strokes = strokes.toSorted((a, b) => a.bbox.cx - b.bbox.cx);
}
const node_index = bvh_make_internal(bvh);
const left_of_split_count = Math.floor(strokes.length / 2);
const child1 = bvh_construct_rec(state, bvh, !vertical, sorted_strokes.slice(0, left_of_split_count), depth + 1);
const child2 = bvh_construct_rec(state, bvh, !vertical, sorted_strokes.slice(left_of_split_count, sorted_strokes.length), depth + 1);
bvh.nodes[child1].parent_index = node_index;
bvh.nodes[child2].parent_index = node_index;
bvh.nodes[node_index].child1 = child1;
bvh.nodes[node_index].child2 = child2;
bvh.nodes[node_index].bbox = quad_union(bvh.nodes[child1].bbox, bvh.nodes[child2].bbox);
if (depth === config.bvh_fullnode_depth) {
const indices = tv_create(Int32Array, round_to_pow2(strokes.length, 32));
for (let i = 0; i < strokes.length; ++i) {
tv_add(indices, strokes[i].index);
}
bvh.nodes[node_index].stroke_indices = indices;
bvh.nodes[node_index].is_fullnode = true;
}
return node_index;
} else {
// leaf
const leaf_index = bvh_make_leaf(bvh, strokes[0].index, strokes[0]);
state.events[strokes[0].index].bvh_node = leaf_index;
return leaf_index;
}
}
function bvh_construct(state) {
const strokes = state.events.filter(e => e.type === EVENT.STROKE && e.deleted !== true);
if (strokes.length > 0) {
state.bvh.root = bvh_construct_rec(state, state.bvh, true, strokes, 0);
}
}

583
client/client_recv.js

@ -0,0 +1,583 @@ @@ -0,0 +1,583 @@
function deserializer_create(buffer, dataview) {
return {
'offset': 0,
'size': buffer.byteLength,
'buffer': buffer,
'view': dataview,
'strview': new Uint8Array(buffer),
};
}
function des_u8(d) {
const value = d.view.getUint8(d.offset);
d.offset += 1;
return value;
}
function des_u16(d) {
const value = d.view.getUint16(d.offset, true);
d.offset += 2;
return value;
}
function des_u32(d) {
const value = d.view.getUint32(d.offset, true);
d.offset += 4;
return value;
}
function des_s32(d) {
const value = d.view.getInt32(d.offset, true);
d.offset += 4;
return value;
}
function des_f32(d) {
const value = d.view.getFloat32(d.offset, true);
d.offset += 4;
return value;
}
function des_align(d, to) {
// TODO: non-stupid version of this
while (d.offset % to != 0) {
d.offset++;
}
}
function des_f32array(d, count) {
const result = new Float32Array(d.buffer, d.offset, count);
d.offset += 4 * count;
return result;
}
function des_u8array(d, count) {
const result = new Uint8Array(d.buffer, d.offset, count);
d.offset += count;
return result;
}
function des_event(d, state = null) {
const event = {};
event.type = des_u32(d);
event.user_id = des_u32(d);
switch (event.type) {
case EVENT.PREDRAW: {
event.x = des_f32(d);
event.y = des_f32(d);
break;
}
case EVENT.USER_JOINED:
case EVENT.LEAVE:
case EVENT.CLEAR: {
break;
}
case EVENT.MOVE_CURSOR: {
event.x = des_f32(d);
event.y = des_f32(d);
break;
}
case EVENT.MOVE_CANVAS: {
event.offset_x = des_s32(d);
event.offset_y = des_s32(d);
event.zoom_level = des_s32(d);
break;
}
case EVENT.ZOOM_CANVAS: {
event.zoom_level = des_s32(d);
event.zoom_cx = des_f32(d);
event.zoom_cy = des_f32(d);
break;
}
case EVENT.SET_COLOR: {
event.color = des_u32(d);
break;
}
case EVENT.SET_WIDTH: {
event.width = des_u16(d);
break;
}
case EVENT.STROKE: {
const stroke_id = des_u32(d);
const point_count = des_u16(d);
const width = des_u16(d);
const color = des_u32(d);
event.coords = des_f32array(d, point_count * 2);
event.press = des_u8array(d, point_count);
des_align(d, 4);
// TODO: remove, this is duplicate data
event.stroke_id = stroke_id;
event.color = color;
event.width = width;
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
event.image_id = des_u32(d);
event.x = des_f32(d);
event.y = des_f32(d);
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.ERASER: {
event.stroke_id = des_u32(d);
break;
}
default: {
console.error('fuck');
}
}
return event;
}
function bitmap_bbox(event) {
const bbox = {
'xmin': event.x,
'xmax': event.x + event.bitmap.width,
'ymin': event.y,
'ymax': event.y + event.bitmap.height,
};
return bbox;
}
function init_player_defaults(state, player_id, color = config.default_color, width = config.default_width) {
state.players[player_id] = {
'color': color,
'width': width,
'points': [],
'online': false,
'cursor': {'x': 0, 'y': 0},
};
}
function handle_event(state, context, event, options = {}) {
if (config.debug_print) console.debug(`event type ${event.type} from user ${event.user_id}`);
let need_draw = false;
if (!(event.user_id in state.players)) {
init_player_defaults(state, event.user_id);
}
switch (event.type) {
case EVENT.USER_JOINED: {
state.players[event.user_id].online = true;
draw_html(state);
break;
}
case EVENT.PREDRAW: {
geometry_add_point(state, context, event.user_id, {'x': event.x, 'y': event.y, 'pressure': 128}, false); // TODO: add pressure to predraw events
need_draw = true;
break;
}
case EVENT.CLEAR: {
geometry_clear_player(state, context, event.user_id);
break;
}
case EVENT.LEAVE: {
if (event.user_id in state.players) {
state.players[event.user_id].online = false;
draw_html(state);
}
break;
}
case EVENT.MOVE_CURSOR: {
if (event.user_id in state.players) {
state.players[event.user_id].cursor.x = event.x;
state.players[event.user_id].cursor.y = event.y;
state.players[event.user_id].online = true;
}
// Should we syncronize this to RAF?
draw_html(state);
break;
}
case EVENT.MOVE_CANVAS: {
// Double-check just in case
// Non-triple equals in on purpose
if (event.user_id == state.following_player) {
state.canvas.offset.x = event.offset_x;
state.canvas.offset.y = event.offset_y;
const zoom_level = event.zoom_level;
const dz = (zoom_level > 0 ? config.zoom_delta : -config.zoom_delta);
const zoom = Math.pow(1.0 + dz, Math.abs(zoom_level))
state.canvas.zoom_level = zoom_level;
state.canvas.zoom = zoom;
state.canvas.target_zoom = zoom;
need_draw = true;
}
break;
}
case EVENT.ZOOM_CANVAS: {
if (event.user_id == state.following_player) {
const zoom_level = event.zoom_level;
const zoom_center = {'x': event.zoom_cx, 'y': event.zoom_cy};
const dz = (zoom_level > 0 ? config.zoom_delta : -config.zoom_delta);
const zoom = Math.pow(1.0 + dz, Math.abs(zoom_level))
state.canvas.zoom_level = zoom_level;
state.canvas.target_zoom = zoom;
state.canvas.zoom_screenp = canvas_to_screen(state, zoom_center);
need_draw = true;
}
break;
}
case EVENT.SET_COLOR: {
state.players[event.user_id].color = event.color;
break;
}
case EVENT.SET_WIDTH: {
state.players[event.user_id].width = event.width;
break;
}
case EVENT.STROKE: {
const point_count = event.coords.length / 2;
if (point_count === 0) {
break;
}
let last_stroke = null;
for (let i = state.events.length - 1; i >= 0; --i) {
if (state.events[i].type === EVENT.STROKE) {
last_stroke = state.events[i];
break;
}
}
const index_difference = state.events.length - (last_stroke === null ? 0 : last_stroke.index);
wasm_ensure_by(state, index_difference, event.coords.length);
const pressures = state.wasm.buffers['pressures'];
const xs = state.wasm.buffers['xs'];
const ys = state.wasm.buffers['ys'];
event.coords_from = xs.tv.size;
event.coords_to = xs.tv.size + point_count;
for (let i = 0; i < index_difference - 1; ++i) {
// Create empty records for all non-stroke events that happened since the last stroke
tv_add(state.wasm.buffers['coords_from'].tv, xs.tv.size);
state.wasm.buffers['coords_from'].used += 4; // 4 bytes, not 4 ints
}
// Create actual records for this stroke
tv_add(state.wasm.buffers['coords_from'].tv, xs.tv.size + point_count);
state.wasm.buffers['coords_from'].used += 4; // 4 bytes, not 4 ints
for (let i = 0; i < event.coords.length; i += 2) {
tv_add(xs.tv, event.coords[i + 0]);
tv_add(ys.tv, event.coords[i + 1]);
}
state.wasm.buffers['xs'].used += point_count * 4;
state.wasm.buffers['ys'].used += point_count * 4;
tv_append(pressures.tv, event.press);
state.wasm.buffers['pressures'].used += point_count;
delete event.coords;
delete event.press;
// TODO: do not do this for my own strokes when we bake locally
geometry_clear_player(state, context, event.user_id);
need_draw = true;
event.index = state.events.length;
geometry_add_stroke(state, context, event, state.events.length, options.skip_bvh === true);
state.stroke_count++;
break;
}
case EVENT.UNDO: {
geometry_add_dummy_stroke(context);
for (let i = state.events.length - 1; i >=0; --i) {
const other_event = state.events[i];
// Users can only undo their own, undeleted (not already undone) events
if (other_event.user_id === event.user_id && !other_event.deleted) {
if (other_event.type === EVENT.STROKE) {
other_event.deleted = true;
if (other_event.bvh_node) {
bvh_delete_stroke(state, other_event);
}
need_draw = true;
break;
} else if (other_event.type === EVENT.UNDO) {
// do not undo an undo, we are not Notepad
} else {
console.error('cant undo event type', other_event.type);
break;
}
}
}
break;
}
case EVENT.IMAGE: {
try {
(async () => {
const url = config.image_url + event.image_id;
const r = await fetch(config.image_url + event.image_id);
const blob = await r.blob();
const bitmap = await createImageBitmap(blob);
const p = {'x': event.x, 'y': event.y};
event.width = bitmap.width;
event.height = bitmap.height;
geometry_add_dummy_stroke(context);
add_image(context, event.image_id, bitmap, p);
// God knows when this will actually complete (it loads the image from the server)
// so do not set need_draw. Instead just schedule the draw ourselves when done
schedule_draw(state, context);
})();
} catch (e) {
console.log('Could not load image bitmap:', e);
}
break;
}
case EVENT.IMAGE_MOVE: {
// Already moved due to local prediction
if (event.user_id !== state.me) {
const image_id = event.image_id;
const image_event = find_image(state, image_id);
if (image_event) {
// if (config.debug_print) console.debug('move image', image_id, 'to', image_event.x, image_event.y);
image_event.x = event.x;
image_event.y = event.y;
move_image(context, image_event);
need_draw = true;
}
}
break;
}
case EVENT.ERASER: {
need_draw = true;
console.error('todo');
// if (event.deleted) {
// break;
// }
// for (const other_event of state.events) {
// if (other_event.type === EVENT.STROKE && other_event.stroke_id === event.stroke_id) {
// // Might already be deleted because of local prediction
// if (!other_event.deleted) {
// other_event.deleted = true;
// const stats = stroke_stats(other_event.points, state.cursor.width);
// redraw_region(stats.bbox);
// }
// break;
// }
// }
break;
}
default: {
console.error('fuck');
}
}
return need_draw;
}
async function handle_message(state, context, d) {
const message_type = des_u32(d);
let do_draw = false;
// if (config.debug_print) console.debug(message_type);
switch (message_type) {
case MESSAGE.JOIN:
case MESSAGE.INIT: {
console.time('init');
state.online = true;
state.server_lsn = des_u32(d);
if (state.server_lsn > state.lsn) {
// Server knows something that we don't
state.lsn = state.server_lsn;
}
let color = config.default_color;
let width = config.default_width;
if (message_type === MESSAGE.JOIN) {
localStorage.setItem('sessionId', des_u32(d));
if (config.debug_print) console.debug('join in');
} else {
color = des_u32(d);
width = des_u16(d);
if (config.debug_print) console.debug('init in');
}
state.me = parseInt(localStorage.getItem('sessionId'));
init_player_defaults(state, state.me);
set_color_u32(state, color);
document.querySelector('#stroke-width').value = width;
fire_event(state, width_event(width));
const event_count = des_u32(d);
const user_count = des_u32(d);
const total_points = des_u32(d);
wasm_ensure_by(state, event_count, round_to_pow2(total_points * 2, 4096));
if (config.debug_print) console.debug(`${event_count} events in init`);
state.events.length = 0;
for (let i = 0; i < user_count; ++i) {
const user_id = des_u32(d);
const user_color = des_u32(d);
const user_width = des_u16(d);
const user_online = des_u8(d);
init_player_defaults(state, user_id, user_color, user_width);
state.players[user_id].online = user_online === 1;
}
des_align(d, 4);
for (let i = 0; i < event_count; ++i) {
const event = des_event(d, state);
handle_event(state, context, event, {'skip_bvh': true});
if (event.type !== EVENT.STROKE || event.coords_to - event.coords_from > 0) {
state.events.push(event);
}
}
state.sn = event_count;
bvh_construct(state);
document.getElementById('debug-render-from').max = state.stroke_count;
document.getElementById('debug-render-to').max = state.stroke_count;
do_draw = true;
send_ack(event_count);
sync_queue(state);
console.timeEnd('init');
update_cursor(state);
draw_html(state);
break;
}
case MESSAGE.FIRE: {
const event = des_event(d);
const need_draw = handle_event(state, context, event);
do_draw = do_draw || need_draw;
break;
}
case MESSAGE.ACK: {
const lsn = des_u32(d);
if (config.debug_print) console.debug(`ack ${lsn} in`);
if (lsn > state.server_lsn) {
// ACKs may arrive out of order
state.server_lsn = lsn;
}
break;
}
case MESSAGE.SYN: {
const sn = des_u32(d);
const count = des_u32(d);
const we_expect = sn - state.sn;
const first = count - we_expect;
if (config.debug_print) console.debug(`syn ${sn} in`);
for (let i = 0; i < count; ++i) {
const event = des_event(d, state);
if (i >= first) {
const need_draw = handle_event(state, context, event);
do_draw = do_draw || need_draw;
state.events.push(event);
}
}
state.sn = sn;
send_ack(sn); // await?
break;
}
default: {
console.error('fuck');
return;
}
}
if (do_draw) {
schedule_draw(state, context);
}
}

398
client/client_send.js

@ -0,0 +1,398 @@ @@ -0,0 +1,398 @@
function serializer_create(size) {
const buffer = new ArrayBuffer(size);
return {
'offset': 0,
'size': size,
'buffer': buffer,
'view': new DataView(buffer),
'strview': new Uint8Array(buffer),
'need_gpu_allocate': true, // need to call glBufferData to create a GPU buffer of size serializer.size
'gpu_upload_from': 0, // need to call glBufferSubData for bytes in [serializer.gpu_upload_from, serializer.offset)
};
}
function ser_ensure(s, size) {
if (s.size < size) {
const new_s = serializer_create(Math.ceil(size * 2));
new_s.strview.set(s.strview);
new_s.offset = s.offset;
return new_s;
}
return s;
}
function ser_ensure_by(s, by) {
if (s.offset + by > s.size) {
const new_s = serializer_create(Math.ceil((s.size + by) * 2));
new_s.strview.set(s.strview);
new_s.offset = s.offset;
return new_s;
}
return s;
}
function ser_clear(s) {
s.offset = 0;
s.gpu_upload_from = 0;
}
function ser_u8(s, value) {
s.view.setUint8(s.offset, value);
s.offset += 1;
}
function ser_u16(s, value) {
s.view.setUint16(s.offset, value, true);
s.offset += 2;
}
function ser_f32(s, value) {
s.view.setFloat32(s.offset, value, true);
s.offset += 4;
}
function ser_u32(s, value) {
s.view.setUint32(s.offset, value, true);
s.offset += 4;
}
function ser_s32(s, value) {
s.view.setInt32(s.offset, value, true);
s.offset += 4;
}
function ser_align(s, to) {
// TODO: non-stupid version of this
while (s.offset % to != 0) {
s.offset++;
}
}
function ser_event(s, event) {
ser_u32(s, event.type);
switch (event.type) {
case EVENT.PREDRAW: {
ser_f32(s, event.x);
ser_f32(s, event.y);
break;
}
case EVENT.CLEAR: {
break;
}
case EVENT.MOVE_CURSOR: {
ser_f32(s, event.x);
ser_f32(s, event.y);
break;
}
case EVENT.MOVE_CANVAS: {
ser_u32(s, event.offset_x);
ser_u32(s, event.offset_y);
ser_s32(s, event.zoom_level);
break;
}
case EVENT.ZOOM_CANVAS: {
ser_s32(s, event.zoom_level);
ser_f32(s, event.zoom_cx);
ser_f32(s, event.zoom_cy);
break;
}
case EVENT.SET_COLOR: {
ser_u32(s, event.color);
break;
}
case EVENT.SET_WIDTH: {
ser_u16(s, event.width);
break;
}
case EVENT.STROKE: {
ser_u16(s, event.points.length);
ser_u16(s, event.width);
ser_u32(s, event.color);
if (config.debug_print) console.debug('original', event.points);
for (const point of event.points) {
ser_f32(s, point.x);
ser_f32(s, point.y);
}
for (const point of event.points) {
ser_u8(s, point.pressure);
}
ser_align(s, 4);
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
const image_id = parseInt(event.image_id);
ser_u32(s, image_id);
ser_f32(s, event.x);
ser_f32(s, event.y);
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.ERASER: {
ser_u32(s, event.stroke_id);
break;
}
default: {
console.error('fuck');
}
}
}
async function send_ack(sn) {
const s = serializer_create(4 + 4);
ser_u32(s, MESSAGE.ACK);
ser_u32(s, sn);
if (config.debug_print) console.debug(`ack ${sn} out`);
try {
if (ws) await ws.send(s.buffer);
} catch(e) {
ws.close();
}
}
async function send_follow(player_id) {
const s = serializer_create(4 + 4);
player_id = player_id === null ? -1 : player_id;
ser_u32(s, MESSAGE.FOLLOW);
ser_u32(s, player_id);
if (config.debug_print) console.debug(`follow ${player_id} out`);
try {
if (ws) await ws.send(s.buffer);
} catch (e) {
ws.close();
}
}
async function sync_queue(state) {
if (ws === null) {
if (config.debug_print) console.debug('socket has closed, stopping SYNs');
return;
}
let size = 4 + 4 + 4; // opcode + lsn + event count
let count = state.lsn - state.server_lsn;
if (count === 0) {
if (config.debug_print) console.debug('server ACKed all events, clearing queue');
state.queue.length = 0;
return;
}
for (let i = count - 1; i >= 0; --i) {
const event = state.queue[state.queue.length - 1 - i];
size += event_size(event);
}
const s = serializer_create(size);
ser_u32(s, MESSAGE.SYN);
ser_u32(s, state.lsn);
ser_u32(s, count);
for (let i = count - 1; i >= 0; --i) {
const event = state.queue[state.queue.length - 1 - i];
ser_event(s, event);
}
if (config.debug_print) console.debug(`syn ${state.lsn} out`);
try {
if (ws) await ws.send(s.buffer);
} catch(e) {
ws.close();
}
setTimeout(() => sync_queue(state), config.sync_timeout);
}
function push_event(state, event) {
state.lsn += 1;
switch (event.type) {
case EVENT.STROKE: {
state.queue.push({
'type': EVENT.STROKE,
'points': event.points,
'width': event.width,
'color': event.color,
});
break;
}
case EVENT.RULER: {
event.type = EVENT.STROKE;
state.queue.push(event);
break;
}
case EVENT.ERASER:
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE:
case EVENT.UNDO:
case EVENT.REDO: {
state.queue.push(event);
break;
}
default: {
console.error('fuck');
}
}
}
// Queue an event and initialize repated sends until ACKed
function queue_event(state, event, skip = false) {
if (!state.online) { return; }
push_event(state, event);
if (skip) {
return;
}
if (state.timers.queue_sync) {
clearTimeout(state.timers.queue_sync);
}
sync_queue(state);
}
// Fire and forget. Doesn't do anything if we are offline
async function fire_event(state, event) {
if (!state.online) { return; }
const s = serializer_create(4 + event_size(event));
ser_u32(s, MESSAGE.FIRE);
ser_event(s, event);
try {
if (ws) await ws.send(s.buffer);
} catch(e) {
ws.close();
}
}
function predraw_event(x, y) {
return {
'type': EVENT.PREDRAW,
'x': x,
'y': y
};
}
function color_event(color_u32) {
return {
'type': EVENT.SET_COLOR,
'color': color_u32,
};
}
function width_event(width) {
return {
'type': EVENT.SET_WIDTH,
'width': width,
};
}
function image_event(image_id, x, y) {
return {
'type': EVENT.IMAGE,
'image_id': image_id,
'x': x,
'y': y,
};
}
function image_move_event(image_id, x, y) {
return {
'type': EVENT.IMAGE_MOVE,
'image_id': image_id,
'x': x,
'y': y,
};
}
function stroke_event(state) {
const stroke = geometry_prepare_stroke(state);
return {
'type': EVENT.STROKE,
'points': stroke.points,
'width': stroke.width,
'color': stroke.color,
};
}
function clear_event(state) {
return {
'type': EVENT.CLEAR
};
}
function movecursor_event(x, y) {
return {
'type': EVENT.MOVE_CURSOR,
'x': x,
'y': y,
};
}
function movecanvas_event(state) {
return {
'type': EVENT.MOVE_CANVAS,
'offset_x': state.canvas.offset.x,
'offset_y': state.canvas.offset.y,
'zoom_level': state.canvas.zoom_level,
};
}
function zoomcanvas_event(state, zoom_cx, zoom_cy) {
return {
'type': EVENT.ZOOM_CANVAS,
'zoom_level': state.canvas.zoom_level,
'zoom_cx': zoom_cx,
'zoom_cy': zoom_cy,
};
}
function undo_event(state) {
return {
'type': EVENT.UNDO,
};
}

20
client/cursor.js

@ -4,8 +4,8 @@ function on_down(e) { @@ -4,8 +4,8 @@ function on_down(e) {
// Scroll wheel (mouse button 3)
if (e.button === 1) {
storage.state.moving = true;
storage.state.mousedown = true;
// storage.state.moving = true;
// storage.state.mousedown = true;
return;
}
@ -225,8 +225,18 @@ function on_leave(e) { @@ -225,8 +225,18 @@ function on_leave(e) {
}
function on_resize(e) {
storage.canvas.max_scroll_x = storage.canvas.width - window.innerWidth;
storage.canvas.max_scroll_y = storage.canvas.height - window.innerHeight;
const width = window.innerWidth;
const height = window.innerHeight;
elements.canvas0.width = elements.canvas1.width = width;
elements.canvas0.height = elements.canvas1.height = height;
storage.ctx1.lineJoin = storage.ctx1.lineCap = storage.ctx0.lineJoin = storage.ctx0.lineCap = 'round';
storage.ctx1.lineWidth = storage.ctx0.lineWidth = storage.cursor.width;
redraw_region({'xmin': 0, 'xmax': width, 'ymin': 0, 'ymax': width});
// storage.canvas.max_scroll_x = storage.canvas.width - window.innerWidth;
// storage.canvas.max_scroll_y = storage.canvas.height - window.innerHeight;
}
async function on_drop(e) {
@ -257,6 +267,8 @@ async function on_drop(e) { @@ -257,6 +267,8 @@ async function on_drop(e) {
}
function on_wheel(e) {
return;
const x = Math.round((e.clientX + storage.canvas.offset_x) / storage.canvas.zoom);
const y = Math.round((e.clientY + storage.canvas.offset_y) / storage.canvas.zoom);

409
client/default.css

@ -1,60 +1,93 @@ @@ -1,60 +1,93 @@
:root {
--dark-blue: #2f343d;
--dark-hover: #888;
--radius: 5px;
--hgap: 5px;
--gap: 10px;
--transform-amimate: transform .1s ease-out;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
touch-action: none;
}
body .main {
height: 100%;
}
body.offline .main {
filter: brightness(50%);
}
.dhide {
display: none !important;
}
.canvas {
position: absolute;
top: 0;
left: 0;
opacity: 1;
transition: opacity .2s;
transform-origin: top left;
pointer-events: none;
.vhide {
visibility: hidden !important;
}
#toucher {
position: fixed;
.flexcol {
display: flex;
flex-direction: column;
}
canvas {
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 5; /* above all canvases, but below tools */
cursor: crosshair;
display: block;
cursor: url('icons/crosshair.svg') 16 16, crosshair;
}
.canvas.white {
opacity: 0;
canvas.picker {
cursor: url('icons/picker.svg') 0 19, crosshair;
}
/*
canvas.movemode {
cursor: grab;
}
canvas.movemode.moving {
cursor: grabbing;
}
#canvas-images {
z-index: 0;
}
canvas.mousemoving {
cursor: move;
}
*/
.brush-dom {
position: absolute;
pointer-events: none;
user-select: none;
top: 0;
left: 0;
}
#canvas0 {
.html-hud {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
background: #eee;
background-position: 0px 0px;
background-size: 32px 32px;
background-image: radial-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 10%);
}
#canvas1 {
z-index: 2;
opacity: 0.3;
.html-hud .player-cursor {
position: absolute;
width: 16px;
height: 16px;
}
.tools-wrapper {
position: fixed;
bottom: 0;
width: 100%;
height: 32px;
display: flex;
justify-content: center;
align-items: end;
@ -62,41 +95,170 @@ html, body { @@ -62,41 +95,170 @@ html, body {
pointer-events: none;
}
.tools {
.pallete-wrapper,
.top-wrapper {
position: fixed;
top: 0;
left: 0;
height: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: center;
transition: var(--transform-amimate);
}
.top-wrapper {
height: unset;
width: 100%;
flex-direction: row;
}
.player-list {
position: absolute;
right: 0;
height: 42px;
padding-left: var(--gap);
padding-right: var(--gap);
background: var(--dark-blue);
display: flex;
gap: var(--gap);
align-items: center;
border-bottom-left-radius: var(--radius);
pointer-events: all;
}
.player-list .player {
width: 24px;
height: 24px;
border-radius: var(--radius);
display: flex;
justify-content: center;
align-items: center;
border: 2px solid transparent;
box-sizing: border-box;
}
.player-list .player.following {
border-color: white;
}
.player-list .player img {
height: 12px;
width: 12px;
filter: invert(100%);
}
.pallete {
pointer-events: none;
display: grid;
flex-direction: column;
align-items: center;
border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius);
/* border-bottom-left-radius: var(--radius);*/
padding-top: var(--gap);
padding-bottom: var(--gap);
overflow: hidden;
}
.pallete-wrapper.hidden {
transform: translateX(-125%); /* to account for a selected color, which is also offset to the right */
}
.top-wrapper.hidden {
transform: translateY(-125%);
}
.pallete .color-major {
pointer-events: all;
background: var(--dark-blue);
display: flex;
transition: var(--transform-amimate);
transform: translateX(calc(-100% + 2 * var(--gap) + 24px));
cursor: pointer;
}
.pallete .color-major.extended {
transform: translateX(0px);
border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
.pallete .color-major.active {
transform: translateX(calc(-100% + 3 * var(--gap) + 24px));
border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
.pallete .color-minor {
padding: var(--gap);
display: flex;
justify-content: center;
align-items: center;
}
.pallete .color-major:not(.active) .color-minor:hover {
background: var(--dark-hover);
}
.pallete .color-pane {
width: 24px;
height: 24px;
box-sizing: border-box;
border-radius: var(--radius);
}
.pallete .color .color-pane:hover {
background: var(--dark-hover);
}
.pallete .color.active:hover {
background: var(--dark-blue);
}
.tools,
.sizer {
pointer-events: all;
display: flex;
align-items: center;
justify-content: center;
background: #333;
border-radius: 5px;
background: var(--dark-blue);
border-radius: var(--radius);
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
height: 42px;
padding-left: 10px;
padding-right: 10px;
padding-left: var(--gap);
padding-right: var(--gap);
}
.sizer {
border-radius: 0;
border-bottom-right-radius: var(--radius);
border-bottom-left-radius: var(--radius);
}
.tool {
cursor: pointer;
padding-left: 10px;
padding-right: 10px;
padding-left: var(--gap);
padding-right: var(--gap);
height: 100%;
display: flex;
align-items: center;
background: #333;
transition: transform .1s ease-in-out;
background: var(--dark-blue);
transition: var(--transform-amimate);
user-select: none;
}
.tool:hover {
background: #888;
background: var(--dark-hover);
}
.tool.active {
transform: translateY(-10px);
border-top-right-radius: 5px;
border-top-left-radius: 5px;
background: #333;
background: var(--dark-blue);
border-top-right-radius: var(--radius);
border-top-left-radius: var(--radius);
}
.tool img {
@ -105,18 +267,163 @@ html, body { @@ -105,18 +267,163 @@ html, body {
filter: invert(100%);
}
.toolbar {
visibility: hidden;
.sizer input[type=range] {
-webkit-appearance: none;
width: 200px;
background: transparent;
}
.sizer input[type=range]:focus {
outline: none;
}
.sizer input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
border: none;
background: white;
height: 20px;
width: 20px;
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--dark-blue);
margin-top: -6px; /* You need to specify a margin in Chrome, but in Firefox and IE it is automatic */
}
.sizer input[type=range]::-moz-range-thumb {
border: none;
background: white;
height: 16px;
width: 16px;
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--dark-blue);
}
.sizer input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
cursor: pointer;
background: white;
border-radius: 2px;
border: none;
}
.floating-image {
.sizer input[type=range]:focus::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
cursor: pointer;
background: white;
border-radius: 2px;
border: none;
}
.sizer input[type=range]::-moz-range-track {
width: 100%;
height: 8px;
cursor: pointer;
background: white;
border-radius: 2px;
border: none;
}
.phone-extra-controls {
display: none;
cursor: pointer; /* for click events on mobile */
pointer-events: all;
position: absolute;
user-drag: none;
right: 0;
background: var(--dark-blue);
height: 42px;
justify-content: center;
align-items: center;
padding-left: var(--gap);
padding-right: var(--gap);
border-top-left-radius: var(--radius);
}
.phone-extra-controls img {
height: 24px;
width: 24px;
filter: invert(100%);
}
@media (hover: none) and (pointer: coarse) {
.phone-extra-controls {
display: flex;
}
}
.offline-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 999;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
padding: 10px;
border-radius: var(--radius);
box-shadow: 0px 2px 3px 0px rgba(155, 150, 100, 0.2);
transition: transform .1s ease-in-out, opacity .1s;
font-family: sans-serif;
font-size: 14px;
color: white;
font-weight: bold;
user-select: none;
transition: transform .1s ease-in-out, opacity .1s;
pointer-events: none;
}
.floating-image.activated {
outline: 5px solid #5286ff;
z-index: 999999 !important;
cursor: grab;
}
.offline-toast.hidden {
transform: translate(-50%, -5px);
opacity: 0;
}
body.offline * {
pointer-events: none;
}
.loader {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 128px;
height: 128px;
pointer-events: none;
background: rgba(0, 0, 0, 0.8);
border-radius: 10px;
transition: opacity .2s;
}
.loader.hidden {
opacity: 0;
}
.debug-window {
position: absolute;
min-width: 256px;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
user-select: none;
padding: 5px;
background: white;
border: 1px solid var(--dark-blue);
}
.picker-preview-outer {
position: absolute;
top: 16px;
right: 16px;
border: 1px solid black;
}
.picker-preview-inner {
width: 64px;
height: 64px;
border: 1px solid white;
}

66
client/draw.js

@ -1,66 +0,0 @@ @@ -1,66 +0,0 @@
function draw_stroke(stroke) {
const points = stroke.points;
if (points.length === 0) {
return;
}
// if (config.debug_print) console.debug(points)
storage.ctx0.beginPath();
storage.ctx0.moveTo(points[0].x, points[0].y);
storage.ctx0.strokeStyle = color_from_u32(stroke.color);
storage.ctx0.lineWidth = stroke.width;
for (let i = 1; i < points.length; ++i) {
const p = points[i];
storage.ctx0.lineTo(p.x, p.y);
}
storage.ctx0.stroke();
}
function redraw_predraw() {
storage.ctx1.clearRect(0, 0, storage.ctx1.canvas.width, storage.ctx1.canvas.height);
}
function predraw_user(user_id, event) {
if (!(user_id in storage.predraw)) {
storage.predraw[user_id] = [];
}
storage.ctx1.beginPath();
if (storage.predraw[user_id].length > 0) {
const last = storage.predraw[user_id][storage.predraw[user_id].length - 1];
storage.ctx1.moveTo(last.x, last.y);
storage.ctx1.lineTo(event.x, event.y);
} else {
storage.ctx1.moveTo(event.x, event.y);
}
storage.ctx1.stroke();
storage.predraw[user_id].push({ 'x': event.x, 'y': event.y });
}
function redraw_region(bbox) {
if (bbox.xmin === bbox.xmax || bbox.ymin === bbox.ymax) {
return;
}
storage.ctx0.save();
storage.ctx0.clearRect(bbox.xmin, bbox.ymin, bbox.xmax - bbox.xmin, bbox.ymax - bbox.ymin);
storage.ctx0.beginPath();
storage.ctx0.rect(bbox.xmin, bbox.ymin, bbox.xmax - bbox.xmin, bbox.ymax - bbox.ymin);
storage.ctx0.clip();
for (const event of storage.events) {
if (event.type === EVENT.STROKE && !event.deleted) {
if (stroke_intersects_region(event.points, bbox)) {
draw_stroke(event);
}
}
}
storage.ctx0.restore();
}

BIN
client/favicon2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 B

BIN
client/favicon_old.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 B

130
client/heapify.js

@ -0,0 +1,130 @@ @@ -0,0 +1,130 @@
// translated by esbuild to js. original typescript source MIT licensed at https://github.com/luciopaiva/heapify
const ROOT_INDEX = 1;
class MinQueue {
constructor(capacity = 64, keys = [], priorities = [], KeysBackingArrayType = Uint32Array, PrioritiesBackingArrayType = Uint32Array) {
this._capacity = capacity;
this._keys = new KeysBackingArrayType(capacity + ROOT_INDEX);
this._priorities = new PrioritiesBackingArrayType(capacity + ROOT_INDEX);
this._hasPoppedElement = false;
if (keys.length !== priorities.length) {
throw new Error("Number of keys does not match number of priorities provided.");
}
if (capacity < keys.length) {
throw new Error("Capacity less than number of provided keys.");
}
for (let i = 0; i < keys.length; i++) {
this._keys[i + ROOT_INDEX] = keys[i];
this._priorities[i + ROOT_INDEX] = priorities[i];
}
this.length = keys.length;
for (let i = keys.length >>> 1; i >= ROOT_INDEX; i--) {
this.bubbleDown(i);
}
}
get capacity() {
return this._capacity;
}
clear() {
this.length = 0;
this._hasPoppedElement = false;
}
bubbleUp(index) {
const key = this._keys[index];
const priority = this._priorities[index];
while (index > ROOT_INDEX) {
const parentIndex = index >>> 1;
if (this._priorities[parentIndex] <= priority) {
break;
}
this._keys[index] = this._keys[parentIndex];
this._priorities[index] = this._priorities[parentIndex];
index = parentIndex;
}
this._keys[index] = key;
this._priorities[index] = priority;
}
bubbleDown(index) {
const key = this._keys[index];
const priority = this._priorities[index];
const halfLength = ROOT_INDEX + (this.length >>> 1);
const lastIndex = this.length + ROOT_INDEX;
while (index < halfLength) {
const left = index << 1;
let childPriority = this._priorities[left];
let childKey = this._keys[left];
let childIndex = left;
const right = left + 1;
if (right < lastIndex) {
const rightPriority = this._priorities[right];
if (rightPriority < childPriority) {
childPriority = rightPriority;
childKey = this._keys[right];
childIndex = right;
}
}
if (childPriority >= priority) {
break;
}
this._keys[index] = childKey;
this._priorities[index] = childPriority;
index = childIndex;
}
this._keys[index] = key;
this._priorities[index] = priority;
}
push(key, priority) {
if (this.length === this._capacity) {
throw new Error("Heap has reached capacity, can't push new items");
}
if (this._hasPoppedElement) {
this._keys[ROOT_INDEX] = key;
this._priorities[ROOT_INDEX] = priority;
this.length++;
this.bubbleDown(ROOT_INDEX);
this._hasPoppedElement = false;
} else {
const pos = this.length + ROOT_INDEX;
this._keys[pos] = key;
this._priorities[pos] = priority;
this.length++;
this.bubbleUp(pos);
}
}
pop() {
if (this.length === 0) {
return void 0;
}
this.removePoppedElement();
this.length--;
this._hasPoppedElement = true;
return this._keys[ROOT_INDEX];
}
peekPriority() {
this.removePoppedElement();
return this._priorities[ROOT_INDEX];
}
peek() {
this.removePoppedElement();
return this._keys[ROOT_INDEX];
}
removePoppedElement() {
if (this._hasPoppedElement) {
this._keys[ROOT_INDEX] = this._keys[this.length + ROOT_INDEX];
this._priorities[ROOT_INDEX] = this._priorities[this.length + ROOT_INDEX];
this.bubbleDown(ROOT_INDEX);
this._hasPoppedElement = false;
}
}
get size() {
return this.length;
}
dumpRawPriorities() {
this.removePoppedElement();
const result = Array(this.length - ROOT_INDEX);
for (let i = 0; i < this.length; i++) {
result[i] = this._priorities[i + ROOT_INDEX];
}
return `[${result.join(" ")}]`;
}
}

8
client/icons/cheeseburga.svg

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="7.9173mm" height="6.8706mm" version="1.1" viewBox="0 0 7.9173 6.8706" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-130.27 -83.243)" fill="none" stroke="#000" stroke-linecap="round">
<path d="m130.77 83.743h6.9173"/>
<path d="m130.77 89.613h6.9173"/>
<path d="m130.77 86.678h6.9173"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 371 B

281
client/icons/crosshair.svg

@ -0,0 +1,281 @@ @@ -0,0 +1,281 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 32 32"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="crosshair.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#5fa9cd"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="true"
inkscape:zoom="19.02887"
inkscape:cx="10.011104"
inkscape:cy="17.447173"
inkscape:window-width="2558"
inkscape:window-height="1412"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid686" />
</sodipodi:namedview>
<defs
id="defs2">
<linearGradient
id="linearGradient1226"
inkscape:swatch="solid">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop1224" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1226"
id="linearGradient1396"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(7,-19)"
x1="-16.56695"
y1="8.5"
x2="40.56695"
y2="8.5" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1226"
id="linearGradient1465"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(7,-36)"
x1="-16.56695"
y1="8.5"
x2="40.56695"
y2="8.5" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1226"
id="linearGradient1471"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-25,-36)"
x1="-16.56695"
y1="8.5"
x2="40.56695"
y2="8.5" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1226"
id="linearGradient1473"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-25,-19)"
x1="-16.56695"
y1="8.5"
x2="40.56695"
y2="8.5" />
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter2232"
x="-1.1"
y="-0.275"
width="3.2"
height="1.55">
<feFlood
flood-opacity="0.27451"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood2222" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite2224" />
<feGaussianBlur
in="composite1"
stdDeviation="0.5"
result="blur"
id="feGaussianBlur2226" />
<feOffset
dx="0"
dy="0"
result="offset"
id="feOffset2228" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite2230" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter2244"
x="-1.1"
y="-0.275"
width="3.2"
height="1.55">
<feFlood
flood-opacity="0.27451"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood2234" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite2236" />
<feGaussianBlur
in="composite1"
stdDeviation="0.5"
result="blur"
id="feGaussianBlur2238" />
<feOffset
dx="0"
dy="0"
result="offset"
id="feOffset2240" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite2242" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter2256"
x="-1.0999969"
y="-0.275"
width="3.1999937"
height="1.55">
<feFlood
flood-opacity="0.27451"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood2246" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite2248" />
<feGaussianBlur
in="composite1"
stdDeviation="0.5"
result="blur"
id="feGaussianBlur2250" />
<feOffset
dx="0"
dy="0"
result="offset"
id="feOffset2252" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite2254" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter2268"
x="-1.1"
y="-0.275"
width="3.2"
height="1.55">
<feFlood
flood-opacity="0.27451"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood2258" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite2260" />
<feGaussianBlur
in="composite1"
stdDeviation="0.5"
result="blur"
id="feGaussianBlur2262" />
<feOffset
dx="0"
dy="0"
result="offset"
id="feOffset2264" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite2266" />
</filter>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#000000;fill-opacity:1;stroke:url(#linearGradient1396);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers;filter:url(#filter2232)"
id="rect1390"
width="2"
height="8"
x="15"
y="-12"
rx="0"
ry="0"
transform="rotate(90)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:url(#linearGradient1465);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers;filter:url(#filter2268)"
id="rect1463"
width="2"
height="8"
x="15"
y="-28"
rx="0"
ry="0"
transform="rotate(90)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:url(#linearGradient1473);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers;filter:url(#filter2244)"
id="rect1467"
width="2"
height="8"
x="-17"
y="-12"
rx="0"
ry="0"
transform="scale(-1)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:url(#linearGradient1471);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers;filter:url(#filter2256)"
id="rect1469"
width="2.0000057"
height="8"
x="-17"
y="-28"
rx="0"
ry="0"
transform="scale(-1)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

26
client/icons/cursor.svg

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="16"
height="16"
viewBox="0 0 16 16"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<g id="layer1">
<circle
style="fill:none;stroke:#ffffff;stroke-width:1"
id="circle1000"
cy="128.53627"
cx="135.63414"
r="1.5875" />
<circle
style="fill:none;stroke:#000000;stroke-width:0.25"
id="path236"
cy="128.53627"
cx="135.63414"
r="1.5875" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 624 B

54
client/icons/draw.svg

@ -1,54 +0,0 @@ @@ -1,54 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="29.716999mm"
height="30.635mm"
viewBox="0 0 29.716999 30.635"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="draw.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="6.7277216"
inkscape:cx="45.334813"
inkscape:cy="49.273739"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-74.769935,-147.21149)">
<path
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 95.762931,151.36667 -1.459957,1.45995 -4.171293,-4.17129 -7.480518,7.48052 1.501665,1.50166 6.020569,-6.02056 2.669628,2.66962 -3.164437,3.16444 5.325352,5.32535 6.08434,-6.08434 z"
id="path1057"
sodipodi:nodetypes="ccccccccccc" />
<path
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 87.28337,159.84623 5.401522,5.40152 -10.91227,11.04308 H 76.3711 v -5.53233 z"
id="path1785"
sodipodi:nodetypes="cccccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

79
client/icons/erase.svg

@ -1,74 +1,7 @@ @@ -1,74 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="18.310799mm"
height="13.563546mm"
viewBox="0 0 18.310798 13.563546"
version="1.1"
id="svg4722"
sodipodi:docname="erase.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview4724"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="9.5144352"
inkscape:cx="25.697794"
inkscape:cy="26.223312"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs4719">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect26362"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1"
unit="px"
method="auto"
mode="F"
radius="3"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-71.863816,-97.210284)">
<path
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 80.965463,98.271529 c -2.680135,2.680131 -5.360269,5.360271 -8.040401,8.040401 -0.309979,0.30998 -0.316694,0.8191 -0.015,1.13714 l 2.133265,2.24889 a 1.8470002,1.8470002 23.255659 0 0 1.340018,0.57587 h 3.418044 a 1.909119,1.909119 157.42404 0 0 1.353526,-0.56275 l 5.610134,-5.63997 c 0.309156,-0.3108 0.308489,-0.81404 -0.0015,-1.12402 -1.55852,-1.55852 -3.117043,-3.11704 -4.675566,-4.675561 -0.309979,-0.309979 -0.812554,-0.309979 -1.122532,0 z"
id="path5932"
sodipodi:nodetypes="cccccc"
inkscape:path-effect="#path-effect26362"
inkscape:original-d="m 81.526729,97.710263 c -3.054312,3.054307 -6.108624,6.108627 -9.162933,9.162937 l 3.225801,3.40063 h 5.005544 l 6.729686,-6.76547 c -1.932697,-1.9327 -3.865398,-3.865399 -5.798098,-5.798097 z" />
<path
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 85.793244,110.02383 h 3.63137"
id="path6432"
sodipodi:nodetypes="cc" />
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="18.311mm" height="13.564mm" version="1.1" viewBox="0 0 18.311 13.564" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-71.864 -97.21)" stroke="#000" stroke-linecap="round">
<path d="m80.965 98.272c-2.6801 2.6801-5.3603 5.3603-8.0404 8.0404-0.30998 0.30998-0.31669 0.8191-0.015 1.1371l2.1333 2.2489a1.847 1.847 23.256 0 0 1.34 0.57587h3.418a1.9091 1.9091 157.42 0 0 1.3535-0.56275l5.6101-5.64c0.30916-0.3108 0.30849-0.81404-0.0015-1.124-1.5585-1.5585-3.117-3.117-4.6756-4.6756-0.30998-0.30998-0.81255-0.30998-1.1225 0z" stroke-linejoin="round"/>
<path d="m85.793 110.02h3.6314" fill="none" stroke-width="1.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 691 B

54
client/icons/favicon.svg

@ -1,49 +1,7 @@ @@ -1,49 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 9.9999997 10"
version="1.1"
id="svg31118"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="favicon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview31120"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="19.02887"
inkscape:cx="21.730139"
inkscape:cy="19.62807"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs31115" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-102.35789,-119.40706)">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 103.07394,121.49803 c 0,0 1.8926,-1.50652 2.48887,-0.86207 0.80348,0.8684 -2.24958,2.41185 -1.41823,3.25361 1.77046,1.79265 5.16721,-4.69796 6.96607,-2.9338 1.82345,1.78827 -4.48394,5.16682 -2.83582,7.11786 0.74072,0.87686 3.36701,-0.72189 3.36701,-0.72189"
id="path33421"
sodipodi:nodetypes="caaaac" />
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="10mm" height="10mm" version="1.1" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-102.36 -119.41)" stroke-linecap="round" stroke-linejoin="round">
<rect x="102.36" y="119.41" width="10" height="10" ry="1.5851" fill="#fff" style="paint-order:stroke fill markers"/>
<path d="m103.62 121.96s1.6506-1.3139 2.1706-0.75183c0.70073 0.75735-1.9619 2.1034-1.2369 2.8375 1.544 1.5634 4.5064-4.0972 6.0752-2.5586 1.5903 1.5596-3.9105 4.5061-2.4732 6.2076 0.64599 0.76473 2.9364-0.62957 2.9364-0.62957" fill="none" stroke="#000" stroke-width=".87212"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 643 B

7
client/icons/pen.svg

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="29.717mm" height="30.635mm" version="1.1" viewBox="0 0 29.717 30.635" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-74.77 -147.21)" stroke="#000" stroke-linecap="round" stroke-linejoin="round">
<path d="m95.763 151.37-1.46 1.46-4.1713-4.1713-7.4805 7.4805 1.5017 1.5017 6.0206-6.0206 2.6696 2.6696-3.1644 3.1644 5.3254 5.3254 6.0843-6.0843z"/>
<path d="m87.283 159.85 5.4015 5.4015-10.912 11.043h-5.4015v-5.5323z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 500 B

2
client/icons/perfect-bullet.svg

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="85" height="40" version="1.1" viewBox="0 0 85 40" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-15,-10)" fill="#5286ff"><path d="m15 10v40h10v-15h10v15h40v-40h-40v15h-10v-15z"/><path d="m80 10v40h5v-5h5v-5h5v-5h5v-10h-5v-5h-5v-5h-5v-5z"/></g></svg>

After

Width:  |  Height:  |  Size: 335 B

105
client/icons/picker.svg

@ -0,0 +1,105 @@ @@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="20"
height="20"
viewBox="0 0 20 20"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="picker.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#41a7d4"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="32"
inkscape:cx="7.765625"
inkscape:cy="9.0625"
inkscape:window-width="2558"
inkscape:window-height="1412"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid686"
originx="-0.050020602"
originy="0.069140606" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-0.0500206,0.06914061)">
<g
id="g2699"
style="stroke:#ffffff;stroke-width:3.64724409;stroke-dasharray:none;stroke-opacity:1"
transform="translate(-0.2273805,0.5521655)">
<path
id="path2684"
style="stroke:#ffffff;stroke-width:3.64724409;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 17.60468,5.2109617 c -0.867205,0.8672044 -2.273222,0.8672038 -3.140427,-1e-6 -0.867206,-0.8672056 -0.867206,-2.2732231 -2e-6,-3.1404278 0.867205,-0.8672047 2.273222,-0.867204 3.140428,1.7e-6 0.867205,0.8672048 0.867206,2.2732222 10e-7,3.1404271 z"
sodipodi:nodetypes="sssss" />
<path
id="path2686"
style="stroke:#ffffff;stroke-width:3.64724409;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 14.408181,2.2057099 3.062213,3.0622128 -3.09249,3.09249 -3.062213,-3.0622128 z"
sodipodi:nodetypes="ccccc" />
<path
id="path2688"
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:3.64724409;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="M 10.736761,5.9572145 13.718,8.9384545 6.4778464,16.178608 H 4.7742805 l -1.2776741,1.277674 -1.277674,-1.277674 1.2776743,-1.277675 -2e-7,-1.703565 z"
sodipodi:nodetypes="ccccccccc" />
<path
style="fill:#000000;fill-opacity:1;stroke:#ffffff;stroke-width:3.64724409;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 4.7703397,13.904873 v 1 h 1.0000004 l 3.0000002,-3 -1.0000003,-1 z"
id="path2690"
sodipodi:nodetypes="cccccc" />
<path
id="path2692"
style="stroke:#ffffff;stroke-width:3.64724409;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 10.71679,4.0190773 4.939346,4.939346 c 0.34205,0.3420501 0.471794,0.7630421 0.290907,0.9439294 l -0.163256,0.1632553 c -0.180887,0.180888 -0.601879,0.05114 -0.94393,-0.2909063 L 9.9005113,4.8353552 C 9.5584616,4.4933056 9.4287171,4.0723131 9.6096043,3.8914257 L 9.7728599,3.7281701 C 9.9537473,3.5472831 10.37474,3.6770276 10.71679,4.0190773 Z"
sodipodi:nodetypes="sssssssss" />
</g>
<path
id="path1222"
style="stroke:#000000;stroke-width:1.48129;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 17.3773,5.7631272 c -0.867205,0.8672044 -2.273222,0.8672038 -3.140427,-10e-7 -0.867206,-0.8672056 -0.867206,-2.2732231 -2e-6,-3.1404278 0.867205,-0.8672047 2.273222,-0.867204 3.140428,1.7e-6 0.867205,0.8672048 0.867206,2.2732222 1e-6,3.1404271 z"
sodipodi:nodetypes="sssss" />
<path
id="rect1224"
style="stroke:#000000;stroke-width:1.5919;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 14.180801,2.7578754 3.062213,3.0622128 -3.09249,3.09249 -3.062213,-3.0622128 z"
sodipodi:nodetypes="ccccc" />
<path
id="rect3408"
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.70457;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:stroke fill markers"
d="M 10.509381,6.50938 13.49062,9.49062 6.2504659,16.730773 H 4.5469 l -1.2776741,1.277674 -1.277674,-1.277674 1.2776743,-1.277675 -2e-7,-1.703565 z"
sodipodi:nodetypes="ccccccccc" />
<path
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0.42656;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 4.5429592,14.457038 v 1 h 1.0000004 l 3.0000002,-3 -1.0000003,-1 z"
id="path10295"
sodipodi:nodetypes="cccccc" />
<path
id="rect2678"
style="stroke:#000000;stroke-width:1.19462;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
d="m 10.489409,4.5712428 4.939347,4.939346 c 0.34205,0.3420501 0.471794,0.7630422 0.290907,0.9439292 l -0.163256,0.163255 c -0.180887,0.180888 -0.601879,0.05114 -0.94393,-0.290906 L 9.6731308,5.3875207 C 9.3310811,5.0454711 9.2013366,4.6244786 9.3822238,4.4435912 L 9.5454794,4.2803356 c 0.1808874,-0.180887 0.6018796,-0.051142 0.9439296,0.2909072 z"
sodipodi:nodetypes="sssssssss" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

54
client/icons/player-cursor.svg

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="23"
height="27"
viewBox="0 0 23 27"
version="1.1"
id="svg5"
sodipodi:docname="player-cursor.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="true"
inkscape:zoom="26.910887"
inkscape:cx="10.962106"
inkscape:cy="13.488965"
inkscape:window-width="2558"
inkscape:window-height="1412"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid256"
originx="-4.5000348"
originy="-4.5000845" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#ff0000;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 1.4999652,25.999915 9.9999998,-24.9999995 10,24.9999995 c -9.905078,-3.040471 -10,-3 -19.9999998,0 z"
id="path371"
sodipodi:nodetypes="cccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

57
client/icons/player.svg

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="16"
height="16"
viewBox="0 0 16 16"
version="1.1"
id="svg5"
sodipodi:docname="player.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="true"
inkscape:zoom="53.821773"
inkscape:cx="10.070274"
inkscape:cy="8.3237689"
inkscape:window-width="2558"
inkscape:window-height="1412"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid132" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="path186"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
d="M 11.03125 8.609375 A 4 4 0 0 1 8 10 A 4 4 0 0 1 4.9746094 8.6152344 A 8 8 0 0 0 0 16 L 8 16 L 16 16 A 8 8 0 0 0 11.03125 8.609375 z " />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="circle1073"
cx="8"
cy="4"
r="4" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

54
client/icons/redo.svg

@ -1,54 +0,0 @@ @@ -1,54 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="7.3109999mm"
height="7.8540001mm"
viewBox="0 0 7.3109999 7.8540001"
version="1.1"
id="svg14027"
sodipodi:docname="redo.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview14029"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="26.910887"
inkscape:cx="11.129325"
inkscape:cy="18.7099"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs14024" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-119.35516,-62.667286)">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 125.71554,68.856558 h -2.53401 c -2.08565,0 -2.77743,-0.237506 -2.77743,-1.919928 0,-1.682421 0.59993,-1.919928 2.77743,-1.919928 h 2.53401"
id="path14513"
sodipodi:nodetypes="cszsc" />
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 124.57539,63.936869 1.14015,1.079833 -1.13791,1.077584"
id="path14515"
sodipodi:nodetypes="ccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

53
client/icons/ruler.svg

@ -1,49 +1,6 @@ @@ -1,49 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="27.691999mm"
height="27.691999mm"
viewBox="0 0 27.691999 27.691999"
version="1.1"
id="svg10490"
sodipodi:docname="ruler.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview10492"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="9.5144352"
inkscape:cx="50.922623"
inkscape:cy="49.976692"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs10487" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-71.559908,-125.1583)">
<path
id="path10611"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 90.427939,127.32683 -16.699494,16.6995 6.65543,6.65543 2.08721,-2.08721 1.205669,-1.20567 -2.90901,-2.90901 1.414211,-1.41421 2.90901,2.90901 1.555266,-1.55527 1.206692,-1.20669 -2.90901,-2.90901 1.414008,-1.41401 2.90901,2.90901 1.554079,-1.55408 1.205965,-1.20596 -2.91047,-2.91048 1.414007,-1.414 2.910471,2.91047 1.555178,-1.55518 2.087209,-2.08721 z"
sodipodi:nodetypes="ccccccccccccccccccccc" />
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="27.692mm" height="27.692mm" version="1.1" viewBox="0 0 27.692 27.692" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-71.56 -125.16)">
<path d="m90.428 127.33-16.699 16.7 6.6554 6.6554 3.2929-3.2929-2.909-2.909 1.4142-1.4142 2.909 2.909 2.762-2.762-2.909-2.909 1.414-1.414 2.909 2.909 2.76-2.76-2.9105-2.9105 1.414-1.414 2.9105 2.9105 3.6424-3.6424z" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 494 B

59
client/icons/undo.svg

@ -1,54 +1,7 @@ @@ -1,54 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="7.3109999mm"
height="7.8540001mm"
viewBox="0 0 7.3109999 7.8540001"
version="1.1"
id="svg14027"
sodipodi:docname="undo.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview14029"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="26.910887"
inkscape:cx="11.129325"
inkscape:cy="18.7099"
inkscape:window-width="2558"
inkscape:window-height="1413"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs14024" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-119.35516,-62.667286)">
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 120.4041,68.856558 h 2.53401 c 2.08565,0 2.77743,-0.237506 2.77743,-1.919928 0,-1.682421 -0.59993,-1.919928 -2.77743,-1.919928 h -2.53401"
id="path14513"
sodipodi:nodetypes="cszsc" />
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 121.54425,63.936869 -1.14015,1.079833 1.13791,1.077584"
id="path14515"
sodipodi:nodetypes="ccc" />
</g>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="7.311mm" height="7.854mm" version="1.1" viewBox="0 0 7.311 7.854" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-119.36 -62.667)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round">
<path d="m120.4 68.857h2.534c2.0856 0 2.7774-0.23751 2.7774-1.9199 0-1.6824-0.59993-1.9199-2.7774-1.9199h-2.534"/>
<path d="m121.54 63.937-1.1402 1.0798 1.1379 1.0776"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 457 B

275
client/index.html

@ -3,42 +3,261 @@ @@ -3,42 +3,261 @@
<head>
<meta charset="utf-8">
<title>Desk</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="shortcut icon" href="icons/favicon.svg" id="favicon">
<link rel="stylesheet" type="text/css" href="default.css?v=6">
<link rel="stylesheet" type="text/css" href="touch.css?v=4">
<script type="text/javascript" src="index.js?v=10"></script>
<script type="text/javascript" src="cursor.js?v=5"></script>
<script type="text/javascript" src="touch.js?v=20"></script>
<script type="text/javascript" src="websocket.js?v=6"></script>
<script type="text/javascript" src="send.js?v=5"></script>
<script type="text/javascript" src="recv.js?v=5"></script>
<script type="text/javascript" src="math.js?v=5"></script>
<script type="text/javascript" src="draw.js?v=5"></script>
<script type="text/javascript" src="tools.js?v=6"></script>
<link rel="stylesheet" type="text/css" href="default.css">
<link rel="preload" href="icons/cursor.svg" as="image" type="image/svg+xml" />
<link rel="preload" href="icons/picker.svg" as="image" type="image/svg+xml" />
<script type="text/javascript" src="aux.js"></script>
<script type="text/javascript" src="heapify.js"></script>
<script type="text/javascript" src="bvh.js"></script>
<script type="text/javascript" src="math.js"></script>
<script type="text/javascript" src="tools.js"></script>
<script type="text/javascript" src="speed.js"></script>
<script type="text/javascript" src="webgl_geometry.js"></script>
<script type="text/javascript" src="webgl_shaders.js"></script>
<script type="text/javascript" src="webgl_listeners.js"></script>
<script type="text/javascript" src="webgl_draw.js"></script>
<script type="text/javascript" src="index.js"></script>
<script type="text/javascript" src="client_send.js"></script>
<script type="text/javascript" src="client_recv.js"></script>
<script type="text/javascript" src="websocket.js"></script>
</head>
<body>
<div class="toolbar">
<input type="color" id="brush-color">
<input type="number" min="1" id="brush-width">
</div>
<div class="main">
<canvas id="c"></canvas>
<div class="html-hud"></div>
<div class="brush-dom"></div>
<div class="debug-window dhide">
<div id="debug-stats" class="flexcol"></div>
<div class="debug-timings flexcol">
<div class="cpu"></div>
<div class="gpu"></div>
</div>
<label><input type="checkbox" id="debug-red">Simple shader</label>
<label><input type="checkbox" id="debug-do-prepass">Depth prepass</label>
<div class="flexcol">
<label><input type="checkbox" id="debug-limit-from">Limit events from</label>
<input type="range" min="0" max="0" value="0" id="debug-render-from">
</div>
<div class="flexcol">
<label><input type="checkbox" id="debug-limit-to">Limit events to</label>
<input type="range" min="0" max="0" value="0" id="debug-render-to">
</div>
<button id="debug-begin-benchmark" title="Do not forget to enable recording in your browser!">Benchmark</button>
</div>
<div class="tools-wrapper">
<div class="tools">
<div class="tool" data-tool="pencil"><img draggable="false" src="icons/draw.svg"></div>
<div class="tool" data-tool="ruler"><img draggable="false" src="icons/ruler.svg"></div>
<div class="tool" data-tool="eraser"><img draggable="false" src="icons/erase.svg"></div>
<div class="tool" data-tool="undo"><img draggable="false" src="icons/undo.svg"></div>
<!-- <div class="tool" data-tool="redo"><img draggable="false" src="icons/redo.svg"></div> -->
<div class="picker-preview-outer dhide">
<div class="picker-preview-inner">
</div>
</div>
<div class="top-wrapper">
<div class="topleft"></div>
<div class="sizer">
<input type="range" class="slider" id="stroke-width" min="0.01" step="0.01" max="64">
</div>
<div class="player-list vhide"></div>
</div>
<div class="pallete-wrapper">
<!-- Open Color is MIT licensed: https://github.com/yeun/open-color -->
<div class="pallete">
<div class="color-major" style="border-top-right-radius: var(--radius);">
<div class="color-minor" data-color="f1f3f5"><div class="color-pane" style="background: #f1f3f5;"></div></div>
<div class="color-minor" data-color="e9ecef"><div class="color-pane" style="background: #e9ecef;"></div></div>
<div class="color-minor" data-color="dee2e6"><div class="color-pane" style="background: #dee2e6;"></div></div>
<div class="color-minor" data-color="ced4da"><div class="color-pane" style="background: #ced4da;"></div></div>
<div class="color-minor" data-color="adb5bd"><div class="color-pane" style="background: #adb5bd;"></div></div>
<div class="color-minor" data-color="868e96"><div class="color-pane" style="background: #868e96;"></div></div>
<div class="color-minor" data-color="495057"><div class="color-pane" style="background: #495057;"></div></div>
<div class="color-minor" data-color="343a40"><div class="color-pane" style="background: #343a40;"></div></div>
<div class="color-minor" data-color="212529"><div class="color-pane" style="background: #212529;"></div></div>
<div class="color-minor" data-color="000000"><div class="color-pane" style="background: #000000;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="fff5f5"><div class="color-pane" style="background: #fff5f5;"></div></div>
<div class="color-minor" data-color="ffe3e3"><div class="color-pane" style="background: #ffe3e3;"></div></div>
<div class="color-minor" data-color="ffc9c9"><div class="color-pane" style="background: #ffc9c9;"></div></div>
<div class="color-minor" data-color="ffa8a8"><div class="color-pane" style="background: #ffa8a8;"></div></div>
<div class="color-minor" data-color="ff8787"><div class="color-pane" style="background: #ff8787;"></div></div>
<div class="color-minor" data-color="ff6b6b"><div class="color-pane" style="background: #ff6b6b;"></div></div>
<div class="color-minor" data-color="fa5252"><div class="color-pane" style="background: #fa5252;"></div></div>
<div class="color-minor" data-color="f03e3e"><div class="color-pane" style="background: #f03e3e;"></div></div>
<div class="color-minor" data-color="e03131"><div class="color-pane" style="background: #e03131;"></div></div>
<div class="color-minor" data-color="c92a2a"><div class="color-pane" style="background: #c92a2a;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="fff0f6"><div class="color-pane" style="background: #fff0f6;"></div></div>
<div class="color-minor" data-color="ffdeeb"><div class="color-pane" style="background: #ffdeeb;"></div></div>
<div class="color-minor" data-color="fcc2d7"><div class="color-pane" style="background: #fcc2d7;"></div></div>
<div class="color-minor" data-color="faa2c1"><div class="color-pane" style="background: #faa2c1;"></div></div>
<div class="color-minor" data-color="f783ac"><div class="color-pane" style="background: #f783ac;"></div></div>
<div class="color-minor" data-color="f06595"><div class="color-pane" style="background: #f06595;"></div></div>
<div class="color-minor" data-color="e64980"><div class="color-pane" style="background: #e64980;"></div></div>
<div class="color-minor" data-color="d6336c"><div class="color-pane" style="background: #d6336c;"></div></div>
<div class="color-minor" data-color="c2255c"><div class="color-pane" style="background: #c2255c;"></div></div>
<div class="color-minor" data-color="a61e4d"><div class="color-pane" style="background: #a61e4d;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="f8f0fc"><div class="color-pane" style="background: #f8f0fc;"></div></div>
<div class="color-minor" data-color="f3d9fa"><div class="color-pane" style="background: #f3d9fa;"></div></div>
<div class="color-minor" data-color="eebefa"><div class="color-pane" style="background: #eebefa;"></div></div>
<div class="color-minor" data-color="e599f7"><div class="color-pane" style="background: #e599f7;"></div></div>
<div class="color-minor" data-color="da77f2"><div class="color-pane" style="background: #da77f2;"></div></div>
<div class="color-minor" data-color="cc5de8"><div class="color-pane" style="background: #cc5de8;"></div></div>
<div class="color-minor" data-color="be4bdb"><div class="color-pane" style="background: #be4bdb;"></div></div>
<div class="color-minor" data-color="ae3ec9"><div class="color-pane" style="background: #ae3ec9;"></div></div>
<div class="color-minor" data-color="9c36b5"><div class="color-pane" style="background: #9c36b5;"></div></div>
<div class="color-minor" data-color="862e9c"><div class="color-pane" style="background: #862e9c;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="f3f0ff"><div class="color-pane" style="background: #f3f0ff;"></div></div>
<div class="color-minor" data-color="e5dbff"><div class="color-pane" style="background: #e5dbff;"></div></div>
<div class="color-minor" data-color="d0bfff"><div class="color-pane" style="background: #d0bfff;"></div></div>
<div class="color-minor" data-color="b197fc"><div class="color-pane" style="background: #b197fc;"></div></div>
<div class="color-minor" data-color="9775fa"><div class="color-pane" style="background: #9775fa;"></div></div>
<div class="color-minor" data-color="845ef7"><div class="color-pane" style="background: #845ef7;"></div></div>
<div class="color-minor" data-color="7950f2"><div class="color-pane" style="background: #7950f2;"></div></div>
<div class="color-minor" data-color="7048e8"><div class="color-pane" style="background: #7048e8;"></div></div>
<div class="color-minor" data-color="6741d9"><div class="color-pane" style="background: #6741d9;"></div></div>
<div class="color-minor" data-color="5f3dc4"><div class="color-pane" style="background: #5f3dc4;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="edf2ff"><div class="color-pane" style="background: #edf2ff;"></div></div>
<div class="color-minor" data-color="dbe4ff"><div class="color-pane" style="background: #dbe4ff;"></div></div>
<div class="color-minor" data-color="bac8ff"><div class="color-pane" style="background: #bac8ff;"></div></div>
<div class="color-minor" data-color="91a7ff"><div class="color-pane" style="background: #91a7ff;"></div></div>
<div class="color-minor" data-color="748ffc"><div class="color-pane" style="background: #748ffc;"></div></div>
<div class="color-minor" data-color="5c7cfa"><div class="color-pane" style="background: #5c7cfa;"></div></div>
<div class="color-minor" data-color="4c6ef5"><div class="color-pane" style="background: #4c6ef5;"></div></div>
<div class="color-minor" data-color="4263eb"><div class="color-pane" style="background: #4263eb;"></div></div>
<div class="color-minor" data-color="3b5bdb"><div class="color-pane" style="background: #3b5bdb;"></div></div>
<div class="color-minor" data-color="364fc7"><div class="color-pane" style="background: #364fc7;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="e7f5ff"><div class="color-pane" style="background: #e7f5ff;"></div></div>
<div class="color-minor" data-color="d0ebff"><div class="color-pane" style="background: #d0ebff;"></div></div>
<div class="color-minor" data-color="a5d8ff"><div class="color-pane" style="background: #a5d8ff;"></div></div>
<div class="color-minor" data-color="74c0fc"><div class="color-pane" style="background: #74c0fc;"></div></div>
<div class="color-minor" data-color="4dabf7"><div class="color-pane" style="background: #4dabf7;"></div></div>
<div class="color-minor" data-color="339af0"><div class="color-pane" style="background: #339af0;"></div></div>
<div class="color-minor" data-color="228be6"><div class="color-pane" style="background: #228be6;"></div></div>
<div class="color-minor" data-color="1c7ed6"><div class="color-pane" style="background: #1c7ed6;"></div></div>
<div class="color-minor" data-color="1971c2"><div class="color-pane" style="background: #1971c2;"></div></div>
<div class="color-minor" data-color="1864ab"><div class="color-pane" style="background: #1864ab;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="e3fafc"><div class="color-pane" style="background: #e3fafc;"></div></div>
<div class="color-minor" data-color="c5f6fa"><div class="color-pane" style="background: #c5f6fa;"></div></div>
<div class="color-minor" data-color="99e9f2"><div class="color-pane" style="background: #99e9f2;"></div></div>
<div class="color-minor" data-color="66d9e8"><div class="color-pane" style="background: #66d9e8;"></div></div>
<div class="color-minor" data-color="3bc9db"><div class="color-pane" style="background: #3bc9db;"></div></div>
<div class="color-minor" data-color="22b8cf"><div class="color-pane" style="background: #22b8cf;"></div></div>
<div class="color-minor" data-color="15aabf"><div class="color-pane" style="background: #15aabf;"></div></div>
<div class="color-minor" data-color="1098ad"><div class="color-pane" style="background: #1098ad;"></div></div>
<div class="color-minor" data-color="0c8599"><div class="color-pane" style="background: #0c8599;"></div></div>
<div class="color-minor" data-color="0b7285"><div class="color-pane" style="background: #0b7285;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="e6fcf5"><div class="color-pane" style="background: #e6fcf5;"></div></div>
<div class="color-minor" data-color="c3fae8"><div class="color-pane" style="background: #c3fae8;"></div></div>
<div class="color-minor" data-color="96f2d7"><div class="color-pane" style="background: #96f2d7;"></div></div>
<div class="color-minor" data-color="63e6be"><div class="color-pane" style="background: #63e6be;"></div></div>
<div class="color-minor" data-color="38d9a9"><div class="color-pane" style="background: #38d9a9;"></div></div>
<div class="color-minor" data-color="20c997"><div class="color-pane" style="background: #20c997;"></div></div>
<div class="color-minor" data-color="12b886"><div class="color-pane" style="background: #12b886;"></div></div>
<div class="color-minor" data-color="0ca678"><div class="color-pane" style="background: #0ca678;"></div></div>
<div class="color-minor" data-color="099268"><div class="color-pane" style="background: #099268;"></div></div>
<div class="color-minor" data-color="087f5b"><div class="color-pane" style="background: #087f5b;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="ebfbee"><div class="color-pane" style="background: #ebfbee;"></div></div>
<div class="color-minor" data-color="d3f9d8"><div class="color-pane" style="background: #d3f9d8;"></div></div>
<div class="color-minor" data-color="b2f2bb"><div class="color-pane" style="background: #b2f2bb;"></div></div>
<div class="color-minor" data-color="8ce99a"><div class="color-pane" style="background: #8ce99a;"></div></div>
<div class="color-minor" data-color="69db7c"><div class="color-pane" style="background: #69db7c;"></div></div>
<div class="color-minor" data-color="51cf66"><div class="color-pane" style="background: #51cf66;"></div></div>
<div class="color-minor" data-color="40c057"><div class="color-pane" style="background: #40c057;"></div></div>
<div class="color-minor" data-color="37b24d"><div class="color-pane" style="background: #37b24d;"></div></div>
<div class="color-minor" data-color="2f9e44"><div class="color-pane" style="background: #2f9e44;"></div></div>
<div class="color-minor" data-color="2b8a3e"><div class="color-pane" style="background: #2b8a3e;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="f4fce3"><div class="color-pane" style="background: #f4fce3;"></div></div>
<div class="color-minor" data-color="e9fac8"><div class="color-pane" style="background: #e9fac8;"></div></div>
<div class="color-minor" data-color="d8f5a2"><div class="color-pane" style="background: #d8f5a2;"></div></div>
<div class="color-minor" data-color="c0eb75"><div class="color-pane" style="background: #c0eb75;"></div></div>
<div class="color-minor" data-color="a9e34b"><div class="color-pane" style="background: #a9e34b;"></div></div>
<div class="color-minor" data-color="94d82d"><div class="color-pane" style="background: #94d82d;"></div></div>
<div class="color-minor" data-color="82c91e"><div class="color-pane" style="background: #82c91e;"></div></div>
<div class="color-minor" data-color="74b816"><div class="color-pane" style="background: #74b816;"></div></div>
<div class="color-minor" data-color="66a80f"><div class="color-pane" style="background: #66a80f;"></div></div>
<div class="color-minor" data-color="5c940d"><div class="color-pane" style="background: #5c940d;"></div></div>
</div>
<div class="color-major">
<div class="color-minor" data-color="fff9db"><div class="color-pane" style="background: #fff9db;"></div></div>
<div class="color-minor" data-color="fff3bf"><div class="color-pane" style="background: #fff3bf;"></div></div>
<div class="color-minor" data-color="ffec99"><div class="color-pane" style="background: #ffec99;"></div></div>
<div class="color-minor" data-color="ffe066"><div class="color-pane" style="background: #ffe066;"></div></div>
<div class="color-minor" data-color="ffd43b"><div class="color-pane" style="background: #ffd43b;"></div></div>
<div class="color-minor" data-color="fcc419"><div class="color-pane" style="background: #fcc419;"></div></div>
<div class="color-minor" data-color="fab005"><div class="color-pane" style="background: #fab005;"></div></div>
<div class="color-minor" data-color="f59f00"><div class="color-pane" style="background: #f59f00;"></div></div>
<div class="color-minor" data-color="f08c00"><div class="color-pane" style="background: #f08c00;"></div></div>
<div class="color-minor" data-color="e67700"><div class="color-pane" style="background: #e67700;"></div></div>
</div>
<div class="color-major" style="border-bottom-right-radius: var(--radius);">
<div class="color-minor" data-color="fff4e6"><div class="color-pane" style="background: #fff4e6;"></div></div>
<div class="color-minor" data-color="ffe8cc"><div class="color-pane" style="background: #ffe8cc;"></div></div>
<div class="color-minor" data-color="ffd8a8"><div class="color-pane" style="background: #ffd8a8;"></div></div>
<div class="color-minor" data-color="ffc078"><div class="color-pane" style="background: #ffc078;"></div></div>
<div class="color-minor" data-color="ffa94d"><div class="color-pane" style="background: #ffa94d;"></div></div>
<div class="color-minor" data-color="ff922b"><div class="color-pane" style="background: #ff922b;"></div></div>
<div class="color-minor" data-color="fd7e14"><div class="color-pane" style="background: #fd7e14;"></div></div>
<div class="color-minor" data-color="f76707"><div class="color-pane" style="background: #f76707;"></div></div>
<div class="color-minor" data-color="e8590c"><div class="color-pane" style="background: #e8590c;"></div></div>
<div class="color-minor" data-color="d9480f"><div class="color-pane" style="background: #d9480f;"></div></div>
</div>
</div>
</div>
<div class="tools-wrapper">
<div class="tools">
<div class="tool active" data-tool="pencil"><img draggable="false" src="icons/pen.svg"></div>
<div class="tool" data-tool="ruler"><img draggable="false" src="icons/ruler.svg"></div>
<div class="tool" data-tool="eraser"><img draggable="false" src="icons/erase.svg"></div>
<div class="tool" data-tool="undo"><img draggable="false" src="icons/undo.svg"></div>
<!-- <div class="tool" data-tool="redo"><img draggable="false" src="icons/redo.svg"></div> -->
</div>
<div class="phone-extra-controls">
<img draggable="false" src="icons/cheeseburga.svg">
</div>
</div>
</div>
<div id="brush-preview" class="dhide"></div>
<canvas class="canvas white" id="canvas0"></canvas>
<canvas class="canvas" id="canvas1"></canvas>
<div class="canvas" id="canvas-images"></div>
<div class="offline-toast hidden">
Whiteboard offline
</div>
<div class="loader">
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
<!-- <circle cx="64" cy="64" r="32" fill="none" stroke="black" opacity="0.5" stroke-width="3" stroke-linecap="round"/> -->
<path id="spinner-path" fill="none" stroke="#aaaaaa" stroke-width="3" stroke-linecap="round"/>
</svg>
</div>
<div id="toucher"></div>
</body>
</html>

517
client/index.js

@ -1,16 +1,62 @@ @@ -1,16 +1,62 @@
let ws = null;
let ls = window.localStorage;
document.addEventListener('DOMContentLoaded', main);
const config = {
// ws_url: 'wss://desk.some.website/ws/',
// ping_url: 'https://desk.some.website/api/ping',
// image_url: 'https://desk.some.website/images/',
ws_url: `wss://${window.location.host}/ws/`,
ping_url: `https://${window.location.host}/api/ping`,
image_url: `https://${window.location.host}/images/`,
sync_timeout: 1000,
ws_reconnect_timeout: 2000,
brush_preview_timeout: 1000,
second_finger_timeout: 500,
buffer_first_touchmoves: 5,
debug_print: false,
zoom_delta: 0.05,
min_zoom_level: -250,
max_zoom_level: 100,
initial_offline_timeout: 1000,
default_color: 0x00,
default_width: 8,
bytes_per_instance: 4 * 2 + 4, // axy, stroke_id
bytes_per_stroke: 2 * 3 + 2, // r, g, b, width
initial_static_bytes: 4096 * 16,
initial_dynamic_bytes: 4096,
stroke_texture_size: 1024, // means no more than 1024^2 = 1M strokes in total (this is a LOT. HMH blackboard has like 80K)
dynamic_stroke_texture_size: 128, // means no more than 128^2 = 16K dynamic strokes at once
bvh_fullnode_depth: 5,
pattern_fadeout_min: 0.3,
pattern_fadeout_max: 0.75,
min_pressure: 50,
benchmark: {
zoom_level: -75,
offset: { x: 425, y: -1195 },
frames: 500,
},
};
const EVENT = Object.freeze({
PREDRAW: 10,
SET_COLOR: 11,
SET_WIDTH: 12,
CLEAR: 13, // clear predraw events from me (because I started a pan instead of drawing)
MOVE_CURSOR: 14,
LEAVE: 16,
MOVE_CANVAS: 17,
USER_JOINED: 18,
ZOOM_CANVAS: 19,
STROKE: 20,
RULER: 21, /* gets re-written with EVENT.STROKE before sending to server */
RULER: 21, // gets re-written with EVENT.STROKE before sending to server
UNDO: 30,
REDO: 31,
IMAGE: 40,
IMAGE_MOVE: 41,
ERASER: 50,
});
@ -21,326 +67,229 @@ const MESSAGE = Object.freeze({ @@ -21,326 +67,229 @@ const MESSAGE = Object.freeze({
FULL: 103,
FIRE: 104,
JOIN: 105,
FOLLOW: 106,
});
const config = {
ws_url: 'ws://192.168.100.2/ws/',
image_url: 'http://192.168.100.2/images/',
sync_timeout: 1000,
ws_reconnect_timeout: 2000,
second_finger_timeout: 500,
buffer_first_touchmoves: 5,
debug_print: false,
};
const storage = {
'state': {
'drawing': false,
'moving': false,
'moving_image': false,
'mousedown': false,
'spacedown': false,
},
'moving_image_original_x': null,
'moving_image_original_y': null,
'touch': {
'moves': 0,
'drawing': false,
'moving': false,
'waiting_for_second_finger': false,
'position': { 'x': null, 'y': null },
'screen_position': { 'x': null, 'y': null },
'finger_distance': null,
'buffered': [],
'ids': [],
},
'tools': {
'active': null,
'active_element': null,
},
'ruler_origin': {},
'erased': [],
'predraw': {},
'timers': {},
'me': {},
'sn': 0, // what WE think SERVER SN is (we tell this to the server, it uses to decide how much stuff to SYN to us)
'server_lsn': 0, // what SERVER said LSN is (used to decide how much stuff to SYN)
'lsn': 0, // what actual LSN is (can't just use length of local queue because it gets cleared)
'queue': [], // to server
'events': [], // from server
'current_stroke': [],
'desk_id': 123,
'max_zoom': 4,
'min_zoom': 0.2,
'images': {},
// Source:
// https://stackoverflow.com/a/18473154
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
var angleInRadians = (angleInDegrees-90) * Math.PI / 180.0;
'canvas': {
'zoom': 1,
'width': 1500,
'height': 4000,
'offset_x': 0,
'offset_y': 0,
},
'cursor': {
'width': 8,
'color': 'rgb(0, 0, 0)',
'x': 0,
'y': 0,
}
};
const elements = {
'cursor': null,
'canvas0': null,
'canvas1': null,
'active_image': null,
};
function event_size(event) {
let size = 1 + 1; // type + padding
switch (event.type) {
case EVENT.PREDRAW: {
size += 2 * 2;
break;
}
case EVENT.STROKE: {
size += 4 + 2 + 2 + 4 + event.points.length * 2 * 2; // u32 stroke id + u16 (count) + u16 (width) + u32 (color + count * (u16, u16) points
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
size += 4 + 2 + 2; // file id + x + y
break;
}
case EVENT.ERASER: {
size += 4; // stroke id
break;
}
default: {
console.error('fuck');
}
}
return size;
return {
x: centerX + (radius * Math.cos(angleInRadians)),
y: centerY + (radius * Math.sin(angleInRadians))
};
}
function move_canvas() {
elements.canvas0.style.transform = `translate(${-storage.canvas.offset_x}px, ${-storage.canvas.offset_y}px) scale(${storage.canvas.zoom})`;
elements.canvas1.style.transform = `translate(${-storage.canvas.offset_x}px, ${-storage.canvas.offset_y}px) scale(${storage.canvas.zoom})`;
elements.images.style.transform = `translate(${-storage.canvas.offset_x}px, ${-storage.canvas.offset_y}px) scale(${storage.canvas.zoom})`;
}
function describeArc(x, y, radius, startAngle, endAngle) {
var start = polarToCartesian(x, y, radius, endAngle);
var end = polarToCartesian(x, y, radius, startAngle);
var largeArcFlag = (Math.abs(endAngle - startAngle) % 360) <= 180 ? "0" : "1";
function image_at(x, y) {
let image_hit = null;
for (let i = storage.events.length - 1; i >= 0; --i) {
if (!storage.events[i].deleted && storage.events[i].type === EVENT.IMAGE) {
const event = storage.events[i];
const item = document.querySelector(`img[data-image-id="${event.image_id}"]`);
if (item) {
const left = storage.images[event.image_id].x;
const right = left + item.width;
const top = storage.images[event.image_id].y;
const bottom = top + item.height;
if (left <= x && x <= right && top <= y && y <= bottom) {
return item;
}
}
}
}
var d = [
"M", start.x, start.y,
"A", radius, radius, 0, largeArcFlag, 0, end.x, end.y
].join(" ");
return null;
return d;
}
function activate_image(item) {
if (item === null) {
elements.canvas1.classList.remove('disabled');
if (elements.active_image) {
elements.active_image.classList.remove('activated');
elements.active_image = null;
}
return;
let iii = 0;
let a_angel = 0;
let b_angel = 180;
let speed_a = 2;
let speed_b = 6;
let b_fast = true;
function start_spinner(state) {
const str = describeArc(64, 64, 32, a_angel, b_angel);
4
a_angel += speed_a;
b_angel += speed_b;
const diff = b_angel - a_angel;
if (diff > 320) {
speed_a = 6;
speed_b = 2;
} else if (diff < 40) {
speed_a = 2;
speed_b = 6;
}
elements.canvas1.classList.add('disabled');
// if ((speed_a === 1) && Math.abs(a_angel - b_angel) % 360 < 90) {
// speed_a = 3;
// speed_b = 1;
// } else if (Math.abs(a_angel - b_angel) % 360 > 180) {
// speed_a = 1;
// speed_b = 3;
// }
if (elements.active_image) {
if (elements.active_image === item) {
return;
}
document.querySelector('#spinner-path').setAttribute('d', str);
elements.active_image.classList.remove('activated');
if (!state.online) {
window.requestAnimationFrame(() => start_spinner(state));
} else {
document.querySelector('.loader').classList.add('hidden');
}
elements.active_image = item;
item.classList.add('activated');
}
function predraw_event(x, y) {
return {
'type': EVENT.PREDRAW,
'x': x,
'y': y
};
}
function stroke_event() {
return {
'type': EVENT.STROKE,
'points': storage.current_stroke,
'width': storage.cursor.width,
'color': color_to_u32(storage.cursor.color),
};
}
function ruler_event(x, y) {
const points = [];
async function main() {
const state = {
'online': false,
'me': null,
'canvas': {
'offset': { 'x': 0, 'y': 0 },
'zoom_level': 0,
'zoom': 1.0,
'target_zoom': 1.0,
'zoom_screenp': {'x': 0, 'y': 0},
},
'cursor': {
'x': 0,
'y': 0,
},
'sn': 0,
'lsn': 0,
'server_lsn': 0,
'touch': {
'moves': 0,
'drawing': false,
'moving': false,
'erasing': false,
'waiting_for_second_finger': false,
'first_finger_position': null,
'second_finger_position': null,
'buffered': [],
'ids': [],
},
points.push(predraw_event(storage.ruler_origin.x, storage.ruler_origin.y));
points.push(predraw_event(x, y));
return {
'type': EVENT.RULER,
'points': points,
'width': storage.cursor.width,
'color': color_to_u32(storage.cursor.color),
};
}
function undo_event() {
return { 'type': EVENT.UNDO };
}
function redo_event() {
return { 'type': EVENT.REDO };
}
'moving': false,
'drawing': false,
'spacedown': false,
'colorpicking': false,
'zooming': false,
'zoomdown': false,
function image_event(image_id, x, y) {
return {
'type': EVENT.IMAGE,
'image_id': image_id,
'x': x,
'y': y,
}
}
'moving_image': null,
function image_move_event(image_id, x, y) {
return {
'type': EVENT.IMAGE_MOVE,
'image_id': image_id,
'x': x,
'y': y,
}
}
'current_strokes': {},
function eraser_events() {
const result = [];
'rdp_mask': new Uint8Array(1024),
'rdp_traverse_stack': new Uint32Array(4096),
for (const stroke_id of storage.erased) {
result.push({
'type': EVENT.ERASER,
'stroke_id': stroke_id,
});
}
'queue': [],
'events': [],
'stroke_count': 0,
'starting_index': 0,
'total_points': 0,
return result;
}
'bvh': {
'nodes': [],
'root': null,
'pqueue': new MinQueue(1024),
'traverse_stack': tv_create(Uint32Array, 1024),
},
// Generally doesn't return null
function find_stroke_backwards(stroke_id) {
for (let i = storage.events.length - 1; i >= 0; --i) {
const event = storage.events[i];
if (event.type === EVENT.STROKE && event.stroke_id === stroke_id) {
return event;
}
}
'tools': {
'active': null,
'active_element': null,
},
return null;
}
'colors': {
'active_element': null,
'extended_element': null,
},
function queue_undo() {
const event = undo_event();
queue_event(event);
}
'timers': {
'hide_preview': null,
'offline_toast': null,
'raf': false,
},
function main() {
const url = new URL(window.location.href);
const parts = url.pathname.split('/');
storage.desk_id = parts.length > 0 ? parts[parts.length - 1] : 0;
'players': {},
ws_connect(true);
'debug': {
'red': false,
'do_prepass': true,
'limit_from': false,
'limit_to': false,
'render_from': 0,
'render_to': 0,
},
elements.canvas0 = document.getElementById('canvas0');
elements.canvas1 = document.getElementById('canvas1');
elements.images = document.getElementById('canvas-images');
'rdp_cache': {},
tools_init();
'stats': {},
'following_player': null,
// TODO: remove
elements.brush_color = document.getElementById('brush-color');
elements.brush_width = document.getElementById('brush-width');
elements.brush_preview = document.getElementById('brush-preview');
elements.toucher = document.getElementById('toucher');
'color_picked': null,
elements.brush_color.value = storage.cursor.color;
elements.brush_width.value = storage.cursor.width;
'wasm': {},
update_brush();
'background_pattern': 'grid',
};
storage.canvas.offset_x = window.scrollX;
storage.canvas.offset_y = window.scrollY;
const context = {
'canvas': null,
'gl': null,
'programs': {},
'buffers': {},
'locations': {},
'textures': {},
'images': [],
'dynamic_serializer': serializer_create(config.initial_dynamic_bytes),
'dynamic_index_serializer': serializer_create(config.initial_dynamic_bytes),
'clipped_indices': tv_create(Uint32Array, 4096),
'instance_data_points': tv_create(Float32Array, 4096),
'instance_data_ids': tv_create(Uint32Array, 4096),
'instance_data_pressures': tv_create(Uint8Array, 4096),
'dynamic_instance_points': tv_create(Float32Array, 4096),
'dynamic_instance_pressure': tv_create(Uint8Array, 4096),
'dynamic_instance_ids': tv_create(Uint32Array, 4096),
'stroke_data': serializer_create(config.initial_static_bytes),
'dynamic_stroke_data': serializer_create(config.initial_static_bytes),
'dynamic_stroke_count': 0,
'dynamic_segment_count': 0,
storage.canvas.max_scroll_x = storage.canvas.width - window.innerWidth;
storage.canvas.max_scroll_y = storage.canvas.height - window.innerHeight;
'bgcolor': {'r': 1.0, 'g': 1.0, 'b': 1.0},
storage.ctx0 = elements.canvas0.getContext('2d');
storage.ctx1 = elements.canvas1.getContext('2d');
'gpu_timer_ext': null,
'active_image': null,
'last_frame_ts': 0,
'last_frame_dt': 0,
};
load_player_cursor_template(state);
start_spinner(state);
storage.ctx1.canvas.width = storage.ctx0.canvas.width = storage.canvas.width;
storage.ctx1.canvas.height = storage.ctx0.canvas.height = storage.canvas.height;
const url = new URL(window.location.href);
const parts = url.pathname.split('/');
storage.ctx1.lineJoin = storage.ctx1.lineCap = storage.ctx0.lineJoin = storage.ctx0.lineCap = 'round';
storage.ctx1.lineWidth = storage.ctx0.lineWidth = storage.cursor.width;
state.desk_id = parts.length > 0 ? parts[parts.length - 1] : 0;
elements.toucher.addEventListener('mousedown', on_down)
elements.toucher.addEventListener('mousemove', on_move)
elements.toucher.addEventListener('mouseup', on_up);
await init_wasm(state);
elements.toucher.addEventListener('keydown', on_keydown);
elements.toucher.addEventListener('keyup', on_keyup);
elements.toucher.addEventListener('resize', on_resize);
elements.toucher.addEventListener('contextmenu', cancel);
elements.toucher.addEventListener('wheel', on_wheel);
init_webgl(state, context);
init_listeners(state, context);
init_tools(state);
elements.toucher.addEventListener('touchstart', on_touchstart);
elements.toucher.addEventListener('touchmove', on_touchmove);
elements.toucher.addEventListener('touchend', on_touchend);
elements.toucher.addEventListener('touchcancel', on_touchend);
ws_connect(state, context, true);
elements.brush_color.addEventListener('input', update_brush);
elements.brush_width.addEventListener('input', update_brush);
schedule_draw(state, context);
elements.canvas0.addEventListener('dragover', on_move);
elements.canvas0.addEventListener('drop', on_drop);
elements.canvas0.addEventListener('mouseleave', on_leave);
state.timers.offline_toast = setTimeout(() => ui_offline(), config.initial_offline_timeout);
}

36
client/index.log

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
This is pdfTeX, Version 3.141592653-2.6-1.40.24 (TeX Live 2022/Debian) (preloaded format=pdflatex 2023.4.13) 27 APR 2023 21:39
entering extended mode
restricted \write18 enabled.
%&-line parsing enabled.
**/code/desk2/client/index.js
(/code/desk2/client/index.js
LaTeX2e <2022-11-01> patch level 1
L3 programming layer <2023-01-16>
! LaTeX Error: Missing \begin{document}.
See the LaTeX manual or LaTeX Companion for explanation.
Type H <return> for immediate help.
...
l.1 d
ocument.addEventListener('DOMContentLoaded', main);
?
! Emergency stop.
...
l.1 d
ocument.addEventListener('DOMContentLoaded', main);
You're in trouble here. Try typing <return> to proceed.
If that doesn't work, type X <return> to quit.
Here is how much of TeX's memory you used:
18 strings out of 476091
566 string characters out of 5794081
1849330 words of memory out of 5000000
20499 multiletter control sequences out of 15000+600000
512287 words of font info for 32 fonts, out of 8000000 for 9000
1141 hyphenation exceptions out of 8191
13i,0n,12p,112b,14s stack positions out of 10000i,1000n,20000p,200000b,200000s
! ==> Fatal error occurred, no output PDF file produced!

47
client/lod_worker.js

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
let thread_id = null;
let exports = null;
async function init(tid, memory, heap_base) {
thread_id = tid;
const result = await WebAssembly.instantiateStreaming(fetch('wasm/lod.wasm'), {
env: { 'memory': memory }
});
exports = result.instance.exports;
exports.set_sp(heap_base - thread_id * 16 * 4096); // 64K stack
postMessage({ 'type': 'init_done' });
}
function work(indices_base, indices_count, zoom, offsets) {
try {
exports.do_lod(
indices_base, indices_count, zoom,
offsets['coords_from'],
offsets['xs'],
offsets['ys'],
offsets['pressures'],
offsets['result_buffers'] + thread_id * 4,
offsets['result_counts'] + thread_id * 4,
);
} catch (e) {
console.error('WASM:', e);
}
postMessage({ 'type': 'lod_done' });
}
onmessage = (e) => {
const d = e.data;
if (d.type === 'init') {
init(d.thread_id, d.memory, d.heap_base);
} else if (d.type === 'lod') {
work(d.indices_base, d.indices_count, d.zoom, d.offsets);
} else {
console.error('unexpected worker command:', d.type);
}
}

452
client/math.js

@ -1,5 +1,102 @@ @@ -1,5 +1,102 @@
function rdp_find_max(points, start, end) {
const EPS = 0.5;
function round_to_pow2(value, multiple) {
return (value + multiple - 1) & -multiple;
}
function screen_to_canvas(state, p) {
// should be called with coordinates obtained from MouseEvent.clientX/clientY * window.devicePixelRatio
const xc = (p.x - state.canvas.offset.x) / state.canvas.zoom;
const yc = (p.y - state.canvas.offset.y) / state.canvas.zoom;
return {'x': xc, 'y': yc};
}
function canvas_to_screen(state, p) {
const xs = (p.x * state.canvas.zoom + state.canvas.offset.x) / window.devicePixelRatio;
const ys = (p.y * state.canvas.zoom + state.canvas.offset.y) / window.devicePixelRatio;
return {'x': xs, 'y': ys};
}
function process_rdp_indices_r(state, zoom, mask, stroke, start, end) {
// Looks like the recursive implementation spends most of its time in the function call overhead
// Let's try to use an explicit stack instead to give the js engine more room to play with
// Update: it's not faster. But it gives more sensible source-line samples in chrome profiler, so I'll leave it
let result = 0;
const stack = [];
stack.push({'start': start, 'end': end});
while (stack.length > 0) {
const region = stack.pop();
const max = rdp_find_max(state, zoom, stroke.coords_from, region.start, region.end);
if (max !== -1) {
mask[max] = 1;
result += 1;
stack.push({'start': region.start, 'end': max});
stack.push({'start': max, 'end': region.end});
}
}
return result;
}
function process_rdp_indices(state, zoom, stroke) {
const point_count = stroke.coords_to - stroke.coords_from;
if (state.rdp_mask.length < point_count) {
state.rdp_mask = new Uint8Array(point_count);
}
state.rdp_mask.fill(0, 0, point_count);
const mask = state.rdp_mask;
const npoints = 2 + process_rdp_indices_r(state, zoom, mask, stroke, 0, point_count - 1); // 2 is for the first and last vertex, which do not get included by the recursive functions, but should always be there at any lod level
mask[0] = 1;
mask[point_count - 1] = 1;
return npoints;
}
function exponential_smoothing(points, last, up_to) {
const alpha = 0.5;
let pr = 0;
let start = points.length - up_to;
if (start < 0) {
start = 0;
}
for (let i = start; i < points.length; ++i) {
const p = points[i];
pr = alpha * p.pressure + (1 - alpha) * pr;
}
pr = alpha * last.pressure + (1 - alpha) * pr;
return pr;
}
function process_stroke(state, zoom, stroke) {
// Try caching the highest zoom level that only returns the endpoints
if (zoom <= stroke.turns_into_straight_line_zoom) {
return 2;
}
const npoints = process_rdp_indices(state, zoom, stroke, true);
if (npoints === 2 && zoom > stroke.turns_into_straight_line_zoom) {
stroke.turns_into_straight_line_zoom = zoom;
}
return npoints;
}
function rdp_find_max2(zoom, points, start, end) {
const EPS = 0.125 / zoom;
let result = -1;
let max_dist = 0;
@ -14,7 +111,7 @@ function rdp_find_max(points, start, end) { @@ -14,7 +111,7 @@ function rdp_find_max(points, start, end) {
const sin_theta = dy / dist_ab;
const cos_theta = dx / dist_ab;
for (let i = start; i < end; ++i) {
for (let i = start + 1; i < end; ++i) {
const p = points[i];
const ox = p.x - a.x;
@ -26,7 +123,7 @@ function rdp_find_max(points, start, end) { @@ -26,7 +123,7 @@ function rdp_find_max(points, start, end) {
const x = rx + a.x;
const y = ry + a.y;
const dist = Math.abs(y - a.y);
const dist = Math.abs(y - a.y) + Math.abs(a.pressure - p.pressure) / 255 + Math.abs(b.pressure - p.pressure) / 255;
if (dist > EPS && dist > max_dist) {
result = i;
@ -37,125 +134,98 @@ function rdp_find_max(points, start, end) { @@ -37,125 +134,98 @@ function rdp_find_max(points, start, end) {
return result;
}
function process_rdp_r(points, start, end) {
function process_rdp_r2(zoom, points, start, end) {
let result = [];
const max = rdp_find_max(points, start, end);
const max = rdp_find_max2(zoom, points, start, end);
if (max !== -1) {
const before = process_rdp_r(points, start, max);
const after = process_rdp_r(points, max, end);
const before = process_rdp_r2(zoom, points, start, max);
const after = process_rdp_r2(zoom, points, max, end);
result = [...before, points[max], ...after];
}
return result;
}
function process_rdp(points) {
const result = process_rdp_r(points, 0, points.length - 1);
result.unshift(points[0]);
result.push(points[points.length - 1]);
return result;
}
function process_ewmv(points, round = false) {
function process_rdp2(zoom, points) {
const result = [];
const alpha = 0.4;
const stack = [];
stack.push({
'type': 0,
'start': 0,
'end': points.length - 1,
});
result.push(points[0]);
for (let i = 1; i < points.length; ++i) {
const p = points[i];
const x = Math.round(alpha * p.x + (1 - alpha) * result[result.length - 1].x);
const y = Math.round(alpha * p.y + (1 - alpha) * result[result.length - 1].y);
result.push({'x': x, 'y': y});
while (stack.length > 0) {
const entry = stack.pop();
if (entry.type === 0) {
const max = rdp_find_max2(zoom, points, entry.start, entry.end);
if (max !== -1) {
stack.push({
'type': 0,
'start': max,
'end': entry.end
});
stack.push({
'type': 1,
'index': max,
});
stack.push({
'type': 0,
'start': entry.start,
'end': max,
});
}
} else {
result.push(points[entry.index]);
}
}
result.push(points[points.length - 1]);
return result;
}
function process_stroke(points) {
const result0 = process_ewmv(points);
const result1 = process_rdp(result0, true);
return result1;
// TODO: unify with regular process stroke
function process_stroke2(zoom, points) {
const result = process_rdp2(zoom, points);
return result;
}
function stroke_stats(points, width) {
if (points.length === 0) {
const bbox = {
'xmin': 0,
'ymin': 0,
'xmax': 0,
'ymax': 0
};
return {
'bbox': bbox,
'length': 0,
};
}
let length = 0;
let xmin = points[0].x, ymin = points[0].y;
let xmax = xmin, ymax = ymin;
function strokes_intersect_line(state, a, b) {
// TODO: handle stroke / eraser width
const result = [];
for (let i = 0; i < points.length; ++i) {
const point = points[i];
if (point.x < xmin) xmin = point.x;
if (point.y < ymin) ymin = point.y;
if (point.x > xmax) xmax = point.x;
if (point.y > ymax) ymax = point.y;
for (let i = 0; i < state.events.length; ++i) {
const event = state.events[i];
if (event.type === EVENT.STROKE && !event.deleted) {
for (let i = 0; i < event.points.length - 1; ++i) {
const c = event.points[i + 0];
const d = event.points[i + 1];
if (i > 0) {
const last = points[i - 1];
const dx = point.x - last.x;
const dy = point.y - last.y;
length += Math.sqrt(dx * dx + dy * dy);
if (segments_intersect(a, b, c, d)) {
result.push(i);
break;
}
}
}
}
xmin -= width;
ymin -= width;
xmax += width * 2;
ymax += width * 2;
const bbox = {
'xmin': Math.floor(xmin),
'ymin': Math.floor(ymin),
'xmax': Math.ceil(xmax),
'ymax': Math.ceil(ymax)
};
return {
'bbox': bbox,
'length': length,
};
}
function rectangles_intersect(a, b) {
const result = (
a.xmin <= b.xmax
&& a.xmax >= b.xmin
&& a.ymin <= b.ymax
&& a.ymax >= b.ymin
);
return result;
}
function stroke_intersects_region(points, bbox) {
if (points.length === 0) {
return false;
}
const stats = stroke_stats(points, storage.cursor.width);
return rectangles_intersect(stats.bbox, bbox);
}
function color_to_u32(color_str) {
const r = parseInt(color_str.substring(1, 3), 16);
const g = parseInt(color_str.substring(3, 5), 16);
const b = parseInt(color_str.substring(5, 7), 16);
const r = parseInt(color_str.substring(0, 2), 16);
const g = parseInt(color_str.substring(2, 4), 16);
const b = parseInt(color_str.substring(4, 6), 16);
return (r << 16) | (g << 8) | b;
}
@ -176,6 +246,22 @@ function color_from_u32(color_u32) { @@ -176,6 +246,22 @@ function color_from_u32(color_u32) {
return '#' + r_str + g_str + b_str;
}
function color_from_rgbdict(color_dict) {
const r = Math.floor(color_dict.r * 255);
const g = Math.floor(color_dict.g * 255);
const b = Math.floor(color_dict.b * 255);
let r_str = r.toString(16);
let g_str = g.toString(16);
let b_str = b.toString(16);
if (r <= 0xF) r_str = '0' + r_str;
if (g <= 0xF) g_str = '0' + g_str;
if (b <= 0xF) b_str = '0' + b_str;
return '#' + r_str + g_str + b_str;
}
function ccw(A, B, C) {
return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x);
}
@ -185,41 +271,165 @@ function segments_intersect(A, B, C, D) { @@ -185,41 +271,165 @@ function segments_intersect(A, B, C, D) {
return ccw(A, C, D) != ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
}
function strokes_intersect_line(x1, y1, x2, y2) {
const result = [];
function dist_v2(a, b) {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
for (const event of storage.events) {
if (event.type === EVENT.STROKE && !event.deleted) {
if (event.points.length < 2) {
continue;
}
function mid_v2(a, b) {
return {
'x': (a.x + b.x) / 2.0,
'y': (a.y + b.y) / 2.0,
};
}
for (let i = 0; i < event.points.length - 1; ++i) {
const sx1 = event.points[i].x;
const sy1 = event.points[i].y;
function point_in_quad(p, quad_topleft, quad_bottomright) {
if ((quad_topleft.x <= p.x && p.x < quad_bottomright.x) && (quad_topleft.y <= p.y && p.y < quad_bottomright.y)) {
return true;
}
const sx2 = event.points[i + 1].x;
const sy2 = event.points[i + 1].y;
return false;
}
const A = {'x': x1, 'y': y1};
const B = {'x': x2, 'y': y2};
function point_in_bbox(p, bbox) {
if (bbox.x1 <= p.x && p.x < bbox.x2 && bbox.y1 <= p.y && p.y < bbox.y2) {
return true;
}
const C = {'x': sx1, 'y': sy1};
const D = {'x': sx2, 'y': sy2};
return false;
}
if (segments_intersect(A, B, C, D)) {
result.push(event.stroke_id);
break;
}
}
function clamp(v, a, b) {
return (v < a ? a : (v > b ? b : v));
}
function dot(a, b) {
return a.x * b.x + a.y * b.y;
}
function mix(a, b, t) {
return a * t + b * (1 - t);
}
function point_in_stroke(p, xs, ys, pressures, width) {
for (let i = 0; i < xs.length - 1; ++i) {
const ax = xs[i + 0];
const bx = xs[i + 1];
const ay = ys[i + 0];
const by = ys[i + 1];
const at = pressures[i + 0] / 255 * width;
const bt = pressures[i + 1] / 255 * width;
const pa = {
'x': p.x - ax,
'y': p.y - ay,
};
const ba = {
'x': bx - ax,
'y': by - ay,
};
const h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
const thickness = mix(at, bt, h);
const v = {
'x': p.x - (ax + ba.x * h),
'y': p.y - (ay + ba.y * h),
};
const dist = Math.sqrt(dot(v, v)) - thickness;
if (dist <= 0) {
return true;
}
}
return result;
return false;
}
function dist_v2(a, b) {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
function segment_interesects_quad(a, b, quad_topleft, quad_bottomright, quad_topright, quad_bottomleft) {
if (point_in_quad(a, quad_topleft, quad_bottomright)) {
return true;
}
if (point_in_quad(b, quad_topleft, quad_bottomright)) {
return true;
}
if (segments_intersect(a, b, quad_topleft, quad_topright)) return true;
if (segments_intersect(a, b, quad_topright, quad_bottomright)) return true;
if (segments_intersect(a, b, quad_bottomright, quad_bottomleft)) return true;
if (segments_intersect(a, b, quad_bottomleft, quad_topleft)) return true;
return false;
}
function stroke_bbox(state, stroke) {
const radius = stroke.width; // do not divide by 2 to account for max possible pressure
const xs = state.wasm.buffers['xs'].tv.data;
const ys = state.wasm.buffers['ys'].tv.data;
let min_x = xs[stroke.coords_from] - radius;
let max_x = xs[stroke.coords_from] + radius;
let min_y = ys[stroke.coords_from] - radius;
let max_y = ys[stroke.coords_from] + radius;
for (let i = stroke.coords_from + 1; i < stroke.coords_to; ++i) {
const px = xs[i];
const py = ys[i];
min_x = Math.min(min_x, px - radius);
min_y = Math.min(min_y, py - radius);
max_x = Math.max(max_x, px + radius);
max_y = Math.max(max_y, py + radius);
}
return {'x1': min_x, 'y1': min_y, 'x2': max_x, 'y2': max_y, 'cx': (max_x + min_x) / 2, 'cy': (max_y + min_y) / 2};
}
function quads_intersect(a, b) {
if (a.x1 < b.x2 && a.x2 > b.x1 && a.y2 > b.y1 && a.y1 < b.y2) {
return true;
}
return false;
}
function quad_fully_inside(outer, inner) {
if (outer.x1 < inner.x1 && outer.x2 > inner.x2 && outer.y1 < inner.y1 && outer.y2 > inner.y2) {
return true;
}
return false;
}
function quad_union(a, b) {
return {
'x1': Math.min(a.x1, b.x1),
'y1': Math.min(a.y1, b.y1),
'x2': Math.max(a.x2, b.x2),
'y2': Math.max(a.y2, b.y2),
};
}
function box_area(box) {
return (box.x2 - box.x1) * (box.y2 - box.y1);
}
// https://stackoverflow.com/a/47593316
function mulberry32(seed) {
let t = seed + 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
function random_bright_color_from_seed(seed) {
const h = Math.round(mulberry32(seed) * 360);
const s = 25;
const l = 50;
return `hsl(${h}deg ${s}% ${l}%)`;
}

345
client/recv.js

@ -1,345 +0,0 @@ @@ -1,345 +0,0 @@
function deserializer_create(buffer, dataview) {
return {
'offset': 0,
'size': buffer.byteLength,
'buffer': buffer,
'view': dataview,
'strview': new Uint8Array(buffer),
};
}
function des_u8(d) {
const value = d.view.getUint8(d.offset);
d.offset += 1;
return value;
}
function des_u16(d) {
const value = d.view.getUint16(d.offset, true);
d.offset += 2;
return value;
}
function des_s16(d) {
const value = d.view.getInt16(d.offset, true);
d.offset += 2;
return value;
}
function des_u32(d) {
const value = d.view.getUint32(d.offset, true);
d.offset += 4;
return value;
}
function des_u16array(d, count) {
const result = [];
for (let i = 0; i < count; ++i) {
const item = d.view.getUint16(d.offset, true);
d.offset += 2;
result.push(item);
}
return result;
}
function des_event(d) {
const event = {};
event.type = des_u8(d);
event.user_id = des_u32(d);
switch (event.type) {
case EVENT.PREDRAW: {
event.x = des_u16(d);
event.y = des_u16(d);
break;
}
case EVENT.STROKE: {
const stroke_id = des_u32(d);
const point_count = des_u16(d);
const width = des_u16(d);
const color = des_u32(d);
const coords = des_u16array(d, point_count * 2);
event.stroke_id = stroke_id;
event.points = [];
for (let i = 0; i < point_count; ++i) {
const x = coords[2 * i + 0];
const y = coords[2 * i + 1];
event.points.push({'x': x, 'y': y});
}
event.color = color;
event.width = width;
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
event.image_id = des_u32(d);
event.x = des_s16(d); // stored as u16, but actually is s16
event.y = des_s16(d); // stored as u16, but actually is s16
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.ERASER: {
event.stroke_id = des_u32(d);
break;
}
default: {
console.error('fuck');
}
}
return event;
}
function bitmap_bbox(event) {
const x = (event.x <= storage.canvas.width ? event.x : event.x - 65536);
const y = (event.y <= storage.canvas.height ? event.y : event.y - 65536);
const bbox = {
'xmin': x,
'xmax': x + event.bitmap.width,
'ymin': y,
'ymax': y + event.bitmap.height
};
return bbox;
}
async function handle_event(event) {
if (config.debug_print) console.debug(`event type ${event.type} from user ${event.user_id}`);
// TODO(@speed): do not handle locally predicted events
switch (event.type) {
case EVENT.STROKE: {
if (event.user_id in storage.predraw || event.user_id === storage.me.id) {
storage.predraw[event.user_id] = [];
redraw_predraw();
}
draw_stroke(event);
break;
}
case EVENT.UNDO: {
for (let i = storage.events.length - 1; i >=0; --i) {
const other_event = storage.events[i];
// Users can only undo their own, undeleted (not already undone) events
if (other_event.user_id === event.user_id && !other_event.deleted) {
if (other_event.type === EVENT.STROKE) {
other_event.deleted = true;
const stats = stroke_stats(other_event.points, storage.cursor.width);
redraw_region(stats.bbox);
break;
} else if (other_event.type === EVENT.IMAGE) {
other_event.deleted = true;
const item = document.querySelector(`img[data-image-id="${other_event.image_id}"]`);
if (item) item.remove();
break;
} else if (other_event.type === EVENT.ERASER) {
other_event.deleted = true;
const erased = find_stroke_backwards(other_event.stroke_id);
if (erased) {
erased.deleted = false;
const stats = stroke_stats(erased.points, storage.cursor.width);
redraw_region(stats.bbox);
}
break;
} else if (other_event.type === EVENT.IMAGE_MOVE) {
const item = document.querySelector(`img[data-image-id="${other_event.image_id}"]`);
const ix = storage.images[other_event.image_id].x -= other_event.x;
const iy = storage.images[other_event.image_id].y -= other_event.y;
item.style.transform = `translate(${ix}px, ${iy}px)`;
break;
}
}
}
break;
}
case EVENT.IMAGE: {
const url = config.image_url + event.image_id;
const item = document.createElement('img');
item.classList.add('floating-image');
item.style['z-index'] = storage.events.length;
item.setAttribute('data-image-id', event.image_id);
item.setAttribute('src', url);
item.style.transform = `translate(${event.x}px, ${event.y}px)`;
elements.images.appendChild(item);
storage.images[event.image_id] = {
'x': event.x, 'y': event.y
};
// const r = await fetch(config.image_url + event.image_id);
// const blob = await r.blob();
// const bitmap = await createImageBitmap(blob);
// event.bitmap = bitmap;
// const bbox = bitmap_bbox(event);
// storage.ctx0.drawImage(bitmap, bbox.xmin, bbox.ymin);
break;
}
case EVENT.IMAGE_MOVE: {
// Already moved due to local prediction
if (event.user_id !== storage.me.id) {
const image_id = event.image_id;
const item = document.querySelector(`.floating-image[data-image-id="${image_id}"]`);
const ix = storage.images[event.image_id].x += event.x;
const iy = storage.images[event.image_id].y += event.y;
if (item) {
item.style.transform = `translate(${ix}px, ${iy}px)`;
}
}
break;
}
case EVENT.ERASER: {
if (event.deleted) {
break;
}
for (const other_event of storage.events) {
if (other_event.type === EVENT.STROKE && other_event.stroke_id === event.stroke_id) {
// Might already be deleted because of local prediction
if (!other_event.deleted) {
other_event.deleted = true;
const stats = stroke_stats(other_event.points, storage.cursor.width);
redraw_region(stats.bbox);
}
break;
}
}
break;
}
default: {
console.error('fuck');
}
}
}
async function handle_message(d) {
const message_type = des_u8(d);
if (config.debug_print) console.debug(message_type);
switch (message_type) {
case MESSAGE.JOIN:
case MESSAGE.INIT: {
elements.canvas0.classList.add('white');
storage.me.id = des_u32(d);
storage.server_lsn = des_u32(d);
if (storage.server_lsn > storage.lsn) {
// Server knows something that we don't
storage.lsn = storage.server_lsn;
}
if (message_type === MESSAGE.JOIN) {
ls.setItem('sessionId', des_u32(d));
if (config.debug_print) console.debug('join in');
} else {
if (config.debug_print) console.debug('init in');
}
const event_count = des_u32(d);
if (config.debug_print) console.debug(`${event_count} events in init`);
storage.ctx0.clearRect(0, 0, storage.ctx0.canvas.width, storage.ctx0.canvas.height);
elements.images.innerHTML = '';
storage.events.length = 0;
for (let i = 0; i < event_count; ++i) {
const event = des_event(d);
await handle_event(event);
storage.events.push(event);
}
elements.canvas0.classList.remove('white');
send_ack(event_count);
sync_queue();
break;
}
case MESSAGE.FIRE: {
const user_id = des_u32(d);
const predraw_event = des_event(d);
predraw_user(user_id, predraw_event);
break;
}
case MESSAGE.ACK: {
const lsn = des_u32(d);
if (config.debug_print) console.debug(`ack ${lsn} in`);
if (lsn > storage.server_lsn) {
// ACKs may arrive out of order
storage.server_lsn = lsn;
}
break;
}
case MESSAGE.SYN: {
const sn = des_u32(d);
const count = des_u32(d);
const we_expect = sn - storage.sn;
const first = count - we_expect;
if (config.debug_print) console.debug(`syn ${sn} in`);
for (let i = 0; i < count; ++i) {
const event = des_event(d);
if (i >= first) {
handle_event(event);
storage.events.push(event);
}
}
storage.sn = sn;
await send_ack(sn);
break;
}
default: {
console.error('fuck');
return;
}
}
}

201
client/send.js

@ -1,201 +0,0 @@ @@ -1,201 +0,0 @@
function serializer_create(size) {
const buffer = new ArrayBuffer(size);
return {
'offset': 0,
'size': size,
'buffer': buffer,
'view': new DataView(buffer),
'strview': new Uint8Array(buffer),
};
}
function ser_u8(s, value) {
s.view.setUint8(s.offset, value);
s.offset += 1;
}
function ser_u16(s, value) {
s.view.setUint16(s.offset, value, true);
s.offset += 2;
}
function ser_u32(s, value) {
s.view.setUint32(s.offset, value, true);
s.offset += 4;
}
function ser_event(s, event) {
ser_u8(s, event.type);
ser_u8(s, 0); // padding for 16bit alignment
switch (event.type) {
case EVENT.PREDRAW: {
ser_u16(s, event.x);
ser_u16(s, event.y);
break;
}
case EVENT.STROKE: {
ser_u16(s, event.points.length);
ser_u16(s, event.width);
ser_u32(s, event.color);
if (config.debug_print) console.debug('original', event.points);
for (const point of event.points) {
ser_u16(s, point.x);
ser_u16(s, point.y);
}
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
const image_id = parseInt(event.image_id);
ser_u32(s, image_id);
ser_u16(s, event.x);
ser_u16(s, event.y);
break;
}
case EVENT.UNDO:
case EVENT.REDO: {
break;
}
case EVENT.ERASER: {
ser_u32(s, event.stroke_id);
break;
}
default: {
console.error('fuck');
}
}
}
async function send_ack(sn) {
const s = serializer_create(1 + 4);
ser_u8(s, MESSAGE.ACK);
ser_u32(s, sn);
if (config.debug_print) console.debug(`ack ${sn} out`);
try {
if (ws) await ws.send(s.buffer);
} catch(e) {
ws.close();
}
}
async function sync_queue() {
if (ws === null) {
if (config.debug_print) console.debug('socket has closed, stopping SYNs');
return;
}
let size = 1 + 1 + 4 + 4; // opcode + lsn + event count
let count = storage.lsn - storage.server_lsn;
if (count === 0) {
if (config.debug_print) console.debug('server ACKed all events, clearing queue');
storage.queue.length = 0;
return;
}
for (let i = count - 1; i >= 0; --i) {
const event = storage.queue[storage.queue.length - 1 - i];
size += event_size(event);
}
const s = serializer_create(size);
ser_u8(s, MESSAGE.SYN);
ser_u8(s, 0); // padding for 16bit alignment
ser_u32(s, storage.lsn);
ser_u32(s, count);
for (let i = count - 1; i >= 0; --i) {
const event = storage.queue[storage.queue.length - 1 - i];
ser_event(s, event);
}
if (config.debug_print) console.debug(`syn ${storage.lsn} out`);
try {
if (ws) await ws.send(s.buffer);
} catch(e) {
ws.close();
}
setTimeout(sync_queue, config.sync_timeout);
}
function push_event(event) {
storage.lsn += 1;
switch (event.type) {
case EVENT.STROKE: {
const points = process_stroke(event.points);
storage.queue.push({
'type': EVENT.STROKE,
'points': points,
'width': event.width,
'color': event.color,
});
break;
}
case EVENT.RULER: {
event.type = EVENT.STROKE;
storage.queue.push(event);
break;
}
case EVENT.ERASER:
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE:
case EVENT.UNDO:
case EVENT.REDO: {
storage.queue.push(event);
break;
}
default: {
console.error('fuck');
}
}
}
// Queue an event and initialize repated sends until ACKed
function queue_event(event, skip = false) {
push_event(event);
if (skip) {
return;
}
if (storage.timers.queue_sync) {
clearTimeout(storage.timers.queue_sync);
}
sync_queue();
}
// Fire and forget. Doesn't do anything if we are offline
async function fire_event(event) {
const s = serializer_create(1 + event_size(event));
ser_u8(s, MESSAGE.FIRE);
ser_event(s, event);
try {
if (ws) await ws.send(s.buffer);
} catch(e) {
ws.close();
}
}

231
client/speed.js

@ -0,0 +1,231 @@ @@ -0,0 +1,231 @@
function worker_message(worker, message) {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data);
worker.postMessage(message);
});
}
function workers_messages(workers, messages) {
const promises = [];
for (let i = 0; i < workers.length; ++i) {
promises.push(worker_message(workers[i], messages[i]));
}
return Promise.all(promises);
}
function workers_thread_message(workers, message, thread_field=null) {
const messages = [];
for (let i = 0; i < workers.length; ++i) {
if (thread_field !== null) {
const m = structuredClone(message);
m[thread_field] = i;
messages.push(m);
} else {
messages.push(message);
}
}
return workers_messages(workers, messages);
}
async function init_wasm(state) {
const memory = new WebAssembly.Memory({
initial: 32, // 2MiB, 1MiB of which is stack
maximum: 16384, // 1GiB
shared: true,
});
// "Master thread" to do maintance on (static allocations, merging results etc)
const master_wasm = await WebAssembly.instantiateStreaming(fetch('wasm/lod.wasm'), {
env: { 'memory': memory }
});
const nworkers = navigator.hardwareConcurrency;
state.wasm.exports = master_wasm.instance.exports;
state.wasm.heap_base = state.wasm.exports.alloc_static(0);
state.wasm.workers = [];
state.wasm.memory = memory;
for (let i = 0; i < nworkers; ++i) {
const w = new Worker('lod_worker.js');
state.wasm.workers.push(w);
}
await workers_thread_message(state.wasm.workers, {
'type': 'init',
'heap_base': state.wasm.heap_base,
'memory': memory,
}, 'thread_id');
state.wasm.stroke_bytes = 4096;
state.wasm.coords_bytes = 4096;
state.wasm.buffers = {
'xs': {
'offset': state.wasm.exports.alloc_static(state.wasm.coords_bytes / 2),
'used': 0
},
'ys': {
'offset': state.wasm.exports.alloc_static(state.wasm.coords_bytes / 2),
'used': 0
},
'coords_from': {
'offset': state.wasm.exports.alloc_static(state.wasm.stroke_bytes),
'used': 0,
},
'pressures': {
'offset': state.wasm.exports.alloc_static(state.wasm.coords_bytes / 8),
'used': 0
},
};
const mem = state.wasm.memory.buffer;
state.wasm.buffers['xs'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 8,
mem, state.wasm.buffers['xs'].offset);
state.wasm.buffers['ys'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 8,
mem, state.wasm.buffers['ys'].offset);
state.wasm.buffers['coords_from'].tv = tv_create_on(Uint32Array, state.wasm.stroke_bytes / 4,
mem, state.wasm.buffers['coords_from'].offset);
state.wasm.buffers['pressures'].tv = tv_create_on(Uint8Array, state.wasm.coords_bytes / 8,
mem, state.wasm.buffers['pressures'].offset);
tv_add(state.wasm.buffers['coords_from'].tv, 0);
state.wasm.buffers['coords_from'].used = 4;
}
function wasm_ensure_by(state, nstrokes, ncoords) {
const buffers = state.wasm.buffers;
const old_coords_from_offset = buffers['coords_from'].offset;
const old_pressures_offset = buffers['pressures'].offset;
const old_size_coords = state.wasm.coords_bytes;
const old_size_strokes = state.wasm.stroke_bytes;
let realloc = false;
if (buffers['xs'].used + ncoords * 4 > state.wasm.coords_bytes / 2) {
state.wasm.coords_bytes += round_to_pow2(ncoords * 4, 4096 * 16); // 1 wasm page (although it doesn't matter here)
realloc = true;
}
if (buffers['coords_from'].used + nstrokes * 4 > state.wasm.stroke_bytes / 2) {
state.wasm.stroke_bytes += round_to_pow2(nstrokes * 4, 4096 * 16);
realloc = true;
}
if (realloc) {
const current_pages = Math.ceil(state.wasm.memory.buffer.byteLength / (4096 * 16));
const need_pages = 2 * Math.ceil((state.wasm.coords_bytes * 3 + state.wasm.stroke_bytes * 2) / (4096 * 16)); // TODO: figure out actual memory requirements
const grow_by = Math.max(1, need_pages - current_pages);
// const grow_by = 16;
state.wasm.memory.grow(grow_by);
state.wasm.exports.free_static();
const mem = state.wasm.memory.buffer;
const memv = new Uint8Array(mem);
buffers['xs'].offset = state.wasm.exports.alloc_static(state.wasm.coords_bytes / 2);
buffers['ys'].offset = state.wasm.exports.alloc_static(state.wasm.coords_bytes / 2);
buffers['coords_from'].offset = state.wasm.exports.alloc_static(state.wasm.stroke_bytes);
buffers['pressures'].offset = state.wasm.exports.alloc_static(state.wasm.coords_bytes / 8);
buffers['xs'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 8, mem, buffers['xs'].offset);
buffers['ys'].tv = tv_create_on(Float32Array, state.wasm.coords_bytes / 8, mem, buffers['ys'].offset);
buffers['coords_from'].tv = tv_create_on(Uint32Array, state.wasm.stroke_bytes / 4, mem, buffers['coords_from'].offset);
buffers['pressures'].tv = tv_create_on(Uint8Array, state.wasm.coords_bytes / 8, mem, buffers['pressures'].offset);
// TODO: this should have been automatic maybe?
buffers['xs'].tv.size = buffers['xs'].used / 4;
buffers['ys'].tv.size = buffers['ys'].used / 4;
buffers['coords_from'].tv.size = buffers['coords_from'].used / 4;
buffers['pressures'].tv.size = buffers['pressures'].used;
const tmp = new Uint8Array(Math.max(state.wasm.coords_bytes, state.wasm.stroke_bytes)); // TODO: needed?
// Copy from back to front (otherwise we will overwrite)
tmp.set(new Uint8Array(mem, old_pressures_offset, buffers['pressures'].used));
memv.set(new Uint8Array(tmp.buffer, 0, buffers['pressures'].used), buffers['pressures'].offset);
tmp.set(new Uint8Array(mem, old_coords_from_offset, old_size_strokes));
memv.set(new Uint8Array(tmp.buffer, 0, old_size_strokes), buffers['coords_from'].offset);
}
}
async function do_lod(state, context) {
state.wasm.exports.free_dynamic();
const buffers = state.wasm.buffers;
const result_buffers = state.wasm.exports.alloc_dynamic(state.wasm.workers.length * 4);
const result_counts = state.wasm.exports.alloc_dynamic(state.wasm.workers.length * 4);
const clipped_indices = state.wasm.exports.alloc_dynamic(context.clipped_indices.size * 4);
const mem = new Uint8Array(state.wasm.memory.buffer);
// Dynamic input data that should (by design) never be too big
mem.set(tv_bytes(context.clipped_indices), clipped_indices);
// TODO: this is a very naive and dumb way of distributing work. Better way
// would be to distrubute strokes based on total point count, so that
// each worker gets approximately the same amout of _points_
const indices_per_thread = Math.floor(context.clipped_indices.size / state.wasm.workers.length);
const offsets = {
'coords_from': buffers['coords_from'].offset,
'xs': buffers['xs'].offset,
'ys': buffers['ys'].offset,
'pressures': buffers['pressures'].offset,
'result_buffers': result_buffers,
'result_counts': result_counts,
};
const jobs = [];
for (let i = 0; i < state.wasm.workers.length; ++i) {
let count = indices_per_thread;
if (i === state.wasm.workers.length - 1) {
count += context.clipped_indices.size % state.wasm.workers.length;
}
jobs.push({
'type': 'lod',
'indices_base': clipped_indices + i * 4 * indices_per_thread,
'indices_count': count,
'zoom': state.canvas.zoom,
'offsets': offsets
});
}
await workers_messages(state.wasm.workers, jobs);
const result_offset = state.wasm.exports.merge_results(
result_counts,
result_buffers,
state.wasm.workers.length
);
const segment_count = new Int32Array(state.wasm.memory.buffer, result_counts, 1)[0]; // by convention
// Use results without copying from WASM memory
const wasm_points = new Float32Array(state.wasm.memory.buffer,
result_offset, segment_count * 2);
const wasm_ids = new Uint32Array(state.wasm.memory.buffer,
result_offset + segment_count * 2 * 4, segment_count);
const wasm_pressures = new Uint8Array(state.wasm.memory.buffer,
result_offset + segment_count * 2 * 4 + segment_count * 4, segment_count);
context.instance_data_points.data = wasm_points;
context.instance_data_points.size = segment_count * 2;
context.instance_data_ids.data = wasm_ids;
context.instance_data_ids.size = segment_count;
context.instance_data_pressures.data = wasm_pressures;
context.instance_data_pressures.size = segment_count;
return segment_count;
}

163
client/tools.js

@ -1,23 +1,152 @@ @@ -1,23 +1,152 @@
function tools_switch(tool) {
if (storage.tools.active_element) {
storage.tools.active_element.classList.remove('active');
function switch_tool(state, item) {
const tool = item.getAttribute('data-tool');
if (tool === 'undo') {
queue_event(state, undo_event(state));
return;
}
if (state.tools.active_element) {
state.tools.active_element.classList.remove('active');
}
storage.tools.active = tool;
storage.tools.active_element = document.querySelector(`.tool[data-tool="${tool}"]`);
storage.tools.active_element.classList.add('active');
state.tools.active = tool;
state.tools.active_element = item;
state.tools.active_element.classList.add('active');
}
function tools_init() {
const pencil = document.querySelector('.tool[data-tool="pencil"]');
const ruler = document.querySelector('.tool[data-tool="ruler"]');
const eraser = document.querySelector('.tool[data-tool="eraser"]');
const undo = document.querySelector('.tool[data-tool="undo"]');
function select_color(state, item, color_u32) {
if (state.colors.active_element) {
state.colors.active_element.classList.remove('active');
}
if (state.colors.extended_element) {
state.colors.extended_element.classList.remove('extended');
state.colors.extended_element = null;
}
const last_minor = item.lastElementChild;
const color_css = color_from_u32(color_u32);
last_minor.setAttribute('data-color', color_css.substring(1));
last_minor.querySelector('.color-pane').style.background = color_css;
state.colors.active_element = item;
item.classList.add('active');
}
function extend_major_color(state, item) {
if (state.colors.active_element) {
state.colors.active_element.classList.remove('active');
}
if (state.colors.extended_element) {
state.colors.extended_element.classList.remove('extended');
}
const last_minor = item.lastElementChild;
// Restore last pane color in case it was overwritten by active color
last_minor.querySelector('.color-pane').style.background = '#' + item.getAttribute('data-last-color');
last_minor.setAttribute('data-color', item.getAttribute('data-last-color'));
state.colors.extended_element = item;
item.classList.add('extended');
}
function set_color_u32(state, color_u32) {
if (color_u32 === state.players[state.me].color) {
return;
}
const color_css = color_from_u32(color_u32).substring(1);
const color_minor = document.querySelector(`.color-minor[data-color="${color_css}"]`);
if (!color_minor) {
set_color_u32(state, 0);
return;
}
pencil.addEventListener('click', () => tools_switch('pencil'));
ruler.addEventListener('click', () => tools_switch('ruler'));
eraser.addEventListener('click', () => tools_switch('eraser'));
undo.addEventListener('click', queue_undo);
const major_color = color_minor.parentElement;
select_color(state, major_color, color_u32);
tools_switch('pencil');
}
state.players[state.me].color = color_u32
update_cursor(state);
fire_event(state, color_event(color_u32));
}
function switch_stroke_width(e, state) {
if (!state.online) return;
const value = parseInt(e.target.value);
state.players[state.me].width = value;
update_cursor(state);
}
function broadcast_stroke_width(e, state) {
const value = e.target.value;
fire_event(state, width_event(value));
}
function init_tools(state) {
const tools = document.querySelectorAll('.tools .tool');
const color_groups = document.querySelectorAll('.pallete .color-major');
tools.forEach((item) => { item.addEventListener('click', () => switch_tool(state, item)); });
color_groups.forEach((item) => {
item.setAttribute('data-last-color', item.lastElementChild.getAttribute('data-color'));
let longtouch_timer = null;
item.addEventListener('touchstart', (e) => {
longtouch_timer = setTimeout(() => {
extend_major_color(state, item);
}, 500);
});
item.addEventListener('touchmove', (e) => {
if (longtouch_timer) {
clearTimeout(longtouch_timer);
}
longtouch_timer = null;
});
item.addEventListener('touchend', (e) => {
if (longtouch_timer) {
clearTimeout(longtouch_timer);
}
longtouch_timer = null;
});
item.addEventListener('click', (e) => {
if (e.ctrlKey) {
extend_major_color(state, item);
return;
}
let color_element = e.target;
let target = e.target;
while (!target.classList.contains('color-minor')) {
target = target.parentElement;
}
const color_str = target.getAttribute('data-color');
const color_u32 = color_to_u32(color_str);
set_color_u32(state, color_u32);
})
});
switch_tool(state, document.querySelector('.tool[data-tool="pencil"]'));
const slider = document.querySelector('#stroke-width');
// slider.value = state.players[state.me].width;
slider.addEventListener('input', (e) => switch_stroke_width(e, state));
slider.addEventListener('change', (e) => broadcast_stroke_width(e, state));
document.querySelector('.phone-extra-controls').addEventListener('click', zenmode);
}

1
client/wasm/compile_command

@ -0,0 +1 @@ @@ -0,0 +1 @@
clang -Oz --target=wasm32 -nostdlib -msimd128 -mbulk-memory -matomics -Wl,--no-entry,--import-memory,--shared-memory,--export-all,--max-memory=$((1024 * 1024 * 1024)) -z stack-size=$((1024 * 1024)) lod.c -o lod.wasm

351
client/wasm/lod.c

@ -0,0 +1,351 @@ @@ -0,0 +1,351 @@
#include <wasm_simd128.h>
extern char __heap_base;
static int allocated_static;
static int allocated_dynamic;
void
set_sp(char *sp)
{
__asm__ __volatile__(
".globaltype __stack_pointer, i32\n"
"local.get %0\n"
"global.set __stack_pointer\n"
: : "r"(sp)
);
}
void
free_static(void)
{
allocated_static = 0;
}
void
free_dynamic(void)
{
allocated_dynamic = 0;
}
void *
alloc_static(int size)
{
// This IS NOT thread-safe
void *result = &__heap_base + allocated_static;
allocated_static += size;
return(result);
}
static int
round_to_pow2(int value, int multiple)
{
return((value + multiple - 1) & -multiple);
}
void *
alloc_dynamic(int size)
{
// Very ad-van-ced thread-safe allocator
// CAN be called from multiple threads
size = round_to_pow2(size, 4);
int original_allocated_dynamic = __atomic_fetch_add(&allocated_dynamic, size, __ATOMIC_SEQ_CST);
void *result = &__heap_base + allocated_static + original_allocated_dynamic;
return(result);
}
static int
rdp_find_max(float *xs, float *ys, unsigned char *pressures, float zoom, int coords_from,
int segment_start, int segment_end)
{
int result = -1;
if (segment_start == segment_end) {
return(result);
}
float EPS = 0.125f / zoom * 255.0f;
float max_dist = 0.0f;
float ax = xs[coords_from + segment_start];
float ay = ys[coords_from + segment_start];
float bx = xs[coords_from + segment_end];
float by = ys[coords_from + segment_end];
unsigned char ap = pressures[coords_from / 2 + segment_start];
unsigned char bp = pressures[coords_from / 2 + segment_end];
float dx = bx - ax;
float dy = by - ay;
float dist_ab = __builtin_sqrtf(dx * dx + dy * dy);
float dir_nx = dy / dist_ab * 255.0f;
float dir_ny = -dx / dist_ab * 255.0f;
#if 0
for (int i = segment_start + 1; i < segment_end; ++i) {
float px = xs[coords_from + i];
float py = ys[coords_from + i];
unsigned char pp = pressures[coords_from + i];
float apx = px - ax;
float apy = py - ay;
float dist = __builtin_fabsf(apx * dir_nx + apy * dir_ny)
+ __builtin_abs(pp - ap) + __builtin_abs(pp - bp);
if (dist > EPS && dist > max_dist) {
result = i;
max_dist = dist;
}
}
#else
v128_t ax_x4 = wasm_f32x4_splat(ax);
v128_t ay_x4 = wasm_f32x4_splat(ay);
v128_t ap_x4 = wasm_f32x4_splat(ap);
v128_t bp_x4 = wasm_f32x4_splat(bp);
v128_t dir_nx_x4 = wasm_f32x4_splat(dir_nx);
v128_t dir_ny_x4 = wasm_f32x4_splat(dir_ny);
v128_t index_x4 = wasm_u32x4_make(segment_start + 1, segment_start + 2, segment_start + 3, segment_start + 4);
v128_t four_x4 = wasm_u32x4_const_splat(4);
v128_t max_dist_x4 = wasm_f32x4_splat(EPS);
v128_t max_index_x4 = wasm_u32x4_const_splat(-1);
for (int i = segment_start + 1; i < segment_end - 3; i += 4) {
v128_t px_x4 = wasm_v128_load(xs + coords_from + i);
v128_t py_x4 = wasm_v128_load(ys + coords_from + i);
v128_t pp_x4 = wasm_f32x4_make(
pressures[coords_from / 2 + i + 0],
pressures[coords_from / 2 + i + 1],
pressures[coords_from / 2 + i + 2],
pressures[coords_from / 2 + i + 3]
);
v128_t apx_x4 = wasm_f32x4_sub(px_x4, ax_x4);
v128_t apy_x4 = wasm_f32x4_sub(py_x4, ay_x4);
v128_t dist_x4 = wasm_f32x4_add(
wasm_f32x4_add(
wasm_f32x4_abs(wasm_f32x4_sub(pp_x4, ap_x4)),
wasm_f32x4_abs(wasm_f32x4_sub(pp_x4, bp_x4))
),
wasm_f32x4_abs(
wasm_f32x4_add(
wasm_f32x4_mul(apx_x4, dir_nx_x4),
wasm_f32x4_mul(apy_x4, dir_ny_x4)
)
)
);
v128_t mask = wasm_f32x4_gt(dist_x4, max_dist_x4);
max_index_x4 = wasm_v128_bitselect(index_x4, max_index_x4, mask);
max_dist_x4 = wasm_v128_bitselect(dist_x4, max_dist_x4, mask);
index_x4 = wasm_i32x4_add(index_x4, four_x4);
}
int indices[4];
float values[4];
wasm_v128_store(indices, max_index_x4);
wasm_v128_store(values, max_dist_x4);
for (int i = 0; i < 4; ++i) {
if (indices[i] != -1) {
if (values[i] > max_dist) {
result = indices[i];
max_dist = values[i];
}
}
}
if (max_dist == EPS) {
max_dist = 0.0f;
result = -1;
}
int remainder = (segment_end - segment_start - 1) % 4;
for (int i = segment_end - remainder; i < segment_end; ++i) {
float px = xs[coords_from + i];
float py = ys[coords_from + i];
unsigned char pp = pressures[coords_from + i];
float apx = px - ax;
float apy = py - ay;
float dist = __builtin_fabsf(apx * dir_nx + apy * dir_ny)
+ __builtin_abs(pp - ap) + __builtin_abs(pp - bp);
if (dist > EPS && dist > max_dist) {
result = i;
max_dist = dist;
}
}
#endif
return(result);
}
void
do_lod(int *clipped_indices, int clipped_count, float zoom,
int *stroke_coords_from,
float *xs,
float *ys,
unsigned char *pressures,
char **result_buffer,
int *result_count)
{
if (clipped_count == 0) {
result_count[0] = 0;
return;
}
int first_stroke = clipped_indices[0];
int last_stroke = clipped_indices[clipped_count - 1];
int total_points = 0;
for (int i = 0; i < clipped_count; ++i) {
int stroke_index = clipped_indices[i];
total_points += stroke_coords_from[stroke_index + 1] - stroke_coords_from[stroke_index];
}
int *segments_from = alloc_dynamic((clipped_count + 1) * 4);
int *segments = alloc_dynamic(total_points * 4); // TODO: this is a very conservative estimate, we can lower memory usage if we get this tighter
int segments_head = 0;
int stack[4096]; // TODO: what's a reasonable max size for this?
int max_stack_size = 0;
for (int i = 0; i < clipped_count; ++i) {
int stroke_index = clipped_indices[i];
// TODO: convert to a proper CSR, save half the memory
int coords_from = stroke_coords_from[stroke_index];
int coords_to = stroke_coords_from[stroke_index + 1];
int point_count = coords_to - coords_from;
// Basic CSR crap
segments_from[i] = segments_head;
int segment_count = 2;
int stack_head = 0;
segments[segments_head++] = 0;
stack[stack_head++] = 0;
stack[stack_head++] = 0;
stack[stack_head++] = point_count - 1;
while (stack_head > 0) {
if (stack_head > max_stack_size) { max_stack_size = stack_head; }
int end = stack[--stack_head];
int start = stack[--stack_head];
int type = stack[--stack_head];
if (type == 1) {
segments[segments_head++] = start;
} else {
int max = rdp_find_max(xs, ys, pressures, zoom, coords_from, start, end);
if (max != -1) {
segment_count += 1;
stack[stack_head++] = 0;
stack[stack_head++] = max;
stack[stack_head++] = end;
stack[stack_head++] = 1;
stack[stack_head++] = max;
stack[stack_head++] = -1;
stack[stack_head++] = 0;
stack[stack_head++] = start;
stack[stack_head++] = max;
}
}
}
segments[segments_head++] = point_count - 1;
}
segments_from[clipped_count] = segments_head;
// Write actual coordinates (points) and stroke ids
// Do this in one allocation so that they're not interleaved between threads
char *output = alloc_dynamic(segments_head * (3 * 4 + 1));
float *points = (float *) output;
int *ids = (int *) (output + segments_head * 4 * 2);
unsigned char *pressures_res = (unsigned char *) (output + segments_head * 4 * 3);
int phead = 0;
int ihead = 0;
for (int i = 0; i < clipped_count; ++i) {
int stroke_index = clipped_indices[i];
// TODO: convert to a proper CSR, save half the memory
int base_stroke = stroke_coords_from[stroke_index];
int from = segments_from[i];
int to = segments_from[i + 1];
for (int j = from; j < to; ++j) {
int point_index = segments[j];
float x = xs[base_stroke + point_index];
float y = ys[base_stroke + point_index];
points[phead++] = x;
points[phead++] = y;
pressures_res[ihead] = pressures[base_stroke + point_index];
if (j != to - 1) {
ids[ihead++] = stroke_index;
} else {
ids[ihead++] = stroke_index | (1 << 31);
}
}
}
result_buffer[0] = output;
result_count[0] = segments_head;
}
// NOT thread-safe, only call from one thread
char *
merge_results(int *segment_counts, char **buffers, int nthreads)
{
int total_segments = 0;
for (int i = 0; i < nthreads; ++i) {
total_segments += segment_counts[i];
}
char *merged = alloc_dynamic(total_segments * (3 * 4 + 1));
float *points = (float *) merged;
int *ids = (int *) (merged + total_segments * 4 * 2);
unsigned char *pressures = (unsigned char *) (merged + total_segments * 4 * 3);
for (int i = 0; i < nthreads; ++i) {
int segments = segment_counts[i];
if (segments > 0) {
__builtin_memcpy(points, buffers[i], segments * 4 * 2);
__builtin_memcpy(ids, buffers[i] + segments * 4 * 2, segments * 4);
__builtin_memcpy(pressures, buffers[i] + segments * 4 * 3, segments);
points += segments * 2;
ids += segments;
pressures += segments;
}
}
segment_counts[0] = total_segments;
return(merged);
}

BIN
client/wasm/lod.wasm

Binary file not shown.

422
client/webgl_draw.js

@ -0,0 +1,422 @@ @@ -0,0 +1,422 @@
function schedule_draw(state, context, animate = false) {
if (!state.timers.raf) {
window.requestAnimationFrame(async (ts) => {
await draw(state, context, animate, ts);
});
state.timers.raf = true;
}
}
function upload_if_needed(gl, buffer_kind, serializer) {
if (serializer.need_gpu_allocate) {
if (config.debug_print) console.debug('gpu allocate');
gl.bufferData(buffer_kind, serializer.size, gl.DYNAMIC_DRAW);
serializer.need_gpu_allocate = false;
serializer.gpu_upload_from = 0;
}
if (serializer.gpu_upload_from < serializer.offset) {
if (config.debug_print) console.debug('gpu upload');
const upload_offset = serializer.gpu_upload_from;
const upload_size = serializer.offset - upload_offset;
gl.bufferSubData(buffer_kind, upload_offset, new Uint8Array(serializer.buffer, upload_offset, upload_size));
serializer.gpu_upload_from = serializer.offset;
}
}
function upload_square_rgba16ui_texture(gl, serializer, texture_size) {
// TODO: only subupload what's needed
const bpp = 2 * 4;
const data_size = serializer.offset;
const data_pixels = data_size / bpp; // data_size % bpp is expected to always be zero here
const rows = Math.ceil(data_pixels / texture_size);
const last_row = data_pixels % texture_size;
const whole_upload = (rows - 1) * texture_size * bpp;
// Upload whole rows
if (rows > 1) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, texture_size, rows - 1, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, new Uint16Array(serializer.buffer, 0, whole_upload / 2));
}
// Upload last row
if (last_row > 0) {
const last_row_upload = last_row * bpp;
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, rows - 1, last_row, 1, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, new Uint16Array(serializer.buffer, whole_upload, last_row_upload / 2));
}
}
function draw_html(state) {
// HUD-like things. Player cursors, screens
for (const player_id in state.players) {
if (player_id === state.me) continue;
const player = state.players[player_id];
let player_cursor_element = document.querySelector(`.player-cursor[data-player-id="${player_id}"]`);
if (player_cursor_element === null && player.online) {
player_cursor_element = insert_player_cursor(state, player_id);
}
if (!player.online && player_cursor_element !== null) {
player_cursor_element.remove();
const player_list_item = document.querySelector(`.player-list .player[data-player-id="${player_id}"]`);
if (player_list_item) player_list_item.remove();
if (document.querySelector('.player-list').childElementCount === 0) {
document.querySelector('.player-list').classList.add('vhide');
}
}
if (player_cursor_element && player.online) {
const screenp = canvas_to_screen(state, player.cursor);
player_cursor_element.style.transform = `translate(${Math.round(screenp.x)}px, ${Math.round(screenp.y)}px) rotate(-30deg)`;
}
}
}
async function draw(state, context, animate, ts) {
const dt = ts - context.last_frame_ts;
const cpu_before = performance.now();
context.last_frame_ts = ts;
const gl = context.gl;
const width = window.innerWidth;
const height = window.innerHeight;
bvh_clip(state, context);
const segment_count = await geometry_write_instances(state, context);
const dynamic_segment_count = context.dynamic_segment_count;
const dynamic_stroke_count = context.dynamic_stroke_count;
let query = null;
if (context.gpu_timer_ext !== null) {
query = gl.createQuery();
gl.beginQuery(context.gpu_timer_ext.TIME_ELAPSED_EXT, query);
}
// Only clear once we have the data, this might not always be on the same frame?
gl.viewport(0, 0, context.canvas.width, context.canvas.height);
gl.clearColor(context.bgcolor.r, context.bgcolor.g, context.bgcolor.b, 1);
gl.clearDepth(0.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// Draw the background pattern
if (state.background_pattern === 'dots') {
gl.useProgram(context.programs['pattern'].dots);
buffers = context.buffers['pattern'];
locations = context.locations['pattern'].dots;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance_dot']);
gl.enableVertexAttribArray(locations['a_center']);
gl.vertexAttribPointer(locations['a_center'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribDivisor(locations['a_center'], 1);
gl.uniform2f(locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
const zoom = state.canvas.zoom;
const zoom_log2 = Math.log2(zoom);
const zoom_previous = Math.pow(2, Math.floor(zoom_log2));
const zoom_next = Math.pow(2, Math.ceil(zoom_log2));
// Previous level
{
const one_dot = new Float32Array(geometry_gen_quad(0, 0, 1 / zoom_previous));
const dot_instances = new Float32Array(geometry_gen_fullscreen_grid(state, context, 32 / zoom_previous, 32 / zoom_previous));
const t = Math.min(1.0, 1.0 - (zoom / zoom_previous) / 2.0);
gl.uniform1f(locations['u_fadeout'], t);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance_dot']);
gl.bufferData(gl.ARRAY_BUFFER, dot_instances, gl.STREAM_DRAW);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, dot_instances.length / 2);
}
// Next level
if (zoom_previous != zoom_next) {
const dot_instances = new Float32Array(geometry_gen_fullscreen_grid(state, context, 32 / zoom_next, 32 / zoom_next));
const t = Math.min(1.0, 1.0 - (zoom_next / zoom) / 2.0);
gl.uniform1f(locations['u_fadeout'], t);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance_dot']);
gl.bufferData(gl.ARRAY_BUFFER, dot_instances, gl.STREAM_DRAW);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, dot_instances.length / 2);
}
} else if (state.background_pattern === 'grid') {
const zoom = state.canvas.zoom;
let zoom_log8 = Math.log(zoom) / Math.log(8);
//if (zoom_log2 === Math.floor(zoom_log2)) {
// zoom_log2 -= 0.001;
//}
const zoom_previous = Math.pow(8, Math.floor(zoom_log8));
let zoom_next = Math.pow(8, Math.ceil(zoom_log8));
if (zoom_next === zoom_previous) {
zoom_next = zoom_previous * 8;
}
gl.useProgram(context.programs['pattern'].grid);
buffers = context.buffers['pattern'];
locations = context.locations['pattern'].grid;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance_grid']);
gl.enableVertexAttribArray(locations['a_data']);
gl.vertexAttribPointer(locations['a_data'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribDivisor(locations['a_data'], 1);
gl.uniform2f(locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1f(locations['u_fadeout'], 1.0);
// Previous level (major lines)
{
const grid_instances = new Float32Array(geometry_gen_fullscreen_grid_1d(state, context, 32 / zoom_previous, 32 / zoom_previous));
let t = (zoom / zoom_previous - 1) / -7 + 1;
t = 0.25;
gl.uniform1f(locations['u_fadeout'], t);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance_grid']);
gl.bufferData(gl.ARRAY_BUFFER, grid_instances, gl.STREAM_DRAW);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, grid_instances.length / 2);
}
// Next level (minor lines)
{
const grid_instances = new Float32Array(geometry_gen_fullscreen_grid_1d(state, context, 32 / zoom_next, 32 / zoom_next));
let t = (zoom_next / zoom - 1) / 7;
t = Math.min(0.1, -t + 1); // slight fade-in
gl.uniform1f(locations['u_fadeout'], t);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance_grid']);
gl.bufferData(gl.ARRAY_BUFFER, grid_instances, gl.STREAM_DRAW);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, grid_instances.length / 2);
}
}
gl.clear(gl.DEPTH_BUFFER_BIT); // draw images above the background pattern
gl.useProgram(context.programs['image']);
buffers = context.buffers['image'];
locations = context.locations['image'];
{
let offset = 0;
const quads = geometry_image_quads(state, context);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_quads']);
gl.bufferData(gl.ARRAY_BUFFER, quads, gl.STATIC_DRAW);
gl.vertexAttribDivisor(locations['a_pos'], 0);
gl.enableVertexAttribArray(locations['a_pos']);
gl.vertexAttribPointer(locations['a_pos'], 2, gl.FLOAT, false, 2 * 4, 0);
for (const entry of context.images) {
if (state.active_image === entry.key) {
//gl.uniform1i(locations['u_active'], 1);
} else {
//gl.uniform1i(locations['u_active'], 0);
}
gl.uniform2f(locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(locations['u_texture'], 0); // Only 1 active texture for each drawcall
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.drawArrays(gl.TRIANGLES, offset, 6);
offset += 6;
}
}
gl.clear(gl.DEPTH_BUFFER_BIT); // draw strokes above the images
gl.useProgram(context.programs['sdf'].main);
buffers = context.buffers['sdf'];
locations = context.locations['sdf'].main;
// "Static" data upload
if (segment_count > 0) {
const total_static_size = context.instance_data_points.size * 4 +
context.instance_data_ids.size * 4 +
context.instance_data_pressures.size;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance']);
gl.bufferData(gl.ARRAY_BUFFER, total_static_size, gl.STREAM_DRAW);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, tv_data(context.instance_data_points));
gl.bufferSubData(gl.ARRAY_BUFFER, context.instance_data_points.size * 4, tv_data(context.instance_data_ids));
gl.bufferSubData(gl.ARRAY_BUFFER, context.instance_data_points.size * 4 + context.instance_data_ids.size * 4,
tv_data(context.instance_data_pressures));
gl.bindTexture(gl.TEXTURE_2D, context.textures['stroke_data']);
upload_square_rgba16ui_texture(gl, context.stroke_data, config.stroke_texture_size);
gl.uniform2f(locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(locations['u_stroke_count'], state.events.length);
gl.uniform1i(locations['u_debug_mode'], state.debug.red);
gl.uniform1i(locations['u_stroke_data'], 0);
gl.uniform1i(locations['u_stroke_texture_size'], config.stroke_texture_size);
gl.enableVertexAttribArray(locations['a_a']);
gl.enableVertexAttribArray(locations['a_b']);
gl.enableVertexAttribArray(locations['a_stroke_id']);
gl.enableVertexAttribArray(locations['a_pressure']);
// Points (a, b) and stroke ids are stored in separate cpu buffers so that points can be reused (look at stride and offset values)
gl.vertexAttribPointer(locations['a_a'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribPointer(locations['a_b'], 2, gl.FLOAT, false, 2 * 4, 2 * 4);
gl.vertexAttribIPointer(locations['a_stroke_id'], 1, gl.INT, 4, context.instance_data_points.size * 4);
gl.vertexAttribPointer(locations['a_pressure'], 2, gl.UNSIGNED_BYTE, true, 1, context.instance_data_points.size * 4 + context.instance_data_ids.size * 4);
gl.vertexAttribDivisor(locations['a_a'], 1);
gl.vertexAttribDivisor(locations['a_b'], 1);
gl.vertexAttribDivisor(locations['a_stroke_id'], 1);
gl.vertexAttribDivisor(locations['a_pressure'], 1);
// Static draw (everything already bound)
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, segment_count);
// I don't really know why I need to do this, but it
// makes background patter drawcall work properly
gl.vertexAttribDivisor(locations['a_a'], 0);
gl.vertexAttribDivisor(locations['a_b'], 0);
gl.vertexAttribDivisor(locations['a_stroke_id'], 0);
gl.vertexAttribDivisor(locations['a_pressure'], 0);
}
// Dynamic strokes should be drawn above static strokes
gl.clear(gl.DEPTH_BUFFER_BIT);
// Dynamic draw (strokes currently being drawn)
if (dynamic_segment_count > 0) {
gl.uniform1i(locations['u_stroke_count'], dynamic_stroke_count);
gl.uniform1i(locations['u_stroke_data'], 0);
gl.uniform1i(locations['u_stroke_texture_size'], config.dynamic_stroke_texture_size);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_dynamic_instance']);
// Dynamic data upload
const total_dynamic_size =
context.dynamic_instance_points.size * 4 + context.dynamic_instance_ids.size * 4 +
context.dynamic_instance_pressure.size;
gl.bufferData(gl.ARRAY_BUFFER, total_dynamic_size, gl.STREAM_DRAW);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, tv_data(context.dynamic_instance_points));
gl.bufferSubData(gl.ARRAY_BUFFER, context.dynamic_instance_points.size * 4, tv_data(context.dynamic_instance_ids));
gl.bufferSubData(gl.ARRAY_BUFFER, context.dynamic_instance_points.size * 4 + context.dynamic_instance_ids.size * 4,
tv_data(context.dynamic_instance_pressure));
gl.bindTexture(gl.TEXTURE_2D, context.textures['dynamic_stroke_data']);
upload_square_rgba16ui_texture(gl, context.dynamic_stroke_data, config.dynamic_stroke_texture_size);
gl.enableVertexAttribArray(locations['a_a']);
gl.enableVertexAttribArray(locations['a_b']);
gl.enableVertexAttribArray(locations['a_stroke_id']);
gl.enableVertexAttribArray(locations['a_pressure']);
// Points (a, b) and stroke ids are stored in separate cpu buffers so that points can be reused (look at stride and offset values)
gl.vertexAttribPointer(locations['a_a'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribPointer(locations['a_b'], 2, gl.FLOAT, false, 2 * 4, 2 * 4);
gl.vertexAttribIPointer(locations['a_stroke_id'], 1, gl.INT, 4, context.dynamic_instance_points.size * 4);
gl.vertexAttribPointer(locations['a_pressure'], 2, gl.UNSIGNED_BYTE, true, 1, context.dynamic_instance_points.size * 4 + context.dynamic_instance_ids.size * 4);
gl.vertexAttribDivisor(locations['a_a'], 1);
gl.vertexAttribDivisor(locations['a_b'], 1);
gl.vertexAttribDivisor(locations['a_stroke_id'], 1);
gl.vertexAttribDivisor(locations['a_pressure'], 1);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, dynamic_segment_count);
gl.vertexAttribDivisor(locations['a_a'], 0);
gl.vertexAttribDivisor(locations['a_b'], 0);
gl.vertexAttribDivisor(locations['a_stroke_id'], 0);
gl.vertexAttribDivisor(locations['a_pressure'], 0);
}
document.getElementById('debug-stats').innerHTML = `
<span>Segments onscreen: ${segment_count}</span>
<span>Canvas offset: (${Math.round(state.canvas.offset.x * 100) / 100}, ${Math.round(state.canvas.offset.y * 100) / 100})</span>
<span>Canvas zoom level: ${state.canvas.zoom_level}</span>
<span>Canvas zoom: ${Math.round(state.canvas.zoom * 100) / 100}</span>`;
if (context.gpu_timer_ext) {
gl.endQuery(context.gpu_timer_ext.TIME_ELAPSED_EXT);
const next_tick = () => {
if (query) {
// At some point in the future, after returning control to the browser
const available = gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE);
const disjoint = gl.getParameter(context.gpu_timer_ext.GPU_DISJOINT_EXT);
if (available && !disjoint) {
// See how much time the rendering of the object took in nanoseconds.
const timeElapsed = gl.getQueryParameter(query, gl.QUERY_RESULT);
//console.debug(timeElapsed / 1000000);
document.querySelector('.debug-timings .gpu').innerHTML = 'Last GPU Frametime: ' + Math.round(timeElapsed / 10000) / 100 + 'ms';
}
if (available || disjoint) {
// Clean up the query object.
gl.deleteQuery(query);
// Don't re-enter this polling loop.
query = null;
} else if (!available) {
setTimeout(next_tick, 0);
}
}
}
setTimeout(next_tick, 0);
}
const cpu_after = performance.now();
state.timers.raf = false;
document.querySelector('.debug-timings .cpu').innerHTML = 'Last CPU Frametime: ' + Math.round((cpu_after - cpu_before) * 100) / 100 + 'ms';
if (state.debug.benchmark_mode) {
const redraw = state.debug.on_benchmark();
if (redraw) {
schedule_draw(state, context);
}
}
if (state.canvas.target_zoom != state.canvas.zoom) {
update_canvas_zoom(state, state.canvas.zoom, state.canvas.target_zoom, animate ? dt : context.last_frame_dt);
schedule_draw(state, context, true);
}
context.last_frame_dt = dt;
}
function update_canvas_zoom(state, current, target, dt) {
const rate = Math.min(1.0, dt / 16.66 * 0.3);
if (Math.abs(1.0 - current / target) > 0.01) {
state.canvas.zoom = current + (target - current) * rate;
} else {
state.canvas.zoom = target;
}
// https://gist.github.com/aolo2/a373363419bd5a9283977ab9f8841f78
const zc = state.canvas.zoom_screenp;
state.canvas.offset.x = zc.x - (zc.x - state.canvas.offset.x) * state.canvas.zoom / current;
state.canvas.offset.y = zc.y - (zc.y - state.canvas.offset.y) * state.canvas.zoom / current;
update_cursor(state);
}

377
client/webgl_geometry.js

@ -0,0 +1,377 @@ @@ -0,0 +1,377 @@
function push_stroke(s, stroke, stroke_index) {
const points = stroke.points;
if (points.length < 2) {
return;
}
for (let i = 0; i < points.length - 1; ++i) {
const from = points[i];
const to = points[i + 1];
ser_f32(s, from.x);
ser_f32(s, from.y);
ser_f32(s, to.x);
ser_f32(s, to.y);
ser_u32(s, stroke_index);
}
}
function geometry_prepare_stroke(state) {
if (!state.online) {
return null;
}
if (state.players[state.me].points.length === 0) {
return null;
}
const points = process_stroke2(state.canvas.zoom, state.players[state.me].points);
return {
'color': state.players[state.me].color,
'width': state.players[state.me].width,
'points': points,
'user_id': state.me,
};
}
async function geometry_write_instances(state, context, callback) {
state.stats.rdp_max_count = 0;
state.stats.rdp_segments = 0;
const segment_count = await do_lod(state, context);
if (config.debug_print) console.debug('instances:', segment_count, 'rdp max:', state.stats.rdp_max_count, 'rdp segments:', state.stats.rdp_segments);
return segment_count;
}
function geometry_add_dummy_stroke(context) {
context.stroke_data = ser_ensure_by(context.stroke_data, config.bytes_per_stroke);
ser_u16(context.stroke_data, 0);
ser_u16(context.stroke_data, 0);
ser_u16(context.stroke_data, 0);
ser_u16(context.stroke_data, 0);
}
function geometry_add_stroke(state, context, stroke, stroke_index, skip_bvh = false) {
if (!state.online || !stroke || stroke.coords_to - stroke.coords_from === 0) return;
stroke.bbox = stroke_bbox(state, stroke);
stroke.area = box_area(stroke.bbox);
context.stroke_data = ser_ensure_by(context.stroke_data, config.bytes_per_stroke);
const color_u32 = stroke.color;
const r = (color_u32 >> 16) & 0xFF;
const g = (color_u32 >> 8) & 0xFF;
const b = color_u32 & 0xFF;
ser_u16(context.stroke_data, r);
ser_u16(context.stroke_data, g);
ser_u16(context.stroke_data, b);
ser_u16(context.stroke_data, stroke.width);
if (!skip_bvh) bvh_add_stroke(state, state.bvh, stroke_index, stroke);
}
function geometry_delete_stroke(state, context, stroke_index) {
// NEXT: deleted wrong stroke
let offset = 0;
for (let i = 0; i < stroke_index; ++i) {
const event = state.events[i];
if (event.type === EVENT.STROKE) {
offset += (event.points.length * 12 + 6) * config.bytes_per_point;
}
}
const stroke = state.events[stroke_index];
for (let i = 0; i < stroke.points.length * 12 + 6; ++i) {
context.static_stroke_serializer.view.setUint8(offset + config.bytes_per_point - 1, 125);
offset += config.bytes_per_point;
}
}
function recompute_dynamic_data(state, context) {
let total_points = 0;
let total_strokes = 0;
for (const player_id in state.players) {
const player = state.players[player_id];
if (player.points.length > 0) {
total_points += player.points.length;
total_strokes += 1;
}
}
tv_ensure(context.dynamic_instance_points, round_to_pow2(total_points * 2, 4096));
tv_ensure(context.dynamic_instance_pressure, round_to_pow2(total_points, 4096));
tv_ensure(context.dynamic_instance_ids, round_to_pow2(total_points, 4096));
tv_clear(context.dynamic_instance_points);
tv_clear(context.dynamic_instance_pressure);
tv_clear(context.dynamic_instance_ids);
context.dynamic_stroke_data = ser_ensure(context.dynamic_stroke_data, config.bytes_per_stroke * total_strokes);
ser_clear(context.dynamic_stroke_data);
let stroke_index = 0;
for (const player_id in state.players) {
// player has the same data as their current stroke: points, color, width
const player = state.players[player_id];
for (let i = 0; i < player.points.length; ++i) {
const p = player.points[i];
tv_add(context.dynamic_instance_points, p.x);
tv_add(context.dynamic_instance_points, p.y);
tv_add(context.dynamic_instance_pressure, p.pressure);
if (i !== player.points.length - 1) {
tv_add(context.dynamic_instance_ids, stroke_index);
} else {
tv_add(context.dynamic_instance_ids, stroke_index | (1 << 31));
}
}
if (player.points.length > 0) {
const color_u32 = player.color;
const r = (color_u32 >> 16) & 0xFF;
const g = (color_u32 >> 8) & 0xFF;
const b = color_u32 & 0xFF;
ser_u16(context.dynamic_stroke_data, r);
ser_u16(context.dynamic_stroke_data, g);
ser_u16(context.dynamic_stroke_data, b);
ser_u16(context.dynamic_stroke_data, player.width);
stroke_index += 1; // TODO: proper player Z order
}
}
context.dynamic_segment_count = total_points;
context.dynamic_stroke_count = total_strokes;
}
function geometry_add_point(state, context, player_id, point, is_pen) {
if (!state.online) return;
const player = state.players[player_id];
const points = player.points;
if (point.pressure < config.min_pressure) {
point.pressure = config.min_pressure;
}
if (points.length > 0) {
// pulled from "perfect-freehand" package. MIT
// https://github.com/steveruizok/perfect-freehand/
const streamline = 0.5;
const t = 0.15 + (1 - streamline) * 0.85
const smooth_pressure = exponential_smoothing(points, point, 3);
points.push({
'x': player.dynamic_head.x * t + point.x * (1 - t),
'y': player.dynamic_head.y * t + point.y * (1 - t),
'pressure': is_pen ? player.dynamic_head.pressure * t + smooth_pressure * (1 - t) : point.pressure,
});
if (is_pen) {
point.pressure = smooth_pressure;
}
} else {
state.players[player_id].points.push(point);
}
recompute_dynamic_data(state, context);
player.dynamic_head = point;
}
function geometry_clear_player(state, context, player_id) {
if (!state.online) return;
state.players[player_id].points.length = 0;
recompute_dynamic_data(state, context);
}
function add_image(context, image_id, bitmap, p) {
const x = p.x;
const y = p.y;
const gl = context.gl;
const id = Object.keys(context.images).length;
const entry = {
'texture': gl.createTexture(),
'key': image_id,
'at': p,
'width': bitmap.width,
'height': bitmap.height,
};
context.images.push(entry);
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
}
function move_image(context, image_event) {
const x = image_event.x;
const y = image_event.y;
const count = Object.keys(context.textures['image']).length;
for (let id = 0; id < count; ++id) {
const image = context.textures['image'][id];
if (image.image_id === image_event.image_id) {
context.quad_positions[id * 12 + 0] = x;
context.quad_positions[id * 12 + 1] = y;
context.quad_positions[id * 12 + 2] = x;
context.quad_positions[id * 12 + 3] = y + image_event.height;
context.quad_positions[id * 12 + 4] = x + image_event.width;
context.quad_positions[id * 12 + 5] = y + image_event.height;
context.quad_positions[id * 12 + 6] = x + image_event.width;
context.quad_positions[id * 12 + 7] = y;
context.quad_positions[id * 12 + 8] = x;
context.quad_positions[id * 12 + 9] = y;
context.quad_positions[id * 12 + 10] = x + image_event.width;
context.quad_positions[id * 12 + 11] = y + image_event.height;
context.quad_positions_f32 = new Float32Array(context.quad_positions);
break;
}
}
}
function image_at(state, x, y) {
for (let i = state.events.length - 1; i >= 0; --i) {
const event = state.events[i];
if (event.type === EVENT.IMAGE && !event.deleted) {
if ('height' in event && 'width' in event) {
if (event.x <= x && x <= event.x + event.width && event.y <= y && y <= event.y + event.height) {
return event;
}
}
}
}
return null;
}
function geometry_gen_circle(cx, cy, r, n) {
const step = 2 * Math.PI / n;
const result = [];
for (let i = 0; i < n; ++i) {
const theta = i * step;
const next_theta = (i < n - 1 ? (i + 1) * step : 0);
const x = cx + r * Math.cos(theta);
const y = cy + r * Math.sin(theta);
const next_x = cx + r * Math.cos(next_theta);
const next_y = cy + r * Math.sin(next_theta);
result.push(cx, cy, x, y, next_x, next_y);
}
return result;
}
function geometry_gen_quad(cx, cy, r) {
const result = [
cx - r,
cy - r,
cx + r,
cy - r,
cx - r,
cy + r,
cx + r,
cy + r,
cx - r,
cy + r,
cx + r,
cy - r,
];
return result;
}
function geometry_gen_fullscreen_grid(state, context, step_x, step_y) {
const result = [];
const width = context.canvas.width;
const height = context.canvas.height;
const topleft = screen_to_canvas(state, {'x': 0, 'y': 0});
const bottomright = screen_to_canvas(state, {'x': width, 'y': height});
topleft.x = Math.floor(topleft.x / step_x) * step_x;
topleft.y = Math.ceil(topleft.y / step_y) * step_y;
bottomright.x = Math.floor(bottomright.x / step_x) * step_x;
bottomright.y = Math.ceil(bottomright.y / step_y) * step_y;
for (let y = topleft.y; y <= bottomright.y; y += step_y) {
for (let x = topleft.x; x <= bottomright.x; x += step_x) {
result.push(x, y);
}
}
return result;
}
function geometry_gen_fullscreen_grid_1d(state, context, step_x, step_y) {
const result = [];
const width = context.canvas.width;
const height = context.canvas.height;
const topleft = screen_to_canvas(state, {'x': 0, 'y': 0});
const bottomright = screen_to_canvas(state, {'x': width, 'y': height});
topleft.x = Math.floor(topleft.x / step_x) * step_x;
topleft.y = Math.floor(topleft.y / step_y) * step_y;
bottomright.x = Math.ceil(bottomright.x / step_x) * step_x;
bottomright.y = Math.ceil(bottomright.y / step_y) * step_y;
for (let x = topleft.x; x <= bottomright.x; x += step_x) {
result.push(1, x);
}
for (let y = topleft.y; y <= bottomright.y; y += step_y) {
result.push(-1, y);
}
return result;
}
function geometry_image_quads(state, context) {
const result = new Float32Array(context.images.length * 12);
for (let i = 0; i < context.images.length; ++i) {
const entry = context.images[i];
result[i * 12 + 0] = entry.at.x;
result[i * 12 + 1] = entry.at.y;
result[i * 12 + 2] = entry.at.x + entry.width;
result[i * 12 + 3] = entry.at.y;
result[i * 12 + 4] = entry.at.x;
result[i * 12 + 5] = entry.at.y + entry.height;
result[i * 12 + 6] = entry.at.x + entry.width;
result[i * 12 + 7] = entry.at.y + entry.height;
result[i * 12 + 8] = entry.at.x;
result[i * 12 + 9] = entry.at.y + entry.height;
result[i * 12 + 10] = entry.at.x + entry.width;
result[i * 12 + 11] = entry.at.y;
}
return result;
}

657
client/webgl_listeners.js

@ -0,0 +1,657 @@ @@ -0,0 +1,657 @@
function init_listeners(state, context) {
window.addEventListener('keydown', (e) => keydown(e, state, context));
window.addEventListener('keyup', (e) => keyup(e, state, context));
window.addEventListener('paste', (e) => paste(e, state, context));
context.canvas.addEventListener('pointerdown', (e) => mousedown(e, state, context));
context.canvas.addEventListener('pointermove', (e) => mousemove(e, state, context));
context.canvas.addEventListener('pointerup', (e) => mouseup(e, state, context));
context.canvas.addEventListener('pointerleave', (e) => mouseup(e, state, context));
context.canvas.addEventListener('pointerleave', (e) => mouseleave(e, state, context));
context.canvas.addEventListener('contextmenu', cancel);
context.canvas.addEventListener('wheel', (e) => wheel(e, state, context));
context.canvas.addEventListener('touchstart', (e) => touchstart(e, state, context));
context.canvas.addEventListener('touchmove', (e) => touchmove(e, state, context));
context.canvas.addEventListener('touchend', (e) => touchend(e, state, context));
context.canvas.addEventListener('touchcancel', (e) => touchend(e, state, context));
context.canvas.addEventListener('drop', (e) => on_drop(e, state, context));
context.canvas.addEventListener('dragover', (e) => mousemove(e, state, context));
debug_panel_init(state, context);
}
function debug_panel_init(state, context) {
document.getElementById('debug-red').checked = state.debug.red;
document.getElementById('debug-do-prepass').checked = state.debug.do_prepass;
document.getElementById('debug-limit-from').checked = state.debug.limit_from;
document.getElementById('debug-limit-to').checked = state.debug.limit_to;
document.getElementById('debug-red').addEventListener('change', (e) => {
state.debug.red = e.target.checked;
schedule_draw(state, context);
});
document.getElementById('debug-do-prepass').addEventListener('change', (e) => {
state.debug.do_prepass = e.target.checked;
schedule_draw(state, context);
});
document.getElementById('debug-limit-from').addEventListener('change', (e) => {
state.debug.limit_from = e.target.checked;
schedule_draw(state, context);
});
document.getElementById('debug-limit-to').addEventListener('change', (e) => {
state.debug.limit_to = e.target.checked;
schedule_draw(state, context);
});
document.getElementById('debug-render-from').addEventListener('input', (e) => {
state.debug.render_from = parseInt(e.target.value);
schedule_draw(state, context);
});
document.getElementById('debug-render-to').addEventListener('input', (e) => {
state.debug.render_to = parseInt(e.target.value);
schedule_draw(state, context);
});
document.getElementById('debug-begin-benchmark').addEventListener('click', (e) => {
state.canvas.zoom_level = config.benchmark.zoom_level;
state.canvas.offset.x = config.benchmark.offset.x;
state.canvas.offset.y = config.benchmark.offset.y;
const dz = (state.canvas.zoom_level > 0 ? config.zoom_delta : -config.zoom_delta);
state.canvas.zoom = Math.pow(1.0 + dz, Math.abs(state.canvas.zoom_level))
state.debug.benchmark_mode = true;
const origin_x = state.canvas.offset.x;
const origin_y = state.canvas.offset.y;
const original_button_text = e.target.innerText;
let frame = 0;
state.debug.on_benchmark = () => {
if (frame >= config.benchmark.frames) {
state.debug.benchmark_mode = false;
e.target.disabled = false;
e.target.innerText = original_button_text;
return false;
}
state.canvas.offset.x = origin_x + Math.round(100 * Math.cos(frame / 360));
state.canvas.offset.y = origin_y + Math.round(100 * Math.sin(frame / 360));
frame += 1;
return true;
}
e.target.disabled = true;
e.target.innerText = 'Benchmark in progress...';
schedule_draw(state, context);
});
}
function cancel(e) {
e.preventDefault();
return false;
}
function zenmode() {
document.querySelector('.pallete-wrapper').classList.toggle('hidden');
document.querySelector('.top-wrapper').classList.toggle('hidden');
}
function enter_picker_mode(state, context) {
document.querySelector('canvas').classList.add('picker');
document.querySelector('.picker-preview-outer').classList.remove('dhide');
document.querySelector('.brush-dom').classList.add('dhide');
state.colorpicking = true;
const canvasp = screen_to_canvas(state, state.cursor);
update_color_picker_color(state, context, canvasp);
}
function exit_picker_mode(state) {
document.querySelector('canvas').classList.remove('picker');
document.querySelector('.picker-preview-outer').classList.add('dhide');
document.querySelector('.brush-dom').classList.remove('dhide');
state.colorpicking = false;
}
async function paste(e, state, context) {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
await insert_image(state, context, file);
}
}
}
function keydown(e, state, context) {
if (e.code === 'Space' && !state.drawing) {
state.spacedown = true;
context.canvas.classList.add('movemode');
} else if (e.code === 'Tab') {
e.preventDefault();
zenmode();
} else if (e.code === 'ControlLeft' || e.code === 'ControlRight') {
enter_picker_mode(state, context);
} else if (e.code === 'KeyD') {
document.querySelector('.debug-window').classList.toggle('dhide');
} else if (e.code === 'KeyZ') {
if (e.ctrlKey) {
queue_event(state, undo_event(state));
} else {
state.zoomdown = true;
}
}
}
function keyup(e, state, context) {
if (e.code === 'Space' && state.spacedown) {
state.spacedown = false;
state.moving = false;
context.canvas.classList.remove('movemode');
} else if (e.code === 'ControlLeft' || e.code === 'ControlRight') {
exit_picker_mode(state);
} else if (e.code === 'KeyZ') {
state.zoomdown = false;
}
}
function mousedown(e, state, context) {
const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY};
const canvasp = screen_to_canvas(state, screenp);
if (e.button === 2) {
// Right click on image to select it
const image_event = image_at(state, canvasp.x, canvasp.y);
if (image_event) {
context.active_image = image_event.image_id;
} else {
context.active_image = null;
}
schedule_draw(state, context);
return;
}
if (e.button !== 0 && e.button !== 1) {
return;
}
if (state.zoomdown) {
state.zooming = true;
state.canvas.zoom_screenp = screenp;
return;
}
if (state.colorpicking) {
const color_u32 = color_to_u32(state.color_picked.substring(1));
state.players[state.me].color = color_u32;
update_cursor(state);
fire_event(state, color_event(color_u32));
return;
}
if (context.active_image) {
// Move selected image with left click
const image_event = image_at(state, canvasp.x, canvasp.y);
if (image_event && image_event.image_id === context.active_image) {
state.moving_image = image_event;
return;
}
}
if (state.spacedown || e.button === 1) {
state.moving = true;
context.canvas.classList.add('moving');
if (e.button === 1) {
context.canvas.classList.add('mousemoving');
}
return;
}
if (state.tools.active === 'pencil') {
canvasp.pressure = Math.ceil(e.pressure * 255);
geometry_clear_player(state, context, state.me);
geometry_add_point(state, context, state.me, canvasp);
state.drawing = true;
context.active_image = null;
schedule_draw(state, context);
} else if (state.tools.active === 'ruler') {
} else if (state.tools.active === 'eraser') {
state.erasing = true;
}
}
function update_color_picker_color(state, context, canvasp) {
const stroke_index = bvh_point(state, canvasp);
let color_under_cursor = color_from_rgbdict(context.bgcolor);
if (stroke_index != null) {
color_under_cursor = color_from_u32(state.events[stroke_index].color);
}
document.querySelector('.picker-preview-inner').style.background = color_under_cursor;
state.color_picked = color_under_cursor;
}
function mousemove(e, state, context) {
e.preventDefault();
let do_draw = false;
const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY};
const canvasp = screen_to_canvas(state, screenp);
if (state.me in state.players) {
const me = state.players[state.me];
const width = Math.max(me.width * state.canvas.zoom, 2.0);
const radius = Math.round(width / 2);
const brush_x = screenp.x - radius - 2;
const brush_y = screenp.y - radius - 2;
document.querySelector('.brush-dom').style.transform = `translate(${brush_x}px, ${brush_y}px)`;
}
if (state.me in state.players && dist_v2(state.players[state.me].cursor, canvasp) > 5) {
state.players[state.me].cursor = canvasp;
fire_event(state, movecursor_event(canvasp.x, canvasp.y));
}
if (state.colorpicking) {
update_color_picker_color(state, context, canvasp);
}
if (state.zooming) {
const zooming_in = e.movementY > 0;
const zooming_out = e.movementY < 0;
let zoom_level = null;
if (zooming_in) {
zoom_level = state.canvas.zoom_level + 1
} else if (zooming_out) {
zoom_level = state.canvas.zoom_level - 1;
} else {
return;
}
if (zoom_level < config.min_zoom_level || zoom_level > config.max_zoom_level) {
return;
}
const dz = (zoom_level > 0 ? config.zoom_delta : -config.zoom_delta);
state.canvas.zoom_level = zoom_level;
state.canvas.target_zoom = Math.pow(1.0 + dz, Math.abs(zoom_level))
do_draw = true;
}
if (state.moving) {
state.canvas.offset.x += e.movementX;
state.canvas.offset.y += e.movementY;
// If we are moving our canvas, we don't need to follow anymore
if (state.following_player !== null) {
toggle_follow_player(state, state.following_player);
}
fire_event(state, movecanvas_event(state));
draw_html(state, context);
do_draw = true;
}
if (state.moving_image) {
state.moving_image.x += e.movementX / state.canvas.zoom;
state.moving_image.y += e.movementY / state.canvas.zoom;
move_image(context, state.moving_image);
do_draw = true;
}
if (state.drawing) {
canvasp.pressure = Math.ceil(e.pressure * 255);
geometry_add_point(state, context, state.me, canvasp, e.pointerType === "pen");
fire_event(state, predraw_event(canvasp.x, canvasp.y));
do_draw = true;
}
if (state.erasing) {
const p1 = screen_to_canvas(state, state.cursor);
const p2 = { 'x': canvasp.x, 'y': canvasp.y };
const erased = strokes_intersect_line(state, p1, p2);
for (const index of erased) {
if (!state.events[index].deleted) {
state.events[index].deleted = true;
do_draw = true;
geometry_delete_stroke(state, context, index);
}
}
}
if (do_draw) {
schedule_draw(state, context);
}
state.cursor = screenp;
return false;
}
function mouseup(e, state, context) {
if (e.button !== 0 && e.button !== 1) {
return;
}
if (state.zooming) {
state.zooming = false;
return;
}
if (state.moving_image) {
schedule_draw(state, context);
queue_event(state, image_move_event(context.active_image, state.moving_image.x, state.moving_image.y));
state.moving_image = null;
return;
}
if (state.moving || e.button === 1) {
state.moving = false;
context.canvas.classList.remove('moving');
if (e.button === 1) {
context.canvas.classList.remove('mousemoving');
}
return;
}
if (state.drawing) {
const stroke = geometry_prepare_stroke(state);
if (stroke) {
// TODO: be able to add a baked stroke locally
//geometry_add_stroke(state, context, stroke, 0);
queue_event(state, stroke_event(state));
//geometry_clear_player(state, context, state.me);
schedule_draw(state, context);
}
state.drawing = false;
return;
}
if (state.erasing) {
state.erasing = false;
return;
}
}
function mouseleave(e, state, context) {
if (state.moving) {
state.moving = false;
context.canvas.classList.remove('movemode');
}
exit_picker_mode(state);
// something else?
}
function update_cursor(state) {
const me = state.players[state.me];
const width = Math.max(me.width * state.canvas.zoom, 2.0);
const radius = Math.round(width / 2);
const current_color = color_from_u32(me.color);
const stroke = (me.color === 0xFFFFFF ? 'black' : 'white');
const svg = `<svg style="display: block" xmlns="http://www.w3.org/2000/svg" width="${width + 4}" height="${width + 4}">
<circle cx="${radius + 2}" cy="${radius + 2}" r="${radius}" stroke="${stroke}" fill="none" stroke-width="3"/>
<circle cx="${radius + 2}" cy="${radius + 2}" r="${radius}" stroke="none" fill="${current_color}" stroke-width="1"/>
</svg>`.replaceAll('\n', ' ');
document.querySelector('.brush-dom').innerHTML = svg;
const brush_x = state.cursor.x - width / 2 - 2;
const brush_y = state.cursor.y - width / 2 - 2;
document.querySelector('.brush-dom').style.transform = `translate(${Math.round(brush_x)}px, ${Math.round(brush_y)}px)`;
}
function wheel(e, state, context) {
const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY};
const canvasp = screen_to_canvas(state, screenp);
const zooming_in = e.deltaY < 0;
const zoom_level = zooming_in ? state.canvas.zoom_level + 2 : state.canvas.zoom_level - 2;
if (zoom_level < config.min_zoom_level || zoom_level > config.max_zoom_level) {
return;
}
const dz = (zoom_level > 0 ? config.zoom_delta : -config.zoom_delta);
state.canvas.zoom_level = zoom_level;
state.canvas.target_zoom = Math.pow(1.0 + dz, Math.abs(zoom_level))
state.canvas.zoom_screenp = screenp;
// If we are moving our canvas, we don't need to follow anymore
if (state.following_player !== null) {
toggle_follow_player(state, state.following_player);
}
fire_event(state, zoomcanvas_event(state, canvasp.x, canvasp.y));
schedule_draw(state, context);
}
function start_move(e, state, context) {
// two touch identifiers are expected to be pushed into state.touch.ids at this point
geometry_clear_player(state, context, state.me); // Hide predraws of this stroke that is not means to be
fire_event(state, clear_event(state)); // Tell others to hide predraws of this stroke
for (const touch of e.touches) {
const screenp = {'x': window.devicePixelRatio * touch.clientX, 'y': window.devicePixelRatio * touch.clientY};
if (touch.identifier === state.touch.ids[0]) {
state.touch.first_finger_position = screenp;
} else if (touch.identifier === state.touch.ids[1]) {
state.touch.second_finger_position = screenp;
}
}
}
function touchstart(e, state, context) {
e.preventDefault();
// First finger(s) down?
if (state.touch.ids.length === 0) {
if (e.changedTouches.length === 1) {
// We give a bit of time to add a second finger
state.touch.waiting_for_second_finger = true;
state.touch.moves = 0;
state.touch.buffered.length = 0;
state.touch.ids.push(e.changedTouches[0].identifier);
state.touch.drawing = true;
setTimeout(() => {
state.touch.waiting_for_second_finger = false;
}, config.second_finger_timeout);
} else {
state.touch.ids.push(e.changedTouches[0].identifier);
state.touch.ids.push(e.changedTouches[1].identifier);
start_move(e, state, context);
}
return;
}
// There are touches already
if (state.touch.waiting_for_second_finger) {
if (e.changedTouches.length === 1) {
state.touch.ids.push(e.changedTouches[0].identifier);
start_move(e, state, context);
}
return;
}
}
function touchmove(e, state, context) {
if (state.touch.ids.length === 1) {
const touch = find_touch(e.changedTouches, state.touch.ids[0]);
if (!touch) {
return;
}
const screenp = {'x': window.devicePixelRatio * touch.clientX, 'y': window.devicePixelRatio * touch.clientY};
const canvasp = screen_to_canvas(state, screenp);
if (state.touch.moving) {
// Can happen if we have been panning the canvas and lifted one finger,
// but not the second one
return;
}
state.touch.moves += 1;
if (state.touch.moves > config.buffer_first_touchmoves) {
// At this point touch with second finger will NOT start a pan
state.touch.waiting_for_second_finger = false;
}
canvasp.pressure = 128; // TODO: check out touch devices' e.pressure
geometry_add_point(state, context, state.me, canvasp);
fire_event(state, predraw_event(canvasp.x, canvasp.y));
schedule_draw(state, context);
return;
}
if (state.touch.ids.length === 2) {
state.touch.moving = true;
let first_finger_position = null;
let second_finger_position = null;
// A separate loop because touches might be in different order ? (question mark)
// IMPORTANT: e.touches, not e.changedTouches!
for (const touch of e.touches) {
const screenp = {'x': window.devicePixelRatio * touch.clientX, 'y': window.devicePixelRatio * touch.clientY};
if (touch.identifier === state.touch.ids[0]) {
first_finger_position = screenp;
} else if (touch.identifier === state.touch.ids[1]) {
second_finger_position = screenp;
}
}
const old_finger_midpoint = mid_v2(state.touch.first_finger_position, state.touch.second_finger_position);
const new_finger_midpoint = mid_v2(first_finger_position, second_finger_position);
const new_finger_midpoint_canvas = mid_v2(
screen_to_canvas(state, first_finger_position),
screen_to_canvas(state, second_finger_position)
);
const old_finger_distance = dist_v2(state.touch.first_finger_position, state.touch.second_finger_position);
const new_finger_distance = dist_v2(first_finger_position, second_finger_position);
const dx = new_finger_midpoint.x - old_finger_midpoint.x;
const dy = new_finger_midpoint.y - old_finger_midpoint.y;
const old_zoom = state.canvas.zoom;
state.canvas.offset.x += dx;
state.canvas.offset.y += dy;
// console.log(new_finger_distance, state.touch.finger_distance);
const scale_by = new_finger_distance / old_finger_distance;
const dz = state.canvas.zoom * (scale_by - 1.0);
const zoom_offset_x = dz * new_finger_midpoint_canvas.x;
const zoom_offset_y = dz * new_finger_midpoint_canvas.y;
if (config.min_zoom <= state.canvas.zoom * scale_by && state.canvas.zoom * scale_by <= config.max_zoom) {
state.canvas.zoom *= scale_by;
state.canvas.offset.x -= zoom_offset_x;
state.canvas.offset.y -= zoom_offset_y;
}
// If we are moving our canvas, we don't need to follow anymore
if (state.following_player !== null) {
toggle_follow_player(state, state.following_player);
}
state.touch.first_finger_position = first_finger_position;
state.touch.second_finger_position = second_finger_position;
fire_event(state, movecanvas_event(state));
draw_html(state, context);
schedule_draw(state, context);
return;
}
}
function touchend(e, state, context) {
for (const touch of e.changedTouches) {
if (state.touch.drawing) {
if (state.touch.ids[0] == touch.identifier) {
const stroke = geometry_prepare_stroke(state);
if (stroke) {
queue_event(state, stroke_event(state));
//geometry_clear_player(state, context, state.me);
schedule_draw(state, context);
}
state.touch.drawing = false;
}
}
const index = state.touch.ids.indexOf(touch.identifier);
if (index !== -1) {
state.touch.ids.splice(index, 1);
}
}
if (state.touch.ids.length === 0) {
// Only allow drawing again when ALL fingers have been lifted
state.touch.moving = false;
waiting_for_second_finger = false;
}
}
async function on_drop(e, state, context) {
e.preventDefault();
if (e.dataTransfer.files.length !== 1) {
return;
}
const file = e.dataTransfer.files[0];
await insert_image(state, context, file);
return false;
}

556
client/webgl_shaders.js

@ -0,0 +1,556 @@ @@ -0,0 +1,556 @@
const simple_vs_src = `#version 300 es
in vec2 a_pos;
uniform vec2 u_scale;
uniform vec2 u_res;
uniform vec2 u_translation;
out vec2 v_uv;
flat out int v_quad_id;
void main() {
vec2 screen01 = (a_pos * u_scale + u_translation) / u_res;
vec2 screen02 = screen01 * 2.0;
screen02.y = 2.0 - screen02.y;
int vertex_index = gl_VertexID % 6;
if (vertex_index == 0) {
v_uv = vec2(0.0, 0.0);
} else if (vertex_index == 1 || vertex_index == 5) {
v_uv = vec2(1.0, 0.0);
} else if (vertex_index == 2 || vertex_index == 4) {
v_uv = vec2(0.0, 1.0);
} else {
v_uv = vec2(1.0, 1.0);
}
v_quad_id = gl_VertexID / 6;
gl_Position = vec4(screen02 - 1.0, 0.0, 1.0);
}
`;
const simple_fs_src = `#version 300 es
precision highp float;
in vec2 v_uv;
flat in int v_quad_id;
layout(location = 0) out vec4 FragColor;
void main() {
vec2 pixel = fwidth(v_uv);
vec2 border = 2.0 * pixel;
if (border.x <= v_uv.x && v_uv.x <= 1.0 - border.x && border.y <= v_uv.y && v_uv.y <= 1.0 - border.y) {
discard;
} else {
vec3 color = vec3(float(v_quad_id * 869363 % 255) / 255.0, float(v_quad_id * 278975 % 255) / 255.0, float(v_quad_id * 587286 % 255) / 255.0);
float alpha = 0.5;
FragColor = vec4(color * alpha, alpha);
}
}
`;
const opaque_vs_src = `#version 300 es
in vec3 a_pos; // .z is radius
in vec4 a_line;
in int a_stroke_id;
uniform vec2 u_scale;
uniform vec2 u_res;
uniform vec2 u_translation;
uniform int u_stroke_count;
flat out int v_stroke_id;
void main() {
// Do not inflate quad (as opposed to the full sdf shader), thus only leaving the opaque part
// Shrink to not include the caps
vec2 line_dir = normalize(a_line.zw - a_line.xy);
int vertex_index = gl_VertexID % 4;
vec2 pos = a_pos.xy;
if (vertex_index == 0 || vertex_index == 2) {
// vertices on the "beginning" side of the line
pos.xy += line_dir * a_pos.z / 2.0;
} else {
// on the "ending" side of the line
pos.xy -= line_dir * a_pos.z / 2.0;
}
vec2 screen01 = (pos * u_scale + u_translation) / u_res;
vec2 screen02 = screen01 * 2.0;
screen02.y = 2.0 - screen02.y;
v_stroke_id = a_stroke_id;
gl_Position = vec4(screen02 - 1.0, (float(a_stroke_id) / float(u_stroke_count)) * 2.0 - 1.0, 1.0);
}
`;
const nop_fs_src = `#version 300 es
precision highp float;
flat in int v_stroke_id;
layout(location = 0) out vec4 FragColor;
void main() {
vec3 color = vec3(float(v_stroke_id * 3245 % 255) / 255.0, float(v_stroke_id * 7343 % 255) / 255.0, float(v_stroke_id * 5528 % 255) / 255.0);
FragColor = vec4(color, 1.0);
}
`;
const sdf_vs_src = `#version 300 es
in vec2 a_a; // point from
in vec2 a_b; // point to
in int a_stroke_id;
in vec2 a_pressure;
uniform vec2 u_scale;
uniform vec2 u_res;
uniform vec2 u_translation;
uniform int u_stroke_count;
uniform int u_stroke_texture_size;
uniform highp usampler2D u_stroke_data;
out vec4 v_line;
out vec2 v_texcoord;
out vec3 v_color;
flat out vec2 v_thickness;
void main() {
vec2 screen02;
float apron = 1.0; // google "futanari inflation rule 34"
int stroke_data_y = a_stroke_id / u_stroke_texture_size;
int stroke_data_x = a_stroke_id % u_stroke_texture_size;
uvec4 stroke_data = texelFetch(u_stroke_data, ivec2(stroke_data_x, stroke_data_y), 0);
float radius = float(stroke_data.w);
vec2 line_dir = normalize(a_b - a_a);
vec2 up_dir = vec2(line_dir.y, -line_dir.x);
vec2 pixel = vec2(2.0) / u_res * apron;
float rscale = apron / u_scale.x;
int vertex_index = gl_VertexID % 6;
vec2 outwards;
vec2 origin;
if (vertex_index == 0) {
// "top left" aka "p1"
origin = a_a;
outwards = up_dir - line_dir;
} else if (vertex_index == 1 || vertex_index == 5) {
// "top right" aka "p2"
origin = a_b;
outwards = up_dir + line_dir;
} else if (vertex_index == 2 || vertex_index == 4) {
// "bottom left" aka "p3"
origin = a_a;
outwards = -up_dir - line_dir;
} else {
// "bottom right" aka "p4"
origin = a_b;
outwards = -up_dir + line_dir;
}
vec2 pos = origin + normalize(outwards) * radius * 2.0 * max(a_pressure.x, a_pressure.y); // doubling is to account for max possible pressure
screen02 = (pos.xy * u_scale + u_translation) / u_res * 2.0 + outwards * pixel;
v_texcoord = pos.xy + outwards * rscale;
screen02.y = 2.0 - screen02.y;
v_line = vec4(a_a, a_b);
v_thickness = radius * a_pressure; // pressure 0.5 is the "neutral" pressure
v_color = vec3(stroke_data.xyz) / 255.0;
if (a_stroke_id >> 31 != 0) {
screen02 += vec2(100.0); // shift offscreen
}
gl_Position = vec4(screen02 - 1.0, (float(a_stroke_id) / float(u_stroke_count)) * 2.0 - 1.0, 1.0);
}
`;
const sdf_fs_src = `#version 300 es
precision highp float;
uniform int u_debug_mode;
in vec4 v_line;
in vec2 v_texcoord;
in vec3 v_color;
flat in vec2 v_thickness;
layout(location = 0) out vec4 FragColor;
void main() {
if (u_debug_mode == 0) {
vec2 a = v_line.xy;
vec2 b = v_line.zw;
vec2 pa = v_texcoord - a.xy, ba = b.xy - a.xy;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
float dist = length(v_texcoord - (a + ba * h)) - mix(v_thickness.x, v_thickness.y, h);
float fade = 0.5 * length(fwidth(v_texcoord));
float alpha = 1.0 - smoothstep(-fade, fade, dist);
FragColor = vec4(v_color * alpha, alpha);
} else {
FragColor = vec4(0.2, 0.0, 0.0, 0.2);
}
}
`;
const tquad_vs_src = `#version 300 es
in vec2 a_pos;
uniform vec2 u_scale;
uniform vec2 u_res;
uniform vec2 u_translation;
out vec2 v_texcoord;
void main() {
vec2 screen01 = (a_pos * u_scale + u_translation) / u_res;
vec2 screen02 = screen01 * 2.0;
screen02.y = 2.0 - screen02.y;
vec2 screen11 = screen02 - 1.0;
int vertex_index = gl_VertexID % 6;
if (vertex_index == 0) {
v_texcoord = vec2(0.0, 0.0);
} else if (vertex_index == 1 || vertex_index == 5) {
v_texcoord = vec2(1.0, 0.0);
} else if (vertex_index == 2 || vertex_index == 4) {
v_texcoord = vec2(0.0, 1.0);
} else {
v_texcoord = vec2(1.0, 1.0);
}
gl_Position = vec4(screen11, 0, 1);
}
`;
const tquad_fs_src = `#version 300 es
precision highp float;
in vec2 v_texcoord;
uniform sampler2D u_texture;
layout(location = 0) out vec4 FragColor;
void main() {
FragColor = texture(u_texture, v_texcoord);
}
`;
const grid_vs_src = `#version 300 es
in vec2 a_data; // per-instance
out float v_fadeout;
uniform vec2 u_scale;
uniform vec2 u_res;
uniform vec2 u_translation;
uniform float u_fadeout;
void main() {
vec2 origin;
vec2 minor_offset;
vec2 major_offset;
vec2 pixel = 2.0 / u_res;
if (a_data.x > 0.0) {
// Vertical, treat Y as X
float x = a_data.y;
origin = vec2(x, 0.0);
minor_offset = pixel * vec2(1.0, 0.0);
major_offset = vec2(0.0, 2.0);
} else {
// Horizontal, treat Y as Y
float y = a_data.y;
origin = vec2(0.0, y);
minor_offset = pixel * vec2(0.0, 1.0);
major_offset = vec2(2.0, 0.0);
}
vec2 v = (origin * u_scale + u_translation) / u_res * 2.0;
vec2 pos;
if (a_data.x > 0.0) {
v.y = 0.0;
} else {
v.x = 0.0;
}
if (gl_VertexID % 6 == 0) {
pos = v;
} else if (gl_VertexID % 6 == 1 || gl_VertexID % 6 == 5) {
pos = v + (a_data.x > 0.0 ? minor_offset : major_offset);
//pos = v + minor_offset;
} else if (gl_VertexID % 6 == 2 || gl_VertexID % 6 == 4) {
pos = v + (a_data.x > 0.0 ? major_offset : minor_offset);
//pos = v + major_offset;
} else if (gl_VertexID % 6 == 3) {
pos = v + major_offset + minor_offset;
//pos = v + major_offset + minor_offset;
}
vec2 screen02 = pos;
screen02.y = 2.0 - screen02.y;
v_fadeout = u_fadeout;
gl_Position = vec4(screen02 - 1.0, 0.0, 1.0);
}
`;
const dots_vs_src = `#version 300 es
in vec2 a_center; // per-instance
out float v_fadeout;
uniform vec2 u_scale;
uniform vec2 u_res;
uniform vec2 u_translation;
uniform float u_fadeout;
void main() {
vec2 v = (a_center * u_scale + u_translation) / u_res * 2.0;
vec2 pos;
vec2 pixel = 2.0 / u_res;
if (gl_VertexID % 6 == 0) {
pos = v + pixel * vec2(-1.0);
} else if (gl_VertexID % 6 == 1) {
pos = v + pixel * vec2(1.0, -1.0);
} else if (gl_VertexID % 6 == 2) {
pos = v + pixel * vec2(-1.0, 1.0);
} else if (gl_VertexID % 6 == 3) {
pos = v + pixel * vec2(1.0);
} else if (gl_VertexID % 6 == 4) {
pos = v + pixel * vec2(-1.0, 1.0);
} else if (gl_VertexID % 6 == 5) {
pos = v + pixel * vec2(1.0, -1.0);
}
vec2 screen02 = pos;
screen02.y = 2.0 - screen02.y;
v_fadeout = u_fadeout;
gl_Position = vec4(screen02 - 1.0, 0.0, 1.0);
}
`;
const dots_fs_src = `#version 300 es
precision highp float;
in float v_fadeout;
layout(location = 0) out vec4 FragColor;
void main() {
vec3 color = vec3(0.5);
FragColor = vec4(color * v_fadeout, v_fadeout);
}
`;
function init_webgl(state, context) {
context.canvas = document.querySelector('#c');
context.gl = context.canvas.getContext('webgl2', {
'preserveDrawingBuffer': true,
'desynchronized': true,
'antialias': false,
});
const gl = context.gl;
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.GEQUAL);
context.gpu_timer_ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
if (context.gpu_timer_ext === null) {
context.gpu_timer_ext = gl.getExtension('EXT_disjoint_timer_query');
}
const quad_vs = create_shader(gl, gl.VERTEX_SHADER, tquad_vs_src);
const quad_fs = create_shader(gl, gl.FRAGMENT_SHADER, tquad_fs_src);
const sdf_vs = create_shader(gl, gl.VERTEX_SHADER, sdf_vs_src);
const sdf_fs = create_shader(gl, gl.FRAGMENT_SHADER, sdf_fs_src);
const opaque_vs = create_shader(gl, gl.VERTEX_SHADER, opaque_vs_src);
const nop_fs = create_shader(gl, gl.FRAGMENT_SHADER, nop_fs_src);
const simple_vs = create_shader(gl, gl.VERTEX_SHADER, simple_vs_src);
const simple_fs = create_shader(gl, gl.FRAGMENT_SHADER, simple_fs_src);
const dots_vs = create_shader(gl, gl.VERTEX_SHADER, dots_vs_src);
const dots_fs = create_shader(gl, gl.FRAGMENT_SHADER, dots_fs_src);
const grid_vs = create_shader(gl, gl.VERTEX_SHADER, grid_vs_src);
context.programs['image'] = create_program(gl, quad_vs, quad_fs);
context.programs['debug'] = create_program(gl, simple_vs, simple_fs);
context.programs['sdf'] = {
'opaque': create_program(gl, opaque_vs, nop_fs),
'main': create_program(gl, sdf_vs, sdf_fs),
};
context.programs['pattern'] = {
'dots': create_program(gl, dots_vs, dots_fs),
'grid': create_program(gl, grid_vs, dots_fs),
};
context.locations['image'] = {
'a_pos': gl.getAttribLocation(context.programs['image'], 'a_pos'),
'u_res': gl.getUniformLocation(context.programs['image'], 'u_res'),
'u_scale': gl.getUniformLocation(context.programs['image'], 'u_scale'),
'u_translation': gl.getUniformLocation(context.programs['image'], 'u_translation'),
'u_texture': gl.getUniformLocation(context.programs['image'], 'u_texture'),
};
context.locations['debug'] = {
'a_pos': gl.getAttribLocation(context.programs['debug'], 'a_pos'),
'u_res': gl.getUniformLocation(context.programs['debug'], 'u_res'),
'u_scale': gl.getUniformLocation(context.programs['debug'], 'u_scale'),
'u_translation': gl.getUniformLocation(context.programs['debug'], 'u_translation'),
};
context.locations['sdf'] = {
'opaque': {
'a_pos': gl.getAttribLocation(context.programs['sdf'].opaque, 'a_pos'),
'a_line': gl.getAttribLocation(context.programs['sdf'].opaque, 'a_line'),
'a_stroke_id': gl.getAttribLocation(context.programs['sdf'].opaque, 'a_stroke_id'),
'u_res': gl.getUniformLocation(context.programs['sdf'].opaque, 'u_res'),
'u_scale': gl.getUniformLocation(context.programs['sdf'].opaque, 'u_scale'),
'u_translation': gl.getUniformLocation(context.programs['sdf'].opaque, 'u_translation'),
'u_stroke_count': gl.getUniformLocation(context.programs['sdf'].opaque, 'u_stroke_count'),
},
'main': {
'a_a': gl.getAttribLocation(context.programs['sdf'].main, 'a_a'),
'a_b': gl.getAttribLocation(context.programs['sdf'].main, 'a_b'),
'a_stroke_id': gl.getAttribLocation(context.programs['sdf'].main, 'a_stroke_id'),
'a_pressure': gl.getAttribLocation(context.programs['sdf'].main, 'a_pressure'),
'u_res': gl.getUniformLocation(context.programs['sdf'].main, 'u_res'),
'u_scale': gl.getUniformLocation(context.programs['sdf'].main, 'u_scale'),
'u_translation': gl.getUniformLocation(context.programs['sdf'].main, 'u_translation'),
'u_debug_mode': gl.getUniformLocation(context.programs['sdf'].main, 'u_debug_mode'),
'u_stroke_count': gl.getUniformLocation(context.programs['sdf'].main, 'u_stroke_count'),
'u_stroke_data': gl.getUniformLocation(context.programs['sdf'].main, 'u_stroke_data'),
'u_stroke_texture_size': gl.getUniformLocation(context.programs['sdf'].main, 'u_stroke_texture_size'),
}
};
context.locations['pattern'] = {
'dots': {
'a_xy': gl.getAttribLocation(context.programs['pattern'].dots, 'a_xy'),
'a_center': gl.getAttribLocation(context.programs['pattern'].dots, 'a_center'),
'u_res': gl.getUniformLocation(context.programs['pattern'].dots, 'u_res'),
'u_scale': gl.getUniformLocation(context.programs['pattern'].dots, 'u_scale'),
'u_translation': gl.getUniformLocation(context.programs['pattern'].dots, 'u_translation'),
'u_fadeout': gl.getUniformLocation(context.programs['pattern'].dots, 'u_fadeout'),
},
'grid': {
'a_data': gl.getAttribLocation(context.programs['pattern'].grid, 'a_data'),
'u_res': gl.getUniformLocation(context.programs['pattern'].grid, 'u_res'),
'u_scale': gl.getUniformLocation(context.programs['pattern'].grid, 'u_scale'),
'u_translation': gl.getUniformLocation(context.programs['pattern'].grid, 'u_translation'),
'u_fadeout': gl.getUniformLocation(context.programs['pattern'].grid, 'u_fadeout'),
}
};
context.buffers['debug'] = {
'b_packed': gl.createBuffer(),
};
context.buffers['image'] = {
'b_quads': gl.createBuffer(),
};
context.buffers['sdf'] = {
'b_instance': gl.createBuffer(),
'b_dynamic_instance': gl.createBuffer(),
};
context.buffers['pattern'] = {
'b_instance_dot': gl.createBuffer(),
'b_instance_grid': gl.createBuffer(),
'b_dot': gl.createBuffer(),
};
context.textures = {
'stroke_data': gl.createTexture(),
'dynamic_stroke_data': gl.createTexture(),
};
gl.bindTexture(gl.TEXTURE_2D, context.textures['stroke_data']);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16UI, config.stroke_texture_size, config.stroke_texture_size, 0, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, null);
gl.bindTexture(gl.TEXTURE_2D, context.textures['dynamic_stroke_data']);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16UI, config.dynamic_stroke_texture_size, config.dynamic_stroke_texture_size, 0, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, null);
const resize_canvas = (entries) => {
// https://www.khronos.org/webgl/wiki/HandlingHighDPI
const entry = entries[0];
let width;
let height;
if (entry.devicePixelContentBoxSize) {
width = entry.devicePixelContentBoxSize[0].inlineSize;
height = entry.devicePixelContentBoxSize[0].blockSize;
} else if (entry.contentBoxSize) {
// fallback for Safari that will not always be correct
width = Math.round(entry.contentBoxSize[0].inlineSize * devicePixelRatio);
height = Math.round(entry.contentBoxSize[0].blockSize * devicePixelRatio);
}
context.canvas.width = width;
context.canvas.height = height;
schedule_draw(state, context);
}
const resize_observer = new ResizeObserver(resize_canvas);
resize_observer.observe(context.canvas);
}
function create_shader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
return shader;
}
console.error(type, ':', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
}
function create_program(gl, vs, fs) {
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (gl.getProgramParameter(program, gl.LINK_STATUS)) {
return program;
}
console.error('link:', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
}

58
client/websocket.js

@ -6,24 +6,41 @@ @@ -6,24 +6,41 @@
//
// Details best described here: https://github.com/kee-org/KeeFox/issues/189
function ws_connect(first_connect = false) {
const session_id = ls.getItem('sessionId') || '0';
const desk_id = storage.desk_id;
ws = new WebSocket(`${config.ws_url}?deskId=${desk_id}&sessionId=${session_id}`);
ws.addEventListener('open', on_open);
ws.addEventListener('message', on_message);
ws.addEventListener('error', on_error);
ws.addEventListener('close', on_close);
async function ws_connect(state, context, first_connect = false) {
const session_id = localStorage.getItem('sessionId') || '0';
const desk_id = state.desk_id;
try {
const resp = await fetch(config.ping_url);
if (resp.ok) {
const text = await resp.text();
if (text === 'pong') {
ws = new WebSocket(`${config.ws_url}?deskId=${desk_id}&sessionId=${session_id}`);
ws.addEventListener('open', () => on_open(state));
ws.addEventListener('message', (e) => on_message(state, context, e));
ws.addEventListener('error', () => on_error(state, context));
ws.addEventListener('close', () => on_close(state, context));
return;
}
}
} catch (e) {
console.log('Could not ping the server:', e);
}
state.timers.offline_toast = setTimeout(() => ws_connect(state, context, first_connect), config.ws_reconnect_timeout);
}
function on_open() {
clearTimeout(storage.timers.ws_reconnect);
function on_open(state) {
clearTimeout(state.timers.offline_toast);
ui_online();
if (config.debug_print) console.debug('open')
}
async function on_message(event) {
async function on_message(state, context, event) {
const data = event.data;
let message_data = null;
@ -31,7 +48,7 @@ async function on_message(event) { @@ -31,7 +48,7 @@ async function on_message(event) {
message_data = await data.arrayBuffer();
const view = new DataView(message_data);
const d = deserializer_create(message_data, view);
await handle_message(d);
await handle_message(state, context, d);
} else {
/* For all my Safari < 14 bros out there */
const reader = new FileReader();
@ -39,19 +56,20 @@ async function on_message(event) { @@ -39,19 +56,20 @@ async function on_message(event) {
message_data = e.target.result;
const view = new DataView(message_data);
const d = deserializer_create(message_data, view);
await handle_message(d);
await handle_message(state, context, d);
};
reader.readAsArrayBuffer(data);
}
}
function on_close() {
function on_close(state, context) {
state.timers.offline_toast = setTimeout(() => ui_offline(), config.initial_offline_timeout);
ws = null;
if (config.debug_print) console.debug('close');
storage.timers.ws_reconnect = setTimeout(ws_connect, config.ws_reconnect_timeout);
ws_connect(state, context, false);
}
function on_error() {
ws.close();
}
function on_error(state, context) {
ws.close(state, context);
}

4
server/config.js

@ -2,4 +2,6 @@ export const HOST = '127.0.0.1'; @@ -2,4 +2,6 @@ export const HOST = '127.0.0.1';
export const PORT = 3003;
export const DATADIR = 'data';
export const SYNC_TIMEOUT = 1000;
export const IMAGEDIR = 'images';
export const SYNC_MAX_ATTEMPTS = 3;
export const IMAGEDIR = 'images';
export const DEBUG_PRINT = true;

BIN
server/data-local.sqlite

Binary file not shown.

84
server/deserializer.js

@ -26,40 +26,100 @@ export function u32(d) { @@ -26,40 +26,100 @@ export function u32(d) {
return value;
}
function u16array(d, count) {
const array = new Uint16Array(d.view.buffer, d.offset, count);
d.offset += count * 2;
export function s32(d) {
const value = d.view.getInt32(d.offset, true);
d.offset += 4;
return value;
}
export function f32(d) {
const value = d.view.getFloat32(d.offset, true);
d.offset += 4;
return value;
}
function f32array(d, count) {
const array = new Float32Array(d.view.buffer, d.offset, count);
d.offset += count * 4;
return array;
}
function u8array(d, count) {
const array = new Uint8Array(d.view.buffer, d.offset, count);
d.offset += count;
return array;
}
export function align(d, to) {
while (d.offset % to !== 0) {
d.offset++;
}
}
export function event(d) {
const event = {};
event.type = u8(d);
u8(d); // padding
event.type = u32(d);
switch (event.type) {
case EVENT.PREDRAW: {
event.x = u16(d);
event.y = u16(d);
event.x = f32(d);
event.y = f32(d);
break;
}
case EVENT.MOVE_CURSOR: {
event.x = f32(d);
event.y = f32(d);
break;
}
case EVENT.MOVE_CANVAS: {
event.offset_x = u32(d);
event.offset_y = u32(d);
event.zoom_level = s32(d);
break;
}
case EVENT.ZOOM_CANVAS: {
event.zoom_level = s32(d);
event.zoom_cx = f32(d);
event.zoom_cy = f32(d);
break;
}
case EVENT.CLEAR: {
break;
}
case EVENT.SET_COLOR: {
event.color = u32(d);
break;
}
case EVENT.SET_WIDTH: {
event.width = u16(d);
break;
}
case EVENT.STROKE: {
// point_count + width align to 4 bytes :D
const point_count = u16(d);
const width = u16(d);
const color = u32(d);
event.width = width;
event.color = color;
event.points = u16array(d, point_count * 2);
event.points = f32array(d, point_count * 2);
event.pressures = u8array(d, point_count);
align(d, 4);
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
event.image_id = u32(d);
event.x = u16(d);
event.y = u16(d);
event.x = f32(d);
event.y = f32(d);
break;
}
@ -74,11 +134,11 @@ export function event(d) { @@ -74,11 +134,11 @@ export function event(d) {
}
default: {
console.error('fuck');
console.error('fuck', event.type);
console.trace();
process.exit(1);
}
}
return event;
}
}

16
server/enums.js

@ -6,6 +6,16 @@ export const SESSION = Object.freeze({ @@ -6,6 +6,16 @@ export const SESSION = Object.freeze({
export const EVENT = Object.freeze({
PREDRAW: 10,
SET_COLOR: 11,
SET_WIDTH: 12,
CLEAR: 13,
MOVE_CURSOR: 14,
LEAVE: 16,
MOVE_CANVAS: 17,
USER_JOINED: 18,
ZOOM_CANVAS: 19,
STROKE: 20,
UNDO: 30,
REDO: 31,
@ -21,9 +31,5 @@ export const MESSAGE = Object.freeze({ @@ -21,9 +31,5 @@ export const MESSAGE = Object.freeze({
FULL: 103,
FIRE: 104,
JOIN: 105,
FOLLOW: 106,
});
export const SNS = Object.freeze({
DESK: 1,
SESSION: 2,
});

12
server/http.js

@ -7,16 +7,14 @@ export async function route(req) { @@ -7,16 +7,14 @@ export async function route(req) {
if (url.pathname === '/api/image') {
const desk_id = url.searchParams.get('deskId') || '0';
const formData = await req.formData();
const file = formData.get('file');
const image_id = math.fast_random32();
const formdata = await req.formData();
const file = formdata.get('file');
const image_id = math.crypto_random32();
await Bun.write(config.IMAGEDIR + '/' + image_id, file);
storage.put_image(image_id, desk_id);
Bun.write(config.IMAGEDIR + '/' + image_id, file);
return new Response(image_id);
} else if (url.pathname === '/api/ping') {
return new Response('pong');
}
}
}

6
server/math.js

@ -9,6 +9,6 @@ export function crypto_random32() { @@ -9,6 +9,6 @@ export function crypto_random32() {
return dataview.getUint32(0);
}
export function fast_random32() {
return Math.floor(Math.random() * 4294967296);
}
export function round_to_pow2(value, multiple) {
return (value + multiple - 1) & -multiple;
}

70
server/milton.js

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
import * as storage from './storage.js'
import * as math from './math.js'
import { SESSION, MESSAGE, EVENT } from './enums';
let first_point_x = null;
let first_point_y = null;
function parse_and_insert_stroke(desk_id, line) {
const words = line.split(' ');
const width = parseInt(words.shift());
const data = new Float32Array(words.map(i => parseFloat(i)));
const points = new Float32Array(data.length / 3 * 2);
const pressures = new Uint8Array(data.length / 3);
if (first_point_x === null) {
first_point_x = points[0];
first_point_y = points[1];
}
for (let i = 0; i < data.length; i += 3) {
points[i / 3 * 2 + 0] = data[i + 0];
points[i / 3 * 2 + 1] = data[i + 1];
pressures[i / 3 + 0] = Math.floor(data[i + 2] * 255);
}
const stroke_res = storage.queries.insert_stroke.get({
'$width': width,
'$color': 0,
'$points': points,
'$pressures': pressures,
});
storage.queries.insert_event.run({
'$type': EVENT.STROKE,
'$desk_id': desk_id,
'$session_id': 0,
'$stroke_id': stroke_res.id,
'$image_id': 0,
'$x': 0,
'$y': 0,
});
}
async function import_milton_file_to_sqlite(fullpath) {
storage.startup();
const desk_id = 9881; // math.fast_random32();
console.log(`Importing ${fullpath} into desk ${desk_id}`);
storage.queries.insert_desk.run({
'$id': desk_id,
'$title': `Desk ${desk_id}`
});
const input_file = Bun.file(fullpath);
const input_text = await input_file.text();
const input_lines = input_text.split('\n');
for (let i = 0; i < input_lines.length; ++i) {
console.log(`Importing ${i}/${input_lines.length}`);
parse_and_insert_stroke(desk_id, input_lines[i]);
}
console.log(`Finished importing desk ${desk_id}`);
}
import_milton_file_to_sqlite("/home/aolo2/Documents/bin/milton/build/points_pressure.txt");

154
server/recv.js

@ -2,9 +2,10 @@ import * as des from './deserializer'; @@ -2,9 +2,10 @@ import * as des from './deserializer';
import * as send from './send';
import * as math from './math';
import * as storage from './storage';
import * as config from './config';
import { SESSION, MESSAGE, EVENT } from './enums';
import { sessions, desks } from './storage';
import { sessions, desks, queries } from './storage';
// Session ACKed events up to SN
function recv_ack(d, session) {
@ -12,65 +13,47 @@ function recv_ack(d, session) { @@ -12,65 +13,47 @@ function recv_ack(d, session) {
session.state = SESSION.READY;
session.sn = sn;
session.sync_attempts = 0;
console.log(`ack ${sn} in`);
}
function handle_event(session, event) {
switch (event.type) {
case EVENT.STROKE: {
event.stroke_id = math.fast_random32();
storage.put_stroke(event.stroke_id, session.desk_id, event.points, event.width, event.color);
storage.put_event(event);
break;
}
case EVENT.ERASER:
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE:
case EVENT.UNDO: {
storage.put_event(event);
break;
}
default: {
console.error('fuck');
console.trace();
process.exit(1);
}
}
if (config.DEBUG_PRINT) console.log(`ack ${sn} in`);
}
async function recv_syn(d, session) {
const padding = des.u8(d);
const lsn = des.u32(d);
const count = des.u32(d);
console.log(`syn ${lsn} in, total size = ${d.size}`);
if (config.DEBUG_PRINT) console.log(`syn ${lsn} in, total size = ${d.size}`);
const we_expect = lsn - session.lsn;
const first = count - we_expect;
const events = [];
console.log(`we expect ${we_expect}, count ${count}`);
if (config.DEBUG_PRINT) console.log(`we expect ${we_expect}, count ${count}`);
for (let i = 0; i < count; ++i) {
const event = des.event(d);
if (i >= first) {
event.desk_id = session.desk_id;
event.user_id = session.user_id;
event.user_id = session.id;
handle_event(session, event);
events.push(event);
}
}
desks[session.desk_id].sn += we_expect;
desks[session.desk_id].events.push(...events);
session.lsn = lsn;
storage.save_desk_sn(session.desk_id, desks[session.desk_id].sn);
storage.save_session_lsn(session.id, lsn);
storage.queries.update_desk_sn.run({
'$id': session.desk_id,
'$sn': desks[session.desk_id].sn
});
storage.queries.update_session_lsn.run({
'$id': session.id,
'$lsn': lsn
});
send.send_ack(session.ws, lsn);
send.sync_desk(session.desk_id);
}
@ -78,22 +61,98 @@ async function recv_syn(d, session) { @@ -78,22 +61,98 @@ async function recv_syn(d, session) {
function recv_fire(d, session) {
const event = des.event(d);
for (const sid in sessions) {
const other = sessions[sid];
if (other.id === session.id) {
continue;
event.user_id = session.id;
switch (event.type) {
case EVENT.SET_COLOR: {
session.color = event.color;
storage.queries.update_session_color.run({
'$id': session.id,
'$color': event.color
});
break;
}
case EVENT.SET_WIDTH: {
session.width = event.width;
storage.queries.update_session_width.run({
'$id': session.id,
'$width': event.width
});
break;
}
}
send.fire_event(session, event);
}
function recv_follow(d, session) {
const user_id = des.u32(d);
if (other.state !== SESSION.READY) {
continue;
if (config.DEBUG_PRINT) console.log(`follow ${user_id} in`);
if (user_id === 4294967295) {
// unfollow
session.follow = null;
} else {
// follow
session.follow = user_id;
}
}
function handle_event(session, event) {
switch (event.type) {
case EVENT.STROKE: {
const stroke_result = storage.queries.insert_stroke.get({
'$width': event.width,
'$color': event.color,
'$points': event.points,
'$pressures': event.pressures,
});
event.stroke_id = stroke_result.id;
storage.queries.insert_event.run({
'$type': event.type,
'$desk_id': session.desk_id,
'$session_id': session.id,
'$stroke_id': event.stroke_id,
'$image_id': 0,
'$x': 0,
'$y': 0,
});
desks[session.desk_id].total_points += event.points.length;
break;
}
if (other.desk_id != session.desk_id) {
continue;
case EVENT.ERASER:
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE:
case EVENT.UNDO: {
storage.queries.insert_event.run({
'$type': event.type,
'$desk_id': session.desk_id,
'$session_id': session.id,
'$stroke_id': event.stroke_id || 0,
'$image_id': event.image_id || 0,
'$x': event.x || 0,
'$y': event.y || 0,
});
break;
}
send.send_fire(other.ws, session.user_id, event);
default: {
console.error('fuck');
console.trace();
process.exit(1);
}
}
}
@ -104,7 +163,7 @@ export async function handle_message(ws, d) { @@ -104,7 +163,7 @@ export async function handle_message(ws, d) {
const session = sessions[ws.data.session_id];
const desk_id = session.desk_id;
const message_type = des.u8(d);
const message_type = des.u32(d);
switch (message_type) {
case MESSAGE.FIRE: {
@ -122,6 +181,11 @@ export async function handle_message(ws, d) { @@ -122,6 +181,11 @@ export async function handle_message(ws, d) {
break;
}
case MESSAGE.FOLLOW: {
recv_follow(d, session);
break;
}
default: {
console.error('fuck');
console.trace();

146
server/send.js

@ -7,23 +7,51 @@ import { MESSAGE, SESSION, EVENT } from './enums'; @@ -7,23 +7,51 @@ import { MESSAGE, SESSION, EVENT } from './enums';
import { sessions, desks } from './storage';
function event_size(event) {
let size = 1 + 4; // type + user_id
let size = 4 + 4; // type + user_id
switch (event.type) {
case EVENT.PREDRAW: {
size += 2 * 2;
case EVENT.PREDRAW:
case EVENT.MOVE_CURSOR: {
size += 4 * 2;
break;
}
case EVENT.MOVE_CANVAS: {
size += 4 * 2 + 4;
break;
}
case EVENT.ZOOM_CANVAS: {
size += 4 + 4 * 2;
break;
}
case EVENT.USER_JOINED:
case EVENT.LEAVE:
case EVENT.CLEAR: {
break;
}
case EVENT.SET_COLOR: {
size += 4;
break;
}
case EVENT.SET_WIDTH: {
size += 2;
break;
}
case EVENT.STROKE: {
size += 4 + 2 + 2 + 4; // stroke id + point count + width + color
size += event.points.byteLength;
size += math.round_to_pow2(event.pressures.byteLength, 4);
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
size += 4 + 2 + 2; // file id + x + y
size += 4 + 4 + 4; // file id + x + y
break;
}
@ -38,6 +66,7 @@ function event_size(event) { @@ -38,6 +66,7 @@ function event_size(event) {
}
default: {
console.error(event.desk_id);
console.error('fuck');
console.trace();
process.exit(1);
@ -48,23 +77,21 @@ function event_size(event) { @@ -48,23 +77,21 @@ function event_size(event) {
}
function create_session(ws, desk_id) {
const user = {
id: math.crypto_random32(),
login: 'unnamed',
};
const session = {
id: math.crypto_random32(),
user_id: user.id,
desk_id: desk_id,
state: SESSION.OPENED,
sn: 0,
lsn: 0,
ws: ws,
color: 0x00,
width: 8,
};
storage.create_user(user);
storage.create_session(session);
storage.queries.insert_session.run({
'$id': session.id,
'$desk_id': desk_id
});
sessions[session.id] = session;
@ -81,11 +108,12 @@ export async function send_init(ws) { @@ -81,11 +108,12 @@ export async function send_init(ws) {
const desk = desks[desk_id];
let opcode = MESSAGE.INIT;
let size = 1 + 4 + 4 + 4 + 4; // opcode + user_id + lsn + event count + stroke count
let size = 4 + 4 + 4 + 4 + 4 + 4; // opcode + user_id + lsn + event count + stroke count + user count + total_point_count
let session = null;
if (session_id in sessions && sessions[session_id].desk_id == desk_id) {
session = sessions[session_id];
size += 4 + 2; // color + width
} else {
size += 4; // session id
opcode = MESSAGE.JOIN;
@ -98,7 +126,17 @@ export async function send_init(ws) { @@ -98,7 +126,17 @@ export async function send_init(ws) {
session.sn = 0; // Always re-send everything on reconnect
session.state = SESSION.OPENED;
console.log(`session ${session.id} opened`);
if (config.DEBUG_PRINT) console.log(`session ${session.id} opened`);
let user_count = 0;
for (const sid in sessions) {
const other_session = sessions[sid];
if (other_session.id !== session_id && other_session.desk_id === desk_id) {
++user_count;
size += 4 + 4 + 2 + 1; // user id + color + width + online
}
}
for (const event of desk.events) {
size += event_size(event);
@ -106,15 +144,32 @@ export async function send_init(ws) { @@ -106,15 +144,32 @@ export async function send_init(ws) {
const s = ser.create(size);
ser.u8(s, opcode);
ser.u32(s, session.user_id);
ser.u32(s, opcode);
ser.u32(s, session.lsn);
if (opcode === MESSAGE.JOIN) {
ser.u32(s, session.id);
} else {
ser.u32(s, session.color);
ser.u16(s, session.width);
}
ser.u32(s, desk.events.length);
ser.u32(s, user_count);
ser.u32(s, desk.total_points);
for (const sid in sessions) {
const other_session = sessions[sid];
if (other_session.id !== session_id && other_session.desk_id === desk_id) {
// console.log(other_session.id, other_session.color, other_session.width);
ser.u32(s, other_session.id);
ser.u32(s, other_session.color);
ser.u16(s, other_session.width);
ser.u8(s, other_session.state === SESSION.READY);
}
}
ser.align(s, 4);
for (const event of desk.events) {
ser.event(s, event);
@ -128,31 +183,55 @@ export function send_ack(ws, lsn) { @@ -128,31 +183,55 @@ export function send_ack(ws, lsn) {
return;
}
const size = 1 + 4; // opcode + lsn
const size = 4 + 4; // opcode + lsn
const s = ser.create(size);
ser.u8(s, MESSAGE.ACK);
ser.u32(s, MESSAGE.ACK);
ser.u32(s, lsn);
console.log(`ack ${lsn} out`);
if (config.DEBUG_PRINT) console.log(`ack ${lsn} out`);
ws.send(s.buffer);
}
export function send_fire(ws, user_id, event) {
function send_fire(ws, event) {
if (!ws) {
return;
}
const s = ser.create(1 + 4 + event_size(event));
const s = ser.create(4 + 4 + event_size(event));
ser.u8(s, MESSAGE.FIRE);
ser.u32(s, user_id);
ser.u32(s, MESSAGE.FIRE);
ser.event(s, event);
ws.send(s.buffer);
}
export function fire_event(from_session, event) {
for (const sid in sessions) {
const other = sessions[sid];
if (other.id === from_session.id) {
continue;
}
if (other.state !== SESSION.READY) {
continue;
}
if (other.desk_id != from_session.desk_id) {
continue;
}
if (event.type === EVENT.MOVE_CANVAS && other.follow !== from_session.id) {
// Do not spam canvas move events to those who don't follow us
continue;
}
send_fire(other.ws, event);
}
}
async function sync_session(session_id) {
if (!(session_id in sessions)) {
return;
@ -169,11 +248,11 @@ async function sync_session(session_id) { @@ -169,11 +248,11 @@ async function sync_session(session_id) {
return;
}
let size = 1 + 4 + 4; // opcode + sn + event count
let count = desk.sn - session.sn;
let size = 4 + 4 + 4; // opcode + sn + event count
let count = desk.sn - session.sn;
if (count === 0) {
console.log('client ACKed all events');
if (config.DEBUG_PRINT) console.log('client ACKed all events');
return;
}
@ -184,7 +263,7 @@ async function sync_session(session_id) { @@ -184,7 +263,7 @@ async function sync_session(session_id) {
const s = ser.create(size);
ser.u8(s, MESSAGE.SYN);
ser.u32(s, MESSAGE.SYN);
ser.u32(s, desk.sn);
ser.u32(s, count);
@ -192,12 +271,15 @@ async function sync_session(session_id) { @@ -192,12 +271,15 @@ async function sync_session(session_id) {
const event = desk.events[desk.events.length - 1 - i];
ser.event(s, event);
}
console.debug(`syn ${desk.sn} out`);
if (config.DEBUG_PRINT) console.log(`syn ${desk.sn} out`);
await session.ws.send(s.buffer);
session.sync_timer = setTimeout(() => sync_session(session_id), config.SYNC_TIMEOUT);
if (session.sync_attempts < config.SYNC_MAX_ATTEMPTS) {
session.sync_attempts += 1;
session.sync_timer = setTimeout(() => sync_session(session_id), config.SYNC_TIMEOUT);
}
}
export function sync_desk(desk_id) {
@ -210,4 +292,4 @@ export function sync_desk(desk_id) { @@ -210,4 +292,4 @@ export function sync_desk(desk_id) {
sync_session(sid);
}
}
}
}

68
server/serializer.js

@ -21,42 +21,98 @@ export function u16(s, value) { @@ -21,42 +21,98 @@ export function u16(s, value) {
s.offset += 2;
}
export function f32(s, value) {
s.view.setFloat32(s.offset, value, true);
s.offset += 4;
}
export function u32(s, value) {
s.view.setUint32(s.offset, value, true);
s.offset += 4;
}
export function s32(s, value) {
s.view.setInt32(s.offset, value, true);
s.offset += 4;
}
export function bytes(s, bytes) {
s.strview.set(new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength), s.offset);
s.offset += bytes.byteLength;
}
export function align(s, to) {
// TODO: non-stupid version of this
while (s.offset % to != 0) {
s.offset++;
}
}
export function event(s, event) {
u8(s, event.type);
u32(s, event.type); // for alignment reasons
u32(s, event.user_id);
switch (event.type) {
case EVENT.PREDRAW: {
u16(s, event.x);
u16(s, event.y);
f32(s, event.x);
f32(s, event.y);
break;
}
case EVENT.MOVE_CURSOR: {
f32(s, event.x);
f32(s, event.y);
break;
}
case EVENT.MOVE_CANVAS: {
u32(s, event.offset_x);
u32(s, event.offset_y);
s32(s, event.zoom_level);
break;
}
case EVENT.ZOOM_CANVAS: {
s32(s, event.zoom_level);
f32(s, event.zoom_cx);
f32(s, event.zoom_cy);
break;
}
case EVENT.USER_JOINED:
case EVENT.LEAVE:
case EVENT.CLEAR: {
break;
}
case EVENT.SET_COLOR: {
u32(s, event.color);
break;
}
case EVENT.SET_WIDTH: {
u16(s, event.width);
break;
}
case EVENT.STROKE: {
const points_bytes = event.points;
const pressures_bytes = event.pressures;
u32(s, event.stroke_id);
u16(s, points_bytes.byteLength / 2 / 2); // each point is 2 u16s
u16(s, points_bytes.byteLength / 2 / 4); // each point is 2 * f32
u16(s, event.width);
u32(s, event.color);
bytes(s, points_bytes);
bytes(s, pressures_bytes);
align(s, 4);
break;
}
case EVENT.IMAGE:
case EVENT.IMAGE_MOVE: {
u32(s, event.image_id);
u16(s, event.x);
u16(s, event.y);
f32(s, event.x);
f32(s, event.y);
break;
}

16
server/server.js

@ -1,13 +1,11 @@ @@ -1,13 +1,11 @@
import * as config from './config';
import * as storage from './storage';
import * as http_server from './http';
import * as math from './math';
import * as ser from './serializer';
import * as des from './deserializer';
import * as send from './send';
import * as recv from './recv';
import { MESSAGE, EVENT, SESSION, SNS } from './enums';
import { MESSAGE, EVENT, SESSION } from './enums';
import { sessions, desks } from './storage';
export function startup() {
@ -29,7 +27,11 @@ export function startup() { @@ -29,7 +27,11 @@ export function startup() {
events: [],
};
storage.create_desk(desk_id);
storage.queries.insert_desk.run({
'$id': desk_id,
'$title': `Desk ${desk_id}`
});
desks[desk_id] = desk;
}
@ -49,6 +51,8 @@ export function startup() { @@ -49,6 +51,8 @@ export function startup() {
websocket: {
open(ws) {
send.send_init(ws);
const userjoin_event = {'type': EVENT.USER_JOINED, 'user_id': ws.data.session_id};
send.fire_event(sessions[ws.data.session_id], userjoin_event);
},
async message(ws, u8array) {
@ -59,6 +63,8 @@ export function startup() { @@ -59,6 +63,8 @@ export function startup() {
close(ws, code, message) {
if (ws.data.session_id in sessions) {
const leave_event = {'type': EVENT.LEAVE, 'user_id': ws.data.session_id};
send.fire_event(sessions[ws.data.session_id], leave_event);
console.log(`session ${ws.data.session_id} closed`);
sessions[ws.data.session_id].state = SESSION.CLOSED;
sessions[ws.data.session_id].ws = null;
@ -72,4 +78,4 @@ export function startup() { @@ -72,4 +78,4 @@ export function startup() {
});
console.log(`Running on ${config.HOST}:${config.PORT}`)
}
}

179
server/storage.js

@ -3,11 +3,12 @@ import * as sqlite from 'bun:sqlite'; @@ -3,11 +3,12 @@ import * as sqlite from 'bun:sqlite';
import { EVENT, SESSION } from './enums';
// In-memory views
export const sessions = {};
export const desks = {};
let db = null;
const queries = {};
export const queries = {};
export let db = null;
export function startup() {
const path = `${config.DATADIR}/db.sqlite`;
@ -20,31 +21,12 @@ export function startup() { @@ -20,31 +21,12 @@ export function startup() {
title TEXT
);`).run();
db.query(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
login TEXT
);`).run();
db.query(`CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY,
user_id INTEGER,
desk_id INTEGER,
lsn INTEGER,
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE
ON UPDATE NO ACTION,
FOREIGN KEY (desk_id)
REFERENCES desks (id)
ON DELETE CASCADE
ON UPDATE NO ACTION
);`).run();
db.query(`CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY,
desk_id INTEGER,
color INTEGER,
width INTEGER,
FOREIGN KEY (desk_id)
REFERENCES desks (id)
@ -54,22 +36,17 @@ export function startup() { @@ -54,22 +36,17 @@ export function startup() {
db.query(`CREATE TABLE IF NOT EXISTS strokes (
id INTEGER PRIMARY KEY,
desk_id INTEGER,
points BLOB,
width INTEGER,
color INTEGER,
FOREIGN KEY (desk_id)
REFERENCES desks (id)
ON DELETE CASCADE
ON UPDATE NO ACTION
points BLOB,
pressures BLOB
);`).run();
db.query(`CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
type INTEGER,
desk_id INTEGER,
user_id INTEGER,
session_id INTEGER,
stroke_id INTEGER,
image_id INTEGER,
x INTEGER,
@ -80,150 +57,84 @@ export function startup() { @@ -80,150 +57,84 @@ export function startup() {
ON DELETE CASCADE
ON UPDATE NO ACTION
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE
FOREIGN KEY (session_id)
REFERENCES sessions (id)
ON DELETE NO ACTION
ON UPDATE NO ACTION
FOREIGN KEY (stroke_id)
REFERENCES strokes (id)
ON DELETE CASCADE
ON UPDATE NO ACTION
FOREIGN KEY (image_id)
REFERENCES images (id)
ON DELETE CASCADE
ON UPDATE NO ACTION
);`).run();
db.query(`CREATE INDEX IF NOT EXISTS idx_events_desk_id
ON events (desk_id);
`).run();
// INSERT
queries.insert_desk = db.query('INSERT INTO desks (id, title, sn) VALUES ($id, $title, 0) RETURNING id');
queries.insert_stroke = db.query('INSERT INTO strokes (width, color, points, pressures) VALUES ($width, $color, $points, $pressures) RETURNING id');
queries.insert_session = db.query('INSERT INTO sessions (id, desk_id, lsn) VALUES ($id, $desk_id, 0) RETURNING id');
queries.insert_event = db.query('INSERT INTO events (type, desk_id, session_id, stroke_id, image_id, x, y) VALUES ($type, $desk_id, $session_id, $stroke_id, $image_id, $x, $y) RETURNING id');
db.query(`CREATE INDEX IF NOT EXISTS idx_strokes_desk_id
ON strokes (desk_id);
`).run();
// UPDATE
queries.update_desk_sn = db.query('UPDATE desks SET sn = $sn WHERE id = $id');
queries.update_session_lsn = db.query('UPDATE sessions SET lsn = $lsn WHERE id = $id');
queries.update_session_color = db.query('UPDATE sessions SET color = $color WHERE id = $id');
queries.update_session_width = db.query('UPDATE sessions SET width = $width WHERE id = $id');
const res1 = db.query('SELECT COUNT(id) as count FROM desks').get();
const res2 = db.query('SELECT COUNT(id) as count FROM events').get();
const res3 = db.query('SELECT COUNT(id) as count FROM strokes').get();
const res4 = db.query('SELECT COUNT(id) as count FROM users').get();
const res5 = db.query('SELECT COUNT(id) as count FROM sessions').get();
const res6 = db.query('SELECT COUNT(id) as count FROM images').get();
queries.desks = db.query('SELECT id, sn FROM desks');
queries.events = db.query('SELECT * FROM events');
queries.sessions = db.query('SELECT id, lsn, user_id, desk_id FROM sessions');
queries.strokes = db.query('SELECT * FROM strokes');
queries.empty_desk = db.query('INSERT INTO desks (id, title, sn) VALUES (?1, ?2, 0)');
queries.desk_strokes = db.query('SELECT id, points FROM strokes WHERE desk_id = ?1');
queries.put_desk_stroke = db.query('INSERT INTO strokes (id, desk_id, points, width, color) VALUES (?1, ?2, ?3, ?4, ?5)');
queries.clear_desk_events = db.query('DELETE FROM events WHERE desk_id = ?1');
queries.set_desk_sn = db.query('UPDATE desks SET sn = ?1 WHERE id = ?2');
queries.save_session_lsn = db.query('UPDATE sessions SET lsn = ?1 WHERE id = ?2');
queries.create_session = db.query('INSERT INTO sessions (id, lsn, user_id, desk_id) VALUES (?1, 0, ?2, ?3)');
queries.create_user = db.query('INSERT INTO users (id, login) VALUES (?1, ?2)');
queries.put_event = db.query('INSERT INTO events (type, desk_id, user_id, stroke_id, image_id, x, y) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)');
queries.put_image = db.query('INSERT INTO images (id, desk_id) VALUES (?1, ?2)');
const res4 = db.query('SELECT COUNT(id) as count FROM sessions').get();
console.log(`Storing data in ${path}`);
console.log(`Entity count at startup:
${res1.count} desks
${res2.count} events
${res3.count} strokes
${res4.count} users
${res5.count} sessions
${res6.count} images`
);
${res4.count} sessions
`);
const stored_desks = get_desks();
const stored_events = get_events();
const stored_strokes = get_strokes();
const stored_sessions = get_sessions();
// Init in-memory view: merge strokes into events, set all sessions to closed
const stored_desks = db.query('SELECT * FROM desks').all();
const stored_events = db.query('SELECT * FROM events').all();
const stored_strokes = db.query('SELECT * FROM strokes').all();
const stored_sessions = db.query('SELECT * FROM sessions').all();
const stroke_dict = {};
for (const stroke of stored_strokes) {
stroke.points = new Uint16Array(stroke.points.buffer);
stroke_dict[stroke.id] = stroke;
}
for (const desk of stored_desks) {
desks[desk.id] = desk;
desks[desk.id].events = [];
desks[desk.id].total_points = 0;
}
for (const stroke of stored_strokes) {
stroke.points = new Float32Array(stroke.points.buffer);
stroke_dict[stroke.id] = stroke;
}
for (const event of stored_events) {
if (event.type === EVENT.STROKE) {
const stroke = stroke_dict[event.stroke_id];
event.points = stroke.points;
event.pressures = stroke.pressures;
event.color = stroke.color;
event.width = stroke.width;
}
desks[event.desk_id].total_points += stroke.points.length / 2;
}
desks[event.desk_id].events.push(event);
}
for (const desk of stored_desks) {
desk.sn = desk.events.length;
}
for (const session of stored_sessions) {
session.state = SESSION.CLOSED;
session.ws = null;
session.sync_attempts = 0;
session.follow = null;
sessions[session.id] = session;
}
}
export function get_strokes() {
return queries.strokes.all();
}
export function get_sessions() {
return queries.sessions.all();
}
export function get_desks() {
return queries.desks.all();
}
export function get_events() {
return queries.events.all();
}
export function get_desk_strokes(desk_id) {
return queries.desk_strokes.all(desk_id);
}
export function put_event(event) {
return queries.put_event.get(event.type, event.desk_id || 0, event.user_id || 0, event.stroke_id || 0, event.image_id || 0, event.x || 0, event.y || 0);
}
export function put_stroke(stroke_id, desk_id, points, width, color) {
return queries.put_desk_stroke.get(stroke_id, desk_id, new Uint8Array(points.buffer, points.byteOffset, points.byteLength), width, color);
}
export function clear_events(desk_id) {
return queries.clear_desk_events.get(desk_id);
}
export function create_desk(desk_id, title = 'untitled') {
return queries.empty_desk.get(desk_id, title);
}
export function save_desk_sn(desk_id, sn) {
return queries.set_desk_sn.get(sn, desk_id);
}
export function create_session(session) {
return queries.create_session.get(session.id, session.user_id, session.desk_id);
}
export function create_user(user) {
return queries.create_user.get(user.id, user.login);
}
export function save_session_lsn(session_id, lsn) {
return queries.save_session_lsn.get(lsn, session_id);
}
export function put_image(image_id, desk_id) {
return queries.put_image.get(image_id, desk_id);
}
Loading…
Cancel
Save