dev #930
@ -7,6 +7,7 @@ import {
|
||||
drawGableRoof,
|
||||
drawRoofByAttribute,
|
||||
drawShedRoof,
|
||||
equalizeParallelEaveLabels,
|
||||
equalizeSymmetricHips,
|
||||
snapNearAxisEdges,
|
||||
toGeoJSON,
|
||||
@ -16,6 +17,7 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||
import Big from 'big.js'
|
||||
import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils'
|
||||
import { logger } from '@/util/logger'
|
||||
import { debugCapture } from '@/util/debugCapture'
|
||||
|
||||
// ========================================================================
|
||||
// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local')
|
||||
@ -100,6 +102,137 @@ function __attachDebugLabels(canvas, parentId) {
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// [ROOF-FACE-DIAG 2026-06-04] 지붕면 할당 디버그 — 로컬 전용.
|
||||
// 할당된 각 지붕면(name === POLYGON_TYPE.ROOF)에
|
||||
// - 캔버스: 면 식별자 + 평면면적 라벨(F-n) (사람이 "F-3" 지목용)
|
||||
// - 로거: 면적(평면/실제)·구배·방향·꼭짓점 절대좌표 전체 (대화/디버그 공유용 — 본체)
|
||||
// 캔버스 라벨만으론 Claude 가 못 읽으므로 logger 덤프가 핵심.
|
||||
// ========================================================================
|
||||
const ROOF_FACE_LABEL_NAME = '__roofFaceDebugLabel'
|
||||
|
||||
// fabric Polygon 의 points 는 pathOffset 기준 상대좌표 → 변환행렬로 절대(canvas)좌표 복원.
|
||||
function __roofFaceAbsPoints(roof) {
|
||||
const m = roof.calcTransformMatrix()
|
||||
const ox = roof.pathOffset?.x ?? 0
|
||||
const oy = roof.pathOffset?.y ?? 0
|
||||
return (roof.points || []).map((p) => fabric.util.transformPoint({ x: p.x - ox, y: p.y - oy }, m))
|
||||
}
|
||||
|
||||
function __shoelaceAreaPx(pts) {
|
||||
let a = 0
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const cur = pts[i]
|
||||
const nxt = pts[(i + 1) % pts.length]
|
||||
a += cur.x * nxt.y - nxt.x * cur.y
|
||||
}
|
||||
return Math.abs(a) / 2
|
||||
}
|
||||
|
||||
export function reattachRoofFaceDebugLabels(canvas) {
|
||||
if (!__isDebugLabelsEnabled()) return
|
||||
if (!canvas) return
|
||||
|
||||
// 재할당/재진입 시 기존 면 라벨 제거 → 카운트 reset 후 새로 부여.
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((o) => o.name === ROOF_FACE_LABEL_NAME)
|
||||
.forEach((o) => canvas.remove(o))
|
||||
|
||||
const roofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF)
|
||||
logger.log(`[ROOF-FACE-DIAG] 할당 지붕면 ${roofs.length}개`)
|
||||
|
||||
// [ROOF-FACE-DIAG 2026-06-04] logger.log 는 브라우저 콘솔뿐이라 Claude 가 못 읽음.
|
||||
// 좌표/면적을 debugCapture 로 debug/debug.log 에 영속화 → log-check 로 읽는 본체.
|
||||
const records = []
|
||||
|
||||
roofs.forEach((roof, i) => {
|
||||
const label = `F-${i + 1}`
|
||||
const pts = __roofFaceAbsPoints(roof)
|
||||
if (pts.length < 3) {
|
||||
logger.log(`[ROOF-FACE-DIAG] ${label} 좌표 부족(pts=${pts.length}) skip`)
|
||||
records.push({ face: label, skip: `pts=${pts.length}` })
|
||||
return
|
||||
}
|
||||
// 좌표 단위 1px = 10mm (planeSize = hypot(px)*10) → 평면면적 ㎡ = pxArea * 100 / 1e6 = pxArea / 1e4
|
||||
const planeM2 = __shoelaceAreaPx(pts) / 10000
|
||||
// 구배(寸) → 경사각. 실제 지붕면적 = 평면 / cos(angle). 구배 없으면 실제 = 평면.
|
||||
const pitch = Number(roof.pitch ?? roof.roofMaterial?.pitch ?? 0) || 0
|
||||
const angle = Math.atan(pitch / 10)
|
||||
const actualM2 = angle ? planeM2 / Math.cos(angle) : planeM2
|
||||
|
||||
// 캔버스 라벨: 면 중심에 식별자 + 평면면적만 (좌표는 로그에만).
|
||||
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length
|
||||
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length
|
||||
const text = new fabric.Text(`${label} ${planeM2.toFixed(2)}㎡`, {
|
||||
left: cx,
|
||||
top: cy,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
fontSize: 12,
|
||||
fill: '#000',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: 'rgba(0,255,255,0.85)',
|
||||
selectable: false,
|
||||
evented: false,
|
||||
hasControls: false,
|
||||
hasBorders: false,
|
||||
name: ROOF_FACE_LABEL_NAME,
|
||||
parentId: roof.id,
|
||||
excludeFromExport: true,
|
||||
})
|
||||
canvas.add(text)
|
||||
text.bringToFront()
|
||||
|
||||
// 꼭짓점별 좌표 라벨 (화면 표시 — local 전용). 같은 name 으로 cleanup 에 함께 제거됨.
|
||||
pts.forEach((p) => {
|
||||
const coord = new fabric.Text(`${Math.round(p.x)},${Math.round(p.y)}`, {
|
||||
left: p.x,
|
||||
top: p.y,
|
||||
originX: 'center',
|
||||
originY: 'bottom',
|
||||
fontSize: 9,
|
||||
fill: '#0a0',
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
selectable: false,
|
||||
evented: false,
|
||||
hasControls: false,
|
||||
hasBorders: false,
|
||||
name: ROOF_FACE_LABEL_NAME,
|
||||
parentId: roof.id,
|
||||
excludeFromExport: true,
|
||||
})
|
||||
canvas.add(coord)
|
||||
coord.bringToFront()
|
||||
})
|
||||
|
||||
// 로거 덤프(본체): 평면/실제 면적 + 구배 + 방향 + 변 개수 + 꼭짓점 절대좌표 전체.
|
||||
const ptsStr = pts.map((p) => `(${p.x.toFixed(1)},${p.y.toFixed(1)})`).join(' ')
|
||||
logger.log(
|
||||
`[ROOF-FACE-DIAG] ${label} id=${roof.id?.slice?.(0, 8) ?? 'none'} ` +
|
||||
`plane=${planeM2.toFixed(2)}㎡ actual=${actualM2.toFixed(2)}㎡ pitch=${pitch} ` +
|
||||
`dir=${roof.direction ?? 'none'} lines=${roof.lines?.length ?? 0} pts=${pts.length} ${ptsStr}`,
|
||||
)
|
||||
records.push({
|
||||
face: label,
|
||||
id: roof.id?.slice?.(0, 8) ?? null,
|
||||
planeM2: Number(planeM2.toFixed(2)),
|
||||
actualM2: Number(actualM2.toFixed(2)),
|
||||
pitch,
|
||||
dir: roof.direction ?? null,
|
||||
lines: roof.lines?.length ?? 0,
|
||||
points: pts.map((p) => ({ x: Number(p.x.toFixed(1)), y: Number(p.y.toFixed(1)) })),
|
||||
})
|
||||
})
|
||||
|
||||
// Claude 가 읽는 본체 — debug/debug.log 에 [ROOF-FACE-DIAG] 라벨로 영속화.
|
||||
debugCapture.log('ROOF-FACE-DIAG', { count: roofs.length, faces: records })
|
||||
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
type: 'QPolygon',
|
||||
// lines: [],
|
||||
@ -710,6 +843,18 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
if (this.innerLines && this.innerLines.length > 0) {
|
||||
const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP)
|
||||
equalizeSymmetricHips(hips, this.canvas)
|
||||
|
||||
// 평행 처마 쌍(고정 outerLine ↔ 이동 eaveHelpLine) 라벨 통일.
|
||||
// OVER_EPS 클램프로 이동 변이 1mm 짧아져 평행인데 라벨이 1 차이 나는 케이스 보정.
|
||||
// eaveHelpLine 은 이 지붕 소속(parentId)만, outerLine 은 좌표가 겹치는 것만 그룹핑되므로 cross-roof 오머지 없음.
|
||||
const eaveCandidates = (this.canvas?.getObjects() || []).filter(
|
||||
(o) =>
|
||||
(o.name === 'outerLine' || (o.name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE && o.parentId === this.id)) &&
|
||||
typeof o.x1 === 'number' &&
|
||||
typeof o.y1 === 'number',
|
||||
)
|
||||
equalizeParallelEaveLabels(eaveCandidates, this.canvas)
|
||||
|
||||
this.canvas?.renderAll()
|
||||
}
|
||||
|
||||
|
||||
@ -263,15 +263,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
||||
if (addedRoofs.length > 0) {
|
||||
const raftCodeList = findCommonCode('203800')
|
||||
setRaftCodes(raftCodeList)
|
||||
// [2026-06-02] addedRoofs[0]에서 받은 roofSizeSet을 그대로 사용 (basicSetting으로 덮어씌우지 않기)
|
||||
setCurrentRoof({
|
||||
...addedRoofs[0],
|
||||
planNo: currentCanvasPlan?.planNo || planNo,
|
||||
roofSizeSet: String(basicSetting.roofSizeSet),
|
||||
roofAngleSet: basicSetting.roofAngleSet,
|
||||
roofSizeSet: String(addedRoofs[0].roofSizeSet),
|
||||
roofAngleSet: addedRoofs[0].roofAngleSet,
|
||||
})
|
||||
// 입력모드도 함께 동기화
|
||||
setInputMode(addedRoofs[0].roofSizeSet)
|
||||
} else {
|
||||
/** 데이터 설정 확인 후 데이터가 없으면 기본 데이터 설정 */
|
||||
setCurrentRoof({ ...DEFAULT_ROOF_SETTINGS })
|
||||
setInputMode(DEFAULT_ROOF_SETTINGS.roofSizeSet)
|
||||
}
|
||||
}, [addedRoofs])
|
||||
|
||||
|
||||
@ -1082,39 +1082,49 @@ export default function StuffDetail() {
|
||||
return
|
||||
}
|
||||
|
||||
// T01 / 1차 user + 2차 ID: firstAgent 검증 후에만 적용 (실패 시 무반영)
|
||||
get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => {
|
||||
logger.debug('[PLANREQ-DEBUG] firstAgent result', { firstAgentId: res?.firstAgentId })
|
||||
if (!res?.firstAgentId) {
|
||||
swalFire({
|
||||
title: getMessage('stuff.detail.planReq.message.notMatch'),
|
||||
type: 'alert',
|
||||
icon: 'warning',
|
||||
})
|
||||
return
|
||||
}
|
||||
// [PLANREQ-FORCE-SELECT 2026-06-05] T01 / 1차 user + 2차 ID
|
||||
// 매핑 실패해도 모든 필드 적용 + 2차점을 옵션에 강제 추가 + selected
|
||||
// 1차점 정보(firstAgent) 가 있으면 1차점도 함께 반영
|
||||
// 'No data' 응답(4xx + message)도 매핑 실패와 동일하게 처리, 진짜 네트워크 에러만 알림
|
||||
const applyOtherSaleStore = () => {
|
||||
applyFields()
|
||||
setOtherSaleStoreList((prev) => {
|
||||
const exists = prev.some((o) => o.saleStoreId === info.saleStoreId)
|
||||
return exists ? prev : [...prev, { saleStoreId: info.saleStoreId, saleStoreName: info.saleStoreName }]
|
||||
})
|
||||
setOtherSelOptions(info.saleStoreId)
|
||||
form.setValue('otherSaleStoreId', info.saleStoreId)
|
||||
form.setValue('otherSaleStoreName', info.saleStoreName)
|
||||
form.setValue('otherSaleStoreLevel', info.saleStoreLevel)
|
||||
const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName }
|
||||
setSaleStoreList((prev) => {
|
||||
const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
|
||||
return exists ? prev : [...prev, firstAgent]
|
||||
})
|
||||
setShowSaleStoreList((prev) => {
|
||||
const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
|
||||
return exists ? prev : [...prev, firstAgent]
|
||||
})
|
||||
setSelOptions(res.firstAgentId)
|
||||
form.setValue('saleStoreId', res.firstAgentId)
|
||||
form.setValue('saleStoreName', res.firstAgentName)
|
||||
form.setValue('saleStoreLevel', '1')
|
||||
}).catch(() => {
|
||||
// 매핑 실패 — 아무것도 적용 안 함 + 사용자에게 알림
|
||||
}
|
||||
get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => {
|
||||
logger.debug('[PLANREQ-DEBUG] firstAgent result', { firstAgentId: res?.firstAgentId })
|
||||
applyOtherSaleStore()
|
||||
if (res?.firstAgentId) {
|
||||
const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName }
|
||||
setSaleStoreList((prev) => {
|
||||
const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
|
||||
return exists ? prev : [...prev, firstAgent]
|
||||
})
|
||||
setShowSaleStoreList((prev) => {
|
||||
const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
|
||||
return exists ? prev : [...prev, firstAgent]
|
||||
})
|
||||
setSelOptions(res.firstAgentId)
|
||||
form.setValue('saleStoreId', res.firstAgentId)
|
||||
form.setValue('saleStoreName', res.firstAgentName)
|
||||
form.setValue('saleStoreLevel', '1')
|
||||
}
|
||||
}).catch((error) => {
|
||||
// 'No data' 는 1차점 매핑 정보가 없을 뿐 — 2차점만 옵션 추가+select
|
||||
const message = error?.response?.data?.message
|
||||
if (message === 'No data') {
|
||||
applyOtherSaleStore()
|
||||
return
|
||||
}
|
||||
// 진짜 네트워크/서버 에러
|
||||
swalFire({
|
||||
title: getMessage('stuff.detail.planReq.message.notMatch'),
|
||||
title: getMessage('stuff.detail.planReq.message.networkError'),
|
||||
type: 'alert',
|
||||
icon: 'warning',
|
||||
})
|
||||
@ -1878,16 +1888,18 @@ export default function StuffDetail() {
|
||||
)) ||
|
||||
null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-origin grey"
|
||||
onClick={() => {
|
||||
onSearchDesignRequestPopOpen()
|
||||
}}
|
||||
style={{ display: showButton }}
|
||||
>
|
||||
{getMessage('stuff.planReqPopup.title')}
|
||||
</button>
|
||||
{session?.storeId === 'T01' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-origin grey"
|
||||
onClick={() => {
|
||||
onSearchDesignRequestPopOpen()
|
||||
}}
|
||||
style={{ display: showButton }}
|
||||
>
|
||||
{getMessage('stuff.planReqPopup.title')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -2468,7 +2480,7 @@ export default function StuffDetail() {
|
||||
></button>
|
||||
) : null}
|
||||
</div>
|
||||
{managementState?.tempFlg === '1' ? (
|
||||
{managementState?.tempFlg === '1' && session?.storeId === 'T01' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -18,6 +18,7 @@ import { reattachDebugLabels } from '@/components/fabric/QPolygon'
|
||||
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
|
||||
import { findInteriorPoint } from '@/util/skeleton-utils'
|
||||
import { logger } from '@/util/logger'
|
||||
import { debugCapture } from '@/util/debugCapture'
|
||||
import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
|
||||
|
||||
// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 시 出幅 변경분 surgical 반영 기능 토글.
|
||||
@ -357,8 +358,36 @@ export function useEavesGableEdit(id) {
|
||||
}
|
||||
}
|
||||
const labelOf = (line) => (line ? labelByLine.get(line) || '?' : null)
|
||||
const t1 = { x: target.x1, y: target.y1 }
|
||||
const t2 = { x: target.x2, y: target.y2 }
|
||||
// [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 선택된 target 은 이동 전 wallLine.
|
||||
// 이동된 실제 위치는 wall.baseLines 에 있다.
|
||||
// wallId 매칭은 이동 후 인덱스 재정렬로 잘못된 baseLine 반환 → 기하학적 매칭으로 교체.
|
||||
// 같은 방향(수직/수평) + target 의 고정 끝점(이동 안 된 쪽) 공유 여부로 식별.
|
||||
const _wall = canvas.getObjects().find((o) => o.name === POLYGON_TYPE.WALL && o.attributes?.roofId === target.attributes?.roofId)
|
||||
const _BL_TOL = 5
|
||||
const _tIsV = Math.abs(target.x1 - target.x2) < 0.5
|
||||
const _tIsH = Math.abs(target.y1 - target.y2) < 0.5
|
||||
const _matchedBase = _wall?.baseLines?.find((bl) => {
|
||||
if (_tIsV) {
|
||||
if (Math.abs(bl.x1 - bl.x2) >= 0.5) return false // bl 방향 불일치
|
||||
if (Math.abs(bl.x1 - target.x1) >= _BL_TOL) return false // 다른 수직선
|
||||
return (
|
||||
Math.abs(bl.y1 - target.y1) < _BL_TOL || Math.abs(bl.y1 - target.y2) < _BL_TOL ||
|
||||
Math.abs(bl.y2 - target.y1) < _BL_TOL || Math.abs(bl.y2 - target.y2) < _BL_TOL
|
||||
)
|
||||
}
|
||||
if (_tIsH) {
|
||||
if (Math.abs(bl.y1 - bl.y2) >= 0.5) return false // bl 방향 불일치
|
||||
if (Math.abs(bl.y1 - target.y1) >= _BL_TOL) return false // 다른 수평선
|
||||
return (
|
||||
Math.abs(bl.x1 - target.x1) < _BL_TOL || Math.abs(bl.x1 - target.x2) < _BL_TOL ||
|
||||
Math.abs(bl.x2 - target.x1) < _BL_TOL || Math.abs(bl.x2 - target.x2) < _BL_TOL
|
||||
)
|
||||
}
|
||||
// 대각선: wallId fallback
|
||||
return bl.attributes?.wallId === target.attributes?.wallId
|
||||
})
|
||||
const t1 = _matchedBase ? { x: _matchedBase.x1, y: _matchedBase.y1 } : { x: target.x1, y: target.y1 }
|
||||
const t2 = _matchedBase ? { x: _matchedBase.x2, y: _matchedBase.y2 } : { x: target.x2, y: target.y2 }
|
||||
const h1Match = findHipAtEndpoint(roof, t1)
|
||||
const h2Match = findHipAtEndpoint(roof, t2)
|
||||
logger.log(
|
||||
@ -459,6 +488,7 @@ export function useEavesGableEdit(id) {
|
||||
})
|
||||
}
|
||||
logger.log('[KERAB-VALLEY-DIAG] ' + JSON.stringify(valleyInfo))
|
||||
debugCapture.log('KERAB-VALLEY-DIAG', { targetId: target.id, count: valleyInfo.length, valleys: valleyInfo })
|
||||
}
|
||||
// [KERAB-VALLEY-EXT 2026-05-27] 모델 교체: skeleton valley → 외곽 polygon concave corner.
|
||||
// "확장" = roofLine 의 한쪽 끝점에서 self-extension (반대 끝점 → 그 끝점 방향) 으로 연장.
|
||||
@ -755,11 +785,13 @@ export function useEavesGableEdit(id) {
|
||||
return Math.abs((vax * vbx + vay * vby) / (ma * mb)) < PERP_EPS
|
||||
}
|
||||
const computePendingKLine = (apex) => {
|
||||
const ax = h2Match.near.x - h1Match.near.x
|
||||
const ay = h2Match.near.y - h1Match.near.y
|
||||
// [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 hip.near 가 target 끝점에서 벗어나므로
|
||||
// 이동한 처마라인 끝점(t1/t2) 기준으로 foot 계산
|
||||
const ax = t2.x - t1.x
|
||||
const ay = t2.y - t1.y
|
||||
const aSq = ax * ax + ay * ay || 1
|
||||
const tFoot = ((apex.x - h1Match.near.x) * ax + (apex.y - h1Match.near.y) * ay) / aSq
|
||||
const foot = { x: h1Match.near.x + tFoot * ax, y: h1Match.near.y + tFoot * ay }
|
||||
const tFoot = ((apex.x - t1.x) * ax + (apex.y - t1.y) * ay) / aSq
|
||||
const foot = { x: t1.x + tFoot * ax, y: t1.y + tFoot * ay }
|
||||
return { x1: apex.x, y1: apex.y, x2: foot.x, y2: foot.y }
|
||||
}
|
||||
const pushApexIfNew = (point, callerTag = '?') => {
|
||||
@ -1602,8 +1634,8 @@ export function useEavesGableEdit(id) {
|
||||
roof,
|
||||
target,
|
||||
markerApex,
|
||||
h1Match.near,
|
||||
h2Match.near,
|
||||
t1,
|
||||
t2,
|
||||
[h1Match.hip, h2Match.hip, ...pathHips],
|
||||
pathRidges,
|
||||
extLines,
|
||||
@ -1805,16 +1837,77 @@ export function useEavesGableEdit(id) {
|
||||
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (wallBase 측 ray).
|
||||
for (const il of roof.innerLines || []) {
|
||||
if (!il || il.visible === false) continue
|
||||
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
||||
if (il.lineName !== 'ridge') continue
|
||||
const ip = lineLineIntersection(
|
||||
wStart,
|
||||
wRayEnd,
|
||||
{ x: il.x1, y: il.y1 },
|
||||
{
|
||||
x: il.x2,
|
||||
y: il.y2,
|
||||
},
|
||||
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] trim 대상 = 원래 ridge/hip + 마루 확장(ExtRidge) + kLine(kerabPatternRidge).
|
||||
// stop 룰(룰 1) 은 ridge 만, trim 룰(룰 2) 은 hip 도 포함 — vExt 는 hip 통과 후 절삭.
|
||||
// 절삭 방향은 V apex 우선 룰 (아래) 로 결정.
|
||||
const isRidge =
|
||||
il.name === LINE_TYPE.SUBLINE.RIDGE &&
|
||||
(il.lineName === 'ridge' || il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge')
|
||||
const isHip =
|
||||
il.name === LINE_TYPE.SUBLINE.HIP &&
|
||||
(il.lineName === 'hip' || il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip')
|
||||
if (!isRidge && !isHip) continue
|
||||
const ip = lineLineIntersection(vStart, vEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
|
||||
if (!ip) continue
|
||||
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
|
||||
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
|
||||
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰.
|
||||
// V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점.
|
||||
// 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정.
|
||||
// 1) 한 끝만 V apex → 그 V apex 측 살림, 반대 끝 절삭.
|
||||
// 2) 양 끝 모두 V apex → vExt 와 먼 끝 살림 (가까운 끝 절삭).
|
||||
// 3) 둘 다 V apex 아님 → 기존 fallback (B 측 = wallExt 측 절삭).
|
||||
const isVApex = (px, py) => {
|
||||
let cnt = 0
|
||||
for (const other of roof.innerLines || []) {
|
||||
if (!other || other === il) continue
|
||||
if (other.visible === false) continue
|
||||
if (other.name !== LINE_TYPE.SUBLINE.HIP) continue
|
||||
if (other.lineName !== 'kerabPatternHip' && other.lineName !== 'kerabPatternExtHip') continue
|
||||
if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) cnt++
|
||||
else if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) cnt++
|
||||
if (cnt >= 2) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const e1V = isVApex(il.x1, il.y1)
|
||||
const e2V = isVApex(il.x2, il.y2)
|
||||
const e1B = isBSide(il.x1, il.y1)
|
||||
const e2B = isBSide(il.x2, il.y2)
|
||||
let trimEnd = 0
|
||||
if (e1V && !e2V) trimEnd = 2 // e1 V apex 살림, e2 절삭
|
||||
else if (!e1V && e2V) trimEnd = 1
|
||||
else if (e1V && e2V) {
|
||||
// 양 끝 모두 V apex → vExt 와 가까운 끝 절삭
|
||||
const d1 = Math.hypot(il.x1 - veMid.x, il.y1 - veMid.y)
|
||||
const d2 = Math.hypot(il.x2 - veMid.x, il.y2 - veMid.y)
|
||||
trimEnd = d1 < d2 ? 1 : 2
|
||||
} else {
|
||||
// 둘 다 V apex 아님 → 기존 B 측 fallback
|
||||
if (e1B && !e2B) trimEnd = 1
|
||||
else if (!e1B && e2B) trimEnd = 2
|
||||
else continue
|
||||
}
|
||||
const oldPt = trimEnd === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
|
||||
const originalAttrs = { ...(il.attributes || {}) }
|
||||
if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y })
|
||||
else il.set({ x2: ip.x, y2: ip.y })
|
||||
if (typeof il.setCoords === 'function') il.setCoords()
|
||||
const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
|
||||
il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz }
|
||||
// [KERAB-EXTRIDGE-CONNECT 2026-06-01] cascade hide 시 ExtRidge 끝점을 vExt 끝점(=ip)로 연장.
|
||||
// ExtRidge 와 vExt 끝점이 분리돼 split graph 의 sub-roof 외곽이 안 닫히는 문제 해소.
|
||||
trimCascadePts.push({ pt: oldPt, newPt: { x: ip.x, y: ip.y } })
|
||||
newTrimRecords.push({
|
||||
line: il,
|
||||
end: trimEnd,
|
||||
oldPt,
|
||||
newPt: { x: ip.x, y: ip.y },
|
||||
originalAttrs,
|
||||
})
|
||||
logger.log(
|
||||
'[KERAB-VALLEY-EXT-TRIM] ridge trim ' +
|
||||
JSON.stringify({ lineName: il.lineName, trimEnd, oldPt, ip: { x: Math.round(ip.x * 100) / 100, y: Math.round(ip.y * 100) / 100 } }),
|
||||
)
|
||||
if (!ip) continue
|
||||
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
|
||||
@ -1914,6 +2007,13 @@ export function useEavesGableEdit(id) {
|
||||
const isBSide = (px, py) => (px - veMid.x) * bDirX + (py - veMid.y) * bDirY > 0
|
||||
const trimCascadePts = []
|
||||
const newTrimRecords = []
|
||||
// cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide
|
||||
const cascadeHidden = new Set()
|
||||
let cascadeGuard = 0
|
||||
while (trimCascadePts.length && cascadeGuard++ < 200) {
|
||||
const entry = trimCascadePts.shift()
|
||||
if (!entry) continue
|
||||
const pt = entry.pt || entry
|
||||
for (const il of roof.innerLines || []) {
|
||||
if (!il || il.visible === false) continue
|
||||
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] trim 대상 = 원래 ridge/hip + 마루 확장(ExtRidge) + kLine(kerabPatternRidge).
|
||||
@ -2000,6 +2100,81 @@ export function useEavesGableEdit(id) {
|
||||
ip: { x: Math.round(ip.x * 100) / 100, y: Math.round(ip.y * 100) / 100 },
|
||||
}),
|
||||
)
|
||||
trimCascadePts.push({ pt: m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } })
|
||||
}
|
||||
}
|
||||
// [KERAB-VALLEY-EXT-SPLIT 2026-06-04] Option A: vExt(내부 roofLine)가 ridge/hip 와
|
||||
// *내부* 교점에서 만나면 그 교점에서 vExt 자체를 분할한다.
|
||||
// 이유: getSplitRoofsPoints(usePolygon.js)는 라인 *끝점*만 그래프 노드로 만든다
|
||||
// (교차분할 없음). 절삭된 ridge(예: RG-2)가 vExt 의 끝점이 아닌 중간(internal)에
|
||||
// 닿으면 공유 노드가 없어 dead-end → 지붕면 할당에서 고립 폐기되고 인접 면이 안
|
||||
// 쪼개진다(예: F-2 비대). vExt 를 교점에서 분할해 끝점=노드를 만들어 ridge 가
|
||||
// 할당 그래프에 연결되게 한다. 절삭 규칙/방향은 건드리지 않고 vExt 분할만 추가.
|
||||
// revert: 분할 세그먼트도 lineName='kerabPatternValleyExt' + __targetId 이므로
|
||||
// applyKerabRevertPattern 의 valleyExt 제거 스캔에서 자동 정리된다.
|
||||
{
|
||||
const SPLIT_TOL = 1.0
|
||||
const interiorPts = []
|
||||
for (const r of newTrimRecords) {
|
||||
if (!r || r.hide || !r.newPt) continue
|
||||
const ip = r.newPt
|
||||
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
|
||||
const dS = Math.hypot(ip.x - vStart.x, ip.y - vStart.y)
|
||||
const dE = Math.hypot(ip.x - vEnd.x, ip.y - vEnd.y)
|
||||
if (dS < SPLIT_TOL || dE < SPLIT_TOL) continue // 끝점은 이미 노드
|
||||
if (interiorPts.some((p) => Math.hypot(p.x - ip.x, p.y - ip.y) < SPLIT_TOL)) continue
|
||||
interiorPts.push({ x: ip.x, y: ip.y })
|
||||
}
|
||||
if (interiorPts.length) {
|
||||
const vdx2 = vEnd.x - vStart.x
|
||||
const vdy2 = vEnd.y - vStart.y
|
||||
const tOf = (p) => (p.x - vStart.x) * vdx2 + (p.y - vStart.y) * vdy2
|
||||
interiorPts.sort((a, b) => tOf(a) - tOf(b))
|
||||
const bps = [vStart, ...interiorPts, vEnd]
|
||||
// 첫 구간은 기존 vExt 재사용(끝점 단축), 나머지는 새 QLine 세그먼트.
|
||||
vExt.set({ x2: bps[1].x, y2: bps[1].y })
|
||||
if (typeof vExt.setCoords === 'function') vExt.setCoords()
|
||||
const sz0 = calcLinePlaneSize({ x1: vExt.x1, y1: vExt.y1, x2: vExt.x2, y2: vExt.y2 })
|
||||
vExt.attributes = { ...vExt.attributes, planeSize: sz0, actualSize: sz0 }
|
||||
const newSegs = []
|
||||
for (let bi = 1; bi < bps.length - 1; bi++) {
|
||||
const a = bps[bi]
|
||||
const b = bps[bi + 1]
|
||||
const segSz = calcLinePlaneSize({ x1: a.x, y1: a.y, x2: b.x, y2: b.y })
|
||||
const seg = new QLine([a.x, a.y, b.x, b.y], {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '#1083E3',
|
||||
strokeWidth: 3,
|
||||
name: LINE_TYPE.SUBLINE.VALLEY,
|
||||
textMode: roof.textMode,
|
||||
attributes: { ...(vExt.attributes || {}), planeSize: segSz, actualSize: segSz },
|
||||
})
|
||||
seg.lineName = 'kerabPatternValleyExt'
|
||||
seg.__targetId = target.id
|
||||
seg.__valleyExtSource = vExt.__valleyExtSource
|
||||
if (vExt.parentLine) seg.parentLine = vExt.parentLine
|
||||
if (vExt.direction) seg.direction = vExt.direction
|
||||
canvas.add(seg)
|
||||
seg.bringToFront()
|
||||
roof.innerLines.push(seg)
|
||||
newSegs.push(seg)
|
||||
}
|
||||
logger.log(
|
||||
'[KERAB-VALLEY-EXT-SPLIT] vExt split ' +
|
||||
JSON.stringify({
|
||||
source: vr.source,
|
||||
breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })),
|
||||
segs: newSegs.length + 1,
|
||||
}),
|
||||
)
|
||||
debugCapture.log('KERAB-VALLEY-EXT-SPLIT', {
|
||||
source: vr.source,
|
||||
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
|
||||
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
|
||||
breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })),
|
||||
segCount: newSegs.length + 1,
|
||||
})
|
||||
}
|
||||
// cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide
|
||||
const cascadeHidden = new Set()
|
||||
@ -2038,12 +2213,35 @@ export function useEavesGableEdit(id) {
|
||||
if (newTrimRecords.length) {
|
||||
target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords)
|
||||
}
|
||||
} else {
|
||||
logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source)
|
||||
}
|
||||
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적.
|
||||
// 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리.
|
||||
// 한 mousedown 에 여러 vExt 가 호출될 수 있어 concat.
|
||||
if (newTrimRecords.length) {
|
||||
target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords)
|
||||
}
|
||||
debugCapture.log('KERAB-VALLEY-EXT-TRIM', {
|
||||
source: vr.source,
|
||||
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
|
||||
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
|
||||
count: newTrimRecords.length,
|
||||
trims: newTrimRecords.map((r) => ({
|
||||
lineName: r.line?.lineName,
|
||||
name: r.line?.name,
|
||||
label: typeof labelOf === 'function' ? labelOf(r.line) : undefined,
|
||||
hide: r.hide ?? false,
|
||||
end: r.end,
|
||||
oldPt: r.oldPt ? { x: Math.round(r.oldPt.x * 100) / 100, y: Math.round(r.oldPt.y * 100) / 100 } : null,
|
||||
newPt: r.newPt ? { x: Math.round(r.newPt.x * 100) / 100, y: Math.round(r.newPt.y * 100) / 100 } : null,
|
||||
now: r.line ? { x1: Math.round(r.line.x1 * 100) / 100, y1: Math.round(r.line.y1 * 100) / 100, x2: Math.round(r.line.x2 * 100) / 100, y2: Math.round(r.line.y2 * 100) / 100 } : null,
|
||||
})),
|
||||
})
|
||||
} else {
|
||||
logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2-a: valleyExt 경로상 교차 hip/ridge 절삭 ──
|
||||
// trim 대상 = ridge + 케라바 토글 생성 hip(kerabPatternHip/ExtHip). 원래 지붕 hip 보존.
|
||||
@ -2229,8 +2427,16 @@ export function useEavesGableEdit(id) {
|
||||
}
|
||||
// condition 1: 자연 만남 — ext 없이 hip 제거 + kLine
|
||||
target.set({ attributes })
|
||||
|
||||
applyKerabKLinePattern(roof, target, apex, h1Match.near, h2Match.near, [h1Match.hip, h2Match.hip], null, null)
|
||||
applyKerabKLinePattern(
|
||||
roof,
|
||||
target,
|
||||
apex,
|
||||
t1,
|
||||
t2,
|
||||
[h1Match.hip, h2Match.hip],
|
||||
null,
|
||||
null,
|
||||
)
|
||||
dumpInnerLineSnapshot('AFTER')
|
||||
|
||||
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
selectedRoofMaterialSelector,
|
||||
} from '@/store/settingAtom'
|
||||
import { usePopup } from '@/hooks/usePopup'
|
||||
import { POLYGON_TYPE, LINE_TYPE } from '@/common/common'
|
||||
import { POLYGON_TYPE } from '@/common/common'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import ActualSizeSetting from '@/components/floor-plan/modal/roofAllocation/ActualSizeSetting'
|
||||
import { useMessage } from '@/hooks/useMessage'
|
||||
@ -33,6 +33,7 @@ import { useText } from '@/hooks/useText'
|
||||
import { fabric } from 'fabric'
|
||||
import { QLine } from '@/components/fabric/QLine'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { reattachRoofFaceDebugLabels } from '@/components/fabric/QPolygon'
|
||||
import { calcLineActualSize2 } from '@/util/qpolygon-utils'
|
||||
// [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert
|
||||
import { isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS, notifyLowPitchRestrictionForRoofs } from '@/util/roof-pitch-warning'
|
||||
@ -680,10 +681,15 @@ export function useRoofAllocationSetting(id) {
|
||||
|
||||
const eq = (p, q) => Math.abs(p.x - q.x) < 0.5 && Math.abs(p.y - q.y) < 0.5
|
||||
|
||||
// [KERAB-VALLEY-OVERLAP-BAND-ID 2026-06-04] 밴드 sub-roof 식별.
|
||||
// split 후 sub-roof 의 V1(vExt) edge 는 lineName 을 잃어 'L' 로 나오므로(BAND-PROBE 확인)
|
||||
// vExt 기반 식별은 불가. 대신 밴드만 overlap-typed(kerabValleyOverlapLine) 변을 보유한다는
|
||||
// 사실(인접 실제 면은 split 후 ov:0)을 이용 — overlap 변 2개 이상이면 밴드로 인정.
|
||||
// 밴드는 항상 V2/V3/V4 3변이 overlap → ov>=3 이지만, 안전 여유로 2 로 둔다.
|
||||
const rects = newSubRoofs.filter((sub) => {
|
||||
if (!sub.lines || sub.lines.length < 3) return false
|
||||
const cnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length
|
||||
return cnt >= Math.max(3, sub.lines.length - 1)
|
||||
const ovCnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length
|
||||
return ovCnt >= 2
|
||||
})
|
||||
|
||||
if (rects.length === 0) return
|
||||
@ -738,11 +744,66 @@ export function useRoofAllocationSetting(id) {
|
||||
`shares=[${[...adjacencyMap.values()].map((s) => s.length).join(',')}]`,
|
||||
)
|
||||
|
||||
// [KERAB-VALLEY-OVERLAP-FOLD-DIAG 2026-06-04] fold 직전 — 띠(X) + 인접 면 전체 좌표 덤프 (대각선 원인 추적)
|
||||
{
|
||||
const rnd = (p) => ({ x: Math.round(p.x), y: Math.round(p.y) })
|
||||
const adjDump = []
|
||||
adjacencyMap.forEach((shares, sub) => {
|
||||
adjDump.push({
|
||||
subId: String(sub.id).slice(0, 8),
|
||||
shares: shares.length,
|
||||
shareEdges: shares.map((s) => ({ subEdgeStart: s.subEdgeStart, xEdgeIdx: s.xEdgeIdx })),
|
||||
pts: (sub.points || []).map(rnd),
|
||||
})
|
||||
})
|
||||
debugCapture.log('KERAB-VALLEY-OVERLAP-FOLD-DIAG', {
|
||||
XId: String(X.id).slice(0, 8),
|
||||
Xpts: Xpts.map(rnd),
|
||||
XlineTypes: Xlines.map((l) => (l?.attributes?.type === 'kerabValleyOverlapLine' ? 'OV' : l?.lineName || 'L')),
|
||||
adjacents: adjDump,
|
||||
})
|
||||
}
|
||||
|
||||
if (adjacencyMap.size === 0) {
|
||||
canvas.remove(X)
|
||||
return
|
||||
}
|
||||
|
||||
// [KERAB-VALLEY-OVERLAP-FOLD 2026-06-04] 밴드를 "변 연장" 으로 한 면에 흡수 (대각선 제거).
|
||||
// 할당 = 이어진 라인을 따라 면을 만드는 것. 밴드의 V1(내부 변)을 전부 가진 인접 면이
|
||||
// V1 을 밴드 OV 외곽경로(V3→V2→V4, x 를 출폭만큼 바깥)로 연장해 밴드를 흡수한다.
|
||||
// 부분만 닿은 면(이전 detour 우회 대상)은 흡수 안 함 — 그 우회가 대각선의 근본원인이었다.
|
||||
const N = Xpts.length
|
||||
const isOVedge = (i) => Xlines[i]?.attributes?.type === 'kerabValleyOverlapLine'
|
||||
|
||||
// 밴드의 V1(비 OV) 변 run — 밴드는 V1 이 한 덩어리이므로 연속 가정, 아니면 보존(흡수 skip).
|
||||
let lStart = -1
|
||||
let lLen = 0
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (!isOVedge(i)) {
|
||||
if (lStart === -1) lStart = i
|
||||
lLen++
|
||||
}
|
||||
}
|
||||
let lContig = lStart !== -1 && lLen < N
|
||||
for (let k = 0; k < lLen && lContig; k++) {
|
||||
if (isOVedge((lStart + k) % N)) lContig = false
|
||||
}
|
||||
if (!lContig) {
|
||||
logger.log(`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 run 비연속 (lStart=${lStart} lLen=${lLen})`)
|
||||
return
|
||||
}
|
||||
const vStartVtx = Xpts[lStart]
|
||||
const vFinVtx = Xpts[(lStart + lLen) % N]
|
||||
// OV 외곽 중간점들 — vFin 에서 vStart 로 도는 순서
|
||||
const ovFromFin = []
|
||||
for (let k = 1; k <= N - (lLen + 1); k++) {
|
||||
ovFromFin.push(Xpts[(lStart + lLen + k) % N])
|
||||
}
|
||||
|
||||
// 흡수 면 = V1 을 가장 많이(전부) 공유하는 인접 면
|
||||
let absorber = null
|
||||
let absorberShares = null
|
||||
adjacencyMap.forEach((shares, sub) => {
|
||||
// 다중 공유 → bowtie 위험, 머지 skip
|
||||
if (shares.length !== 1) {
|
||||
@ -892,7 +953,121 @@ export function useRoofAllocationSetting(id) {
|
||||
`oldSigned=${oldSigned.toFixed(0)} → newSigned=${signedArea(finalPts).toFixed(0)} ` +
|
||||
`pts=${finalPts.length} lines=${newSubLines.length}`,
|
||||
)
|
||||
if (!absorber || shares.length > absorberShares.length) {
|
||||
absorber = sub
|
||||
absorberShares = shares
|
||||
}
|
||||
})
|
||||
if (!absorber || absorberShares.length < lLen) {
|
||||
logger.log(
|
||||
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 전체를 가진 흡수 면 없음 ` + `(maxShares=${absorberShares?.length ?? 0}, lLen=${lLen})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const aPts = (absorber.points || []).map((p) => ({ x: p.x, y: p.y }))
|
||||
const aLines = [...(absorber.lines || [])]
|
||||
const aN = aPts.length
|
||||
const absShared = absorberShares.map((s) => s.subEdgeStart).sort((a, b) => a - b)
|
||||
let aContig = true
|
||||
for (let i = 1; i < absShared.length; i++) {
|
||||
if (absShared[i] !== absShared[i - 1] + 1) aContig = false
|
||||
}
|
||||
if (!aContig || absShared[absShared.length - 1] + 1 > aN - 1) {
|
||||
logger.log(`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 흡수 면 공유 변 비연속/wrap [${absShared.join(',')}]`)
|
||||
return
|
||||
}
|
||||
const runStart = absShared[0]
|
||||
const runEnd = absShared[absShared.length - 1] + 1
|
||||
// 삽입 방향: 흡수 면 run 시작점이 vStart 면 ovFromFin 역순, vFin 이면 그대로
|
||||
const insert = eq(aPts[runStart], vStartVtx) ? [...ovFromFin].reverse() : [...ovFromFin]
|
||||
|
||||
const newPts = []
|
||||
for (let i = 0; i <= runStart; i++) newPts.push(aPts[i])
|
||||
for (const p of insert) newPts.push(p)
|
||||
for (let i = runEnd; i < aN; i++) newPts.push(aPts[i])
|
||||
|
||||
// 면적 부호 보존 + 확장(절댓값 증가) 검증 — 실패 시 밴드 보존(흡수 skip)
|
||||
const oldSigned = signedArea(aPts)
|
||||
const newSigned = signedArea(newPts)
|
||||
if ((oldSigned !== 0 && Math.sign(newSigned) !== Math.sign(oldSigned)) || Math.abs(newSigned) <= Math.abs(oldSigned) + 0.5) {
|
||||
logger.log(
|
||||
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 확장 검증 실패 ` + `oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 새 라인 재구성 — 각 변을 기존 흡수 면 라인 또는 밴드 OV 라인에서 매핑, 없으면 생성
|
||||
const findLine = (p1, p2) => {
|
||||
for (const l of aLines) {
|
||||
if (!l) continue
|
||||
if ((eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) || (eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1)))
|
||||
return { src: l, reuse: true }
|
||||
}
|
||||
for (const l of Xlines) {
|
||||
if (!l) continue
|
||||
if ((eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) || (eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1)))
|
||||
return { src: l, reuse: false }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const newLines = []
|
||||
for (let i = 0; i < newPts.length; i++) {
|
||||
const p1 = newPts[i]
|
||||
const p2 = newPts[(i + 1) % newPts.length]
|
||||
const found = findLine(p1, p2)
|
||||
if (found && found.reuse) {
|
||||
found.src.idx = newLines.length + 1
|
||||
newLines.push(found.src)
|
||||
} else {
|
||||
const attrs = found?.src?.attributes
|
||||
? { ...found.src.attributes }
|
||||
: {
|
||||
type: 'kerabValleyOverlapLine',
|
||||
offset: 0,
|
||||
}
|
||||
const ln = new QLine([p1.x, p1.y, p2.x, p2.y], {
|
||||
stroke: absorber.stroke,
|
||||
strokeWidth: absorber.strokeWidth,
|
||||
fontSize: absorber.fontSize,
|
||||
attributes: attrs,
|
||||
textVisible: false,
|
||||
parent: absorber,
|
||||
parentId: absorber.id,
|
||||
idx: newLines.length + 1,
|
||||
})
|
||||
ln.startPoint = p1
|
||||
ln.endPoint = p2
|
||||
newLines.push(ln)
|
||||
}
|
||||
}
|
||||
|
||||
// [KERAB-VALLEY-OVERLAP-FOLD 2026-06-04] fabric.Polygon points 갱신 (canvas-util#anchorWrapper 패턴).
|
||||
// _setPositionDimensions 가 bbox 재계산하므로 변경 전 앵커(pts[0]) 절대좌표 캡쳐 후 복원.
|
||||
const anchorIdx = 0
|
||||
const oldLocal = {
|
||||
x: absorber.points[anchorIdx].x - absorber.pathOffset.x,
|
||||
y: absorber.points[anchorIdx].y - absorber.pathOffset.y,
|
||||
}
|
||||
const absolutePoint = fabric.util.transformPoint(oldLocal, absorber.calcTransformMatrix())
|
||||
|
||||
absorber.points = newPts
|
||||
absorber.lines = newLines
|
||||
absorber._setPositionDimensions({})
|
||||
|
||||
const strokeW = absorber.strokeUniform ? absorber.strokeWidth / absorber.scaleX : absorber.strokeWidth
|
||||
const baseW = absorber.width + strokeW
|
||||
const baseH = absorber.height + (absorber.strokeUniform ? absorber.strokeWidth / absorber.scaleY : absorber.strokeWidth)
|
||||
const newX = (absorber.points[anchorIdx].x - absorber.pathOffset.x) / Math.max(baseW, 1e-9)
|
||||
const newY = (absorber.points[anchorIdx].y - absorber.pathOffset.y) / Math.max(baseH, 1e-9)
|
||||
absorber.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
|
||||
absorber.setCoords?.()
|
||||
absorber.dirty = true
|
||||
|
||||
logger.log(
|
||||
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} → absorber=${absorber.id} 흡수 완료 ` +
|
||||
`pts=${newPts.length} lines=${newLines.length} oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
|
||||
)
|
||||
|
||||
canvas.remove(X)
|
||||
})
|
||||
@ -1069,10 +1244,52 @@ export function useRoofAllocationSetting(id) {
|
||||
// case A(저장 X→케라바→할당) 에서 canvas 의 kerabValleyOverlapLine 가 silent 사라지는 문제 회피.
|
||||
// vExt(kerabPatternValleyExt) 는 roof.innerLines 에 안정적으로 보존 — 거기서 직접 재계산.
|
||||
// 조건: vExt 가 polygon 내부에 들어온 케이스 한정 (innerLines 에 있으면 자동 충족).
|
||||
const vExtsForOverlap = (roofBase.innerLines || []).filter(
|
||||
(l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false,
|
||||
)
|
||||
for (const vExt of vExtsForOverlap) {
|
||||
const vExtsForOverlap = (roofBase.innerLines || []).filter((l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false)
|
||||
// [KERAB-VALLEY-OVERLAP-DEDUP 2026-06-04] buildOverlapLine(useEavesGableEdit)가 케라바 토글 시
|
||||
// 생성한 밴드와 이 RECALC 가 이중 생성 → split 가 중복 면 → 머지 모호 → 대각선.
|
||||
// 단일 소스로 통일: 재계산 전 기존 kerabValleyOverlapLine 을 roofBase/canvas 에서 제거.
|
||||
// vExt 가 있는 roof 만 (= RECALC 가 다시 생성할 roof) 대상 — 아니면 기존 밴드 유실.
|
||||
if (vExtsForOverlap.length > 0) {
|
||||
const isBandLine = (l) => l && (l.lineName === 'kerabValleyOverlapLine' || l.attributes?.type === 'kerabValleyOverlapLine')
|
||||
roofBase.lines = (roofBase.lines || []).filter((l) => !isBandLine(l))
|
||||
roofBase.innerLines = (roofBase.innerLines || []).filter((l) => !isBandLine(l))
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((o) => isBandLine(o) && (o.roofId === roofBase.id || o.parentId === roofBase.id || o.attributes?.roofId === roofBase.id))
|
||||
.forEach((o) => canvas.remove(o))
|
||||
}
|
||||
// [KERAB-VALLEY-EXT-SPLIT 2026-06-04] vExt 가 split 되면 같은 V1 이 여러 collinear 세그먼트로 쪼개진다.
|
||||
// overlap 밴드는 V1(벽) 당 한 번만 그려야 한다 — 세그먼트마다 make() 하면 분할점에서 중복 변이 2개 생긴다.
|
||||
// 같은 wall(__targetId/wallLine/source)로 묶고, 세그먼트 양 끝의 최외곽 두 점을 vStart/vEnd 로 써서 한 번만 재계산.
|
||||
const overlapGroups = new Map()
|
||||
for (const seg of vExtsForOverlap) {
|
||||
const gkey = seg.__targetId || seg.attributes?.wallLine || seg.__valleyExtSource || seg.parentLine?.id || seg.id
|
||||
if (!overlapGroups.has(gkey)) overlapGroups.set(gkey, [])
|
||||
overlapGroups.get(gkey).push(seg)
|
||||
}
|
||||
for (const segs of overlapGroups.values()) {
|
||||
// 대표 세그먼트 — 속성/wallLine 해석용
|
||||
const vExt = segs[0]
|
||||
// 그룹 내 모든 끝점 중 가장 먼 두 점 = 합쳐진 V1 전체 span
|
||||
let combStart = { x: vExt.x1, y: vExt.y1 }
|
||||
let combEnd = { x: vExt.x2, y: vExt.y2 }
|
||||
if (segs.length > 1) {
|
||||
const pts = []
|
||||
for (const s of segs) {
|
||||
pts.push({ x: s.x1, y: s.y1 }, { x: s.x2, y: s.y2 })
|
||||
}
|
||||
let maxD = -1
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
for (let j = i + 1; j < pts.length; j++) {
|
||||
const d = Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y)
|
||||
if (d > maxD) {
|
||||
maxD = d
|
||||
combStart = pts[i]
|
||||
combEnd = pts[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] wallLineId 보장 fallback 체인.
|
||||
// 1) vExt.attributes.wallLine (정상 케이스)
|
||||
// 2) vExt.__targetId (useEavesGableEdit.js drawValleyExtensions 에서 백업)
|
||||
@ -1083,7 +1300,7 @@ export function useRoofAllocationSetting(id) {
|
||||
if (!wallLineId && vExt.parentLine) wallLineId = vExt.parentLine.attributes?.wallLine
|
||||
if (!wallLineId) {
|
||||
// vStart 가 끝점인 roof.lines 변에서 wallLine 추출
|
||||
const vStartP = { x: vExt.x1, y: vExt.y1 }
|
||||
const vStartP = { x: combStart.x, y: combStart.y }
|
||||
for (const rl of roofBase.lines || []) {
|
||||
if (!rl?.attributes?.wallLine) continue
|
||||
const m1 = Math.hypot(rl.x1 - vStartP.x, rl.y1 - vStartP.y) < 1
|
||||
@ -1107,8 +1324,8 @@ export function useRoofAllocationSetting(id) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const vStart = { x: vExt.x1, y: vExt.y1 }
|
||||
const vEnd = { x: vExt.x2, y: vExt.y2 }
|
||||
const vStart = { x: combStart.x, y: combStart.y }
|
||||
const vEnd = { x: combEnd.x, y: combEnd.y }
|
||||
const dxT = target.x2 - target.x1
|
||||
const dyT = target.y2 - target.y1
|
||||
const lenTSq = dxT * dxT + dyT * dyT
|
||||
@ -1152,11 +1369,14 @@ export function useRoofAllocationSetting(id) {
|
||||
return p
|
||||
}
|
||||
const vStartSnap = snapToCorner(vStart)
|
||||
const wallLn = make(newWStart, wEndProj)
|
||||
const bsLn = make(vStartSnap, newWStart)
|
||||
const beLn = make(vEnd, wEndProj)
|
||||
roofBase.lines.push(wallLn, bsLn, beLn)
|
||||
roofBase.innerLines.push(wallLn, bsLn, beLn)
|
||||
// [KERAB-VALLEY-OVERLAP-SINGLE-BAND 2026-06-04] 밴드(V1·V2·V3·V4 사각형)는 새 지붕면이 아니라
|
||||
// "겹침" 영역 — 단일 면으로 두고 mergeValleyOverlapSubRoofs 가 인접 면(F3)에 접어 넣는다.
|
||||
// 따라서 벽/connector 를 분할하지 않고 quad 4변만 만든다.
|
||||
// V1(vExt)은 이미 innerLines 에 존재 → 나머지 3변(start connector, wall, end connector)만 추가.
|
||||
// V1 의 RG-2 교차 노드(Option A split)는 RG-2 를 그래프에 연결하되 밴드는 한 면으로 유지.
|
||||
const made = [make(vStartSnap, newWStart), make(newWStart, wEndProj), make(wEndProj, vEnd)]
|
||||
roofBase.lines.push(...made)
|
||||
roofBase.innerLines.push(...made)
|
||||
}
|
||||
|
||||
if (roofBase.separatePolygon.length > 0) {
|
||||
@ -1228,6 +1448,9 @@ export function useRoofAllocationSetting(id) {
|
||||
drawDirectionArrow(roof)
|
||||
})
|
||||
|
||||
// [ROOF-FACE-DIAG 2026-06-04] 할당된 지붕면별 면적/좌표 디버그 라벨+로그 (로컬 전용).
|
||||
reattachRoofFaceDebugLabels(canvas)
|
||||
|
||||
setRoofMaterials(newRoofList)
|
||||
setRoofsStore(newRoofList)
|
||||
/** 외곽선 삭제 */
|
||||
|
||||
@ -10,6 +10,7 @@ import { QLine } from '@/components/fabric/QLine'
|
||||
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||
import { useLine } from '@/hooks/useLine'
|
||||
import { logger } from '@/util/logger'
|
||||
import { debugCapture } from '@/util/debugCapture'
|
||||
|
||||
export const usePolygon = () => {
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
@ -763,6 +764,23 @@ export const usePolygon = () => {
|
||||
`(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`,
|
||||
)
|
||||
})
|
||||
const __splitLineRec = (l) => ({
|
||||
lineName: l.lineName ?? 'none',
|
||||
name: l.name,
|
||||
type: l.attributes?.type ?? 'none',
|
||||
isStart: l.attributes?.isStart ?? false,
|
||||
x1: Math.round(l.x1 * 100) / 100,
|
||||
y1: Math.round(l.y1 * 100) / 100,
|
||||
x2: Math.round(l.x2 * 100) / 100,
|
||||
y2: Math.round(l.y2 * 100) / 100,
|
||||
})
|
||||
debugCapture.log('SPLIT-ALLOC-DIAG', {
|
||||
polygonId: polygon.id?.slice(0, 8),
|
||||
visibleInner: innerLines.length,
|
||||
polygonLines: polygon.lines?.length ?? 0,
|
||||
inner: innerLines.map(__splitLineRec),
|
||||
outer: (polygon.lines || []).map(__splitLineRec),
|
||||
})
|
||||
|
||||
/*// innerLine이 세팅이 안되어있는경우 찾아서 세팅한다.
|
||||
if (!innerLines || innerLines.length === 0) {
|
||||
|
||||
@ -30,7 +30,7 @@ export const useSwal = () => {
|
||||
if (type === 'alert') {
|
||||
MySwal.fire({
|
||||
title,
|
||||
text,
|
||||
...(html ? { html } : { text }),
|
||||
icon: icon === '' ? 'success' : icon,
|
||||
confirmButtonText: getMessage('common.ok'),
|
||||
}).then(() => {
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
"modal.roof.shape.setting.patten.a": "Aパターン",
|
||||
"modal.roof.shape.setting.patten.b": "Bパターン",
|
||||
"modal.roof.shape.setting.side": "個別に設定",
|
||||
"modal.roof.material.low.pitch.warning": "2寸以上2.5寸未満の瓦を使用する場合、適用可能な瓦製品および構造物に制限があります。必ず施工マニュアルをご確認ください。",
|
||||
"modal.roof.material.low.pitch.warning": "2寸以上2.5寸未満の瓦を使用する場合、適用可能な瓦製品および架台に制限があります。\n必ず施工マニュアルをご確認ください。",
|
||||
"plan.menu.roof.cover": "伏せ図入力",
|
||||
"plan.menu.roof.cover.outline.drawing": "外壁線を描く",
|
||||
"plan.menu.roof.cover.roof.shape.setting": "屋根形状の設定",
|
||||
@ -810,6 +810,7 @@
|
||||
"stuff.detail.tempSave.message3": "二次販売店を選択してください。",
|
||||
"stuff.detail.confirm.message1": "販売店情報を変更すると、設計依頼文書番号が削除されます。変更しますか?",
|
||||
"stuff.detail.planReq.message.notMatch": "設計依頼の販売店情報が一致しないため、インポートできません。",
|
||||
"stuff.detail.planReq.message.networkError": "設計依頼情報の照会中にネットワークエラーが発生しました。しばらくしてから再度お試しください。",
|
||||
"stuff.detail.delete.message1": "仕様が確定したものは削除できません。",
|
||||
"stuff.detail.planList.title": "プランリスト",
|
||||
"stuff.detail.planList.cnt": "全体",
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
"modal.roof.shape.setting.patten.a": "A 패턴",
|
||||
"modal.roof.shape.setting.patten.b": "B 패턴",
|
||||
"modal.roof.shape.setting.side": "변별로 설정",
|
||||
"modal.roof.material.low.pitch.warning": "2寸 이상 2.5寸 미만의 기와를 사용하는 경우, 적용 가능한 기와 제품 및 구조물에 제한이 있습니다. 반드시 시공 매뉴얼을 확인해 주세요.",
|
||||
"modal.roof.material.low.pitch.warning": "2寸 이상 2.5寸 미만의 기와를 사용하는 경우, 적용 가능한 기와 제품 및 구조물 에 제한이 있습니다. \n반드시 시공 매뉴얼을 확인해 주시기 바랍니다.",
|
||||
"plan.menu.roof.cover": "지붕덮개",
|
||||
"plan.menu.roof.cover.outline.drawing": "외벽선 그리기",
|
||||
"plan.menu.roof.cover.roof.shape.setting": "지붕형상 설정",
|
||||
@ -810,6 +810,7 @@
|
||||
"stuff.detail.tempSave.message3": "2차 판매점을 선택해주세요.",
|
||||
"stuff.detail.confirm.message1": "판매점 정보를 변경하면 설계의뢰 문서번호가 삭제됩니다. 변경하시겠습니까?",
|
||||
"stuff.detail.planReq.message.notMatch": "설계의뢰의 판매점 정보가 일치하지 않아 가져올 수 없습니다.",
|
||||
"stuff.detail.planReq.message.networkError": "설계의뢰 정보 조회 중 네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||
"stuff.detail.delete.message1": "사양이 확정된 물건은 삭제할 수 없습니다.",
|
||||
"stuff.detail.planList.title": "플랜목록",
|
||||
"stuff.detail.planList.cnt": "전체",
|
||||
|
||||
@ -129,7 +129,9 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
|
||||
(il.lineName === 'kerabPatternRidge' ||
|
||||
il.lineName === 'kerabPatternExtRidge' ||
|
||||
il.lineName === 'kerabPatternHip' ||
|
||||
il.lineName === 'kerabPatternExtHip')
|
||||
il.lineName === 'kerabPatternExtHip' ||
|
||||
il.lineName === 'kerabPatternValleyExt' ||
|
||||
il.lineName === 'kerabValleyOverlapLine')
|
||||
const oldSegDx = oldCorner2.x - oldCorner1.x
|
||||
const oldSegDy = oldCorner2.y - oldCorner1.y
|
||||
const oldSegLen2 = oldSegDx * oldSegDx + oldSegDy * oldSegDy || 1
|
||||
@ -150,18 +152,102 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
|
||||
if (!il) continue
|
||||
|
||||
// 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점.
|
||||
// 절삭/복원 흐름 skip.
|
||||
// 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 →
|
||||
// 직각/형상 보존. 절삭/복원 흐름 skip.
|
||||
if (isKerabPatternLine(il)) {
|
||||
const oldX1 = il.x1
|
||||
const oldY1 = il.y1
|
||||
const oldX2 = il.x2
|
||||
const oldY2 = il.y2
|
||||
const np1 = mapToNewSeg({ x: il.x1, y: il.y1 })
|
||||
if (np1) il.set({ x1: np1.x, y1: np1.y })
|
||||
const np2 = mapToNewSeg({ x: il.x2, y: il.y2 })
|
||||
if (np2) il.set({ x2: np2.x, y2: np2.y })
|
||||
if (np1 && np2) {
|
||||
il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y })
|
||||
} else if (np1 && !np2) {
|
||||
const dx = np1.x - il.x1
|
||||
const dy = np1.y - il.y1
|
||||
il.set({ x1: np1.x, y1: np1.y, x2: il.x2 + dx, y2: il.y2 + dy })
|
||||
} else if (!np1 && np2) {
|
||||
const dx = np2.x - il.x2
|
||||
const dy = np2.y - il.y2
|
||||
il.set({ x1: il.x1 + dx, y1: il.y1 + dy, x2: np2.x, y2: np2.y })
|
||||
}
|
||||
if (typeof il.setCoords === 'function') il.setCoords()
|
||||
if (np1 || np2) {
|
||||
logger.log(
|
||||
'[KERAB-PATTERN-CORNER-SNAP] mapped ' +
|
||||
JSON.stringify({ lineName: il.lineName, np1, np2, newPts: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } }),
|
||||
)
|
||||
// [KERAB-PATTERN-CASCADE 2026-06-01] vExt 등 평행 이동 시 옛 끝점에 닿아있던
|
||||
// 다른 innerLine 끝점도 같은 변위로 평행 이동. RG-1 의 valley-trim 결과 끝점이
|
||||
// vExt 끝점과 분리되어 split graph 의 closed path 안 만들어지는 문제 해소.
|
||||
// cascade: vExt 옛 segment 위에 끝점이 있는 다른 innerLine 도 같은 변위로 평행 이동.
|
||||
// 끝점-끝점 일치 외에 segment 위 중간 점(예: kLine 끝점이 vExt segment 위)도 매칭.
|
||||
// 케라바 패턴 라인 가드 제거 — 자체 매핑된 라인은 이미 새 좌표라 옛 segment 위 X → 자동 skip.
|
||||
const dxVExt = il.x1 - oldX1
|
||||
const dyVExt = il.y1 - oldY1
|
||||
const pointOnOldSeg = (px, py) => {
|
||||
const sdx = oldX2 - oldX1
|
||||
const sdy = oldY2 - oldY1
|
||||
const slen2 = sdx * sdx + sdy * sdy
|
||||
if (slen2 < 1e-6) return Math.hypot(px - oldX1, py - oldY1) < 1.0
|
||||
const t = ((px - oldX1) * sdx + (py - oldY1) * sdy) / slen2
|
||||
if (t < -0.02 || t > 1.02) return false
|
||||
const projX = oldX1 + t * sdx
|
||||
const projY = oldY1 + t * sdy
|
||||
return Math.hypot(px - projX, py - projY) < 1.0
|
||||
}
|
||||
if (Math.hypot(dxVExt, dyVExt) > 0.01) {
|
||||
// cascade 대상: roof.innerLines + canvas 의 kerabValleyOverlapLine (innerLines 미포함).
|
||||
// overlap 보조선들은 vExt 의 끝점과 90도로 만나므로 vExt 이동에 같이 따라가야 정합.
|
||||
const overlapInCanvas = (canvas.getObjects() || []).filter(
|
||||
(o) =>
|
||||
o &&
|
||||
o.lineName === 'kerabValleyOverlapLine' &&
|
||||
(o.roofId === roof.id || o.attributes?.roofId === roof.id),
|
||||
)
|
||||
const cascadeTargets = [...(roof.innerLines || []), ...overlapInCanvas]
|
||||
for (const other of cascadeTargets) {
|
||||
if (!other || other === il) continue
|
||||
let moved = false
|
||||
const hit1 = pointOnOldSeg(other.x1, other.y1)
|
||||
const hit2 = pointOnOldSeg(other.x2, other.y2)
|
||||
// [KERAB-VALLEY-EXT-PARALLEL 2026-06-05] vExt(골짜기 확장라인)는 self-extension
|
||||
// 수직/수평 라인이라 한 끝만 cascade 로 끌면 대각선이 된다 (사용자 룰: 대각선은 hip뿐).
|
||||
// split 된 vExt 세그먼트는 한 끝만 옛 segment 에 닿아도 양 끝을 같은 변위로 평행이동.
|
||||
if (other.lineName === 'kerabPatternValleyExt' && (hit1 || hit2)) {
|
||||
other.set({
|
||||
x1: other.x1 + dxVExt,
|
||||
y1: other.y1 + dyVExt,
|
||||
x2: other.x2 + dxVExt,
|
||||
y2: other.y2 + dyVExt,
|
||||
})
|
||||
moved = true
|
||||
} else {
|
||||
if (hit1) {
|
||||
other.set({ x1: other.x1 + dxVExt, y1: other.y1 + dyVExt })
|
||||
moved = true
|
||||
}
|
||||
if (hit2) {
|
||||
other.set({ x2: other.x2 + dxVExt, y2: other.y2 + dyVExt })
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
if (moved) {
|
||||
if (typeof other.setCoords === 'function') other.setCoords()
|
||||
logger.log(
|
||||
'[KERAB-PATTERN-CASCADE] moved ' +
|
||||
JSON.stringify({
|
||||
lineName: other.lineName,
|
||||
name: other.name,
|
||||
dx: dxVExt,
|
||||
dy: dyVExt,
|
||||
newPts: { x1: other.x1, y1: other.y1, x2: other.x2, y2: other.y2 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@ -169,6 +255,41 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
|
||||
const orig = il.__shrinkOrig || { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }
|
||||
const orig1 = { x: orig.x1, y: orig.y1 }
|
||||
const orig2 = { x: orig.x2, y: orig.y2 }
|
||||
|
||||
// [KERAB-OFFSET-CORNER-SHORTCUT 2026-06-01] orig 끝점이 옛 corner 와 일치하면 새 corner 로 직접 snap.
|
||||
// 일반 절삭 흐름은 il segment 와 새 roofLine 변의 lineLineIntersection 으로 ip 계산하는데,
|
||||
// 끝점이 옛 corner 위에 있으면 ip 가 새 corner segment 밖으로 떨어져 segOk 가 reject → 절삭 실패.
|
||||
// 그 결과 c1·c2 비대칭으로 kLine 대각선 변형됨.
|
||||
const CORNER_SNAP_TOL_TRIM = 0.5
|
||||
let cornerSnapped = false
|
||||
const trySnap = (epx, epy, which) => {
|
||||
if (Math.hypot(epx - oldCorner1.x, epy - oldCorner1.y) < CORNER_SNAP_TOL_TRIM) {
|
||||
if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }
|
||||
if (which === 1) il.set({ x1: newCorner1.x, y1: newCorner1.y })
|
||||
else il.set({ x2: newCorner1.x, y2: newCorner1.y })
|
||||
cornerSnapped = true
|
||||
return true
|
||||
}
|
||||
if (Math.hypot(epx - oldCorner2.x, epy - oldCorner2.y) < CORNER_SNAP_TOL_TRIM) {
|
||||
if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }
|
||||
if (which === 1) il.set({ x1: newCorner2.x, y1: newCorner2.y })
|
||||
else il.set({ x2: newCorner2.x, y2: newCorner2.y })
|
||||
cornerSnapped = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
trySnap(orig.x1, orig.y1, 1)
|
||||
trySnap(orig.x2, orig.y2, 2)
|
||||
if (cornerSnapped) {
|
||||
if (typeof il.setCoords === 'function') il.setCoords()
|
||||
logger.log(
|
||||
'[KERAB-OFFSET-CORNER-SHORTCUT] snapped ' +
|
||||
JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const d1 = (orig1.x - newAxisMid.x) * nx + (orig1.y - newAxisMid.y) * ny
|
||||
const d2 = (orig2.x - newAxisMid.x) * nx + (orig2.y - newAxisMid.y) * ny
|
||||
|
||||
|
||||
@ -6346,6 +6346,93 @@ export const equalizeSymmetricHips = (hipLines, canvas) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 평행한 처마 변 쌍(고정 outerLine ↔ 이동 eaveHelpLine)의 라벨을 통일.
|
||||
*
|
||||
* 배경: WallBaseLine 을 옆 변과 평행이 되도록 옮기면 OVER_EPS(0.1px=1mm) 클램프 때문에
|
||||
* 이동 변이 평행 직전에서 멈춰 좌표 길이가 고정 변보다 정확히 1mm 짧아진다
|
||||
* (예: 고정 2805 ↔ 이동 2804). 기하/SK 안전을 위해 OVER_EPS 는 유지해야 하므로
|
||||
* 좌표는 건드리지 않고 '표시 라벨' 만 맞춘다. 고객이 1mm 차이도 거부하기 때문.
|
||||
*
|
||||
* 좌표 길이(calcLinePlaneSize 와 동일식)로 그룹핑한다. outerLine 의 attributes.planeSize 는
|
||||
* stale 일 수 있어 신뢰하지 않는다. 클램프는 항상 '짧게' 만들므로 그룹 최대값이 의도한 값.
|
||||
* 기준(최대) 변은 손대지 않고, 짧아진 변만 최대값으로 끌어올린다.
|
||||
*
|
||||
* @param {Array} lines - outerLine + eaveHelpLine 등 후보 라인 배열 (x1,y1,x2,y2 필수)
|
||||
* @param {object} canvas - fabric canvas (lengthText 동기화용; null 이면 attribute 만 갱신)
|
||||
*/
|
||||
export const equalizeParallelEaveLabels = (lines, canvas) => {
|
||||
if (!lines || lines.length < 2) return
|
||||
|
||||
const _coordLen = (l) => Math.round(Math.sqrt((l.x2 - l.x1) ** 2 + (l.y2 - l.y1) ** 2) * 10)
|
||||
const _orient = (l) => {
|
||||
const adx = Math.abs(l.x2 - l.x1)
|
||||
const ady = Math.abs(l.y2 - l.y1)
|
||||
if (adx < 0.5 && ady >= 0.5) return 'V'
|
||||
if (ady < 0.5 && adx >= 0.5) return 'H'
|
||||
return null // 대각선(hip 등) 은 equalizeSymmetricHips 담당 → 제외
|
||||
}
|
||||
// 평행 두 변이 마주보는 처마 쌍인지: 평행 방향(변화 축) 의 범위가 겹쳐야 한다.
|
||||
const _overlap = (a, b, orient) => {
|
||||
const [ak1, ak2, bk1, bk2] =
|
||||
orient === 'V'
|
||||
? [Math.min(a.y1, a.y2), Math.max(a.y1, a.y2), Math.min(b.y1, b.y2), Math.max(b.y1, b.y2)]
|
||||
: [Math.min(a.x1, a.x2), Math.max(a.x1, a.x2), Math.min(b.x1, b.x2), Math.max(b.x1, b.x2)]
|
||||
return Math.min(ak2, bk2) - Math.max(ak1, bk1) > 0.5
|
||||
}
|
||||
const _apply = (line, plane, actual) => {
|
||||
const text = canvas ? canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id) : null
|
||||
// 표시 모드(평면/실측) 는 갱신 전 기준으로 판정해야 잘못된 모드 전환을 막는다.
|
||||
const wasPlane = text ? !text.actualSize || text.text === String(text.planeSize) : true
|
||||
if (line.attributes) {
|
||||
line.attributes.planeSize = plane
|
||||
line.attributes.actualSize = actual
|
||||
}
|
||||
if (text) {
|
||||
text.set({ planeSize: plane, actualSize: actual })
|
||||
const __raw = wasPlane ? plane : actual
|
||||
text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') })
|
||||
}
|
||||
}
|
||||
|
||||
const used = new Set()
|
||||
lines.forEach((a, i) => {
|
||||
if (used.has(i)) return
|
||||
const oa = _orient(a)
|
||||
if (!oa) {
|
||||
used.add(i)
|
||||
return
|
||||
}
|
||||
const baseLen = _coordLen(a)
|
||||
const group = [{ idx: i, line: a, len: baseLen }]
|
||||
lines.forEach((b, j) => {
|
||||
if (j <= i || used.has(j)) return
|
||||
if (_orient(b) !== oa) return
|
||||
const lenB = _coordLen(b)
|
||||
if (Math.abs(lenB - baseLen) > 5) return
|
||||
if (!_overlap(a, b, oa)) return
|
||||
group.push({ idx: j, line: b, len: lenB })
|
||||
})
|
||||
if (group.length >= 2) {
|
||||
let ref = group[0]
|
||||
group.forEach((g) => {
|
||||
if (g.len > ref.len) ref = g
|
||||
})
|
||||
const plane = ref.len
|
||||
group.forEach(({ idx, line, len }) => {
|
||||
used.add(idx)
|
||||
if (len >= plane) return // 기준(최대) 변은 손대지 않음
|
||||
const oldPlane = line.attributes?.planeSize ?? len
|
||||
const oldActual = line.attributes?.actualSize ?? oldPlane
|
||||
const actual = oldPlane > 0 ? Math.round((oldActual * plane) / oldPlane) : plane
|
||||
_apply(line, plane, actual)
|
||||
})
|
||||
} else {
|
||||
used.add(i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 포인트와 기울기를 기준으로 선의 길이를 구한다.
|
||||
* @param lineLength
|
||||
|
||||
@ -72,7 +72,7 @@ export const notifyLowPitchRestrictionForRoofs = async (roofs, swalFire, getMess
|
||||
swalFire({
|
||||
type: 'alert',
|
||||
icon: 'warning',
|
||||
text: getMessage('modal.roof.material.low.pitch.warning'),
|
||||
html: getMessage('modal.roof.material.low.pitch.warning').replace(/\n/g, '<br>'),
|
||||
confirmFn: () => resolve(),
|
||||
})
|
||||
})
|
||||
|
||||
@ -2013,6 +2013,16 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
|
||||
return
|
||||
}
|
||||
|
||||
// [KERAB-SKIP 2026-06-04] 순수 케라바 변(GABLE/GABLE_LEFT/GABLE_RIGHT/JERKINHEAD)은
|
||||
// 처마처럼 wall→roof 사이에 hip이 없으므로 eaveHelpLine 불필요.
|
||||
// 적용 시 SK 오염. HIPANDGABLE은 처마+케라바 혼합(hip 있음)이므로 제외하지 않음.
|
||||
if ([
|
||||
LINE_TYPE.WALLLINE.GABLE,
|
||||
LINE_TYPE.WALLLINE.GABLE_LEFT,
|
||||
LINE_TYPE.WALLLINE.GABLE_RIGHT,
|
||||
LINE_TYPE.WALLLINE.JERKINHEAD,
|
||||
].includes(wallBaseLine.attributes?.type)) return
|
||||
|
||||
// [진단] sortWallLines ↔ sortWallBaseLines 페어링 검증
|
||||
// collapse 시 ensureCounterClockwiseLines 시작점이 달라지면 인덱스가 어긋남
|
||||
// 정상: 같은 물리 벽 → 적어도 한쪽 끝점 공유 또는 방향 동일(수직/수평)
|
||||
@ -2111,17 +2121,6 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver
|
||||
const getAddLine = (p1, p2, stroke = '', lineType = 'eaveHelpLine') => {
|
||||
movedLines.push({ index, p1, p2 })
|
||||
|
||||
// [진단] eaveHelpLine 생성 추적: 어느 index/어느 wallBaseLine-wallLine 쌍에서 만들어지는지
|
||||
// logger.log('🎯 [getAddLine]', {
|
||||
// index,
|
||||
// lineType,
|
||||
// p1: { x: Math.round(p1.x), y: Math.round(p1.y) },
|
||||
// p2: { x: Math.round(p2.x), y: Math.round(p2.y) },
|
||||
// wallLine: `(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)})`,
|
||||
// wallBaseLine: `(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`,
|
||||
// wallBaseLen: Math.round(Math.hypot(wallBaseLine.x2 - wallBaseLine.x1, wallBaseLine.y2 - wallBaseLine.y1)),
|
||||
// })
|
||||
|
||||
const dx = Math.abs(p2.x - p1.x);
|
||||
const dy = Math.abs(p2.y - p1.y);
|
||||
const isDiagonal = dx > 0.5 && dy > 0.5; // x, y 변화가 모두 있으면 대각선
|
||||
@ -3147,8 +3146,8 @@ function findMatchingLine(edgePolygon, roof, roofPoints) {
|
||||
*/
|
||||
function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine, lastSkeletonLines) {
|
||||
const edgePoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y }));
|
||||
//const polygons = createPolygonsFromSkeletonLines(skeletonLines, selectBaseLine);
|
||||
//logger.log("edgePoints::::::", edgePoints)
|
||||
|
||||
|
||||
// 1. Initialize processedLines with a deep copy of lastSkeletonLines
|
||||
let processedLines = []
|
||||
// 1. 케라바 면과 관련된 불필요한 스켈레톤 선을 제거합니다.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user