Compare commits

..

147 Commits
master ... sdf

Author SHA1 Message Date
Aleksey Olokhtonov dc824c12c9 Reuse a single circle geometry. One circle per segment. Still needs last circle 4 days ago
A.Olokhtonov 78ba044527 Use straight up geometry for everything, finally obtaining TRANSPARENCY! 1 week ago
A.Olokhtonov dc235c7365 Implement partial uploads to texture 3 weeks ago
A.Olokhtonov 0c3259d00f Tweak initial BVH construction to split along the longest axis, instead 3 weeks ago
Aleksey Olokhtonov a2f574214c Fix warning when dynamic draw has only a single point. Fragment shader fix is untested. 4 weeks ago
Aleksey Olokhtonov 0c21579694 Boudning boxes debug draw. Fix missing mipmap warning. Fix dynamic stroke not drawing on empty canvas 4 weeks ago
A.Olokhtonov 4e09c47190 Sudden shader refactoring (automatically extract locations a-la 2 months ago
A.Olokhtonov fea2874004 Merge scale and move local history, fixing undo for image move/scale 2 months ago
A.Olokhtonov 8bea9593da The big unAWAITing! Also found the image scale undo bug, but haven't 2 months ago
A.Olokhtonov 29ec265632 Keep multiple current strokes ("prestrokes") per player in a queue 2 months ago
A.Olokhtonov bdc3bdc9a1 Undo for all available actions 2 months ago
A.Olokhtonov 5d45eb3255 Undo eraser 2 months ago
A.Olokhtonov 49620ff8fb Fix image selection order, fix image upload typo. Bind hotkeys to switch between tools 2 months ago
A.Olokhtonov ea409f76c4 Basic snapping to grid is kinda working (needs more testing). Deleted obsolete shaders and debug options 5 months ago
A.Olokhtonov 8b4b4e09f7 Basic line drawing is back IN 5 months ago
A.Olokhtonov b11c46354f Add line-line intersection test to the eraser to kinda make it work for faster movement 5 months ago
A.Olokhtonov ab1659f77f Fix missing dummy stroke for undo event. Fix typo in listener 5 months ago
A.Olokhtonov 803b7f80cb Eraser has gone on-line! 5 months ago
A.Olokhtonov 0ffac004c0 Eraser works again! Offline though 5 months ago
A.Olokhtonov 21aecb7d08 Make image move and image scale work in multiplayer. Add width and height to image event and fix late-arriving bitmaps breaking things 5 months ago
A.Olokhtonov 45bac3395a Clean up image move/scale logic a little bit. Remove obsolete cursor.js file 5 months ago
A.Olokhtonov 9d6f333778 Resize images (local only) 5 months ago
A.Olokhtonov 254268c309 Move images (local only) 5 months ago
A.Olokhtonov c1583cb8fb Resize handles for images 5 months ago
A.Olokhtonov 66e84c1a50 More betterer active image highlight 5 months ago
A.Olokhtonov acb9aaa83d Fix misallocation of pressures tv_array 6 months ago
A.Olokhtonov 4a6d1d6640 Fix dummy offsets not getting inserted if first event is not a stroke 6 months ago
A.Olokhtonov c6f8bc4b5a Fix first stroke preview not showing 6 months ago
A.Olokhtonov 5871833cd1 (Probably) fix the nasty WASM mis-allocations 6 months ago
A.Olokhtonov 8a15093147 The images are in 6 months ago
A.Olokhtonov 903660adbc Marginally better grid level transitions 6 months ago
Aleksey Olokhtonov 5aa29b18c0 Fix follow mode by separating canvas move and zoom events. Touch devices are TODO! 6 months ago
Aleksey Olokhtonov 01560389af Fix predrawn curves having zero width 6 months ago
Aleksey Olokhtonov 90f0beb4f9 Undo is back in! 6 months ago
Aleksey Olokhtonov e2ba3bb1c2 Make zoom animation use delta time 6 months ago
A.Olokhtonov bf2eace6fe Grid background pattern 6 months ago
A.Olokhtonov eafe860990 Zoom without the mousewheel by holding down Z 6 months ago
A.Olokhtonov eb66ffbcad Significantly improve stroke smoothing and pressure handling. "Fix" issue with underallocation of WASM memory by doubling the size 6 months ago
A.Olokhtonov 30559b0381 The dots are now zoomable 6 months ago
A.Olokhtonov c13e5a6848 Smooth zooming 6 months ago
A.Olokhtonov ee42e400c4 Dots pattern with fancy fadeout 6 months ago
A.Olokhtonov 807a1d0c03 More betterer picker icon. Hide dom brush when in picker mode 6 months ago
A.Olokhtonov 07bb47b6dc Dynamic cursor that shows size and color of the brush. Background pattern scaffolding 6 months 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 7 months ago
A.Olokhtonov e4a10ebf30 Make color picker actually change the active color 7 months ago
A.Olokhtonov 68c892ba5f Color picker: picks colors! They don't get used though 7 months ago
A.Olokhtonov 48e9dd9a3e Remove stupid try catch from webgl_draw 7 months ago
A.Olokhtonov 221e2a5aed Fix bvh fullnodes returning full buffer instead of tv_data. Skip handling of empty strokes 7 months ago
A.Olokhtonov c3f66d966c Determinitstic zooming 7 months ago
A.Olokhtonov acdbf73a26 Add new strokes to fullnodes. Strokes appear one too late though 8 months ago
A.Olokhtonov 6d9791d3e0 Cache full stroke lists in BVH levels (massive speedup!). Remove "line_threshold" array, since it has very little effect. 8 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 8 months ago
A.Olokhtonov c5ad40f739 Update README 8 months ago
A.Olokhtonov 4f84509b16 Multithreading works! Kinda sorta (slows down in chrome, out of bounds accesses on phone) 8 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 8 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 8 months ago
A.Olokhtonov 37ff1e55bc Oops, actually enable SIMD 8 months ago
A.Olokhtonov 84a5859541 SIMD version of rdp_find_max. 16->12ms cpu frametime firefox, 16->9ms chrome 8 months ago
A.Olokhtonov 6f78c0ae21 AoS -> SoA for point coordinates 8 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 8 months ago
A.Olokhtonov 40dd278f21 Improved shader (thanks BYP!) 10 months ago
A.Olokhtonov 6f19e6c954 Uneven capsules (and a very naive shader) to draw getter variable-width lines 10 months ago
aolo2 dfecad2c99 Fix strokes being added to storage even when skipped by SN logic 10 months ago
aolo2 704c833c16 First working draft of pressure hanlding 10 months ago
aolo2 71f1016a40 Store WASM-processed data in WASM memory 10 months ago
A.Olokhtonov 8abe88373f Skip copying out segments and segments_from. Use wasm_points and wasm_ids as is 10 months ago
A.Olokhtonov 3bf812df39 Move point writes to WASM, already a lot faster!!!! 10 months ago
A.Olokhtonov c9d1706b6e Fix final (???) WASM issue 10 months ago
A.Olokhtonov 1960bdebe9 LOD generation has been wassembled! (a little borked for now though) 10 months ago
A.Olokhtonov cf11f6c701 Successfull sum function in wasm :D 10 months ago
A.Olokhtonov 28fec7f306 Redraw HTML on canvas move, first draft of wasm LOD core 10 months ago
A.Olokhtonov 4b5b8db053 Update README 10 months ago
A.Olokhtonov ab7545918b Fix state.me not being set when joining new desk 10 months ago
A.Olokhtonov 7555de10bf Long touch to open color picker details 10 months ago
A.Olokhtonov 08ba8a7687 Fix GC pauses! 10 months ago
A.Olokhtonov cc800c1ffe Fancy-shmancy Open Color (TM) color pallete 10 months ago
A.Olokhtonov 8bbcfb616f JOIN event, send online info for users in init message 10 months ago
A.Olokhtonov 99bd99a465 Follow and unfollow players like in Figma 10 months ago
A.Olokhtonov ee1d1471f4 Pretty(er) player cursor: bright colors and an actual icon 10 months ago
A.Olokhtonov d8a5cd3fca Draw player pointers as colored squares with html 10 months ago
A.Olokhtonov 1f983f3389 Fix multiplayer, add mouse wheel panning 10 months ago
A.Olokhtonov c893a73ec5 You can draw once again! 10 months ago
A.Olokhtonov 847fb70381 Don't repeat points segment points. Introduce "tv" (typedvector) 10 months ago
A.Olokhtonov 9bbab32369 Try to speed up the LOD calculation with a cache (kinda succesfull, but not really) 11 months ago
A.Olokhtonov a60c3d1948 Recompute LODs and instance data on demand - ??? - it works??? 11 months ago
A.Olokhtonov 2d88b57dca Fix off-by-factor-of-2 in the vertex shader 11 months ago
A.Olokhtonov 248501e50f Remove recursive allocations from process_rdp 11 months ago
A.Olokhtonov 31a0b0113a Prototype: separate per-quad data using instanced rendeding and per-stroke data using a texture 11 months ago
A.Olokhtonov 1438b0ad73 LODs work! Need to fix that memory usage though 11 months ago
A.Olokhtonov 60fc48b1e7 Fix dynamic draw to use indexed, and thus the same shader as main 11 months ago
A.Olokhtonov fdca3e6b07 Fix epsilon calculation for RDP 11 months ago
A.Olokhtonov 7b53c7215e Fix predraw artifacts by making the lines too fat at far zoomout 11 months ago
A.Olokhtonov 72eedf9b48 Fix dynamic data 11 months ago
A.Olokhtonov 24222f9565 Fix wrong sort order for indices 12 months ago
A.Olokhtonov cca3b4cd5d Fix the depth prepass artefacts 12 months ago
A.Olokhtonov a991cf3247 BVH construction and modification 12 months ago
aolo2 1691bd07ae Milton blackboard import 12 months ago
A.Olokhtonov 6be8ba7823 Add readme 12 months ago
A.Olokhtonov 7752e41bf5 Separate CPU and GPU timings in debug window 12 months ago
A.Olokhtonov 1bc6f2c3fe No clipping at zoom < 0.3. Really fast 12 months ago
A.Olokhtonov 316d0fedc9 Use correct rendering order.. 1 year ago
A.Olokhtonov e86ffd6508 Depth prepass with simplified opaque shapes 1 year ago
A.Olokhtonov 7e3b6156c0 Render stroke_id to a small texture 1 year ago
A.Olokhtonov 0d8667aa3a Use TypedArray for culling. This is up to 10 times faster! 1 year ago
A.Olokhtonov e41997563f Faster clipping 1 year ago
A.Olokhtonov 46cd48fecd Cull segments, switch to indexed drawing (only for static data for now) 1 year ago
A.Olokhtonov be50af68d4 Delete garbage file 1 year ago
A.Olokhtonov 72deea0b03 Added LICENSE (unlicense) 1 year ago
A.Olokhtonov 9a8854dc90 Handle touchstart with 2 e.changedTouches. This fixes panning sometimes not working on iPhone 1 year ago
A.Olokhtonov 1803dedee2 Black-on-white cursor for ultimate readability 1 year 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. 1 year 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 1 year ago
A.Olokhtonov eefbb8ef75 Do not upload buffers each frame (DUH!), re-introduce dynamic data, set headers in caddy to enable precise timings 1 year ago
A.Olokhtonov 605500028a Bump version 1 year ago
A.Olokhtonov c1fd46da53 We have achieved good AA! 1 year ago
A.Olokhtonov 01db70cab0 Good speed (quad per line segment). Need to figure out rounding 1 year ago
A.Olokhtonov e3ae4cf6cd Upload whole point buffer, and align its size to 8K 1 year ago
A.Olokhtonov 46587068e6 Limit max points per quad, fix wrong rounding of texture size 1 year ago
A.Olokhtonov f72cbf5941 SDF bull fixed boxes + passed stroke width to shader 1 year ago
A.Olokhtonov 69feb482a2 sdf bullshit (almost working) 1 year ago
A.Olokhtonov ad9cded350 Sweet juicy svg cursor 2 years ago
A.Olokhtonov 6f446b7d13 Fwidth in the shader for "more gooder" AA 2 years ago
A.Olokhtonov 8b3f28337e SDF town 2 years ago
A.Olokhtonov 8557c5d47e Caps and joints via fragment shader 2 years ago
A.Olokhtonov 0271e38dbc Fix wrong buffered points 2 years ago
A.Olokhtonov 31b18e69a0 Images moving around, paste image from clipboard 2 years ago
A.Olokhtonov 3b8232e196 Image broadcast 2 years ago
A.Olokhtonov 1edcc6725b Moar better color/width sync 2 years ago
A.Olokhtonov ac0d8f7605 Betta websocket reconnect 2 years ago
A.Olokhtonov 33898ab27a Client code cleanup p1 2 years ago
A.Olokhtonov dec07b4edc Remove users from backend, leave only sessions. Simplify storage 2 years ago
A.Olokhtonov cb783db614 Per-user stroke width and color (for dynamic strokes) kinda work 2 years ago
A.Olokhtonov 165d9235ce Wrote what to do NEXT: 2 years ago
A.Olokhtonov 7c2ba5ff72 Do not schedule RAF if it already is scheduled (somewhat fixes event spam from chrome!) 2 years ago
A.Olokhtonov 110afe123f Brush preview 2 years ago
A.Olokhtonov 45c3af9c67 Brush width control. Phone "zen mode" button 2 years ago
A.Olokhtonov bf273a9424 Fix HiDPI support on non-touch devices 2 years ago
A.Olokhtonov 59cb197e58 I don't even know anymore (colors?) 2 years ago
A.Olokhtonov 343008c0af f32 coordinates 2 years ago
A.Olokhtonov f24e8d386b Pictures good 2 years ago
A.Olokhtonov 29f697dceb Texture test 2 years ago
A.Olokhtonov 04c11e23f3 Nice touch! 2 years ago
A.Olokhtonov 5c0d9e1537 The gooder biba 2 years ago
A.Olokhtonov 5593536485 Working rounded lines! 2 years ago
A.Olokhtonov ab152b2d0a Investigating rounded corners (buggy!) 2 years ago
A.Olokhtonov e056d6c698 Round caps! 2 years ago
A.Olokhtonov 7011cc86be Some kind of shitty webgl line renderer 2 years ago
  1. 6
      .gitignore
  2. 7
      Caddyfile
  3. 24
      LICENSE
  4. 104
      README.txt
  5. 268
      client/aux.js
  6. 427
      client/bvh.js
  7. 593
      client/client_recv.js
  8. 436
      client/client_send.js
  9. 37
      client/config.js
  10. 315
      client/cursor.js
  11. 420
      client/default.css
  12. 66
      client/draw.js
  13. BIN
      client/favicon2.png
  14. BIN
      client/favicon_old.png
  15. 130
      client/heapify.js
  16. 8
      client/icons/cheeseburga.svg
  17. 281
      client/icons/crosshair.svg
  18. 26
      client/icons/cursor.svg
  19. 54
      client/icons/draw.svg
  20. 79
      client/icons/erase.svg
  21. 54
      client/icons/favicon.svg
  22. 7
      client/icons/pen.svg
  23. 2
      client/icons/perfect-bullet.svg
  24. 105
      client/icons/picker.svg
  25. 54
      client/icons/player-cursor.svg
  26. 57
      client/icons/player.svg
  27. 52
      client/icons/pointer.svg
  28. 54
      client/icons/redo.svg
  29. 53
      client/icons/ruler.svg
  30. 59
      client/icons/undo.svg
  31. 269
      client/index.html
  32. 486
      client/index.js
  33. 36
      client/index.log
  34. 47
      client/lod_worker.js
  35. 497
      client/math.js
  36. 345
      client/recv.js
  37. 201
      client/send.js
  38. 236
      client/speed.js
  39. 177
      client/tools.js
  40. 359
      client/touch.js
  41. 132
      client/undo.js
  42. 1
      client/wasm/compile_command
  43. 351
      client/wasm/lod.c
  44. BIN
      client/wasm/lod.wasm
  45. 615
      client/webgl_draw.js
  46. 549
      client/webgl_geometry.js
  47. 839
      client/webgl_listeners.js
  48. 463
      client/webgl_shaders.js
  49. 58
      client/websocket.js
  50. 4
      server/config.js
  51. BIN
      server/data-local.sqlite
  52. 103
      server/deserializer.js
  53. 18
      server/enums.js
  54. 12
      server/http.js
  55. 6
      server/math.js
  56. 85
      server/milton.js
  57. 163
      server/recv.js
  58. 168
      server/send.js
  59. 87
      server/serializer.js
  60. 18
      server/server.js
  61. 182
      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>

104
README.txt

@ -0,0 +1,104 @@ @@ -0,0 +1,104 @@
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
+ Resize and move pictures (draw handles)
- Z-prepass fringe bug (also, when do we enable the prepass?)
- Frame-independent lerp where applicable
+ 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
+ Undo history of moving and scaling images seems messed up
- Nothing get's drawn if we enable snapping and draw a curve where first and last point match
- Weird clipping on HMH desk full zoomout after running "benchmark"
- Stuck in color picker mode when mouse leaves screen
- Debug
* Debug view for BVH
- 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
- Handle out of space
- Local prediction for tools!
- Immediately commit a stroke to the canvas, change order if earlier strokes arrive
- Show my own image immediately, show placeholders while images are loading (add bitmap size to event)
- undo immediately, this one can not arrive out of order, because noone else is going to undo MY actions
* 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
+ Live preview
~ Alignment (horizontal, vertical, diagonal, etc) [kinda gets covered by the snapping? question mark?]
+ Undo
+ Undo for eraser
+ Undo for images (add, move, scale)
- Redo
+ Snapping to grid
- Snapping to other points?
- Color picker should work for ruler
- Show previous color in color picker preview
- Stick picker preview to cursor
* 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
- Replace "geometry_add_dummy_stroke" with something not [so] cursed
- Automatically extract locations from shaders (see nitka project for code examples)
- Presentation / "marketing"
- Title (InfiNotes? MegaDesk?)
- 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
- Stroke smoothing
https://github.com/xournalpp/xournalpp/issues/2320
https://www.digital-epigraphy.com/tutorials/the-most-useful-new-features-of-photoshop-cc-using-brush-stroke-smoothing-for-digital-inking
https://stackoverflow.com/questions/20618804/how-to-smooth-a-curve-for-a-dataset
- Curve modification
- Select curves (with a lasso?)
- Move whole curve
- Move single point
- Move multiple points
* Customizable background
+ Dots pattern
+ Grid pattern
- Menu option
- Offline mode
- Only one user
- No server, everything applied immediately
- Allow export to file
- Save to browser storage (probaby indexed db + recent events in localstorage)
Bonus-bonus:
- Actually infinite canvas (replace floats with something, some kind of fixed point scheme? chunks? multilevel scheme?)

268
client/aux.js

@ -0,0 +1,268 @@ @@ -0,0 +1,268 @@
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, bitmap.width, bitmap.height);
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:
case EVENT.LIFT: {
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 + 4 + 4; // file id + x + y + width + height
break;
}
case EVENT.IMAGE_SCALE: {
size += 4 + 4 + 4 + 4; // file_id + corner + 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;
}
function get_image(context, key) {
for (const entry of context.images) {
if (entry.key === key) {
return entry;
}
}
return null;
}
function grid_snap_step(state) {
const zoom_log2 = Math.log2(state.canvas.zoom);
const zoom_previous = Math.pow(2, Math.floor(zoom_log2));
const zoom_next = Math.pow(2, Math.ceil(zoom_log2));
if (Math.abs(state.canvas.zoom - zoom_previous) < Math.abs(state.canvas.zoom - zoom_next)) {
return 32 / zoom_previous;
} else {
return 32 / zoom_next;
}
}

