shuishen
23 hours ago 8f75db57f3055b2a3575c86ee3e6acf1dd56ce47
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
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { DeleteOutlined, DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue";
 
import { acceptPointCloudAutoAnnotation, artifactUrl, createDensePointCloudClassification, createPhotoReconstructionRun, createPointCloudAnnotation, createPointCloudAnnotationClass, createPointCloudAnnotationSourceRun, createPointCloudAutoAnnotation, createPointCloudModelInference, createPointCloudRun, createPointCloudTrainingRun, createTexturedMeshAnnotationSourceRun, createTexturedMeshModelInference, deletePointCloudAnnotation, deletePointCloudAnnotationClass, deletePointCloudAnnotationSource, loadLatestPointCloudAutoAnnotation, loadPhotoReconstructionJob, loadPointCloudAnnotationClasses, loadPointCloudAnnotationSourceDeletionPlan, loadPointCloudAnnotationSourceJob, loadPointCloudAnnotationSources, loadPointCloudAnnotations, loadPointCloudArtifactHealth, loadPointCloudAutoAnnotationMergePreview, loadPointCloudAutoAnnotationReview, loadPointCloudModelInferenceJob, loadPointCloudSemanticModels, loadPointCloudTrainingJob, savePointCloudAutoAnnotationReview, updatePointCloudAnnotationClassColor, uploadPhotoReconstructionFile, uploadPointCloudFile, uploadTexturedMeshTextureFile, type ComputeDevice, type GeoFeature, type PhotoReconstructionJob, type PointCloudAnnotation, type PointCloudAnnotationClass, type PointCloudAnnotationSource, type PointCloudAnnotationSourceDeletionPlan, type PointCloudAnnotationSourceJob, type PointCloudArtifactHealth, type PointCloudAutoAnnotationMergePreview, type PointCloudCase, type PointCloudInferenceJob, type PointCloudSemanticModel, type PointCloudTrainingJob } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import RunDeletionControl from "@/components/RunDeletionControl.vue";
import SemanticModelDeletionControl from "@/components/SemanticModelDeletionControl.vue";
import SparsePointCloudViewer from "@/components/SparsePointCloudViewer.vue";
import PointCloudAnnotationViewer from "@/components/PointCloudAnnotationViewer.vue";
import PointCloudModelCard from "@/components/PointCloudModelCard.vue";
import PointCloudSemanticNavigation, { type PointCloudSemanticSection } from "@/components/PointCloudSemanticNavigation.vue";
import PointCloudTaskList from "@/components/PointCloudTaskList.vue";
import TexturedMeshViewer from "@/components/TexturedMeshViewer.vue";
import { useArtifactStore } from "@/stores/artifacts";
 
const store = useArtifactStore();
const caseId = ref("");
const selectedName = ref("");
const files = ref<File[]>([]);
const photoFiles = ref<File[]>([]);
const showRunForm = ref(false);
const running = ref(false);
const photoSubmitting = ref(false);
const photoJob = ref<PhotoReconstructionJob | null>(null);
const usePhotoPositionPriors = ref(false);
const workflow = ref<"photo" | "geometry" | "semantic">("photo");
const semanticSection = ref<PointCloudSemanticSection>("classification");
const runError = ref<string | null>(null);
const searchText = ref("");
const vectorFeatures = ref<GeoFeature[]>([]);
const densePreviewMode = ref<"textured" | "geometry" | "point_colors">("textured");
let photoPollTimer: ReturnType<typeof setTimeout> | undefined;
const annotationSources = ref<PointCloudAnnotationSource[]>([]);
const annotationClasses = ref<PointCloudAnnotationClass[]>([]);
const annotations = ref<PointCloudAnnotation[]>([]);
const annotationSourceId = ref("");
const annotationSaving = ref(false);
const annotationNotice = ref<{ type: "info" | "success" | "error"; message: string } | null>(null);
const annotationDeletingId = ref("");
const annotationSourceModalOpen = ref(false);
const annotationSourceFile = ref<File | null>(null);
const annotationSourceMode = ref<"pointcloud" | "texturedMesh">("pointcloud");
const annotationTextureFiles = ref<File[]>([]);
const annotationSourceSubmitting = ref(false);
const annotationSourceJob = ref<PointCloudAnnotationSourceJob | null>(null);
let annotationSourcePollTimer: ReturnType<typeof setTimeout> | undefined;
const annotationSourceRemovalPlan = ref<PointCloudAnnotationSourceDeletionPlan | null>(null);
const annotationSourceRemovalOpen = ref(false);
const annotationSourceRemovalLoading = ref(false);
const annotationSourceRemoving = ref(false);
const annotationClassModalOpen = ref(false);
const annotationClassSubmitting = ref(false);
const annotationClassKey = ref("");
const annotationClassLabel = ref("");
const annotationClassColor = ref("#3289c7");
const annotationClassDeletingCode = ref<number | null>(null);
const annotationClassColorDrafts = ref<Record<number, string>>({});
const annotationClassColorSavingCode = ref<number | null>(null);
const trainingJob = ref<PointCloudTrainingJob | null>(null);
let trainingPollTimer: ReturnType<typeof setTimeout> | undefined;
const concentratedAnnotationIds = ref<string[]>([]);
const semanticModels = ref<PointCloudSemanticModel[]>([]);
const artifactHealth = ref<PointCloudArtifactHealth | null>(null);
const selectedSemanticModelId = ref("");
const modelDevice = ref<ComputeDevice>("auto");
const semanticClassificationRoute = ref<"rule" | "model">("rule");
const inferenceFile = ref<File | null>(null);
const inferenceInputModalOpen = ref(false);
const inferenceInputMode = ref<"pointcloud" | "texturedMesh">("pointcloud");
const inferenceTextureFiles = ref<File[]>([]);
const inferenceLaunchContext = ref<"semantic">("semantic");
const inferenceSubmitting = ref(false);
const inferenceJob = ref<PointCloudInferenceJob | null>(null);
const inferenceSummary = ref<{ class_counts?: Record<string, number>; input_points?: number; preview_points?: number } | null>(null);
let inferencePollTimer: ReturnType<typeof setTimeout> | undefined;
const autoAnnotationThreshold = ref(0.95);
const autoAnnotationSubmitting = ref(false);
const autoAnnotationAccepting = ref(false);
const autoAnnotationJob = ref<PointCloudInferenceJob | null>(null);
const autoAnnotationSummary = ref<{ automatic_annotation?: { candidate_count?: number; candidate_confidence?: number; candidate_class_counts?: Record<string, number> }; class_counts?: Record<string, number>; input_points?: number } | null>(null);
const autoAnnotationReviewChanges = ref<Array<[number, number]>>([]);
const autoAnnotationReviewSchema = ref<PointCloudAnnotationClass[] | null>(null);
const autoAnnotationMergePreview = ref<PointCloudAutoAnnotationMergePreview | null>(null);
const autoAnnotationReviewSaving = ref(false);
let autoAnnotationPollTimer: ReturnType<typeof setTimeout> | undefined;
 
const workflowCases = computed(() => Object.values(store.pointCloudCases).filter((item) => {
  const run = item.run;
  if (workflow.value === "photo") return Boolean(run.photo_reconstruction || run.dense_photo_reconstruction);
  if (workflow.value === "semantic") return (run.point_clouds ?? []).some((cloud) => Boolean(cloud.semantic_preview_point_cloud));
  return Boolean(run.point_clouds?.length);
}));
const currentCase = computed<PointCloudCase | undefined>(() => workflowCases.value.find((item) => item.id === caseId.value) ?? workflowCases.value[0]);
const selectedCloud = computed(() => (currentCase.value?.run.point_clouds ?? []).find((item) => item.file === selectedName.value) ?? currentCase.value?.run.point_clouds?.[0]);
const photoRun = computed(() => currentCase.value?.run.photo_reconstruction);
const densePhotoRun = computed(() => currentCase.value?.run.dense_photo_reconstruction);
const densePreviewSource = computed(() => {
  const dense = densePhotoRun.value;
  if (!dense) return "";
  if (densePreviewMode.value === "geometry" && dense.geometry_preview_model_file) return dense.geometry_preview_model_file;
  if (densePreviewMode.value === "point_colors" && dense.point_color_preview_model_file) return dense.point_color_preview_model_file;
  return dense.textured_model_file;
});
const caseOptions = computed(() => workflowCases.value.map((item) => ({ value: item.id, label: item.label })));
const filteredCases = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
const photoJobMessage = computed(() => {
  const job = photoJob.value;
  if (!job) return "";
  if (job.status === "queued") return `已排队:${job.inputImages} 张照片等待 CPU 资源。`;
  if (job.stage === "sparse_sfm") return "正在进行 CPU 稀疏相机建模。";
  if (job.stage === "dense_mvs") return "正在进行 CPU 稠密点云、网格与纹理重建,通常需要几十分钟。";
  if (job.status === "complete") return "重建完成,结果已加入案例库。";
  return job.error || "照片重建失败。";
});
const photoJobProgress = computed(() => photoJob.value?.progress ?? { percent: 0, stage: "queued", message: "照片已保存,正在等待 CPU 重建资源。", inputImages: photoJob.value?.inputImages ?? 0, estimate: true });
function executionLabel(job: { device: string; requestedDevice?: ComputeDevice; environment?: string; torchVersion?: string; fallbackUsed?: boolean; fallbackReason?: string | null }) {
  const device = job.device === "cuda" ? "GPU CUDA" : "CPU";
  const selection = job.requestedDevice ? `请求 ${job.requestedDevice} -> ${device}` : device;
  return [selection, job.environment, job.torchVersion ? `PyTorch ${job.torchVersion}` : "", job.fallbackUsed && job.fallbackReason ? `回退:${job.fallbackReason}` : ""].filter(Boolean).join(" / ");
}
 
function rings(feature: GeoFeature): number[][][] {
  const geometry = feature.geometry;
  if (!geometry?.coordinates) return [];
  if (geometry.type === "Polygon") return geometry.coordinates as number[][][];
  if (geometry.type === "MultiPolygon") return (geometry.coordinates as number[][][][]).flat();
  return [];
}
const allRings = computed(() => vectorFeatures.value.flatMap(rings));
const vectorViewBox = computed(() => {
  const points = allRings.value.flat();
  if (!points.length) return "0 0 1 1";
  const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]);
  const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys);
  return `${minX} ${minY} ${Math.max(maxX - minX, 1)} ${Math.max(maxY - minY, 1)}`;
});
const vectorPaths = computed(() => allRings.value.map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${point[1]}`).join(" ") + " Z"));
const annotationSource = computed(() => annotationSources.value.find((item) => item.id === annotationSourceId.value));
const annotationSourceDescription = computed(() => annotationSource.value?.sourceKind.includes("多视角照片特征融合")
  ? `当前显示 ${annotationSource.value.pointCount.toLocaleString()} 个原始 LAS RGB/XYZ 采样点;点序与同目录照片特征 NPZ 严格一致。覆盖率伪彩没有作为标注底图或训练颜色。`
  : annotationSource.value?.sourceHasRgb === false
    ? `当前显示 ${annotationSource.value.pointCount.toLocaleString()} 个 XYZ 点的中性预览。源 PLY 没有可读取的逐点 RGB,因而不能用于当前 RGB 语义模型训练。`
    : `当前直接显示该数据源全部 ${annotationSource.value?.pointCount.toLocaleString() ?? 0} 个 RGB/XYZ 点,不是语义规则分类颜色。大规模点云会相应占用更多浏览器内存与显存。`);
const selectedSemanticModel = computed(() => semanticModels.value.find((item) => item.id === selectedSemanticModelId.value));
const semanticModelOptions = computed(() => semanticModels.value.map((model) => ({ value: model.id, label: `${model.label} | ${Object.values(model.classes).map((item) => item.label).join(" / ")}` })));
function annotationSourceLabel(sourceId: string) { return annotationSources.value.find((item) => item.id === sourceId)?.label ?? sourceId; }
function annotationClassDisplayLabel(code: string | number) { return annotationClasses.value.find((item) => item.code === Number(code))?.label ?? `类别 ${code}`; }
function annotationTrainerLabel(annotation: PointCloudAnnotation) { return annotationSources.value.find((item) => item.id === annotation.sourceId)?.sourceKind.includes("多视角照片特征融合") ? "多视角特征训练" : "自动选择 GPU / CPU 训练"; }
const selectedSourceAnnotations = computed(() => annotations.value.filter((item) => item.sourceId === annotationSourceId.value));
const latestSourceAnnotation = computed(() => selectedSourceAnnotations.value[0]);
function isMultiviewAnnotationSource(sourceId: string) {
  const artifactRoot = annotationSources.value.find((item) => item.id === sourceId)?.artifactRoot ?? "";
  const segments = artifactRoot.split("/");
  return (segments[segments.length - 1] ?? "").startsWith("multiview-feature-");
}
const concentratedTrainingOptions = computed(() => {
  const selectedSources = new Map(annotations.value.filter((item) => concentratedAnnotationIds.value.includes(item.id)).map((item) => [item.sourceId, item.id]));
  return annotations.value
    .filter((item) => annotationSources.value.some((source) => source.id === item.sourceId && source.sourceHasRgb !== false) && !isMultiviewAnnotationSource(item.sourceId))
    .map((item) => ({
      value: item.id,
      label: `${annotationSourceLabel(item.sourceId)} | ${item.labelCount.toLocaleString()} 点`,
      disabled: Boolean(selectedSources.get(item.sourceId) && selectedSources.get(item.sourceId) !== item.id),
    }));
});
const concentratedTrainingReady = computed(() => {
  const selected = annotations.value.filter((item) => concentratedAnnotationIds.value.includes(item.id));
  return selected.length >= 2 && new Set(selected.map((item) => item.sourceId)).size === selected.length;
});
const activeSemanticTaskCount = computed(() => [annotationSourceJob.value, trainingJob.value, inferenceJob.value, autoAnnotationJob.value].filter((job) => job && ["queued", "running"].includes(job.status)).length);
 
function syncSelection() { selectedName.value = currentCase.value?.run.point_clouds?.[0]?.file ?? ""; }
function selectWorkflowCase() { caseId.value = workflowCases.value[0]?.id ?? ""; syncSelection(); }
async function removePointCloudRun() { await store.loadPointCloud(true); selectWorkflowCase(); }
async function startSemanticCaseReview() {
  if (!currentCase.value) return;
  if (!selectedCloud.value?.semantic_annotation_source_point_cloud) {
    runError.value = "\u8be5\u5386\u53f2\u6a21\u578b\u5206\u7c7b\u6848\u4f8b\u5c1a\u672a\u751f\u6210\u4eba\u5de5\u590d\u6838\u7528 RGB/XYZ PLY\u3002\u8bf7\u5148\u5237\u65b0\u6848\u4f8b\uff1b\u82e5\u4ecd\u4e0d\u53ef\u7528\uff0c\u8bf7\u8054\u7cfb\u672c\u5730\u7ba1\u7406\u5458\u8865\u751f\u6210\u8be5\u590d\u6838\u6e90\u3002";
    return;
  }
  const sourceId = `${currentCase.value.id}:${selectedCloud.value.semantic_annotation_source_point_cloud}`;
  try {
    await refreshAnnotationData();
    if (!annotationSources.value.some((item) => item.id === sourceId)) throw new Error("该模型分类结果未提供可复核的 RGB LAS 标注源。");
    annotationSourceId.value = sourceId;
    workflow.value = "semantic";
    semanticSection.value = "annotation";
  } catch (error) { runError.value = error instanceof Error ? error.message : "无法将模型分类结果送入人工复核。"; }
}
async function removeSemanticModel() { await refreshSemanticModels(); autoAnnotationJob.value = null; autoAnnotationSummary.value = null; inferenceJob.value = null; inferenceSummary.value = null; }
function beforeUpload(file: File) { files.value = [...files.value, file]; return false; }
function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); }
function beforePhotoUpload(file: File) { photoFiles.value = [...photoFiles.value, file]; return false; }
function removePhotoFile(file: { name: string }) { photoFiles.value = photoFiles.value.filter((item) => item.name !== file.name); }
function stopPhotoPolling() { if (photoPollTimer) clearTimeout(photoPollTimer); photoPollTimer = undefined; }
async function loadVector() {
  vectorFeatures.value = [];
  if (!currentCase.value || !selectedCloud.value) return;
  const vectorFile = selectedCloud.value.semantic_vector_file || selectedCloud.value.vector_file;
  if (!vectorFile || !vectorFile.toLowerCase().endsWith(".geojson")) return;
  const response = await fetch(artifactUrl(`${currentCase.value.artifactRoot}/${vectorFile}`), { cache: "no-store" });
  if (!response.ok) throw new Error(`\u65e0\u6cd5\u8bfb\u53d6\u70b9\u4e91\u77e2\u91cf\u7ed3\u679c (${response.status})`);
  vectorFeatures.value = ((await response.json()) as { features?: GeoFeature[] }).features ?? [];
}
async function refreshAnnotationData() {
  const [sources, classes, revisions] = await Promise.all([loadPointCloudAnnotationSources(), loadPointCloudAnnotationClasses(), loadPointCloudAnnotations()]);
  annotationSources.value = sources;
  annotationClasses.value = classes;
  annotations.value = revisions;
  if (!annotationSourceId.value || !annotationSources.value.some((item) => item.id === annotationSourceId.value)) annotationSourceId.value = annotationSources.value[0]?.id ?? "";
}
async function refreshSemanticModels() {
  const [models, health] = await Promise.all([loadPointCloudSemanticModels(), loadPointCloudArtifactHealth()]);
  semanticModels.value = models;
  artifactHealth.value = health;
  if (!selectedSemanticModelId.value || !semanticModels.value.some((item) => item.id === selectedSemanticModelId.value)) selectedSemanticModelId.value = semanticModels.value[0]?.id ?? "";
}
function stopTrainingPolling() { if (trainingPollTimer) clearTimeout(trainingPollTimer); trainingPollTimer = undefined; }
async function pollTrainingJob() {
  if (!trainingJob.value) return;
  try {
    trainingJob.value = await loadPointCloudTrainingJob(trainingJob.value.id);
    if (trainingJob.value.status === "complete") {
      await refreshSemanticModels();
      return;
    }
    if (!['complete', 'failed'].includes(trainingJob.value.status)) trainingPollTimer = setTimeout(() => { void pollTrainingJob(); }, 2_500);
  } catch (error) { runError.value = error instanceof Error ? error.message : "无法读取训练任务状态。"; }
}
async function saveAnnotation(labels: Array<[number, number]>) {
  if (!annotationSourceId.value) return;
  annotationSaving.value = true; annotationNotice.value = { type: "info", message: `正在保存 ${labels.length.toLocaleString()} 个确认点...` };
  try { const { annotation } = await createPointCloudAnnotation(annotationSourceId.value, labels); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: `已保存 ${annotation.id}:${annotation.labelCount.toLocaleString()} 个确认点。` }; }
  catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "保存标注失败。" }; }
  finally { annotationSaving.value = false; }
}
async function deleteAnnotation(annotationId: string) {
  annotationDeletingId.value = annotationId;
  try { await deletePointCloudAnnotation(annotationId); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: `已删除标注版本 ${annotationId}。` }; }
  catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "删除标注版本失败。" }; }
  finally { annotationDeletingId.value = ""; }
}
function beforeAnnotationSourceUpload(file: File) { annotationSourceFile.value = file; return false; }
function removeAnnotationSourceUpload() { annotationSourceFile.value = null; }
function beforeAnnotationTextureUpload(file: File) { annotationTextureFiles.value = [...annotationTextureFiles.value, file]; return false; }
function removeAnnotationTextureUpload(file: { name: string }) { const index = annotationTextureFiles.value.findIndex((item) => item.name === file.name); if (index >= 0) annotationTextureFiles.value.splice(index, 1); }
const annotationSourceReady = computed(() => Boolean(annotationSourceFile.value) && (annotationSourceMode.value === "pointcloud" || annotationTextureFiles.value.length > 0));
function stopAnnotationSourcePolling() { if (annotationSourcePollTimer) clearTimeout(annotationSourcePollTimer); annotationSourcePollTimer = undefined; }
async function pollAnnotationSourceJob() {
  if (!annotationSourceJob.value) return;
  try {
    annotationSourceJob.value = await loadPointCloudAnnotationSourceJob(annotationSourceJob.value.id);
    if (annotationSourceJob.value.status === "complete") {
      await store.loadPointCloud(true);
      await refreshAnnotationData();
      await refreshAnnotationData();
      const source = annotationSources.value.find((item) => item.runId === annotationSourceJob.value?.runId);
      if (source) annotationSourceId.value = source.id;
      annotationSourceModalOpen.value = false;
      annotationNotice.value = { type: "success", message: `已生成标注源:${annotationSourceJob.value.inputName}` };
      return;
    }
    if (annotationSourceJob.value.status !== "failed") annotationSourcePollTimer = setTimeout(() => { void pollAnnotationSourceJob(); }, 2_500);
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取标注源任务状态。" }; }
}
async function createAnnotationSource() {
  if (!annotationSourceFile.value) return;
  annotationSourceSubmitting.value = true;
  try {
    const upload = await uploadPointCloudFile(annotationSourceFile.value);
    const { job } = annotationSourceMode.value === "texturedMesh"
      ? await createTexturedMeshAnnotationSourceRun(upload, await Promise.all(annotationTextureFiles.value.map(uploadTexturedMeshTextureFile)))
      : await createPointCloudAnnotationSourceRun(upload);
    annotationSourceFile.value = null;
    annotationTextureFiles.value = [];
    annotationSourceJob.value = job;
    annotationNotice.value = { type: "info", message: job.textureCount ? `正在烘焙 ${job.inputName} 的 ${job.textureCount} 张纹理图,完成后会自动加入标注源。` : `正在处理 ${job.inputName},完成后会自动加入标注源。` };
    stopAnnotationSourcePolling();
    void pollAnnotationSourceJob();
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "新增标注源失败。" }; }
  finally { annotationSourceSubmitting.value = false; }
}
async function openAnnotationSourceRemoval() {
  if (!annotationSourceId.value) return;
  annotationSourceRemovalLoading.value = true;
  annotationSourceRemovalPlan.value = null;
  try {
    annotationSourceRemovalPlan.value = await loadPointCloudAnnotationSourceDeletionPlan(annotationSourceId.value);
    annotationSourceRemovalOpen.value = true;
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取数据链路移除范围。" }; }
  finally { annotationSourceRemovalLoading.value = false; }
}
async function removeAnnotationSourceChain() {
  if (!annotationSourceRemovalPlan.value) return;
  annotationSourceRemoving.value = true;
  try {
    const { removed } = await deletePointCloudAnnotationSource(annotationSourceRemovalPlan.value.sourceId);
    annotationSourceRemovalOpen.value = false;
    annotationSourceRemovalPlan.value = null;
    annotationSourceId.value = "";
    await store.loadPointCloud(true);
    await refreshAnnotationData();
    await refreshSemanticModels();
    const detail = removed.removed;
    annotationNotice.value = { type: "success", message: `已移除完整数据链路:${detail.outputDirectories} 个结果目录、${detail.rawDirectories + detail.processedDirectories} 个上传/处理目录、${detail.annotationRevisions} 个标注版本、${detail.trainingRuns} 个训练结果和 ${detail.inferenceRuns} 个推理结果。` };
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "移除数据链路失败。" }; }
  finally { annotationSourceRemoving.value = false; }
}
function annotationColor(rgb: number[]) { return `#${rgb.map((item) => item.toString(16).padStart(2, "0")).join("")}`; }
function hexColorToRgb(value: string) {
  const match = /^#([0-9a-f]{6})$/i.exec(value);
  return match ? [Number.parseInt(match[1].slice(0, 2), 16), Number.parseInt(match[1].slice(2, 4), 16), Number.parseInt(match[1].slice(4, 6), 16)] : null;
}
function openAnnotationClassModal() {
  annotationClassColorDrafts.value = Object.fromEntries(annotationClasses.value.map((item) => [item.code, annotationColor(item.color)]));
  annotationClassModalOpen.value = true;
}
async function updateAnnotationClassColor(code: number) {
  const color = hexColorToRgb(annotationClassColorDrafts.value[code] ?? "");
  if (!color) { annotationNotice.value = { type: "error", message: "请选择有效的 RGB 颜色。" }; return; }
  annotationClassColorSavingCode.value = code;
  try {
    await updatePointCloudAnnotationClassColor(code, color);
    await refreshAnnotationData();
    annotationClassColorDrafts.value = Object.fromEntries(annotationClasses.value.map((item) => [item.code, annotationColor(item.color)]));
    annotationNotice.value = { type: "success", message: "已更新标签显示颜色;类别编码、历史标注和模型不受影响。" };
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "更新标签颜色失败。" }; }
  finally { annotationClassColorSavingCode.value = null; }
}
async function createAnnotationClass() {
  const color = hexColorToRgb(annotationClassColor.value);
  if (!color) { annotationNotice.value = { type: "error", message: "请选择有效的 RGB 颜色。" }; return; }
  annotationClassSubmitting.value = true;
  try {
    await createPointCloudAnnotationClass({ key: annotationClassKey.value.trim(), label: annotationClassLabel.value.trim(), color });
    await refreshAnnotationData();
    annotationClassColorDrafts.value = Object.fromEntries(annotationClasses.value.map((item) => [item.code, annotationColor(item.color)]));
    annotationClassKey.value = ""; annotationClassLabel.value = ""; annotationClassColor.value = "#3289c7";
    annotationNotice.value = { type: "success", message: "已新增标签分类。" };
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "新增标签分类失败。" }; }
  finally { annotationClassSubmitting.value = false; }
}
async function deleteAnnotationClass(code: number) {
  annotationClassDeletingCode.value = code;
  try { await deletePointCloudAnnotationClass(code); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: "已删除未使用的自定义标签分类。" }; }
  catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "删除标签分类失败。" }; }
  finally { annotationClassDeletingCode.value = null; }
}
async function startTraining(annotationIds: string[]) {
  try { const { job } = await createPointCloudTrainingRun(annotationIds, modelDevice.value); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); }
  catch (error) { runError.value = error instanceof Error ? error.message : "启动训练失败。"; }
}
async function startConcentratedTraining() {
  if (!concentratedTrainingReady.value) return;
  await startTraining([...concentratedAnnotationIds.value]);
}
function beforeInferenceUpload(file: File) { inferenceFile.value = file; return false; }
function removeInferenceUpload() { inferenceFile.value = null; }
function beforeInferenceTextureUpload(file: File) { inferenceTextureFiles.value = [...inferenceTextureFiles.value, file]; return false; }
function removeInferenceTextureUpload(file: { name: string }) { const index = inferenceTextureFiles.value.findIndex((item) => item.name === file.name); if (index >= 0) inferenceTextureFiles.value.splice(index, 1); }
const inferenceInputReady = computed(() => Boolean(inferenceFile.value) && (inferenceInputMode.value === "pointcloud" || inferenceTextureFiles.value.length > 0));
function openModelInference() { inferenceLaunchContext.value = "semantic"; inferenceInputModalOpen.value = true; }
function stopInferencePolling() { if (inferencePollTimer) clearTimeout(inferencePollTimer); inferencePollTimer = undefined; }
async function loadInferenceSummary(job: PointCloudInferenceJob) {
  if (!job.summary) return;
  const response = await fetch(artifactUrl(job.summary), { cache: "no-store" });
  if (response.ok) inferenceSummary.value = await response.json() as { class_counts?: Record<string, number>; input_points?: number; preview_points?: number };
}
async function pollInferenceJob() {
  if (!inferenceJob.value) return;
  try {
    inferenceJob.value = await loadPointCloudModelInferenceJob(inferenceJob.value.id);
    if (inferenceJob.value.status === "complete") {
      await loadInferenceSummary(inferenceJob.value);
      await store.loadPointCloud(true);
      if (inferenceLaunchContext.value === "semantic") { workflow.value = "semantic"; caseId.value = inferenceJob.value.runId; syncSelection(); showRunForm.value = false; }
      return;
    }
    if (inferenceJob.value.status !== "failed") inferencePollTimer = setTimeout(() => { void pollInferenceJob(); }, 2_500);
  } catch (error) { runError.value = error instanceof Error ? error.message : "无法读取模型应用任务状态。"; }
}
async function applySemanticModel() {
  if (!selectedSemanticModelId.value || !inferenceFile.value || !inferenceInputReady.value) return;
  inferenceSubmitting.value = true; runError.value = null; inferenceJob.value = null; inferenceSummary.value = null;
  try {
    const upload = await uploadPointCloudFile(inferenceFile.value);
    const { job } = inferenceInputMode.value === "texturedMesh"
      ? await createTexturedMeshModelInference(selectedSemanticModelId.value, upload, await Promise.all(inferenceTextureFiles.value.map(uploadTexturedMeshTextureFile)), modelDevice.value)
      : await createPointCloudModelInference(selectedSemanticModelId.value, upload, modelDevice.value);
    inferenceFile.value = null; inferenceTextureFiles.value = []; inferenceInputModalOpen.value = false; inferenceJob.value = job; stopInferencePolling(); void pollInferenceJob();
  } catch (error) { runError.value = error instanceof Error ? error.message : "应用训练模型失败。"; }
  finally { inferenceSubmitting.value = false; }
}
function stopAutoAnnotationPolling() { if (autoAnnotationPollTimer) clearTimeout(autoAnnotationPollTimer); autoAnnotationPollTimer = undefined; }
async function loadAutoAnnotationSummary(job: PointCloudInferenceJob) {
  if (!job.summary) return;
  const response = await fetch(artifactUrl(job.summary), { cache: "no-store" });
  if (response.ok) autoAnnotationSummary.value = await response.json() as typeof autoAnnotationSummary.value;
}
async function loadAutoAnnotationReviewSchema(job: PointCloudInferenceJob) {
  if (!job.metadata) { autoAnnotationReviewSchema.value = null; return; }
  const response = await fetch(artifactUrl(job.metadata), { cache: "no-store" });
  if (!response.ok) { autoAnnotationReviewSchema.value = null; return; }
  const metadata = await response.json() as { classes?: Record<string, { code?: number; key?: string; label?: string; color?: number[] }> };
  const values = Object.entries(metadata.classes ?? {}).flatMap(([code, item]) => {
    const numericCode = item.code ?? Number(code);
    return Number.isInteger(numericCode) && typeof item.key === "string" && typeof item.label === "string" && Array.isArray(item.color) && item.color.length === 3
      ? [{ code: numericCode, key: item.key, label: item.label, color: item.color, builtIn: annotationClasses.value.some((current) => current.code === numericCode && current.builtIn) }]
      : [];
  });
  autoAnnotationReviewSchema.value = values.length ? values : null;
}
async function loadAutoAnnotationReview(job: PointCloudInferenceJob) {
  const review = await loadPointCloudAutoAnnotationReview(job.runId);
  autoAnnotationReviewChanges.value = review?.corrections ?? [];
  await loadAutoAnnotationReviewSchema(job);
  await loadAutoAnnotationMergePreview(job);
}
async function loadAutoAnnotationMergePreview(job: PointCloudInferenceJob) {
  if (!annotationSourceId.value) return;
  autoAnnotationMergePreview.value = await loadPointCloudAutoAnnotationMergePreview(job.runId, annotationSourceId.value, latestSourceAnnotation.value?.id);
}
async function restoreAutoAnnotation() {
  if (!annotationSourceId.value || !selectedSemanticModelId.value) return;
  try {
    const job = await loadLatestPointCloudAutoAnnotation(annotationSourceId.value, selectedSemanticModelId.value);
    if (!job) return;
    autoAnnotationJob.value = job;
    await loadAutoAnnotationSummary(job);
    await loadAutoAnnotationReview(job);
  } catch (error) {
    annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法恢复已完成的自动标注结果。" };
  }
}
async function saveAutoAnnotationReview(changes: Array<[number, number]>) {
  if (!autoAnnotationJob.value || !annotationSourceId.value) return;
  autoAnnotationReviewSaving.value = true;
  try {
    const { review } = await savePointCloudAutoAnnotationReview(autoAnnotationJob.value.runId, annotationSourceId.value, changes);
    autoAnnotationReviewChanges.value = changes;
    await loadAutoAnnotationMergePreview(autoAnnotationJob.value);
    annotationNotice.value = { type: "success", message: `已保存 ${review.correctionCount.toLocaleString()} 个候选审阅修正;尚未合并进训练标注。` };
  } catch (error) {
    annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "保存候选审阅修正失败。" };
  } finally { autoAnnotationReviewSaving.value = false; }
}
async function pollAutoAnnotationJob() {
  if (!autoAnnotationJob.value) return;
  try {
    autoAnnotationJob.value = await loadPointCloudModelInferenceJob(autoAnnotationJob.value.id);
    if (autoAnnotationJob.value.status === "complete") { await loadAutoAnnotationSummary(autoAnnotationJob.value); await loadAutoAnnotationReview(autoAnnotationJob.value); return; }
    if (autoAnnotationJob.value.status !== "failed") autoAnnotationPollTimer = setTimeout(() => { void pollAutoAnnotationJob(); }, 2_500);
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取自动标注任务状态。" }; }
}
async function startAutoAnnotation() {
  if (!annotationSourceId.value || !selectedSemanticModelId.value) return;
  autoAnnotationSubmitting.value = true; autoAnnotationJob.value = null; autoAnnotationSummary.value = null; autoAnnotationMergePreview.value = null;
  try {
    const { job } = await createPointCloudAutoAnnotation(selectedSemanticModelId.value, annotationSourceId.value, autoAnnotationThreshold.value, modelDevice.value);
    autoAnnotationJob.value = job; annotationNotice.value = { type: "info", message: "自动标注候选正在生成;不会覆盖现有人工标注。" }; stopAutoAnnotationPolling(); void pollAutoAnnotationJob();
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "启动自动标注失败。" }; }
  finally { autoAnnotationSubmitting.value = false; }
}
async function acceptAutoAnnotation() {
  if (!autoAnnotationJob.value || !annotationSourceId.value || autoAnnotationJob.value.status !== "complete") return;
  autoAnnotationAccepting.value = true;
  try {
    const { annotation } = await acceptPointCloudAutoAnnotation(autoAnnotationJob.value.runId, annotationSourceId.value, latestSourceAnnotation.value?.id);
    await refreshAnnotationData();
    annotationNotice.value = { type: "success", message: `已确认合并 ${annotation.labelCount.toLocaleString()} 个候选/人工标签,人工标签优先。可使用新版本重新训练。` };
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "确认自动标注候选失败。" }; }
  finally { autoAnnotationAccepting.value = false; }
}
async function submitRun() {
  if (!files.value.length) { runError.value = "请选择至少一份 PLY、PCD、XYZ、LAS 或 LAZ 点云。"; return; }
  running.value = true; runError.value = null;
  try {
    const pointClouds = [];
    for (const file of files.value) pointClouds.push(await uploadPointCloudFile(file));
    const { run } = await createPointCloudRun(pointClouds);
    await store.loadPointCloud(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false;
  } catch (error) { runError.value = error instanceof Error ? error.message : "三维点云运行失败"; }
  finally { running.value = false; }
}
async function classifyDenseResult() {
  if (!currentCase.value) return;
  running.value = true; runError.value = null;
  try {
    const { run } = await createDensePointCloudClassification(currentCase.value.id);
    await store.loadPointCloud(true); workflow.value = "semantic"; caseId.value = run.id; syncSelection();
  } catch (error) { runError.value = error instanceof Error ? error.message : "稠密点云分类失败"; }
  finally { running.value = false; }
}
async function pollPhotoReconstructionJob() {
  const current = photoJob.value;
  if (!current) return;
  try {
    const job = await loadPhotoReconstructionJob(current.id);
    photoJob.value = job;
    if (job.status === "complete") {
      await store.loadPointCloud(true);
      caseId.value = job.run?.id ?? job.runId;
      syncSelection();
      return;
    }
    if (job.status !== "failed") photoPollTimer = setTimeout(() => { void pollPhotoReconstructionJob(); }, 3_000);
  } catch (error) {
    runError.value = error instanceof Error ? error.message : "无法读取照片重建任务状态。";
  }
}
async function submitPhotoReconstruction() {
  if (photoFiles.value.length < 3) { runError.value = "请至少选择 3 张同一架次、同一相机的 JPG/JPEG 照片。"; return; }
  photoSubmitting.value = true;
  runError.value = null;
  photoJob.value = null;
  try {
    const uploads = [];
    for (const file of photoFiles.value) uploads.push(await uploadPhotoReconstructionFile(file));
    const { job } = await createPhotoReconstructionRun(uploads, usePhotoPositionPriors.value);
    photoFiles.value = [];
    photoJob.value = job;
    showRunForm.value = false;
    stopPhotoPolling();
    void pollPhotoReconstructionJob();
  } catch (error) {
    runError.value = error instanceof Error ? error.message : "照片重建任务提交失败。";
  } finally { photoSubmitting.value = false; }
}
 
watch([caseId, selectedName], loadVector);
watch(annotationSourceMode, () => { annotationSourceFile.value = null; annotationTextureFiles.value = []; });
watch(inferenceInputMode, () => { inferenceFile.value = null; inferenceTextureFiles.value = []; });
watch(semanticClassificationRoute, () => { files.value = []; runError.value = null; });
watch([annotationSourceId, selectedSemanticModelId], () => { autoAnnotationJob.value = null; autoAnnotationSummary.value = null; autoAnnotationMergePreview.value = null; autoAnnotationReviewChanges.value = []; void restoreAutoAnnotation(); });
watch(workflow, (next, previous) => { searchText.value = ""; if (next === "semantic" && previous !== "semantic") semanticSection.value = "classification"; selectWorkflowCase(); });
watch(semanticSection, (section) => { if (section !== "classification") showRunForm.value = false; });
onMounted(async () => { await store.loadPointCloud(); selectWorkflowCase(); await loadVector(); await refreshAnnotationData(); await refreshSemanticModels(); await restoreAutoAnnotation(); });
onBeforeUnmount(() => { stopPhotoPolling(); stopAnnotationSourcePolling(); stopTrainingPolling(); stopInferencePolling(); stopAutoAnnotationPolling(); });
</script>
 
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <a-tabs v-model:active-key="workflow" class="pointcloud-workflow-tabs">
    <a-tab-pane key="photo" tab="照片三维重建" />
    <a-tab-pane key="geometry" tab="点云几何处理" />
    <a-tab-pane key="semantic" tab="点云语义分类" />
  </a-tabs>
  <a-modal v-model:open="annotationSourceModalOpen" title="新增标注源" :confirm-loading="annotationSourceSubmitting" :ok-button-props="{ disabled: !annotationSourceReady }" ok-text="上传并生成预览" @ok="createAnnotationSource">
    <a-radio-group v-model:value="annotationSourceMode" button-style="solid">
      <a-radio-button value="pointcloud">普通点云</a-radio-button>
      <a-radio-button value="texturedMesh">纹理网格</a-radio-button>
    </a-radio-group>
    <template v-if="annotationSourceMode === 'pointcloud'">
      <p>上传 PLY、PCD、XYZ、LAS 或 LAZ。系统会按原始字节保存文件,在后台生成独立 RGB/XYZ 标注预览,不会改写已有数据源。</p>
      <a-upload accept=".ply,.pcd,.xyz,.xyzn,.xyzrgb,.las,.laz" :file-list="annotationSourceFile ? [{ uid: annotationSourceFile.name, name: annotationSourceFile.name, status: 'done' as const }] : []" :before-upload="beforeAnnotationSourceUpload" @remove="removeAnnotationSourceUpload"><a-button><UploadOutlined />选择点云文件</a-button></a-upload>
    </template>
    <template v-else>
      <p>适用于 PLY 内已声明 UV 和 TextureFile 的纹理三角网格。请选择同一导出目录中的一个 PLY 与全部 JPG/PNG 纹理图;系统逐项核对文件名后,按三角面中心采样纹理颜色生成独立 RGB/XYZ 标注源。</p>
      <p>普通航拍照片、没有相机位姿的照片不能用于此入口。生成点不是原始顶点,不会制造语义真值。</p>
      <a-upload accept=".ply" :file-list="annotationSourceFile ? [{ uid: annotationSourceFile.name, name: annotationSourceFile.name, status: 'done' as const }] : []" :before-upload="beforeAnnotationSourceUpload" @remove="removeAnnotationSourceUpload"><a-button><FileOutlined />选择纹理网格 PLY</a-button></a-upload>
      <a-upload multiple accept=".jpg,.jpeg,.png" :file-list="annotationTextureFiles.map((file, index) => ({ uid: `${file.name}-${index}`, name: file.name, status: 'done' as const }))" :before-upload="beforeAnnotationTextureUpload" @remove="removeAnnotationTextureUpload"><a-button><FileImageOutlined />选择全部纹理图</a-button></a-upload>
    </template>
  </a-modal>
  <a-modal v-model:open="inferenceInputModalOpen" title="选择待预测输入" :confirm-loading="inferenceSubmitting" :ok-button-props="{ disabled: !selectedSemanticModelId || !inferenceInputReady }" ok-text="开始应用模型" @ok="applySemanticModel">
    <a-radio-group v-model:value="inferenceInputMode" button-style="solid">
      <a-radio-button value="pointcloud">普通点云</a-radio-button>
      <a-radio-button value="texturedMesh">纹理网格</a-radio-button>
    </a-radio-group>
    <template v-if="inferenceInputMode === 'pointcloud'">
      <p>选择已有真实逐点 RGB 的 PLY、PCD、LAS 或 LAZ 点云。XYZ-only 输入会被明确拒绝,不会伪造颜色特征。</p>
      <a-upload accept=".ply,.pcd,.las,.laz" :file-list="inferenceFile ? [{ uid: inferenceFile.name, name: inferenceFile.name, status: 'done' as const }] : []" :before-upload="beforeInferenceUpload" @remove="removeInferenceUpload"><a-button><UploadOutlined />选择点云文件</a-button></a-upload>
    </template>
    <template v-else>
      <p>选择一个在 PLY 内声明 UV 与 TextureFile 的纹理三角网格,以及同一导出目录下全部 JPG/JPEG/PNG 纹理图。系统会严格核对文件名,先在 CPU 烘焙为 RGB/XYZ 面中心点云,再调用训练模型。</p>
      <p>烘焙点不是原始网格顶点;普通航拍照片或没有匹配 TextureFile 的图片不能用于此入口。</p>
      <a-upload accept=".ply" :file-list="inferenceFile ? [{ uid: inferenceFile.name, name: inferenceFile.name, status: 'done' as const }] : []" :before-upload="beforeInferenceUpload" @remove="removeInferenceUpload"><a-button><FileOutlined />选择纹理网格 PLY</a-button></a-upload>
      <a-upload multiple accept=".jpg,.jpeg,.png" :file-list="inferenceTextureFiles.map((file, index) => ({ uid: `${file.name}-${index}`, name: file.name, status: 'done' as const }))" :before-upload="beforeInferenceTextureUpload" @remove="removeInferenceTextureUpload"><a-button><FileImageOutlined />选择全部纹理图片</a-button></a-upload>
    </template>
  </a-modal>
  <a-modal v-model:open="annotationClassModalOpen" title="管理标签分类" :footer="null" width="760px" wrap-class-name="annotation-class-modal">
    <a-alert class="annotation-class-note" type="info" show-icon message="颜色只用于工作台显示" description="修改颜色不会改变类别编码、已保存标注、训练模型、历史预测文件或自动标注候选。" />
    <section class="annotation-class-section">
      <div class="annotation-class-section-heading"><h3>新增分类</h3><span>系统会分配未占用的 LAS 兼容编码</span></div>
      <div class="annotation-class-create">
        <a-input v-model:value="annotationClassLabel" placeholder="中文名称,例如:变压器" />
        <a-input v-model:value="annotationClassKey" placeholder="英文 key,例如:transformer" />
        <label class="annotation-color-field"><span>显示颜色</span><input v-model="annotationClassColor" aria-label="新分类颜色" type="color" class="annotation-color-input" /></label>
        <a-button type="primary" :loading="annotationClassSubmitting" :disabled="!annotationClassLabel.trim() || !annotationClassKey.trim()" @click="createAnnotationClass"><PlusOutlined />新增分类</a-button>
      </div>
    </section>
    <section class="annotation-class-section annotation-class-existing">
      <div class="annotation-class-section-heading"><h3>现有分类</h3><span>内置类别不可删除,但可调整显示颜色</span></div>
      <a-list size="small" bordered :data-source="annotationClasses" class="annotation-class-list"><template #renderItem="{ item }"><a-list-item><div class="annotation-class-row"><div class="annotation-class-identity"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" /><div><strong>{{ item.label }}</strong><a-typography-text type="secondary">{{ item.key }} / {{ item.code }}</a-typography-text></div></div><div class="annotation-class-row-actions"><label class="annotation-color-field"><span>显示颜色</span><input v-model="annotationClassColorDrafts[item.code]" :aria-label="`${item.label} 显示颜色`" type="color" class="annotation-color-input" /></label><a-button size="small" :loading="annotationClassColorSavingCode === item.code" @click="updateAnnotationClassColor(item.code)">保存颜色</a-button><a-popconfirm v-if="!item.builtIn" title="删除后无法恢复这个未使用的分类,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotationClass(item.code)"><a-button size="small" danger :loading="annotationClassDeletingCode === item.code">删除</a-button></a-popconfirm><a-tag v-else>内置编码</a-tag></div></div></a-list-item></template></a-list>
    </section>
  </a-modal>
  <a-modal v-model:open="annotationSourceRemovalOpen" title="移除完整数据链路" ok-text="移除全部关联数据" ok-type="danger" cancel-text="取消" :confirm-loading="annotationSourceRemoving" @ok="removeAnnotationSourceChain">
    <a-alert type="error" show-icon message="此操作不可恢复" description="将删除本地工作台中该数据源的全部关联副本、结果和依赖产物;不会删除 baseData 或其他外部输入。" />
    <a-descriptions v-if="annotationSourceRemovalPlan" class="annotation-removal-summary" size="small" :column="1" bordered>
      <a-descriptions-item label="数据源">{{ annotationSourceRemovalPlan.label }}</a-descriptions-item>
      <a-descriptions-item label="生成结果目录">{{ annotationSourceRemovalPlan.outputDirectories }}</a-descriptions-item>
      <a-descriptions-item label="原始上传副本">{{ annotationSourceRemovalPlan.rawDirectories }}</a-descriptions-item>
      <a-descriptions-item label="处理副本">{{ annotationSourceRemovalPlan.processedDirectories }}</a-descriptions-item>
      <a-descriptions-item label="标注版本">{{ annotationSourceRemovalPlan.annotationRevisions }}</a-descriptions-item>
      <a-descriptions-item label="训练结果">{{ annotationSourceRemovalPlan.trainingRuns }}</a-descriptions-item>
      <a-descriptions-item label="模型推理结果">{{ annotationSourceRemovalPlan.inferenceRuns }}</a-descriptions-item>
      <a-descriptions-item v-if="annotationSourceRemovalPlan.siblingSources > 1" label="同一运行的其他标注源">{{ annotationSourceRemovalPlan.siblingSources - 1 }} 个,也会一并移除</a-descriptions-item>
    </a-descriptions>
    <p v-if="annotationSourceRemovalPlan?.preservesExternalInputs" class="annotation-removal-note">该来源没有由控制台保存的原始上传副本;仅移除当前工作台生成的结果和关联产物,外部输入保持不变。</p>
  </a-modal>
  <section class="workspace-command pointcloud-command">
    <div v-if="workflow === 'photo'"><h2>新建照片三维重建</h2><p>上传同一架次、同一相机的 JPG/JPEG 序列,在 CPU 上生成稠密点云、网格与纹理预览。</p></div>
    <div v-else-if="workflow === 'geometry'"><h2>新建点云几何处理</h2><p>上传已有点云或重建导出物,生成 DSM、高出地物足迹、栅格和几何结果。</p></div>
    <div v-else-if="workflow === 'semantic'"><h2>{{ { classification: '点云语义分类', annotation: '标注工作台', auto_annotation: '自动标注复核', training: '训练数据集', models: '模型中心', tasks: '任务与记录' }[semanticSection] }}</h2><p>{{ semanticSection === 'classification' ? '先选择规则初筛或已训练模型;两条路线都输出待人工复核的分类候选。' : '点云分类、人工复核、训练数据和模型应用在独立步骤中完成,避免混杂操作。' }}</p></div>
    <a-button v-if="workflow !== 'semantic' || semanticSection === 'classification'" type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
  </section>
  <section v-if="showRunForm && (workflow !== 'semantic' || semanticSection === 'classification')" class="surface-section pointcloud-run-form">
    <template v-if="workflow === 'photo'">
      <a-alert type="info" show-icon message="单次选择 3 至 1000 张同一架次、同一相机的 JPG/JPEG。原图按字节保存,后台异步执行稀疏 SfM、CPU 稠密点云、网格和纹理;大批量照片可能需要数小时或更久。有每张照片 GPS/RTK 时再打开下方先验。" />
      <a-upload multiple accept=".jpg,.jpeg" :file-list="photoFiles.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforePhotoUpload" @remove="removePhotoFile"><a-button><UploadOutlined />选择重建照片</a-button></a-upload>
      <a-switch v-model:checked="usePhotoPositionPriors" checked-children="GPS/RTK 先验" un-checked-children="通用配对" />
    </template>
    <template v-else-if="workflow === 'semantic'">
      <a-radio-group v-model:value="semanticClassificationRoute" button-style="solid">
        <a-radio-button value="rule">规则初筛</a-radio-button>
        <a-radio-button value="model">已训练模型</a-radio-button>
      </a-radio-group>
      <template v-if="semanticClassificationRoute === 'rule'">
        <a-alert type="info" show-icon message="规则初筛适用于尚未积累足够人工标注时。单次最多 2 份 PLY/PCD/XYZ/LAS/LAZ,输出仅为需要人工复核的规则候选。" />
        <a-upload multiple accept=".ply,.pcd,.xyz,.xyzn,.xyzrgb,.las,.laz" :file-list="files.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforeUpload" @remove="removeFile"><a-button><UploadOutlined />选择点云</a-button></a-upload>
      </template>
      <template v-else>
        <a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚无可用训练模型" description="先在“标注、训练与模型应用”中保存人工标注版本并完成监督训练;规则初筛仍可作为当前数据的起点。" />
        <template v-else>
          <a-alert type="info" show-icon message="已训练模型分类会使用历史人工确认样本,对新点云生成待复核候选;误检、漏检和新场景样本应回流标注与集中训练。" />
          <a-space wrap class="model-inference-controls">
            <a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" />
            <a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" />
            <a-button type="primary" :disabled="!selectedSemanticModelId" @click="openModelInference"><UploadOutlined />选择预测输入</a-button>
          </a-space>
          <a-alert v-if="selectedSemanticModel" class="model-inference-model" type="info" show-icon :message="`所选模型测试 F1:${Object.entries(selectedSemanticModel.testF1).map(([key, score]) => `${key} ${score.toFixed(3)}`).join(';') || '未记录可用测试指标'}`" description="测试指标只代表训练时保留的空间分块,不能替代新场景人工复核。" />
        </template>
      </template>
    </template>
    <template v-else>
      <a-alert type="info" show-icon message="单次最多 2 份 PLY/PCD/XYZ/LAS/LAZ。源文件会按字节保存到独立运行目录,并输出 DSM、高出地物栅格和 GeoAI 足迹矢量。" />
      <a-upload multiple accept=".ply,.pcd,.xyz,.xyzn,.xyzrgb,.las,.laz" :file-list="files.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforeUpload" @remove="removeFile"><a-button><UploadOutlined />选择点云</a-button></a-upload>
    </template>
    <a-alert v-if="runError" type="error" show-icon :message="runError" />
    <a-button v-if="workflow === 'photo'" type="primary" :loading="photoSubmitting" :disabled="photoFiles.length < 3" @click="submitPhotoReconstruction"><PlayCircleOutlined />开始照片重建</a-button>
    <a-button v-else-if="workflow === 'semantic' && semanticClassificationRoute === 'rule'" type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />开始规则初筛</a-button>
    <a-button v-else-if="workflow === 'geometry'" type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />开始点云几何处理</a-button>
  </section>
  <a-alert v-if="photoJob" class="pointcloud-photo-job" :type="photoJob.status === 'failed' ? 'error' : photoJob.status === 'complete' ? 'success' : 'info'" show-icon :message="photoJobMessage">
    <template #description><div class="photo-job-progress"><a-progress :percent="photoJobProgress.percent" :status="photoJob.status === 'failed' ? 'exception' : photoJob.status === 'complete' ? 'success' : 'active'" /><span>{{ photoJobProgress.message }}</span><small>阶段里程碑进度,不代表剩余时间;原生 COLMAP/OpenMVS 在单个计算节点内不提供可靠的细粒度百分比。</small></div></template>
  </a-alert>
 
  <template v-if="currentCase && selectedCloud">
    <a-row v-if="workflow === 'geometry'" :gutter="[18, 18]" class="pointcloud-workspace">
      <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></a-list-item></template></a-list></section></a-col>
      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="(currentCase.run.point_clouds ?? []).map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedCloud.raster_width }} x {{ selectedCloud.raster_height }} 本地坐标栅格</span></div><div class="comparison-grid pointcloud-images"><figure><figcaption>DSM 与高出地物栅格</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.preview_file}`)" /></figure><figure><figcaption>高出地物足迹矢量</figcaption><svg class="pointcloud-vector" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg></figure></div></section></a-col>
    </a-row>
    <section v-if="workflow === 'semantic'" class="semantic-workspace-shell">
      <aside class="semantic-workspace-nav"><PointCloudSemanticNavigation :active="semanticSection" :annotation-count="annotations.length" :model-count="semanticModels.length" :active-task-count="activeSemanticTaskCount" @change="semanticSection = $event" /></aside>
      <main class="semantic-workspace-content">
    <section v-if="semanticSection === 'classification' && selectedCloud.semantic_preview_point_cloud" class="result-band">
      <a-row :gutter="[18, 18]" class="pointcloud-workspace">
        <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>语义分类案例</h2><a-input-search v-model:value="searchText" placeholder="搜索分类案例" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></a-list-item></template></a-list></section></a-col>
        <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>语义分类结果</h2><p>{{ selectedCloud.semantic_method }}。颜色:棕色地面、绿色植被、橙色构筑物、紫色杆塔候选、黄色电线候选、灰色未知。</p></div><a-tag color="gold">人工核验候选</a-tag></div><SparsePointCloudViewer :source="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" /><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item v-for="(count, key) in selectedCloud.semantic_class_counts" :key="key" :label="String(key)">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions></section></a-col>
      </a-row>
      <section class="surface-section result-band"><div class="section-heading"><div><h2>分类结果与下载</h2><p>{{ currentCase.note }}</p></div></div><a-space wrap><a-button v-if="selectedCloud.semantic_method?.startsWith('已训练模型')" type="primary" @click="startSemanticCaseReview"><PlayCircleOutlined />开始人工复核</a-button><a-button v-if="selectedCloud.semantic_preview_point_cloud" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" target="_blank"><DownloadOutlined />语义预览 PLY</a-button><a-button v-if="selectedCloud.semantic_classified_las" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_classified_las}`)" target="_blank"><DownloadOutlined />语义分类 LAS</a-button><a-button v-if="selectedCloud.semantic_summary_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_summary_file}`)" target="_blank"><FileOutlined />语义统计 CSV</a-button><a-button v-if="selectedCloud.semantic_vector_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_vector_file}`)" target="_blank"><FileOutlined />语义候选 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
    </section>
    <section v-if="semanticSection === 'tasks'" class="surface-section">
      <div class="section-heading"><div><h2>任务与记录</h2><p>查看本机任务状态、失败信息和模型/推理/标注产物校验结果。</p></div><a-tag color="blue">本机任务</a-tag></div>
      <PointCloudTaskList />
      <a-divider />
      <a-descriptions v-if="artifactHealth" size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="通过校验">{{ artifactHealth.healthy }}</a-descriptions-item><a-descriptions-item label="异常产物">{{ artifactHealth.invalid }}</a-descriptions-item></a-descriptions>
      <a-alert v-for="item in artifactHealth?.records.filter((record) => record.status === 'invalid')" :key="`${item.kind}-${item.id}`" type="error" show-icon :message="`${item.kind} / ${item.id}`" :description="[...item.missing, ...item.errors].join(';')" />
      <a-empty v-if="artifactHealth && !artifactHealth.invalid" description="当前发现的点云产物均通过校验" />
    </section>
    <section v-if="semanticSection === 'models' || (['annotation', 'auto_annotation', 'training'].includes(semanticSection) && annotationSource)" class="surface-section result-band">
      <div v-if="semanticSection === 'models'" class="section-heading"><div><h2>模型中心</h2><p>选择本机已完成的模型,核验其训练指标与产物状态,再用于新的 RGB 点云或纹理网格预测。模型输出始终需要人工复核。</p></div><a-tag color="blue">自动 GPU / CPU</a-tag></div>
      <div v-else class="section-heading"><div><h2>人工标注与监督训练</h2><p>仅保存你刷选确认的真值点;规则候选颜色不会写入训练标签。融合标注使用原始 RGB/XYZ 与照片特征的局部注意力基线,当前不冒充为官方 Point Transformer V3。任务会自动探测并优先使用本机可用 GPU,未通过检测时回退 CPU。</p></div><a-tag color="blue">自动 GPU / CPU</a-tag></div>
      <template v-if="['annotation', 'auto_annotation', 'training'].includes(semanticSection)">
        <div class="annotation-source-row">
          <div class="annotation-source-field annotation-source-field-primary"><span>标注源</span><a-select v-model:value="annotationSourceId" :options="annotationSources.map((item) => ({ value: item.id, label: item.label }))" /></div>
          <a-tooltip title="移除选中数据源的完整数据链路"><a-button danger :loading="annotationSourceRemovalLoading" :disabled="!annotationSourceId" aria-label="移除完整数据链路" @click="openAnnotationSourceRemoval"><DeleteOutlined /></a-button></a-tooltip>
          <div class="annotation-source-field"><span>计算设备</span><a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" /></div>
        </div>
      </template>
      <template v-if="semanticSection === 'annotation'">
      <div class="annotation-workbench-controls">
        <div class="annotation-management-actions"><a-button @click="annotationSourceModalOpen = true"><PlusOutlined />新增标注源</a-button><a-button @click="openAnnotationClassModal">管理标签分类</a-button></div>
        <div class="annotation-class-legend"><span>标注分类</span><a-tag v-for="item in annotationClasses" :key="item.code"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" />{{ item.label }}</a-tag></div>
      </div>
      <a-alert type="info" show-icon :message="`标注底图:${annotationSource!.sourceKind}`" :description="annotationSourceDescription" />
      <a-alert v-if="annotationNotice" :type="annotationNotice.type" show-icon :message="annotationNotice.message" />
      <a-alert v-if="annotationSourceJob" :type="annotationSourceJob.status === 'failed' ? 'error' : annotationSourceJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`标注源处理任务:${annotationSourceJob.status}`" :description="annotationSourceJob.error || `正在处理 ${annotationSourceJob.inputName};原始文件会保留,完成后生成独立可标注预览。`" />
      <PointCloudAnnotationViewer :source="annotationSource!.url" :source-id="annotationSource!.id" :classes="annotationClasses" :disabled="annotationSaving" @save="saveAnnotation" />
      </template>
      <template v-if="semanticSection === 'auto_annotation'">
      <section class="model-inference-result auto-annotation-workspace">
        <div class="section-heading"><div><h3>自动标注当前源</h3><p>直接对当前标注源生成模型候选,无需再次上传点云。仅保留达到置信度阈值的预测;生成完成后必须确认合并,人工标签始终优先。</p></div><a-tag color="gold">候选待确认</a-tag></div>
        <a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚无可用于自动标注的已完成模型" description="先从一个人工标注版本启动训练,训练完成后模型会自动出现在此处。" />
        <a-space v-else wrap class="model-inference-controls">
          <a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" />
          <SemanticModelDeletionControl :model-id="selectedSemanticModelId" :label="selectedSemanticModel?.label ?? selectedSemanticModelId" :disabled="!selectedSemanticModelId" @removed="removeSemanticModel" />
          <label>最低置信度 <a-input-number v-model:value="autoAnnotationThreshold" :min="0.5" :max="0.999" :step="0.01" :precision="3" /></label>
          <a-button type="primary" :loading="autoAnnotationSubmitting" :disabled="!selectedSemanticModelId" @click="startAutoAnnotation"><PlayCircleOutlined />自动标注当前源</a-button>
        </a-space>
        <PointCloudModelCard :model="selectedSemanticModel" :health="artifactHealth" />
        <a-alert v-if="autoAnnotationJob" :type="autoAnnotationJob.status === 'failed' ? 'error' : autoAnnotationJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`自动标注任务:${autoAnnotationJob.status}(${executionLabel(autoAnnotationJob)})`" :description="autoAnnotationJob.error || (autoAnnotationJob.status === 'complete' ? `已生成高置信度候选,尚未写入人工训练标注。` : `正在对当前 ${annotationSource!.pointCount.toLocaleString()} 点源进行后台推理。`)" />
        <template v-if="autoAnnotationJob?.status === 'complete'">
          <a-descriptions v-if="autoAnnotationSummary" size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item label="输入点数">{{ autoAnnotationSummary.input_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="候选阈值">{{ autoAnnotationSummary.automatic_annotation?.candidate_confidence }}</a-descriptions-item><a-descriptions-item label="高置信候选">{{ autoAnnotationSummary.automatic_annotation?.candidate_count?.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in autoAnnotationSummary.class_counts" :key="String(code)" :label="`${annotationClassDisplayLabel(code)}:预测总数`">{{ count.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in autoAnnotationSummary.automatic_annotation?.candidate_class_counts" :key="`candidate-${String(code)}`" :label="`${annotationClassDisplayLabel(code)}:高置信候选`">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions>
          <a-alert v-if="autoAnnotationMergePreview" type="info" show-icon :message="`合并预览:最终将保存 ${autoAnnotationMergePreview.finalLabelCount.toLocaleString()} 个标签点`" :description="`高置信候选 ${autoAnnotationMergePreview.highConfidenceCandidateCount.toLocaleString()};已拒绝 ${autoAnnotationMergePreview.rejectedCandidateCount.toLocaleString()};已改类 ${autoAnnotationMergePreview.reclassifiedCandidateCount.toLocaleString()};人工标签覆盖 ${autoAnnotationMergePreview.baseHumanOverrideCount.toLocaleString()}。最终类别:${Object.entries(autoAnnotationMergePreview.classCounts).map(([code, count]) => `${annotationClassDisplayLabel(code)} ${count.toLocaleString()}`).join(';') || '无'}。`" />
          <section v-if="autoAnnotationJob.preview" class="auto-annotation-preview"><div class="section-heading"><div><h3>候选审阅与修正</h3><p>选择“拒绝候选”后用笔刷或框选剔除错分点;选择正确类别可改类。保存的修正独立于人工标注,最终合并时人工标注仍优先。</p></div><a-tag color="gold">未合并</a-tag></div><a-space wrap class="annotation-management-row"><a-tag v-for="item in annotationClasses.filter((item) => autoAnnotationSummary?.class_counts?.[String(item.code)] !== undefined)" :key="`legend-${item.code}`"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" />{{ item.label }}</a-tag></a-space><PointCloudAnnotationViewer :source="artifactUrl(autoAnnotationJob.preview)" :source-id="annotationSource!.id" :classes="annotationClasses" :review-schema="autoAnnotationReviewSchema" :review-mode="true" :initial-review-changes="autoAnnotationReviewChanges" :disabled="autoAnnotationReviewSaving" @review-save="saveAutoAnnotationReview" /></section>
          <a-space wrap><a-button v-if="autoAnnotationJob.preview" type="link" :href="artifactUrl(autoAnnotationJob.preview)" target="_blank"><DownloadOutlined />预测预览 PLY</a-button><a-button v-if="autoAnnotationJob.candidateFile" type="link" :href="artifactUrl(autoAnnotationJob.candidateFile)" target="_blank"><FileOutlined />候选与置信度 JSON</a-button><a-popconfirm title="确认后会创建新的标注版本,并把高置信候选与当前人工标注合并;人工标签优先。" ok-text="确认合并" cancel-text="取消" @confirm="acceptAutoAnnotation"><a-button type="primary" :loading="autoAnnotationAccepting">确认合并候选</a-button></a-popconfirm></a-space>
        </template>
      </section>
      </template>
      <template v-if="semanticSection === 'training'">
      <a-divider />
      <div class="section-heading"><div><h3>当前标注源的已保存版本</h3><p>切换标注源后只显示对应版本。至少两个类别、每类 500 个用户确认点后可启动训练;融合样本还要求每个类别覆盖训练、验证和测试 XY 区域,避免相邻线路或塔体点泄漏到测试集。</p></div></div>
      <a-alert v-if="!selectedSourceAnnotations.length" type="warning" show-icon message="当前标注源尚未保存标注版本" description="先选择类别,用笔刷或框选标出确认点,然后点击点云面板右下角的“保存标注版本”。保存成功后可从这里启动单源训练。" />
      <a-list v-else size="small" :data-source="selectedSourceAnnotations"><template #renderItem="{ item }"><a-list-item><a-space wrap><span>{{ item.id }}</span><span>{{ annotationSourceLabel(item.sourceId) }}</span><span>{{ item.labelCount.toLocaleString() }} 点</span><a-button size="small" type="primary" @click="startTraining([item.id])">{{ annotationTrainerLabel(item) }}</a-button><a-popconfirm title="删除后不能恢复该标注版本,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotation(item.id)"><a-tooltip title="删除标注版本"><a-button size="small" danger :loading="annotationDeletingId === item.id" aria-label="删除标注版本"><DeleteOutlined /></a-button></a-tooltip></a-popconfirm></a-space></a-list-item></template></a-list>
      <a-divider />
      <div class="section-heading"><div><h3>集中训练数据集</h3><p>每个 RGB/XYZ 标注源选择一个不可变版本。系统按来源分别进行空间 XY 划分,再汇总训练,避免同一来源的相邻点泄漏到验证或测试。多视角照片特征版本仍使用独立训练流程,不能与普通 RGB/XYZ 源混合。</p></div></div>
      <template v-if="concentratedTrainingOptions.length >= 2">
        <div class="concentrated-training-controls"><a-select v-model:value="concentratedAnnotationIds" mode="multiple" :options="concentratedTrainingOptions" placeholder="选择至少两个不同标注源的已保存版本" /><a-button type="primary" :disabled="!concentratedTrainingReady" @click="startConcentratedTraining"><PlayCircleOutlined />集中训练</a-button></div>
        <a-alert type="info" show-icon message="训练集校验" :description="concentratedTrainingReady ? `已选择 ${concentratedAnnotationIds.length} 个不同数据源版本。训练前仍会校验来源校验和、RGB 特征、类别映射、每类至少 500 点以及全部类别覆盖训练/验证/测试。` : '请选择至少两个不同 RGB/XYZ 标注源的已保存版本;同一数据源只能选择一个版本。'" />
      </template>
      <a-alert v-else type="warning" show-icon message="集中训练暂不可用" :description="`当前只有 ${concentratedTrainingOptions.length} 个具备已保存版本的 RGB/XYZ 标注源;至少需要两个不同源。先新增并保存另一个 RGB/XYZ 标注源的人工标注版本。`" />
      <a-alert v-if="trainingJob" :type="trainingJob.status === 'failed' ? 'error' : trainingJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`训练任务:${trainingJob.status}(${executionLabel(trainingJob)})`" :description="trainingJob.error || (trainingJob.preview ? `已生成预测预览:${trainingJob.preview}` : `后台${executionLabel(trainingJob)}训练中,可继续浏览案例。`)" />
      </template>
      <template v-if="semanticSection === 'models'">
      <a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚未发现可用训练模型。先完成并保留一次监督训练。" />
      <template v-else>
      <section class="model-application-band">
        <div class="model-band-heading"><div><h3>选择模型与预测输入</h3><p>支持普通 RGB 点云,或 PLY 加其全部纹理图片的纹理网格;纹理网格会先转换为 RGB 面中心点云。</p></div></div>
        <div class="model-inference-controls">
          <div class="model-control-field"><span>已训练模型</span><a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" /></div>
          <a-tooltip title="移除选中模型及其本机推理产物"><SemanticModelDeletionControl :model-id="selectedSemanticModelId" :label="selectedSemanticModel?.label ?? selectedSemanticModelId" :disabled="!selectedSemanticModelId" @removed="removeSemanticModel" /></a-tooltip>
          <div class="model-control-field"><span>计算设备</span><a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" /></div>
          <a-button type="primary" :disabled="!selectedSemanticModelId" @click="openModelInference"><UploadOutlined />选择预测输入</a-button>
        </div>
      </section>
      <section class="model-details-band">
        <div class="model-band-heading"><div><h3>当前模型信息</h3><p>测试指标来自训练时保留的空间分块,不能代表新场景准确率。</p></div></div>
        <PointCloudModelCard :model="selectedSemanticModel" :health="artifactHealth" />
      </section>
      </template>
      <a-alert v-if="inferenceJob" :type="inferenceJob.status === 'failed' ? 'error' : inferenceJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`模型应用任务:${inferenceJob.status}(${executionLabel(inferenceJob)})`" :description="inferenceJob.error || (inferenceJob.status === 'complete' ? `已完成 ${inferenceJob.inputName} 的分类候选。` : `后台${executionLabel(inferenceJob)}推理中,可继续浏览案例。`)" />
      <section v-if="inferenceJob?.status === 'complete' && inferenceJob.preview" class="model-inference-result">
        <SparsePointCloudViewer :source="artifactUrl(inferenceJob.preview)" />
        <a-descriptions v-if="inferenceSummary" size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item label="输入点数">{{ inferenceSummary.input_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="预览点数">{{ inferenceSummary.preview_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in inferenceSummary.class_counts" :key="String(code)" :label="String(code)">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions>
        <a-space wrap><a-button type="link" :href="artifactUrl(inferenceJob.preview)" target="_blank"><DownloadOutlined />预测预览 PLY</a-button><a-button v-if="inferenceJob.classifiedLas" type="link" :href="artifactUrl(inferenceJob.classifiedLas)" target="_blank"><DownloadOutlined />分类 LAS</a-button><a-button v-if="inferenceJob.classCounts" type="link" :href="artifactUrl(inferenceJob.classCounts)" target="_blank"><FileOutlined />类别统计 CSV</a-button><a-button v-if="inferenceJob.summary" type="link" :href="artifactUrl(inferenceJob.summary)" target="_blank"><FileOutlined />预测摘要 JSON</a-button><a-button v-if="inferenceJob.metadata" type="link" :href="artifactUrl(inferenceJob.metadata)" target="_blank"><FileOutlined />运行元数据</a-button><a-button v-if="selectedSemanticModel" type="link" :href="artifactUrl(selectedSemanticModel.model)" target="_blank"><DownloadOutlined />模型权重</a-button></a-space>
      </section>
      </template>
    </section>
    <section v-else-if="['annotation', 'auto_annotation', 'training'].includes(semanticSection)" class="surface-section"><a-empty description="请先新增或选择一个可用标注源" /><a-button type="primary" @click="annotationSourceModalOpen = true"><PlusOutlined />新增标注源</a-button></section>
      </main>
    </section>
    <a-row v-if="workflow === 'geometry'" :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><div class="section-heading"><div><h2>点云与几何结果</h2><p>坐标为点云本地坐标;不是经纬度或测绘精度结果。</p></div><a-tag color="orange">{{ selectedCloud.elevated_footprint_count }} 个足迹</a-tag></div><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="原始点">{{ selectedCloud.original_points }}</a-descriptions-item><a-descriptions-item label="降采样点">{{ selectedCloud.downsampled_points }}</a-descriptions-item><a-descriptions-item label="地面内点">{{ selectedCloud.ground_inliers }}</a-descriptions-item><a-descriptions-item label="高出点">{{ selectedCloud.elevated_points }} ({{ (selectedCloud.elevated_point_ratio * 100).toFixed(1) }}%)</a-descriptions-item><a-descriptions-item label="网格三角形">{{ selectedCloud.mesh_triangles }}</a-descriptions-item><a-descriptions-item label="矢量化">{{ selectedCloud.vectorizer }}</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>固定处理参数</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="体素尺寸">{{ currentCase.run.thresholds.voxel_size_local_units }}</a-descriptions-item><a-descriptions-item label="地面 RANSAC 距离">{{ currentCase.run.thresholds.ground_plane_distance }}</a-descriptions-item><a-descriptions-item label="高出地物阈值">{{ currentCase.run.thresholds.elevated_height }}</a-descriptions-item><a-descriptions-item label="耗时">{{ selectedCloud.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col></a-row>
    <section v-if="workflow === 'geometry'" class="surface-section result-band"><div class="section-heading"><div><h2>几何处理下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 5 }"><a-descriptions-item label="能力边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="Open3D">{{ currentCase.run.versions.open3d }}</a-descriptions-item><a-descriptions-item label="GeoAI">{{ currentCase.run.versions['geoai-py'] }}</a-descriptions-item><a-descriptions-item label="处理点云">{{ currentCase.run.processed_point_clouds }}</a-descriptions-item></a-descriptions><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.classified_point_cloud}`)" target="_blank"><DownloadOutlined />几何分类点云 PLY</a-button><a-button v-if="selectedCloud.mesh_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.mesh_file}`)" target="_blank"><FileOutlined />近似网格 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.dsm_file}`)" target="_blank"><FileImageOutlined />DSM GeoTIFF</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.vector_file}`)" target="_blank"><FileOutlined />高出地物 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.summary_file}`)" target="_blank"><FileOutlined />汇总 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
  </template>
  <template v-else-if="currentCase && densePhotoRun">
    <a-row :gutter="[18, 18]" class="pointcloud-workspace">
      <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></a-list-item></template></a-list></section></a-col>
      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>CPU 稠密重建与纹理网格</h2><p>拖动旋转、滚轮缩放。模型为局部 SfM 坐标,未经过控制点测绘配准。</p></div><a-space><a-tag color="green">CPU MVS</a-tag><a-button type="primary" size="small" :loading="running" @click="classifyDenseResult"><PlayCircleOutlined />对此稠密点云分类</a-button></a-space></div><a-segmented v-if="densePhotoRun.geometry_preview_model_file" v-model:value="densePreviewMode" :options="[{ label: '可靠纹理', value: 'textured' }, { label: '点云颜色', value: 'point_colors', disabled: !densePhotoRun.point_color_preview_model_file }, { label: '完整几何', value: 'geometry' }]" /><TexturedMeshViewer :source="artifactUrl(`${currentCase.artifactRoot}/${densePreviewSource}`)" :surface="densePreviewMode" /></section></a-col>
    </a-row>
    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>重建统计</h2><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="输入照片">{{ densePhotoRun.input_images }}</a-descriptions-item><a-descriptions-item label="注册照片">{{ densePhotoRun.registered_images }}</a-descriptions-item><a-descriptions-item label="稀疏点">{{ densePhotoRun.sparse_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="稠密点">{{ densePhotoRun.dense_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格顶点">{{ densePhotoRun.mesh_vertices.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格面">{{ densePhotoRun.mesh_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.preview_mesh_faces" label="可靠纹理面">{{ densePhotoRun.preview_mesh_faces.toLocaleString() }} ({{ ((densePhotoRun.preview_mesh_face_ratio ?? 0) * 100).toFixed(1) }}%)</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.point_color_preview_faces" label="点云颜色面">{{ densePhotoRun.point_color_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.geometry_preview_faces" label="完整几何面">{{ densePhotoRun.geometry_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.elapsed_seconds !== null" label="耗时">{{ densePhotoRun.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>结果边界</h2><p>可靠纹理只显示有一致照片证据的面,孔洞代表纹理覆盖不足。点云颜色将最近融合点的真实 RGB 投影到完整网格,显示连续表面但不等同逐面照片纹理。完整几何使用中性材质,不补造颜色或纹理;它用于核验几何连续性。</p></section></a-col></a-row>
    <section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.textured_model_file}`)" target="_blank"><DownloadOutlined />可靠纹理 GLB</a-button><a-button v-if="densePhotoRun.point_color_preview_model_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.point_color_preview_model_file}`)" target="_blank"><DownloadOutlined />点云颜色 GLB</a-button><a-button v-if="densePhotoRun.geometry_preview_model_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.geometry_preview_model_file}`)" target="_blank"><DownloadOutlined />完整几何 GLB</a-button><a-button v-if="densePhotoRun.texture_preview_filter_report" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.texture_preview_filter_report}`)" target="_blank"><FileOutlined />纹理过滤报告</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.dense_point_cloud_file}`)" target="_blank"><FileOutlined />稠密点云 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.mesh_file}`)" target="_blank"><FileOutlined />原始网格 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.texture_file}`)" target="_blank"><FileImageOutlined />纹理图</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
  </template>
  <template v-else-if="currentCase && photoRun">
    <a-row :gutter="[18, 18]" class="pointcloud-workspace">
      <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>影像稀疏重建点云</h2><p>顶点颜色点云。鼠标拖动旋转,滚轮缩放;坐标没有绝对尺度或方向。</p></div><a-tag color="blue">CPU SfM</a-tag></div><SparsePointCloudViewer :source="artifactUrl(`${currentCase.artifactRoot}/${photoRun.point_cloud_file}`)" /></section></a-col>
    </a-row>
    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>重建统计</h2><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="输入照片">{{ photoRun.input_images }}</a-descriptions-item><a-descriptions-item label="注册照片">{{ photoRun.registered_images }}</a-descriptions-item><a-descriptions-item label="稀疏点">{{ photoRun.sparse_points }}</a-descriptions-item><a-descriptions-item label="平均重投影误差">{{ photoRun.mean_reprojection_error_pixels }} px</a-descriptions-item><a-descriptions-item label="相机组">{{ photoRun.camera_count }}</a-descriptions-item><a-descriptions-item label="耗时">{{ photoRun.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>坐标与限制</h2><p>本次仅完成稀疏 SfM。当前机器无 CUDA,不能运行 pycolmap 的稠密 PatchMatch;GPS 保留在输入清单中,但未用于宣称测绘级地理配准。</p></section></a-col></a-row>
    <section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.point_cloud_file}`)" target="_blank"><DownloadOutlined />稀疏点云 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.camera_pose_file}`)" target="_blank"><FileOutlined />相机位姿 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.input_manifest_file}`)" target="_blank"><FileOutlined />输入 GPS 清单</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
  </template>
</template>
 
<style scoped>
.pointcloud-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
.pointcloud-workflow-tabs { margin-bottom: 16px; }
.pointcloud-photo-job { margin-bottom: 24px; }
.pointcloud-workspace, .result-band { margin-bottom: 24px; }
.semantic-workspace-shell { display: grid; grid-template-columns: 236px minmax(0, 1fr); align-items: start; gap: 18px; margin-bottom: 24px; }
.semantic-workspace-nav { position: sticky; top: 16px; }
.semantic-workspace-content { min-width: 0; }
.semantic-workspace-content > .surface-section, .semantic-workspace-content > .result-band { margin-bottom: 0; }
.pointcloud-images figure { min-width: 0; }
.pointcloud-images :deep(.ant-image), .pointcloud-images :deep(img) { width: 100%; height: 360px; object-fit: contain; background: #171e1a; }
.pointcloud-vector { width: 100%; height: 360px; border: 1px solid #d9d9d9; background: #f7f8f9; }
.pointcloud-vector path { fill: rgba(230, 90, 45, 0.24); stroke: #bf4c20; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.photo-job-progress { display: grid; gap: 6px; margin-top: 8px; }
.photo-job-progress small { color: #6a7885; }
.model-inference-controls { display: grid; grid-template-columns: minmax(340px, 1fr) auto minmax(240px, 300px) auto; align-items: end; gap: 12px; padding: 12px; border: 1px solid #d8e5df; background: #f7faf8; }
.model-control-field { display: grid; gap: 6px; min-width: 0; color: #53675e; font-size: 12px; font-weight: 600; }
.model-control-field :deep(.ant-select) { width: 100%; }
.model-application-band { margin-top: 16px; }
.model-details-band { margin-top: 16px; padding-top: 16px; border-top: 1px solid #e4ece8; }
.model-band-heading { margin-bottom: 10px; }
.model-band-heading h3 { margin: 0; color: #263a32; font-size: 15px; }
.model-band-heading p { margin: 4px 0 0; color: #687b73; font-size: 13px; line-height: 1.5; }
.model-select { min-width: min(100%, 440px); }
.concentrated-training-controls { display: grid; grid-template-columns: minmax(0, 560px) max-content; align-items: start; gap: 8px; margin-bottom: 12px; }
.concentrated-training-controls :deep(.ant-select) { width: 100%; }
.model-inference-model { margin-bottom: 12px; }
.model-inference-result { display: grid; gap: 16px; margin-top: 16px; }
.auto-annotation-preview { display: grid; gap: 12px; }
.annotation-source-row { display: grid; grid-template-columns: minmax(340px, 1fr) auto minmax(240px, 300px); align-items: end; gap: 12px; margin: 16px 0; padding: 12px; border: 1px solid #d8e5df; background: #f7faf8; }
.annotation-source-field { display: grid; gap: 6px; min-width: 0; color: #53675e; font-size: 12px; font-weight: 600; }
.annotation-source-field :deep(.ant-select) { width: 100%; }
.annotation-source-row > :deep(.ant-btn) { margin-bottom: 0; }
.annotation-workbench-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin: 0 0 16px; padding: 12px; border: 1px solid #d8e5df; background: #fff; }
.annotation-management-actions, .annotation-class-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.annotation-class-legend { flex: 1 1 420px; }
.annotation-class-legend > span { color: #53675e; font-size: 12px; font-weight: 600; white-space: nowrap; }
.annotation-management-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
.annotation-class-note { margin-bottom: 16px; }
.annotation-class-section { padding: 16px; border: 1px solid #d8e5df; background: #f8fbf9; }
.annotation-class-section + .annotation-class-section { margin-top: 16px; }
.annotation-class-section-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 12px; margin-bottom: 12px; }
.annotation-class-section-heading h3 { margin: 0; font-size: 14px; color: #1d392d; }
.annotation-class-section-heading span { color: #687a71; font-size: 12px; }
.annotation-class-create { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(160px, 1fr) max-content max-content; align-items: end; gap: 12px; }
.annotation-color-field { display: grid; gap: 5px; color: #53675e; font-size: 12px; font-weight: 600; white-space: nowrap; }
.annotation-class-existing { background: #fff; }
.annotation-class-list { max-height: 360px; overflow: auto; background: #fff; }
.annotation-class-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; }
.annotation-class-identity, .annotation-class-row-actions { display: flex; align-items: center; gap: 10px; min-width: 0; }
.annotation-class-identity > div { display: grid; gap: 2px; min-width: 0; }
.annotation-class-identity strong { color: #263d32; }
.annotation-class-row-actions { flex-wrap: wrap; justify-content: flex-end; }
.annotation-class-swatch { display: inline-block; width: 14px; height: 14px; border: 1px solid rgba(0, 0, 0, 0.22); vertical-align: -2px; }
.annotation-color-input { width: 34px; height: 32px; padding: 2px; border: 1px solid #d9d9d9; background: #fff; }
.annotation-removal-summary { margin-top: 16px; }
.annotation-removal-note { margin: 12px 0 0; color: #6a7885; }
@media (max-width: 1199px) { .semantic-workspace-shell { grid-template-columns: 1fr; } .semantic-workspace-nav { position: static; } .pointcloud-images :deep(img), .pointcloud-vector { height: 300px; } }
@media (max-width: 980px) { .model-inference-controls { grid-template-columns: minmax(0, 1fr) auto; } .model-inference-controls .model-control-field:last-of-type { grid-column: 1 / -1; } }
@media (max-width: 780px) { .annotation-source-row { grid-template-columns: minmax(0, 1fr) auto; } .annotation-source-row .annotation-source-field:last-child { grid-column: 1 / -1; } }
@media (max-width: 640px) { .concentrated-training-controls { grid-template-columns: minmax(0, 1fr); } .annotation-source-row, .model-inference-controls, .annotation-class-create { grid-template-columns: minmax(0, 1fr); } .annotation-source-row > :deep(.ant-btn), .model-inference-controls > :deep(.ant-btn) { justify-self: start; } .annotation-source-row .annotation-source-field:last-child, .model-inference-controls .model-control-field:last-of-type { grid-column: auto; } .annotation-workbench-controls { align-items: stretch; } .annotation-management-actions, .annotation-class-legend, .annotation-class-row, .annotation-class-row-actions { align-items: flex-start; } .annotation-class-row { flex-direction: column; } .annotation-class-row-actions { justify-content: flex-start; } }
</style>