diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 7810851d..5486c54b 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -7,6 +7,7 @@ import { drawGableRoof, drawRoofByAttribute, drawShedRoof, + equalizeParallelEaveLabels, equalizeSymmetricHips, snapNearAxisEdges, toGeoJSON, @@ -16,6 +17,7 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' import { logger } from '@/util/logger' +import { debugCapture } from '@/util/debugCapture' // ======================================================================== // [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local') @@ -100,6 +102,137 @@ function __attachDebugLabels(canvas, parentId) { canvas.renderAll() } +// ======================================================================== +// [ROOF-FACE-DIAG 2026-06-04] 지붕면 할당 디버그 — 로컬 전용. +// 할당된 각 지붕면(name === POLYGON_TYPE.ROOF)에 +// - 캔버스: 면 식별자 + 평면면적 라벨(F-n) (사람이 "F-3" 지목용) +// - 로거: 면적(평면/실제)·구배·방향·꼭짓점 절대좌표 전체 (대화/디버그 공유용 — 본체) +// 캔버스 라벨만으론 Claude 가 못 읽으므로 logger 덤프가 핵심. +// ======================================================================== +const ROOF_FACE_LABEL_NAME = '__roofFaceDebugLabel' + +// fabric Polygon 의 points 는 pathOffset 기준 상대좌표 → 변환행렬로 절대(canvas)좌표 복원. +function __roofFaceAbsPoints(roof) { + const m = roof.calcTransformMatrix() + const ox = roof.pathOffset?.x ?? 0 + const oy = roof.pathOffset?.y ?? 0 + return (roof.points || []).map((p) => fabric.util.transformPoint({ x: p.x - ox, y: p.y - oy }, m)) +} + +function __shoelaceAreaPx(pts) { + let a = 0 + for (let i = 0; i < pts.length; i++) { + const cur = pts[i] + const nxt = pts[(i + 1) % pts.length] + a += cur.x * nxt.y - nxt.x * cur.y + } + return Math.abs(a) / 2 +} + +export function reattachRoofFaceDebugLabels(canvas) { + if (!__isDebugLabelsEnabled()) return + if (!canvas) return + + // 재할당/재진입 시 기존 면 라벨 제거 → 카운트 reset 후 새로 부여. + canvas + .getObjects() + .filter((o) => o.name === ROOF_FACE_LABEL_NAME) + .forEach((o) => canvas.remove(o)) + + const roofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF) + logger.log(`[ROOF-FACE-DIAG] 할당 지붕면 ${roofs.length}개`) + + // [ROOF-FACE-DIAG 2026-06-04] logger.log 는 브라우저 콘솔뿐이라 Claude 가 못 읽음. + // 좌표/면적을 debugCapture 로 debug/debug.log 에 영속화 → log-check 로 읽는 본체. + const records = [] + + roofs.forEach((roof, i) => { + const label = `F-${i + 1}` + const pts = __roofFaceAbsPoints(roof) + if (pts.length < 3) { + logger.log(`[ROOF-FACE-DIAG] ${label} 좌표 부족(pts=${pts.length}) skip`) + records.push({ face: label, skip: `pts=${pts.length}` }) + return + } + // 좌표 단위 1px = 10mm (planeSize = hypot(px)*10) → 평면면적 ㎡ = pxArea * 100 / 1e6 = pxArea / 1e4 + const planeM2 = __shoelaceAreaPx(pts) / 10000 + // 구배(寸) → 경사각. 실제 지붕면적 = 평면 / cos(angle). 구배 없으면 실제 = 평면. + const pitch = Number(roof.pitch ?? roof.roofMaterial?.pitch ?? 0) || 0 + const angle = Math.atan(pitch / 10) + const actualM2 = angle ? planeM2 / Math.cos(angle) : planeM2 + + // 캔버스 라벨: 면 중심에 식별자 + 평면면적만 (좌표는 로그에만). + const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length + const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length + const text = new fabric.Text(`${label} ${planeM2.toFixed(2)}㎡`, { + left: cx, + top: cy, + originX: 'center', + originY: 'center', + fontSize: 12, + fill: '#000', + fontFamily: 'monospace', + fontWeight: 'bold', + backgroundColor: 'rgba(0,255,255,0.85)', + selectable: false, + evented: false, + hasControls: false, + hasBorders: false, + name: ROOF_FACE_LABEL_NAME, + parentId: roof.id, + excludeFromExport: true, + }) + canvas.add(text) + text.bringToFront() + + // 꼭짓점별 좌표 라벨 (화면 표시 — local 전용). 같은 name 으로 cleanup 에 함께 제거됨. + pts.forEach((p) => { + const coord = new fabric.Text(`${Math.round(p.x)},${Math.round(p.y)}`, { + left: p.x, + top: p.y, + originX: 'center', + originY: 'bottom', + fontSize: 9, + fill: '#0a0', + fontFamily: 'monospace', + backgroundColor: 'rgba(255,255,255,0.7)', + selectable: false, + evented: false, + hasControls: false, + hasBorders: false, + name: ROOF_FACE_LABEL_NAME, + parentId: roof.id, + excludeFromExport: true, + }) + canvas.add(coord) + coord.bringToFront() + }) + + // 로거 덤프(본체): 평면/실제 면적 + 구배 + 방향 + 변 개수 + 꼭짓점 절대좌표 전체. + const ptsStr = pts.map((p) => `(${p.x.toFixed(1)},${p.y.toFixed(1)})`).join(' ') + logger.log( + `[ROOF-FACE-DIAG] ${label} id=${roof.id?.slice?.(0, 8) ?? 'none'} ` + + `plane=${planeM2.toFixed(2)}㎡ actual=${actualM2.toFixed(2)}㎡ pitch=${pitch} ` + + `dir=${roof.direction ?? 'none'} lines=${roof.lines?.length ?? 0} pts=${pts.length} ${ptsStr}`, + ) + records.push({ + face: label, + id: roof.id?.slice?.(0, 8) ?? null, + planeM2: Number(planeM2.toFixed(2)), + actualM2: Number(actualM2.toFixed(2)), + pitch, + dir: roof.direction ?? null, + lines: roof.lines?.length ?? 0, + points: pts.map((p) => ({ x: Number(p.x.toFixed(1)), y: Number(p.y.toFixed(1)) })), + }) + }) + + // Claude 가 읽는 본체 — debug/debug.log 에 [ROOF-FACE-DIAG] 라벨로 영속화. + debugCapture.log('ROOF-FACE-DIAG', { count: roofs.length, faces: records }) + + canvas.renderAll() +} + export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', // lines: [], @@ -710,6 +843,18 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { if (this.innerLines && this.innerLines.length > 0) { const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP) equalizeSymmetricHips(hips, this.canvas) + + // 평행 처마 쌍(고정 outerLine ↔ 이동 eaveHelpLine) 라벨 통일. + // OVER_EPS 클램프로 이동 변이 1mm 짧아져 평행인데 라벨이 1 차이 나는 케이스 보정. + // eaveHelpLine 은 이 지붕 소속(parentId)만, outerLine 은 좌표가 겹치는 것만 그룹핑되므로 cross-roof 오머지 없음. + const eaveCandidates = (this.canvas?.getObjects() || []).filter( + (o) => + (o.name === 'outerLine' || (o.name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE && o.parentId === this.id)) && + typeof o.x1 === 'number' && + typeof o.y1 === 'number', + ) + equalizeParallelEaveLabels(eaveCandidates, this.canvas) + this.canvas?.renderAll() } diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index ec5a90ee..33ee5cca 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -263,15 +263,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla if (addedRoofs.length > 0) { const raftCodeList = findCommonCode('203800') setRaftCodes(raftCodeList) + // [2026-06-02] addedRoofs[0]에서 받은 roofSizeSet을 그대로 사용 (basicSetting으로 덮어씌우지 않기) setCurrentRoof({ ...addedRoofs[0], planNo: currentCanvasPlan?.planNo || planNo, - roofSizeSet: String(basicSetting.roofSizeSet), - roofAngleSet: basicSetting.roofAngleSet, + roofSizeSet: String(addedRoofs[0].roofSizeSet), + roofAngleSet: addedRoofs[0].roofAngleSet, }) + // 입력모드도 함께 동기화 + setInputMode(addedRoofs[0].roofSizeSet) } else { /** 데이터 설정 확인 후 데이터가 없으면 기본 데이터 설정 */ setCurrentRoof({ ...DEFAULT_ROOF_SETTINGS }) + setInputMode(DEFAULT_ROOF_SETTINGS.roofSizeSet) } }, [addedRoofs]) diff --git a/src/components/management/StuffDetail.jsx b/src/components/management/StuffDetail.jsx index 874aa8fd..30370d73 100644 --- a/src/components/management/StuffDetail.jsx +++ b/src/components/management/StuffDetail.jsx @@ -1082,39 +1082,49 @@ export default function StuffDetail() { return } - // T01 / 1차 user + 2차 ID: firstAgent 검증 후에만 적용 (실패 시 무반영) - get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => { - logger.debug('[PLANREQ-DEBUG] firstAgent result', { firstAgentId: res?.firstAgentId }) - if (!res?.firstAgentId) { - swalFire({ - title: getMessage('stuff.detail.planReq.message.notMatch'), - type: 'alert', - icon: 'warning', - }) - return - } + // [PLANREQ-FORCE-SELECT 2026-06-05] T01 / 1차 user + 2차 ID + // 매핑 실패해도 모든 필드 적용 + 2차점을 옵션에 강제 추가 + selected + // 1차점 정보(firstAgent) 가 있으면 1차점도 함께 반영 + // 'No data' 응답(4xx + message)도 매핑 실패와 동일하게 처리, 진짜 네트워크 에러만 알림 + const applyOtherSaleStore = () => { applyFields() + setOtherSaleStoreList((prev) => { + const exists = prev.some((o) => o.saleStoreId === info.saleStoreId) + return exists ? prev : [...prev, { saleStoreId: info.saleStoreId, saleStoreName: info.saleStoreName }] + }) setOtherSelOptions(info.saleStoreId) form.setValue('otherSaleStoreId', info.saleStoreId) form.setValue('otherSaleStoreName', info.saleStoreName) form.setValue('otherSaleStoreLevel', info.saleStoreLevel) - const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName } - setSaleStoreList((prev) => { - const exists = prev.some((s) => s.saleStoreId === res.firstAgentId) - return exists ? prev : [...prev, firstAgent] - }) - setShowSaleStoreList((prev) => { - const exists = prev.some((s) => s.saleStoreId === res.firstAgentId) - return exists ? prev : [...prev, firstAgent] - }) - setSelOptions(res.firstAgentId) - form.setValue('saleStoreId', res.firstAgentId) - form.setValue('saleStoreName', res.firstAgentName) - form.setValue('saleStoreLevel', '1') - }).catch(() => { - // 매핑 실패 — 아무것도 적용 안 함 + 사용자에게 알림 + } + get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => { + logger.debug('[PLANREQ-DEBUG] firstAgent result', { firstAgentId: res?.firstAgentId }) + applyOtherSaleStore() + if (res?.firstAgentId) { + const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName } + setSaleStoreList((prev) => { + const exists = prev.some((s) => s.saleStoreId === res.firstAgentId) + return exists ? prev : [...prev, firstAgent] + }) + setShowSaleStoreList((prev) => { + const exists = prev.some((s) => s.saleStoreId === res.firstAgentId) + return exists ? prev : [...prev, firstAgent] + }) + setSelOptions(res.firstAgentId) + form.setValue('saleStoreId', res.firstAgentId) + form.setValue('saleStoreName', res.firstAgentName) + form.setValue('saleStoreLevel', '1') + } + }).catch((error) => { + // 'No data' 는 1차점 매핑 정보가 없을 뿐 — 2차점만 옵션 추가+select + const message = error?.response?.data?.message + if (message === 'No data') { + applyOtherSaleStore() + return + } + // 진짜 네트워크/서버 에러 swalFire({ - title: getMessage('stuff.detail.planReq.message.notMatch'), + title: getMessage('stuff.detail.planReq.message.networkError'), type: 'alert', icon: 'warning', }) @@ -1878,16 +1888,18 @@ export default function StuffDetail() { )) || null} - + {session?.storeId === 'T01' && ( + + )} @@ -2468,7 +2480,7 @@ export default function StuffDetail() { > ) : null} - {managementState?.tempFlg === '1' ? ( + {managementState?.tempFlg === '1' && session?.storeId === 'T01' ? ( <>