-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathapi.py
More file actions
1521 lines (1291 loc) · 61.7 KB
/
Copy pathapi.py
File metadata and controls
1521 lines (1291 loc) · 61.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""REST API routes (Flask Blueprint) — File management + Analysis"""
from flask import Blueprint, jsonify, request, send_file, current_app
from werkzeug.exceptions import RequestEntityTooLarge
import numpy as np
import json
import os
import re
import struct
import io
import glob
import shutil
import tempfile
import threading
import time
import uuid
import urllib.parse
from datetime import datetime
from pointcloud_io import read_pointcloud, arrays_to_binary, gaussians_to_binary, write_las, SUPPORTED_EXTENSIONS
import copc_io
api_bp = Blueprint('api', __name__)
# ── Per-map file locks for concurrent operations ──
_map_locks_guard = threading.Lock()
_map_locks: dict[str, threading.Lock] = {}
def _get_map_lock(map_name: str) -> threading.Lock:
"""Return a per-map lock, creating one if needed."""
with _map_locks_guard:
if map_name not in _map_locks:
_map_locks[map_name] = threading.Lock()
return _map_locks[map_name]
# ── JSON Content-Type validation ───────────────────
# JSON bodies are small control payloads; cap them well below MAX_CONTENT_LENGTH
# (sized for file uploads) so a hostile 5GB JSON body can't exhaust memory.
_MAX_JSON_BYTES = 64 * 1024 * 1024
def _require_json():
"""Return a 415/413 error response if the request isn't acceptable JSON, else None."""
ct = request.content_type or ''
if not ct.startswith('application/json'):
return jsonify({'error': 'Content-Type must be application/json'}), 415
if (request.content_length or 0) > _MAX_JSON_BYTES:
return jsonify({'error': 'JSON body too large'}), 413
return None
# ── Correlation-ID helper ──────────────────────────
def _error_response(e: Exception, context: str = ''):
"""Log the real exception server-side and return a generic error with correlation ID."""
cid = uuid.uuid4().hex[:8]
logger = current_app.config.get('LOGGER')
if logger:
logger.error(f"[{cid}] {context} {type(e).__name__}: {e}")
# Oversized drag&drop upload: don't bury it in a generic 500 — tell the user the
# cap and point them at the no-limit path (drop the file in the maps directory
# and load it from the list instead of uploading it through the browser).
if isinstance(e, RequestEntityTooLarge):
limit_mb = current_app.config.get('MAX_CONTENT_LENGTH', 0) / (1024 * 1024)
return jsonify({'error':
f'File exceeds the {limit_mb:.0f} MB upload limit. Place it in the maps '
f'directory and load it from the list — direct load has no size limit.',
'cid': cid}), 413
return jsonify({'error': 'Internal server error', 'cid': cid}), 500
# ── Path Traversal prevention ─────────────────────
_MAP_NAME_RE = re.compile(r'^[\w.\- ]+$')
def _safe_path(base_dir, name):
"""Verify name is a single safe path component strictly inside base_dir.
Returns None on violation. base_dir itself is never a valid result —
otherwise names like '.' would let callers rmtree/rename the whole tree.
"""
if not name or name in ('.', '..') or not _MAP_NAME_RE.fullmatch(name):
return None
resolved = os.path.realpath(os.path.join(base_dir, name))
base = os.path.realpath(base_dir)
if os.path.dirname(resolved) != base or resolved == base:
return None
return resolved
# ══════════════════════════════════════════════════════
# Maps API
# ══════════════════════════════════════════════════════
@api_bp.route('/api/maps')
def list_maps():
maps_dir = current_app.config['MAPS_DIR']
maps = []
if os.path.isdir(maps_dir):
for d in sorted(os.listdir(maps_dir)):
p = os.path.join(maps_dir, d)
if os.path.isdir(p):
# Include LAZ/COPC alongside LAS so COPC maps appear in the list.
las_files = sorted(glob.glob(os.path.join(p, '*.las'))
+ glob.glob(os.path.join(p, '*.laz')))
las_info = []
for lf in las_files:
info = {'name': os.path.basename(lf)}
try:
info['size'] = os.path.getsize(lf)
if lf.lower().endswith('.las'):
# Fast path: LAS 1.2 legacy point count at byte 107.
with open(lf, 'rb') as fh:
fh.seek(107)
info['num_points'] = struct.unpack('<I', fh.read(4))[0]
if info['num_points'] == 0:
# LAS 1.4 (PDRF>=6) leaves the legacy count 0 —
# fall back to the real header via laspy.
import laspy
with laspy.open(lf) as fh:
info['num_points'] = int(fh.header.point_count)
else:
# LAZ/COPC (often LAS 1.4): read header via laspy.
import laspy
with laspy.open(lf) as fh:
info['num_points'] = int(fh.header.point_count)
except Exception:
info.setdefault('size', 0)
info['num_points'] = 0
las_info.append(info)
try:
created = os.path.getctime(p)
except Exception:
created = 0
maps.append({
'name': d,
'path': p,
'las_files': [os.path.basename(f) for f in las_files],
'las_info': las_info,
'created': created,
})
return jsonify(maps)
@api_bp.route('/api/maps/<name>', methods=['DELETE'])
def delete_map(name):
maps_dir = current_app.config['MAPS_DIR']
safe = _safe_path(maps_dir, name)
if not safe:
return jsonify({'error': 'Invalid name'}), 400
if not os.path.isdir(safe):
return jsonify({'error': 'Not found'}), 404
lock = _get_map_lock(name)
with lock:
try:
shutil.rmtree(safe)
except Exception as e:
return _error_response(e, 'delete_map')
# Map is gone — drop its lock so _map_locks doesn't grow unboundedly.
with _map_locks_guard:
_map_locks.pop(name, None)
return jsonify({'status': 'ok'})
@api_bp.route('/api/maps/<name>/rename', methods=['POST'])
def rename_map(name):
err = _require_json()
if err:
return err
maps_dir = current_app.config['MAPS_DIR']
new_name = request.json.get('new_name', '').strip()
if not new_name:
return jsonify({'error': 'New name required'}), 400
old_safe = _safe_path(maps_dir, name)
new_safe = _safe_path(maps_dir, new_name)
if not old_safe or not new_safe:
return jsonify({'error': 'Invalid name'}), 400
if not os.path.isdir(old_safe):
return jsonify({'error': 'Not found'}), 404
if os.path.exists(new_safe):
return jsonify({'error': 'Name already exists'}), 409
names = sorted([name, new_name])
lock_a = _get_map_lock(names[0])
lock_b = _get_map_lock(names[1])
with lock_a:
with lock_b:
try:
os.rename(old_safe, new_safe)
return jsonify({'status': 'ok'})
except Exception as e:
return _error_response(e, 'rename_map')
# ══════════════════════════════════════════════════════
# Point Cloud Loading
# ══════════════════════════════════════════════════════
def _upload_tmp_dir():
"""Temp dir for drag&drop uploads — outside the maps tree so dropped files
never accumulate as multi-GB copies there. Lives under DATA_DIR (mode 0700),
not a predictable world-writable /tmp name another local user could
pre-create and control."""
import config
d = os.path.join(config.DATA_DIR, 'uploads')
os.makedirs(d, mode=0o700, exist_ok=True)
return d
@api_bp.route('/api/load_pointcloud', methods=['POST'])
def load_pointcloud():
tmp_path = None
is_upload = False
try:
path = None
saved_path = None
# preview_points > 0: caller wants a bounded raw point payload even for
# COPC files (compare overlay) instead of streaming meta.
preview_pts = 0
if request.is_json:
try:
preview_pts = min(int(request.json.get('preview_points') or 0),
10_000_000)
except (TypeError, ValueError):
preview_pts = 0
if request.is_json and 'path' in request.json:
path = request.json['path']
saved_path = path
maps_dir = os.path.realpath(current_app.config['MAPS_DIR'])
if not os.path.realpath(path).startswith(maps_dir + os.sep):
return jsonify({'error': 'Access denied'}), 403
elif 'file' in request.files:
f = request.files['file']
orig_name = f.filename or 'upload.las'
suffix = os.path.splitext(orig_name)[1].lower() or '.las'
# Drag&drop only gives us the bytes, so dropped files are uploaded to
# a private temp dir (NOT under the maps tree) and removed once the
# COPC exists — no multi-GB copy accumulates. See _upload_tmp_dir().
upload_dir = _upload_tmp_dir()
# Fail clearly (not a generic 500 mid-write) if the disk can't hold it.
need = request.content_length or 0
free = shutil.disk_usage(upload_dir).free
if need and free < need + (1 << 30): # +1GB headroom
return jsonify({'error':
f'Disk full: need ~{need / 1e9:.1f}GB but only '
f'{free / 1e9:.1f}GB free. Free up space, or place the file '
f'in the maps directory and load it from the list (no upload).'
}), 507
safe_name = orig_name.replace(os.sep, '_').replace('/', '_')
saved_path = os.path.join(upload_dir, f'{uuid.uuid4().hex[:8]}_{safe_name}')
f.save(saved_path)
path = saved_path
is_upload = True
if not path or not os.path.isfile(path):
return jsonify({'error': 'File not found'}), 404
ext = os.path.splitext(path)[1].lower()
if ext not in SUPPORTED_EXTENSIONS:
return jsonify({'error': f'Unsupported format: {ext}'}), 400
# is_copc opens the file via CopcReader (~0.3s) — call it once and reuse.
file_is_copc = ext in ('.las', '.laz') and copc_io.is_copc(path)
# Large non-COPC LAS/LAZ: convert to COPC in the background and return a
# job id immediately so the client can show conversion progress. Small
# files fall through to the legacy whole-cloud path.
if ext in ('.las', '.laz') and not file_is_copc:
import config
threshold = getattr(config, 'COPC_STREAM_MIN_POINTS', 2_000_000)
if _point_count(path) >= threshold:
job_id = _start_convert_job(path, current_app.config.get('LOGGER'),
cleanup_src=is_upload)
resp = jsonify({'mode': 'converting', 'job': job_id})
resp.status_code = 202
if saved_path:
resp.headers['X-Saved-Path'] = urllib.parse.quote(saved_path)
return resp
# COPC: stream via octree LOD (JSON meta) instead of a whole-cloud binary.
# The frontend distinguishes by Content-Type and switches into copc mode.
if file_is_copc:
if preview_pts > 0:
binary = copc_io.copc_preview_binary(path, max_points=preview_pts)
resp = send_file(io.BytesIO(binary),
mimetype='application/octet-stream')
else:
resp = jsonify(copc_io.copc_meta(path))
if saved_path:
resp.headers['X-Saved-Path'] = urllib.parse.quote(saved_path)
return resp
# Small upload fully read into memory below — drop the temp copy after.
if is_upload:
tmp_path = path
d = read_pointcloud(path)
if d.get('type') == 'gaussian':
binary = gaussians_to_binary(
d['x'], d['y'], d['z'], d['r'], d['g'], d['b'],
d['scale_x'], d['scale_y'], d['scale_z'],
d['rot_0'], d['rot_1'], d['rot_2'], d['rot_3'],
d['opacity'], d['n'])
else:
binary = arrays_to_binary(d['x'], d['y'], d['z'], d['intensity'],
d['r'], d['g'], d['b'], d['n'],
classification=d.get('classification'))
resp = send_file(io.BytesIO(binary), mimetype='application/octet-stream')
if saved_path:
resp.headers['X-Saved-Path'] = urllib.parse.quote(saved_path)
return resp
except Exception as e:
return _error_response(e, 'load_pointcloud')
finally:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
# Backward-compatible alias
@api_bp.route('/api/load_las', methods=['POST'])
def load_las():
return load_pointcloud()
# ══════════════════════════════════════════════════════
# COPC octree LOD streaming
# ══════════════════════════════════════════════════════
# ── Background COPC conversion jobs ──
_convert_jobs = {} # job_id -> {status, percent, copc_path, error}
_convert_src_jobs = {} # src realpath -> job_id of a RUNNING conversion
_convert_jobs_lock = threading.Lock()
_CONVERT_JOB_TTL = 3600 # finished jobs pruned an hour after completion
def _prune_convert_jobs_locked():
"""Drop finished jobs whose result nobody can still care about (TTL passed).
Caller must hold _convert_jobs_lock."""
now = time.monotonic()
for jid in [jid for jid, j in _convert_jobs.items()
if j.get('status') in ('done', 'error')
and now - j.get('finished_at', now) > _CONVERT_JOB_TTL]:
del _convert_jobs[jid]
def _point_count(path):
try:
import laspy
with laspy.open(path) as f:
return int(f.header.point_count)
except Exception:
return 0
def _start_convert_job(src_path, logger=None, cleanup_src=False):
"""Kick off COPC conversion in a background thread; return its job id.
Progress is reported during the copclib build; the client polls
/api/copc/convert_status. When *cleanup_src* is set (drag&drop upload), the
temp original is removed once the COPC exists so no copy lingers."""
src_key = os.path.realpath(src_path)
job_id = uuid.uuid4().hex[:12]
with _convert_jobs_lock:
_prune_convert_jobs_locked()
# Same source already converting (double-click, page reload): join that
# job instead of racing a second writer onto the same output file.
existing = _convert_src_jobs.get(src_key)
if existing and _convert_jobs.get(existing, {}).get('status') == 'running':
return existing
_convert_jobs[job_id] = {'status': 'running', 'percent': 0, 'phase': 'reading'}
_convert_src_jobs[src_key] = job_id
def run():
try:
def prog(done, total, phase='writing'):
pct = int(done / total * 100) if total else 0
with _convert_jobs_lock:
if job_id in _convert_jobs:
_convert_jobs[job_id]['percent'] = pct
_convert_jobs[job_id]['phase'] = phase
copc_path = copc_io.ensure_copc(src_path, progress=prog)
# Drop the uploaded temp original once the COPC (a separate file) is
# built — the COPC is what gets streamed from here on.
if cleanup_src and copc_path != src_path:
try:
os.remove(src_path)
except OSError:
pass
with _convert_jobs_lock:
_convert_jobs[job_id].update(
status='done', percent=100, copc_path=copc_path,
finished_at=time.monotonic())
except Exception as e:
if logger:
logger.warning(f"COPC convert job {job_id} failed: {e}")
with _convert_jobs_lock:
_convert_jobs[job_id].update(status='error', error=str(e),
finished_at=time.monotonic())
finally:
with _convert_jobs_lock:
if _convert_src_jobs.get(src_key) == job_id:
del _convert_src_jobs[src_key]
threading.Thread(target=run, daemon=True).start()
return job_id
def _copc_guard(path):
"""Validate *path* is inside an allowed root and is a COPC file. Returns an
error (response, status) tuple on failure, else None.
Allowed roots: MAPS_DIR (files loaded from the list) and the upload temp dir
(drag&drop uploads are converted to COPC there, then streamed from it)."""
if not path:
return jsonify({'error': 'path required'}), 400
real = os.path.realpath(path)
roots = [os.path.realpath(current_app.config['MAPS_DIR']),
os.path.realpath(_upload_tmp_dir())]
if not any(real.startswith(r + os.sep) for r in roots):
return jsonify({'error': 'Access denied'}), 403
if not os.path.isfile(path):
return jsonify({'error': 'File not found'}), 404
return None
@api_bp.route('/api/copc/meta', methods=['GET'])
def copc_meta():
path = request.args.get('path', '')
err = _copc_guard(path)
if err:
return err
try:
return jsonify(copc_io.copc_meta(path))
except Exception as e:
return _error_response(e, 'copc_meta')
@api_bp.route('/api/copc/convert_status', methods=['GET'])
def copc_convert_status():
job = request.args.get('job', '')
with _convert_jobs_lock:
j = dict(_convert_jobs.get(job, {}))
if not j:
return jsonify({'error': 'unknown job'}), 404
if j['status'] == 'done':
try:
return jsonify({'status': 'done', 'percent': 100,
'meta': copc_io.copc_meta(j['copc_path']),
'path': j['copc_path']})
except Exception as e:
return _error_response(e, 'copc_convert_status')
if j['status'] == 'error':
return jsonify({'status': 'error', 'error': j.get('error', 'conversion failed')})
return jsonify({'status': 'running', 'percent': j.get('percent', 0),
'phase': j.get('phase', 'writing')})
@api_bp.route('/api/copc/hierarchy', methods=['GET'])
def copc_hierarchy():
path = request.args.get('path', '')
err = _copc_guard(path)
if err:
return err
try:
max_depth = int(request.args.get('max_depth', 32))
data = copc_io.copc_hierarchy(path, max_depth=max_depth)
# The node list is large (tens of MB for a big map). Serialize compactly
# and gzip it — it's highly repetitive (coordinates), so it shrinks ~6×,
# cutting transfer time, especially over a remote connection.
payload = json.dumps(data, separators=(',', ':')).encode('utf-8')
headers = {}
if 'gzip' in (request.headers.get('Accept-Encoding') or ''):
import gzip
payload = gzip.compress(payload, 5)
headers['Content-Encoding'] = 'gzip'
return current_app.response_class(
payload, mimetype='application/json', headers=headers)
except Exception as e:
return _error_response(e, 'copc_hierarchy')
@api_bp.route('/api/copc/nodes', methods=['POST'])
def copc_nodes():
err = _require_json()
if err:
return err
path = request.json.get('path', '')
guard = _copc_guard(path)
if guard:
return guard
try:
keys = request.json.get('keys', []) or []
# Multi-blob: one round-trip carries many nodes, each as its own payload.
binary = copc_io.copc_nodes_multiblob(path, keys)
return send_file(io.BytesIO(binary), mimetype='application/octet-stream')
except Exception as e:
return _error_response(e, 'copc_nodes')
# ══════════════════════════════════════════════════════
# Merge & Save (Map A + transformed Map B)
# ══════════════════════════════════════════════════════
def _make_save_dir(maps_dir, tag):
"""Create a unique '<timestamp>_<tag>' directory under maps_dir.
Two requests landing in the same second would otherwise write into the
same directory — create with exist_ok=False and retry once with a short
random suffix on collision. Returns (save_dir, name)."""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
name = f'{timestamp}_{tag}'
save_dir = os.path.join(maps_dir, name)
try:
os.makedirs(save_dir, exist_ok=False)
except FileExistsError:
name = f'{timestamp}_{tag}_{uuid.uuid4().hex[:6]}'
save_dir = os.path.join(maps_dir, name)
os.makedirs(save_dir, exist_ok=False)
return save_dir, name
def _euler_xyz_matrix(rx_deg, ry_deg, rz_deg):
"""R = Rx·Ry·Rz — the composition three.js uses for Euler order 'XYZ',
which is what the viewer applies via object.rotation. Column-vector
convention: P' = R @ P."""
rx_r, ry_r, rz_r = np.radians([float(rx_deg), float(ry_deg), float(rz_deg)])
cx, sx = np.cos(rx_r), np.sin(rx_r)
cy, sy = np.cos(ry_r), np.sin(ry_r)
cz, sz = np.cos(rz_r), np.sin(rz_r)
Rx = np.array([[1, 0, 0], [0, cx, -sx], [0, sx, cx]])
Ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
Rz = np.array([[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]])
return Rx @ Ry @ Rz
@api_bp.route('/api/save_compare_b', methods=['POST'])
def save_compare_b():
err = _require_json()
if err:
return err
try:
data = request.json
path_a = data.get('path_a', '')
path_b = data.get('path', '') or data.get('path_b', '')
ox, oy, oz = data.get('ox', 0), data.get('oy', 0), data.get('oz', 0)
rx, ry, rz = data.get('rx', 0), data.get('ry', 0), data.get('rz', 0)
# Pivot = B's centering offset in the viewer (three.js rotates the
# object about its local origin, which is exactly this point). Without
# it we fall back to B's bbox midpoint — close, but only the client
# knows the exact offset its geometry was centered with.
pivot = data.get('pivot')
maps_dir = os.path.realpath(current_app.config['MAPS_DIR'])
for p in [path_a, path_b]:
if not p:
continue
if not os.path.realpath(p).startswith(maps_dir + os.sep):
return jsonify({'error': 'Access denied'}), 403
if not os.path.isfile(p):
return jsonify({'error': f'File not found: {p}'}), 404
log = current_app.config.get('LOGGER')
# ── Read Map B and apply transform ──
d_b = read_pointcloud(path_b)
if d_b.get('type') == 'gaussian':
return jsonify({'error': 'Gaussian splat files (.splat / 3DGS .ply) '
'cannot be merged — a point cloud is required'}), 400
bx, by, bz = d_b['x'].astype(np.float64), d_b['y'].astype(np.float64), d_b['z'].astype(np.float64)
if rx != 0 or ry != 0 or rz != 0:
# Match the viewer exactly: three.js Euler 'XYZ' (R = Rx·Ry·Rz)
# about pivot c → P' = R·(P − c) + c + t
R = _euler_xyz_matrix(rx, ry, rz)
if pivot is not None:
px, py, pz = (float(pivot[0]), float(pivot[1]), float(pivot[2]))
elif len(bx):
px = (float(bx.min()) + float(bx.max())) / 2.0
py = (float(by.min()) + float(by.max())) / 2.0
pz = (float(bz.min()) + float(bz.max())) / 2.0
else:
px = py = pz = 0.0
pts = R @ np.vstack([bx - px, by - py, bz - pz])
bx, by, bz = pts[0] + px, pts[1] + py, pts[2] + pz
bx += ox; by += oy; bz += oz
b_intensity = d_b['intensity']
b_r, b_g, b_b = d_b['r'], d_b['g'], d_b['b']
# ── Read Map A ──
if path_a:
d_a = read_pointcloud(path_a)
if d_a.get('type') == 'gaussian':
return jsonify({'error': 'Gaussian splat files (.splat / 3DGS .ply) '
'cannot be merged — a point cloud is required'}), 400
ax, ay, az = d_a['x'].astype(np.float64), d_a['y'].astype(np.float64), d_a['z'].astype(np.float64)
a_intensity = d_a['intensity']
a_r, a_g, a_b = d_a['r'], d_a['g'], d_a['b']
else:
d_a = None
ax = ay = az = np.array([], dtype=np.float64)
a_intensity = a_r = a_g = a_b = np.array([], dtype=np.float32)
# ── Merge A + B ──
mx = np.concatenate([ax, bx])
my = np.concatenate([ay, by])
mz = np.concatenate([az, bz])
m_intensity = np.concatenate([a_intensity, b_intensity])
m_r = np.concatenate([a_r, b_r])
m_g = np.concatenate([a_g, b_g])
m_b = np.concatenate([a_b, b_b])
# Classification survives the merge only if every side has it (missing
# side of an A+B merge gets 0 = "never classified").
b_cls = d_b.get('classification')
a_cls = (d_a.get('classification') if d_a is not None
else np.array([], dtype=np.float32))
if b_cls is not None and a_cls is not None:
m_cls = np.concatenate([a_cls, b_cls])
elif b_cls is not None or (a_cls is not None and len(a_cls)):
m_cls = np.concatenate([
a_cls if a_cls is not None else np.zeros(len(ax), np.float32),
b_cls if b_cls is not None else np.zeros(len(bx), np.float32),
])
else:
m_cls = None
save_dir, save_name = _make_save_dir(maps_dir, 'merged')
save_path = os.path.join(save_dir, 'map.las')
n_total = len(mx)
write_las(save_path, mx, my, mz, intensity=m_intensity,
r=m_r, g=m_g, b=m_b, classification=m_cls)
if log:
log.info(f"[Merge] A={len(ax)} + B={len(bx)} = {n_total} pts -> {save_path}")
return jsonify({
'path': save_path,
'points': n_total,
'points_a': len(ax),
'points_b': len(bx),
'name': save_name,
})
except Exception as e:
return _error_response(e, 'save_compare_b')
# ══════════════════════════════════════════════════════
# Polygon Selection → LAS
# ══════════════════════════════════════════════════════
MAX_SELECTION_OPS = 64
MAX_SELECTION_POLY_VERTS = 4096
def _points_in_poly(px, py, poly):
"""Vectorised even-odd ray cast — the numpy twin of isPointInPoly2D() in
viewer-tools.js. NaN screen coords (points on the camera plane) compare
false everywhere and land outside, which is what the JS does too."""
inside = np.zeros(px.shape, dtype=bool)
n = len(poly)
j = n - 1
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
crosses = (yi > py) != (yj > py)
with np.errstate(divide='ignore', invalid='ignore'):
# yj == yi makes this inf/nan, but crosses is False there — masked out.
x_at_py = (xj - xi) * (py - yi) / (yj - yi) + xi
inside ^= crosses & (px < x_at_py)
j = i
return inside
def _selection_survivors(xc, yc, zc, op):
"""Boolean mask of the points a single lasso op keeps.
Mirrors filterPoints() in parse-worker.js exactly, including its treatment
of points outside the depth range: they survive a delete and are dropped by
a keep, since the lasso says nothing about what the camera cannot see."""
mvp = np.asarray(op['mvp'], dtype=np.float64) # column-major, 16 elements
poly = op['poly']
keep = bool(op.get('keep'))
cx = mvp[0] * xc + mvp[4] * yc + mvp[8] * zc + mvp[12]
cy = mvp[1] * xc + mvp[5] * yc + mvp[9] * zc + mvp[13]
cz = mvp[2] * xc + mvp[6] * yc + mvp[10] * zc + mvp[14]
cw = mvp[3] * xc + mvp[7] * yc + mvp[11] * zc + mvp[15]
with np.errstate(divide='ignore', invalid='ignore'):
ndc_x, ndc_y, ndc_z = cx / cw, cy / cw, cz / cw
offscreen = ~np.isfinite(ndc_z) | (ndc_z < -1) | (ndc_z > 1)
sx = (ndc_x * 0.5 + 0.5) * float(op['w'])
sy = (-ndc_y * 0.5 + 0.5) * float(op['h'])
inside = _points_in_poly(sx, sy, poly)
return np.where(offscreen, not keep, inside == keep)
def _write_las_subset(las, mask, out_path):
"""Write the masked points of an already-read laspy LasData to out_path.
Goes through laspy rather than write_las() so the export is a faithful
subset: original scales/offsets, point format, CRS and extra dimensions all
survive, and intensity/RGB keep their raw values instead of round-tripping
through read_pointcloud's 0-1 normalisation. COPC VLRs are dropped — the
output has no octree, and leaving them would make the file claim otherwise.
"""
import laspy
header = laspy.LasHeader(version=las.header.version,
point_format=las.header.point_format)
header.scales = las.header.scales
header.offsets = las.header.offsets
for vlr in las.header.vlrs:
if vlr.user_id.strip('\x00').lower() != 'copc':
header.vlrs.append(vlr)
out = laspy.LasData(header)
out.points = las.points[mask]
out.write(out_path)
def _validate_selection_ops(raw_ops):
"""Return (ops, error). Each op must be a full screen-space lasso record."""
if not isinstance(raw_ops, list) or not raw_ops:
return None, 'no polygon selection to save'
if len(raw_ops) > MAX_SELECTION_OPS:
return None, f'too many selection ops (max {MAX_SELECTION_OPS})'
ops = []
for op in raw_ops:
if not isinstance(op, dict):
return None, 'malformed selection op'
mvp = op.get('mvp')
poly = op.get('poly')
if not isinstance(mvp, list) or len(mvp) != 16:
return None, 'selection op needs a 16-element mvp matrix'
if not isinstance(poly, list) or len(poly) < 3:
return None, 'selection polygon needs at least 3 vertices'
if len(poly) > MAX_SELECTION_POLY_VERTS:
return None, f'selection polygon too complex (max {MAX_SELECTION_POLY_VERTS} vertices)'
try:
mvp_f = [float(v) for v in mvp]
poly_f = [(float(p[0]), float(p[1])) for p in poly]
w, h = float(op.get('w', 0)), float(op.get('h', 0))
except (TypeError, ValueError, IndexError):
return None, 'selection op contains non-numeric values'
if not (np.isfinite(mvp_f).all() and np.isfinite(poly_f).all()):
return None, 'selection op contains non-finite values'
if w <= 0 or h <= 0:
return None, 'selection op needs a positive viewport size'
ops.append({'mvp': mvp_f, 'poly': poly_f, 'w': w, 'h': h,
'keep': bool(op.get('keep'))})
return ops, None
@api_bp.route('/api/save_selection', methods=['POST'])
def save_selection():
"""Re-apply the viewer's polygon edits to the *full-resolution* source file
and stream the survivors back as a LAS download.
The viewer only ever holds a subset — a downsample for plain files, whatever
LOD is resident for COPC — so exporting its geometry would silently thin the
result. Each lasso is stored as a screen-space op (the same {mvp,w,h,poly,
keep} record COPC replays onto freshly streamed chunks), which replays just
as well against every point in the file."""
err = _require_json()
if err:
return err
tmp_path = None
try:
data = request.json
path = data.get('path', '')
if not path:
return jsonify({'error': 'no source file for this map — '
'reload it from the map list and try again'}), 400
# Same roots as COPC streaming: the maps library plus the upload temp dir.
guard = _copc_guard(path)
if guard:
return guard
ops, op_err = _validate_selection_ops(data.get('ops'))
if op_err:
return jsonify({'error': op_err}), 400
# Viewer geometry is centred on this offset; the ops were captured in
# that frame, so project centred coords but write originals back out.
offset = data.get('coord_offset') or [0, 0, 0]
try:
ox, oy, oz = (float(offset[0]), float(offset[1]), float(offset[2]))
except (TypeError, ValueError, IndexError):
return jsonify({'error': 'invalid coord_offset'}), 400
# LAS/LAZ (and therefore COPC) is read once via laspy and written back
# out as a true subset; other formats go through read_pointcloud.
las = d = None
if os.path.splitext(path)[1].lower() in ('.las', '.laz'):
import laspy
las = laspy.read(path)
x = np.asarray(las.x, dtype=np.float64)
y = np.asarray(las.y, dtype=np.float64)
z = np.asarray(las.z, dtype=np.float64)
else:
d = read_pointcloud(path)
if d.get('type') == 'gaussian':
return jsonify({'error': 'Gaussian splat files cannot be exported as LAS'}), 400
x = d['x'].astype(np.float64)
y = d['y'].astype(np.float64)
z = d['z'].astype(np.float64)
total = len(x)
mask = np.ones(total, dtype=bool)
xc, yc, zc = x - ox, y - oy, z - oz
for op in ops:
# Only points still alive can be culled further — replaying on the
# survivors keeps this O(remaining) instead of O(total) per op.
idx = np.flatnonzero(mask)
if idx.size == 0:
break
mask[idx] = _selection_survivors(xc[idx], yc[idx], zc[idx], op)
kept = int(mask.sum())
if kept == 0:
return jsonify({'error': 'the selection leaves no points'}), 400
fd, tmp_path = tempfile.mkstemp(suffix='.las', prefix='selection_')
os.close(fd)
if las is not None:
_write_las_subset(las, mask, tmp_path)
else:
# PLY/XYZ/PCD/PTS have no LAS header to inherit — rebuild one. RGB is
# only written when the source actually had it (read_pointcloud
# substitutes mid grey otherwise, which would be invented data).
cls = d.get('classification')
has_rgb = d.get('has_rgb')
write_las(tmp_path, x[mask], y[mask], z[mask],
intensity=d['intensity'][mask],
r=d['r'][mask] if has_rgb else None,
g=d['g'][mask] if has_rgb else None,
b=d['b'][mask] if has_rgb else None,
classification=cls[mask] if cls is not None else None)
log = current_app.config.get('LOGGER')
if log:
log.info(f"[Selection] {kept}/{total} pts from {path} "
f"({len(ops)} op(s)) -> LAS download")
base = os.path.basename(path)
for ext in ('.copc.laz', '.laz', '.las'):
if base.lower().endswith(ext):
base = base[:-len(ext)]
break
else:
base = os.path.splitext(base)[0]
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
download_name = f'{base}_selection_{stamp}.las'
# Hand ownership of the temp file to the response so it is removed once
# the bytes are on the wire, whether or not the client hangs up.
fh = open(tmp_path, 'rb')
os.unlink(tmp_path)
tmp_path = None
resp = send_file(fh, mimetype='application/octet-stream',
as_attachment=True, download_name=download_name)
resp.headers['X-Point-Count'] = str(kept)
resp.headers['X-Source-Point-Count'] = str(total)
return resp
except Exception as e:
return _error_response(e, 'save_selection')
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except OSError:
pass
# ══════════════════════════════════════════════════════
# Screenshot Save
# ══════════════════════════════════════════════════════
@api_bp.route('/api/save_screenshot', methods=['POST'])
def save_screenshot():
err = _require_json()
if err:
return err
try:
import base64
data = request.json
map_path = data.get('path', '')
image_b64 = data.get('image', '')
if not map_path or not image_b64:
return jsonify({'error': 'Missing path or image'}), 400
save_dir = map_path if os.path.isdir(map_path) else os.path.dirname(map_path)
# Same boundary every other endpoint enforces — without it this writes
# attacker-chosen directories anywhere the server can.
maps_dir = os.path.realpath(current_app.config['MAPS_DIR'])
if not os.path.realpath(save_dir).startswith(maps_dir + os.sep):
return jsonify({'error': 'Access denied'}), 403
if not os.path.isdir(save_dir):
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, 'screenshot.png')
image_data = base64.b64decode(image_b64)
with open(save_path, 'wb') as f:
f.write(image_data)
log = current_app.config.get('LOGGER')
if log:
log.info(f"[Screenshot] Saved: {save_path} ({len(image_data)} bytes)")
return jsonify({'status': 'ok', 'file': save_path})
except Exception as e:
return _error_response(e, 'save_screenshot')
# ══════════════════════════════════════════════════════
# Analysis API
# ══════════════════════════════════════════════════════
@api_bp.route('/api/analysis/statistics', methods=['POST'])
def analysis_statistics():
"""Compute basic statistics: point count, bounding box, density, height distribution."""
try:
err = _require_json()
if err:
return err
path = request.json.get('path', '')
if not path:
return jsonify({'error': 'Path required'}), 400
maps_dir = os.path.realpath(current_app.config['MAPS_DIR'])
if not os.path.realpath(path).startswith(maps_dir + os.sep):
return jsonify({'error': 'Access denied'}), 403
if not os.path.isfile(path):
return jsonify({'error': 'File not found'}), 404
d = read_pointcloud(path)
x, y, z = d['x'].astype(np.float64), d['y'].astype(np.float64), d['z'].astype(np.float64)
n = len(x)
if n == 0:
return jsonify({'error': 'Empty point cloud'}), 400
bbox = {
'min': [float(x.min()), float(y.min()), float(z.min())],
'max': [float(x.max()), float(y.max()), float(z.max())],
}
extent = [bbox['max'][i] - bbox['min'][i] for i in range(3)]
area_xy = extent[0] * extent[1] if extent[0] > 0 and extent[1] > 0 else 0
density = n / area_xy if area_xy > 0 else 0
# Height histogram (20 bins)
hist_counts, hist_edges = np.histogram(z, bins=20)
return jsonify({
'num_points': n,
'bounding_box': bbox,
'extent': extent,
'density_per_m2': round(density, 2),
'height_stats': {
'mean': round(float(z.mean()), 4),
'std': round(float(z.std()), 4),
'min': round(float(z.min()), 4),
'max': round(float(z.max()), 4),
},
'height_histogram': {
'counts': hist_counts.tolist(),
'edges': [round(float(e), 4) for e in hist_edges.tolist()],
},
})
except Exception as e:
return _error_response(e, 'analysis_statistics')
@api_bp.route('/api/analysis/sor', methods=['POST'])
def analysis_sor():
"""Statistical Outlier Removal: remove points that are far from their k-nearest neighbors."""
try:
err = _require_json()
if err:
return err
data = request.json
path = data.get('path', '')
try:
k = int(data.get('k', 20))
std_ratio = float(data.get('std_ratio', 2.0))
except (TypeError, ValueError):
return jsonify({'error': 'k and std_ratio must be numeric'}), 400
if not 1 <= k <= 200:
return jsonify({'error': 'k must be between 1 and 200'}), 400
if not std_ratio > 0:
return jsonify({'error': 'std_ratio must be > 0'}), 400
if not path:
return jsonify({'error': 'Path required'}), 400
maps_dir = os.path.realpath(current_app.config['MAPS_DIR'])