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)
}