From bf98f69145a754ac259d1c25aba41905061b4be2 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 12 Jun 2026 17:11:40 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[2294=5F4]=20=ED=83=80=EC=9E=85=20=EB=B0=95?= =?UTF-8?q?=EA=B3=B5=E2=86=92=EC=B2=98=EB=A7=88=20=EC=9A=B0=EC=A7=84?= =?UTF-8?q?=EA=B0=81=20=EC=A7=80=EB=B6=95=ED=95=A0=EB=8B=B9=204=EB=A9=B4?= =?UTF-8?q?=20=EB=AF=B8=EC=83=9D=EC=84=B1=20=EC=88=98=EC=A0=95=20=E2=80=94?= =?UTF-8?q?=20SK=20=EA=B2=A9=EC=9E=90=20=EB=B0=98-=EC=97=A3=EC=A7=80=20pur?= =?UTF-8?q?ge=20+=20startPoint=20=EB=B0=A9=EC=96=B4=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useEavesGableEdit.js | 9 +++++++++ src/hooks/roofcover/useRoofAllocationSetting.js | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 7dc90dc5..b050fac3 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -3748,6 +3748,11 @@ export function useEavesGableEdit(id) { // 힙 = apex → hit (단일 직선 45°). if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y }) else il.set({ x1: hit.x, y1: hit.y, x2: apex.x, y2: apex.y }) + // [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] .set 은 'modified' 를 안 띄워 startPoint/endPoint + // 가 옛 좌표로 남는다. 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로 + // 동기화 누락 시 절삭된 힙이 옛 좌표로 연결돼 인접 roofLine 이 면에 포함되지 않아 미할당된다. + il.startPoint = { x: il.x1, y: il.y1 } + il.endPoint = { x: il.x2, y: il.y2 } const sz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) if (il.attributes) { il.attributes.planeSize = sz @@ -3839,6 +3844,10 @@ export function useEavesGableEdit(id) { const d2 = Math.hypot(h.il.x2 - cenX, h.il.y2 - cenY) if (d1 >= d2) h.il.set({ x2: rp.x, y2: rp.y }) else h.il.set({ x1: rp.x, y1: rp.y }) + // [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] 절삭 후 startPoint/endPoint 동기화. + // 누락 시 할당 graph 가 옛 inner 끝점으로 면을 닫으려다 실패 → 인접 roofLine 미할당. + h.il.startPoint = { x: h.il.x1, y: h.il.y1 } + h.il.endPoint = { x: h.il.x2, y: h.il.y2 } const sz = calcLinePlaneSize({ x1: h.il.x1, y1: h.il.y1, x2: h.il.x2, y2: h.il.y2 }) if (h.il.attributes) { h.il.attributes.planeSize = sz diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 94688b98..b5ec24fb 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -1228,6 +1228,17 @@ export function useRoofAllocationSetting(id) { roofBase.innerLines.push(...made) } + // [KERAB-TYPE-EAVES-SK-HELPER-PURGE 2026-06-12] kerabPatternHip 이 있을 때 + // name='hip', lineName 미설정 인 SK 격자 반-엣지를 innerLines 에서 제거한다. + // 이 선들은 외곽 gable 변과 동일직선상에서 더 짧은 경로를 제공하여 Dijkstra 가 + // degenerate collinear face 를 먼저 선택 → 진짜 top/bottom 사다리꼴 면의 start 가 + // 소진돼 미생성된다. kerab 케이스에서만 purge — 일반 SK hip 에서는 건드리지 않음. + if (roofBase.innerLines.some((l) => l?.lineName === 'kerabPatternHip')) { + const skHelpers = roofBase.innerLines.filter((l) => !l?.lineName && l?.name === 'hip') + skHelpers.forEach((l) => canvas.remove(l)) + roofBase.innerLines = roofBase.innerLines.filter((l) => !(!l?.lineName && l?.name === 'hip')) + } + if (roofBase.separatePolygon.length > 0) { splitPolygonWithSeparate(roofBase.separatePolygon) } else { From 8511c1915cbaa20205e50cc94045e7a05308af88 Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 15 Jun 2026 13:45:44 +0900 Subject: [PATCH 2/5] =?UTF-8?q?local=EC=97=90=EC=84=9C=20=ED=95=9C?= =?UTF-8?q?=EA=B8=80=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/ja.json | 1 + src/locales/ko.json | 29 +++++++++++++++-------------- src/store/localeAtom.js | 5 ++++- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/locales/ja.json b/src/locales/ja.json index 0eb938ad..4d8af44c 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -589,6 +589,7 @@ "common.message.no.edit.save": "このドキュメントは変更できません。", "common.message.ref.file.change": "ファイルを変更しますか?", "common.message.ref.file.noImage": "画像ではありません。", + "common.message.ref.file.convertFail": "図面の変換に失敗しました。時間をおいて再度お試しください。", "common.load": "読込", "common.input.file": "ファイルを読み込む", "common.input.file.load": "読込", diff --git a/src/locales/ko.json b/src/locales/ko.json index 7d0a94f0..24a9482f 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -328,7 +328,7 @@ "modal.canvas.setting.first.option.display": "화면 표시", "modal.canvas.setting.first.option.border": "테두리만", "modal.canvas.setting.first.option.line": "라인해치", - "modal.canvas.setting.first.option.all": "All painted", + "modal.canvas.setting.first.option.all": "전체 칠하기", "modal.canvas.setting.wallline.properties.setting": "외벽선 속성 설정", "modal.canvas.setting.wallline.properties.setting.info": "※ 속성을 변경할 외벽선을 선택하고 처마로 설정 또는 케라바로 설정\n 버튼을 클릭하여 설정값을 적용하십시오.\n", "modal.canvas.setting.wallline.properties.setting.eaves": "처마로 설정", @@ -534,12 +534,12 @@ "common.message.file.template.validation03": "Non-registerable extension", "common.message.file.template.validation04": "Exceed capacity \n Uploadable capacity ", "common.message.file.template.validation05": "업로드 파일을 선택해주세요. {0} MB", - "common.message.multi.insert": "Total {0} cases ({1} successes {2} failures {3})", + "common.message.multi.insert": "총 {0}건 (성공 {1} 실패 {2} {3})", "common.message.error": "Error occurred please contact site administrator.", "common.message.data.save": "Do you want to save it?", "common.message.data.delete": "정말로 삭제하시겠습니까?", - "common.message.data.exists": "{0} is data that already exists.", - "common.message.data.no.exists": "{0} is data that does not exist.", + "common.message.data.exists": "{0}은(는) 이미 존재하는 데이터입니다.", + "common.message.data.no.exists": "{0}은(는) 존재하지 않는 데이터입니다.", "common.message.all": "All", "common.message.tab.close.all": "Close all tabs?", "common.message.transfer.save": "Want to {0} transfer it?", @@ -570,18 +570,18 @@ "common.message.menu.validation01": "There is no menu to save the order.", "common.message.menu.validation02": "The same sort order exists.", "common.message.menuCode.check01": "Registerable", - "common.message.menuCode.check02": "Unable to register", - "common.message.pleaseSelect": "Please Select {0}", - "common.message.pleaseInput": "Please Input a {0}.", - "common.message.pleaseInputOr": "Please Input a {0} or {1}.", + "common.message.menuCode.check02": "등록할 수 없습니다.", + "common.message.pleaseSelect": "{0}을(를) 선택해 주세요.", + "common.message.pleaseInput": "{0}을(를) 입력해 주세요.", + "common.message.pleaseInputOr": "{0} 또는 {1}을(를) 입력해 주세요.", "common.message.please.input.over": "{0} 이상 값을 입력해주세요.", "common.message.approved ": "Approved.", "common.message.errorFieldExist": "Error Field Exist", "common.message.storeIdExist ": "이미 사용하고 있는 판매점 ID 입니다.", "common.message.userIdExist ": "이미 사용하고 있는 사용자 ID 입니다.", "common.message.noExists ": "삭제된 게시물 입니다.", - "common.message.emailReqTo": "Email To is required", - "common.message.downloadPeriod": "Please select the download search period within {0} days.", + "common.message.emailReqTo": "받는 사람(Email To)은 필수입니다.", + "common.message.downloadPeriod": "다운로드 조회 기간을 {0}일 이내로 선택해 주세요.", "common.message.backToSubmit": "판매점 블록 해제를 실행하시겠습니까?", "common.message.backToG3": "Back to G3 처리를 실행하시겠습니까?", "common.message.writeToConfirm": "작성 해제를 실행하시겠습니까?", @@ -589,6 +589,7 @@ "common.message.no.edit.save": "This document cannot be changed.", "common.message.ref.file.change": "파일을 변경하시겠습니까?", "common.message.ref.file.noImage": "이미지가 아닙니다.", + "common.message.ref.file.convertFail": "도면 변환에 실패했습니다. 잠시 후 다시 시도해 주세요.", "common.load": "불러오기", "common.input.file": "파일 불러오기", "common.input.file.load": "불러오기", @@ -911,7 +912,7 @@ "offset": "출폭", "width": "폭", "size": "치수", - "size.angle": "寸(度)", + "size.angle": "치(도)", "eaves": "처마", "eaves.line": "처마선", "gable": "케라바", @@ -999,10 +1000,10 @@ "estimate.detail.objectName": "안건명", "estimate.detail.objectRemarks": "메모", "estimate.detail.estimateType": "주문분류", - "estimate.detail.estimateType.yjss": "住宅PKG", - "estimate.detail.estimateType.yjod": "積上げ( YJOD )", + "estimate.detail.estimateType.yjss": "주택PKG", + "estimate.detail.estimateType.yjod": "적산(YJOD)", "estimate.detail.agency": "2차점명", - "estimate.detail.roofCns": "지붕재・사양시공", + "estimate.detail.roofCns": "지붕재·시공구분", "estimate.detail.remarks": "비고", "estimate.detail.fileFlg": "후일자료제출", "estimate.detail.dragFileGuide": "(※ 북면설치인 경우, 파일 첨부가 필수입니다.)", diff --git a/src/store/localeAtom.js b/src/store/localeAtom.js index 0564e633..bc26ea12 100644 --- a/src/store/localeAtom.js +++ b/src/store/localeAtom.js @@ -1,8 +1,11 @@ import { atom } from 'recoil' +// localhost(yarn dev, RUN_MODE=local) 에서만 기본 한국어, 그 외(dev/prd)는 일본어 +const DEFAULT_LOCALE = process.env.NEXT_PUBLIC_RUN_MODE === 'local' ? 'ko' : 'ja' + export const globalLocaleStore = atom({ key: 'globalLocaleState', - default: 'ja', + default: DEFAULT_LOCALE, }) export const appMessageStore = atom({ From 666e75244dd5e415dca1d5ac0ce3f3d9b01b66a1 Mon Sep 17 00:00:00 2001 From: "DESKTOP-6E8S9S5\\LEE" Date: Mon, 15 Jun 2026 15:59:50 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EB=B0=9C=EC=A0=84=EC=8B=9C=EB=AE=AC?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=ED=8C=9D=EC=97=85=EC=B0=BD=20?= =?UTF-8?q?=EC=98=A4=ED=94=88=20=EC=86=8C=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 2 ++ .env.local.dev | 2 ++ .env.localhost | 2 ++ .env.production | 2 ++ src/components/header/Header.jsx | 30 ++++++++++++++++++++++++++++++ src/locales/ja.json | 1 + src/locales/ko.json | 1 + 7 files changed, 40 insertions(+) diff --git a/.env.development b/.env.development index 6b3844f8..0d44ce67 100644 --- a/.env.development +++ b/.env.development @@ -15,6 +15,8 @@ NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png? NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="https://q-order-dev.q-cells.jp/eos/login/autoLogin" NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="https://q-musubi-dev.q-cells.jp/qm/login/autoLogin" +NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL="https://q-order-dev.q-cells.jp/eos/simulation/auth" +NEXT_PUBLIC_ECONOMIC_SIMULATION_URL="https://q-order-dev.q-cells.jp/eos/simulation/list" # 테스트용 # AWS_REGION="ap-northeast-2" diff --git a/.env.local.dev b/.env.local.dev index a53524ca..8b31ac6e 100644 --- a/.env.local.dev +++ b/.env.local.dev @@ -14,6 +14,8 @@ NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secr NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="http://q-order-stg.q-cells.jp:8120/eos/login/autoLogin" NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="http://q-musubi-stg.q-cells.jp:8120/qm/login/autoLogin" +NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/auth" +NEXT_PUBLIC_ECONOMIC_SIMULATION_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/list" # 테스트용 # AWS_REGION="ap-northeast-2" diff --git a/.env.localhost b/.env.localhost index 7cab2757..cd3a3245 100644 --- a/.env.localhost +++ b/.env.localhost @@ -15,6 +15,8 @@ NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png? NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="http://q-order-stg.q-cells.jp:8120/eos/login/autoLogin" NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="http://q-musubi-stg.q-cells.jp:8120/qm/login/autoLogin" +NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/auth" +NEXT_PUBLIC_ECONOMIC_SIMULATION_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/list" # 테스트용 # AWS_REGION="ap-northeast-2" diff --git a/.env.production b/.env.production index 50904369..e421b123 100644 --- a/.env.production +++ b/.env.production @@ -15,6 +15,8 @@ NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png? NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="https://q-order.q-cells.jp/eos/login/autoLogin" NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="https://q-musubi.q-cells.jp/qm/login/autoLogin" +NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL="https://q-order.q-cells.jp/eos/simulation/auth" +NEXT_PUBLIC_ECONOMIC_SIMULATION_URL="https://q-order.q-cells.jp/eos/simulation/list" # AWS_REGION="ap-northeast-2" # AMPLIFY_BUCKET="interplug" diff --git a/src/components/header/Header.jsx b/src/components/header/Header.jsx index 8c2ccbff..689bac59 100644 --- a/src/components/header/Header.jsx +++ b/src/components/header/Header.jsx @@ -164,6 +164,29 @@ export default function Header(props) { getAutoLoginParam() }, [userSession]) + const openEconomicSimulation = async () => { + const encRes = await promiseGet({ + url: '/api/pwrGnrSimulation/encData', + params: { saleStoreId: sessionState.saleStoreId, userId: sessionState.userId }, + }) + const { encSaleStoreId, encUserId } = encRes.data + + if (!encSaleStoreId || !encUserId) return + + const authRes = await fetch(process.env.NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ encSaleStoreId, encUserId }), + }) + + if (authRes.ok) { + window.open(process.env.NEXT_PUBLIC_ECONOMIC_SIMULATION_URL, 'economicSimulation', `height=${screen.height},width=${screen.width},fullscreen=yes`) + } else { + const body = await authRes.json() + swalFire({ text: body.resultMessage, type: 'error' }) + } + } + const menus = [ { id: 0, name: 'header.menus.home', url: '/', children: [] }, { @@ -187,6 +210,7 @@ export default function Header(props) { ], }, { id: 8, name: 'qna.title', url: '/community/qna', children: [] }, + { id: 9, name: 'header.menus.economic.simulation', action: openEconomicSimulation, children: [] }, ] const syncSession = useCallback(() => { @@ -237,6 +261,11 @@ export default function Header(props) { onMouseLeave={(e) => ToggleonMouse(e, 'remove', 'nav > ul')} > {menu.children.length === 0 ? ( + menu.action ? ( + + ) : ( {getMessage(menu.name)} + ) ) : ( diff --git a/src/locales/ja.json b/src/locales/ja.json index 0eb938ad..3932290e 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -10,6 +10,7 @@ "header.menus.community.notice": "お知らせ", "header.menus.community.faq": "FAQ", "header.menus.community.archive": "各種資料ダウンロード", + "header.menus.economic.simulation": "経済シミュレーション管理", "header.logout": "ログアウト", "header.go": "移動", "header.online.warranty.system": "オンライン保証システム", diff --git a/src/locales/ko.json b/src/locales/ko.json index 7d0a94f0..68d0d476 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -10,6 +10,7 @@ "header.menus.community.notice": "공지사항", "header.menus.community.faq": "FAQ", "header.menus.community.archive": "각종 자료 다운로드", + "header.menus.economic.simulation": "경제시뮬레이션 관리", "header.logout": "로그아웃", "header.go": "이동", "header.online.warranty.system": "온라인보증시스템", From 1e0124d9f275e87a0a17f80d5ae9c5f690aba8fb Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 15 Jun 2026 16:01:11 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[2294=5F5]=20=EC=BC=80=EB=9D=BC=EB=B0=94=20?= =?UTF-8?q?=EC=82=AC=EA=B0=81=ED=98=95=20=EA=B2=B0=EC=A0=95=EB=A1=A0?= =?UTF-8?q?=EC=A0=81=20=EC=86=94=EB=B2=84=20+=20=EC=B2=98=EB=A7=88?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=EC=A7=80=EB=B6=95=EB=A9=B4=20=ED=95=A0?= =?UTF-8?q?=EB=8B=B9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - solveRectangleKerab: 4변 타입만으로 힙/마루 재생성(forward·revert 단일 공식) - nGable=0 우진각·1 Y apex·1 CASE B 케라바·2 맞배 지원, 그 외 기존 maze 폴백 - hip attributes.extended=true 부여 → integrateExtensionLines merge 스킵 → split 4면 정상 할당 - outerLine bbox 기준 sideOf 수정(出幅 offset 좌표 불일치 해소) - apex depth clamp min(L/2, oppDist) → R3-outside 방지 - detectKerabHipMarks·extendKerabHipsTo45 함수 추출(reclick·revert 45° 통합) - KERAB-EXTHIP-COORD-CLEAN: orphan ext-hip 좌표 기반 재제거 - useRefFiles: 변환 API FileData 누락 시 에러 메시지 swalFire 안내 - kerab-offset-surgical: skipKerabHips 옵션(케라바 hip t-ratio 재계산 방지) Co-Authored-By: Claude Opus 4 --- src/hooks/common/useRefFiles.js | 107 +-- src/hooks/roofcover/useEavesGableEdit.js | 827 ++++++++++++++++------- src/util/kerab-offset-surgical.js | 6 +- 3 files changed, 641 insertions(+), 299 deletions(-) diff --git a/src/hooks/common/useRefFiles.js b/src/hooks/common/useRefFiles.js index 4c037d0c..26bc6455 100644 --- a/src/hooks/common/useRefFiles.js +++ b/src/hooks/common/useRefFiles.js @@ -253,58 +253,71 @@ export function useRefFiles() { * @param converterUrl */ const handleUploadConvertRefFile = async (file, converterUrl) => { - const formData = new FormData() - formData.append('file', file) + try { + const formData = new FormData() + formData.append('file', file) - /** 캐드 도면 파일 변환 */ - const res = await post({ url: converterUrl, data: formData }) - logger.log('🚀 ~ handleUploadConvertRefFile ~ res:', res) + /** 캐드 도면 파일 변환 */ + const res = await post({ url: converterUrl, data: formData }) + logger.log('🚀 ~ handleUploadConvertRefFile ~ res:', res) - // Convert Base64 to Blob - const base64Data = res.Files[0].FileData - const byteCharacters = atob(base64Data) - const byteArrays = [] - - for (let offset = 0; offset < byteCharacters.length; offset += 512) { - const slice = byteCharacters.slice(offset, offset + 512) - const byteNumbers = new Array(slice.length) - - for (let i = 0; i < slice.length; i++) { - byteNumbers[i] = slice.charCodeAt(i) + // 변환 API 가 정상 변환 대신 에러 본문(Code/Message)을 반환한 경우 그대로 throw [DXF-CONVERT-FAIL 2026-06-15] + if (!res?.Files?.[0]?.FileData) { + throw new Error(res?.Message || getMessage('common.message.ref.file.convertFail')) } - const byteArray = new Uint8Array(byteNumbers) - byteArrays.push(byteArray) + // Convert Base64 to Blob + const base64Data = res.Files[0].FileData + const byteCharacters = atob(base64Data) + const byteArrays = [] + + for (let offset = 0; offset < byteCharacters.length; offset += 512) { + const slice = byteCharacters.slice(offset, offset + 512) + const byteNumbers = new Array(slice.length) + + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i) + } + + const byteArray = new Uint8Array(byteNumbers) + byteArrays.push(byteArray) + } + + const blob = new Blob(byteArrays, { type: 'image/png' }) + + // Create File object from Blob + const convertImg = new File([blob], res.Files[0].FileName, { type: 'image/png' }) + + const newFormData = new FormData() + newFormData.append('file', convertImg) + + /** 캐드 도면 파일 업로드 */ + const result = await post({ + url: `${Config().baseUrl}/api/image/cad`, + data: newFormData, + }) + logger.log('🚀 ~ handleUploadConvertRefFile ~ result:', result) + + setCurrentBgImage(`${process.env.NEXT_PUBLIC_AWS_S3_BASE_URL}/${result.fileName}`) + // setCurrentBgImage(result.filePath) + setRefImage(file) + + const params = { + objectId: currentCanvasPlan.id, + planNo: currentCanvasPlan.planNo, + // imagePath: `${process.env.NEXT_PUBLIC_HOST_URL}${res.filePath}`, + imagePath: `${result.filePath}`, + } + logger.log('🚀 ~ handleUploadImageRefFile ~ params:', params) + await setBackGroundImage(params) + } catch (error) { + // 변환/업로드 실패 시 API 응답 메시지를 그대로 노출 (없으면 기본 안내) [DXF-CONVERT-FAIL 2026-06-15] + logger.error('🚨 handleUploadConvertRefFile error:', error) + const apiMessage = error?.response?.data?.Message || error?.message || getMessage('common.message.ref.file.convertFail') + swalFire({ text: apiMessage, type: 'alert', icon: 'error' }) + } finally { + setPopSpinnerStore(false) } - - const blob = new Blob(byteArrays, { type: 'image/png' }) - - // Create File object from Blob - const convertImg = new File([blob], res.Files[0].FileName, { type: 'image/png' }) - - const newFormData = new FormData() - newFormData.append('file', convertImg) - - /** 캐드 도면 파일 업로드 */ - const result = await post({ - url: `${Config().baseUrl}/api/image/cad`, - data: newFormData, - }) - logger.log('🚀 ~ handleUploadConvertRefFile ~ result:', result) - - setCurrentBgImage(`${process.env.NEXT_PUBLIC_AWS_S3_BASE_URL}/${result.fileName}`) - // setCurrentBgImage(result.filePath) - setRefImage(file) - - const params = { - objectId: currentCanvasPlan.id, - planNo: currentCanvasPlan.planNo, - // imagePath: `${process.env.NEXT_PUBLIC_HOST_URL}${res.filePath}`, - imagePath: `${result.filePath}`, - } - logger.log('🚀 ~ handleUploadImageRefFile ~ params:', params) - await setBackGroundImage(params) - setPopSpinnerStore(false) } /** diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index b050fac3..d76900cc 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -167,6 +167,184 @@ export function useEavesGableEdit(id) { // R3 outside: 끝점이 roofLine 폴리곤 밖(경계 tol 초과)으로 이탈 금지. // R4 anchor: 토글 전후로 움직이지 않은 roofLine 코너(stable corner)의 끝점 점유수 불변 // (코너에서 hip 이 떨어지거나 엉뚱한 코너로 횡단하면 점유수 변화). + // [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (1/2): 케라바 hip(확장라인) 식별. + // reclick 검증 코드에서 무손실 추출. surgical 적용 *전*(옛 폴리곤) 에 호출해야 한다 — + // outer endpoint = 옛 roof.points 변 위 끝점, side = hip 직선이 지나는 wall 코너(A/B). + const detectKerabHipMarks = (target, roof) => { + const marks = [] + if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return marks + const wA = { x: target.x1, y: target.y1 } + const wB = { x: target.x2, y: target.y2 } + const pts = roof.points + const isOnOldPolyEdge = (P) => { + for (let i = 0; i < pts.length; i++) { + const A = pts[i] + const B = pts[(i + 1) % pts.length] + const ddx = B.x - A.x + const ddy = B.y - A.y + const lenSq = ddx * ddx + ddy * ddy + if (lenSq < 1e-6) continue + const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq + if (t < -0.02 || t > 1.02) continue + const projX = A.x + t * ddx + const projY = A.y + t * ddy + const d = Math.hypot(P.x - projX, P.y - projY) + if (d < 2.0) return true + } + return false + } + for (const il of roof.innerLines) { + if (!il || (il.lineName !== 'hip' && il.lineName !== 'kerabPatternHip')) continue + const e1 = { x: il.x1, y: il.y1 } + const e2 = { x: il.x2, y: il.y2 } + const e1OnEdge = isOnOldPolyEdge(e1) + const e2OnEdge = isOnOldPolyEdge(e2) + let which = null + if (e1OnEdge && !e2OnEdge) which = 1 + else if (e2OnEdge && !e1OnEdge) which = 2 + if (which === null) continue + const outerEnd = which === 1 ? e1 : e2 + const innerEnd = which === 1 ? e2 : e1 + const ddx = outerEnd.x - innerEnd.x + const ddy = outerEnd.y - innerEnd.y + const llen = Math.hypot(ddx, ddy) || 1 + const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen) + const dA = distPerp(wA) + const dB = distPerp(wB) + if (Math.min(dA, dB) > 5.0) continue + const side = dA <= dB ? 'A' : 'B' + marks.push({ il, which, side }) + } + logger.log( + '[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' + + JSON.stringify(marks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))), + ) + return marks + } + // [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (2/2): 케라바 hip 을 45° ray-cast 확장. + // surgical 적용 *후*(새 폴리곤) 에 호출. wall 코너에서 옛 hip 방향(45°)으로 새 roofLine 변까지. + // CORNER-SNAP 금지 룰의 실제 구현부 — roofLine 코너가 아니라 교점까지 뻗는다. + // [45-UNIFY 2026-06-15] reclick·revert 두 경로의 45° 구현을 한 벌로 통합. 가드는 opt-in: + // - useRevertGuards=false(reclick): 순수 45° ray-cast (기존 reclick 동작 그대로). + // - useRevertGuards=true(revert): outerShared(골짜기 내부 hip 제외) + outerOnVertex(게이블 코너 + // hip 은 같은 인덱스 새 꼭짓점으로 직접 스냅) 가드 적용. oldPolyPoints = 이동 전 폴리곤 꼭짓점. + const extendKerabHipsTo45 = (target, roof, hipMarks, options = {}) => { + if (!roof || !Array.isArray(roof.points) || !hipMarks || !hipMarks.length) return + const { useRevertGuards = false, oldPolyPoints = null } = options + const wA = { x: target.x1, y: target.y1 } + const wB = { x: target.x2, y: target.y2 } + const rps = roof.points + const M = rps.length + if (!M) return + const baseTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45]' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES]' + const okTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45] ' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended ' + const rayHit = (P, dir, A, B) => { + const sx = B.x - A.x + const sy = B.y - A.y + const denom = dir.x * sy - dir.y * sx + if (Math.abs(denom) < 1e-9) return Infinity + const ax = A.x - P.x + const ay = A.y - P.y + const t = (ax * sy - ay * sx) / denom + const s = (ax * dir.y - ay * dir.x) / denom + if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t + return Infinity + } + const cast45 = (wCorner, innerEnd, outerOld, il, which, side) => { + const dx = outerOld.x - innerEnd.x + const dy = outerOld.y - innerEnd.y + const dlen = Math.hypot(dx, dy) + if (dlen < 1e-6) return null + const ux = dx / dlen + const uy = dy / dlen + let bestT = Infinity + for (let k = 0; k < M; k++) { + const A = rps[k] + const B = rps[(k + 1) % M] + const t = rayHit(wCorner, { x: ux, y: uy }, A, B) + if (t < bestT) bestT = t + } + if (!isFinite(bestT) || bestT < 0.5) { + logger.log(baseTag + ' no-hit ' + JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } })) + return null + } + return { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT } + } + for (const mark of hipMarks) { + const { il, which, side } = mark + const wCorner = side === 'A' ? wA : wB + const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } + const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } + let hit + if (useRevertGuards) { + // outerShared: outer 끝이 다른 내부선과 공유 → 골짜기 내부 hip → 확장 스킵. + const SHARE_TOL = 2.0 + const outerShared = (roof.innerLines || []).some( + (o) => + o && + o !== il && + o.visible !== false && + (Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL || + Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL), + ) + // outerOnVertex: outer 가 (이동 전) 폴리곤 꼭짓점이면 게이블 코너 hip → 같은 인덱스 새 꼭짓점 스냅. + const VERT_TOL = 2.0 + const opp = Array.isArray(oldPolyPoints) ? oldPolyPoints : [] + let vtxIdx = -1 + for (let vi = 0; vi < opp.length; vi++) { + const p = opp[vi] + if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) { + vtxIdx = vi + break + } + } + const outerOnVertex = vtxIdx >= 0 + if (outerShared && !outerOnVertex) { + logger.log(baseTag + ' skip (interior hip — outer shared) ' + JSON.stringify({ lineName: il.lineName, which, side, outerOld })) + continue + } + if (outerOnVertex && rps.length === opp.length && rps[vtxIdx]) { + hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y } + } else { + hit = cast45(wCorner, innerEnd, outerOld, il, which, side) + if (!hit) continue + } + } else { + hit = cast45(wCorner, innerEnd, outerOld, il, which, side) + if (!hit) continue + } + const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y) + const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y) + const ratio = oldLen > 0 ? newLen / oldLen : 1 + if (which === 1) il.set({ x1: hit.x, y1: hit.y }) + else il.set({ x2: hit.x, y2: hit.y }) + if (il.attributes) { + const oldPlane = il.attributes.planeSize ?? 0 + const oldActual = il.attributes.actualSize ?? 0 + il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100 + il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100 + il.attributes.extended = true + } + il.__extended = true + if (typeof il.setCoords === 'function') il.setCoords() + if (typeof il.setLength === 'function') il.setLength() + if (typeof il.addLengthText === 'function') il.addLengthText() + logger.log( + okTag + + JSON.stringify({ + lineName: il.lineName, + which, + side, + hit, + ratio: Number(ratio.toFixed(3)), + x1: il.x1, + y1: il.y1, + x2: il.x2, + y2: il.y2, + }), + ) + } + } const snapshotKerabState = (roof) => { if (!roof || !Array.isArray(roof.innerLines)) return null const lines = roof.innerLines @@ -441,137 +619,50 @@ export function useEavesGableEdit(id) { // (케라바 ONLY-RECLICK 은 KERAB-PATTERN-CORNER-SNAP 필요하므로 그대로 둠.) const isEaves = attributes?.type === LINE_TYPE.WALLLINE.EAVES let reclickRoof = null - const hipMarks = [] + let hasKerabPattern = false + let hipMarks = [] if (isEaves) { reclickRoof = canvas .getObjects() .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) if (reclickRoof && Array.isArray(reclickRoof.innerLines) && Array.isArray(reclickRoof.points)) { - const wA = { x: target.x1, y: target.y1 } - const wB = { x: target.x2, y: target.y2 } - const pts = reclickRoof.points - // outer endpoint 식별: 옛 roof.points 변 위 (perpendicular distance < 2) 인 끝점. - const isOnOldPolyEdge = (P) => { - for (let i = 0; i < pts.length; i++) { - const A = pts[i] - const B = pts[(i + 1) % pts.length] - const ddx = B.x - A.x - const ddy = B.y - A.y - const lenSq = ddx * ddx + ddy * ddy - if (lenSq < 1e-6) continue - const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq - if (t < -0.02 || t > 1.02) continue - const projX = A.x + t * ddx - const projY = A.y + t * ddy - const d = Math.hypot(P.x - projX, P.y - projY) - if (d < 2.0) return true - } - return false - } - for (const il of reclickRoof.innerLines) { - if (!il || il.lineName !== 'hip') continue - const e1 = { x: il.x1, y: il.y1 } - const e2 = { x: il.x2, y: il.y2 } - const e1OnEdge = isOnOldPolyEdge(e1) - const e2OnEdge = isOnOldPolyEdge(e2) - let which = null - if (e1OnEdge && !e2OnEdge) which = 1 - else if (e2OnEdge && !e1OnEdge) which = 2 - if (which === null) continue - const outerEnd = which === 1 ? e1 : e2 - const innerEnd = which === 1 ? e2 : e1 - // transit corner: hip 직선 위에 wA/wB 중 어느 코너가 있는지 (perpendicular distance). - const ddx = outerEnd.x - innerEnd.x - const ddy = outerEnd.y - innerEnd.y - const llen = Math.hypot(ddx, ddy) || 1 - const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen) - const dA = distPerp(wA) - const dB = distPerp(wB) - if (Math.min(dA, dB) > 5.0) continue - const side = dA <= dB ? 'A' : 'B' - hipMarks.push({ il, which, side }) - } - logger.log( - '[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' + - JSON.stringify(hipMarks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))), + hasKerabPattern = reclickRoof.innerLines.some( + (il) => il && typeof il.lineName === 'string' && il.lineName.startsWith('kerabPattern'), ) + // surgical 적용 *전*(옛 폴리곤) 에 케라바 hip 식별 — outer endpoint 가 옛 변 위에 있어야 잡힌다. + hipMarks = detectKerabHipMarks(target, reclickRoof) } } - applyTargetOffsetSurgical(target, newOffset, isEaves ? { skipInnerLines: true } : undefined) - if (isEaves && reclickRoof && hipMarks.length) { - const wA = { x: target.x1, y: target.y1 } - const wB = { x: target.x2, y: target.y2 } - const rps = reclickRoof.points - const M = rps.length - const rayHit = (P, dir, A, B) => { - const sx = B.x - A.x - const sy = B.y - A.y - const denom = dir.x * sy - dir.y * sx - if (Math.abs(denom) < 1e-9) return Infinity - const ax = A.x - P.x - const ay = A.y - P.y - const t = (ax * sy - ay * sx) / denom - const s = (ax * dir.y - ay * dir.x) / denom - if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t - return Infinity - } - for (const mark of hipMarks) { - const { il, which, side } = mark - const wCorner = side === 'A' ? wA : wB - const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } - const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } - const dx = outerOld.x - innerEnd.x - const dy = outerOld.y - innerEnd.y - const dlen = Math.hypot(dx, dy) - if (dlen < 1e-6) continue - const ux = dx / dlen - const uy = dy / dlen - let bestT = Infinity - for (let k = 0; k < M; k++) { - const A = rps[k] - const B = rps[(k + 1) % M] - const t = rayHit(wCorner, { x: ux, y: uy }, A, B) - if (t < bestT) bestT = t - } - if (!isFinite(bestT) || bestT < 0.5) { - logger.log( - '[KERAB-OFFSET-ONLY-RECLICK-EAVES] no-hit ' + - JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }), - ) - continue - } - const hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT } - const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y) - const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y) - const ratio = oldLen > 0 ? newLen / oldLen : 1 - if (which === 1) il.set({ x1: hit.x, y1: hit.y }) - else il.set({ x2: hit.x, y2: hit.y }) - if (il.attributes) { - const oldPlane = il.attributes.planeSize ?? 0 - const oldActual = il.attributes.actualSize ?? 0 - il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100 - il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100 - il.attributes.extended = true - } - il.__extended = true + // [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] 出幅 재변경(eaves→eaves)을 skLine 과 동일 경로로 통일. + // 출발 형상(skLine / A/B타입)에 무관하게 동일 규칙(= 출폭함수): + // - 케라바 hip(확장라인)은 surgical CORNER-SNAP 금지 → wall 코너에서 45° ray-cast 로 roofLine 까지. + // - 측면 수직선/변조각(normal line)은 surgical(recomputeNormalLine)이 새 코너로 추종. + // skLine: 케라바 패턴 없음 → skipInnerLines(전부 45°가 처리). + // A/B타입: 케라바 패턴 보유 → skipKerabHips(normal/ridge 는 surgical, hip 만 45°가 처리). + // 출폭 증감(확장/수축) 양방향이 한 경로에서 일관 처리됨. + const reclickBeforeSnap = isEaves && reclickRoof ? snapshotKerabState(reclickRoof) : null + applyTargetOffsetSurgical( + target, + newOffset, + isEaves ? (hasKerabPattern ? { skipKerabHips: true } : { skipInnerLines: true }) : undefined, + ) + // surgical 적용 *후*(새 폴리곤) 에 케라바 hip 을 45° ray-cast 로 새 roofLine 변까지 확장. + if (isEaves && reclickRoof) extendKerabHipsTo45(target, reclickRoof, hipMarks) + // [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] A/B타입은 케라바 hip 외 inner line 좌표가 + // surgical 내부(recomputeKerabPatternLine/recomputeNormalLine)에서 갱신됨 → + // 길이/치수 라벨만 새 좌표 기준으로 새로고침한다(hip 은 45° 블록에서 이미 갱신). + if (isEaves && hasKerabPattern && reclickRoof && Array.isArray(reclickRoof.innerLines)) { + for (const il of reclickRoof.innerLines) { + if (!il || il.lineName === 'kerabPatternHip') continue if (typeof il.setCoords === 'function') il.setCoords() if (typeof il.setLength === 'function') il.setLength() if (typeof il.addLengthText === 'function') il.addLengthText() - logger.log( - '[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended ' + - JSON.stringify({ - lineName: il.lineName, - which, - side, - hit, - ratio: Number(ratio.toFixed(3)), - x1: il.x1, - y1: il.y1, - x2: il.x2, - y2: il.y2, - }), - ) } + logger.log('[KERAB-OFFSET-RECLICK-UNIFY] surgical label refresh done') + } + // [KERAB-OFFSET-RECLICK-RULECHECK 2026-06-15] 라인변경(출폭 재적용) 후 규칙 검사 — 사용자 요구. + if (isEaves && reclickRoof && reclickBeforeSnap) { + runKerabRuleCheck(reclickRoof, 'reclick', reclickBeforeSnap) } target.set({ attributes }) canvas.renderAll() @@ -604,6 +695,19 @@ export function useEavesGableEdit(id) { } } + // [KERAB-RECT-SOLVER 2026-06-15] 사각형이면 토글 이력과 무관한 결정론적 솔버로 처리. + // 최종 4변 타입만으로 내부선 전부 재생성(Y 불변식 + apex 멈춤 구조적 보장). 미지원 형상은 + // false → 아래 기존 forward/revert/type 경로로 폴백. + if (radioTypeRef.current === '1' && (attributes?.type === LINE_TYPE.WALLLINE.EAVES || attributes?.type === LINE_TYPE.WALLLINE.GABLE)) { + const rectRoof = canvas + .getObjects() + .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) + if (rectRoof && solveRectangleKerab(rectRoof, target, attributes)) { + logger.log('[KERAB-RECT-SOLVER] handled deterministically → maze 우회') + return + } + } + // [2240 KERAB-SIMPLE 2026-05-20] 사용자 설명 정직 알고리즘: // 1) target 양 끝점에 직접 끝이 닿은 hip 2개를 찾는다 (nearestRoofPoint 안 씀) // 2) 두 hip 직선의 무한확장 교점 = apex @@ -2551,139 +2655,18 @@ export function useEavesGableEdit(id) { // 게이블 코너 hip 판정용으로 이동 전 옛 폴리곤 꼭짓점을 미리 스냅샷한다. const oldPolyPoints = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y })) if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true }) - const wA = { x: target.x1, y: target.y1 } - const wB = { x: target.x2, y: target.y2 } - const rps = Array.isArray(roof.points) ? roof.points : [] - const M = rps.length - if (!M) return - const rayHit = (P, dir, A, B) => { - const sx = B.x - A.x - const sy = B.y - A.y - const denom = dir.x * sy - dir.y * sx - if (Math.abs(denom) < 1e-9) return Infinity - const ax = A.x - P.x - const ay = A.y - P.y - const t = (ax * sy - ay * sx) / denom - const s = (ax * dir.y - ay * dir.x) / denom - if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t - return Infinity - } + // [45-UNIFY 2026-06-15] reclick 과 동일한 extendKerabHipsTo45 사용 (revert 가드 on): + // forward 가 붙여둔 __kerabRevertOuterWhich/Side 플래그로 marks 를 만든 뒤, surgical *후* + // 새 폴리곤에 대해 outerShared/outerOnVertex 가드를 적용해 45° 확장. 플래그는 사용 후 정리. + const marks = [] for (const il of roof.innerLines || []) { if (!il || !il.__kerabRevertOuterWhich) continue - const which = il.__kerabRevertOuterWhich - const side = il.__kerabRevertOuterSide - const wCorner = side === 'A' ? wA : wB - const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } - const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } - // [KERAB-REVERT-SHARED-ENDPOINT-GUARD 2026-06-10] outer(확장) 끝이 다른 내부선과 - // 공유되면 확장하지 않는다. 골짜기(valley) 내부 hip 은 양 끝이 skeleton 교점이라 outer 도 - // 공유 → 자동 제외. 게이블/코너 hip 의 outer 는 free(roofLine 변에 홀로 도달) → 확장. - // 판정이 출폭·snapshot 좌표와 무관한 구조 기준이라 다중 출폭 변경에도 안 깨진다. - // (구 c1/c2 가드는 c1/c2 를 현재 출폭으로, outerOld 를 forward 당시 출폭으로 잡아 - // 출폭이 여러 번 바뀌면 게이블 hip 을 interior 로 오판 → SK 확장이 중간에 멈췄다.) - const SHARE_TOL = 2.0 - const outerShared = (roof.innerLines || []).some( - (o) => - o && - o !== il && - o.visible !== false && - (Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL || - Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL), - ) - // [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] TYPE 출신 게이블 hip 의 outer 끝은 폴리곤 코너(roofLine 꼭짓점) - // 라 인접 면경계선과 공유 → outerShared 가 true 가 돼 확장이 스킵됐다(처마변경 실패: hip 이 옛 코너에 멈춤). - // 골짜기 내부 hip 의 outer 는 폴리곤 경계에 없는 skeleton 교점이다. 따라서 outerOld 가 (이동 전) 폴리곤 - // 꼭짓점이면 게이블 코너 hip 으로 보고 확장을 강제, 공유여도 스킵하지 않는다. - const VERT_TOL = 2.0 - // outerOld 가 (이동 전) 폴리곤 꼭짓점이면 그 인덱스를 잡는다. surgical 은 점 순서·개수를 보존하므로 - // 같은 인덱스의 새 roof.points 가 곧 이동 후 코너 = 게이블 hip 의 새 outer 끝점. - let vtxIdx = -1 - for (let vi = 0; vi < oldPolyPoints.length; vi++) { - const p = oldPolyPoints[vi] - if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) { - vtxIdx = vi - break - } - } - const outerOnVertex = vtxIdx >= 0 - if (outerShared && !outerOnVertex) { - logger.log( - '[KERAB-REVERT-EXTEND-45] skip (interior hip — outer shared) ' + - JSON.stringify({ lineName: il.lineName, which, side, outerOld }), - ) - delete il.__kerabRevertOuterWhich - delete il.__kerabRevertOuterSide - continue - } - let hit - // [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] 게이블 코너 hip: 45° ray-cast 는 출폭이 클 때 인접 - // 변에 먼저 닿아(예: 출폭 80 → -803 에서 멈춤) 코너(-839)에 못 미친다 → rLine 겹침/틈. - // surgical 후 동일 인덱스 코너로 직접 스냅해 항상 새 roofLine 꼭짓점까지 도달시킨다. - if (outerOnVertex && rps.length === oldPolyPoints.length && rps[vtxIdx]) { - hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y } - } else { - // hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음. - const dx = outerOld.x - innerEnd.x - const dy = outerOld.y - innerEnd.y - const dlen = Math.hypot(dx, dy) - if (dlen < 1e-6) { - delete il.__kerabRevertOuterWhich - delete il.__kerabRevertOuterSide - continue - } - const ux = dx / dlen - const uy = dy / dlen - // wL 코너에서 45° 방향으로 ray → 새 rL 변 (roof.points 는 surgical 후 새 출폭) 의 첫 hit. - let bestT = Infinity - for (let k = 0; k < M; k++) { - const A = rps[k] - const B = rps[(k + 1) % M] - const t = rayHit(wCorner, { x: ux, y: uy }, A, B) - if (t < bestT) bestT = t - } - if (!isFinite(bestT) || bestT < 0.5) { - logger.log( - '[KERAB-REVERT-EXTEND-45] no-hit ' + - JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }), - ) - delete il.__kerabRevertOuterWhich - delete il.__kerabRevertOuterSide - continue - } - hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT } - } - const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y) - const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y) - const ratio = oldLen > 0 ? newLen / oldLen : 1 - if (which === 1) il.set({ x1: hit.x, y1: hit.y }) - else il.set({ x2: hit.x, y2: hit.y }) - if (il.attributes) { - const oldPlane = il.attributes.planeSize ?? 0 - const oldActual = il.attributes.actualSize ?? 0 - il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100 - il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100 - il.attributes.extended = true - } - il.__extended = true - if (typeof il.setCoords === 'function') il.setCoords() - if (typeof il.setLength === 'function') il.setLength() - if (typeof il.addLengthText === 'function') il.addLengthText() - logger.log( - '[KERAB-REVERT-EXTEND-45] ' + - JSON.stringify({ - lineName: il.lineName, - which, - side, - hit, - ratio: Number(ratio.toFixed(3)), - x1: il.x1, - y1: il.y1, - x2: il.x2, - y2: il.y2, - }), - ) - delete il.__kerabRevertOuterWhich - delete il.__kerabRevertOuterSide + marks.push({ il, which: il.__kerabRevertOuterWhich, side: il.__kerabRevertOuterSide }) + } + extendKerabHipsTo45(target, roof, marks, { useRevertGuards: true, oldPolyPoints }) + for (const m of marks) { + delete m.il.__kerabRevertOuterWhich + delete m.il.__kerabRevertOuterSide } runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap) } @@ -3403,6 +3386,31 @@ export function useEavesGableEdit(id) { removeLine(eh) roof.innerLines = roof.innerLines.filter((il) => il !== eh) } + // [KERAB-EXTHIP-COORD-CLEAN 2026-06-15] __extHipsCreated 는 객체 참조라, 양쪽 박공→한 변씩 처마 + // 복원 시퀀스에서 중간 인접변 작업이 ext-hip 을 파괴·재생성하면 죽은 객체를 가리켜 위 루프가 + // no-op 이 된다 → 재생성된 ext-hip 이 orphan 으로 잔류(apex 에서 끝나는 1881 V, "라인은 roofLine + // 까지" 규칙 위반). 이 kLine 의 apex(=roofLine 반대쪽 안쪽 끝점) 를 공유하는 ext 라인을 좌표로 + // 한 번 더 제거해 재생성돼도 잡는다. orphan 없으면 0개 제거 → 정상 케이스 무영향. + { + const _r = ridgeAtMid.ridge + const _tMid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 } + const _apexPt = + Math.hypot(_r.x1 - _tMid.x, _r.y1 - _tMid.y) >= Math.hypot(_r.x2 - _tMid.x, _r.y2 - _tMid.y) + ? { x: _r.x1, y: _r.y1 } + : { x: _r.x2, y: _r.y2 } + const _EXT_TOL = 1.0 + const _orphans = roof.innerLines.filter( + (il) => + il && + (il.lineName === 'kerabPatternExtHip' || il.lineName === 'kerabPatternExtRidge') && + (Math.hypot(il.x1 - _apexPt.x, il.y1 - _apexPt.y) < _EXT_TOL || Math.hypot(il.x2 - _apexPt.x, il.y2 - _apexPt.y) < _EXT_TOL), + ) + for (const eh of _orphans) { + removeLine(eh) + roof.innerLines = roof.innerLines.filter((il) => il !== eh) + } + if (_orphans.length) logger.log('[KERAB-EXTHIP-COORD-CLEAN] removed ' + JSON.stringify({ apex: _apexPt, count: _orphans.length })) + } removeLine(ridgeAtMid.ridge) roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge) for (const snap of removedHips) { @@ -3555,10 +3563,37 @@ export function useEavesGableEdit(id) { ix = -ix iy = -iy } - const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) } + // [KERAB-TYPE-EAVES-APEX-CLAMP 2026-06-15] apex 깊이 = L/2 (45° 이등변). 단 변이 폴리곤 inward + // 두께보다 길면(가로로 납작한 폴리곤) mid+inward*(L/2) 가 반대편 roofLine 을 넘어 폭주 + // (R3-outside). "라인은 roofLine 까지" 규칙 → inward ray 가 반대편 변에 닿는 거리로 상한. + // 정상 切妻(세로로 긴 폴리곤)은 oppDist > L/2 라 min=L/2, 기존과 동치 → 무영향. + const oppRayDist = (P, dx, dy) => { + let best = Infinity + const M = cps.length + for (let i = 0; i < M; i++) { + const A = cps[i] + const B = cps[(i + 1) % M] + const ax = B.x - A.x + const ay = B.y - A.y + const den = dx * ay - dy * ax + if (Math.abs(den) < 1e-9) continue + const t = ((A.x - P.x) * ay - (A.y - P.y) * ax) / den + const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den + if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t + } + return best + } + const oppDist = oppRayDist(mid, ix, iy) + const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2) + const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth } // 마루 단축: 케라바변쪽 끝점 → apex. if (midIsA) ridge.set({ x1: apex.x, y1: apex.y }) else ridge.set({ x2: apex.x, y2: apex.y }) + // [KERAB-TYPE-EAVES-RIDGE-ZERO 2026-06-15] apex 가 반대편 roofLine 에 클램프되면 native 마루가 + // 거기로 단축돼 zero-length 가 될 수 있다(R2 위반). 객체는 revert 위해 보존하되 invisible 처리 + // (R2 체크는 visible 만 검사). 마루 형상 재구성은 완전 우진각 시 reconstruct 가 담당. + const ridgeLen = Math.hypot(ridge.x2 - ridge.x1, ridge.y2 - ridge.y1) + if (ridgeLen < 1) ridge.visible = false const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 }) if (ridge.attributes) { ridge.attributes.planeSize = rsz @@ -3945,6 +3980,296 @@ export function useEavesGableEdit(id) { if (lbl) lbl.set({ visible: !hide }) } + // [KERAB-RECT-SOLVER 2026-06-15] 사각형(axis-aligned 4코너) 결정론적 케라바 솔버. + // 토글 이력과 무관하게 「최종 4변 타입」만으로 내부선(힙/마루/케라바 줄기)을 매번 처음부터 + // 재생성한다 = 출폭/변타입의 순수 함수. forward/revert/type 미로를 통째로 우회. + // 구조적으로 보장되는 두 불변식: + // 불변식1(Y): 줄기(마루)는 박공으로 바뀐 변(slope 없음) 방향으로만 뻗는다. \|/, /|\ 불가. + // 불변식2: 힙은 apex(45° 교점)에서 멈춘다 → wallbase 교점 초과(폴리곤 밖) 힙 없음. + // 지원: 박공 0개(우진각)·1개(Y, apex 내부일 때)·2개 마주보기(맞배). 그 외(인접 2박공/3·4박공/ + // 비사각형/회전사각형/radio2 타입)는 false 반환 → 기존 경로 폴백. + const solveRectangleKerab = (roof, target, newAttributes) => { + try { + if (!roof || !Array.isArray(roof.points) || roof.points.length !== 4) return false + if (!Array.isArray(roof.innerLines)) roof.innerLines = [] + const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id) + // [KERAB-RECT-PROBE 2026-06-15] outerLine 이 wallbase(출폭0) 좌표인지 roofLine 좌표인지 + // 실제 데이터로 확정하기 위한 진입 덤프. solver 가 좌표 불일치로 false 폴백해도 이건 먼저 찍힘. + logger.log( + '[KERAB-RECT-PROBE] entry ' + + JSON.stringify({ + roofPoints: roof.points.map((p) => ({ x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 })), + outerLineCount: outerLines.length, + outerLines: outerLines.map((o) => ({ + type: o.attributes?.type, + offset: o.attributes?.offset, + x1: Math.round(o.x1 * 10) / 10, + y1: Math.round(o.y1 * 10) / 10, + x2: Math.round(o.x2 * 10) / 10, + y2: Math.round(o.y2 * 10) / 10, + })), + newType: newAttributes?.type, + newOffset: newAttributes?.offset, + }), + ) + if (outerLines.length !== 4) return false + const newType = newAttributes?.type + const TOL = 0.6 + const xs = roof.points.map((p) => p.x) + const ys = roof.points.map((p) => p.y) + let minX = Math.min(...xs) + let maxX = Math.max(...xs) + let minY = Math.min(...ys) + let maxY = Math.max(...ys) + let W = maxX - minX + let H = maxY - minY + if (W < 2 || H < 2) return false + // 네 코너가 실제 직사각형 코너와 일치해야 함 (axis-aligned). + const corners = [ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ] + for (const c of corners) { + if (!roof.points.some((p) => Math.abs(p.x - c.x) < TOL && Math.abs(p.y - c.y) < TOL)) return false + } + // [KERAB-RECT-SIDE-FIX 2026-06-15] outerLine 은 roof.points(roofLine)에서 出幅만큼 안쪽으로 + // 들어온 wallbase 좌표라, roof.points bbox 와 직접 비교하면 出幅(예 50)만큼 어긋나 TOL 초과 → + // sideOf 가 null → 솔버 전체 false 폴백(증상: 토글 시 KERAB-SIMPLE 미로로 빠져 그림 깨짐). + // 변 분류는 outerLine 자체 bbox 기준으로, 지오메트리(힙/마루)는 그대로 roof.points 기준으로 푼다. + const oxs = outerLines.flatMap((o) => [o.x1, o.x2]) + const oys = outerLines.flatMap((o) => [o.y1, o.y2]) + const oMinX = Math.min(...oxs) + const oMaxX = Math.max(...oxs) + const oMinY = Math.min(...oys) + const oMaxY = Math.max(...oys) + // 각 외곽선 → 변(top/bottom/left/right) 분류 + 유효 타입(EAVES/GABLE) 확정. + const sideOf = (o) => { + const horiz = Math.abs(o.y1 - o.y2) < TOL + const vert = Math.abs(o.x1 - o.x2) < TOL + if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMinY) < TOL) return 'top' + if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMaxY) < TOL) return 'bottom' + if (vert && Math.abs((o.x1 + o.x2) / 2 - oMinX) < TOL) return 'left' + if (vert && Math.abs((o.x1 + o.x2) / 2 - oMaxX) < TOL) return 'right' + return null + } + const sides = {} + for (const o of outerLines) { + const s = sideOf(o) + if (!s || sides[s]) return false + const eff = o === target ? newType : o.attributes?.type + if (eff !== LINE_TYPE.WALLLINE.EAVES && eff !== LINE_TYPE.WALLLINE.GABLE) return false + sides[s] = { o, gable: eff === LINE_TYPE.WALLLINE.GABLE } + } + if (!sides.top || !sides.bottom || !sides.left || !sides.right) return false + + const gTop = sides.top.gable + const gBottom = sides.bottom.gable + const gLeft = sides.left.gable + const gRight = sides.right.gable + const nGable = [gTop, gBottom, gLeft, gRight].filter(Boolean).length + + // [KERAB-RECT-GATE 2026-06-15] 出幅을 경계에 반영하기 전에 지원 형상인지 먼저 판정한다. + // 미지원(인접 2박공/3·4박공/얕은 Y)인데 offset 을 먼저 옮기면 폴백된 미로가 offset 을 한 번 더 + // 적용해 이중 出幅이 된다 → 반드시 무변형 상태에서 게이트하고, 미지원이면 boundary 손대지 않고 false. + { + let ok = false + if (nGable === 0) ok = true + // [KERAB-RECT-GATE-1 2026-06-15] 단일 박공은 항상 지원: depth<=span 이면 Y(apex)모델, + // long-edge(케라바, depth>span)면 CASE B(반대코너 45° 힙 2개 + 변 위 마루 1개). 둘 다 정의됨. + else if (nGable === 1) ok = true + else if (nGable === 2) { + ok = (gTop && gBottom) || (gLeft && gRight) + } + if (!ok) return false + } + + // [KERAB-RECT-OFFSET 2026-06-15] 지원 형상 확정 후, 토글되는 변의 出幅 변경분을 경계(roofLine)에 + // 먼저 반영한다(inner line 은 안 건드림). 이후 갱신된 roof.points 로 내부 지오메트리를 푼다. + // 出幅 입력이 기존과 같으면 delta 0 → 무동작. 다르면 maze 의 offset-surgical 과 동일 경로. + if (newAttributes && newAttributes.offset != null) { + applyTargetOffsetSurgical(target, newAttributes.offset, { skipInnerLines: true }) + const nxs = roof.points.map((p) => p.x) + const nys = roof.points.map((p) => p.y) + minX = Math.min(...nxs) + maxX = Math.max(...nxs) + minY = Math.min(...nys) + maxY = Math.max(...nys) + W = maxX - minX + H = maxY - minY + } + + const cx = (minX + maxX) / 2 + const cy = (minY + maxY) / 2 + + const out = [] + const addRidge = (x1, y1, x2, y2) => { + if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'ridge' }) + } + const addHip = (x1, y1, x2, y2) => { + if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'hip' }) + } + + if (nGable === 0) { + // 우진각: 마루는 긴 축, apex 는 짧은 변/2 안쪽. 힙 4개. + if (W >= H) { + const inset = H / 2 + const lA = { x: minX + inset, y: cy } + const rA = { x: maxX - inset, y: cy } + addRidge(lA.x, lA.y, rA.x, rA.y) + addHip(minX, minY, lA.x, lA.y) + addHip(minX, maxY, lA.x, lA.y) + addHip(maxX, minY, rA.x, rA.y) + addHip(maxX, maxY, rA.x, rA.y) + } else { + const inset = W / 2 + const tA = { x: cx, y: minY + inset } + const bA = { x: cx, y: maxY - inset } + addRidge(tA.x, tA.y, bA.x, bA.y) + addHip(minX, minY, tA.x, tA.y) + addHip(maxX, minY, tA.x, tA.y) + addHip(minX, maxY, bA.x, bA.y) + addHip(maxX, maxY, bA.x, bA.y) + } + } else if (nGable === 1) { + // 박공변이 짧은/중간 변(맞은편 apex 가 폴리곤 안)이면 Y(apex+마루). + // 긴 변(케라바=마루와 평행, W>2H or H>2W)이면 맞은편 두 코너에서 45° 힙이 박공변까지 + // 직진하고 박공변 위에 마루가 놓인다(CASE B). 어느 쪽이든 단일 박공은 항상 유효. + if (gBottom || gTop) { + const depth = W / 2 // apex 깊이 = 박공변(가로) 길이/2 + if (depth <= H - 0.5) { + // Y: apex 가 맞은편 변 안쪽 + if (gBottom) { + const apex = { x: cx, y: minY + depth } + addHip(minX, minY, apex.x, apex.y) + addHip(maxX, minY, apex.x, apex.y) + addRidge(apex.x, apex.y, cx, maxY) + } else { + const apex = { x: cx, y: maxY - depth } + addHip(minX, maxY, apex.x, apex.y) + addHip(maxX, maxY, apex.x, apex.y) + addRidge(apex.x, apex.y, cx, minY) + } + } else if (gBottom) { + // 케라바(긴 가로 변): 위쪽 두 코너 → 박공변(아래)까지 45° 직진 + 박공변 위 마루 + addHip(minX, minY, minX + H, maxY) + addHip(maxX, minY, maxX - H, maxY) + addRidge(minX + H, maxY, maxX - H, maxY) + } else { + addHip(minX, maxY, minX + H, minY) + addHip(maxX, maxY, maxX - H, minY) + addRidge(minX + H, minY, maxX - H, minY) + } + } else { + const depth = H / 2 + if (depth <= W - 0.5) { + if (gLeft) { + const apex = { x: maxX - depth, y: cy } + addHip(maxX, minY, apex.x, apex.y) + addHip(maxX, maxY, apex.x, apex.y) + addRidge(apex.x, apex.y, minX, cy) + } else { + const apex = { x: minX + depth, y: cy } + addHip(minX, minY, apex.x, apex.y) + addHip(minX, maxY, apex.x, apex.y) + addRidge(apex.x, apex.y, maxX, cy) + } + } else if (gLeft) { + // 케라바(긴 세로 변): 오른쪽 두 코너 → 박공변(왼쪽)까지 45° 직진 + 박공변 위 마루 + addHip(maxX, minY, minX, minY + W) + addHip(maxX, maxY, minX, maxY - W) + addRidge(minX, minY + W, minX, maxY - W) + } else { + addHip(minX, minY, maxX, minY + W) + addHip(minX, maxY, maxX, maxY - W) + addRidge(maxX, minY + W, maxX, maxY - W) + } + } + } else if (nGable === 2) { + // 맞배: 마주보는 두 박공 사이를 잇는 단일 마루, 힙 없음. + if (gTop && gBottom) { + addRidge(cx, minY, cx, maxY) + } else if (gLeft && gRight) { + addRidge(minX, cy, maxX, cy) + } else { + return false // 인접 2박공 = 굽은 마루, 미지원 → 폴백 + } + } else { + return false // 3·4 박공 → 폴백 + } + if (!out.length) return false + + const beforeSnap = snapshotKerabState(roof) + // 기존 내부 HIP/RIDGE(네이티브 + kerabPattern) 전부 제거 후 재생성. + for (const il of [...roof.innerLines]) { + if (!il) continue + if (il.name === LINE_TYPE.SUBLINE.HIP || il.name === LINE_TYPE.SUBLINE.RIDGE) { + canvas.remove(il) + const idx = roof.innerLines.indexOf(il) + if (idx >= 0) roof.innerLines.splice(idx, 1) + } + } + for (const o of outerLines) removeKerabHalfLabels(o.id) + for (const ln of out) { + const sz = calcLinePlaneSize({ x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }) + const isRidge = ln.kind === 'ridge' + const q = new QLine([ln.x1, ln.y1, ln.x2, ln.y2], { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: isRidge ? 4 : 3, + name: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP, + textMode: roof.textMode, + attributes: { + roofId: roof.id, + type: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP, + planeSize: sz, + actualSize: sz, + // [KERAB-RECT-EXTENDED 2026-06-15] 솔버 hip 은 roofLine 코너(=출폭 끝점)까지 이미 닿아 있다. + // allocation 의 integrateExtensionLines 가 ext+hip 머지로 hip 을 wall 코너까지 단축시키면 + // split graph 가 polygon 코너와 끊겨 1면만 잡힌다(native hip 은 extended 플래그로 머지 스킵). + // 동일하게 extended=true 부여 → INTEGRATE 가 merge 스킵, ext 는 lines 에서만 제거. + ...(isRidge ? {} : { extended: true }), + }, + }) + q.lineName = ln.kind + if (!isRidge) q.__extended = true + // 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로 동기화 필수. + q.startPoint = { x: ln.x1, y: ln.y1 } + q.endPoint = { x: ln.x2, y: ln.y2 } + canvas.add(q) + q.bringToFront() + roof.innerLines.push(q) + if (typeof q.setCoords === 'function') q.setCoords() + if (typeof q.setLength === 'function') q.setLength() + if (typeof q.addLengthText === 'function') q.addLengthText() + } + // 박공 변엔 절반 라벨 + 원본 길이 숨김, 처마 변엔 원본 길이 노출. + for (const key of ['top', 'bottom', 'left', 'right']) { + const s = sides[key] + if (s.gable) { + addKerabHalfLabels(s.o, { x: s.o.x1, y: s.o.y1 }, { x: s.o.x2, y: s.o.y2 }) + hideOriginalLengthText(s.o.id, true) + } else { + hideOriginalLengthText(s.o.id, false) + } + } + target.set({ attributes: newAttributes }) + canvas.renderAll() + const phase = newType === LINE_TYPE.WALLLINE.GABLE ? 'forward' : 'revert' + runKerabRuleCheck(roof, phase, beforeSnap) + try { + reattachDebugLabels(canvas, roof.id) + } catch (e) {} + logger.log('[KERAB-RECT-SOLVER] built ' + JSON.stringify({ nGable, lines: out.length, W: Math.round(W), H: Math.round(H) })) + return true + } catch (err) { + logger.warn('[KERAB-RECT-SOLVER] error → fallback', err) + return false + } + } + // [KERAB-PATTERN-EXT-CLEAN 2026-05-19] forward 시 제거되는 hip 과 짝이었던 orphan // extensionLine 정리. 한 끝점이 hip 의 한 끝점과 같고 다른 끝점이 hip 의 진행 // 방향과 동일선상에 있으면 짝으로 판정. 잔류 시 allocation 의 integrateExtensionLines diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js index 019c7538..d24e87c8 100644 --- a/src/util/kerab-offset-surgical.js +++ b/src/util/kerab-offset-surgical.js @@ -29,7 +29,7 @@ const lineLineIntersection = (p1, p2, p3, p4) => { * @returns true=적용됨 / false=조건 미달 또는 변경 없음 */ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}) => { - const { skipInnerLines = false } = options + const { skipInnerLines = false, skipKerabHips = false } = options const roof = canvas .getObjects() .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) @@ -363,6 +363,10 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {} // 골짜기(케라바 패턴) 라인과 일반 라인은 각자 독립 핸들러로 처리되어 서로 간섭하지 않는다. for (const il of roof.innerLines || []) { if (!il) continue + // [KERAB-HIP-45-PROTECT 2026-06-15] 케라바 hip(확장라인)은 CORNER-SNAP 금지 — + // 호출자(reclick 45° 블록)가 wall 코너에서 45° ray-cast 로 roofLine 까지 뻗는다. + // 여기서 recomputeKerabPatternLine 의 t-ratio 매핑을 타면 roofLine 코너로 스냅돼 룰 위반. + if (skipKerabHips && il.lineName === 'kerabPatternHip') continue if (isKerabPatternLine(il)) recomputeKerabPatternLine(il) else recomputeNormalLine(il) } From 7423f7a05a3657c25f195dd915318befd6c6e815 Mon Sep 17 00:00:00 2001 From: "DESKTOP-6E8S9S5\\LEE" Date: Mon, 15 Jun 2026 16:01:26 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=EB=B0=9C=EC=A0=84=EC=8B=9C=EB=AE=AC?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=ED=8C=9D=EC=97=85=EC=B0=BD=20?= =?UTF-8?q?=EC=98=A4=ED=94=88=20=EC=86=8C=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/header/Header.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/header/Header.jsx b/src/components/header/Header.jsx index 689bac59..13105ad8 100644 --- a/src/components/header/Header.jsx +++ b/src/components/header/Header.jsx @@ -210,7 +210,9 @@ export default function Header(props) { ], }, { id: 8, name: 'qna.title', url: '/community/qna', children: [] }, - { id: 9, name: 'header.menus.economic.simulation', action: openEconomicSimulation, children: [] }, + ...(process.env.NEXT_PUBLIC_RUN_MODE !== 'production' + ? [{ id: 9, name: 'header.menus.economic.simulation', action: openEconomicSimulation, children: [] }] + : []), ] const syncSession = useCallback(() => {