427
client/bvh.js

@ -0,0 +1,427 @@ @@ -0,0 +1,427 @@
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 = tv_data(node.stroke_indices).indexOf(stroke.index);
if (index_index !== -1) {
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_undelete_stroke(state, stroke) {
let node = state.bvh.nodes[stroke.bvh_node];
while (node.parent_index !== null) {
if (node.is_fullnode) {
tv_add2(node.stroke_indices, stroke.index);
break;
}
node = state.bvh.nodes[node.parent_index];
}
}
function bvh_copy_fullnode(quad, node, result_buffer) {
if (quad_fully_inside(quad, node.bbox)) {
tv_append(result_buffer, tv_data(node.stroke_indices));
return true;
}
return false;
}
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) {
const fully_inside = bvh_copy_fullnode(quad, node, result_buffer);
if (fully_inside) {
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, strokes, depth) {
if (strokes.length > 1) {
// internal
let sorted_strokes;
let min_x = strokes[0].bbox.cx;
let min_y = strokes[0].bbox.cy;
let max_x = strokes[0].bbox.cx;
let max_y = strokes[0].bbox.cy;
for (let i = 0; i < strokes.length; ++i) {
const stroke = strokes[i];
const cx = stroke.bbox.cx;
const cy = stroke.bbox.cy;
if (cx < min_x) {
min_x = cx;
}
if (cy < min_y) {
min_y = cx;
}
if (cx > max_x) {
max_x = cx;
}
if (cy > max_y) {
max_y = cy;
}
}
const vertical = (max_y - min_y) > (max_x - min_x);
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, sorted_strokes.slice(0, left_of_split_count), depth + 1);
const child2 = bvh_construct_rec(state, bvh, 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, strokes, 0);
}
}
function bvh_get_fullnodes_debug(state, context) {
const bvh = state.bvh;
const result = [];
const stack = [];
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 quad = {
'x1': screen_topleft.x,
'y1': screen_topleft.y,
'x2': screen_bottomright.x,
'y2': screen_bottomright.y
};
if (bvh.root === null) {
return;
}
stack.push({'depth': 0, 'node_index': bvh.root});
while (stack.length > 0) {
const entry = stack.pop();
const node = bvh.nodes[entry.node_index];
if (!quads_intersect(node.bbox, quad)) {
continue;
}
if (node.is_fullnode) {
result.push({...node.bbox});
continue;
}
if (!node.is_leaf && entry.depth < config.bvh_fullnode_depth) {
stack.push({'depth': entry.depth + 1, 'node_index': node.child1});
stack.push({'depth': entry.depth + 1, 'node_index': node.child2});
}
}
return result;
}

593
client/client_recv.js

@ -0,0 +1,593 @@ @@ -0,0 +1,593 @@
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:
case EVENT.LIFT: {
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: {
event.image_id = des_u32(d);
event.x = des_f32(d);
event.y = des_f32(d);
event.width = des_u32(d);
event.height = des_u32(d);
break;
}
case EVENT.IMAGE_MOVE: {
event.image_id = des_u32(d);
event.x = des_f32(d);
event.y = des_f32(d);
break;
}
case EVENT.IMAGE_SCALE: {
event.image_id = des_u32(d);
event.corner = 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},
'strokes': [],
'current_prestroke': false,
};
}
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: {
const player = state.players[event.user_id];
if (!player.current_prestroke) {
geometry_start_prestroke(state, event.user_id);
}
geometry_add_prepoint(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: {
// TODO: @touch
break;
}
case EVENT.LIFT: {
// Current stroke from player ended. Handle following PREDRAWN events as next stroke
geometry_end_prestroke(state, 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 ? -1 : 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;
need_draw = true;
event.index = state.events.length;
geometry_clear_oldest_prestroke(state, context, event.user_id);
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);
need_draw = undo(state, context, event, options);
break;
}
case EVENT.IMAGE: {
const p = {'x': event.x, 'y': event.y};
geometry_add_dummy_stroke(context);
add_image(context, event.image_id, null, p, event.width, event.height);
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();
// NOTE: this will resolve when bitmap is ready, which will be much later
const bitmap = await createImageBitmap(blob);
event.width = bitmap.width;
event.height = bitmap.height;
add_image(context, event.image_id, bitmap, p, bitmap.width, bitmap.height);
// 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: {
geometry_add_dummy_stroke(context);
const image_id = event.image_id;
const image = get_image(context, image_id);
if (image) {
// if (config.debug_print) console.debug('move image', image_id, 'to', image_event.x, image_event.y);
push_image_move(image, event.x, event.y);
need_draw = true;
}
break;
}
case EVENT.IMAGE_SCALE: {
geometry_add_dummy_stroke(context);
const image_id = event.image_id;
const image = get_image(context, image_id);
if (image !== null) {
push_image_scale(image, event.corner, event.x, event.y);
need_draw = true;
}
break;
}
case EVENT.ERASER: {
geometry_add_dummy_stroke(context);
need_draw = true;
const stroke = state.events[event.stroke_id];
stroke.deleted = true;
if (!options.skip_bvh) {
bvh_delete_stroke(state, stroke);
}
break;
}
default: {
console.error('fuck');
}
}
return need_draw;
}
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);
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);
break;
}
default: {
console.error('fuck');
return;
}
}
if (do_draw) {
schedule_draw(state, context);
}
}

436
client/client_send.js

@ -0,0 +1,436 @@ @@ -0,0 +1,436 @@
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/glTexSubImage2D 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:
case EVENT.LIFT: {
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);
ser_u32(s, event.width);
ser_u32(s, event.height);
break;
}
case EVENT.IMAGE_SCALE: {
const image_id = parseInt(event.image_id);
ser_u32(s, image_id);
ser_u32(s, event.corner); // which corner was moved
ser_f32(s, event.x); // where corner was moved to (canvas coordinates)
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');
}
}
}
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) ws.send(s.buffer);
} catch(e) {
ws.close();
}
}
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) ws.send(s.buffer);
} catch (e) {
ws.close();
}
}
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) 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.IMAGE_SCALE:
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
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) ws.send(s.buffer);
} catch(e) {
ws.close();
}
}
function predraw_event(x, y) {
return {
'type': EVENT.PREDRAW,
'x': x,
'y': y
};
}
function lift_event() {
return {
'type': EVENT.LIFT,
};
}
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, width, height) {
return {
'type': EVENT.IMAGE,
'image_id': image_id,
'x': x,
'y': y,
'width': width,
'height': height,
};
}
function image_move_event(image_id, x, y) {
return {
'type': EVENT.IMAGE_MOVE,
'image_id': image_id,
'x': x,
'y': y,
};
}
function image_scale_event(image_id, corner, x, y) {
return {
'type': EVENT.IMAGE_SCALE,
'image_id': image_id,
'corner': corner,
'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,
};
}
function eraser_event(stroke_id) {
return {
'type': EVENT.ERASER,
'stroke_id': stroke_id,
}
}

37
client/config.js

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
const config = {
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,
draw_bvh: false,
draw_fullnodes: false,
zoom_delta: 0.05,
min_zoom_level: -275,
max_zoom_level: 200,
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,
initial_wasm_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
ui_texture_size: 16,
bvh_fullnode_depth: 6,
pattern_fadeout_min: 0.3,
pattern_fadeout_max: 0.75,
min_pressure: 50,
benchmark: {
zoom_level: -18,
offset: { x: 654, y: 372 },
frames: 500,
},
};

315
client/cursor.js

