@dssp/supervision 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
2
  /**
3
- * 검측 관리 상세 - AI 자동 검측
3
+ * 검측 관리 상세 - AI 자동 검측 (동영상 세트 + 측정 모델 2종)
4
4
  *
5
- * 검측 체크리스트 항목을 AI 엔진(inspection-ai)에 보내:
6
- * 1) 촬영 가이드(plan-shots)를 받아 단계별 촬영 슬롯을 표시하고
7
- * 2) 촬영된 사진으로 자동 판정(inspect) 실행한 뒤
8
- * 3) 항목별 상세 / 추가 촬영(재검사) / 종합 리포트를 거쳐
9
- * 4) 검사 등록 감리자 적합/부적합 자동 반영 + AI판단 근거를 첨부한다.
5
+ * 체크리스트 항목을 AI 엔진(inspection-ai)과 측정 서버(measure-engine)연동해:
6
+ * 1) 촬영 가이드(plan-shots, 동영상 최대 3건)를 표시하고
7
+ * 2) LidarInspector 앱이 만든 세트(video.mov/poses.jsonl/meta.json) 업로드하면
8
+ * 3) 세트별 대표 3프레임으로 자동 판정(inspect, input_kind=video_frames) 실행한
9
+ * 4) 「추가 작업 필요」(재촬영/기준값 입력/AI 미지원)를 분류해 측정 UI(철근 배근 길이·기둥벽체 두께)로 처리하고
10
+ * 5) 측정 결과는 apply-measurement로 판정 확정 → 검사 등록 시 감리자 적합/부적합 자동 반영 + 근거 첨부한다.
10
11
  */
11
12
  import '@material/web/icon/icon.js';
12
13
  import '@material/web/progress/circular-progress.js';
@@ -22,20 +23,36 @@ import './component/building-inspection-detail-header';
22
23
  const ENGINE_BASE_URL = 'https://hatiolab-korea-uni.nruing.com';
23
24
  const ENGINE_RUN_URL = `${ENGINE_BASE_URL}/api/run/inspection-ai`;
24
25
  const ENGINE_AUTH = `Basic ${btoa('admin:admin1234')}`;
25
- const MAX_IMAGES = 8;
26
- /* 엔진 verdict(기계값) → 화면 라벨. pass/fail만 합/불로 자동 체크, 나머지(보류/미지원/오류)는 감리자 판단 */
26
+ /* 측정 서버(measure-engine/server.py). 배포 시 실제 주소로 수정 — https 페이지에서는 https(리버스 프록시) 필요 */
27
+ const MEASURE_BASE_URL = 'https://hatiolab-korea-uni.nruing.com/measure';
28
+ const MEASURE_AUTH = ENGINE_AUTH;
29
+ /* 촬영 안내 이미지 (엔진 전달물 고정 그림 + 픽토그램) */
30
+ const captureGuideImg = new URL('../../assets/ai-measurement/capture_guide.png', import.meta.url).href;
31
+ const pictoRebarImg = new URL('../../assets/ai-measurement/picto-rebar-parallel.svg', import.meta.url).href;
32
+ const pictoMemberImg = new URL('../../assets/ai-measurement/picto-member-corner.svg', import.meta.url).href;
33
+ /* 세트 폴더 필수 파일 (LidarInspector 앱이 촬영 1회당 생성) */
34
+ const SET_FILES = ['video.mov', 'poses.jsonl', 'meta.json'];
35
+ /* 엔진 verdict(기계값) → 화면 라벨. pass/fail만 합/불로 자동 체크, 나머지는 감리자 판단 */
27
36
  const VERDICT_LABEL = {
28
37
  pass: '적합',
29
38
  fail: '부적합',
30
- hold: '보류',
31
- pending: '미지원',
32
- error: '오류'
39
+ hold: '재촬영 필요',
40
+ pending: 'AI 미지원',
41
+ error: '분석 오류'
33
42
  };
34
- /* 엔진 routing(기계값) → 화면 라벨 */
43
+ /* 엔진 routing(기계값) → 화면 라벨. cv 계열은 CV팀 명세 고정 문구 "3D 계측" */
35
44
  const ROUTING_LABEL = {
36
45
  vlm: 'AI 사진판독',
37
- cv: '자동 측정',
38
- cv_missing: '자동 측정 누락'
46
+ cv: '3D 계측',
47
+ cv_missing: '3D 계측'
48
+ };
49
+ /* operator_inputs 필드 키 → 한글 라벨 (모르는 키는 키 그대로 노출) */
50
+ const FIELD_LABEL = {
51
+ name: '부재/변 이름',
52
+ member_dim_mm: '설계 단면 치수 (mm)',
53
+ cover_spec_mm: '기준(최소) 피복두께 (mm)',
54
+ design_mm: '설계 두께 (mm)',
55
+ tol_mm: '허용 공차 ± (mm)'
39
56
  };
40
57
  let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement extends PageView {
41
58
  constructor() {
@@ -44,23 +61,33 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
44
61
  this.buildingInspection = {};
45
62
  this.buildingInspectionId = '';
46
63
  this.guide = null; // plan-shots 응답 {shots, total_shots, markdown}
47
- this.photos = [];
64
+ this.sets = []; // 촬영 건별 업로드된 세트 (shots 순서)
65
+ this.photos = []; // inspect에 보낸 대표 프레임(표시/첨부용)
48
66
  this.result = null; // inspect 응답
49
67
  this.mainPhotoIndex = 0;
50
68
  this.panel = 'none';
51
69
  this.selectedItem = null;
52
- this.recaptureBase = 0; // 재촬영 패널 진입 시점의 사진
70
+ this.recaptureBase = 0; // 재검사 진입 시점의 프레임 (used_images 보정용)
71
+ this.recaptureSet = null; // 재촬영으로 새로 올린 세트
53
72
  this.expandedReportId = null; // 종합 리포트에서 펼친 항목(아코디언)
73
+ /* 측정 패널 상태 */
74
+ this.measureSetIndex = 0;
75
+ this.measureInputs = {};
76
+ this.measureRunning = false;
77
+ this.measureMsg = '';
78
+ this.measureResult = null; // 측정 서버 원본 응답 — apply-measurement에 가공 없이 전달
79
+ this.measureImageUrl = ''; // 근거 이미지(오버레이/결과사진)
80
+ this.thicknessFrames = [];
81
+ this.thicknessSlot = null;
82
+ this.thicknessFrameUrl = '';
83
+ this.thicknessClick = null;
54
84
  this.loading = false;
55
85
  this.loadingText = '';
56
86
  /** 뮤테이션 가능 여부('dcsp guest' 권한 보유 시 false) — 게스트에게는 생성/수정/삭제 버튼을 숨긴다 */
57
87
  this.canMutate = false;
58
- /* 슬롯 클릭으로 선택기를 경우 대상 슬롯 인덱스 (null이면 뒤에 추가) */
59
- this.pendingSlotIndex = null;
60
- this._resetPhotos = () => {
61
- this.photos.forEach(p => URL.revokeObjectURL(p.url));
62
- this.photos = [];
63
- };
88
+ /* 세트 업로드 대상: 촬영 인덱스(가이드 화면) 또는 -1(재촬영 패널) */
89
+ this.pendingSetIndex = null;
90
+ this.objectUrls = []; // 페이지 이탈 시 정리할 blob URL들
64
91
  this._openReport = () => {
65
92
  this.panel = 'report';
66
93
  this.selectedItem = null;
@@ -70,6 +97,16 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
70
97
  this.panel = 'none';
71
98
  this.selectedItem = null;
72
99
  };
100
+ /* 클릭 좌표를 표시 크기로 나눠 0~1 정규화 — 원본 해상도는 알 필요 없다 */
101
+ this._onThicknessClick = (e) => {
102
+ if (this.measureRunning)
103
+ return;
104
+ const rect = e.target.getBoundingClientRect();
105
+ this.thicknessClick = {
106
+ x: (e.clientX - rect.left) / rect.width,
107
+ y: (e.clientY - rect.top) / rect.height
108
+ };
109
+ };
73
110
  }
74
111
  async connectedCallback() {
75
112
  super.connectedCallback();
@@ -93,6 +130,9 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
93
130
  text: item.name || ''
94
131
  }));
95
132
  }
133
+ get uploadedSets() {
134
+ return this.sets.filter((s) => !!s);
135
+ }
96
136
  render() {
97
137
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
98
138
  return html `
@@ -114,8 +154,14 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
114
154
  ${this.result ? this._renderResult() : this._renderGuide()}
115
155
  </div>
116
156
 
117
- <!-- 모바일에서 촬영/앨범 선택이 함께 뜨도록 capture 속성 없이 사용 -->
118
- <input id="photoInput" type="file" accept="image/*" multiple @change=${this._onPhotosSelected} />
157
+ <!-- 세트 파일 선택: video.mov/poses.jsonl/meta.json 3개 멀티선택 또는 zip 1개 -->
158
+ <input
159
+ id="setInput"
160
+ type="file"
161
+ accept=".mov,.jsonl,.json,.zip,video/quicktime"
162
+ multiple
163
+ @change=${this._onSetFilesSelected}
164
+ />
119
165
 
120
166
  ${this.loading
121
167
  ? html `
@@ -129,66 +175,107 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
129
175
  : ''}
130
176
  `;
131
177
  }
132
- /* ─── 1단계: 촬영 가이드 화면 ─── */
178
+ /* ─── 1단계: 촬영 가이드 + 세트 업로드 화면 ─── */
133
179
  _renderGuide() {
134
180
  if (!this.guide) {
135
181
  return html `<div class="empty-state">촬영 가이드를 준비 중입니다...</div>`;
136
182
  }
137
183
  const shots = this.guide.shots || [];
138
184
  const total = this.guide.total_shots || shots.length;
139
- const taken = this.photos.length;
140
- const current = shots[Math.min(taken, shots.length - 1)];
141
- const done = taken >= total;
185
+ const uploaded = this.uploadedSets.length;
186
+ const current = shots[Math.min(uploaded, shots.length - 1)];
187
+ const done = uploaded >= total;
142
188
  return html `
143
189
  <div class="guide-container">
