dev #916
@ -253,58 +253,71 @@ export function useRefFiles() {
|
||||
* @param converterUrl
|
||||
*/
|
||||
const handleUploadConvertRefFile = async (file, converterUrl) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
/** 캐드 도면 파일 변환 */
|
||||
const res = await post({ url: converterUrl, data: formData })
|
||||
logger.log('🚀 ~ handleUploadConvertRefFile ~ res:', res)
|
||||
/** 캐드 도면 파일 변환 */
|
||||
const res = await post({ url: converterUrl, data: formData })
|
||||
logger.log('🚀 ~ handleUploadConvertRefFile ~ res:', res)
|
||||
|
||||
// Convert Base64 to Blob
|
||||
const base64Data = res.Files[0].FileData
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteArrays = []
|
||||
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||
const slice = byteCharacters.slice(offset, offset + 512)
|
||||
const byteNumbers = new Array(slice.length)
|
||||
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i)
|
||||
// 변환 API 가 정상 변환 대신 에러 본문(Code/Message)을 반환한 경우 그대로 throw [DXF-CONVERT-FAIL 2026-06-15]
|
||||
if (!res?.Files?.[0]?.FileData) {
|
||||
throw new Error(res?.Message || getMessage('common.message.ref.file.convertFail'))
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
byteArrays.push(byteArray)
|
||||
// Convert Base64 to Blob
|
||||
const base64Data = res.Files[0].FileData
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteArrays = []
|
||||
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||
const slice = byteCharacters.slice(offset, offset + 512)
|
||||
const byteNumbers = new Array(slice.length)
|
||||
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i)
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
byteArrays.push(byteArray)
|
||||
}
|
||||
|
||||
const blob = new Blob(byteArrays, { type: 'image/png' })
|
||||
|
||||
// Create File object from Blob
|
||||
const convertImg = new File([blob], res.Files[0].FileName, { type: 'image/png' })
|
||||
|
||||
const newFormData = new FormData()
|
||||
newFormData.append('file', convertImg)
|
||||
|
||||
/** 캐드 도면 파일 업로드 */
|
||||
const result = await post({
|
||||
url: `${Config().baseUrl}/api/image/cad`,
|
||||
data: newFormData,
|
||||
})
|
||||
logger.log('🚀 ~ handleUploadConvertRefFile ~ result:', result)
|
||||
|
||||
setCurrentBgImage(`${process.env.NEXT_PUBLIC_AWS_S3_BASE_URL}/${result.fileName}`)
|
||||
// setCurrentBgImage(result.filePath)
|
||||
setRefImage(file)
|
||||
|
||||
const params = {
|
||||
objectId: currentCanvasPlan.id,
|
||||
planNo: currentCanvasPlan.planNo,
|
||||
// imagePath: `${process.env.NEXT_PUBLIC_HOST_URL}${res.filePath}`,
|
||||
imagePath: `${result.filePath}`,
|
||||
}
|
||||
logger.log('🚀 ~ handleUploadImageRefFile ~ params:', params)
|
||||
await setBackGroundImage(params)
|
||||
} catch (error) {
|
||||
// 변환/업로드 실패 시 API 응답 메시지를 그대로 노출 (없으면 기본 안내) [DXF-CONVERT-FAIL 2026-06-15]
|
||||
logger.error('🚨 handleUploadConvertRefFile error:', error)
|
||||
const apiMessage = error?.response?.data?.Message || error?.message || getMessage('common.message.ref.file.convertFail')
|
||||
swalFire({ text: apiMessage, type: 'alert', icon: 'error' })
|
||||
} finally {
|
||||
setPopSpinnerStore(false)
|
||||
}
|
||||
|
||||
const blob = new Blob(byteArrays, { type: 'image/png' })
|
||||
|
||||
// Create File object from Blob
|
||||
const convertImg = new File([blob], res.Files[0].FileName, { type: 'image/png' })
|
||||
|
||||
const newFormData = new FormData()
|
||||
newFormData.append('file', convertImg)
|
||||
|
||||
/** 캐드 도면 파일 업로드 */
|
||||
const result = await post({
|
||||
url: `${Config().baseUrl}/api/image/cad`,
|
||||
data: newFormData,
|
||||
})
|
||||
logger.log('🚀 ~ handleUploadConvertRefFile ~ result:', result)
|
||||
|
||||
setCurrentBgImage(`${process.env.NEXT_PUBLIC_AWS_S3_BASE_URL}/${result.fileName}`)
|
||||
// setCurrentBgImage(result.filePath)
|
||||
setRefImage(file)
|
||||
|
||||
const params = {
|
||||
objectId: currentCanvasPlan.id,
|
||||
planNo: currentCanvasPlan.planNo,
|
||||
// imagePath: `${process.env.NEXT_PUBLIC_HOST_URL}${res.filePath}`,
|
||||
imagePath: `${result.filePath}`,
|
||||
}
|
||||
logger.log('🚀 ~ handleUploadImageRefFile ~ params:', params)
|
||||
await setBackGroundImage(params)
|
||||
setPopSpinnerStore(false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -167,6 +167,184 @@ export function useEavesGableEdit(id) {
|
||||
// R3 outside: 끝점이 roofLine 폴리곤 밖(경계 tol 초과)으로 이탈 금지.
|
||||
// R4 anchor: 토글 전후로 움직이지 않은 roofLine 코너(stable corner)의 끝점 점유수 불변
|
||||
// (코너에서 hip 이 떨어지거나 엉뚱한 코너로 횡단하면 점유수 변화).
|
||||
// [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (1/2): 케라바 hip(확장라인) 식별.
|
||||
// reclick 검증 코드에서 무손실 추출. surgical 적용 *전*(옛 폴리곤) 에 호출해야 한다 —
|
||||
// outer endpoint = 옛 roof.points 변 위 끝점, side = hip 직선이 지나는 wall 코너(A/B).
|
||||
const detectKerabHipMarks = (target, roof) => {
|
||||
const marks = []
|
||||
if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return marks
|
||||
const wA = { x: target.x1, y: target.y1 }
|
||||
const wB = { x: target.x2, y: target.y2 }
|
||||
const pts = roof.points
|
||||
const isOnOldPolyEdge = (P) => {
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const A = pts[i]
|
||||
const B = pts[(i + 1) % pts.length]
|
||||
const ddx = B.x - A.x
|
||||
const ddy = B.y - A.y
|
||||
const lenSq = ddx * ddx + ddy * ddy
|
||||
if (lenSq < 1e-6) continue
|
||||
const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq
|
||||
if (t < -0.02 || t > 1.02) continue
|
||||
const projX = A.x + t * ddx
|
||||
const projY = A.y + t * ddy
|
||||
const d = Math.hypot(P.x - projX, P.y - projY)
|
||||
if (d < 2.0) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
for (const il of roof.innerLines) {
|
||||
if (!il || (il.lineName !== 'hip' && il.lineName !== 'kerabPatternHip')) continue
|
||||
const e1 = { x: il.x1, y: il.y1 }
|
||||
const e2 = { x: il.x2, y: il.y2 }
|
||||
const e1OnEdge = isOnOldPolyEdge(e1)
|
||||
const e2OnEdge = isOnOldPolyEdge(e2)
|
||||
let which = null
|
||||
if (e1OnEdge && !e2OnEdge) which = 1
|
||||
else if (e2OnEdge && !e1OnEdge) which = 2
|
||||
if (which === null) continue
|
||||
const outerEnd = which === 1 ? e1 : e2
|
||||
const innerEnd = which === 1 ? e2 : e1
|
||||
const ddx = outerEnd.x - innerEnd.x
|
||||
const ddy = outerEnd.y - innerEnd.y
|
||||
const llen = Math.hypot(ddx, ddy) || 1
|
||||
const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen)
|
||||
const dA = distPerp(wA)
|
||||
const dB = distPerp(wB)
|
||||
if (Math.min(dA, dB) > 5.0) continue
|
||||
const side = dA <= dB ? 'A' : 'B'
|
||||
marks.push({ il, which, side })
|
||||
}
|
||||
logger.log(
|
||||
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' +
|
||||
JSON.stringify(marks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))),
|
||||
)
|
||||
return marks
|
||||
}
|
||||
// [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (2/2): 케라바 hip 을 45° ray-cast 확장.
|
||||
// surgical 적용 *후*(새 폴리곤) 에 호출. wall 코너에서 옛 hip 방향(45°)으로 새 roofLine 변까지.
|
||||
// CORNER-SNAP 금지 룰의 실제 구현부 — roofLine 코너가 아니라 교점까지 뻗는다.
|
||||
// [45-UNIFY 2026-06-15] reclick·revert 두 경로의 45° 구현을 한 벌로 통합. 가드는 opt-in:
|
||||
// - useRevertGuards=false(reclick): 순수 45° ray-cast (기존 reclick 동작 그대로).
|
||||
// - useRevertGuards=true(revert): outerShared(골짜기 내부 hip 제외) + outerOnVertex(게이블 코너
|
||||
// hip 은 같은 인덱스 새 꼭짓점으로 직접 스냅) 가드 적용. oldPolyPoints = 이동 전 폴리곤 꼭짓점.
|
||||
const extendKerabHipsTo45 = (target, roof, hipMarks, options = {}) => {
|
||||
if (!roof || !Array.isArray(roof.points) || !hipMarks || !hipMarks.length) return
|
||||
const { useRevertGuards = false, oldPolyPoints = null } = options
|
||||
const wA = { x: target.x1, y: target.y1 }
|
||||
const wB = { x: target.x2, y: target.y2 }
|
||||
const rps = roof.points
|
||||
const M = rps.length
|
||||
if (!M) return
|
||||
const baseTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45]' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES]'
|
||||
const okTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45] ' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended '
|
||||
const rayHit = (P, dir, A, B) => {
|
||||
const sx = B.x - A.x
|
||||
const sy = B.y - A.y
|
||||
const denom = dir.x * sy - dir.y * sx
|
||||
if (Math.abs(denom) < 1e-9) return Infinity
|
||||
const ax = A.x - P.x
|
||||
const ay = A.y - P.y
|
||||
const t = (ax * sy - ay * sx) / denom
|
||||
const s = (ax * dir.y - ay * dir.x) / denom
|
||||
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
|
||||
return Infinity
|
||||
}
|
||||
const cast45 = (wCorner, innerEnd, outerOld, il, which, side) => {
|
||||
const dx = outerOld.x - innerEnd.x
|
||||
const dy = outerOld.y - innerEnd.y
|
||||
const dlen = Math.hypot(dx, dy)
|
||||
if (dlen < 1e-6) return null
|
||||
const ux = dx / dlen
|
||||
const uy = dy / dlen
|
||||
let bestT = Infinity
|
||||
for (let k = 0; k < M; k++) {
|
||||
const A = rps[k]
|
||||
const B = rps[(k + 1) % M]
|
||||
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
|
||||
if (t < bestT) bestT = t
|
||||
}
|
||||
if (!isFinite(bestT) || bestT < 0.5) {
|
||||
logger.log(baseTag + ' no-hit ' + JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }))
|
||||
return null
|
||||
}
|
||||
return { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
|
||||
}
|
||||
for (const mark of hipMarks) {
|
||||
const { il, which, side } = mark
|
||||
const wCorner = side === 'A' ? wA : wB
|
||||
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
||||
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
|
||||
let hit
|
||||
if (useRevertGuards) {
|
||||
// outerShared: outer 끝이 다른 내부선과 공유 → 골짜기 내부 hip → 확장 스킵.
|
||||
const SHARE_TOL = 2.0
|
||||
const outerShared = (roof.innerLines || []).some(
|
||||
(o) =>
|
||||
o &&
|
||||
o !== il &&
|
||||
o.visible !== false &&
|
||||
(Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL ||
|
||||
Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL),
|
||||
)
|
||||
// outerOnVertex: outer 가 (이동 전) 폴리곤 꼭짓점이면 게이블 코너 hip → 같은 인덱스 새 꼭짓점 스냅.
|
||||
const VERT_TOL = 2.0
|
||||
const opp = Array.isArray(oldPolyPoints) ? oldPolyPoints : []
|
||||
let vtxIdx = -1
|
||||
for (let vi = 0; vi < opp.length; vi++) {
|
||||
const p = opp[vi]
|
||||
if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) {
|
||||
vtxIdx = vi
|
||||
break
|
||||
}
|
||||
}
|
||||
const outerOnVertex = vtxIdx >= 0
|
||||
if (outerShared && !outerOnVertex) {
|
||||
logger.log(baseTag + ' skip (interior hip — outer shared) ' + JSON.stringify({ lineName: il.lineName, which, side, outerOld }))
|
||||
continue
|
||||
}
|
||||
if (outerOnVertex && rps.length === opp.length && rps[vtxIdx]) {
|
||||
hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y }
|
||||
} else {
|
||||
hit = cast45(wCorner, innerEnd, outerOld, il, which, side)
|
||||
if (!hit) continue
|
||||
}
|
||||
} else {
|
||||
hit = cast45(wCorner, innerEnd, outerOld, il, which, side)
|
||||
if (!hit) continue
|
||||
}
|
||||
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
|
||||
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
|
||||
const ratio = oldLen > 0 ? newLen / oldLen : 1
|
||||
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
|
||||
else il.set({ x2: hit.x, y2: hit.y })
|
||||
if (il.attributes) {
|
||||
const oldPlane = il.attributes.planeSize ?? 0
|
||||
const oldActual = il.attributes.actualSize ?? 0
|
||||
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
|
||||
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
|
||||
il.attributes.extended = true
|
||||
}
|
||||
il.__extended = true
|
||||
if (typeof il.setCoords === 'function') il.setCoords()
|
||||
if (typeof il.setLength === 'function') il.setLength()
|
||||
if (typeof il.addLengthText === 'function') il.addLengthText()
|
||||
logger.log(
|
||||
okTag +
|
||||
JSON.stringify({
|
||||
lineName: il.lineName,
|
||||
which,
|
||||
side,
|
||||
hit,
|
||||
ratio: Number(ratio.toFixed(3)),
|
||||
x1: il.x1,
|
||||
y1: il.y1,
|
||||
x2: il.x2,
|
||||
y2: il.y2,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
const snapshotKerabState = (roof) => {
|
||||
if (!roof || !Array.isArray(roof.innerLines)) return null
|
||||
const lines = roof.innerLines
|
||||
@ -441,137 +619,50 @@ export function useEavesGableEdit(id) {
|
||||
// (케라바 ONLY-RECLICK 은 KERAB-PATTERN-CORNER-SNAP 필요하므로 그대로 둠.)
|
||||
const isEaves = attributes?.type === LINE_TYPE.WALLLINE.EAVES
|
||||
let reclickRoof = null
|
||||
const hipMarks = []
|
||||
let hasKerabPattern = false
|
||||
let hipMarks = []
|
||||
if (isEaves) {
|
||||
reclickRoof = canvas
|
||||
.getObjects()
|
||||
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
||||
if (reclickRoof && Array.isArray(reclickRoof.innerLines) && Array.isArray(reclickRoof.points)) {
|
||||
const wA = { x: target.x1, y: target.y1 }
|
||||
const wB = { x: target.x2, y: target.y2 }
|
||||
const pts = reclickRoof.points
|
||||
// outer endpoint 식별: 옛 roof.points 변 위 (perpendicular distance < 2) 인 끝점.
|
||||
const isOnOldPolyEdge = (P) => {
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const A = pts[i]
|
||||
const B = pts[(i + 1) % pts.length]
|
||||
const ddx = B.x - A.x
|
||||
const ddy = B.y - A.y
|
||||
const lenSq = ddx * ddx + ddy * ddy
|
||||
if (lenSq < 1e-6) continue
|
||||
const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq
|
||||
if (t < -0.02 || t > 1.02) continue
|
||||
const projX = A.x + t * ddx
|
||||
const projY = A.y + t * ddy
|
||||
const d = Math.hypot(P.x - projX, P.y - projY)
|
||||
if (d < 2.0) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
for (const il of reclickRoof.innerLines) {
|
||||
if (!il || il.lineName !== 'hip') continue
|
||||
const e1 = { x: il.x1, y: il.y1 }
|
||||
const e2 = { x: il.x2, y: il.y2 }
|
||||
const e1OnEdge = isOnOldPolyEdge(e1)
|
||||
const e2OnEdge = isOnOldPolyEdge(e2)
|
||||
let which = null
|
||||
if (e1OnEdge && !e2OnEdge) which = 1
|
||||
else if (e2OnEdge && !e1OnEdge) which = 2
|
||||
if (which === null) continue
|
||||
const outerEnd = which === 1 ? e1 : e2
|
||||
const innerEnd = which === 1 ? e2 : e1
|
||||
// transit corner: hip 직선 위에 wA/wB 중 어느 코너가 있는지 (perpendicular distance).
|
||||
const ddx = outerEnd.x - innerEnd.x
|
||||
const ddy = outerEnd.y - innerEnd.y
|
||||
const llen = Math.hypot(ddx, ddy) || 1
|
||||
const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen)
|
||||
const dA = distPerp(wA)
|
||||
const dB = distPerp(wB)
|
||||
if (Math.min(dA, dB) > 5.0) continue
|
||||
const side = dA <= dB ? 'A' : 'B'
|
||||
hipMarks.push({ il, which, side })
|
||||
}
|
||||
logger.log(
|
||||
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' +
|
||||
JSON.stringify(hipMarks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))),
|
||||
hasKerabPattern = reclickRoof.innerLines.some(
|
||||
(il) => il && typeof il.lineName === 'string' && il.lineName.startsWith('kerabPattern'),
|
||||
)
|
||||
// surgical 적용 *전*(옛 폴리곤) 에 케라바 hip 식별 — outer endpoint 가 옛 변 위에 있어야 잡힌다.
|
||||
hipMarks = detectKerabHipMarks(target, reclickRoof)
|
||||
}
|
||||
}
|
||||
applyTargetOffsetSurgical(target, newOffset, isEaves ? { skipInnerLines: true } : undefined)
|
||||
if (isEaves && reclickRoof && hipMarks.length) {
|
||||
const wA = { x: target.x1, y: target.y1 }
|
||||
const wB = { x: target.x2, y: target.y2 }
|
||||
const rps = reclickRoof.points
|
||||
const M = rps.length
|
||||
const rayHit = (P, dir, A, B) => {
|
||||
const sx = B.x - A.x
|
||||
const sy = B.y - A.y
|
||||
const denom = dir.x * sy - dir.y * sx
|
||||
if (Math.abs(denom) < 1e-9) return Infinity
|
||||
const ax = A.x - P.x
|
||||
const ay = A.y - P.y
|
||||
const t = (ax * sy - ay * sx) / denom
|
||||
const s = (ax * dir.y - ay * dir.x) / denom
|
||||
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
|
||||
return Infinity
|
||||
}
|
||||
for (const mark of hipMarks) {
|
||||
const { il, which, side } = mark
|
||||
const wCorner = side === 'A' ? wA : wB
|
||||
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
||||
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
|
||||
const dx = outerOld.x - innerEnd.x
|
||||
const dy = outerOld.y - innerEnd.y
|
||||
const dlen = Math.hypot(dx, dy)
|
||||
if (dlen < 1e-6) continue
|
||||
const ux = dx / dlen
|
||||
const uy = dy / dlen
|
||||
let bestT = Infinity
|
||||
for (let k = 0; k < M; k++) {
|
||||
const A = rps[k]
|
||||
const B = rps[(k + 1) % M]
|
||||
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
|
||||
if (t < bestT) bestT = t
|
||||
}
|
||||
if (!isFinite(bestT) || bestT < 0.5) {
|
||||
logger.log(
|
||||
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] no-hit ' +
|
||||
JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
|
||||
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
|
||||
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
|
||||
const ratio = oldLen > 0 ? newLen / oldLen : 1
|
||||
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
|
||||
else il.set({ x2: hit.x, y2: hit.y })
|
||||
if (il.attributes) {
|
||||
const oldPlane = il.attributes.planeSize ?? 0
|
||||
const oldActual = il.attributes.actualSize ?? 0
|
||||
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
|
||||
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
|
||||
il.attributes.extended = true
|
||||
}
|
||||
il.__extended = true
|
||||
// [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] 出幅 재변경(eaves→eaves)을 skLine 과 동일 경로로 통일.
|
||||
// 출발 형상(skLine / A/B타입)에 무관하게 동일 규칙(= 출폭함수):
|
||||
// - 케라바 hip(확장라인)은 surgical CORNER-SNAP 금지 → wall 코너에서 45° ray-cast 로 roofLine 까지.
|
||||
// - 측면 수직선/변조각(normal line)은 surgical(recomputeNormalLine)이 새 코너로 추종.
|
||||
// skLine: 케라바 패턴 없음 → skipInnerLines(전부 45°가 처리).
|
||||
// A/B타입: 케라바 패턴 보유 → skipKerabHips(normal/ridge 는 surgical, hip 만 45°가 처리).
|
||||
// 출폭 증감(확장/수축) 양방향이 한 경로에서 일관 처리됨.
|
||||
const reclickBeforeSnap = isEaves && reclickRoof ? snapshotKerabState(reclickRoof) : null
|
||||
applyTargetOffsetSurgical(
|
||||
target,
|
||||
newOffset,
|
||||
isEaves ? (hasKerabPattern ? { skipKerabHips: true } : { skipInnerLines: true }) : undefined,
|
||||
)
|
||||
// surgical 적용 *후*(새 폴리곤) 에 케라바 hip 을 45° ray-cast 로 새 roofLine 변까지 확장.
|
||||
if (isEaves && reclickRoof) extendKerabHipsTo45(target, reclickRoof, hipMarks)
|
||||
// [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] A/B타입은 케라바 hip 외 inner line 좌표가
|
||||
// surgical 내부(recomputeKerabPatternLine/recomputeNormalLine)에서 갱신됨 →
|
||||
// 길이/치수 라벨만 새 좌표 기준으로 새로고침한다(hip 은 45° 블록에서 이미 갱신).
|
||||
if (isEaves && hasKerabPattern && reclickRoof && Array.isArray(reclickRoof.innerLines)) {
|
||||
for (const il of reclickRoof.innerLines) {
|
||||
if (!il || il.lineName === 'kerabPatternHip') continue
|
||||
if (typeof il.setCoords === 'function') il.setCoords()
|
||||
if (typeof il.setLength === 'function') il.setLength()
|
||||
if (typeof il.addLengthText === 'function') il.addLengthText()
|
||||
logger.log(
|
||||
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended ' +
|
||||
JSON.stringify({
|
||||
lineName: il.lineName,
|
||||
which,
|
||||
side,
|
||||
hit,
|
||||
ratio: Number(ratio.toFixed(3)),
|
||||
x1: il.x1,
|
||||
y1: il.y1,
|
||||
x2: il.x2,
|
||||
y2: il.y2,
|
||||
}),
|
||||
)
|
||||
}
|
||||
logger.log('[KERAB-OFFSET-RECLICK-UNIFY] surgical label refresh done')
|
||||
}
|
||||
// [KERAB-OFFSET-RECLICK-RULECHECK 2026-06-15] 라인변경(출폭 재적용) 후 규칙 검사 — 사용자 요구.
|
||||
if (isEaves && reclickRoof && reclickBeforeSnap) {
|
||||
runKerabRuleCheck(reclickRoof, 'reclick', reclickBeforeSnap)
|
||||
}
|
||||
target.set({ attributes })
|
||||
canvas.renderAll()
|
||||
@ -604,6 +695,19 @@ export function useEavesGableEdit(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// [KERAB-RECT-SOLVER 2026-06-15] 사각형이면 토글 이력과 무관한 결정론적 솔버로 처리.
|
||||
// 최종 4변 타입만으로 내부선 전부 재생성(Y 불변식 + apex 멈춤 구조적 보장). 미지원 형상은
|
||||
// false → 아래 기존 forward/revert/type 경로로 폴백.
|
||||
if (radioTypeRef.current === '1' && (attributes?.type === LINE_TYPE.WALLLINE.EAVES || attributes?.type === LINE_TYPE.WALLLINE.GABLE)) {
|
||||
const rectRoof = canvas
|
||||
.getObjects()
|
||||
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
||||
if (rectRoof && solveRectangleKerab(rectRoof, target, attributes)) {
|
||||
logger.log('[KERAB-RECT-SOLVER] handled deterministically → maze 우회')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// [2240 KERAB-SIMPLE 2026-05-20] 사용자 설명 정직 알고리즘:
|
||||
// 1) target 양 끝점에 직접 끝이 닿은 hip 2개를 찾는다 (nearestRoofPoint 안 씀)
|
||||
// 2) 두 hip 직선의 무한확장 교점 = apex
|
||||
@ -2551,139 +2655,18 @@ export function useEavesGableEdit(id) {
|
||||
// 게이블 코너 hip 판정용으로 이동 전 옛 폴리곤 꼭짓점을 미리 스냅샷한다.
|
||||
const oldPolyPoints = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y }))
|
||||
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true })
|
||||
const wA = { x: target.x1, y: target.y1 }
|
||||
const wB = { x: target.x2, y: target.y2 }
|
||||
const rps = Array.isArray(roof.points) ? roof.points : []
|
||||
const M = rps.length
|
||||
if (!M) return
|
||||
const rayHit = (P, dir, A, B) => {
|
||||
const sx = B.x - A.x
|
||||
const sy = B.y - A.y
|
||||
const denom = dir.x * sy - dir.y * sx
|
||||
if (Math.abs(denom) < 1e-9) return Infinity
|
||||
const ax = A.x - P.x
|
||||
const ay = A.y - P.y
|
||||
const t = (ax * sy - ay * sx) / denom
|
||||
const s = (ax * dir.y - ay * dir.x) / denom
|
||||
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
|
||||
return Infinity
|
||||
}
|
||||
// [45-UNIFY 2026-06-15] reclick 과 동일한 extendKerabHipsTo45 사용 (revert 가드 on):
|
||||
// forward 가 붙여둔 __kerabRevertOuterWhich/Side 플래그로 marks 를 만든 뒤, surgical *후*
|
||||
// 새 폴리곤에 대해 outerShared/outerOnVertex 가드를 적용해 45° 확장. 플래그는 사용 후 정리.
|
||||
const marks = []
|
||||
for (const il of roof.innerLines || []) {
|
||||
if (!il || !il.__kerabRevertOuterWhich) continue
|
||||
const which = il.__kerabRevertOuterWhich
|
||||
const side = il.__kerabRevertOuterSide
|
||||
const wCorner = side === 'A' ? wA : wB
|
||||
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
||||
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
|
||||
// [KERAB-REVERT-SHARED-ENDPOINT-GUARD 2026-06-10] outer(확장) 끝이 다른 내부선과
|
||||
// 공유되면 확장하지 않는다. 골짜기(valley) 내부 hip 은 양 끝이 skeleton 교점이라 outer 도
|
||||
// 공유 → 자동 제외. 게이블/코너 hip 의 outer 는 free(roofLine 변에 홀로 도달) → 확장.
|
||||
// 판정이 출폭·snapshot 좌표와 무관한 구조 기준이라 다중 출폭 변경에도 안 깨진다.
|
||||
// (구 c1/c2 가드는 c1/c2 를 현재 출폭으로, outerOld 를 forward 당시 출폭으로 잡아
|
||||
// 출폭이 여러 번 바뀌면 게이블 hip 을 interior 로 오판 → SK 확장이 중간에 멈췄다.)
|
||||
const SHARE_TOL = 2.0
|
||||
const outerShared = (roof.innerLines || []).some(
|
||||
(o) =>
|
||||
o &&
|
||||
o !== il &&
|
||||
o.visible !== false &&
|
||||
(Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL ||
|
||||
Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL),
|
||||
)
|
||||
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] TYPE 출신 게이블 hip 의 outer 끝은 폴리곤 코너(roofLine 꼭짓점)
|
||||
// 라 인접 면경계선과 공유 → outerShared 가 true 가 돼 확장이 스킵됐다(처마변경 실패: hip 이 옛 코너에 멈춤).
|
||||
// 골짜기 내부 hip 의 outer 는 폴리곤 경계에 없는 skeleton 교점이다. 따라서 outerOld 가 (이동 전) 폴리곤
|
||||
// 꼭짓점이면 게이블 코너 hip 으로 보고 확장을 강제, 공유여도 스킵하지 않는다.
|
||||
const VERT_TOL = 2.0
|
||||
// outerOld 가 (이동 전) 폴리곤 꼭짓점이면 그 인덱스를 잡는다. surgical 은 점 순서·개수를 보존하므로
|
||||
// 같은 인덱스의 새 roof.points 가 곧 이동 후 코너 = 게이블 hip 의 새 outer 끝점.
|
||||
let vtxIdx = -1
|
||||
for (let vi = 0; vi < oldPolyPoints.length; vi++) {
|
||||
const p = oldPolyPoints[vi]
|
||||
if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) {
|
||||
vtxIdx = vi
|
||||
break
|
||||
}
|
||||
}
|
||||
const outerOnVertex = vtxIdx >= 0
|
||||
if (outerShared && !outerOnVertex) {
|
||||
logger.log(
|
||||
'[KERAB-REVERT-EXTEND-45] skip (interior hip — outer shared) ' +
|
||||
JSON.stringify({ lineName: il.lineName, which, side, outerOld }),
|
||||
)
|
||||
delete il.__kerabRevertOuterWhich
|
||||
delete il.__kerabRevertOuterSide
|
||||
continue
|
||||
}
|
||||
let hit
|
||||
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] 게이블 코너 hip: 45° ray-cast 는 출폭이 클 때 인접
|
||||
// 변에 먼저 닿아(예: 출폭 80 → -803 에서 멈춤) 코너(-839)에 못 미친다 → rLine 겹침/틈.
|
||||
// surgical 후 동일 인덱스 코너로 직접 스냅해 항상 새 roofLine 꼭짓점까지 도달시킨다.
|
||||
if (outerOnVertex && rps.length === oldPolyPoints.length && rps[vtxIdx]) {
|
||||
hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y }
|
||||
} else {
|
||||
// hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음.
|
||||
const dx = outerOld.x - innerEnd.x
|
||||
const dy = outerOld.y - innerEnd.y
|
||||
const dlen = Math.hypot(dx, dy)
|
||||
if (dlen < 1e-6) {
|
||||
delete il.__kerabRevertOuterWhich
|
||||
delete il.__kerabRevertOuterSide
|
||||
continue
|
||||
}
|
||||
const ux = dx / dlen
|
||||
const uy = dy / dlen
|
||||
// wL 코너에서 45° 방향으로 ray → 새 rL 변 (roof.points 는 surgical 후 새 출폭) 의 첫 hit.
|
||||
let bestT = Infinity
|
||||
for (let k = 0; k < M; k++) {
|
||||
const A = rps[k]
|
||||
const B = rps[(k + 1) % M]
|
||||
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
|
||||
if (t < bestT) bestT = t
|
||||
}
|
||||
if (!isFinite(bestT) || bestT < 0.5) {
|
||||
logger.log(
|
||||
'[KERAB-REVERT-EXTEND-45] no-hit ' +
|
||||
JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }),
|
||||
)
|
||||
delete il.__kerabRevertOuterWhich
|
||||
delete il.__kerabRevertOuterSide
|
||||
continue
|
||||
}
|
||||
hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
|
||||
}
|
||||
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
|
||||
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
|
||||
const ratio = oldLen > 0 ? newLen / oldLen : 1
|
||||
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
|
||||
else il.set({ x2: hit.x, y2: hit.y })
|
||||
if (il.attributes) {
|
||||
const oldPlane = il.attributes.planeSize ?? 0
|
||||
const oldActual = il.attributes.actualSize ?? 0
|
||||
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
|
||||
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
|
||||
il.attributes.extended = true
|
||||
}
|
||||
il.__extended = true
|
||||
if (typeof il.setCoords === 'function') il.setCoords()
|
||||
if (typeof il.setLength === 'function') il.setLength()
|
||||
if (typeof il.addLengthText === 'function') il.addLengthText()
|
||||
logger.log(
|
||||
'[KERAB-REVERT-EXTEND-45] ' +
|
||||
JSON.stringify({
|
||||
lineName: il.lineName,
|
||||
which,
|
||||
side,
|
||||
hit,
|
||||
ratio: Number(ratio.toFixed(3)),
|
||||
x1: il.x1,
|
||||
y1: il.y1,
|
||||
x2: il.x2,
|
||||
y2: il.y2,
|
||||
}),
|
||||
)
|
||||
delete il.__kerabRevertOuterWhich
|
||||
delete il.__kerabRevertOuterSide
|
||||
marks.push({ il, which: il.__kerabRevertOuterWhich, side: il.__kerabRevertOuterSide })
|
||||
}
|
||||
extendKerabHipsTo45(target, roof, marks, { useRevertGuards: true, oldPolyPoints })
|
||||
for (const m of marks) {
|
||||
delete m.il.__kerabRevertOuterWhich
|
||||
delete m.il.__kerabRevertOuterSide
|
||||
}
|
||||
runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap)
|
||||
}
|
||||
@ -3403,6 +3386,31 @@ export function useEavesGableEdit(id) {
|
||||
removeLine(eh)
|
||||
roof.innerLines = roof.innerLines.filter((il) => il !== eh)
|
||||
}
|
||||
// [KERAB-EXTHIP-COORD-CLEAN 2026-06-15] __extHipsCreated 는 객체 참조라, 양쪽 박공→한 변씩 처마
|
||||
// 복원 시퀀스에서 중간 인접변 작업이 ext-hip 을 파괴·재생성하면 죽은 객체를 가리켜 위 루프가
|
||||
// no-op 이 된다 → 재생성된 ext-hip 이 orphan 으로 잔류(apex 에서 끝나는 1881 V, "라인은 roofLine
|
||||
// 까지" 규칙 위반). 이 kLine 의 apex(=roofLine 반대쪽 안쪽 끝점) 를 공유하는 ext 라인을 좌표로
|
||||
// 한 번 더 제거해 재생성돼도 잡는다. orphan 없으면 0개 제거 → 정상 케이스 무영향.
|
||||
{
|
||||
const _r = ridgeAtMid.ridge
|
||||
const _tMid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 }
|
||||
const _apexPt =
|
||||
Math.hypot(_r.x1 - _tMid.x, _r.y1 - _tMid.y) >= Math.hypot(_r.x2 - _tMid.x, _r.y2 - _tMid.y)
|
||||
? { x: _r.x1, y: _r.y1 }
|
||||
: { x: _r.x2, y: _r.y2 }
|
||||
const _EXT_TOL = 1.0
|
||||
const _orphans = roof.innerLines.filter(
|
||||
(il) =>
|
||||
il &&
|
||||
(il.lineName === 'kerabPatternExtHip' || il.lineName === 'kerabPatternExtRidge') &&
|
||||
(Math.hypot(il.x1 - _apexPt.x, il.y1 - _apexPt.y) < _EXT_TOL || Math.hypot(il.x2 - _apexPt.x, il.y2 - _apexPt.y) < _EXT_TOL),
|
||||
)
|
||||
for (const eh of _orphans) {
|
||||
removeLine(eh)
|
||||
roof.innerLines = roof.innerLines.filter((il) => il !== eh)
|
||||
}
|
||||
if (_orphans.length) logger.log('[KERAB-EXTHIP-COORD-CLEAN] removed ' + JSON.stringify({ apex: _apexPt, count: _orphans.length }))
|
||||
}
|
||||
removeLine(ridgeAtMid.ridge)
|
||||
roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge)
|
||||
for (const snap of removedHips) {
|
||||
@ -3555,10 +3563,37 @@ export function useEavesGableEdit(id) {
|
||||
ix = -ix
|
||||
iy = -iy
|
||||
}
|
||||
const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) }
|
||||
// [KERAB-TYPE-EAVES-APEX-CLAMP 2026-06-15] apex 깊이 = L/2 (45° 이등변). 단 변이 폴리곤 inward
|
||||
// 두께보다 길면(가로로 납작한 폴리곤) mid+inward*(L/2) 가 반대편 roofLine 을 넘어 폭주
|
||||
// (R3-outside). "라인은 roofLine 까지" 규칙 → inward ray 가 반대편 변에 닿는 거리로 상한.
|
||||
// 정상 切妻(세로로 긴 폴리곤)은 oppDist > L/2 라 min=L/2, 기존과 동치 → 무영향.
|
||||
const oppRayDist = (P, dx, dy) => {
|
||||
let best = Infinity
|
||||
const M = cps.length
|
||||
for (let i = 0; i < M; i++) {
|
||||
const A = cps[i]
|
||||
const B = cps[(i + 1) % M]
|
||||
const ax = B.x - A.x
|
||||
const ay = B.y - A.y
|
||||
const den = dx * ay - dy * ax
|
||||
if (Math.abs(den) < 1e-9) continue
|
||||
const t = ((A.x - P.x) * ay - (A.y - P.y) * ax) / den
|
||||
const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den
|
||||
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t
|
||||
}
|
||||
return best
|
||||
}
|
||||
const oppDist = oppRayDist(mid, ix, iy)
|
||||
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
|
||||
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
|
||||
// 마루 단축: 케라바변쪽 끝점 → apex.
|
||||
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
|
||||
else ridge.set({ x2: apex.x, y2: apex.y })
|
||||
// [KERAB-TYPE-EAVES-RIDGE-ZERO 2026-06-15] apex 가 반대편 roofLine 에 클램프되면 native 마루가
|
||||
// 거기로 단축돼 zero-length 가 될 수 있다(R2 위반). 객체는 revert 위해 보존하되 invisible 처리
|
||||
// (R2 체크는 visible 만 검사). 마루 형상 재구성은 완전 우진각 시 reconstruct 가 담당.
|
||||
const ridgeLen = Math.hypot(ridge.x2 - ridge.x1, ridge.y2 - ridge.y1)
|
||||
if (ridgeLen < 1) ridge.visible = false
|
||||
const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 })
|
||||
if (ridge.attributes) {
|
||||
ridge.attributes.planeSize = rsz
|
||||
@ -3945,6 +3980,296 @@ export function useEavesGableEdit(id) {
|
||||
if (lbl) lbl.set({ visible: !hide })
|
||||
}
|
||||
|
||||
// [KERAB-RECT-SOLVER 2026-06-15] 사각형(axis-aligned 4코너) 결정론적 케라바 솔버.
|
||||
// 토글 이력과 무관하게 「최종 4변 타입」만으로 내부선(힙/마루/케라바 줄기)을 매번 처음부터
|
||||
// 재생성한다 = 출폭/변타입의 순수 함수. forward/revert/type 미로를 통째로 우회.
|
||||
// 구조적으로 보장되는 두 불변식:
|
||||
// 불변식1(Y): 줄기(마루)는 박공으로 바뀐 변(slope 없음) 방향으로만 뻗는다. \|/, /|\ 불가.
|
||||
// 불변식2: 힙은 apex(45° 교점)에서 멈춘다 → wallbase 교점 초과(폴리곤 밖) 힙 없음.
|
||||
// 지원: 박공 0개(우진각)·1개(Y, apex 내부일 때)·2개 마주보기(맞배). 그 외(인접 2박공/3·4박공/
|
||||
// 비사각형/회전사각형/radio2 타입)는 false 반환 → 기존 경로 폴백.
|
||||
const solveRectangleKerab = (roof, target, newAttributes) => {
|
||||
try {
|
||||
if (!roof || !Array.isArray(roof.points) || roof.points.length !== 4) return false
|
||||
if (!Array.isArray(roof.innerLines)) roof.innerLines = []
|
||||
const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id)
|
||||
// [KERAB-RECT-PROBE 2026-06-15] outerLine 이 wallbase(출폭0) 좌표인지 roofLine 좌표인지
|
||||
// 실제 데이터로 확정하기 위한 진입 덤프. solver 가 좌표 불일치로 false 폴백해도 이건 먼저 찍힘.
|
||||
logger.log(
|
||||
'[KERAB-RECT-PROBE] entry ' +
|
||||
JSON.stringify({
|
||||
roofPoints: roof.points.map((p) => ({ x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 })),
|
||||
outerLineCount: outerLines.length,
|
||||
outerLines: outerLines.map((o) => ({
|
||||
type: o.attributes?.type,
|
||||
offset: o.attributes?.offset,
|
||||
x1: Math.round(o.x1 * 10) / 10,
|
||||
y1: Math.round(o.y1 * 10) / 10,
|
||||
x2: Math.round(o.x2 * 10) / 10,
|
||||
y2: Math.round(o.y2 * 10) / 10,
|
||||
})),
|
||||
newType: newAttributes?.type,
|
||||
newOffset: newAttributes?.offset,
|
||||
}),
|
||||
)
|
||||
if (outerLines.length !== 4) return false
|
||||
const newType = newAttributes?.type
|
||||
const TOL = 0.6
|
||||
const xs = roof.points.map((p) => p.x)
|
||||
const ys = roof.points.map((p) => p.y)
|
||||
let minX = Math.min(...xs)
|
||||
let maxX = Math.max(...xs)
|
||||
let minY = Math.min(...ys)
|
||||
let maxY = Math.max(...ys)
|
||||
let W = maxX - minX
|
||||
let H = maxY - minY
|
||||
if (W < 2 || H < 2) return false
|
||||
// 네 코너가 실제 직사각형 코너와 일치해야 함 (axis-aligned).
|
||||
const corners = [
|
||||
{ x: minX, y: minY },
|
||||
{ x: maxX, y: minY },
|
||||
{ x: maxX, y: maxY },
|
||||
{ x: minX, y: maxY },
|
||||
]
|
||||
for (const c of corners) {
|
||||
if (!roof.points.some((p) => Math.abs(p.x - c.x) < TOL && Math.abs(p.y - c.y) < TOL)) return false
|
||||
}
|
||||
// [KERAB-RECT-SIDE-FIX 2026-06-15] outerLine 은 roof.points(roofLine)에서 出幅만큼 안쪽으로
|
||||
// 들어온 wallbase 좌표라, roof.points bbox 와 직접 비교하면 出幅(예 50)만큼 어긋나 TOL 초과 →
|
||||
// sideOf 가 null → 솔버 전체 false 폴백(증상: 토글 시 KERAB-SIMPLE 미로로 빠져 그림 깨짐).
|
||||
// 변 분류는 outerLine 자체 bbox 기준으로, 지오메트리(힙/마루)는 그대로 roof.points 기준으로 푼다.
|
||||
const oxs = outerLines.flatMap((o) => [o.x1, o.x2])
|
||||
const oys = outerLines.flatMap((o) => [o.y1, o.y2])
|
||||
const oMinX = Math.min(...oxs)
|
||||
const oMaxX = Math.max(...oxs)
|
||||
const oMinY = Math.min(...oys)
|
||||
const oMaxY = Math.max(...oys)
|
||||
// 각 외곽선 → 변(top/bottom/left/right) 분류 + 유효 타입(EAVES/GABLE) 확정.
|
||||
const sideOf = (o) => {
|
||||
const horiz = Math.abs(o.y1 - o.y2) < TOL
|
||||
const vert = Math.abs(o.x1 - o.x2) < TOL
|
||||
if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMinY) < TOL) return 'top'
|
||||
if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMaxY) < TOL) return 'bottom'
|
||||
if (vert && Math.abs((o.x1 + o.x2) / 2 - oMinX) < TOL) return 'left'
|
||||
if (vert && Math.abs((o.x1 + o.x2) / 2 - oMaxX) < TOL) return 'right'
|
||||
return null
|
||||
}
|
||||
const sides = {}
|
||||
for (const o of outerLines) {
|
||||
const s = sideOf(o)
|
||||
if (!s || sides[s]) return false
|
||||
const eff = o === target ? newType : o.attributes?.type
|
||||
if (eff !== LINE_TYPE.WALLLINE.EAVES && eff !== LINE_TYPE.WALLLINE.GABLE) return false
|
||||
sides[s] = { o, gable: eff === LINE_TYPE.WALLLINE.GABLE }
|
||||
}
|
||||
if (!sides.top || !sides.bottom || !sides.left || !sides.right) return false
|
||||
|
||||
const gTop = sides.top.gable
|
||||
const gBottom = sides.bottom.gable
|
||||
const gLeft = sides.left.gable
|
||||
const gRight = sides.right.gable
|
||||
const nGable = [gTop, gBottom, gLeft, gRight].filter(Boolean).length
|
||||
|
||||
// [KERAB-RECT-GATE 2026-06-15] 出幅을 경계에 반영하기 전에 지원 형상인지 먼저 판정한다.
|
||||
// 미지원(인접 2박공/3·4박공/얕은 Y)인데 offset 을 먼저 옮기면 폴백된 미로가 offset 을 한 번 더
|
||||
// 적용해 이중 出幅이 된다 → 반드시 무변형 상태에서 게이트하고, 미지원이면 boundary 손대지 않고 false.
|
||||
{
|
||||
let ok = false
|
||||
if (nGable === 0) ok = true
|
||||
// [KERAB-RECT-GATE-1 2026-06-15] 단일 박공은 항상 지원: depth<=span 이면 Y(apex)모델,
|
||||
// long-edge(케라바, depth>span)면 CASE B(반대코너 45° 힙 2개 + 변 위 마루 1개). 둘 다 정의됨.
|
||||
else if (nGable === 1) ok = true
|
||||
else if (nGable === 2) {
|
||||
ok = (gTop && gBottom) || (gLeft && gRight)
|
||||
}
|
||||
if (!ok) return false
|
||||
}
|
||||
|
||||
// [KERAB-RECT-OFFSET 2026-06-15] 지원 형상 확정 후, 토글되는 변의 出幅 변경분을 경계(roofLine)에
|
||||
// 먼저 반영한다(inner line 은 안 건드림). 이후 갱신된 roof.points 로 내부 지오메트리를 푼다.
|
||||
// 出幅 입력이 기존과 같으면 delta 0 → 무동작. 다르면 maze 의 offset-surgical 과 동일 경로.
|
||||
if (newAttributes && newAttributes.offset != null) {
|
||||
applyTargetOffsetSurgical(target, newAttributes.offset, { skipInnerLines: true })
|
||||
const nxs = roof.points.map((p) => p.x)
|
||||
const nys = roof.points.map((p) => p.y)
|
||||
minX = Math.min(...nxs)
|
||||
maxX = Math.max(...nxs)
|
||||
minY = Math.min(...nys)
|
||||
maxY = Math.max(...nys)
|
||||
W = maxX - minX
|
||||
H = maxY - minY
|
||||
}
|
||||
|
||||
const cx = (minX + maxX) / 2
|
||||
const cy = (minY + maxY) / 2
|
||||
|
||||
const out = []
|
||||
const addRidge = (x1, y1, x2, y2) => {
|
||||
if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'ridge' })
|
||||
}
|
||||
const addHip = (x1, y1, x2, y2) => {
|
||||
if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'hip' })
|
||||
}
|
||||
|
||||
if (nGable === 0) {
|
||||
// 우진각: 마루는 긴 축, apex 는 짧은 변/2 안쪽. 힙 4개.
|
||||
if (W >= H) {
|
||||
const inset = H / 2
|
||||
const lA = { x: minX + inset, y: cy }
|
||||
const rA = { x: maxX - inset, y: cy }
|
||||
addRidge(lA.x, lA.y, rA.x, rA.y)
|
||||
addHip(minX, minY, lA.x, lA.y)
|
||||
addHip(minX, maxY, lA.x, lA.y)
|
||||
addHip(maxX, minY, rA.x, rA.y)
|
||||
addHip(maxX, maxY, rA.x, rA.y)
|
||||
} else {
|
||||
const inset = W / 2
|
||||
const tA = { x: cx, y: minY + inset }
|
||||
const bA = { x: cx, y: maxY - inset }
|
||||
addRidge(tA.x, tA.y, bA.x, bA.y)
|
||||
addHip(minX, minY, tA.x, tA.y)
|
||||
addHip(maxX, minY, tA.x, tA.y)
|
||||
addHip(minX, maxY, bA.x, bA.y)
|
||||
addHip(maxX, maxY, bA.x, bA.y)
|
||||
}
|
||||
} else if (nGable === 1) {
|
||||
// 박공변이 짧은/중간 변(맞은편 apex 가 폴리곤 안)이면 Y(apex+마루).
|
||||
// 긴 변(케라바=마루와 평행, W>2H or H>2W)이면 맞은편 두 코너에서 45° 힙이 박공변까지
|
||||
// 직진하고 박공변 위에 마루가 놓인다(CASE B). 어느 쪽이든 단일 박공은 항상 유효.
|
||||
if (gBottom || gTop) {
|
||||
const depth = W / 2 // apex 깊이 = 박공변(가로) 길이/2
|
||||
if (depth <= H - 0.5) {
|
||||
// Y: apex 가 맞은편 변 안쪽
|
||||
if (gBottom) {
|
||||
const apex = { x: cx, y: minY + depth }
|
||||
addHip(minX, minY, apex.x, apex.y)
|
||||
addHip(maxX, minY, apex.x, apex.y)
|
||||
addRidge(apex.x, apex.y, cx, maxY)
|
||||
} else {
|
||||
const apex = { x: cx, y: maxY - depth }
|
||||
addHip(minX, maxY, apex.x, apex.y)
|
||||
addHip(maxX, maxY, apex.x, apex.y)
|
||||
addRidge(apex.x, apex.y, cx, minY)
|
||||
}
|
||||
} else if (gBottom) {
|
||||
// 케라바(긴 가로 변): 위쪽 두 코너 → 박공변(아래)까지 45° 직진 + 박공변 위 마루
|
||||
addHip(minX, minY, minX + H, maxY)
|
||||
addHip(maxX, minY, maxX - H, maxY)
|
||||
addRidge(minX + H, maxY, maxX - H, maxY)
|
||||
} else {
|
||||
addHip(minX, maxY, minX + H, minY)
|
||||
addHip(maxX, maxY, maxX - H, minY)
|
||||
addRidge(minX + H, minY, maxX - H, minY)
|
||||
}
|
||||
} else {
|
||||
const depth = H / 2
|
||||
if (depth <= W - 0.5) {
|
||||
if (gLeft) {
|
||||
const apex = { x: maxX - depth, y: cy }
|
||||
addHip(maxX, minY, apex.x, apex.y)
|
||||
addHip(maxX, maxY, apex.x, apex.y)
|
||||
addRidge(apex.x, apex.y, minX, cy)
|
||||
} else {
|
||||
const apex = { x: minX + depth, y: cy }
|
||||
addHip(minX, minY, apex.x, apex.y)
|
||||
addHip(minX, maxY, apex.x, apex.y)
|
||||
addRidge(apex.x, apex.y, maxX, cy)
|
||||
}
|
||||
} else if (gLeft) {
|
||||
// 케라바(긴 세로 변): 오른쪽 두 코너 → 박공변(왼쪽)까지 45° 직진 + 박공변 위 마루
|
||||
addHip(maxX, minY, minX, minY + W)
|
||||
addHip(maxX, maxY, minX, maxY - W)
|
||||
addRidge(minX, minY + W, minX, maxY - W)
|
||||
} else {
|
||||
addHip(minX, minY, maxX, minY + W)
|
||||
addHip(minX, maxY, maxX, maxY - W)
|
||||
addRidge(maxX, minY + W, maxX, maxY - W)
|
||||
}
|
||||
}
|
||||
} else if (nGable === 2) {
|
||||
// 맞배: 마주보는 두 박공 사이를 잇는 단일 마루, 힙 없음.
|
||||
if (gTop && gBottom) {
|
||||
addRidge(cx, minY, cx, maxY)
|
||||
} else if (gLeft && gRight) {
|
||||
addRidge(minX, cy, maxX, cy)
|
||||
} else {
|
||||
return false // 인접 2박공 = 굽은 마루, 미지원 → 폴백
|
||||
}
|
||||
} else {
|
||||
return false // 3·4 박공 → 폴백
|
||||
}
|
||||
if (!out.length) return false
|
||||
|
||||
const beforeSnap = snapshotKerabState(roof)
|
||||
// 기존 내부 HIP/RIDGE(네이티브 + kerabPattern) 전부 제거 후 재생성.
|
||||
for (const il of [...roof.innerLines]) {
|
||||
if (!il) continue
|
||||
if (il.name === LINE_TYPE.SUBLINE.HIP || il.name === LINE_TYPE.SUBLINE.RIDGE) {
|
||||
canvas.remove(il)
|
||||
const idx = roof.innerLines.indexOf(il)
|
||||
if (idx >= 0) roof.innerLines.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
for (const o of outerLines) removeKerabHalfLabels(o.id)
|
||||
for (const ln of out) {
|
||||
const sz = calcLinePlaneSize({ x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 })
|
||||
const isRidge = ln.kind === 'ridge'
|
||||
const q = new QLine([ln.x1, ln.y1, ln.x2, ln.y2], {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '#1083E3',
|
||||
strokeWidth: isRidge ? 4 : 3,
|
||||
name: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
|
||||
textMode: roof.textMode,
|
||||
attributes: {
|
||||
roofId: roof.id,
|
||||
type: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
|
||||
planeSize: sz,
|
||||
actualSize: sz,
|
||||
// [KERAB-RECT-EXTENDED 2026-06-15] 솔버 hip 은 roofLine 코너(=출폭 끝점)까지 이미 닿아 있다.
|
||||
// allocation 의 integrateExtensionLines 가 ext+hip 머지로 hip 을 wall 코너까지 단축시키면
|
||||
// split graph 가 polygon 코너와 끊겨 1면만 잡힌다(native hip 은 extended 플래그로 머지 스킵).
|
||||
// 동일하게 extended=true 부여 → INTEGRATE 가 merge 스킵, ext 는 lines 에서만 제거.
|
||||
...(isRidge ? {} : { extended: true }),
|
||||
},
|
||||
})
|
||||
q.lineName = ln.kind
|
||||
if (!isRidge) q.__extended = true
|
||||
// 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로 동기화 필수.
|
||||
q.startPoint = { x: ln.x1, y: ln.y1 }
|
||||
q.endPoint = { x: ln.x2, y: ln.y2 }
|
||||
canvas.add(q)
|
||||
q.bringToFront()
|
||||
roof.innerLines.push(q)
|
||||
if (typeof q.setCoords === 'function') q.setCoords()
|
||||
if (typeof q.setLength === 'function') q.setLength()
|
||||
if (typeof q.addLengthText === 'function') q.addLengthText()
|
||||
}
|
||||
// 박공 변엔 절반 라벨 + 원본 길이 숨김, 처마 변엔 원본 길이 노출.
|
||||
for (const key of ['top', 'bottom', 'left', 'right']) {
|
||||
const s = sides[key]
|
||||
if (s.gable) {
|
||||
addKerabHalfLabels(s.o, { x: s.o.x1, y: s.o.y1 }, { x: s.o.x2, y: s.o.y2 })
|
||||
hideOriginalLengthText(s.o.id, true)
|
||||
} else {
|
||||
hideOriginalLengthText(s.o.id, false)
|
||||
}
|
||||
}
|
||||
target.set({ attributes: newAttributes })
|
||||
canvas.renderAll()
|
||||
const phase = newType === LINE_TYPE.WALLLINE.GABLE ? 'forward' : 'revert'
|
||||
runKerabRuleCheck(roof, phase, beforeSnap)
|
||||
try {
|
||||
reattachDebugLabels(canvas, roof.id)
|
||||
} catch (e) {}
|
||||
logger.log('[KERAB-RECT-SOLVER] built ' + JSON.stringify({ nGable, lines: out.length, W: Math.round(W), H: Math.round(H) }))
|
||||
return true
|
||||
} catch (err) {
|
||||
logger.warn('[KERAB-RECT-SOLVER] error → fallback', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// [KERAB-PATTERN-EXT-CLEAN 2026-05-19] forward 시 제거되는 hip 과 짝이었던 orphan
|
||||
// extensionLine 정리. 한 끝점이 hip 의 한 끝점과 같고 다른 끝점이 hip 의 진행
|
||||
// 방향과 동일선상에 있으면 짝으로 판정. 잔류 시 allocation 의 integrateExtensionLines
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user