@ -1,315 +0,0 @@ @@ -1,315 +0,0 @@
function on_down(e) {
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);
// Scroll wheel (mouse button 3)
if (e.button === 1) {
storage.state.moving = true;
storage.state.mousedown = true;
return;
}
// Right mouse button
if (e.button === 2) {
const image_hit = image_at(x, y);
activate_image(image_hit);
e.preventDefault();
return;
}
// Left mouse button
if (e.button === 0) {
const image_hit = image_at(x, y);
if (elements.active_image !== null && image_hit !== null) {
const image_id = image_hit.getAttribute('data-image-id');
const image_position = storage.images[image_id];
storage.state.moving_image = true;
storage.moving_image_original_x = image_position.x;
storage.moving_image_original_y = image_position.y;
return;
}
if (storage.state.moving) {
storage.state.mousedown = true;
return;
}
storage.state.drawing = true;
if (storage.ctx1.lineWidth !== storage.cursor.width) {
storage.ctx1.lineWidth = storage.cursor.width;
}
storage.cursor.x = x;
storage.cursor.y = y;
if (storage.tools.active === 'pencil') {
const predraw = predraw_event(x, y);
storage.current_stroke.push(predraw);
fire_event(predraw);
} else if (storage.tools.active === 'ruler') {
storage.ruler_origin.x = x;
storage.ruler_origin.y = y;
}
}
}
function on_move(e) {
const last_x = storage.cursor.x;
const last_y = storage.cursor.y;
const x = storage.cursor.x = Math.max(Math.round((e.clientX + storage.canvas.offset_x) / storage.canvas.zoom), 0);
const y = storage.cursor.y = Math.max(Math.round((e.clientY + storage.canvas.offset_y) / storage.canvas.zoom), 0);
const old_offset_x = storage.canvas.offset_x;
const old_offset_y = storage.canvas.offset_y;
if (elements.active_image && storage.state.moving_image) {
const dx = Math.round(e.movementX / storage.canvas.zoom);
const dy = Math.round(e.movementY / storage.canvas.zoom);
const image_id = elements.active_image.getAttribute('data-image-id');
const ix = storage.images[image_id].x += dx;
const iy = storage.images[image_id].y += dy;
elements.active_image.style.transform = `translate(${ix}px, ${iy}px)`;
return;
}
if (storage.state.drawing) {
if (storage.tools.active === 'pencil') {
const width = storage.cursor.width;
storage.ctx1.beginPath();
storage.ctx1.moveTo(last_x, last_y);
storage.ctx1.lineTo(x, y);
storage.ctx1.stroke();
const predraw = predraw_event(x, y);
storage.current_stroke.push(predraw);
fire_event(predraw);
} else if (storage.tools.active === 'eraser') {
const erased = strokes_intersect_line(last_x, last_y, x, y);
storage.erased.push(...erased);
if (erased.length > 0) {
for (const other_event of storage.events) {
for (const stroke_id of erased) {
if (stroke_id === other_event.stroke_id) {
if (!other_event.deleted) {
other_event.deleted = true;
const stats = stroke_stats(other_event.points, storage.cursor.width);
redraw_region(stats.bbox);
}
}
}
}
}
} else if (storage.tools.active === 'ruler') {
const old_ruler = [
{'x': storage.ruler_origin.x, 'y': storage.ruler_origin.y},
{'x': last_x, 'y': last_y}
];
const stats = stroke_stats(old_ruler, storage.cursor.width);
const bbox = stats.bbox;
storage.ctx1.clearRect(bbox.xmin, bbox.ymin, bbox.xmax - bbox.xmin, bbox.ymax - bbox.ymin);
storage.ctx1.beginPath();
storage.ctx1.moveTo(storage.ruler_origin.x, storage.ruler_origin.y);
storage.ctx1.lineTo(x, y);
storage.ctx1.stroke();
} else {
console.error('fuck');
}
} else if (storage.state.moving && storage.state.mousedown) {
storage.canvas.offset_x -= e.movementX;
storage.canvas.offset_y -= e.movementY;
if (storage.canvas.offset_x !== old_offset_x || storage.canvas.offset_y !== old_offset_y) {
move_canvas();
}
// if (storage.canvas.offset_x > storage.canvas.max_scroll_x) storage.canvas.offset_x = storage.canvas.max_scroll_x;
// if (storage.canvas.offset_x < 0) storage.canvas.offset_x = 0;
// if (storage.canvas.offset_y > storage.canvas.max_scroll_y) storage.canvas.offset_y = storage.canvas.max_scroll_y;
// if (storage.canvas.offset_y < 0) storage.canvas.offset_y = 0;
}
e.preventDefault();
}
async function on_up(e) {
if (storage.state.moving_image && e.button === 0) {
storage.state.moving_image = false;
const image_id = elements.active_image.getAttribute('data-image-id');
const position = storage.images[image_id];
// Store delta instead of new position for easy undo
const event = image_move_event(image_id, position.x - storage.moving_image_original_x, position.y - storage.moving_image_original_y);
await queue_event(event);
storage.moving_image_original_x = null;
storage.moving_image_original_y = null;
return;
}
if (storage.state.moving && (e.button === 1 || e.button === 0)) {
storage.state.mousedown = false;
if (!storage.state.spacedown) {
storage.state.moving = false;
return;
}
}
if (storage.state.drawing && e.button === 0) {
if (storage.tools.active === 'pencil') {
const event = stroke_event();
storage.current_stroke = [];
await queue_event(event);
} else if (storage.tools.active === 'eraser') {
const events = eraser_events();
storage.erased = [];
if (events.length > 0) {
for (const event of events) {
await queue_event(event);
}
}
} else if (storage.tools.active === 'ruler') {
const event = ruler_event(storage.cursor.x, storage.cursor.y);
await queue_event(event);
} else {
console.error('fuck');
}
storage.state.drawing = false;
return;
}
}
function on_keydown(e) {
if (e.code === 'Space' && !storage.state.drawing) {
storage.state.moving = true;
storage.state.spacedown = true;
return;
}
if (e.code === 'KeyZ' && e.ctrlKey) {
undo();
return;
}
}
function on_keyup(e) {
if (e.code === 'Space' && storage.state.moving) {
storage.state.moving = false;
storage.state.spacedown = false;
}
}
function on_leave(e) {
// TODO: broken when "moving"
if (storage.state.moving) {
storage.state.moving = false;
storage.state.holding = false;
return;
}
}
function on_resize(e) {
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) {
e.preventDefault();
const file = e.dataTransfer.files[0];
const bitmap = await createImageBitmap(file);
const x = storage.cursor.x - Math.round(bitmap.width / 2);
const y = storage.cursor.y - Math.round(bitmap.height / 2);
// storage.ctx0.drawImage(bitmap, x, y);
const form_data = new FormData();
form_data.append('file', file);
const resp = await fetch(`/api/image?deskId=${storage.desk_id}`, {
method: 'post',
body: form_data,
})
if (resp.ok) {
const image_id = await resp.text();
const event = image_event(image_id, x, y);
await queue_event(event);
}
return false;
}
function on_wheel(e) {
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);
const dz = (e.deltaY < 0 ? 0.1 : -0.1);
storage.canvas.zoom += dz;
if (storage.canvas.zoom > storage.max_zoom) {
storage.canvas.zoom = storage.max_zoom;
return;
}
if (storage.canvas.zoom < storage.min_zoom) {
storage.canvas.zoom = storage.min_zoom;
return;
}
const zoom_offset_x = Math.round(dz * x);
const zoom_offset_y = Math.round(dz * y);
storage.canvas.offset_x += zoom_offset_x;
storage.canvas.offset_y += zoom_offset_y;
move_canvas();
}
function cancel(e) {
e.preventDefault();
return false;
}
function update_brush() {
elements.brush_preview.classList.remove('dhide');
const color = elements.brush_color.value;
const width = elements.brush_width.value;
storage.cursor.color = color;
storage.cursor.width = width;
const x = Math.round(storage.cursor.x - width / 2);
const y = Math.round(storage.cursor.y - width / 2);
elements.brush_preview.style.transform = `translate(${x}px, ${y}px)`;
elements.brush_preview.style.width = width + 'px';
elements.brush_preview.style.height = width + 'px';
elements.brush_preview.style.background = color;
if (storage.timers.brush_preview) {
clearTimeout(storage.timers.brush_preview);
}
storage.timers.brush_preview = setTimeout(() => {
elements.brush_preview.classList.add('dhide');
}, 1000);
}

420
client/default.css