144
- <div class="step-badge">${Math.min(taken + 1, total)}단계 · ${(current === null || current === void 0 ? void 0 : current.target) || '촬영'}</div>
190
+ <div class="step-badge">동영상 ${Math.min(uploaded + 1, total)}/${total} · ${(current === null || current === void 0 ? void 0 : current.target) || '촬영'}</div>
145
191
  <div class="guide-title">${(current === null || current === void 0 ? void 0 : current.target) || ''}${done ? ' 촬영 완료' : ''}</div>
146
- <div class="guide-desc">이 사진을 토대로 AI가 적용할 체크리스트를 결정하고 일부를 자동 판정합니다.</div>
192
+ <div class="guide-desc">
193
+ 전용 촬영 앱(LidarInspector)으로 동영상을 촬영한 뒤, 앱이 만든 파일 3개(video.mov·poses.jsonl·meta.json)를 촬영 건마다
194
+ 업로드해주세요. 일반 카메라 동영상은 사용할 수 없습니다.
195
+ </div>
147
196
 
148
197
  <div class="card">
149
- <h4>촬영 가이드</h4>
198
+ <h4>촬영 가이드 (동영상 ${total}건)</h4>
150
199
  <ul>
151
- ${shots.map(s => html `<li>${s.target} — ${s.how}</li>`)}
200
+ ${shots.map(s => html `<li>촬영 ${s.shot_no || ''} · ${s.target} — ${s.how}</li>`)}
152
201
  </ul>
153
202
  </div>
154
203
 
204
+ <div class="card">
205
+ <h4>공통 촬영법 — 옆으로 이동 · 대상 정면 유지 · 대상 전체 포함</h4>
206
+ <img class="capture-guide-img" src=${captureGuideImg} alt="촬영 안내" />
207
+ <div class="picto-strip">
208
+ <img src=${pictoRebarImg} alt="철근: 재려는 변과 나란히" />
209
+ <img src=${pictoMemberImg} alt="기둥·벽: 모서리가 보이게" />
210
+ </div>
211
+ </div>
212
+
155
213
  <div class="card" style="display: flex; flex-direction: column; gap: 10px;">
156
214
  <div class="progress-row">
157
- <span>사진 진행</span>
158
- <span>${taken} / ${total}</span>
215
+ <span>촬영 세트 업로드</span>
216
+ <span>${uploaded} / ${total}</span>
159
217
  </div>
160
- <div class="progress-bar"><div style="width: ${total ? (taken / total) * 100 : 0}%"></div></div>
161
- ${!done ? html `<div class="next-shot">다음 촬영: ${(current === null || current === void 0 ? void 0 : current.how) || (current === null || current === void 0 ? void 0 : current.target) || ''}</div>` : ''}
162
- <div class="slot-grid">
163
- ${shots.map((s, i) => html `
164
- <div class="slot" @click=${() => this._openPhotoPicker(i)}>
165
- ${this.photos[i]
166
- ? html `<img src=${this.photos[i].url} alt=${s.target} />`
167
- : html `<md-icon>add_a_photo</md-icon>${s.target}`}
218
+ <div class="progress-bar"><div style="width: ${total ? (uploaded / total) * 100 : 0}%"></div></div>
219
+ ${shots.map((s, i) => {
220
+ const set = this.sets[i];
221
+ return html `
222
+ <div class="set-card" ?uploaded=${!!set} @click=${() => !set && this._openSetPicker(i)}>
223
+ <md-icon>${set ? 'check_circle' : 'video_file'}</md-icon>
224
+ <div class="set-main">
225
+ <b>촬영 ${s.shot_no || i + 1} · ${s.target}</b>
226
+ ${set
227
+ ? html `세트 업로드 완료${set.technique ? ` (${set.technique})` : ''}
228
+ ${set.warnings.map(w => html `<div class="set-warn">⚠ ${w}</div>`)}`
229
+ : html `탭해서 파일 3개(video.mov·poses.jsonl·meta.json)를 한 번에 선택`}
168
230
  </div>
169
- `)}
170
- </div>
231
+ ${set
232
+ ? html `<button
233
+ class="text-button"
234
+ @click=${(e) => {
235
+ e.stopPropagation();
236
+ this._openSetPicker(i);
237
+ }}
238
+ >
239
+ 교체
240
+ </button>`
241
+ : ''}
242
+ </div>
243
+ `;
244
+ })}
171
245
  </div>
172
246
 
173
- <button class="primary-button" ?disabled=${taken === 0} @click=${() => this._runInspection()}>
174
- 사진 선택 ${taken ? `(${taken}/${total})` : ''}
247
+ <button class="primary-button" ?disabled=${uploaded === 0} @click=${() => this._runInspection()}>
248
+ AI 자동 판정 실행 ${uploaded ? `(세트 ${uploaded}/${total})` : ''}
175
249
  </button>
176
- ${taken > 0
177
- ? html `
178
- <div style="display: flex; justify-content: flex-end;">
179
- <button class="text-button" @click=${this._resetPhotos}>사진 초기화</button>
180
- </div>
181
- `
182
- : ''}
183
250
  </div>
184
251
  `;
185
252
  }
186
253
  /* ─── 2단계: 판정 결과 화면 ─── */
254
+ /* 항목 그룹: auto(판정완료) / recapture(재촬영) / measure(기준값 입력) / manual(AI 미지원·오류) */
255
+ _groupOf(item) {
256
+ if (item.verdict === 'pass' || item.verdict === 'fail')
257
+ return 'auto';
258
+ if (item.verdict === 'hold')
259
+ return 'recapture';
260
+ if (item.verdict === 'pending' && item.pending_reason === 'measure')
261
+ return 'measure';
262
+ return 'manual';
263
+ }
264
+ /* verdict → 화면 라벨 (pending은 pending_reason으로 세분) */
265
+ _verdictLabel(item) {
266
+ if (item.verdict === 'pending') {
267
+ return item.pending_reason === 'measure' ? '기준값 입력 필요' : 'AI 미지원';
268
+ }
269
+ return VERDICT_LABEL[item.verdict] || item.verdict;
270
+ }
187
271
  _renderResult() {
188
272
  const items = this._sortedItems();
189
- const autoItems = items.filter(it => it.verdict === 'pass' || it.verdict === 'fail');
190
- const moreItems = items.filter(it => it.verdict !== 'pass' && it.verdict !== 'fail');
191
- // 클릭한 항목의 used_images만 표시 (선택 없으면 전체 항목 합집합). used_images는 전체 사진 기준 1-based
273
+ const autoItems = items.filter(it => this._groupOf(it) === 'auto');
274
+ const recaptureItems = items.filter(it => this._groupOf(it) === 'recapture');
275
+ const measureItems = items.filter(it => this._groupOf(it) === 'measure');
276
+ const manualItems = items.filter(it => this._groupOf(it) === 'manual');
277
+ const moreCount = recaptureItems.length + measureItems.length + manualItems.length;
278
+ // 클릭한 항목의 used_images만 표시 (선택 없으면 전체 항목 합집합). used_images는 전체 프레임 기준 1-based
192
279
  const focusItems = this.selectedItem ? [this.selectedItem] : items;
193
280
  const usedNos = new Set();
194
281
  for (const it of focusItems)
@@ -196,16 +283,17 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
196
283
  usedNos.add(n);
197
284
  const usedPhotos = this.photos.map((photo, i) => ({ photo, no: i + 1 })).filter(x => usedNos.has(x.no));
198
285
  const mainPhoto = usedPhotos[this.mainPhotoIndex];
286
+ let no = 0;
199
287
  return html `
200
288
  <div class="result-body">
201
289
  <div class="photo-pane">
202
- <div class="caption">판정에 사용된 사진</div>
203
- <div class="photo-main">${mainPhoto ? html `<img src=${mainPhoto.photo.url} alt="현장 사진" />` : ''}</div>
290
+ <div class="caption">판정에 사용된 프레임 (동영상에서 추출)</div>
291
+ <div class="photo-main">${mainPhoto ? html `<img src=${mainPhoto.photo.url} alt="현장 프레임" />` : ''}</div>
204
292
  <div class="photo-thumbs">
