diff --git a/.env.development b/.env.development index 6b3844f8..f6410ff7 100644 --- a/.env.development +++ b/.env.development @@ -10,11 +10,14 @@ SESSION_SECRET="i3iHH1yp2/2SpQSIySQ4bpyc4g0D+zCF9FAn5xUG0+Y=" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_bV5zuYMyyIYFlOb3" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_yAS4QDalL9jgQ7vS" -NEXT_PUBLIC_CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_a0FLEK6M2oTpXInK" -NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?Secret=secret_a0FLEK6M2oTpXInK" +CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png" +CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png" +CONVERTER_API_TOKEN="mcHqQkSZP30vxCRpIWJV62ciBYT887RI" 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_URL="https://q-order-dev.q-cells.jp/eos/simulation/list" +NEXT_PUBLIC_ECONOMIC_SIMULATION_DETAIL_URL="https://q-order-dev.q-cells.jp/eos/simulation/detail" # 테스트용 # AWS_REGION="ap-northeast-2" diff --git a/.env.local.dev b/.env.local.dev index a53524ca..399816b4 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_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/list" +NEXT_PUBLIC_ECONOMIC_SIMULATION_DETAIL_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/detail" # 테스트용 # AWS_REGION="ap-northeast-2" diff --git a/.env.localhost b/.env.localhost index 7cab2757..ab7efa7f 100644 --- a/.env.localhost +++ b/.env.localhost @@ -10,11 +10,14 @@ SESSION_SECRET="i3iHH1yp2/2SpQSIySQ4bpyc4g0D+zCF9FAn5xUG0+Y=" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_bV5zuYMyyIYFlOb3" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_yAS4QDalL9jgQ7vS" -NEXT_PUBLIC_CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_a0FLEK6M2oTpXInK" -NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?Secret=secret_a0FLEK6M2oTpXInK" +CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png" +CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png" +CONVERTER_API_TOKEN="mcHqQkSZP30vxCRpIWJV62ciBYT887RI" 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_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/list" +NEXT_PUBLIC_ECONOMIC_SIMULATION_DETAIL_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/detail" # 테스트용 # AWS_REGION="ap-northeast-2" diff --git a/.env.production b/.env.production index 50904369..9c4645bf 100644 --- a/.env.production +++ b/.env.production @@ -10,11 +10,14 @@ SESSION_SECRET="i3iHH1yp2/2SpQSIySQ4bpyc4g0D+zCF9FAn5xUG0+Y=" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_bV5zuYMyyIYFlOb3" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_yAS4QDalL9jgQ7vS" -NEXT_PUBLIC_CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_a0FLEK6M2oTpXInK" -NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?Secret=secret_a0FLEK6M2oTpXInK" +CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png" +CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png" +CONVERTER_API_TOKEN="mcHqQkSZP30vxCRpIWJV62ciBYT887RI" 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_URL="https://q-order.q-cells.jp/eos/simulation/list" +NEXT_PUBLIC_ECONOMIC_SIMULATION_DETAIL_URL="https://q-order.q-cells.jp/eos/simulation/detail" # AWS_REGION="ap-northeast-2" # AMPLIFY_BUCKET="interplug" diff --git a/src/app/api/image/convert/route.js b/src/app/api/image/convert/route.js new file mode 100644 index 00000000..d5c7115d --- /dev/null +++ b/src/app/api/image/convert/route.js @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server' + +// ConvertAPI 토큰은 서버 전용 env 로만 보관 (NEXT_PUBLIC_ 아님) — 클라이언트 번들에 노출되지 않는다. +const CONVERTER_API_TOKEN = process.env.CONVERTER_API_TOKEN +const CONVERTER_URLS = { + dwg: process.env.CONVERTER_DWG_API_URL, + dxf: process.env.CONVERTER_DXF_API_URL, +} + +export async function POST(req) { + try { + const formData = await req.formData() + const file = formData.get('file') + const type = formData.get('type') + + const convertUrl = CONVERTER_URLS[type] + if (!file || !convertUrl) { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }) + } + + const upstream = new FormData() + upstream.append('file', file, file.name) + + const res = await fetch(convertUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${CONVERTER_API_TOKEN}` }, + body: upstream, + }) + + const data = await res.json() + return NextResponse.json(data, { status: res.status }) + } catch (error) { + console.error('CAD convert proxy error:', error) + return NextResponse.json({ Message: error?.message ?? 'Failed to convert CAD file' }, { status: 500 }) + } +} diff --git a/src/components/floor-plan/CanvasMenu.jsx b/src/components/floor-plan/CanvasMenu.jsx index 96cf6a22..0718ea52 100644 --- a/src/components/floor-plan/CanvasMenu.jsx +++ b/src/components/floor-plan/CanvasMenu.jsx @@ -105,6 +105,20 @@ export default function CanvasMenu(props) { // 발전시뮬레이션 메뉴 이동 const { objectNo, pid } = floorPlanState + const openEconomicSimulation = async (objectNo, planNo) => { + const encRes = await promiseGet({ + url: '/api/pwrGnrSimulation/encData', + option: { params: { saleStoreId: sessionState.storeId, userId: sessionState.userId } }, + }) + const { encSaleStoreId, encUserId } = encRes.data + if (!encSaleStoreId || !encUserId) return + window.open( + `${process.env.NEXT_PUBLIC_ECONOMIC_SIMULATION_DETAIL_URL}?authYn=Y&encStoreId=${encodeURIComponent(encSaleStoreId)}&encRegId=${encodeURIComponent(encUserId)}&stuffNo=${objectNo}&planNo=${planNo}`, + 'economicSimulation', + `height=${screen.height},width=${screen.width},fullscreen=yes` + ) + } + // 발전시물레이션 Excel/PDF 다운 const { promiseGet, promisePost } = useAxios(globalLocale) const pwrGnrSimTypeRecoil = useRecoilValue(pwrGnrSimTypeState) @@ -294,15 +308,30 @@ export default function CanvasMenu(props) { } }) break + case 'economicSimulation': + setIsGlobalLoading(true) + promiseGet({ url: `/api/estimate/${objectNo}/${selectedPlan?.planNo ?? pid}/detail` }).then(async (res) => { + if (res.status === 200) { + const estimateDetail = res.data + if (estimateDetail.estimateDate !== null && estimateDetail.docNo) { + setIsGlobalLoading(false) + await openEconomicSimulation(objectNo, selectedPlan?.planNo ?? pid) + } else { + setIsGlobalLoading(false) + swalFire({ text: getMessage('simulator.menu.move.valid1') }) + } + } + }) + break } - if (menu.type !== 'simulation' && menu.type !== 'estimate' && menu.type !== 'drawing') { + if (menu.type !== 'simulation' && menu.type !== 'estimate' && menu.type !== 'drawing' && menu.type !== 'economicSimulation') { setSelectedMenu(menu.type) setCurrentMenu(menu.title) } if (pathname !== '/floor-plan') { //견적서 or 발전시뮬레이션 탭에서 같은 탭 클릭시 화면 이동했다 돌아오지않도록.. - if (menu.type !== 'drawing' && menu.type !== 'estimate' && menu.type !== 'simulation') { + if (menu.type !== 'drawing' && menu.type !== 'estimate' && menu.type !== 'simulation' && menu.type !== 'economicSimulation') { router.push(`/floor-plan?pid=${pid}&objectNo=${objectNo}`) } } diff --git a/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx b/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx index 70f1f1ab..24640917 100644 --- a/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx +++ b/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx @@ -171,8 +171,9 @@ export default function ModuleTabContents({ tabIndex, addRoof, setAddedRoofs, ro
{/* 2026-06-05 공법 버튼을 배열 인덱스 대신 constTp로 매핑 — API 응답 항목 추가/순서변경(예: 標準施工(Ⅱ) WORK_LV_ID_-2) 시 인덱스 밀림 방지. 운영(구 API)엔 -2가 없어 optional 은 데이터 있을 때만 렌더 */} {[ - { constTp: 'WORK_LV_ID_-1', label: '標準施工(Ⅰ)' }, + // [표시순서변경 2026-06-17] 標準(Ⅱ) 를 標準(Ⅰ) 앞으로 — 사용자 요청 { constTp: 'WORK_LV_ID_-2', label: '標準施工(Ⅱ)', optional: true }, + { constTp: 'WORK_LV_ID_-1', label: '標準施工(Ⅰ)' }, { constTp: 'WORK_LV_ID_1', label: '標準施工' }, { constTp: 'WORK_LV_ID_3', label: '強化施工' }, { constTp: 'WORK_LV_ID_4', label: '多設施工' }, diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index 98a4c4ee..efe40fce 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -867,8 +867,9 @@ const Trestle = forwardRef((props, ref) => {
{/* 2026-06-05 공법 버튼을 배열 인덱스 대신 constTp로 매핑 — API 응답 항목 추가/순서변경(예: 標準施工(II) WORK_LV_ID_-2) 시 인덱스 밀림 방지. 운영(구 API)엔 -2가 없어 optional 은 데이터 있을 때만 렌더 */} {[ - { constTp: 'WORK_LV_ID_-1', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(I)` }, + // [표시순서변경 2026-06-17] 標準(II) 를 標準(I) 앞으로 — 사용자 요청 { constTp: 'WORK_LV_ID_-2', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(II)`, optional: true }, + { constTp: 'WORK_LV_ID_-1', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(I)` }, { constTp: 'WORK_LV_ID_1', label: getMessage('modal.module.basic.setting.module.standard.construction') }, { constTp: 'WORK_LV_ID_3', label: getMessage('modal.module.basic.setting.module.enforce.construction') }, { constTp: 'WORK_LV_ID_4', label: getMessage('modal.module.basic.setting.module.multiple.construction') }, diff --git a/src/components/header/Header.jsx b/src/components/header/Header.jsx index 8c2ccbff..558dc582 100644 --- a/src/components/header/Header.jsx +++ b/src/components/header/Header.jsx @@ -164,6 +164,23 @@ export default function Header(props) { getAutoLoginParam() }, [userSession]) + const openEconomicSimulation = async () => { + const encRes = await promiseGet({ + url: '/api/pwrGnrSimulation/encData', + option: { params: { saleStoreId: sessionState.storeId, userId: sessionState.userId } }, + }) + + const { encSaleStoreId, encUserId } = encRes.data + + if (!encSaleStoreId || !encUserId) return + + window.open( + `${process.env.NEXT_PUBLIC_ECONOMIC_SIMULATION_URL}?authYn=Y&encStoreId=${encodeURIComponent(encSaleStoreId)}&encRegId=${encodeURIComponent(encUserId)}`, + 'economicSimulation', + `height=${screen.height},width=${screen.width},fullscreen=yes` + ) + } + const menus = [ { id: 0, name: 'header.menus.home', url: '/', children: [] }, { @@ -187,7 +204,10 @@ export default function Header(props) { ], }, { id: 8, name: 'qna.title', url: '/community/qna', children: [] }, - ] + process.env.NEXT_PUBLIC_RUN_MODE !== 'production' + ? { id: 9, name: 'header.menus.economic.simulation', action: openEconomicSimulation, children: [] } + : null, + ].filter(Boolean) const syncSession = useCallback(() => { setSessionState({ ...userSession }) @@ -237,6 +257,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/hooks/common/useRefFiles.js b/src/hooks/common/useRefFiles.js index 4c037d0c..09e0653e 100644 --- a/src/hooks/common/useRefFiles.js +++ b/src/hooks/common/useRefFiles.js @@ -25,8 +25,6 @@ import { logger } from '@/util/logger' * @returns {object} */ export function useRefFiles() { - const converterDwgUrl = process.env.NEXT_PUBLIC_CONVERTER_DWG_API_URL - const converterDxfUrl = process.env.NEXT_PUBLIC_CONVERTER_DXF_API_URL const { getMessage } = useMessage() const [refImage, setRefImage] = useState(null) const [refFileMethod, setRefFileMethod] = useState('1') @@ -89,9 +87,9 @@ export function useRefFiles() { })) if (file.name.split('.').pop() === 'dwg') { - handleUploadConvertRefFile(file, converterDwgUrl); + handleUploadConvertRefFile(file, 'dwg'); } else if (file.name.split('.').pop() === 'dxf') { - handleUploadConvertRefFile(file, converterDxfUrl); + handleUploadConvertRefFile(file, 'dxf'); } else { if (file && ['image/png', 'image/jpg', 'image/jpeg', 'image/bmp', 'image/gif'].includes(file.type)) { handleUploadImageRefFile(file) @@ -152,7 +150,7 @@ export function useRefFiles() { * 주소로 구글 맵 이미지 다운로드하여 캔버스 배경으로 로드 */ const handleMapImageDown = async () => { - debugger; logger.log('🚀 ~ handleMapImageDown ~ handleMapImageDown:') + logger.log('🚀 ~ handleMapImageDown ~ handleMapImageDown:') if (queryRef.current.value === '' || queryRef.current.value === null) { return } @@ -250,61 +248,75 @@ export function useRefFiles() { /** * RefFile이 캐드 도면 파일일 경우 변환하여 이미지로 저장 * @param {*} file - * @param converterUrl + * @param {'dwg'|'dxf'} cadType */ - const handleUploadConvertRefFile = async (file, converterUrl) => { - const formData = new FormData() - formData.append('file', file) + const handleUploadConvertRefFile = async (file, cadType) => { + try { + const formData = new FormData() + formData.append('file', file) + formData.append('type', cadType) - /** 캐드 도면 파일 변환 */ - const res = await post({ url: converterUrl, data: formData }) - logger.log('🚀 ~ handleUploadConvertRefFile ~ res:', res) + /** 캐드 도면 변환은 서버 프록시 경유 — ConvertAPI 토큰을 클라이언트에 노출하지 않는다 */ + const res = await post({ url: `${Config().baseUrl}/api/image/convert`, 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 7dc90dc5..35221da3 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) } @@ -2786,6 +2769,416 @@ export function useEavesGableEdit(id) { logger.log('[KERAB-REVERT] applied ' + JSON.stringify({ ok, c1, c2, apex: ridgeAtMid.apex })) if (ok) return } + // [KERAB-VALLEY-EAVES-RULE1 2026-06-16] A/B타입 native 케라바 변(스냅샷·native마루·kLine 전부 없음)의 + // 첫 처마 변환 "씨앗". 양 코너에서 폴리곤 안쪽 45° 힙 2개를 그려 각자 첫 교차(다른 inner line 또는 + // 상대편 roofLine)까지 뻗는다. R3 절삭만 — 반사·힙 사이 마루 생성은 보류(다각형 넓이 의존, 추후 결정). + // 出幅은 일반 라인변경과 동일하게 기존 surgical 로 처리. 게이트: 변 위에 collinear 게이블벽 inner line + // 이 실재할 때만(= 진짜 케라바 기하). 없으면 아래 attr-only 폴백. + { + const EDGE_TOL = 0.5 + const NP = roof.points.length + let edgeIdx = -1 + for (let i = 0; i < NP; i++) { + const p = roof.points[i] + const q = roof.points[(i + 1) % NP] + if ( + (Math.hypot(p.x - c1.x, p.y - c1.y) < EDGE_TOL && Math.hypot(q.x - c2.x, q.y - c2.y) < EDGE_TOL) || + (Math.hypot(p.x - c2.x, p.y - c2.y) < EDGE_TOL && Math.hypot(q.x - c1.x, q.y - c1.y) < EDGE_TOL) + ) { + edgeIdx = i + break + } + } + // 변환되는 게이블 변의 "게이블벽" 전체를 제거한다(사용자 확정 2026-06-16). + // 게이블벽 = 변 c1↔c2(roofLine쪽) 또는 옛 出幅쪽 outerLine 과 같은 무한직선 위의 HIP 세그먼트. + // 변이 게이블일 때만 존재한 구조 → eaves 가 되면 소멸. 한 직선이 ridge 에 의해 여러 세그먼트로 + // 쪼개져 변 구간 밖(아래)까지 뻗기도 한다(예: H-9 = 변 아래 연장). 따라서 변 구간 겹침이 아니라 + // "무한직선 동일선상(양 끝점 수직거리<1)" 전체를 게이블벽으로 본다 — i5(변 위) + i10(변 아래) 모두. + // 이 전체 제거가 ridge(RG-2)의 anchor 를 끊어, 뒤의 dangling 정리에서 RG-2 까지 연쇄 제거된다. + const isCollinearWith = (l, A, B) => { + const dx = B.x - A.x + const dy = B.y - A.y + const lenSq = dx * dx + dy * dy || 1 + const perp = (px, py) => { + const t = ((px - A.x) * dx + (py - A.y) * dy) / lenSq + return Math.hypot(px - (A.x + t * dx), py - (A.y + t * dy)) + } + return perp(l.x1, l.y1) < 1.0 && perp(l.x2, l.y2) < 1.0 + } + // 옛 出幅쪽 수직벽 식별용 — surgical 전 target outerLine 끝점(=出幅 위치) 캡처. + const tA = { x: target.x1, y: target.y1 } + const tB = { x: target.x2, y: target.y2 } + // 게이블벽(HIP)만 직접 제거. native 마루(RIDGE)는 여기서 보존하되, anchor 가 끊겨 dangling 이 + // 되는 ridge(RG-2)는 뒤의 cleanupDangling 이 연쇄 제거한다(사용자 확정 2026-06-16). + const gableWallLines = (roof.innerLines || []).filter( + (l) => + l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.HIP && (isCollinearWith(l, c1, c2) || isCollinearWith(l, tA, tB)), + ) + if (edgeIdx >= 0 && gableWallLines.length > 0) { + target.set({ attributes }) + // 1) 옛 게이블벽 라인 제거 + 짝 확장 helper(extensionLine) 동반 제거. + for (const gw of gableWallLines) { + removeLine(gw) + removeOrphanExtensionsForHip(gw) + } + roof.innerLines = roof.innerLines.filter((l) => !gableWallLines.includes(l)) + // 2) 出幅 반영 (일반 라인변경과 동일 — 기존 surgical, inner line 불변). + applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true }) + // 3) 새 roofLine 코너 (인덱스는 surgical 후에도 안정). + const pts = roof.points + const nA = pts[edgeIdx] + const nB = pts[(edgeIdx + 1) % NP] + const newC1IsA = Math.hypot(nA.x - c1.x, nA.y - c1.y) <= Math.hypot(nA.x - c2.x, nA.y - c2.y) + const idxA = newC1IsA ? edgeIdx : (edgeIdx + 1) % NP + const idxB = newC1IsA ? (edgeIdx + 1) % NP : edgeIdx + const newC1 = pts[idxA] + const newC2 = pts[idxB] + // 폴리곤 중심 (inward 판정용). + let cenX = 0 + let cenY = 0 + for (const p of pts) { + cenX += p.x + cenY += p.y + } + cenX /= pts.length || 1 + cenY /= pts.length || 1 + // 코너의 두 이웃 변 방향 → 폴리곤 안쪽 45° 이등분선 (convex/reflex 모두 안쪽으로 보정). + const inwardDir = (idx) => { + const C = pts[idx] + const prev = pts[(idx - 1 + NP) % NP] + const next = pts[(idx + 1) % NP] + const n1 = Math.hypot(prev.x - C.x, prev.y - C.y) || 1 + const n2 = Math.hypot(next.x - C.x, next.y - C.y) || 1 + let dx = (prev.x - C.x) / n1 + (next.x - C.x) / n2 + let dy = (prev.y - C.y) / n1 + (next.y - C.y) / n2 + const dl = Math.hypot(dx, dy) + if (dl < 1e-6) { + // 일직선(180°) — 변 수직 안쪽 법선. + dx = -(next.y - C.y) / n2 + dy = (next.x - C.x) / n2 + } else { + dx /= dl + dy /= dl + } + if (dx * (cenX - C.x) + dy * (cenY - C.y) < 0) { + dx = -dx + dy = -dy + } + return { x: dx, y: dy } + } + // corner 에서 dir 로 ray → 첫 교차(roofLine 변 + 기존 inner line), 자기 변(t≈0) 제외. + const rayFirstHit = (P, dir) => { + const segHit = (A, B) => { + const ex = B.x - A.x + const ey = B.y - A.y + const den = dir.x * ey - dir.y * ex + if (Math.abs(den) < 1e-9) return Infinity + const t = ((A.x - P.x) * ey - (A.y - P.y) * ex) / den + const u = ((A.x - P.x) * dir.y - (A.y - P.y) * dir.x) / den + if (t > 1.0 && u >= -1e-6 && u <= 1 + 1e-6) return t + return Infinity + } + let best = Infinity + let hit = null + let hitLine = null + for (let i = 0; i < NP; i++) { + const t = segHit(pts[i], pts[(i + 1) % NP]) + if (t < best) { + best = t + hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: null, kind: 'roofLine' } + hitLine = null + } + } + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const t = segHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (t < best) { + best = t + hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: il, kind: il.name } + hitLine = il + } + } + return hit + } + const mkValleyHip = (corner, hit) => { + const arr = [corner.x, corner.y, hit.x, hit.y] + const sz = calcLinePlaneSize({ x1: arr[0], y1: arr[1], x2: arr[2], y2: arr[3] }) + const hip = new QLine(arr, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.HIP, + textMode: roof.textMode, + attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.HIP, planeSize: sz, actualSize: sz }, + }) + hip.lineName = 'kerabPatternHip' + hip.startPoint = { x: hip.x1, y: hip.y1 } + hip.endPoint = { x: hip.x2, y: hip.y2 } + return hip + } + const addHipFrom = (corner, idx) => { + const dir = inwardDir(idx) + const hit = rayFirstHit(corner, dir) + if (!hit) return { dir, hit: null, hip: null, hitLine: null, hitKind: null } + const h = mkValleyHip(corner, hit) + canvas.add(h) + h.bringToFront() + roof.innerLines.push(h) + return { dir, hit, hip: h, hitLine: hit.line, hitKind: hit.kind } + } + // 임의 방향 ray 로 골짜기 힙 1개 생성(반사 힙용). 첫 교차까지. + const drawHipRay = (from, dir) => { + const hit = rayFirstHit(from, dir) + if (!hit) return null + const h = mkValleyHip(from, hit) + canvas.add(h) + h.bringToFront() + roof.innerLines.push(h) + return { hip: h, hit } + } + // 4) 게이블 구조 잔재 정리 — anchor(게이블벽 HIP) 제거로 dangling 이 된 HIP·확장라인(H-9 등)만 제거. + // ★ 마루(RIDGE)는 절대 제거하지 않는다 — dangling 이어도 보존(사용자 확정 2026-06-16: + // "마루를 지우면 안된다"). RIDGE 는 connectivity 판정에는 포함(다른 HIP 의 접합 대상)하되 + // dangler 후보에서는 제외. VALLEY(골짜기확장)도 vExt 위에서 끝나는 정상 케이스라 제외. + // 새 골짜기 힙 그리기 *전에* 수행해야 raycast 가 깨끗한 기하에 닿아 SHORT-hit 를 피한다. + const cleanupDangling = () => { + const TOL = 2.0 + const rpts = roof.points || [] + const distToSeg = (p, a, b) => { + const ex = b.x - a.x + const ey = b.y - a.y + const l2 = ex * ex + ey * ey || 1 + let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2 + t = Math.max(0, Math.min(1, t)) + return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey)) + } + const minEdgeDist = (pt) => { + let m = Infinity + for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) { + const d = distToSeg(pt, rpts[j], rpts[i]) + if (d < m) m = d + } + return m + } + const removed = [] + for (let iter = 0; iter < 12; iter++) { + const surv = (roof.innerLines || []).filter( + (l) => + l && + l.visible !== false && + (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY), + ) + const ends = [] + for (const l of surv) { + ends.push({ x: l.x1, y: l.y1, line: l }) + ends.push({ x: l.x2, y: l.y2, line: l }) + } + // 마루(RIDGE)·VALLEY 는 dangling 이어도 제거 금지 — HIP 만 제거 후보. + const dangler = surv.find((l) => { + if (l.name !== LINE_TYPE.SUBLINE.HIP) return false + for (const p of [ + { x: l.x1, y: l.y1 }, + { x: l.x2, y: l.y2 }, + ]) { + const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL + const shared = ends.some((e) => e.line !== l && Math.hypot(e.x - p.x, e.y - p.y) < TOL) + if (!onBoundary && !shared) return true + } + return false + }) + if (!dangler) break + removeLine(dangler) + roof.innerLines = roof.innerLines.filter((l) => l !== dangler) + removeOrphanExtensionsForHip(dangler) + removed.push({ n: dangler.name, ln: dangler.lineName || '-', x1: Math.round(dangler.x1), y1: Math.round(dangler.y1), x2: Math.round(dangler.x2), y2: Math.round(dangler.y2) }) + } + return removed + } + const cleaned = cleanupDangling() + const resA = addHipFrom(newC1, idxA) + const resB = addHipFrom(newC2, idxB) + // 5b) 마루(RIDGE) 끝점 상호작용 — anchor 잃은 끝점 해소(사용자 확정 2026-06-16: "RG1(h8) 확장, RG2(b5) 절삭"). + // 규칙: 마루는 절대 삭제 안 함. anchor 잃은(=경계 미접촉 + 다른 선과 미공유) 끝점은 + // · 새 골짜기 힙이 그 마루를 가로지르면 → 교점에서 절삭(trim, 끝점을 교점으로 당김) + // · 가로지르는 힙이 없으면 → 끝점 방향으로 roofLine/다음 라인까지 확장(extend) + const resolveRidgeEndpoints = () => { + const TOL = 2.0 + const rpts = roof.points || [] + const newHips = [resA, resB].map((r) => r && r.hip).filter(Boolean) + const distToSeg = (p, a, b) => { + const ex = b.x - a.x + const ey = b.y - a.y + const l2 = ex * ex + ey * ey || 1 + let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2 + t = Math.max(0, Math.min(1, t)) + return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey)) + } + const minEdgeDist = (pt) => { + let m = Infinity + for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) { + const d = distToSeg(pt, rpts[j], rpts[i]) + if (d < m) m = d + } + return m + } + // 선분 교차(내부 교점만, 끝점 t∈(eps,1-eps)) + const segInt = (p1, p2, p3, p4) => { + const d1x = p2.x - p1.x + const d1y = p2.y - p1.y + const d2x = p4.x - p3.x + const d2y = p4.y - p3.y + const den = d1x * d2y - d1y * d2x + if (Math.abs(den) < 1e-9) return null + const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / den + const u = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / den + if (t <= 1e-6 || t >= 1 - 1e-6 || u <= 1e-6 || u >= 1 - 1e-6) return null + return { x: p1.x + t * d1x, y: p1.y + t * d1y } + } + // 절삭 전용: 마루(rp→rq)는 내부만, 힙(ha→hb)은 끝점 닿음(T자)까지 허용 + const segIntTrim = (rp, rq, ha, hb) => { + const d1x = rq.x - rp.x + const d1y = rq.y - rp.y + const d2x = hb.x - ha.x + const d2y = hb.y - ha.y + const den = d1x * d2y - d1y * d2x + if (Math.abs(den) < 1e-9) return null + const t = ((ha.x - rp.x) * d2y - (ha.y - rp.y) * d2x) / den + const u = ((ha.x - rp.x) * d1y - (ha.y - rp.y) * d1x) / den + if (t <= 1e-6 || t >= 1 - 1e-6) return null // 마루 내부만 + if (u <= 1e-6 || u >= 1 + 1e-3) return null // 힙 끝점 닿음 허용 + return { x: rp.x + t * d1x, y: rp.y + t * d1y } + } + // 힙의 먼 끝(코너=startPoint 반대쪽 endPoint)을 교차점으로 절삭 + 라벨 갱신 + const trimHipFarEnd = (hip, cross) => { + hip.set({ x2: cross.x, y2: cross.y }) + hip.endPoint = { x: cross.x, y: cross.y } + const nsz = calcLinePlaneSize({ x1: hip.x1, y1: hip.y1, x2: cross.x, y2: cross.y }) + hip.attributes = { ...hip.attributes, planeSize: nsz, actualSize: nsz } + hip.setLength?.() + hip.setCoords?.() + hip.addLengthText?.() + } + const ridges = (roof.innerLines || []).filter((l) => l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.RIDGE) + const allEnds = [] + for (const l of (roof.innerLines || []).filter( + (l) => + l && + l.visible !== false && + (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY), + )) { + allEnds.push({ x: l.x1, y: l.y1, line: l }) + allEnds.push({ x: l.x2, y: l.y2, line: l }) + } + const touched = [] + for (const rg of ridges) { + const endsOf = [ + { p: { x: rg.x1, y: rg.y1 }, q: { x: rg.x2, y: rg.y2 }, set: (pt) => rg.set({ x1: pt.x, y1: pt.y }) }, + { p: { x: rg.x2, y: rg.y2 }, q: { x: rg.x1, y: rg.y1 }, set: (pt) => rg.set({ x2: pt.x, y2: pt.y }) }, + ] + for (const { p, q, set } of endsOf) { + const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL + const shared = allEnds.some((e) => e.line !== rg && Math.hypot(e.x - p.x, e.y - p.y) < TOL) + if (onBoundary || shared) continue // anchor 있음 → 그대로 + // (a) 가로지르는 힙 탐색 → 절삭 + let trimPt = null + let trimD = Infinity + for (const h of newHips) { + const ip = segIntTrim(p, q, { x: h.x1, y: h.y1 }, { x: h.x2, y: h.y2 }) + if (!ip) continue + const d = Math.hypot(ip.x - p.x, ip.y - p.y) + if (d < trimD) { + trimD = d + trimPt = ip + } + } + if (trimPt) { + set(trimPt) + touched.push({ act: 'trim', ln: rg.lineName || '-', to: { x: Math.round(trimPt.x), y: Math.round(trimPt.y) } }) + continue + } + // (b) 확장 — q→p 방향으로 첫 교차(roofLine 변 + 다른 inner line, 자기 제외)까지 + const dx = p.x - q.x + const dy = p.y - q.y + const mag = Math.hypot(dx, dy) || 1 + const ux = dx / mag + const uy = dy / mag + const castHit = (A, B) => { + const ip = segInt({ x: q.x, y: q.y }, { x: q.x + ux * 1e5, y: q.y + uy * 1e5 }, A, B) + if (!ip) return Infinity + const t = (ip.x - p.x) * ux + (ip.y - p.y) * uy + return t > 1e-3 ? t : Infinity + } + let best = Infinity + let hit = null + let hitLine = null + for (let i = 0; i < rpts.length; i++) { + const A = rpts[i] + const B = rpts[(i + 1) % rpts.length] + const t = castHit(A, B) + if (t < best) { + best = t + hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) } + hitLine = null // roofLine 변 + } + } + for (const il of roof.innerLines || []) { + if (!il || il === rg || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const t = castHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (t < best) { + best = t + hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) } + hitLine = il + } + } + if (hit) { + set(hit) + touched.push({ act: 'extend', ln: rg.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } }) + // 확장한 마루가 힙 몸통에 닿으면 → 그 힙의 먼 끝(코너 반대쪽)을 교차점으로 절삭(힙은 마루를 넘지 못함) + if (hitLine && hitLine.name === LINE_TYPE.SUBLINE.HIP) { + trimHipFarEnd(hitLine, hit) + touched.push({ act: 'trim-hip', ln: hitLine.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } }) + } + } + } + } + return touched + } + const ridgeOps = resolveRidgeEndpoints() + // 5c) Y 반사 힙 — 새 힙이 *기존 마루(RIDGE)에 직접 부딪힌* 경우만(=raycast hit 가 RIDGE), + // 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사). + // ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16). + const reflectOps = [] + for (const res of [resA, resB]) { + if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue + const M = { x: res.hit.x, y: res.hit.y } + const rg = res.hitLine + const rlen = Math.hypot(rg.x2 - rg.x1, rg.y2 - rg.y1) || 1 + const rx = (rg.x2 - rg.x1) / rlen + const ry = (rg.y2 - rg.y1) / rlen + // 반사 방향 = reflect(역입사) 마루축 기준. w=역입사(코너→M 반대), v_ref=2(w·r̂)r̂ - w + const wx = -res.dir.x + const wy = -res.dir.y + const dot = wx * rx + wy * ry + const refDir = { x: 2 * dot * rx - wx, y: 2 * dot * ry - wy } + const drawn = drawHipRay(M, refDir) + if (drawn) { + reflectOps.push({ from: { x: Math.round(M.x), y: Math.round(M.y) }, to: { x: Math.round(drawn.hit.x), y: Math.round(drawn.hit.y) } }) + } + } + removeKerabHalfLabels(target.id) + hideOriginalLengthText(target.id, false) + canvas.renderAll() + runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap) + logger.log( + '[KERAB-VALLEY-EAVES-RULE1] applied ' + + JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, ridgeOps, reflectOps }), + ) + return + } + } // [2240 KERAB-ATTR-ONLY 2026-05-19] ridge-at-mid 없음 → forward 가 attribute-only 였음 → 동일 대칭으로 속성만 토글 target.set({ attributes }) applyKerabAttributeOnlyPattern() @@ -3403,6 +3796,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 +3973,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 @@ -3748,6 +4193,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 +4289,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 @@ -3936,6 +4390,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/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 { diff --git a/src/locales/ja.json b/src/locales/ja.json index 0eb938ad..fecc3c3f 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": "オンライン保証システム", @@ -248,6 +249,7 @@ "plan.menu.estimate.unLock": "ロック解除", "plan.menu.estimate.lock": "ロック", "plan.menu.simulation": "発電シミュレーション", + "plan.menu.economic.simulation": "経済シミュレーション", "plan.menu.simulation.excel": "Excel", "plan.menu.simulation.pdf": "PDF", "plan.mode.vertical.horizontal": "垂直水平モード", @@ -589,6 +591,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..af972e11 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": "온라인보증시스템", @@ -248,6 +249,7 @@ "plan.menu.estimate.unLock": "잠금 해제", "plan.menu.estimate.lock": "잠금", "plan.menu.simulation": "발전 시뮬레이션", + "plan.menu.economic.simulation": "경제 시뮬레이션", "plan.menu.simulation.excel": "Excel", "plan.menu.simulation.pdf": "PDF", "plan.mode.vertical.horizontal": "수직 수평 모드", @@ -328,7 +330,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 +536,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 +572,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 +591,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 +914,7 @@ "offset": "출폭", "width": "폭", "size": "치수", - "size.angle": "寸(度)", + "size.angle": "치(도)", "eaves": "처마", "eaves.line": "처마선", "gable": "케라바", @@ -999,10 +1002,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({ diff --git a/src/store/menuAtom.js b/src/store/menuAtom.js index 007e7950..51103e05 100644 --- a/src/store/menuAtom.js +++ b/src/store/menuAtom.js @@ -36,7 +36,10 @@ export const menusState = atom({ icon: 'con05', title: MENU.POWER_GENERATION_SIMULATION.DEFAULT, }, - ], + process.env.NEXT_PUBLIC_RUN_MODE !== 'production' + ? { type: 'economicSimulation', name: 'plan.menu.economic.simulation', icon: 'con05', title: '' } + : null, + ].filter(Boolean), }) export const subMenusState = atom({ diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss index 858ab351..e255cfbb 100644 --- a/src/styles/_layout.scss +++ b/src/styles/_layout.scss @@ -79,6 +79,9 @@ header{ background: url(/static/images/common/nav-arr.svg)no-repeat center; transition: all .17s ease-in-out; } + &.no-arrow:after{ + display: none; + } } &:last-child{ margin-right: 0; 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) }