@ -1,60 +1,104 @@ @@ -1,60 +1,104 @@
: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.tool-pointer {
cursor: default;
}
#canvas-images {
z-index: 0;
}
canvas.picker {
cursor: url('icons/picker.svg') 0 19, crosshair;
}
canvas.resize-topleft {
cursor: nwse-resize;
}
canvas.resize-topright {
cursor: nesw-resize;
}
/*
canvas.movemode {
cursor: grab;
}
canvas.movemode.moving {
cursor: grabbing;
}
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 +106,170 @@ html, body { @@ -62,41 +106,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;
background: #333;
border-radius: 5px;
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: 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 +278,163 @@ html, body { @@ -105,18 +278,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;
}
.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;
}
.floating-image {
.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: 320px;
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

52
client/icons/pointer.svg

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32"
height="32"
viewBox="0 0 8.4666665 8.4666666"
version="1.1"
id="svg5"
sodipodi:docname="pointer.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="13.455443"
inkscape:cx="4.2733635"
inkscape:cy="26.086097"
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="grid1775" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0.264583px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 2.5420359,7.0728549 2.0552699,0.9380846 7.1247537,4.4270217 5.0270833,5.0270833 6.0854165,6.8601704 5.1688732,7.3893369 4.1105397,5.5562497 Z"
id="path1887"
sodipodi:nodetypes="cccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 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

269
client/index.html

@ -3,42 +3,255 @@ @@ -3,42 +3,255 @@
<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/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="undo.js"></script>
<script type="text/javascript" src="config.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="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="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="do-snap">Snap to grid</label>
<label><input type="checkbox" id="debug-print">Debug print</label>
<label><input type="checkbox" id="draw-bvh">Show BVH</label>
<button id="debug-begin-benchmark" title="Do not forget to enable recording in your browser!">Benchmark</button>
</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" data-tool="pointer"><img draggable="false" src="icons/pointer.svg"></div>
<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>

486
client/index.js

@ -1,16 +1,28 @@ @@ -1,16 +1,28 @@
let ws = null;
let ls = window.localStorage;
document.addEventListener('DOMContentLoaded', main);
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,
LIFT: 15,
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,
IMAGE_SCALE: 42,
ERASER: 50,
});
@ -21,326 +33,236 @@ const MESSAGE = Object.freeze({ @@ -21,326 +33,236 @@ 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,
// Source:
// https://stackoverflow.com/a/18473154
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
var angleInRadians = (angleInDegrees-90) * Math.PI / 180.0;
'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': {},
'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 = [];
points.push(predraw_event(storage.ruler_origin.x, storage.ruler_origin.y));
points.push(predraw_event(x, y));
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': [],
},
return {
'type': EVENT.RULER,
'points': points,
'width': storage.cursor.width,
'color': color_to_u32(storage.cursor.color),
};
}
'moving': false,
'drawing': false,
'erasing': false,
'spacedown': false,
'colorpicking': false,
'zooming': false,
'zoomdown': false,
'imagemoving': false,
'imagescaling': false,
'linedrawing': false,
function undo_event() {
return { 'type': EVENT.UNDO };
}
'active_image': null,
'scaling_corner': null,
'ruler_origin': null,
'image_actually_moved': false,
function redo_event() {
return { 'type': EVENT.REDO };
}
'current_strokes': {},
function image_event(image_id, x, y) {
return {
'type': EVENT.IMAGE,
'image_id': image_id,
'x': x,
'y': y,
}
}
'rdp_mask': new Uint8Array(1024),
'rdp_traverse_stack': new Uint32Array(4096),
function image_move_event(image_id, x, y) {
return {
'type': EVENT.IMAGE_MOVE,
'image_id': image_id,
'x': x,
'y': y,
}
}
'queue': [],
'events': [],
'stroke_count': 0,
'starting_index': 0,
'total_points': 0,
function eraser_events() {
const result = [];
'bvh': {
'nodes': [],
'root': null,
'pqueue': new MinQueue(1024),
'traverse_stack': tv_create(Uint32Array, 1024),
},
for (const stroke_id of storage.erased) {
result.push({
'type': EVENT.ERASER,
'stroke_id': stroke_id,
});
}
'tools': {
'active': null,
'active_element': null,
},
return result;
}
'colors': {
'active_element': null,
'extended_element': null,
},
// 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;
}
}
'timers': {
'hide_preview': null,
'offline_toast': null,
'raf': false,
},
return null;
}
'players': {},
function queue_undo() {
const event = undo_event();
queue_event(event);
}
'debug': {
'red': false,
'render_from': 0,
'render_to': 0,
},
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;
'rdp_cache': {},
ws_connect(true);
'stats': {},
'following_player': null,
elements.canvas0 = document.getElementById('canvas0');
elements.canvas1 = document.getElementById('canvas1');
elements.images = document.getElementById('canvas-images');
'color_picked': null,
tools_init();
'wasm': {},
// 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');
'background_pattern': 'dots',
elements.brush_color.value = storage.cursor.color;
elements.brush_width.value = storage.cursor.width;
'erase_candidates': tv_create(Uint32Array, 4096),
update_brush();
'snap': null,
};
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,
'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);
}
}

497
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,76 @@ function rdp_find_max(points, start, end) { @@ -37,125 +134,76 @@ 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;
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});
}
const stack = [];
return result;
}
function process_stroke(points) {
const result0 = process_ewmv(points);
const result1 = process_rdp(result0, true);
return result1;
}
function stroke_stats(points, width) {
if (points.length === 0) {
const bbox = {
'xmin': 0,
'ymin': 0,
'xmax': 0,
'ymax': 0
};
stack.push({
'type': 0,
'start': 0,
'end': points.length - 1,
});
return {
'bbox': bbox,
'length': 0,
};
}
let length = 0;
let xmin = points[0].x, ymin = points[0].y;
let xmax = xmin, ymax = ymin;
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;
result.push(points[0]);
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);
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]);
}
}
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
);
result.push(points[points.length - 1]);
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);
// TODO: unify with regular process stroke
function process_stroke2(zoom, points) {
const result = process_rdp2(zoom, points);
return result;
}
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 +224,22 @@ function color_from_u32(color_u32) { @@ -176,6 +224,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 +249,220 @@ function segments_intersect(A, B, C, D) { @@ -185,41 +249,220 @@ 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}%)`;
}
function dot(a, b) {
return a.x * b.x + a.y * b.y;
}
function clamp(x, a, b) {
return x < a ? a : (x > b ? b : x);
}
function length(a) {
return Math.sqrt(dot(a, a));
}
function circle_intersects_capsule(ax, ay, bx, by, p1, p2, cx, cy, r) {
// Basically the SDF computation
const pa = { 'x': cx - ax, 'y': cy - ay };
const ba = { 'x': bx - ax, 'y': by - ay };
const h = clamp(dot(pa, ba) / dot(ba, ba), 0, 1);
const in1 = length({ 'x': cx - (ax + ba.x * h), 'y': cy - (ay + ba.y * h) });
const in2 = (1 - h) * p1 + h * p2;
const dist = in1 - in2;
return dist <= r;
}
function stroke_intersects_capsule(state, stroke, a, b, radius) {
const xs = state.wasm.buffers['xs'].tv.data;
const ys = state.wasm.buffers['ys'].tv.data;
const pressures = state.wasm.buffers['pressures'].tv.data;
for (let i = stroke.coords_from; i < stroke.coords_to - 1; ++i) {
const x1 = xs[i + 0];
const y1 = ys[i + 0];
const x2 = xs[i + 1];
const y2 = ys[i + 1];
const p1 = pressures[i + 0];
const p2 = pressures[i + 1];
// Test if p1 or p2 overlap the capsule
if (circle_intersects_capsule(x1, y1, x2, y2, p1 * stroke.width / 255, p2 * stroke.width / 255, a.x, a.y, radius)) {
return true;
}
if (circle_intersects_capsule(x1, y1, x2, y2, p1 * stroke.width / 255, p2 * stroke.width / 255, b.x, b.y, radius)) {
return true;
}
// Lame test for the quad part, only test for line-line intersection
// TODO: actually test for rotated quad vs circle/rotated quad overlap
if (segments_intersect(a, b, {'x': x1, 'y': y1}, {'x': x2, 'y': y2})) {
return true;
}
}
return false;
}

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();
}
}

236
client/speed.js

@ -0,0 +1,236 @@ @@ -0,0 +1,236 @@
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: 16384, // F U
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');
const initial = config.initial_wasm_bytes;
state.wasm.buffers = {
'xs': {
'used': 0,
'cap': initial
},
'ys': {
'used': 0,
'cap': initial
},
'coords_from': {
'used': 0,
'cap': initial
},
'pressures': {
'used': 0,
'cap': initial
},
};
state.wasm.buffers['xs'].offset = state.wasm.exports.alloc_static(initial);
state.wasm.buffers['ys'].offset = state.wasm.exports.alloc_static(initial);
state.wasm.buffers['pressures'].offset = state.wasm.exports.alloc_static(initial);
state.wasm.buffers['coords_from'].offset = state.wasm.exports.alloc_static(initial);
const mem = state.wasm.memory.buffer;
state.wasm.buffers['xs'].tv = tv_create_on(Float32Array, initial / 4,
mem, state.wasm.buffers['xs'].offset);
state.wasm.buffers['ys'].tv = tv_create_on(Float32Array, initial / 4,
mem, state.wasm.buffers['ys'].offset);
state.wasm.buffers['pressures'].tv = tv_create_on(Uint8Array, initial,
mem, state.wasm.buffers['pressures'].offset);
state.wasm.buffers['coords_from'].tv = tv_create_on(Uint32Array, initial / 4,
mem, state.wasm.buffers['coords_from'].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_ys_offset = buffers['ys'].offset;
const old_coords_from_offset = buffers['coords_from'].offset;
const old_pressures_offset = buffers['pressures'].offset;
let realloc = false;
let coords_bytes = buffers['xs'].cap;
let stroke_bytes = buffers['coords_from'].cap;
if (buffers['xs'].used + ncoords * 4 > buffers['xs'].cap) {
coords_bytes = round_to_pow2(buffers['xs'].cap + ncoords * 4, 4096 * 16); // 1 wasm page (although it doesn't matter here)
realloc = true;
}
if (buffers['coords_from'].used + nstrokes * 4 > buffers['coords_from'].cap) {
stroke_bytes = round_to_pow2(buffers['coords_from'].cap + nstrokes * 4, 4096 * 16);
realloc = true;
}
if (realloc) {
if (config.debug_print) console.debug('WASM static data re-layout');
state.wasm.exports.free_static();
const mem = state.wasm.memory.buffer;
const memv = new Uint8Array(mem);
buffers['xs'].offset = state.wasm.exports.alloc_static(coords_bytes);
buffers['ys'].offset = state.wasm.exports.alloc_static(coords_bytes);
buffers['pressures'].offset = state.wasm.exports.alloc_static(coords_bytes);
buffers['coords_from'].offset = state.wasm.exports.alloc_static(stroke_bytes);
buffers['xs'].tv = tv_create_on(Float32Array, coords_bytes / 4, mem, buffers['xs'].offset);
buffers['ys'].tv = tv_create_on(Float32Array, coords_bytes / 4, mem, buffers['ys'].offset);
buffers['pressures'].tv = tv_create_on(Uint8Array, coords_bytes, mem, buffers['pressures'].offset);
buffers['coords_from'].tv = tv_create_on(Uint32Array, stroke_bytes / 4, mem, buffers['coords_from'].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['pressures'].tv.size = buffers['pressures'].used;
buffers['coords_from'].tv.size = buffers['coords_from'].used / 4;
buffers['xs'].cap = buffers['ys'].cap = buffers['pressures'].cap = coords_bytes;
buffers['coords_from'].cap = stroke_bytes;
const tmp = new Uint8Array(Math.max(coords_bytes, stroke_bytes));
// Copy from back to front (otherwise we will overwrite)
tmp.set(new Uint8Array(mem, old_coords_from_offset, buffers['coords_from'].used));
memv.set(new Uint8Array(tmp.buffer, 0, buffers['coords_from'].used), buffers['coords_from'].offset);
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_ys_offset, buffers['ys'].used));
memv.set(new Uint8Array(tmp.buffer, 0, buffers['ys'].used), buffers['ys'].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);
// NOTE: this static partitioning scheme turned out to be "good enough" (i.e., trying
// to allocate approximately the same amount of points per job wasn't any faster)
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;
}

177
client/tools.js

@ -1,23 +1,166 @@ @@ -1,23 +1,166 @@
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');
}
const old_class = 'tool-' + state.tools.active;
const new_class = 'tool-' + tool;
document.querySelector('canvas').classList.remove(old_class);
state.tools.active = tool;
state.tools.active_element = item;
state.tools.active_element.classList.add('active');
document.querySelector('canvas').classList.add(new_class);
if (tool === 'pencil' || tool === 'eraser' || tool === 'ruler') {
update_cursor(state);
document.querySelector('.brush-dom').classList.remove('dhide');
} else {
document.querySelector('.brush-dom').classList.add('dhide');
}
}
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;
}
storage.tools.active = tool;
storage.tools.active_element = document.querySelector(`.tool[data-tool="${tool}"]`);
storage.tools.active_element.classList.add('active');
const major_color = color_minor.parentElement;
select_color(state, major_color, color_u32);
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 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 init_tools(state) {
const tools = document.querySelectorAll('.tools .tool');
const color_groups = document.querySelectorAll('.pallete .color-major');
pencil.addEventListener('click', () => tools_switch('pencil'));
ruler.addEventListener('click', () => tools_switch('ruler'));
eraser.addEventListener('click', () => tools_switch('eraser'));
undo.addEventListener('click', queue_undo);
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;
tools_switch('pencil');
}
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);
}

359
client/touch.js

@ -1,359 +0,0 @@ @@ -1,359 +0,0 @@
function on_touchstart(e) {
e.preventDefault();
if (storage.touch.drawing) {
return;
}
// First finger(s) down?
if (storage.touch.ids.length === 0) {
// We only handle 1 and 2
if (e.changedTouches.length > 2) {
return;
}
storage.touch.ids.length = 0;
for (const touch of e.changedTouches) {
storage.touch.ids.push(touch.identifier);
}
if (e.changedTouches.length === 1) {
const touch = e.changedTouches[0];
const x = Math.round((touch.clientX + storage.canvas.offset_x) / storage.canvas.zoom);
const y = Math.round((touch.clientY + storage.canvas.offset_y) / storage.canvas.zoom);
storage.touch.position.x = x;
storage.touch.position.y = y;
// We give a bit of time to add a second finger
storage.touch.waiting_for_second_finger = true;
storage.touch.moves = 0;
storage.touch.buffered.length = 0;
storage.ruler_origin.x = x;
storage.ruler_origin.y = y;
setTimeout(() => {
storage.touch.waiting_for_second_finger = false;
}, config.second_finger_timeout);
}
return;
}
// There are touches already
if (storage.touch.waiting_for_second_finger) {
if (e.changedTouches.length === 1) {
const changed_touch = e.changedTouches[0];
storage.touch.screen_position.x = changed_touch.clientX;
storage.touch.screen_position.y = changed_touch.clientY;
storage.touch.ids.push(e.changedTouches[0].identifier);
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 x = touch.clientX;
const y = touch.clientY;
if (touch.identifier === storage.touch.ids[0]) {
first_finger_position = {'x': x, 'y': y};
}
if (touch.identifier === storage.touch.ids[1]) {
second_finger_position = {'x': x, 'y': y};
}
}
storage.touch.finger_distance = dist_v2(
first_finger_position, second_finger_position);
// console.log(storage.touch.finger_distance);
}
return;
}
}
function on_touchmove(e) {
if (storage.touch.ids.length === 1 && !storage.touch.moving) {
storage.touch.moves += 1;
if (storage.touch.moves > config.buffer_first_touchmoves) {
storage.touch.waiting_for_second_finger = false; // Immediately start drawing on move
storage.touch.drawing = true;
if (storage.ctx1.lineWidth !== storage.cursor.width) {
storage.ctx1.lineWidth = storage.cursor.width;
}
} else {
let drawing_touch = null;
for (const touch of e.changedTouches) {
if (touch.identifier === storage.touch.ids[0]) {
drawing_touch = touch;
break;
}
}
if (!drawing_touch) {
return;
}
const last_x = storage.touch.position.x;
const last_y = storage.touch.position.y;
const x = Math.max(Math.round((drawing_touch.clientX + storage.canvas.offset_x) / storage.canvas.zoom), 0);
const y = Math.max(Math.round((drawing_touch.clientY + storage.canvas.offset_y) / storage.canvas.zoom), 0);
storage.touch.buffered.push({
'last_x': last_x,
'last_y': last_y,
'x': x,
'y': y,
});
storage.touch.position.x = x;
storage.touch.position.y = y;
}
}
if (storage.touch.drawing) {
let drawing_touch = null;
for (const touch of e.changedTouches) {
if (touch.identifier === storage.touch.ids[0]) {
drawing_touch = touch;
break;
}
}
if (!drawing_touch) {
return;
}
const last_x = storage.touch.position.x;
const last_y = storage.touch.position.y;
const x = storage.touch.position.x = Math.max(Math.round((drawing_touch.clientX + storage.canvas.offset_x) / storage.canvas.zoom), 0);
const y = storage.touch.position.y = Math.max(Math.round((drawing_touch.clientY + storage.canvas.offset_y) / storage.canvas.zoom), 0);
if (storage.tools.active === 'pencil') {
if (storage.touch.buffered.length > 0) {
for (const p of storage.touch.buffered) {
storage.ctx1.beginPath();
storage.ctx1.moveTo(p.last_x, p.last_y);
storage.ctx1.lineTo(p.x, p.y);
storage.ctx1.stroke();
const predraw = predraw_event(p.x, p.y);
storage.current_stroke.push(predraw);
fire_event(predraw);
}
storage.touch.buffered.length = 0;
}
storage.ctx1.beginPath();
storage.ctx1.moveTo(last_x, last_y);
storage.ctx1.lineTo(x, y);
storage.ctx1.stroke();
const predraw = predraw_event(x, y);
storage.current_stroke.push(predraw);
fire_event(predraw);
storage.touch.position.x = x;
storage.touch.position.y = y;
return;
} else if (storage.tools.active === 'eraser') {
const erase_step = (last_x, last_y, x, y) => {
const erased = strokes_intersect_line(last_x, last_y, x, y);
storage.erased.push(...erased);
if (erased.length > 0) {
for (const other_event of storage.events) {
for (const stroke_id of erased) {
if (stroke_id === other_event.stroke_id) {
if (!other_event.deleted) {
other_event.deleted = true;
const stats = stroke_stats(other_event.points, storage.cursor.width);
redraw_region(stats.bbox);
}
}
}
}
}
};
if (storage.touch.buffered.length > 0) {
for (const p of storage.touch.buffered) {
erase_step(p.last_x, p.last_y, p.x, p.y);
}
storage.touch.buffered.length = 0;
}
erase_step(last_x, last_y, x, y);
} else if (storage.tools.active === 'ruler') {
const old_ruler = [
{'x': storage.ruler_origin.x, 'y': storage.ruler_origin.y},
{'x': last_x, 'y': last_y}
];
const stats = stroke_stats(old_ruler, storage.cursor.width);
const bbox = stats.bbox;
storage.ctx1.clearRect(bbox.xmin, bbox.ymin, bbox.xmax - bbox.xmin, bbox.ymax - bbox.ymin);
storage.ctx1.beginPath();
storage.ctx1.moveTo(storage.ruler_origin.x, storage.ruler_origin.y);
storage.ctx1.lineTo(x, y);
storage.ctx1.stroke();
} else {
console.error('fuck');
}
}
if (storage.touch.ids.length === 2) {
storage.touch.moving = true;
let first_finger_position_screen = null;
let second_finger_position_screen = null;
let first_finger_position_canvas = null;
let second_finger_position_canvas = 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 x = touch.clientX;
const y = touch.clientY;
const xc = Math.max(Math.round((touch.clientX + storage.canvas.offset_x) / storage.canvas.zoom), 0);
const yc = Math.max(Math.round((touch.clientY + storage.canvas.offset_y) / storage.canvas.zoom), 0);
if (touch.identifier === storage.touch.ids[0]) {
first_finger_position_screen = {'x': x, 'y': y};
first_finger_position_canvas = {'x': xc, 'y': yc};
}
if (touch.identifier === storage.touch.ids[1]) {
second_finger_position_screen = {'x': x, 'y': y};
second_finger_position_canvas = {'x': xc, 'y': yc};
}
}
const new_finger_distance = dist_v2(
first_finger_position_screen, second_finger_position_screen);
const zoom_center = {
'x': (first_finger_position_canvas.x + second_finger_position_canvas.x) / 2.0,
'y': (first_finger_position_canvas.y + second_finger_position_canvas.y) / 2.0
};
for (const touch of e.changedTouches) {
// The second finger to be down is considered the "main" one
// Movement of the second finger is ignored
if (touch.identifier === storage.touch.ids[1]) {
const x = Math.round(touch.clientX);
const y = Math.round(touch.clientY);
const dx = x - storage.touch.screen_position.x;
const dy = y - storage.touch.screen_position.y;
const old_zoom = storage.canvas.zoom;
const old_offset_x = storage.canvas.offset_x;
const old_offset_y = storage.canvas.offset_y;
storage.canvas.offset_x -= dx;
storage.canvas.offset_y -= dy;
// console.log(new_finger_distance, storage.touch.finger_distance);
const scale_by = new_finger_distance / storage.touch.finger_distance;
const dz = storage.canvas.zoom * (scale_by - 1.0);
const zoom_offset_y = Math.round(dz * zoom_center.y);
const zoom_offset_x = Math.round(dz * zoom_center.x);
if (storage.min_zoom <= storage.canvas.zoom * scale_by && storage.canvas.zoom * scale_by <= storage.max_zoom) {
storage.canvas.zoom *= scale_by;
storage.canvas.offset_x += zoom_offset_x;
storage.canvas.offset_y += zoom_offset_y;
}
storage.touch.finger_distance = new_finger_distance;
if (storage.canvas.offset_x !== old_offset_x || storage.canvas.offset_y !== old_offset_y || old_zoom !== storage.canvas.zoom) {
move_canvas();
}
storage.touch.screen_position.x = x;
storage.touch.screen_position.y = y;
break;
}
}
return;
}
}
async function on_touchend(e) {
for (const touch of e.changedTouches) {
if (storage.touch.drawing) {
if (storage.touch.ids[0] == touch.identifier) {
storage.touch.drawing = false;
if (storage.tools.active === 'pencil') {
const event = stroke_event();
storage.current_stroke = [];
await queue_event(event);
} else if (storage.tools.active === 'eraser') {
const events = eraser_events();
storage.erased = [];
if (events.length > 0) {
for (const event of events) {
await queue_event(event);
}
}
} else if (storage.tools.active === 'ruler') {
const event = ruler_event(storage.touch.position.x, storage.touch.position.y);
await queue_event(event);
} else {
console.error('fuck');
}
}
}
const index = storage.touch.ids.indexOf(touch.identifier);
if (index !== -1) {
storage.touch.ids.splice(index, 1);
}
if (storage.touch.moving && storage.touch.ids.length === 0) {
// Only allow drawing again when ALL fingers have been lifted
storage.touch.moving = false;
}
}
if (storage.touch.ids.length === 0) {
waiting_for_second_finger = false;
}
}

132
client/undo.js

@ -0,0 +1,132 @@ @@ -0,0 +1,132 @@
function undo(state, context, event, options) {
let need_draw = false;
// Remove effect of latest own event, in a way that is recoverable
// Iterate back to front to find the _latest_ event
for (let i = state.events.length - 1; i >=0; --i) {
const other_event = state.events[i];
let skipped = false;
// Users can only undo their own, undeleted (not already undone) events
if (other_event.user_id === event.user_id && !other_event.deleted) {
// All "persistent" events (those that are pushed using SYN messages) should be handled here
// "Transient" events are by design droppable, and should not be undone, nor saved in state.events at all
switch (other_event.type) {
case EVENT.STROKE: {
other_event.deleted = true;
if (other_event.bvh_node && !options.skip_bvh) {
bvh_delete_stroke(state, other_event);
}
need_draw = true;
break;
}
case EVENT.UNDO: {
// do not undo an undo, we are not Notepad
skipped = true;
break;
}
case EVENT.IMAGE: {
other_event.deleted = true;
const image = get_image(context, other_event.image_id);
if (image !== null) {
image.deleted = true;
}
need_draw = true;
break;
}
case EVENT.IMAGE_MOVE: {
other_event.deleted = true;
const image = get_image(context, other_event.image_id);
if (image !== null) {
pop_image_transform(image);
need_draw = true;
} else {
console.warning('Undo image move for a non-existent image');
}
break;
}
case EVENT.IMAGE_SCALE: {
other_event.deleted = true;
const image = get_image(context, other_event.image_id);
if (image !== null) {
pop_image_transform(image);
need_draw = true;
} else {
console.warning('Undo image scale for a non-existent image');
}
break;
}
case EVENT.ERASER: {
other_event.deleted = true;
const stroke = state.events[other_event.stroke_id];
stroke.deleted = false;
if (!options.skip_bvh) {
bvh_undelete_stroke(state, stroke);
}
need_draw = true;
break;
}
default: {
console.error('cant undo event type', other_event.type);
break;
}
}
if (!skipped) {
break;
}
}
}
return need_draw;
}
function redo() {
console.log('TODO');
}
function push_image_move(image, x, y) {
if (image.transform_head < image.transform_history.length) {
image.transform_history[image.transform_head] = image.at.x;
image.transform_history[image.transform_head + 1] = image.at.y;
image.transform_history[image.transform_head + 2] = image.width;
image.transform_history[image.transform_head + 3] = image.height;
} else {
image.transform_history.push(image.at.x, image.at.y, image.width, image.height);
}
image.at.x = x;
image.at.y = y;
image.transform_head += 4;
}
function push_image_scale(image, corner, x, y) {
if (image.transform_head < image.transform_history.length) {
image.transform_history[image.transform_head] = image.at.x;
image.transform_history[image.transform_head + 1] = image.at.y;
image.transform_history[image.transform_head + 2] = image.width;
image.transform_history[image.transform_head + 3] = image.height;
} else {
image.transform_history.push(image.at.x, image.at.y, image.width, image.height);
}
scale_image(image, corner, {'x': x, 'y': y});
image.transform_head += 4;
}
function pop_image_transform(image, corner, x, y) {
image.transform_head -= 4;
image.at.x = image.transform_history[image.transform_head - 4];
image.at.y = image.transform_history[image.transform_head - 3];
image.width = image.transform_history[image.transform_head - 2];
image.height = image.transform_history[image.transform_head - 1];
}

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.

615
client/webgl_draw.js

@ -0,0 +1,615 @@ @@ -0,0 +1,615 @@
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) {
const bpp = 2 * 4;
const data_size = serializer.offset - serializer.gpu_upload_from;
let data_pixels = data_size / bpp; // data_size % bpp is expected to always be zero here
const pixels_already_uploaded = serializer.gpu_upload_from / bpp;
let rows_uploaded = Math.floor(pixels_already_uploaded / texture_size);
const rows_remainder = pixels_already_uploaded % texture_size;
// Upload first non-whole row (if last upload was not a whole number of rows)
if (rows_remainder > 0) {
const row_upload_to_full = texture_size - rows_remainder;
const first_upload = Math.min(row_upload_to_full, data_pixels);
if (first_upload > 0) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, rows_remainder, rows_uploaded, first_upload, 1, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, new Uint16Array(serializer.buffer, serializer.gpu_upload_from, first_upload * 4));
data_pixels -= first_upload;
serializer.gpu_upload_from += first_upload;
rows_uploaded += 1;
}
}
const rows = Math.ceil(data_pixels / texture_size);
const last_row = data_pixels % texture_size;
const whole_upload = (rows - 1) * texture_size;
// Upload whole rows
if (rows > 1) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, rows_uploaded, texture_size, rows - 1, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, new Uint16Array(serializer.buffer, serializer.gpu_upload_from, whole_upload * 4));
rows_uploaded += rows - 1;
}
// Upload last row
if (last_row > 0) {
const last_row_upload = last_row * bpp;
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, rows_uploaded, last_row, 1, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, new Uint16Array(serializer.buffer, whole_upload, last_row_upload * 4));
}
serializer.gpu_upload_from = serializer.offset;
}
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);
const locations = context.locations;
const buffers = context.buffers;
const programs = context.programs;
const textures = context.textures;
// Draw the background pattern
if (state.background_pattern === 'dots') {
const pr = programs['dots'];
gl.useProgram(pr.program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance_dot']);
gl.enableVertexAttribArray(pr.locations['a_center']);
gl.vertexAttribPointer(pr.locations['a_center'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribDivisor(pr.locations['a_center'], 1);
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.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(pr.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(pr.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 pr = programs['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(pr.program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance_grid']);
gl.enableVertexAttribArray(pr.locations['a_data']);
gl.vertexAttribPointer(pr.locations['a_data'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribDivisor(pr.locations['a_data'], 1);
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1f(pr.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(pr.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(pr.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);
}
}
// Images
{
const pr = programs['image'];
gl.clear(gl.DEPTH_BUFFER_BIT); // draw images above the background pattern
gl.useProgram(pr.program);
let offset = 0;
const quads = geometry_image_quads(state, context);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_images']);
gl.bufferData(gl.ARRAY_BUFFER, quads, gl.STATIC_DRAW);
gl.vertexAttribDivisor(pr.locations['a_pos'], 0);
gl.enableVertexAttribArray(pr.locations['a_pos']);
gl.vertexAttribPointer(pr.locations['a_pos'], 2, gl.FLOAT, false, 2 * 4, 0);
for (const entry of context.images) {
if (!entry.deleted) {
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(pr.locations['u_texture'], 0); // Only 1 active texture for each drawcall
gl.uniform1i(pr.locations['u_solid'], 0);
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.drawArrays(gl.TRIANGLES, offset, 6);
// Highlight active image
if (entry.key === state.active_image) {
gl.uniform1i(pr.locations['u_solid'], 1);
gl.uniform4f(pr.locations['u_color'], 0.133 * 0.5, 0.545 * 0.5, 0.902 * 0.5, 0.5);
gl.drawArrays(gl.TRIANGLES, offset, 6);
}
}
offset += 6;
}
}
const circle_segments = 32;
const circle_points = circle_segments * 3;
const circle_data = geometry_line_segments_with_two_circles(circle_segments);
// "Static" data upload
if (segment_count > 0) {
const pr = programs['main'];
gl.clear(gl.DEPTH_BUFFER_BIT); // draw strokes above the images
gl.useProgram(pr.program);
const total_static_size = context.instance_data_points.size * 4 +
context.instance_data_ids.size * 4 +
round_to_pow2(context.instance_data_pressures.size, 4) +
circle_data.length * 4;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_strokes_static']);
gl.bufferData(gl.ARRAY_BUFFER, total_static_size, gl.DYNAMIC_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.bufferSubData(gl.ARRAY_BUFFER, context.instance_data_points.size * 4 + context.instance_data_ids.size * 4 + round_to_pow2(context.instance_data_pressures.size, 4),
circle_data);
gl.bindTexture(gl.TEXTURE_2D, textures['stroke_data']);
upload_square_rgba16ui_texture(gl, context.stroke_data, config.stroke_texture_size);
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(pr.locations['u_stroke_count'], state.events.length);
gl.uniform1i(pr.locations['u_debug_mode'], state.debug.red);
gl.uniform1i(pr.locations['u_stroke_data'], 0);
gl.uniform1i(pr.locations['u_stroke_texture_size'], config.stroke_texture_size);
gl.uniform1f(pr.locations['u_fixed_pixel_width'], 0);
gl.uniform1i(pr.locations['u_circle_points'], circle_points);
gl.enableVertexAttribArray(pr.locations['a_pos']);
gl.enableVertexAttribArray(pr.locations['a_a']);
gl.enableVertexAttribArray(pr.locations['a_b']);
gl.enableVertexAttribArray(pr.locations['a_stroke_id']);
gl.enableVertexAttribArray(pr.locations['a_pressure']);
// Circle meshes (shared for all instances)
gl.vertexAttribPointer(pr.locations['a_pos'], 2, gl.FLOAT, false, 2 * 4, context.instance_data_points.size * 4 + context.instance_data_ids.size * 4 + round_to_pow2(context.instance_data_pressures.size, 4));
// 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(pr.locations['a_a'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribPointer(pr.locations['a_b'], 2, gl.FLOAT, false, 2 * 4, 2 * 4);
gl.vertexAttribIPointer(pr.locations['a_stroke_id'], 1, gl.INT, 4, context.instance_data_points.size * 4);
gl.vertexAttribPointer(pr.locations['a_pressure'], 2, gl.UNSIGNED_BYTE, true, 1, context.instance_data_points.size * 4 + context.instance_data_ids.size * 4);
gl.vertexAttribDivisor(pr.locations['a_pos'], 0);
gl.vertexAttribDivisor(pr.locations['a_a'], 1);
gl.vertexAttribDivisor(pr.locations['a_b'], 1);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 1);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 1);
// Static draw (everything already bound)
gl.drawArraysInstanced(gl.TRIANGLES, 0, circle_points + 6, segment_count);
// I don't really know why I need to do this, but it
// makes background patter drawcall work properly
gl.vertexAttribDivisor(pr.locations['a_pos'], 0);
gl.vertexAttribDivisor(pr.locations['a_a'], 0);
gl.vertexAttribDivisor(pr.locations['a_b'], 0);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 0);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 0);
}
// Dynamic draw (strokes currently being drawn)
if (false && dynamic_segment_count > 0) {
const pr = programs['main']; // same as static
// Dynamic strokes should be drawn above static strokes
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(pr.program);
gl.uniform1i(pr.locations['u_stroke_count'], dynamic_stroke_count);
gl.uniform1i(pr.locations['u_stroke_data'], 0);
gl.uniform1i(pr.locations['u_stroke_texture_size'], config.dynamic_stroke_texture_size);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_strokes_dynamic']);
// 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, textures['dynamic_stroke_data']);
upload_square_rgba16ui_texture(gl, context.dynamic_stroke_data, config.dynamic_stroke_texture_size);
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(pr.locations['u_stroke_count'], context.dynamic_stroke_count);
gl.uniform1i(pr.locations['u_debug_mode'], state.debug.red);
gl.uniform1i(pr.locations['u_stroke_data'], 0);
gl.uniform1i(pr.locations['u_stroke_texture_size'], config.dynamic_stroke_texture_size);
gl.uniform1f(pr.locations['u_fixed_pixel_width'], 0);
gl.enableVertexAttribArray(pr.locations['a_a']);
gl.enableVertexAttribArray(pr.locations['a_b']);
gl.enableVertexAttribArray(pr.locations['a_stroke_id']);
gl.enableVertexAttribArray(pr.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)
if (context.dynamic_instance_ids.size > 1) {
gl.vertexAttribPointer(pr.locations['a_a'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribPointer(pr.locations['a_b'], 2, gl.FLOAT, false, 2 * 4, 2 * 4);
} else {
// A special case where there is no second point. Reuse the first point and handle the zero length segment in the shader
gl.vertexAttribPointer(pr.locations['a_a'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribPointer(pr.locations['a_b'], 2, gl.FLOAT, false, 2 * 4, 0);
}
gl.vertexAttribIPointer(pr.locations['a_stroke_id'], 1, gl.INT, 4, context.dynamic_instance_points.size * 4);
gl.vertexAttribPointer(pr.locations['a_pressure'], 2, gl.UNSIGNED_BYTE, true, 1, context.dynamic_instance_points.size * 4 + context.dynamic_instance_ids.size * 4);
gl.vertexAttribDivisor(pr.locations['a_a'], 1);
gl.vertexAttribDivisor(pr.locations['a_b'], 1);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 1);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 1);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 32 * 3 + 6 + 32 * 3, dynamic_segment_count);
gl.vertexAttribDivisor(pr.locations['a_a'], 0);
gl.vertexAttribDivisor(pr.locations['a_b'], 0);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 0);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 0);
}
// HUD: resize handles, etc
if (state.active_image !== null) {
const pr = programs['main']; // same as static
const handles = geometry_generate_handles(state, context, state.active_image);
const ui_segments = 7 * 4 - 1; // each square = 4, each line = 1, square->line = 1, line->square = 1
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_hud']);
gl.bufferData(gl.ARRAY_BUFFER, handles.points.byteLength + handles.ids.byteLength + handles.pressures.byteLength, gl.STREAM_DRAW);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, handles.points);
gl.bufferSubData(gl.ARRAY_BUFFER, handles.points.byteLength, handles.ids);
gl.bufferSubData(gl.ARRAY_BUFFER, handles.points.byteLength + handles.ids.byteLength, handles.pressures);
gl.bindTexture(gl.TEXTURE_2D, textures['ui']);
upload_square_rgba16ui_texture(gl, handles.stroke_data, config.ui_texture_size);
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.uniform1i(pr.locations['u_stroke_count'], 8);
gl.uniform1i(pr.locations['u_debug_mode'], 0);
gl.uniform1i(pr.locations['u_stroke_data'], 0);
gl.uniform1i(pr.locations['u_stroke_texture_size'], config.ui_texture_size);
gl.uniform1f(pr.locations['u_fixed_pixel_width'], 2);
gl.enableVertexAttribArray(pr.locations['a_a']);
gl.enableVertexAttribArray(pr.locations['a_b']);
gl.enableVertexAttribArray(pr.locations['a_stroke_id']);
gl.enableVertexAttribArray(pr.locations['a_pressure']);
gl.vertexAttribPointer(pr.locations['a_a'], 2, gl.FLOAT, false, 2 * 4, 0);
gl.vertexAttribPointer(pr.locations['a_b'], 2, gl.FLOAT, false, 2 * 4, 2 * 4);
gl.vertexAttribIPointer(pr.locations['a_stroke_id'], 1, gl.INT, 4, handles.points.byteLength);
gl.vertexAttribPointer(pr.locations['a_pressure'], 2, gl.UNSIGNED_BYTE, true, 1, handles.points.byteLength + handles.ids.byteLength);
gl.vertexAttribDivisor(pr.locations['a_a'], 1);
gl.vertexAttribDivisor(pr.locations['a_b'], 1);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 1);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 1);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 32 * 3 + 6 + 32 * 3, ui_segments);
// I don't really know why I need to do this, but it
// makes background patter drawcall work properly
gl.vertexAttribDivisor(pr.locations['a_a'], 0);
gl.vertexAttribDivisor(pr.locations['a_b'], 0);
gl.vertexAttribDivisor(pr.locations['a_stroke_id'], 0);
gl.vertexAttribDivisor(pr.locations['a_pressure'], 0);
}
if (config.draw_bvh) {
const pr = programs['iquad'];
const bboxes = tv_create(Float32Array, context.clipped_indices.size * 4);
// Debug BVH viz
for (let i = 0; i < context.clipped_indices.size; ++i) {
const stroke_id = context.clipped_indices.data[i];
const stroke = state.events[stroke_id];
tv_add(bboxes, stroke.bbox.x1);
tv_add(bboxes, stroke.bbox.y1);
tv_add(bboxes, stroke.bbox.x2);
tv_add(bboxes, stroke.bbox.y2);
}
const quad_count = bboxes.size / 4;
gl.useProgram(pr.program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_iquads']);
gl.bufferData(gl.ARRAY_BUFFER, tv_data(bboxes), gl.STREAM_DRAW);
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.enableVertexAttribArray(pr.locations['a_topleft']);
gl.enableVertexAttribArray(pr.locations['a_bottomright']);
gl.vertexAttribPointer(pr.locations['a_topleft'], 2, gl.FLOAT, false, 4 * 4, 0);
gl.vertexAttribPointer(pr.locations['a_bottomright'], 2, gl.FLOAT, false, 4 * 4, 2 * 4);
gl.vertexAttribDivisor(pr.locations['a_topleft'], 1);
gl.vertexAttribDivisor(pr.locations['a_bottomright'], 1);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, quad_count);
gl.vertexAttribDivisor(pr.locations['a_topleft'], 0);
gl.vertexAttribDivisor(pr.locations['a_bottomright'], 0);
}
if (config.draw_fullnodes) {
const quads = bvh_get_fullnodes_debug(state, context);
const pr = programs['iquad'];
const bboxes = tv_create(Float32Array, quads.length * 4);
for (let i = 0; i < quads.length; ++i) {
const bbox = quads[i];
tv_add(bboxes, bbox.x1);
tv_add(bboxes, bbox.y1);
tv_add(bboxes, bbox.x2);
tv_add(bboxes, bbox.y2);
}
const quad_count = bboxes.size / 4;
gl.useProgram(pr.program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_iquads']);
gl.bufferData(gl.ARRAY_BUFFER, tv_data(bboxes), gl.STREAM_DRAW);
gl.uniform2f(pr.locations['u_res'], context.canvas.width, context.canvas.height);
gl.uniform2f(pr.locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
gl.uniform2f(pr.locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
gl.enableVertexAttribArray(pr.locations['a_topleft']);
gl.enableVertexAttribArray(pr.locations['a_bottomright']);
gl.vertexAttribPointer(pr.locations['a_topleft'], 2, gl.FLOAT, false, 4 * 4, 0);
gl.vertexAttribPointer(pr.locations['a_bottomright'], 2, gl.FLOAT, false, 4 * 4, 2 * 4);
gl.vertexAttribDivisor(pr.locations['a_topleft'], 1);
gl.vertexAttribDivisor(pr.locations['a_bottomright'], 1);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, quad_count);
gl.vertexAttribDivisor(pr.locations['a_topleft'], 0);
gl.vertexAttribDivisor(pr.locations['a_bottomright'], 0);
}
document.getElementById('debug-stats').innerHTML = `
<span>Strokes onscreen: ${context.clipped_indices.size}</span>
<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);
}

549
client/webgl_geometry.js

@ -0,0 +1,549 @@ @@ -0,0 +1,549 @@
function geometry_prepare_stroke(state) {
if (!state.online) {
return null;
}
const player = state.players[state.me];
const stroke = player.strokes[player.strokes.length - 1]; // MY OWN player.strokes should never be bigger than 1 element
if (stroke.points.length === 0) {
return null;
}
const points = process_stroke2(state.canvas.zoom, stroke.points);
return {
'color': stroke.color,
'width': stroke.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);
}
// Real stroke, add forever
function geometry_add_stroke(state, context, stroke, stroke_index, skip_bvh = false) {
if (!state.online || !stroke || stroke.coords_to - stroke.coords_from === 0 || stroke.deleted) 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 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];
for (const stroke of player.strokes) {
if (!stroke.empty && stroke.points.length > 0) {
total_points += stroke.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 (const stroke of player.strokes) {
if (!stroke.empty && stroke.points.length > 0) {
for (let i = 0; i < stroke.points.length; ++i) {
const p = stroke.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 !== stroke.points.length - 1) {
tv_add(context.dynamic_instance_ids, stroke_index);
} else {
tv_add(context.dynamic_instance_ids, stroke_index | (1 << 31));
}
}
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.dynamic_stroke_data, r);
ser_u16(context.dynamic_stroke_data, g);
ser_u16(context.dynamic_stroke_data, b);
ser_u16(context.dynamic_stroke_data, stroke.width);
stroke_index += 1; // TODO: proper player Z order
}
}
}
context.dynamic_segment_count = total_points;
context.dynamic_stroke_count = total_strokes;
}
function geometry_start_prestroke(state, player_id) {
if (!state.online) return;
const player = state.players[player_id];
player.strokes.push({
'empty': false,
'points': [],
'head': null,
'color': player.color,
'width': player.width,
});
player.current_prestroke = true;
}
function geometry_end_prestroke(state, player_id) {
if (!state.online) return;
const player = state.players[player_id];
player.current_prestroke = false;
}
function geometry_add_prepoint(state, context, player_id, point, is_pen, raw = false) {
if (!state.online) return;
const player = state.players[player_id];
const stroke = player.strokes[player.strokes.length - 1];
const points = stroke.points;
if (point.pressure < config.min_pressure) {
point.pressure = config.min_pressure;
}
if (points.length > 0 && !raw) {
// pulled from "perfect-freehand" package. MIT
// https://github.com/steveruizok/perfect-freehand/
const streamline = 0.75;
const t = 0.15 + (1 - streamline) * 0.85
const smooth_pressure = exponential_smoothing(points, point, 3);
points.push({
'x': stroke.head.x * t + point.x * (1 - t),
'y': stroke.head.y * t + point.y * (1 - t),
'pressure': is_pen ? stroke.head.pressure * t + smooth_pressure * (1 - t) : point.pressure,
});
if (is_pen) {
point.pressure = smooth_pressure;
}
} else {
points.push(point);
}
stroke.head = point;
recompute_dynamic_data(state, context);
}
// Remove prestroke from dynamic data (usually because it's now a real stroke)
function geometry_clear_oldest_prestroke(state, context, player_id) {
if (!state.online) return;
const player = state.players[player_id];
player.strokes.shift();
recompute_dynamic_data(state, context);
}
function add_image(context, image_id, bitmap, p, width, height) {
const gl = context.gl;
let entry = null;
// If bitmap not available yet - create placeholder
// Otherwise - upload actual bitmap
if (bitmap === null) {
entry = {
'texture': gl.createTexture(),
'key': image_id,
'at': {...p},
'raw_at': {...p},
'width': width,
'height': height,
'transform_history': [ p.x, p.y, width, height ],
'transform_head': 4,
};
context.images.push(entry);
} else {
entry = get_image(context, image_id);
}
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
if (bitmap !== null) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
gl.generateMipmap(gl.TEXTURE_2D);
} else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4 * width * height));
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
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);
gl.generateMipmap(gl.TEXTURE_2D);
}
}
function scale_image(image, corner, canvasp) {
let new_width, new_height;
const old_x2 = image.at.x + image.width;
const old_y2 = image.at.y + image.height;
if (corner === 0) {
image.at.x = canvasp.x;
image.at.y = canvasp.y;
new_width = old_x2 - image.at.x;
new_height = old_y2 - image.at.y;
} else if (corner === 1) {
image.at.y = canvasp.y;
new_width = canvasp.x - image.at.x;
new_height = old_y2 - image.at.y;
} else if (corner === 2) {
new_width = canvasp.x - image.at.x;
new_height = canvasp.y - image.at.y;
} else if (corner === 3) {
image.at.x = canvasp.x;
new_width = old_x2 - image.at.x;
new_height = canvasp.y - image.at.y;
}
image.width = new_width;
image.height = new_height;
}
function image_at(context, x, y) {
// Iterate back to front to pick the image at the front first
for (let i = context.images.length - 1; i >= 0; --i) {
const image = context.images[i];
if (!image.deleted) {
const at = image.at;
const w = image.width;
const h = image.height;
const in_x = (at.x <= x && x <= at.x + w) || (at.x + w <= x && x <= at.x);
const in_y = (at.y <= y && y <= at.y + h) || (at.y + h <= y && y <= at.y);
if (in_x && in_y) {
return image;
}
}
}
return null;
}
function image_corner(state, image, canvasp) {
const sp = canvas_to_screen(state, canvasp);
const at = canvas_to_screen(state, image.at);
const w = image.width * state.canvas.zoom;
const h = image.height * state.canvas.zoom;
const width = 8;
if (at.x - width <= sp.x && sp.x <= at.x + width && at.y - width <= sp.y && sp.y <= at.y + width) {
return 0;
}
if (at.x + w - width <= sp.x && sp.x <= at.x + w + width && at.y - width <= sp.y && sp.y <= at.y + width) {
return 1;
}
if (at.x + w - width <= sp.x && sp.x <= at.x + w + width && at.y + h - width <= sp.y && sp.y <= at.y + h + width) {
return 2;
}
if (at.x - width <= sp.x && sp.x <= at.x + width && at.y + h - width <= sp.y && sp.y <= at.y + h + width) {
return 3;
}
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;
}
function geometry_generate_handles(state, context, active_image) {
let image = null;
for (const entry of context.images) {
if (entry.key === active_image) {
image = entry;
break;
}
}
const x1 = image.at.x;
const y1 = image.at.y;
const x2 = image.at.x + image.width;
const y2 = image.at.y + image.height;
const width = 4 / state.canvas.zoom;
const points = new Float32Array([
// top-left handle
x1 - width, y1 - width,
x1 + width, y1 - width,
x1 + width, y1 + width,
x1 - width, y1 + width,
x1 - width, y1 - width,
// -> top-right
x1 + width, y1,
x2 - width, y1,
// top-right handle
x2 - width, y1 - width,
x2 + width, y1 - width,
x2 + width, y1 + width,
x2 - width, y1 + width,
x2 - width, y1 - width,
// -> bottom-right
x2, y1 + width,
x2, y2 - width,
// bottom-right handle
x2 - width, y2 - width,
x2 + width, y2 - width,
x2 + width, y2 + width,
x2 - width, y2 + width,
x2 - width, y2 - width,
// -> bottom-left
x2 - width, y2,
x1 + width, y2,
// bottom-left handle
x1 - width, y2 - width,
x1 + width, y2 - width,
x1 + width, y2 + width,
x1 - width, y2 + width,
x1 - width, y2 - width,
// -> top-left
x1, y2 - width,
x1, y1 + width,
]);
const ids = new Uint32Array([
0, 0, 0, 0, 0 | (1 << 31),
1, 1 | (1 << 31),
2, 2, 2, 2, 2 | (1 << 31),
3, 3 | (1 << 31),
4, 4, 4, 4, 4 | (1 << 31),
5, 5 | (1 << 31),
6, 6, 6, 6, 6 | (1 << 31),
7, 7 | (1 << 31),
]);
const pressures = new Uint8Array([
128, 128, 128, 128, 128,
128, 128, 128,
128, 128, 128, 128, 128,
128, 128, 128,
128, 128, 128, 128, 128,
128, 128, 128,
128, 128, 128, 128, 128,
128, 128, 128,
]);
const stroke_data = serializer_create(8 * 4 * 2);
for (let i = 0; i < 8; ++i) {
ser_u16(stroke_data, 34);
ser_u16(stroke_data, 139);
ser_u16(stroke_data, 230);
ser_u16(stroke_data, 0);
}
return {
'points': points,
'ids': ids,
'pressures': pressures,
'stroke_data': stroke_data,
};
}
function geometry_line_segments_with_two_circles(circle_segments) {
const results = new Float32Array((circle_segments * 3 + 6) * 2); // triangle fan circle + two triangles, all 2D (x + y)
// Generate circle as triangle fan at 0, 0 with radius 1
// This circle will be offset/scaled in the vertex shader
let last_phi = ((circle_segments - 1) / circle_segments) * 2 * Math.PI;
for (let i = 0; i < circle_segments; ++i) {
const phi = i / circle_segments * 2 * Math.PI;
const x1 = Math.cos(phi);
const y1 = Math.sin(phi);
const x2 = Math.cos(last_phi);
const y2 = Math.sin(last_phi);
results[i * 6 + 0] = x1;
results[i * 6 + 1] = y1;
results[i * 6 + 2] = x2;
results[i * 6 + 3] = y2;
results[i * 6 + 4] = 0;
results[i * 6 + 5] = 0;
last_phi = phi;
}
return results;
}

839
client/webgl_listeners.js

@ -0,0 +1,839 @@ @@ -0,0 +1,839 @@
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('do-snap').checked = state.snap !== null;
document.getElementById('debug-print').checked = config.debug_print;
document.getElementById('draw-bvh').checked = config.draw_bvh;
document.getElementById('debug-red').addEventListener('change', (e) => {
state.debug.red = e.target.checked;
schedule_draw(state, context);
});
document.getElementById('do-snap').addEventListener('change', (e) => {
state.snap = e.target.checked ? 'grid' : null;
});
document.getElementById('debug-print').addEventListener('change', (e) => {
config.debug_print = e.target.checked;
});
document.getElementById('draw-bvh').addEventListener('change', (e) => {
config.draw_bvh = e.target.checked;
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.target_zoom = Math.pow(1.0 + dz, Math.abs(state.canvas.zoom_level))
state.canvas.zoom = state.canvas.target_zoom;
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) {
if (state.tools.active === 'pencil') { // or other drawing tools
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) {
if (state.colorpicking) {
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 (config.debug_print) {
console.debug('keydown', e.code);
}
const doing_things = (state.moving || state.drawing || state.erasing || state.colorpicking || state.imagemoving || state.imagescaling || state.linedrawing);
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.paddingcode === 'ControlRight') {
enter_picker_mode(state, context);
} else if (e.code === 'Slash') {
document.querySelector('.debug-window').classList.toggle('dhide');
e.preventDefault();
} else if (e.code === 'KeyZ') {
if (e.ctrlKey) {
queue_event(state, undo_event(state));
} else {
state.zoomdown = true;
}
} else if (e.code === 'KeyS') {
if (!doing_things) {
switch_tool(state, document.querySelector('.tool[data-tool="pointer"]'));
}
} else if (e.code === 'KeyD') {
if (!doing_things) {
switch_tool(state, document.querySelector('.tool[data-tool="pencil"]'));
}
} else if (e.code === 'KeyE') {
if (!doing_things) {
switch_tool(state, document.querySelector('.tool[data-tool="eraser"]'));
}
} else if (e.code === 'KeyR') {
if (!doing_things) {
switch_tool(state, document.querySelector('.tool[data-tool="ruler"]'));
}
} else if (e.code === 'Esc') {
cancel_everything(state, context);
}
}
function keyup(e, state, context) {
if (config.debug_print) {
console.debug('keydown', e.code);
}
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);exit_picker_mode
} 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);
const raw_canvasp = {...canvasp};
if (state.snap === 'grid') {
const step = grid_snap_step(state);
canvasp.x = Math.round(canvasp.x / step) * step;
canvasp.y = Math.round(canvasp.y / step) * step;
}
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 (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_start_prestroke(state, state.me);
geometry_add_prepoint(state, context, state.me, canvasp, e.pointerType === "pen");
state.drawing = true;
state.active_image = null;
schedule_draw(state, context);
} else if (state.tools.active === 'ruler') {
state.linedrawing = true;
state.ruler_origin = canvasp;
geometry_start_prestroke(state, state.me);
} else if (state.tools.active === 'eraser') {
state.erasing = true;
} else if (state.tools.active === 'pointer') {
state.imagescaling = false;
state.imagemoving = false;
if (state.active_image !== null) {
// Check for resize first, because it supports
// clicking slightly outside of the image
const image = get_image(context, state.active_image);
const corner = image_corner(state, image, raw_canvasp);
if (corner !== null) {
// Resize
state.imagescaling = true;
state.scaling_corner = corner;
document.querySelector('canvas').classList.remove('resize-topleft');
document.querySelector('canvas').classList.remove('resize-topright');
if (corner === 0 || corner === 2) {
document.querySelector('canvas').classList.add('resize-topleft');
} else if (corner === 1 || corner === 3) {
document.querySelector('canvas').classList.add('resize-topright');
}
}
}
// Only do picking logic if we haven't started imagescaling already
if (!state.imagescaling) {
const image = image_at(context, raw_canvasp.x, raw_canvasp.y);
if (image !== null) {
state.active_image = image.key;
// Allow immediately moving
state.imagemoving = true;
state.image_actually_moved = false;
image.raw_at.x = image.at.x;
image.raw_at.y = image.at.y;
} else {
state.active_image = null;
}
}
schedule_draw(state, context);
}
}
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);
const raw_canvasp = {...canvasp};
if (state.snap === 'grid') {
const step = grid_snap_step(state);
canvasp.x = Math.round(canvasp.x / step) * step;
canvasp.y = Math.round(canvasp.y / step) * step;
}
if (state.tools.active === 'pointer') {
if (state.active_image !== null) {
const image = get_image(context, state.active_image);
const corner = image_corner(state, image, raw_canvasp);
if (state.scaling_corner === null) {
document.querySelector('canvas').classList.remove('resize-topleft');
document.querySelector('canvas').classList.remove('resize-topright');
if (corner === 0 || corner === 2) {
document.querySelector('canvas').classList.add('resize-topleft');
} else if (corner === 1 || corner === 3) {
document.querySelector('canvas').classList.add('resize-topright');
}
}
}
}
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_screen = canvas_to_screen(state, canvasp);
const brush_x = brush_screen.x - radius - 2;
const brush_y = brush_screen.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.imagescaling) {
const image = get_image(context, state.active_image);
scale_image(image, state.scaling_corner, canvasp);
do_draw = true;
}
if (state.imagemoving) {
const image = get_image(context, state.active_image);
if (image !== null) {
const dx = e.movementX / state.canvas.zoom;
const dy = e.movementY / state.canvas.zoom;
image.raw_at.x += dx;
image.raw_at.y += dy;
if (state.snap === 'grid') {
const step = grid_snap_step(state);
image.at.x = Math.round(image.raw_at.x / step) * step;
image.at.y = Math.round(image.raw_at.y / step) * step;
} else if (state.snap === null) {
image.at.x = image.raw_at.x;
image.at.y = image.raw_at.y;
}
state.image_actually_moved = true;
do_draw = true;
}
}
if (state.drawing) {
canvasp.pressure = Math.ceil(e.pressure * 255);
geometry_add_prepoint(state, context, state.me, canvasp, e.pointerType === "pen");
fire_event(state, predraw_event(canvasp.x, canvasp.y));
do_draw = true;
}
if (state.erasing) {
const me = state.players[state.me];
const radius = Math.round(me.width / 2);
const last_canvasp = screen_to_canvas(state, state.cursor);
const cursor_bbox = {
'x1': Math.min(canvasp.x, last_canvasp.x) - radius,
'y1': Math.min(canvasp.y, last_canvasp.y) - radius,
'x2': Math.max(canvasp.x, last_canvasp.x) + radius,
'y2': Math.max(canvasp.y, last_canvasp.y) + radius,
};
tv_ensure(state.erase_candidates, round_to_pow2(state.stroke_count, 4096));
tv_clear(state.erase_candidates);
// Rough pass, not all of these might actually need to be erased
bvh_intersect_quad(state, state.bvh, cursor_bbox, state.erase_candidates);
// Fine pass, actually run expensive capsule vs capsule intersection tests
for (let i = 0; i < state.erase_candidates.size; ++i) {
const stroke_id = state.erase_candidates.data[i];
const stroke = state.events[stroke_id];
if (!stroke.deleted && stroke_intersects_capsule(state, stroke, last_canvasp, canvasp, radius)) {
stroke.deleted = true;
bvh_delete_stroke(state, stroke);
queue_event(state, eraser_event(stroke_id));
do_draw = true;
}
}
}
if (state.linedrawing) {
const p1 = {'x': state.ruler_origin.x, 'y': state.ruler_origin.y, 'pressure': 128};
const p2 = {'x': canvasp.x, 'y': canvasp.y, 'pressure': 128};
if (state.online) {
const me = state.players[state.me];
const prestroke = me.strokes[me.strokes.length - 1]; // TODO: might as well be me.strokes[0] ?
prestroke.points.length = 2;
prestroke.points[0] = p1;
prestroke.points[1] = p2;
recompute_dynamic_data(state, context);
do_draw = true;
}
}
if (do_draw) {
schedule_draw(state, context);
}
state.cursor = screenp;
return false;
}
function mouseup(e, state, context) {
const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY};
const canvasp = screen_to_canvas(state, screenp);
const raw_canvasp = {...canvasp};
if (state.snap === 'grid') {
const step = grid_snap_step(state);
canvasp.x = Math.round(canvasp.x / step) * step;
canvasp.y = Math.round(canvasp.y / step) * step;
}
if (e.button !== 0 && e.button !== 1) {
return;
}
if (state.zooming) {
state.zooming = false;
return;
}
if (state.imagemoving) {
state.imagemoving = false;
if (state.image_actually_moved) {
state.image_actually_moved = false;
const image = get_image(context, state.active_image);
image.raw_at.x = image.at.x;
image.raw_at.y = image.at.y;
queue_event(state, image_move_event(state.active_image, image.at.x, image.at.y));
schedule_draw(state, context);
}
return;
}
if (state.imagescaling) {
queue_event(state, image_scale_event(state.active_image, state.scaling_corner, canvasp.x, canvasp.y));
state.imagescaling = false;
state.scaling_corner = 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
queue_event(state, stroke_event(state));
schedule_draw(state, context);
}
fire_event(state, lift_event());
state.drawing = false;
return;
}
if (state.erasing) {
state.erasing = false;
return;
}
if (state.linedrawing) {
state.linedrawing = false;
queue_event(state, stroke_event(state));
schedule_draw(state, context);
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) {
if (!(state.me in state.players)) {
// we not ready yet
return;
}
const me = state.players[state.me];
const width = Math.max(me.width * state.canvas.zoom, 2.0);
const radius = Math.round(width / 2);
let svg;
if (state.tools.active === 'pencil' || state.tools.active === 'ruler') {
const current_color = color_from_u32(me.color);
const stroke = (me.color === 0xFFFFFF ? 'black' : 'white');
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', ' ');
} else if (state.tools.active === 'eraser') {
const current_color = '#ffffff';
const stroke = '#000000';
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
// TODO: @touch, remove preview
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
// TODO: fix when doing @touch
//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));
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;
}
function cancel_everything(state, context) {
}

463
client/webgl_shaders.js

@ -0,0 +1,463 @@ @@ -0,0 +1,463 @@
const sdf_vs_src = `#version 300 es
in vec2 a_pos; // for the joins/caps these are relative positions. for line segments these are dummy values
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;
uniform float u_fixed_pixel_width;
uniform int u_circle_points;
out vec3 v_color;
flat out vec2 v_thickness;
void main() {
vec2 screen02;
int per_segment_points = u_circle_points * 2 + 6;
int vertex_index = gl_VertexID % per_segment_points;
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);
if (u_fixed_pixel_width > 0.0) {
radius = u_fixed_pixel_width / u_scale.x;
}
vec2 pos;
if (vertex_index < u_circle_points) {
pos = a_a + a_pos * radius * a_pressure.x * 2.0;
} else {
int vertex_index_line = vertex_index - u_circle_points;
vec2 line_dir = normalize(a_b - a_a);
vec2 up_dir = vec2(line_dir.y, -line_dir.x);
// connecting line
if (vertex_index_line == 0) {
// top left
pos = a_a + up_dir * radius * a_pressure.x * 2.0;
} else if (vertex_index_line == 1 || vertex_index_line == 5) {
// top right
pos = a_b + up_dir * radius * a_pressure.y * 2.0;
} else if (vertex_index_line == 2 || vertex_index_line == 4) {
// bottom left
pos = a_a - up_dir * radius * a_pressure.x * 2.0;
} else {
// bottom right
pos = a_b - up_dir * radius * a_pressure.y * 2.0;
}
}
screen02 = (pos.xy * u_scale + u_translation) / u_res * 2.0;
screen02.y = 2.0 - screen02.y;
v_color = vec3(stroke_data.xyz) / 255.0;
/*
if (a_stroke_id >> 31 != 0 && (vertex_index >= u_circle_points)) {
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 vec3 v_color;
layout(location = 0) out vec4 FragColor;
void main() {
if (u_debug_mode == 0) {
float alpha = 0.5;
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;
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);
}
screen02.y = 2.0 - screen02.y;
vec2 screen11 = screen02 - 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;
uniform int u_solid;
uniform vec4 u_color;
layout(location = 0) out vec4 FragColor;
void main() {
if (u_solid == 0) {
FragColor = texture(u_texture, v_texcoord);
} else {
FragColor = u_color;
}
}
`;
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);
}
`;
//
const iquad_vs_src = `#version 300 es
in vec2 a_topleft; // per-instance
in vec2 a_bottomright; // per-instance
uniform vec2 u_scale;
uniform vec2 u_res;
uniform vec2 u_translation;
out vec3 v_color;
void main() {
vec2 pos;
int vertex_index = gl_VertexID % 6;
if (vertex_index == 0) {
// top left
pos = a_topleft;
} else if (vertex_index == 1 || vertex_index == 5) {
// top right
pos = vec2(a_bottomright.x, a_topleft.y);
} else if (vertex_index == 2 || vertex_index == 4) {
// bottom left
pos = vec2(a_topleft.x, a_bottomright.y);
} else {
// bottom right
pos = a_bottomright;
}
v_color = vec3(
float(int(a_topleft.x) * 908125 % 255) / 255.0,
float(int(a_topleft.y) * 257722 % 255) / 255.0,
float(int(a_bottomright.y) * 826586 % 255) / 255.0
);
vec2 screen01 = (pos * u_scale + u_translation) / u_res;
vec2 screen02 = screen01 * 2.0;
screen02.y = 2.0 - screen02.y;
vec2 screen11 = screen02 - 1.0;
gl_Position = vec4(screen11, 0.0, 1.0);
}
`;
const iquad_fs_src = `#version 300 es
precision highp float;
layout(location = 0) out vec4 FragColor;
in vec3 v_color;
void main() {
FragColor = vec4(v_color, 0.5);
}
`;
function init_webgl(state, context) {
context.canvas = document.querySelector('#c');
context.gl = context.canvas.getContext('webgl2', {
'preserveDrawingBuffer': true,
'desynchronized': true,
'antialias': true,
});
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.NOTEQUAL);
*/
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 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);
const iquad_vs = create_shader(gl, gl.VERTEX_SHADER, iquad_vs_src);
const iquad_fs = create_shader(gl, gl.FRAGMENT_SHADER, iquad_fs_src);
context.programs = {
'image': create_program(gl, quad_vs, quad_fs),
'main': create_program(gl, sdf_vs, sdf_fs),
'dots': create_program(gl, dots_vs, dots_fs),
'grid': create_program(gl, grid_vs, dots_fs),
'iquad': create_program(gl, iquad_vs, iquad_fs),
};
context.buffers = {
'b_images': gl.createBuffer(),
'b_strokes_static': gl.createBuffer(),
'b_strokes_dynamic': gl.createBuffer(),
'b_instance_dot': gl.createBuffer(),
'b_instance_grid': gl.createBuffer(),
'b_dot': gl.createBuffer(),
'b_hud': gl.createBuffer(),
'b_iquads': gl.createBuffer(),
};
context.textures = {
'stroke_data': gl.createTexture(),
'dynamic_stroke_data': gl.createTexture(),
'ui': 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, new Uint16Array(config.stroke_texture_size * config.stroke_texture_size * 4)); // fill the whole texture once with zeroes to kill a warning about a partial upload
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, new Uint16Array(config.dynamic_stroke_texture_size * config.dynamic_stroke_texture_size * 4)); // fill the whole texture once with zeroes to kill a warning about a partial upload
gl.bindTexture(gl.TEXTURE_2D, context.textures['ui']);
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.ui_texture_size, config.ui_texture_size, 0, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, new Uint16Array(config.ui_texture_size * config.ui_texture_size * 4)); // fill the whole texture once with zeroes to kill a warning about a partial upload
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)) {
// src: tiny-sdf
// https://github.com/mapbox/tiny-sdf
const wrapper = {program};
const num_attrs = gl.getProgramParameter(program, gl.ACTIVE_ATTRIBUTES);
const num_uniforms = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
wrapper.locations = {};
for (let i = 0; i < num_attrs; i++) {
const attribute = gl.getActiveAttrib(program, i);
wrapper.locations[attribute.name] = gl.getAttribLocation(program, attribute.name);
}
for (let i = 0; i < num_uniforms; i++) {
const uniform = gl.getActiveUniform(program, i);
wrapper.locations[uniform.name] = gl.getUniformLocation(program, uniform.name);
}
return wrapper;
}
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);
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);
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.