205
293
  ${usedPhotos.map((x, i) => html `
206
294
  <img
207
295
  src=${x.photo.url}
208
- alt="사진 ${x.no}"
296
+ alt="프레임 ${x.no}"
209
297
  ?selected=${i === this.mainPhotoIndex}
210
298
  @click=${() => (this.mainPhotoIndex = i)}
211
299
  />
@@ -215,19 +303,36 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
215
303
 
216
304
  <div class="items-pane">
217
305
  <div class="summary-banner">
218
- 적용 항목 ${items.length}개 · 자동 판정 ${autoItems.length}개 · 추가 촬영 ${moreItems.length}개
306
+ 적용 항목 ${items.length}개 · 자동 판정 ${autoItems.length}개 · 추가 작업 ${moreCount}개
219
307
  </div>
220
308
 
221
309
  ${autoItems.length
222
310
  ? html `
223
311
  <div class="group-title">자동 판정 완료 (${autoItems.length})</div>
224
- ${autoItems.map((it, i) => this._renderItemCard(it, i + 1))}
312
+ ${autoItems.map(it => this._renderItemCard(it, ++no))}
225
313
  `
226
314
  : ''}
227
- ${moreItems.length
315
+ ${moreCount
228
316
  ? html `
229
- <div class="group-title">추가 촬영 필요 (${moreItems.length} 남음)</div>
230
- ${moreItems.map((it, i) => this._renderItemCard(it, i + 1))}
317
+ <div class="group-title">추가 작업 필요 (${moreCount} 남음)</div>
318
+ ${recaptureItems.length
319
+ ? html `
320
+ <div class="group-subtitle">재촬영 필요 (${recaptureItems.length})</div>
321
+ ${recaptureItems.map(it => this._renderItemCard(it, ++no))}
322
+ `
323
+ : ''}
324
+ ${measureItems.length
325
+ ? html `
326
+ <div class="group-subtitle">기준값 입력 필요 — 3D 계측 (${measureItems.length})</div>
327
+ ${measureItems.map(it => this._renderItemCard(it, ++no))}
328
+ `
329
+ : ''}
330
+ ${manualItems.length
331
+ ? html `
332
+ <div class="group-subtitle">AI 미지원 — 감리자 수동 확인 (${manualItems.length})</div>
333
+ ${manualItems.map(it => this._renderItemCard(it, ++no))}
334
+ `
335
+ : ''}
231
336
  `
232
337
  : ''}
233
338
 
@@ -239,26 +344,32 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
239
344
  `;
240
345
  }
241
346
  _renderItemCard(item, no) {
242
- var _a;
243
- const auto = item.verdict === 'pass' || item.verdict === 'fail';
347
+ var _a, _b, _c;
348
+ const group = this._groupOf(item);
349
+ // 측정 완료 항목은 summary(측정 한 줄 설명)를 자르지 않고 그대로, VLM 항목은 관찰/사유
350
+ const desc = item.measurements && item.reason
351
+ ? item.reason
352
+ : item.observation
353
+ ? `관찰: ${item.observation}`
354
+ : item.reason || ((_a = item.measure) === null || _a === void 0 ? void 0 : _a.question) || '';
244
355
  return html `
245
- <div class="item-card" ?selected=${((_a = this.selectedItem) === null || _a === void 0 ? void 0 : _a.item_id) === item.item_id} @click=${() => this._openDetail(item)}>
356
+ <div class="item-card" ?selected=${((_b = this.selectedItem) === null || _b === void 0 ? void 0 : _b.item_id) === item.item_id} @click=${() => this._openDetail(item)}>
246
357
  <div class="item-no">${no}</div>
247
358
  <div class="item-main">
248
359
  <div class="item-name">${item.text}</div>
249
360
  <div>
250
361
  <span class="chip">${ROUTING_LABEL[item.routing] || item.routing}</span>
251
- <span class="chip-verdict">${VERDICT_LABEL[item.verdict] || item.verdict}</span>
362
+ <span class="chip-verdict">${this._verdictLabel(item)}</span>
252
363
  </div>
253
- <div class="item-desc">${item.observation ? `관찰: ${item.observation}` : item.reason || ''}</div>
364
+ <div class="item-desc">${desc}</div>
254
365
  </div>
255
- ${auto
366
+ ${group === 'auto'
256
367
  ? html `
257
368
  <div class="badge" ?pass=${item.verdict === 'pass'} ?fail=${item.verdict === 'fail'}>
258
369
  ${VERDICT_LABEL[item.verdict]}
259
370
  </div>
260
371
  `
261
- : item.verdict === 'hold'
372
+ : group === 'recapture'
262
373
  ? html `
263
374
  <button
264
375
  class="capture-button"
@@ -270,16 +381,30 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
270
381
  촬영하기
271
382
  </button>
272
383
  `
273
- : html `<div class="badge" etc>${VERDICT_LABEL[item.verdict] || item.verdict}</div>`}
384
+ : group === 'measure'
385
+ ? html `
386
+ <button
387
+ class="measure-button"
388
+ @click=${(e) => {
389
+ e.stopPropagation();
390
+ this._openMeasure(item);
391
+ }}
392
+ >
393
+ ${((_c = item.measure) === null || _c === void 0 ? void 0 : _c.next_action) === 'select_and_input' ? '측정 진행' : '기준값 입력'}
394
+ </button>
395
+ `
396
+ : html `<div class="badge" etc>${this._verdictLabel(item)}</div>`}
274
397
  </div>
275
398
  `;
276
399
  }
277
- /* ─── 우측 패널: 상세 / 재촬영 / 리포트 ─── */
400
+ /* ─── 우측 패널: 상세 / 재촬영 / 측정 / 리포트 ─── */
278
401
  _renderSidePane() {
279
402
  if (this.panel === 'detail' && this.selectedItem)
280
403
  return this._renderDetailPane(this.selectedItem);
281
404
  if (this.panel === 'recapture' && this.selectedItem)
282
405
  return this._renderRecapturePane(this.selectedItem);
406
+ if (this.panel === 'measure' && this.selectedItem)
407
+ return this._renderMeasurePane(this.selectedItem);
283
408
  if (this.panel === 'report')
284
409
  return this._renderReportPane();
285
410
  return '';
@@ -287,7 +412,8 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
287
412
  _renderDetailPane(item) {
288
413
  const auto = item.verdict === 'pass' || item.verdict === 'fail';
289
414
  const routingLabel = ROUTING_LABEL[item.routing] || item.routing;
290
- const verdictLabel = VERDICT_LABEL[item.verdict] || item.verdict;
415
+ const verdictLabel = this._verdictLabel(item);
416
+ const measured = !!item.measurements; // 측정 모델로 판정된 항목 — 신뢰도는 확률이 없어 비운다
291
417
  return html `
292
418
  <div class="side-pane">
293
419
  <div class="detail-header">
@@ -304,7 +430,7 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
304
430
  ${verdictLabel || '-'}
305
431
  </span>
306
432
  </div>
307
- ${item.confidence != null
433
+ ${!measured && item.confidence != null
308
434
  ? html `
309
435
  <div class="kv">
310
436
  <label>신뢰도</label>
@@ -314,7 +440,7 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
314
440
  : ''}
315
441
  <div class="kv">
316
442
  <label>처리</label>
317
- <span>${ROUTING_LABEL[item.routing] || item.routing || '-'}</span>
443
+ <span>${routingLabel || '-'}</span>
318
444
  </div>
319
445
  </div>
320
446
 
@@ -325,54 +451,187 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
325
451
  </div>
326
452
  </div>
327
453
 
454
+ ${item.image_data ? html `<img class="evidence-img" src=${item.image_data} alt="측정 근거 이미지" />` : ''}
455
+
328
456
  <button class="text-button" @click=${this._closePanel}>닫기</button>
329
457
  </div>
330
458
  `;
331
459
  }
332
460
  _renderRecapturePane(item) {
333
461
  var _a;
334
- const shots = ((_a = item.recapture_shots) === null || _a === void 0 ? void 0 : _a.length)
335
- ? item.recapture_shots
336
- : [{ target: '추가 사진', how: '항목이 잘 보이도록 촬영해주세요.' }];
337
- const taken = this.photos.length - this.recaptureBase;
338
- const total = shots.length;
462
+ const shot = (_a = item.recapture_shots) === null || _a === void 0 ? void 0 : _a[0];
339
463
  return html `
340
464
  <div class="side-pane">
341
465
  <h3>${item.text}</h3>
342
466
  <div class="why-box">
343
- <b>왜 추가 촬영이 필요한가요?</b><br />
344
- ${item.reason || '판정을 위해 추가 사진이 필요합니다.'}
345
- </div>
346
- <div>
347
- <h4 style="margin: 0 0 8px 0; font-size: 13px;">촬영 가이드</h4>
348
- <ul
349
- style="margin: 0; padding-left: 18px; font-size: 12px; color: #555; display: flex; flex-direction: column; gap: 6px;"
350
- >
351
- ${shots.map(s => html `<li>${s.target} — ${s.how}</li>`)}
352
- </ul>
467
+ <b>왜 재촬영이 필요한가요?</b><br />
468
+ ${item.reason || '판정을 위해 추가 촬영이 필요합니다.'}
353
469
  </div>
354
- <div class="progress-row">
355
- <span>사진 진행</span>
356
- <span>${Math.min(taken, total)} / ${total}</span>
470
+ ${shot
471
+ ? html `
472
+ <div>
473
+ <h4 style="margin: 0 0 8px 0; font-size: 13px;">촬영 가이드</h4>
474
+ <div style="font-size: 12px; color: #555;">${shot.target} — ${shot.how}</div>
475
+ </div>
476
+ `
477
+ : ''}
478
+ <div class="set-card" ?uploaded=${!!this.recaptureSet} @click=${() => this._openSetPicker(-1)}>
479
+ <md-icon>${this.recaptureSet ? 'check_circle' : 'video_file'}</md-icon>
480
+ <div class="set-main">
481
+ <b>재촬영 세트</b>
482
+ ${this.recaptureSet
483
+ ? html `세트 업로드 완료 ${this.recaptureSet.warnings.map(w => html `<div class="set-warn">⚠ ${w}</div>`)}`
484
+ : html `앱으로 재촬영 후 파일 3개를 한 번에 선택`}
485
+ </div>
357
486
  </div>
358
- <div class="progress-bar"><div style="width: ${total ? Math.min(taken / total, 1) * 100 : 0}%"></div></div>
359
- <div class="slot-grid">
360
- ${shots.map((s, i) => {
361
- const photo = this.photos[this.recaptureBase + i];
487
+ ${this.recaptureSet ? html `<button class="primary-button" @click=${() => this._runInspection()}>재검사 실행</button>` : ''}
488
+ <button class="text-button" @click=${this._closePanel}>닫기</button>
489
+ </div>
490
+ `;
491
+ }
492
+ /* ─── 측정 패널 (④ 철근 배근 길이 / ⑤ 기둥·벽체 두께) ─── */
493
+ /* operator_inputs 정의 기반 숫자/텍스트 입력 렌더 (slot·click·n_frames는 별도 UI/고정) */
494
+ _renderMeasureFields(item) {
495
+ var _a;
496
+ const defs = ((_a = item.measure) === null || _a === void 0 ? void 0 : _a.operator_inputs) || [];
497
+ const fields = [];
498
+ for (const d of defs) {
499
+ if (d.key === 'n_frames' || d.key === 'slot' || d.key === 'click')
500
+ continue;
501
+ if (d.fields) {
502
+ // 복합 입력(targets 등): 하위 필드를 각각 입력으로 펼친다
503
+ for (const [k, desc] of Object.entries(d.fields)) {
504
+ if (k === 'name')
505
+ continue; // 부재명은 검사 항목명으로 자동 지정
506
+ fields.push({ key: k, label: FIELD_LABEL[k] || k, desc: String(desc) });
507
+ }
508
+ }
509
+ else {
510
+ fields.push({ key: d.key, label: FIELD_LABEL[d.key] || d.key, desc: d.desc, def: d.default });
511
+ }
512
+ }
513
+ return fields.map(f => {
514
+ var _a;
362
515
  return html `
363
- <div class="slot" @click=${() => this._openPhotoPicker(this.recaptureBase + i)}>
364
- ${photo ? html `<img src=${photo.url} alt=${s.target} />` : html `<md-icon>add_a_photo</md-icon>${s.target}`}
365
- </div>
366
- `;
367
- })}
516
+ <div class="measure-field">
517
+ <label title=${f.desc || ''}>${f.label}${f.desc ? html ` <span style="color:#aaa">— ${f.desc}</span>` : ''}</label>
518
+ <input
519
+ type="number"
520
+ inputmode="decimal"
521
+ .value=${(_a = this.measureInputs[f.key]) !== null && _a !== void 0 ? _a : (f.def != null ? String(f.def) : '')}
522
+ placeholder="도면값"
523
+ @input=${(e) => {
524
+ this.measureInputs = Object.assign(Object.assign({}, this.measureInputs), { [f.key]: e.target.value });
525
+ }}
526
+ />
527
+ </div>
528
+ `;
529
+ });
530
+ }
531
+ _renderMeasurePane(item) {
532
+ const measure = item.measure || {};
533
+ const isThickness = measure.next_action === 'select_and_input';
534
+ const setOptions = this.uploadedSets;
535
+ const done = !!this.measureResult;
536
+ return html `
537
+ <div class="side-pane">
538
+ <h3>${item.text}</h3>
539
+ <div class="detail-header">
540
+ <span class="chip">3D 계측</span>
541
+ <span style="font-size: 12px; color: #777;">${measure.name || ''} (${measure.status || ''})</span>
368
542
  </div>
369
- ${taken > 0
370
- ? html `<button class="primary-button" @click=${() => this._runInspection()}>재검사 실행 (${taken}장)</button>`
543
+ ${measure.capture_guide ? html `<div class="why-box">📷 ${measure.capture_guide}</div>` : ''}
544
+ <img
545
+ class="capture-guide-img"
546
+ style="background: #fff; border: 1px solid #eee; padding: 6px;"
547
+ src=${isThickness ? pictoMemberImg : pictoRebarImg}
548
+ alt="촬영 안내"
549
+ />
550
+
551
+ ${setOptions.length > 1
552
+ ? html `
553
+ <div class="measure-field">
554
+ <label>측정할 촬영 세트</label>
555
+ <select
556
+ .value=${String(this.measureSetIndex)}
557
+ @change=${(e) => this._onMeasureSetChange(parseInt(e.target.value))}
558
+ >
559
+ ${setOptions.map((s, i) => html `<option value=${i} ?selected=${i === this.measureSetIndex}>
560
+ 세트 ${i + 1}${s.technique ? ` (${s.technique})` : ''}
561
+ </option>`)}
562
+ </select>
563
+ </div>
564
+ `
565
+ : ''}
566
+ ${isThickness ? this._renderThicknessPicker() : ''} ${this._renderMeasureFields(item)}
567
+ ${this.measureRunning
568
+ ? html `
569
+ <div class="measure-msg">
570
+ <md-circular-progress indeterminate></md-circular-progress>
571
+ <span>${this.measureMsg || '측정 중...'}</span>
572
+ </div>
573
+ `
574
+ : html `
575
+ <button
576
+ class="primary-button"
577
+ ?disabled=${isThickness && (this.thicknessSlot === null || !this.thicknessClick)}
578
+ @click=${() => (isThickness ? this._runThicknessMeasure(item) : this._runRebarMeasure(item))}
579
+ >
580
+ ${done ? '다시 측정' : isThickness ? '측정 실행 (10~20초)' : '측정 시작 (약 45초)'}
581
+ </button>
582
+ `}
583
+ ${done
584
+ ? html `
585
+ ${this.measureResult.summary || this.measureResult.summary_line || this.measureResult.headline
586
+ ? html `<div class="measure-summary">
587
+ ${this.measureResult.summary || this.measureResult.summary_line || this.measureResult.headline}
588
+ </div>`
589
+ : ''}
590
+ ${this.measureImageUrl ? html `<img class="evidence-img" src=${this.measureImageUrl} alt="측정 근거" />` : ''}
591
+ ${this.canMutate
592
+ ? html `<button class="primary-button" @click=${() => this._applyMeasurement(item)}>판정 확정</button>`
593
+ : ''}
594
+ `
371
595
  : ''}
596
+
372
597
  <button class="text-button" @click=${this._closePanel}>닫기</button>
373
598
  </div>
374
599
  `;
375
600
  }
601
+ /* ⑤ 두께: 프레임 선택 + 측정면 클릭 (자동 면 선택은 정확도가 낮아 클릭 단계 생략 불가) */
602
+ _renderThicknessPicker() {
603
+ return html `
604
+ <div class="measure-field">
605
+ <label>① 모서리가 잘 보이는 프레임을 고르세요</label>
606
+ <div class="frame-strip">
607
+ ${this.thicknessFrames.map(f => html `
608
+ <img
609
+ src=${f.thumbUrl || ''}
610
+ alt="프레임 ${f.index}"
611
+ ?selected=${this.thicknessSlot === f.slot}
612
+ @click=${() => this._selectThicknessFrame(f.slot)}
613
+ />
614
+ `)}
615
+ </div>
616
+ </div>
617
+ ${this.thicknessFrameUrl
618
+ ? html `
619
+ <div class="measure-field">
620
+ <label>② 잴 면의 가운데를 한 번 탭하세요 (모서리·철근 위 말고 <b>면 위</b>)</label>
621
+ <div class="click-stage">
622
+ <img src=${this.thicknessFrameUrl} alt="선택 프레임" @click=${this._onThicknessClick} />
623
+ ${this.thicknessClick
624
+ ? html `<div
625
+ class="click-dot"
626
+ style="left: ${this.thicknessClick.x * 100}%; top: ${this.thicknessClick.y * 100}%"
627
+ ></div>`
628
+ : ''}
629
+ </div>
630
+ </div>
631
+ `
632
+ : ''}
633
+ `;
634
+ }
376
635
  _renderReportPane() {
377
636
  var _a;
378
637
  const items = this._sortedItems();
@@ -400,7 +659,7 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
400
659
  ?fail=${it.verdict === 'fail'}
401
660
  ?etc=${it.verdict !== 'pass' && it.verdict !== 'fail'}
402
661
  >
403
- ${VERDICT_LABEL[it.verdict] || it.verdict}
662
+ ${this._verdictLabel(it)}
404
663
  </span>
405
664
  <md-icon class="report-caret" ?open=${expanded}>expand_more</md-icon>
406
665
  </div>
@@ -411,7 +670,7 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
411
670
  ${it.observation ? html `<div>관찰: ${it.observation}</div>` : ''}
412
671
  ${it.reason ? html `<div>판단: ${it.reason}</div>` : ''}
413
672
  ${((_a = it.used_images) === null || _a === void 0 ? void 0 : _a.length)
414
- ? html `<div class="report-used">근거 사진: ${it.used_images.map(n => `#${n}`).join(', ')}</div>`
673
+ ? html `<div class="report-used">근거 프레임: ${it.used_images.map(n => `#${n}`).join(', ')}</div>`
415
674
  : ''}
416
675
  </div>
417
676
  `
@@ -420,9 +679,7 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
420
679
  `;
421
680
  })}
422
681
  </div>
423
- ${this.canMutate
424
- ? html `<button class="primary-button" @click=${this._registerInspection}>검사 등록</button>`
425
- : ''}
682
+ ${this.canMutate ? html `<button class="primary-button" @click=${this._registerInspection}>검사 등록</button>` : ''}
426
683
  <button class="text-button" @click=${this._closePanel}>닫기</button>
427
684
  </div>
428
685
  `;
@@ -493,8 +750,8 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
493
750
  return;
494
751
  this.project = response.data.project;
495
752
  }
496
- /* ─── 엔진 호출 ─── */
497
- /* 엔진 실행 공통 호출: input(JSON) + 사진 파일들을 multipart로 전송 */
753
+ /* ─── 서버 호출 공통 ─── */
754
+ /* 엔진 실행 공통 호출: input(JSON) + 파일들을 multipart로 전송 */
498
755
  async _callEngine(input, files = []) {
499
756
  const form = new FormData();
500
757
  form.append('input', JSON.stringify(input));
@@ -512,8 +769,29 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
512
769
  }
513
770
  return data.result;
514
771
  }
772
+ /* 측정 서버 호출 (JSON 응답) */
773
+ async _callMeasure(path, init = {}) {
774
+ var _a;
775
+ const response = await fetch(`${MEASURE_BASE_URL}${path}`, Object.assign(Object.assign({}, init), { headers: Object.assign(Object.assign({}, (init.headers || {})), { Authorization: MEASURE_AUTH }) }));
776
+ const data = await response.json().catch(() => ({}));
777
+ if (!response.ok) {
778
+ const msg = ((_a = data === null || data === void 0 ? void 0 : data.errors) === null || _a === void 0 ? void 0 : _a.join('\n')) || (data === null || data === void 0 ? void 0 : data.detail) || (data === null || data === void 0 ? void 0 : data.error) || `측정 서버 오류 (${response.status})`;
779
+ throw new Error(msg);
780
+ }
781
+ return data;
782
+ }
783
+ /* 측정 서버 이미지 → blob URL (img src는 인증 헤더를 못 실어서 fetch로 받는다) */
784
+ async _fetchMeasureImage(path) {
785
+ const response = await fetch(`${MEASURE_BASE_URL}${path}`, { headers: { Authorization: MEASURE_AUTH } });
786
+ if (!response.ok)
787
+ throw new Error(`이미지 수신 실패 (${response.status})`);
788
+ const url = URL.createObjectURL(await response.blob());
789
+ this.objectUrls.push(url);
790
+ return url;
791
+ }
515
792
  /* [1단계] 촬영 가이드 생성 (plan-shots) */
516
793
  async _loadShotGuide() {
794
+ var _a;
517
795
  const checklist = this.engineChecklist;
518
796
  if (!checklist.length) {
519
797
  notify({ message: '체크리스트 항목이 없습니다.', level: 'warn' });
@@ -523,6 +801,7 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
523
801
  this.loading = true;
524
802
  this.loadingText = 'AI 촬영 가이드 생성 중...';
525
803
  this.guide = await this._callEngine({ action: 'plan-shots', checklist });
804
+ this.sets = new Array((((_a = this.guide) === null || _a === void 0 ? void 0 : _a.shots) || []).length).fill(null);
526
805
  }
527
806
  catch (error) {
528
807
  console.error('촬영 가이드 생성 실패:', error);
@@ -532,28 +811,122 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
532
811
  this.loading = false;
533
812
  }
534
813
  }
814
+ /* ─── 세트 업로드 ─── */
815
+ _openSetPicker(index) {
816
+ var _a;
817
+ this.pendingSetIndex = index;
818
+ (_a = this.setInputEl) === null || _a === void 0 ? void 0 : _a.click();
819
+ }
820
+ async _onSetFilesSelected(event) {
821
+ const input = event.target;
822
+ const files = Array.from((input === null || input === void 0 ? void 0 : input.files) || []);
823
+ input.value = '';
824
+ const index = this.pendingSetIndex;
825
+ this.pendingSetIndex = null;
826
+ if (!files.length || index === null)
827
+ return;
828
+ // zip 1개 또는 필수 3파일 검증 (한 번의 선택으로 3개 멀티선택)
829
+ const isZip = files.length === 1 && /\.zip$/i.test(files[0].name);
830
+ if (!isZip) {
831
+ const names = files.map(f => f.name.toLowerCase());
832
+ const missing = SET_FILES.filter(n => !names.includes(n));
833
+ if (missing.length) {
834
+ notify({
835
+ message: `세트 파일이 부족합니다. 누락: ${missing.join(', ')} — 촬영 앱이 만든 3개 파일을 한 번에 선택해주세요.`,
836
+ level: 'warn'
837
+ });
838
+ return;
839
+ }
840
+ }
841
+ try {
842
+ this.loading = true;
843
+ this.loadingText = '촬영 세트 업로드 중...';
844
+ const form = new FormData();
845
+ for (const f of files)
846
+ if (isZip || SET_FILES.includes(f.name.toLowerCase()))
847
+ form.append('files', f, f.name);
848
+ const data = await this._callMeasure('/api/sets', { method: 'POST', body: form });
849
+ const set = {
850
+ setId: data.set_id,
851
+ technique: data.technique,
852
+ label: `세트 ${data.set_id}`,
853
+ warnings: data.warnings || []
854
+ };
855
+ if (index === -1) {
856
+ this.recaptureSet = set;
857
+ }
858
+ else {
859
+ const next = [...this.sets];
860
+ next[index] = set;
861
+ this.sets = next;
862
+ }
863
+ for (const w of set.warnings)
864
+ notify({ message: `업로드 경고: ${w}`, level: 'warn' });
865
+ }
866
+ catch (error) {
867
+ console.error('세트 업로드 실패:', error);
868
+ notify({ message: error.message, level: 'error' });
869
+ }
870
+ finally {
871
+ this.loading = false;
872
+ }
873
+ }
874
+ /* 세트의 대표 3프레임(vlm)을 받아 File 목록으로 (시간순 유지) */
875
+ async _fetchVlmFrames(set, setIndex) {
876
+ const data = await this._callMeasure(`/api/sets/${set.setId}/vlm-frames`);
877
+ const out = [];
878
+ for (const f of data.frames || []) {
879
+ const response = await fetch(`${MEASURE_BASE_URL}${f.url}`, { headers: { Authorization: MEASURE_AUTH } });
880
+ if (!response.ok)
881
+ throw new Error(`프레임 수신 실패 (${response.status})`);
882
+ const blob = await response.blob();
883
+ const file = new File([blob], `set${setIndex + 1}_${f.name}`, { type: 'image/jpeg' });
884
+ const url = URL.createObjectURL(file);
885
+ this.objectUrls.push(url);
886
+ out.push({ file, url, setIndex });
887
+ }
888
+ return out;
889
+ }
535
890
  /* [2단계] 자동 판정 실행 (inspect)
536
- * - 최초 판정: 전체 사진 + 전체 체크리스트
537
- * - 재검사(재촬영 패널): 이번에 새로 찍은 사진만 + 해당 항목만 재판정 후 결과 병합 */
891
+ * - 최초 판정: 업로드된 모든 세트의 대표 3프레임 + 전체 체크리스트 (input_kind=video_frames)
892
+ * - 재검사(재촬영 패널): 세트의 3프레임만 + 해당 항목만 재판정 후 결과 병합 */
538
893
  async _runInspection() {
539
894
  const isRecapture = this.panel === 'recapture' && !!this.selectedItem;
540
- // 재검사는 recaptureBase 이후에 찍은 사진만 전송
541
- const files = (isRecapture ? this.photos.slice(this.recaptureBase) : this.photos).map(p => p.file);
542
- if (!files.length) {
543
- notify({ message: '촬영된 사진이 없습니다.', level: 'warn' });
544
- return;
545
- }
546
- const checklist = isRecapture
547
- ? this.engineChecklist.filter(c => c.item_id === this.selectedItem.item_id)
548
- : this.engineChecklist;
549
895
  try {
550
896
  this.loading = true;
897
+ this.loadingText = '동영상 프레임 추출 중...';
898
+ let newPhotos = [];
899
+ if (isRecapture) {
900
+ if (!this.recaptureSet) {
901
+ notify({ message: '재촬영 세트를 먼저 업로드해주세요.', level: 'warn' });
902
+ return;
903
+ }
904
+ this.recaptureBase = this.photos.length;
905
+ newPhotos = await this._fetchVlmFrames(this.recaptureSet, this.sets.length);
906
+ }
907
+ else {
908
+ const sets = this.uploadedSets;
909
+ if (!sets.length) {
910
+ notify({ message: '업로드된 촬영 세트가 없습니다.', level: 'warn' });
911
+ return;
912
+ }
913
+ for (let i = 0; i < sets.length; i++) {
914
+ newPhotos = [...newPhotos, ...(await this._fetchVlmFrames(sets[i], i))];
915
+ }
916
+ }
917
+ const files = newPhotos.map(p => p.file);
918
+ const checklist = isRecapture
919
+ ? this.engineChecklist.filter(c => c.item_id === this.selectedItem.item_id)
920
+ : this.engineChecklist;
551
921
  this.loadingText = 'AI 자동 판정 중... (최대 1~2분 소요)';
552
- const result = await this._callEngine({ action: 'inspect', checklist }, files);
922
+ const result = await this._callEngine({ action: 'inspect', checklist, input_kind: 'video_frames' }, files);
553
923
  if (isRecapture) {
554
- this._mergeRecaptureResult(result);
924
+ this.photos = [...this.photos, ...newPhotos];
925
+ this._mergeItems((result === null || result === void 0 ? void 0 : result.items_sorted) || (result === null || result === void 0 ? void 0 : result.items) || [], this.recaptureBase);
926
+ this.recaptureSet = null;
555
927
  }
556
928
  else {
929
+ this.photos = newPhotos;
557
930
  this.result = result;
558
931
  }
559
932
  this.panel = 'none';
@@ -568,83 +941,96 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
568
941
  this.loading = false;
569
942
  }
570
943
  }
571
- /* 재검사(부분 전송) 결과를 기존 전체 결과에 병합
572
- * - used_images는 전송한 부분집합 기준 1-based → 전체 사진 인덱스로 보정(recaptureBase 가산)
573
- * - 해당 항목만 교체 후 summary 재계산 */
574
- _mergeRecaptureResult(result) {
944
+ /* 부분 재판정/측정 확정 결과를 기존 전체 결과에 병합하고 summary 재계산
945
+ * - usedImageBase: used_images(부분집합 1-based) → 전체 프레임 인덱스 보정값 */
946
+ _mergeItems(updatedItems, usedImageBase = 0) {
575
947
  var _a, _b;
576
- const base = this.recaptureBase;
577
- const updated = ((result === null || result === void 0 ? void 0 : result.items_sorted) || (result === null || result === void 0 ? void 0 : result.items) || []).map((it) => (Object.assign(Object.assign({}, it), { used_images: (it.used_images || []).map((n) => n + base) })));
948
+ const updated = updatedItems.map((it) => (Object.assign(Object.assign({}, it), { used_images: (it.used_images || []).map((n) => n + usedImageBase) })));
578
949
  const byId = new Map(updated.map((it) => [it.item_id, it]));
579
950
  const replace = (arr) => (arr || []).map(it => byId.get(it.item_id) || it);
580
951
  const items = replace((_a = this.result) === null || _a === void 0 ? void 0 : _a.items);
581
952
  const itemsSorted = ((_b = this.result) === null || _b === void 0 ? void 0 : _b.items_sorted) ? replace(this.result.items_sorted) : undefined;
582
- const summary = { pass: 0, fail: 0, hold: 0, pending: 0, error: 0 };
583
- for (const it of itemsSorted || items)
953
+ const summary = { pass: 0, fail: 0, hold: 0, pending: 0, error: 0, measure: 0 };
954
+ for (const it of itemsSorted || items) {
584
955
  summary[it.verdict] = (summary[it.verdict] || 0) + 1;
585
- this.result = Object.assign(Object.assign(Object.assign(Object.assign({}, this.result), { items }), (itemsSorted ? { items_sorted: itemsSorted } : {})), { summary });
586
- }
587
- /* ─── 사진 선택 ─── */
588
- /* 슬롯 클릭 → 사진 선택기 열기 (모바일: 촬영/앨범 선택 팝업, 채워진 슬롯이면 교체) */
589
- _openPhotoPicker(slotIndex = null) {
590
- var _a;
591
- this.pendingSlotIndex = slotIndex;
592
- (_a = this.photoInputEl) === null || _a === void 0 ? void 0 : _a.click();
593
- }
594
- _onPhotosSelected(event) {
595
- const input = event.target;
596
- const files = Array.from((input === null || input === void 0 ? void 0 : input.files) || []).filter(f => f.type.startsWith('image/') || /\.heic$/i.test(f.name));
597
- input.value = '';
598
- const slotIndex = this.pendingSlotIndex;
599
- this.pendingSlotIndex = null;
600
- if (!files.length)
601
- return;
602
- // 채워진 슬롯을 클릭한 경우 → 해당 사진 교체 (첫 파일만 사용)
603
- if (slotIndex !== null && slotIndex < this.photos.length) {
604
- URL.revokeObjectURL(this.photos[slotIndex].url);
605
- const next = [...this.photos];
606
- next[slotIndex] = { file: files[0], url: URL.createObjectURL(files[0]) };
607
- this.photos = next;
608
- return;
609
- }
610
- // 빈 슬롯/추가 → 순서대로 뒤에 추가
611
- // 엔진 제한은 요청(호출)당 MAX_IMAGES장. 재검사는 새로 찍은 사진만 별도 전송되므로
612
- // 누적 합계가 아니라 이번 회차(최초=전체, 재검사=recaptureBase 이후) 기준으로만 제한한다.
613
- const batchBase = this.panel === 'recapture' ? this.recaptureBase : 0;
614
- const cap = batchBase + MAX_IMAGES;
615
- if (this.photos.length + files.length > cap) {
616
- notify({ message: `사진은 회차당 최대 ${MAX_IMAGES}장까지 사용할 수 있습니다.`, level: 'warn' });
617
- files.length = Math.max(0, cap - this.photos.length);
618
- if (!files.length)
619
- return;
956
+ if (it.verdict === 'pending' && it.pending_reason === 'measure')
957
+ summary.measure += 1;
620
958
  }
621
- this.photos = [...this.photos, ...files.map(file => ({ file, url: URL.createObjectURL(file) }))];
959
+ summary.total = (itemsSorted || items).length;
960
+ this.result = Object.assign(Object.assign(Object.assign(Object.assign({}, this.result), { items }), (itemsSorted ? { items_sorted: itemsSorted } : {})), { summary });
622
961
  }
623
962
  _resetAll() {
624
- this._resetPhotos();
963
+ this.objectUrls.forEach(u => URL.revokeObjectURL(u));
964
+ this.objectUrls = [];
625
965
  this.guide = null;
966
+ this.sets = [];
967
+ this.photos = [];
626
968
  this.result = null;
627
969
  this.panel = 'none';
628
970
  this.selectedItem = null;
629
971
  this.mainPhotoIndex = 0;
630
972
  this.recaptureBase = 0;
973
+ this.recaptureSet = null;
974
+ this._resetMeasureState();
975
+ }
976
+ _resetMeasureState() {
977
+ this.measureInputs = {};
978
+ this.measureRunning = false;
979
+ this.measureMsg = '';
980
+ this.measureResult = null;
981
+ this.measureImageUrl = '';
982
+ this.thicknessFrames = [];
983
+ this.thicknessSlot = null;
984
+ this.thicknessFrameUrl = '';
985
+ this.thicknessClick = null;
631
986
  }
632
987
  /* ─── 패널 열기/닫기 ─── */
633
988
  _openDetail(item) {
634
- // 보류(hold)는 추가 촬영이 필요한 케이스 → 상세 대신 추가 촬영 UI를 바로 표시
635
- if (item.verdict === 'hold') {
989
+ const group = this._groupOf(item);
990
+ // 재촬영/측정 항목은 상세 대신 해당 작업 패널을 바로 표시
991
+ if (group === 'recapture') {
636
992
  this._openRecapture(item);
637
993
  return;
638
994
  }
995
+ if (group === 'measure') {
996
+ this._openMeasure(item);
997
+ return;
998
+ }
639
999
  this.selectedItem = item;
640
- this.mainPhotoIndex = 0; // 선택 항목의 첫 사용 사진부터 표시
1000
+ this.mainPhotoIndex = 0; // 선택 항목의 첫 사용 프레임부터 표시
641
1001
  this.panel = 'detail';
642
1002
  }
643
1003
  _openRecapture(item) {
644
1004
  this.selectedItem = item;
645
- this.recaptureBase = this.photos.length;
1005
+ this.recaptureSet = null;
646
1006
  this.panel = 'recapture';
647
1007
  }
1008
+ async _openMeasure(item) {
1009
+ var _a, _b, _c;
1010
+ this.selectedItem = item;
1011
+ this._resetMeasureState();
1012
+ this.panel = 'measure';
1013
+ // 촬영 기술이 맞는 세트를 기본 선택 (④=04_cover, ⑤=05_thickness)
1014
+ const wantTech = ((_b = (_a = item.measure) === null || _a === void 0 ? void 0 : _a.model_id) === null || _b === void 0 ? void 0 : _b.startsWith('rebar')) ? '04_cover' : '05_thickness';
1015
+ const sets = this.uploadedSets;
1016
+ const found = sets.findIndex(s => s.technique === wantTech);
1017
+ this.measureSetIndex = found >= 0 ? found : 0;
1018
+ if (((_c = item.measure) === null || _c === void 0 ? void 0 : _c.next_action) === 'select_and_input') {
1019
+ await this._loadThicknessFrames();
1020
+ }
1021
+ }
1022
+ async _onMeasureSetChange(index) {
1023
+ var _a, _b;
1024
+ this.measureSetIndex = index;
1025
+ this.measureResult = null;
1026
+ this.measureImageUrl = '';
1027
+ if (((_b = (_a = this.selectedItem) === null || _a === void 0 ? void 0 : _a.measure) === null || _b === void 0 ? void 0 : _b.next_action) === 'select_and_input') {
1028
+ this.thicknessSlot = null;
1029
+ this.thicknessFrameUrl = '';
1030
+ this.thicknessClick = null;
1031
+ await this._loadThicknessFrames();
1032
+ }
1033
+ }
648
1034
  /* 종합 리포트 항목 펼치기/접기 (한 번에 하나만 펼침) */
649
1035
  _toggleReportItem(id) {
650
1036
  this.expandedReportId = this.expandedReportId === id ? null : id;
@@ -653,7 +1039,203 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
653
1039
  var _a, _b;
654
1040
  return ((_a = this.result) === null || _a === void 0 ? void 0 : _a.items_sorted) || ((_b = this.result) === null || _b === void 0 ? void 0 : _b.items) || [];
655
1041
  }
656
- /* ─── [6단계] 검사 등록: 감리자 판정 자동 반영 + AI판단 근거 첨부 ─── */
1042
+ /* ─── 철근 배근 길이 측정 (비동기 job + 진행률 폴링) ─── */
1043
+ async _runRebarMeasure(item) {
1044
+ const set = this.uploadedSets[this.measureSetIndex];
1045
+ if (!set) {
1046
+ notify({ message: '측정할 촬영 세트가 없습니다.', level: 'warn' });
1047
+ return;
1048
+ }
1049
+ const memberDim = parseFloat(this.measureInputs.member_dim_mm);
1050
+ const coverSpec = parseFloat(this.measureInputs.cover_spec_mm);
1051
+ if (!memberDim || !coverSpec) {
1052
+ notify({ message: '설계 단면 치수와 기준 피복두께를 입력해주세요. (도면값)', level: 'warn' });
1053
+ return;
1054
+ }
1055
+ try {
1056
+ this.measureRunning = true;
1057
+ this.measureMsg = '측정 시작...';
1058
+ this.measureResult = null;
1059
+ this.measureImageUrl = '';
1060
+ const { job } = await this._callMeasure('/api/rebar/measure', {
1061
+ method: 'POST',
1062
+ headers: { 'Content-Type': 'application/json' },
1063
+ body: JSON.stringify({
1064
+ set_id: set.setId,
1065
+ targets: [{ name: item.text || '측정 대상', member_dim_mm: memberDim, cover_spec_mm: coverSpec }]
1066
+ })
1067
+ });
1068
+ // 진행률 폴링 — msg는 서버가 주는 한국어 문구 그대로 표시
1069
+ let status;
1070
+ do {
1071
+ await new Promise(r => setTimeout(r, 1500));
1072
+ status = await this._callMeasure(`/api/job/${job}`);
1073
+ this.measureMsg = status.msg || '측정 중...';
1074
+ } while (status.state !== 'done' && this.panel === 'measure');
1075
+ if (this.panel !== 'measure')
1076
+ return; // 패널을 닫았으면 결과 무시
1077
+ const r = status.result || {};
1078
+ if (!r.ok) {
1079
+ notify({ message: r.why || '측정에 실패했습니다.', level: 'error' });
1080
+ // 실패 결과도 확정 가능(엔진이 hold로 변환) — 결과는 보관하되 이미지 없음
1081
+ this.measureResult = r;
1082
+ return;
1083
+ }
1084
+ this.measureResult = r;
1085
+ // 측정에 쓴 프레임 중 가운데 프레임에 측정선 오버레이
1086
+ const frames = r.frames_used || [];
1087
+ const overlayIdx = frames.length ? frames[Math.floor(frames.length / 2)] : 0;
1088
+ try {
1089
+ this.measureImageUrl = await this._fetchMeasureImage(`/api/rebar/overlay/${job}/${overlayIdx}?w=1280`);
1090
+ }
1091
+ catch (e) {
1092
+ console.warn('오버레이 수신 실패:', e);
1093
+ }
1094
+ }
1095
+ catch (error) {
1096
+ console.error('철근 측정 실패:', error);
1097
+ notify({ message: error.message, level: 'error' });
1098
+ }
1099
+ finally {
1100
+ this.measureRunning = false;
1101
+ this.measureMsg = '';
1102
+ }
1103
+ }
1104
+ /* ─── ⑤ 기둥·벽체 두께 측정 (프레임 선택 + 면 클릭, 동기) ─── */
1105
+ async _loadThicknessFrames() {
1106
+ const set = this.uploadedSets[this.measureSetIndex];
1107
+ if (!set)
1108
+ return;
1109
+ try {
1110
+ this.measureRunning = true;
1111
+ this.measureMsg = '스윕에서 프레임 뽑는 중... (첫 호출은 10초쯤)';
1112
+ const data = await this._callMeasure(`/api/thickness/frames/${set.setId}`);
1113
+ const frames = (data.frames || []);
1114
+ // 썸네일은 인증 때문에 fetch로 받아 blob URL로
1115
+ const withThumbs = [];
1116
+ for (const f of frames) {
1117
+ try {
1118
+ const thumbUrl = await this._fetchMeasureImage(`/api/thickness/frame/${set.setId}/${f.slot}?thumb=1`);
1119
+ withThumbs.push(Object.assign(Object.assign({}, f), { thumbUrl }));
1120
+ }
1121
+ catch (_a) {
1122
+ withThumbs.push(f);
1123
+ }
1124
+ }
1125
+ this.thicknessFrames = withThumbs;
1126
+ if (withThumbs.length)
1127
+ await this._selectThicknessFrame(withThumbs[Math.floor(withThumbs.length / 2)].slot);
1128
+ }
1129
+ catch (error) {
1130
+ console.error('프레임 목록 실패:', error);
1131
+ notify({ message: error.message, level: 'error' });
1132
+ }
1133
+ finally {
1134
+ this.measureRunning = false;
1135
+ this.measureMsg = '';
1136
+ }
1137
+ }
1138
+ async _selectThicknessFrame(slot) {
1139
+ const set = this.uploadedSets[this.measureSetIndex];
1140
+ if (!set)
1141
+ return;
1142
+ this.thicknessSlot = slot;
1143
+ this.thicknessClick = null;
1144
+ try {
1145
+ this.thicknessFrameUrl = await this._fetchMeasureImage(`/api/thickness/frame/${set.setId}/${slot}`);
1146
+ }
1147
+ catch (error) {
1148
+ notify({ message: error.message, level: 'error' });
1149
+ }
1150
+ }
1151
+ async _runThicknessMeasure(item) {
1152
+ const set = this.uploadedSets[this.measureSetIndex];
1153
+ if (!set || this.thicknessSlot === null || !this.thicknessClick) {
1154
+ notify({ message: '프레임을 고르고 잴 면을 탭해주세요.', level: 'warn' });
1155
+ return;
1156
+ }
1157
+ try {
1158
+ this.measureRunning = true;
1159
+ this.measureMsg = '측정 중... (이 프레임 첫 측정이면 10~20초)';
1160
+ this.measureResult = null;
1161
+ this.measureImageUrl = '';
1162
+ const form = new FormData();
1163
+ form.append('set_id', set.setId);
1164
+ form.append('slot', String(this.thicknessSlot));
1165
+ form.append('x', String(this.thicknessClick.x));
1166
+ form.append('y', String(this.thicknessClick.y));
1167
+ if (this.measureInputs.design_mm)
1168
+ form.append('design_mm', this.measureInputs.design_mm);
1169
+ if (this.measureInputs.tol_mm)
1170
+ form.append('tol_mm', this.measureInputs.tol_mm);
1171
+ const r = await this._callMeasure('/api/thickness/measure', { method: 'POST', body: form });
1172
+ this.measureResult = r;
1173
+ if (r.image_data)
1174
+ this.measureImageUrl = r.image_data;
1175
+ if (!r.ok)
1176
+ notify({ message: r.summary || r.headline || '측정에 실패했습니다.', level: 'warn' });
1177
+ }
1178
+ catch (error) {
1179
+ console.error('두께 측정 실패:', error);
1180
+ notify({ message: error.message, level: 'error' });
1181
+ }
1182
+ finally {
1183
+ this.measureRunning = false;
1184
+ this.measureMsg = '';
1185
+ }
1186
+ }
1187
+ /* ─── 측정 결과 → 판정 확정 (apply-measurement, OpenAI 미호출·즉시 응답) ─── */
1188
+ async _applyMeasurement(item) {
1189
+ var _a;
1190
+ if (!this.measureResult)
1191
+ return;
1192
+ try {
1193
+ this.loading = true;
1194
+ this.loadingText = '판정 확정 중...';
1195
+ // 측정 서버 응답을 가공 없이 그대로 전달 — 품질 게이트/hold 승격은 엔진이 처리한다
1196
+ const result = await this._callEngine({
1197
+ action: 'apply-measurement',
1198
+ item: { item_id: item.item_id, text: item.text, category: item.category },
1199
+ model_id: ((_a = item.measure) === null || _a === void 0 ? void 0 : _a.model_id) || this.measureResult.model_id,
1200
+ result: this.measureResult
1201
+ });
1202
+ const judged = Object.assign(Object.assign({}, item), result.item);
1203
+ this._mergeItems([judged]);
1204
+ this._resetMeasureState();
1205
+ this.panel = 'none';
1206
+ this.selectedItem = null;
1207
+ if (judged.verdict === 'hold') {
1208
+ notify({ message: '측정 품질이 기준에 못 미쳐 보류되었습니다. 재촬영을 권장합니다.', level: 'warn' });
1209
+ }
1210
+ else {
1211
+ notify({ message: `판정이 확정되었습니다: ${this._verdictLabel(judged)}` });
1212
+ }
1213
+ }
1214
+ catch (error) {
1215
+ console.error('판정 확정 실패:', error);
1216
+ notify({ message: error.message, level: 'error' });
1217
+ }
1218
+ finally {
1219
+ this.loading = false;
1220
+ }
1221
+ }
1222
+ /* base64 data URL → File (측정 근거 이미지 첨부용) */
1223
+ _dataUrlToFile(dataUrl, name) {
1224
+ var _a;
1225
+ try {
1226
+ const [head, body] = dataUrl.split(',');
1227
+ const mime = ((_a = head.match(/data:(.*?);/)) === null || _a === void 0 ? void 0 : _a[1]) || 'image/jpeg';
1228
+ const bin = atob(body);
1229
+ const bytes = new Uint8Array(bin.length);
1230
+ for (let i = 0; i < bin.length; i++)
1231
+ bytes[i] = bin.charCodeAt(i);
1232
+ return new File([bytes], name, { type: mime });
1233
+ }
1234
+ catch (_b) {
1235
+ return null;
1236
+ }
1237
+ }
1238
+ /* ─── 검사 등록: 감리자 판정 자동 반영 + AI판단 근거 첨부 ─── */
657
1239
  async _registerInspection() {
658
1240
  var _a, _b;
659
1241
  const items = this._sortedItems();
@@ -684,7 +1266,8 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
684
1266
  if (response.errors)
685
1267
  throw new Error(((_a = response.errors[0]) === null || _a === void 0 ? void 0 : _a.message) || '검사 등록에 실패했습니다.');
686
1268
  }
687
- // 2) 항목별 첨부: 판단 근거(AI판단 텍스트) + 근거 사진 (판정 여부와 무관하게 전체 항목)
1269
+ // 2) 항목별 첨부: 판단 근거(AI판단 텍스트) + 근거 프레임/측정 근거 이미지
1270
+ // ai_report는 pending/error에서 빈 문자열로 오므로 그 항목은 첨부하지 않는다
688
1271
  const attachments = [];
689
1272
  for (const it of items) {
690
1273
  if (it.ai_report) {
@@ -701,6 +1284,12 @@ let BuildingInspectionAiMeasurement = class BuildingInspectionAiMeasurement exte
701
1284
  attachments.push({ file: photo.file, refBy: it.item_id, refType: 'ChecklistItem', description: 'AI판단' });
702
1285
  }
703
1286
  }
1287
+ // 측정 항목의 근거 이미지 (apply-measurement 응답의 base64)
1288
+ if (it.image_data) {
1289
+ const file = this._dataUrlToFile(it.image_data, `AI측정근거_${(it.text || it.item_id).slice(0, 30)}.jpg`);
1290
+ if (file)
1291
+ attachments.push({ file, refBy: it.item_id, refType: 'ChecklistItem', description: 'AI판단' });
1292
+ }
704
1293
  }
705
1294
  if (attachments.length) {
706
1295
  const attachResponse = await client.mutate({
@@ -839,68 +1428,95 @@ BuildingInspectionAiMeasurement.styles = [
839
1428
  margin-top: 6px;
840
1429
  }
841
1430
 
842
- .progress-row {
1431
+ /* 촬영 안내 그림 — 픽토그램은 배경 투명·짙은 글자라 밝은 카드 위에만 얹는다 */
1432
+ .capture-guide-img {
1433
+ width: 100%;
1434
+ border-radius: 8px;
1435
+ display: block;
1436
+ }
1437
+
1438
+ .picto-strip {
1439
+ display: flex;
1440
+ gap: 8px;
1441
+ margin-top: 10px;
1442
+ }
1443
+
1444
+ .picto-strip img {
1445
+ flex: 1;
1446
+ min-width: 0;
1447
+ background: #fff;
1448
+ border: 1px solid #eee;
1449
+ border-radius: 8px;
1450
+ padding: 6px;
1451
+ }
1452
+
1453
+ /* 세트 업로드 카드 */
1454
+ .set-card {
843
1455
  display: flex;
844
- justify-content: space-between;
845
1456
  align-items: center;
846
- font-size: 13px;
847
- color: #555;
848
- font-weight: 600;
1457
+ gap: 10px;
1458
+ border: 1px dashed #ccc;
1459
+ border-radius: 8px;
1460
+ background: #fafafa;
1461
+ padding: 12px;
1462
+ cursor: pointer;
849
1463
  }
850
1464
 
851
- .progress-bar {
852
- height: 6px;
853
- border-radius: 3px;
854
- background: #eee;
855
- overflow: hidden;
1465
+ .set-card[uploaded] {
1466
+ border-style: solid;
1467
+ border-color: #bfd8bf;
1468
+ background: #f2f8f2;
1469
+ cursor: default;
856
1470
  }
857
1471
 
858
- .progress-bar div {
859
- height: 100%;
860
- background: #7d2f2f;
861
- transition: width 0.3s;
1472
+ .set-card md-icon {
1473
+ --md-icon-size: 28px;
1474
+ color: #b5b5b5;
1475
+ flex-shrink: 0;
862
1476
  }
863
1477
 
864
- .next-shot {
1478
+ .set-card[uploaded] md-icon {
1479
+ color: #2e7d32;
1480
+ }
1481
+
1482
+ .set-card .set-main {
1483
+ flex: 1;
1484
+ min-width: 0;
865
1485
  font-size: 13px;
866
- font-weight: 700;
1486
+ color: #555;
1487
+ }
1488
+
1489
+ .set-card .set-main b {
1490
+ display: block;
867
1491
  color: #333;
1492
+ margin-bottom: 2px;
868
1493
  }
869
1494
 
870
- .slot-grid {
871
- display: flex;
872
- gap: 8px;
1495
+ .set-card .set-warn {
1496
+ font-size: 11px;
1497
+ color: #b7791f;
873
1498
  }
874
1499
 
875
- .slot {
876
- flex: 1;
877
- aspect-ratio: 1;
878
- border: 1px dashed #ccc;
879
- border-radius: 8px;
880
- background: #fafafa;
1500
+ .progress-row {
881
1501
  display: flex;
882
- flex-direction: column;
883
- justify-content: center;
1502
+ justify-content: space-between;
884
1503
  align-items: center;
885
- gap: 4px;
886
- font-size: 12px;
887
- color: #999;
888
- text-align: center;
889
- padding: 4px;
890
- overflow: hidden;
891
- cursor: pointer;
1504
+ font-size: 13px;
1505
+ color: #555;
1506
+ font-weight: 600;
892
1507
  }
893
1508
 
894
- .slot md-icon {
895
- --md-icon-size: 28px;
896
- color: #b5b5b5;
1509
+ .progress-bar {
1510
+ height: 6px;
1511
+ border-radius: 3px;
1512
+ background: #eee;
1513
+ overflow: hidden;
897
1514
  }
898
1515
 
899
- .slot img {
900
- width: 100%;
1516
+ .progress-bar div {
901
1517
  height: 100%;
902
- object-fit: cover;
903
- border-radius: 6px;
1518
+ background: #7d2f2f;
1519
+ transition: width 0.3s;
904
1520
  }
905
1521
 
906
1522
  .primary-button {
@@ -1016,6 +1632,13 @@ BuildingInspectionAiMeasurement.styles = [
1016
1632
  margin-top: 4px;
1017
1633
  }
1018
1634
 
1635
+ .group-subtitle {
1636
+ font-size: 12px;
1637
+ font-weight: 700;
1638
+ color: #777;
1639
+ margin: 2px 0 0 4px;
1640
+ }
1641
+
1019
1642
  .item-card {
1020
1643
  display: flex;
1021
1644
  gap: 10px;
@@ -1120,11 +1743,23 @@ BuildingInspectionAiMeasurement.styles = [
1120
1743
  cursor: pointer;
1121
1744
  }
1122
1745
 
1123
- /* ─── 우측 패널 (상세/재촬영/리포트) ─── */
1746
+ .measure-button {
1747
+ flex-shrink: 0;
1748
+ border: none;
1749
+ border-radius: 14px;
1750
+ background: #e8eef7;
1751
+ color: #2f5d8a;
1752
+ font-size: 12px;
1753
+ font-weight: 700;
1754
+ padding: 6px 12px;
1755
+ cursor: pointer;
1756
+ }
1757
+
1758
+ /* ─── 우측 패널 (상세/재촬영/측정/리포트) ─── */
1124
1759
  .side-pane {
1125
1760
  flex: 1;
1126
- min-width: 280px;
1127
- max-width: 360px;
1761
+ min-width: 300px;
1762
+ max-width: 400px;
1128
1763
  background: #fff;
1129
1764
  border: 1px solid #e5e5e5;
1130
1765
  border-radius: 10px;
@@ -1209,6 +1844,12 @@ BuildingInspectionAiMeasurement.styles = [
1209
1844
  padding: 8px 10px;
1210
1845
  }
1211
1846
 
1847
+ .evidence-img {
1848
+ width: 100%;
1849
+ border-radius: 8px;
1850
+ border: 1px solid #eee;
1851
+ }
1852
+
1212
1853
  .why-box {
1213
1854
  background: #fff8e1;
1214
1855
  border: 1px solid #f4e4b5;
@@ -1219,6 +1860,87 @@ BuildingInspectionAiMeasurement.styles = [
1219
1860
  line-height: 1.6;
1220
1861
  }
1221
1862
 
1863
+ /* 측정 패널 */
1864
+ .measure-field {
1865
+ display: flex;
1866
+ flex-direction: column;
1867
+ gap: 4px;
1868
+ font-size: 12px;
1869
+ color: #555;
1870
+ }
1871
+
1872
+ .measure-field input,
1873
+ .measure-field select {
1874
+ padding: 9px 10px;
1875
+ border: 1px solid #ccc;
1876
+ border-radius: 6px;
1877
+ font-size: 14px;
1878
+ }
1879
+
1880
+ .measure-msg {
1881
+ display: flex;
1882
+ align-items: center;
1883
+ gap: 8px;
1884
+ font-size: 13px;
1885
+ color: #7d2f2f;
1886
+ font-weight: 600;
1887
+ }
1888
+
1889
+ .measure-msg md-circular-progress {
1890
+ --md-circular-progress-size: 22px;
1891
+ }
1892
+
1893
+ .frame-strip {
1894
+ display: flex;
1895
+ gap: 6px;
1896
+ overflow-x: auto;
1897
+ padding-bottom: 4px;
1898
+ }
1899
+
1900
+ .frame-strip img {
1901
+ height: 60px;
1902
+ border-radius: 6px;
1903
+ border: 2px solid transparent;
1904
+ cursor: pointer;
1905
+ opacity: 0.7;
1906
+ }
1907
+
1908
+ .frame-strip img[selected] {
1909
+ border-color: #7d2f2f;
1910
+ opacity: 1;
1911
+ }
1912
+
1913
+ .click-stage {
1914
+ position: relative;
1915
+ line-height: 0;
1916
+ }
1917
+
1918
+ .click-stage img {
1919
+ width: 100%;
1920
+ border-radius: 8px;
1921
+ cursor: crosshair;
1922
+ }
1923
+
1924
+ .click-dot {
1925
+ position: absolute;
1926
+ width: 24px;
1927
+ height: 24px;
1928
+ margin: -12px 0 0 -12px;
1929
+ border: 3px solid #ffd400;
1930
+ border-radius: 50%;
1931
+ pointer-events: none;
1932
+ }
1933
+
1934
+ .measure-summary {
1935
+ background: #f7f7f7;
1936
+ border-radius: 8px;
1937
+ padding: 10px;
1938
+ font-size: 12px;
1939
+ color: #333;
1940
+ line-height: 1.7;
1941
+ white-space: pre-line;
1942
+ }
1943
+
1222
1944
  .report-counts {
1223
1945
  display: flex;
1224
1946
  text-align: center;
@@ -1368,6 +2090,10 @@ __decorate([
1368
2090
  state(),
1369
2091
  __metadata("design:type", Object)
1370
2092
  ], BuildingInspectionAiMeasurement.prototype, "guide", void 0);
2093
+ __decorate([
2094
+ state(),
2095
+ __metadata("design:type", Array)
2096
+ ], BuildingInspectionAiMeasurement.prototype, "sets", void 0);
1371
2097
  __decorate([
1372
2098
  state(),
1373
2099
  __metadata("design:type", Array)
@@ -1392,10 +2118,54 @@ __decorate([
1392
2118
  state(),
1393
2119
  __metadata("design:type", Number)
1394
2120
  ], BuildingInspectionAiMeasurement.prototype, "recaptureBase", void 0);
2121
+ __decorate([
2122
+ state(),
2123
+ __metadata("design:type", Object)
2124
+ ], BuildingInspectionAiMeasurement.prototype, "recaptureSet", void 0);
1395
2125
  __decorate([
1396
2126
  state(),
1397
2127
  __metadata("design:type", Object)
1398
2128
  ], BuildingInspectionAiMeasurement.prototype, "expandedReportId", void 0);
2129
+ __decorate([
2130
+ state(),
2131
+ __metadata("design:type", Number)
2132
+ ], BuildingInspectionAiMeasurement.prototype, "measureSetIndex", void 0);
2133
+ __decorate([
2134
+ state(),
2135
+ __metadata("design:type", Object)
2136
+ ], BuildingInspectionAiMeasurement.prototype, "measureInputs", void 0);
2137
+ __decorate([
2138
+ state(),
2139
+ __metadata("design:type", Boolean)
2140
+ ], BuildingInspectionAiMeasurement.prototype, "measureRunning", void 0);
2141
+ __decorate([
2142
+ state(),
2143
+ __metadata("design:type", String)
2144
+ ], BuildingInspectionAiMeasurement.prototype, "measureMsg", void 0);
2145
+ __decorate([
2146
+ state(),
2147
+ __metadata("design:type", Object)
2148
+ ], BuildingInspectionAiMeasurement.prototype, "measureResult", void 0);
2149
+ __decorate([
2150
+ state(),
2151
+ __metadata("design:type", String)
2152
+ ], BuildingInspectionAiMeasurement.prototype, "measureImageUrl", void 0);
2153
+ __decorate([
2154
+ state(),
2155
+ __metadata("design:type", Array)
2156
+ ], BuildingInspectionAiMeasurement.prototype, "thicknessFrames", void 0);
2157
+ __decorate([
2158
+ state(),
2159
+ __metadata("design:type", Object)
2160
+ ], BuildingInspectionAiMeasurement.prototype, "thicknessSlot", void 0);
2161
+ __decorate([
2162
+ state(),
2163
+ __metadata("design:type", String)
2164
+ ], BuildingInspectionAiMeasurement.prototype, "thicknessFrameUrl", void 0);
2165
+ __decorate([
2166
+ state(),
2167
+ __metadata("design:type", Object)
2168
+ ], BuildingInspectionAiMeasurement.prototype, "thicknessClick", void 0);
1399
2169
  __decorate([
1400
2170
  state(),
1401
2171
  __metadata("design:type", Boolean)
@@ -1409,9 +2179,11 @@ __decorate([
1409
2179
  __metadata("design:type", Object)
1410
2180
  ], BuildingInspectionAiMeasurement.prototype, "canMutate", void 0);
1411
2181
  __decorate([
1412
- query('#photoInput'),
1413
- __metadata("design:type", HTMLInputElement)
1414
- ], BuildingInspectionAiMeasurement.prototype, "photoInputEl", void 0);
2182
+ query('#setInput'),
2183
+ __metadata("design:type", HTMLInputElement
2184
+ /* 세트 업로드 대상: 촬영 건 인덱스(가이드 화면) 또는 -1(재촬영 패널) */
2185
+ )
2186
+ ], BuildingInspectionAiMeasurement.prototype, "setInputEl", void 0);
1415
2187
  BuildingInspectionAiMeasurement = __decorate([
1416
2188
  customElement('building-inspection-detail-ai-measurement')
1417
2189
  ], BuildingInspectionAiMeasurement);