103
server/deserializer.js

@ -26,40 +26,117 @@ export function u32(d) { @@ -26,40 +26,117 @@ 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:
case EVENT.LIFT: {
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: {
event.image_id = u32(d);
event.x = f32(d);
event.y = f32(d);
event.width = u32(d);
event.height = u32(d);
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;
}
case EVENT.IMAGE_SCALE: {
event.image_id = u32(d);
event.corner = u32(d);
event.x = f32(d);
event.y = f32(d);
break;
}
@ -74,11 +151,11 @@ export function event(d) { @@ -74,11 +151,11 @@ export function event(d) {
}
default: {
console.error('fuck');
console.error('fuck', event.type);
console.trace();
process.exit(1);
}
}
return event;
}
}

18
server/enums.js

@ -6,11 +6,23 @@ export const SESSION = Object.freeze({ @@ -6,11 +6,23 @@ export const SESSION = Object.freeze({
export const EVENT = Object.freeze({
PREDRAW: 10,
SET_COLOR: 11,
SET_WIDTH: 12,
CLEAR: 13,
MOVE_CURSOR: 14,
LIFT: 15,
LEAVE: 16,
MOVE_CANVAS: 17,
USER_JOINED: 18,
ZOOM_CANVAS: 19,
STROKE: 20,
UNDO: 30,
REDO: 31,
IMAGE: 40,
IMAGE_MOVE: 41,
IMAGE_SCALE: 42,
ERASER: 50,
});
@ -21,9 +33,5 @@ export const MESSAGE = Object.freeze({ @@ -21,9 +33,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;
}

85
server/milton.js

@ -0,0 +1,85 @@ @@ -0,0 +1,85 @@
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,
'$corner': 0,
'$x': 0,
'$y': 0,
'$width': 0,
'$height': 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}`);
}
async function set_dimentions_to_images(fullpath) {
const images = [
//
];
storage.startup();
for (const image of images) {
storage.db.run(`UPDATE events SET width = ${image.w}, height = ${image.h} WHERE image_id = ${image.t};`);
}
}
set_dimentions_to_images();

163
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,33 +61,116 @@ async function recv_syn(d, session) { @@ -78,33 +61,116 @@ 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,
'$corner': 0,
'$x': 0,
'$y': 0,
'$width': 0,
'$height': 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.IMAGE_SCALE:
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,
'$corner': event.corner || 0,
'$x': event.x || 0,
'$y': event.y || 0,
'$width': event.width || 0,
'$height': event.height || 0,
});
break;
}
send.send_fire(other.ws, session.user_id, event);
default: {
console.error('fuck');
console.trace();
process.exit(1);
}
}
}
export async function handle_message(ws, d) {
export function handle_message(ws, d) {
if (!(ws.data.session_id in sessions)) {
return;
}
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 +188,11 @@ export async function handle_message(ws, d) { @@ -122,6 +188,11 @@ export async function handle_message(ws, d) {
break;
}
case MESSAGE.FOLLOW: {
recv_follow(d, session);
break;
}
default: {
console.error('fuck');
console.trace();

168
server/send.js

@ -7,23 +7,61 @@ import { MESSAGE, SESSION, EVENT } from './enums'; @@ -7,23 +7,61 @@ 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:
case EVENT.LIFT: {
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: {
size += 4 + 4 + 4 + 4 + 4; // file_id + x + y + width + height
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;
}
case EVENT.IMAGE_SCALE: {
size += 4 + 4 + 4 + 4; // file_id + corner + x + y
break;
}
@ -38,6 +76,7 @@ function event_size(event) { @@ -38,6 +76,7 @@ function event_size(event) {
}
default: {
console.error(event.desk_id);
console.error('fuck');
console.trace();
process.exit(1);
@ -48,30 +87,28 @@ function event_size(event) { @@ -48,30 +87,28 @@ 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;
return session;
}
export async function send_init(ws) {
export function send_init(ws) {
if (!ws) {
return;
}
@ -81,11 +118,12 @@ export async function send_init(ws) { @@ -81,11 +118,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 +136,17 @@ export async function send_init(ws) { @@ -98,7 +136,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,21 +154,38 @@ export async function send_init(ws) { @@ -106,21 +154,38 @@ 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);
}
await ws.send(s.buffer);
ws.send(s.buffer);
}
export function send_ack(ws, lsn) {
@ -128,32 +193,56 @@ export function send_ack(ws, lsn) { @@ -128,32 +193,56 @@ 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);
}
async function sync_session(session_id) {
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);
}
}
function sync_session(session_id) {
if (!(session_id in sessions)) {
return;
}
@ -169,11 +258,11 @@ async function sync_session(session_id) { @@ -169,11 +258,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 +273,7 @@ async function sync_session(session_id) { @@ -184,7 +273,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 +281,15 @@ async function sync_session(session_id) { @@ -192,12 +281,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`);
await session.ws.send(s.buffer);
session.sync_timer = setTimeout(() => sync_session(session_id), config.SYNC_TIMEOUT);
if (config.DEBUG_PRINT) console.log(`syn ${desk.sn} out`);
session.ws.send(s.buffer);
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 +302,4 @@ export function sync_desk(desk_id) { @@ -210,4 +302,4 @@ export function sync_desk(desk_id) {
sync_session(sid);
}
}
}
}

87
server/serializer.js

@ -21,42 +21,115 @@ export function u16(s, value) { @@ -21,42 +21,115 @@ 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:
case EVENT.LIFT: {
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: {
u32(s, event.image_id);
f32(s, event.x);
f32(s, event.y);
u32(s, event.width);
u32(s, event.height);
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;
}
case EVENT.IMAGE_SCALE: {
u32(s, event.image_id);
u32(s, event.corner);
f32(s, event.x);
f32(s, event.y);
break;
}

18
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,16 +51,20 @@ export function startup() { @@ -49,16 +51,20 @@ 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) {
const dataview = new DataView(u8array.buffer);
const d = des.create(dataview);
await recv.handle_message(ws, d);
recv.handle_message(ws, d);
},
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}`)
}
}

182
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,176 +36,108 @@ export function startup() { @@ -54,176 +36,108 @@ 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,
corner INTEGER,
x INTEGER,
y INTEGER,
width INTEGER,
height INTEGER,
FOREIGN KEY (desk_id)
REFERENCES desks (id)
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, corner, x, y, width, height) VALUES ($type, $desk_id, $session_id, $stroke_id, $image_id, $corner, $x, $y, $width, $height) 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