Merge pull request 'dev' (#887) from dev into dev-deploy
Reviewed-on: #887
This commit is contained in:
commit
52fe6866d6
@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useMessage } from '@/hooks/useMessage'
|
import { useMessage } from '@/hooks/useMessage'
|
||||||
import { isObjectNotEmpty } from '@/util/common-utils'
|
|
||||||
import QSelectBox from '@/components/common/select/QSelectBox'
|
import QSelectBox from '@/components/common/select/QSelectBox'
|
||||||
import { useModuleTabContents } from '@/hooks/module/useModuleTabContents'
|
import { useModuleTabContents } from '@/hooks/module/useModuleTabContents'
|
||||||
import { useRecoilValue } from 'recoil'
|
import { useRecoilValue } from 'recoil'
|
||||||
@ -170,41 +169,32 @@ export default function ModuleTabContents({ tabIndex, addRoof, setAddedRoofs, ro
|
|||||||
</div>
|
</div>
|
||||||
<div className="module-flex-item non-flex">
|
<div className="module-flex-item non-flex">
|
||||||
<div className="flex-item-btn-wrap">
|
<div className="flex-item-btn-wrap">
|
||||||
<button
|
{/* 2026-06-05 공법 버튼을 배열 인덱스 대신 constTp로 매핑 — API 응답 항목 추가/순서변경(예: 標準施工(Ⅱ) WORK_LV_ID_-2) 시 인덱스 밀림 방지. 운영(구 API)엔 -2가 없어 optional 은 데이터 있을 때만 렌더 */}
|
||||||
className={`btn-frame roof ${isObjectNotEmpty(constructionList[0]) && constructionList[0].constPossYn === 'Y' ? 'white' : ''}`}
|
{[
|
||||||
ref={(el) => (constructionRef.current[0] = el)}
|
{ constTp: 'WORK_LV_ID_-1', label: '標準施工(Ⅰ)' },
|
||||||
onClick={() => (isObjectNotEmpty(constructionList[0]) && constructionList[0].constPossYn === 'Y' ? handleConstruction(0) : null)}
|
{ constTp: 'WORK_LV_ID_-2', label: '標準施工(Ⅱ)', optional: true },
|
||||||
>
|
{ constTp: 'WORK_LV_ID_1', label: '標準施工' },
|
||||||
標準施工(Ⅰ)
|
{ constTp: 'WORK_LV_ID_3', label: '強化施工' },
|
||||||
</button>
|
{ constTp: 'WORK_LV_ID_4', label: '多設施工' },
|
||||||
<button
|
{ constTp: 'WORK_LV_ID_5', label: '多設施工(II)' },
|
||||||
className={`btn-frame roof ${isObjectNotEmpty(constructionList[3]) && constructionList[3].constPossYn === 'Y' ? 'white' : ''}`}
|
].map(({ constTp, label, optional }) => {
|
||||||
ref={(el) => (constructionRef.current[3] = el)}
|
const index = constructionList.findIndex((c) => c.constTp === constTp)
|
||||||
onClick={() => (isObjectNotEmpty(constructionList[3]) && constructionList[3].constPossYn === 'Y' ? handleConstruction(3) : null)}
|
const item = index > -1 ? constructionList[index] : null
|
||||||
>
|
if (optional && !item) return null
|
||||||
多設施工
|
const enabled = item && item.constPossYn === 'Y'
|
||||||
</button>
|
return (
|
||||||
<button
|
<button
|
||||||
className={`btn-frame roof ${isObjectNotEmpty(constructionList[1]) && constructionList[1].constPossYn === 'Y' ? 'white' : ''}`}
|
key={constTp}
|
||||||
ref={(el) => (constructionRef.current[1] = el)}
|
className={`btn-frame roof ${enabled ? 'white' : ''}`}
|
||||||
onClick={() => (isObjectNotEmpty(constructionList[1]) && constructionList[1].constPossYn === 'Y' ? handleConstruction(1) : null)}
|
ref={(el) => {
|
||||||
>
|
if (index > -1) constructionRef.current[index] = el
|
||||||
標準施工
|
}}
|
||||||
</button>
|
onClick={() => (enabled ? handleConstruction(index) : null)}
|
||||||
<button
|
>
|
||||||
className={`btn-frame roof ${isObjectNotEmpty(constructionList[4]) && constructionList[4].constPossYn === 'Y' ? 'white' : ''}`}
|
{label}
|
||||||
ref={(el) => (constructionRef.current[4] = el)}
|
</button>
|
||||||
onClick={() => (isObjectNotEmpty(constructionList[4]) && constructionList[4].constPossYn === 'Y' ? handleConstruction(4) : null)}
|
)
|
||||||
>
|
})}
|
||||||
多設施工(II)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn-frame roof ${isObjectNotEmpty(constructionList[2]) && constructionList[2].constPossYn === 'Y' ? 'white' : ''}`}
|
|
||||||
ref={(el) => (constructionRef.current[2] = el)}
|
|
||||||
onClick={() => (isObjectNotEmpty(constructionList[2]) && constructionList[2].constPossYn === 'Y' ? handleConstruction(2) : null)}
|
|
||||||
>
|
|
||||||
強化施工
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid-check-form-block">
|
<div className="grid-check-form-block">
|
||||||
<div className="d-check-box pop">
|
<div className="d-check-box pop">
|
||||||
|
|||||||
@ -217,7 +217,7 @@ const Trestle = forwardRef((props, ref) => {
|
|||||||
}, [constructionList, autoSelectStep])
|
}, [constructionList, autoSelectStep])
|
||||||
|
|
||||||
const getConstructionState = (index) => {
|
const getConstructionState = (index) => {
|
||||||
if (constructionList && constructionList.length > 0) {
|
if (index > -1 && constructionList && constructionList.length > 0 && constructionList[index]) {
|
||||||
if (constructionList[index].constPossYn === 'Y') {
|
if (constructionList[index].constPossYn === 'Y') {
|
||||||
if (trestleState && trestleState.constTp === constructionList[index].constTp) {
|
if (trestleState && trestleState.constTp === constructionList[index].constTp) {
|
||||||
return 'blue'
|
return 'blue'
|
||||||
@ -865,21 +865,23 @@ const Trestle = forwardRef((props, ref) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="module-flex-item non-flex">
|
<div className="module-flex-item non-flex">
|
||||||
<div className="flex-item-btn-wrap">
|
<div className="flex-item-btn-wrap">
|
||||||
<button className={`btn-frame roof ${getConstructionState(0)}`} onClick={() => handleConstruction(0)}>
|
{/* 2026-06-05 공법 버튼을 배열 인덱스 대신 constTp로 매핑 — API 응답 항목 추가/순서변경(예: 標準施工(II) WORK_LV_ID_-2) 시 인덱스 밀림 방지. 운영(구 API)엔 -2가 없어 optional 은 데이터 있을 때만 렌더 */}
|
||||||
{getMessage('modal.module.basic.setting.module.standard.construction')}(I)
|
{[
|
||||||
</button>
|
{ constTp: 'WORK_LV_ID_-1', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(I)` },
|
||||||
<button className={`btn-frame roof ${getConstructionState(3)}`} onClick={() => handleConstruction(3)}>
|
{ constTp: 'WORK_LV_ID_-2', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(II)`, optional: true },
|
||||||
{getMessage('modal.module.basic.setting.module.multiple.construction')}
|
{ constTp: 'WORK_LV_ID_1', label: getMessage('modal.module.basic.setting.module.standard.construction') },
|
||||||
</button>
|
{ constTp: 'WORK_LV_ID_3', label: getMessage('modal.module.basic.setting.module.enforce.construction') },
|
||||||
<button className={`btn-frame roof ${getConstructionState(1)}`} onClick={() => handleConstruction(1)}>
|
{ constTp: 'WORK_LV_ID_4', label: getMessage('modal.module.basic.setting.module.multiple.construction') },
|
||||||
{getMessage('modal.module.basic.setting.module.standard.construction')}
|
{ constTp: 'WORK_LV_ID_5', label: `${getMessage('modal.module.basic.setting.module.multiple.construction')}(II)` },
|
||||||
</button>
|
].map(({ constTp, label, optional }) => {
|
||||||
<button className={`btn-frame roof ${getConstructionState(4)}`} onClick={() => handleConstruction(4)}>
|
const index = constructionList.findIndex((c) => c.constTp === constTp)
|
||||||
{getMessage('modal.module.basic.setting.module.multiple.construction')}(II)
|
if (optional && index === -1) return null
|
||||||
</button>
|
return (
|
||||||
<button className={`btn-frame roof ${getConstructionState(2)}`} onClick={() => handleConstruction(2)}>
|
<button key={constTp} className={`btn-frame roof ${getConstructionState(index)}`} onClick={() => handleConstruction(index)}>
|
||||||
{getMessage('modal.module.basic.setting.module.enforce.construction')}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid-check-form-flex">
|
<div className="grid-check-form-flex">
|
||||||
<div className="d-check-box pop">
|
<div className="d-check-box pop">
|
||||||
|
|||||||
@ -175,9 +175,10 @@ export function useModuleTabContents({ tabIndex, addRoof, setAddedRoofs, roofTab
|
|||||||
isObjectNotEmpty(moduleConstructionSelectionData?.construction) &&
|
isObjectNotEmpty(moduleConstructionSelectionData?.construction) &&
|
||||||
moduleConstructionSelectionData?.construction.hasOwnProperty('constPossYn') ///키가 있으면
|
moduleConstructionSelectionData?.construction.hasOwnProperty('constPossYn') ///키가 있으면
|
||||||
) {
|
) {
|
||||||
const selectedIndex = moduleConstructionSelectionData.construction.selectedIndex
|
// 2026-06-05 저장값 복원도 constTp 기준 — 항목 추가/순서변경으로 selectedIndex 가 어긋나는 문제 방지
|
||||||
const construction = constructionList[selectedIndex]
|
const selectedIndex = constructionList.findIndex((c) => c.constTp === moduleConstructionSelectionData.construction.constTp)
|
||||||
if (construction.constPossYn === 'Y') {
|
const construction = selectedIndex > -1 ? constructionList[selectedIndex] : null
|
||||||
|
if (construction && construction.constPossYn === 'Y') {
|
||||||
handleConstruction(selectedIndex)
|
handleConstruction(selectedIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -268,7 +269,7 @@ export function useModuleTabContents({ tabIndex, addRoof, setAddedRoofs, roofTab
|
|||||||
const handleConstruction = (index) => {
|
const handleConstruction = (index) => {
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
const isPossibleIndex = constructionRef.current
|
const isPossibleIndex = constructionRef.current
|
||||||
.map((el, i) => (el.classList.contains('white') || el.classList.contains('blue') ? i : -1))
|
.map((el, i) => (el && (el.classList.contains('white') || el.classList.contains('blue')) ? i : -1))
|
||||||
.filter((index) => index !== -1)
|
.filter((index) => index !== -1)
|
||||||
|
|
||||||
isPossibleIndex.forEach((index) => {
|
isPossibleIndex.forEach((index) => {
|
||||||
@ -341,7 +342,7 @@ export function useModuleTabContents({ tabIndex, addRoof, setAddedRoofs, roofTab
|
|||||||
setSelectedConstruction(selectedConstruction)
|
setSelectedConstruction(selectedConstruction)
|
||||||
} else {
|
} else {
|
||||||
constructionRef.current.forEach((ref) => {
|
constructionRef.current.forEach((ref) => {
|
||||||
ref.classList.remove('blue')
|
if (ref) ref.classList.remove('blue')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -253,7 +253,145 @@ export function useEavesGableEdit(id) {
|
|||||||
logger.log(
|
logger.log(
|
||||||
`[KERAB-OFFSET-ONLY-RECLICK] type=${attributes.type} 동일, offset ${oldOffset}→${newOffset} surgical 적용`,
|
`[KERAB-OFFSET-ONLY-RECLICK] type=${attributes.type} 동일, offset ${oldOffset}→${newOffset} surgical 적용`,
|
||||||
)
|
)
|
||||||
applyTargetOffsetSurgical(target, newOffset)
|
// [KERAB-OFFSET-ONLY-RECLICK-EAVES 2026-06-05] 처마 상태 출폭 변경 룰:
|
||||||
|
// - wallbaseLine 안의 내부라인 본체 끝점(apex) 절대 불변
|
||||||
|
// - hip outer endpoint 만 wL 코너에서 hip 방향(=skeleton 45°) 으로 새 rL 변까지 ray-cast 확장
|
||||||
|
// - surgical 의 CORNER-SHORTCUT/SHRINK-TRIM 은 룰 위반 → skipInnerLines:true
|
||||||
|
// (케라바 ONLY-RECLICK 은 KERAB-PATTERN-CORNER-SNAP 필요하므로 그대로 둠.)
|
||||||
|
const isEaves = attributes?.type === LINE_TYPE.WALLLINE.EAVES
|
||||||
|
let reclickRoof = null
|
||||||
|
const 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 }))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
target.set({ attributes })
|
target.set({ attributes })
|
||||||
canvas.renderAll()
|
canvas.renderAll()
|
||||||
return
|
return
|
||||||
@ -2219,11 +2357,105 @@ export function useEavesGableEdit(id) {
|
|||||||
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
|
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
|
||||||
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
|
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
|
||||||
if (c1 && c2) {
|
if (c1 && c2) {
|
||||||
// [KERAB-OFFSET-SURGICAL 2026-05-27] revert 경로에서는 hip snapshot 좌표가 옛 corner 기준이라
|
// [KERAB-REVERT-EXTEND-45 2026-06-05] 룰: hip 은 wallbaseLine 의 45° (skeleton 이론 부정 X).
|
||||||
// surgical 을 pattern 호출 **후**로 미룬다. pattern 이 옛 corner 로 hip 복원 후, surgical 의
|
// 본체 inner endpoint = snapshot 그대로 (apex 위치).
|
||||||
// inner-line corner snap 이 hip 끝점도 새 corner 로 함께 이동시킨다.
|
// 본체 outer endpoint = wL 코너에서 45° 방향(snapshot 의 inner→outer 방향) 으로 ray cast → 새 rL 변 hit point.
|
||||||
|
// wL 코너는 라인이 지나가는 transit point 일 뿐 data endpoint 아님.
|
||||||
|
// skeleton-utils.js drawBaselineToRooflineHelpers (line 770~880) 와 동일 방식.
|
||||||
const surgicalAfter = () => {
|
const surgicalAfter = () => {
|
||||||
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0)
|
// [KERAB-REVERT-EXTEND-45 2026-06-05] 룰: 출폭 변경 시 wallbaseLine 안의 내부라인 끝점 불변.
|
||||||
|
// surgical 의 CORNER-SHORTCUT(roofLine 코너로 스냅)·SHRINK-TRIM(__shrinkOrig 복원) 은 룰 위반 →
|
||||||
|
// skipInnerLines:true 로 roof.points + matchingRL/prev/nextRL + edge 객체만 갱신.
|
||||||
|
// 증상: (1) b1 ray-cast 후 b4 출폭조정 → SHRINK-TRIM restore 가 b1 hip 을 snapshot 좌표로 리셋
|
||||||
|
// (2) CORNER-SHORTCUT 가 hip outer endpoint 를 새 roofLine 코너로 강제 스냅.
|
||||||
|
// hip 의 outer endpoint 만 아래 ray-cast 로 새 rL 변까지 45° 확장한다.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원
|
// [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원
|
||||||
if (Array.isArray(target.__kerabParallelHipsSnapshot)) {
|
if (Array.isArray(target.__kerabParallelHipsSnapshot)) {
|
||||||
@ -2378,9 +2610,9 @@ export function useEavesGableEdit(id) {
|
|||||||
|
|
||||||
// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 본체는 `@/util/kerab-offset-surgical` 로 분리.
|
// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 본체는 `@/util/kerab-offset-surgical` 로 분리.
|
||||||
// 고객 회귀 확인을 위한 토글 — ENABLE_KERAB_OFFSET_SURGICAL false 면 즉시 false 반환.
|
// 고객 회귀 확인을 위한 토글 — ENABLE_KERAB_OFFSET_SURGICAL false 면 즉시 false 반환.
|
||||||
const applyTargetOffsetSurgical = (target, newOffset) => {
|
const applyTargetOffsetSurgical = (target, newOffset, options) => {
|
||||||
if (!ENABLE_KERAB_OFFSET_SURGICAL) return false
|
if (!ENABLE_KERAB_OFFSET_SURGICAL) return false
|
||||||
return applyKerabOffsetSurgical(canvas, target, newOffset)
|
return applyKerabOffsetSurgical(canvas, target, newOffset, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -2792,6 +3024,33 @@ export function useEavesGableEdit(id) {
|
|||||||
logger.log('[KERAB-VALLEY-EXT-TRIM] revert restored ' + target.__valleyExtTrims.length + ' trim(s)')
|
logger.log('[KERAB-VALLEY-EXT-TRIM] revert restored ' + target.__valleyExtTrims.length + ' trim(s)')
|
||||||
delete target.__valleyExtTrims
|
delete target.__valleyExtTrims
|
||||||
}
|
}
|
||||||
|
// [KERAB-REVERT-MARK 2026-06-05] 룰 (b): hip 본체는 wallLine 끝점을 지나고 outer 끝점은 새 roofLine 코너까지 확장.
|
||||||
|
// buildHipFromSnapshot 시점에는 roof.points 가 아직 옛 출폭 → corner 좌표를 미리 정할 수 없음.
|
||||||
|
// 여기서는 outer 끝점이 어느 쪽(which=1/2, side=A/B)인지만 마크하고, 실제 좌표 snap 은 surgicalAfter() 안에서
|
||||||
|
// roof.points 가 새 출폭으로 갱신된 뒤 nearestRoofPoint 로 다시 잡는다.
|
||||||
|
// Corner-side 식별: hip 의 두 끝점 중 wallLine 양 끝점(wA/wB)에 더 가까운 쪽이 corner-side.
|
||||||
|
// apex-side 는 roof 안쪽 깊이 있어 두 wallLine 끝점에서 모두 멀다.
|
||||||
|
const markRevertOuter = (line) => {
|
||||||
|
if (!line) return
|
||||||
|
const wA = { x: target.x1, y: target.y1 }
|
||||||
|
const wB = { x: target.x2, y: target.y2 }
|
||||||
|
const e1 = { x: line.x1, y: line.y1 }
|
||||||
|
const e2 = { x: line.x2, y: line.y2 }
|
||||||
|
const dE1A = Math.hypot(e1.x - wA.x, e1.y - wA.y)
|
||||||
|
const dE1B = Math.hypot(e1.x - wB.x, e1.y - wB.y)
|
||||||
|
const dE2A = Math.hypot(e2.x - wA.x, e2.y - wA.y)
|
||||||
|
const dE2B = Math.hypot(e2.x - wB.x, e2.y - wB.y)
|
||||||
|
const minE1 = Math.min(dE1A, dE1B)
|
||||||
|
const minE2 = Math.min(dE2A, dE2B)
|
||||||
|
const which = minE1 <= minE2 ? 1 : 2
|
||||||
|
const side = which === 1 ? (dE1A <= dE1B ? 'A' : 'B') : dE2A <= dE2B ? 'A' : 'B'
|
||||||
|
line.__kerabRevertOuterWhich = which
|
||||||
|
line.__kerabRevertOuterSide = side
|
||||||
|
logger.log(
|
||||||
|
'[KERAB-REVERT-MARK] ' +
|
||||||
|
JSON.stringify({ lineName: line.lineName, which, side, minE1, minE2 }),
|
||||||
|
)
|
||||||
|
}
|
||||||
const buildHipFromSnapshot = (snap) => {
|
const buildHipFromSnapshot = (snap) => {
|
||||||
const pts = [snap.x1, snap.y1, snap.x2, snap.y2]
|
const pts = [snap.x1, snap.y1, snap.x2, snap.y2]
|
||||||
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
|
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
|
||||||
@ -2806,6 +3065,7 @@ export function useEavesGableEdit(id) {
|
|||||||
})
|
})
|
||||||
if (snap.lineName) hip.lineName = snap.lineName
|
if (snap.lineName) hip.lineName = snap.lineName
|
||||||
if (snap.__extended) hip.__extended = snap.__extended
|
if (snap.__extended) hip.__extended = snap.__extended
|
||||||
|
markRevertOuter(hip)
|
||||||
return hip
|
return hip
|
||||||
}
|
}
|
||||||
const buildHipToApex = (cornerPt) => {
|
const buildHipToApex = (cornerPt) => {
|
||||||
@ -2856,6 +3116,7 @@ export function useEavesGableEdit(id) {
|
|||||||
})
|
})
|
||||||
if (snap.lineName) hip.lineName = snap.lineName
|
if (snap.lineName) hip.lineName = snap.lineName
|
||||||
if (snap.__extended) hip.__extended = snap.__extended
|
if (snap.__extended) hip.__extended = snap.__extended
|
||||||
|
markRevertOuter(hip)
|
||||||
canvas.add(hip)
|
canvas.add(hip)
|
||||||
hip.bringToFront()
|
hip.bringToFront()
|
||||||
roof.innerLines.push(hip)
|
roof.innerLines.push(hip)
|
||||||
|
|||||||
@ -21,9 +21,15 @@ const lineLineIntersection = (p1, p2, p3, p4) => {
|
|||||||
* @param canvas fabric canvas
|
* @param canvas fabric canvas
|
||||||
* @param target outerLine fabric 객체 (target.attributes 는 OLD 상태로 호출되어야 함)
|
* @param target outerLine fabric 객체 (target.attributes 는 OLD 상태로 호출되어야 함)
|
||||||
* @param newOffset 새 출폭 (canvas 단위)
|
* @param newOffset 새 출폭 (canvas 단위)
|
||||||
|
* @param options { skipInnerLines?: boolean }
|
||||||
|
* skipInnerLines=true 면 innerLines 루프(CORNER-SHORTCUT / SHRINK-TRIM / KERAB-PATTERN-CORNER-SNAP) 전체 skip.
|
||||||
|
* [KERAB-REVERT-EXTEND-45 2026-06-05] 룰: 출폭 변경 시 wallbaseLine 안의 내부라인 끝점은 절대 이동 X.
|
||||||
|
* roofLine 코너로 스냅(CORNER-SHORTCUT)·__shrinkOrig 로 복원(SHRINK-TRIM) 둘 다 룰 위반.
|
||||||
|
* 호출자(revert)가 별도 ray-cast 로 hip 의 outer endpoint 만 새 rL 변까지 45° 확장한다.
|
||||||
* @returns true=적용됨 / false=조건 미달 또는 변경 없음
|
* @returns true=적용됨 / false=조건 미달 또는 변경 없음
|
||||||
*/
|
*/
|
||||||
export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
|
export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}) => {
|
||||||
|
const { skipInnerLines = false } = options
|
||||||
const roof = canvas
|
const roof = canvas
|
||||||
.getObjects()
|
.getObjects()
|
||||||
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
||||||
@ -111,7 +117,7 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
|
|||||||
// - 원본 끝점이 새 roofLine 변 안쪽으로 복귀하면 원본 좌표로 복원 (출폭 다시 늘린 케이스).
|
// - 원본 끝점이 새 roofLine 변 안쪽으로 복귀하면 원본 좌표로 복원 (출폭 다시 늘린 케이스).
|
||||||
// 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제.
|
// 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제.
|
||||||
// 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent.
|
// 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent.
|
||||||
{
|
if (!skipInnerLines) {
|
||||||
const newAxisMid = { x: (newCorner1.x + newCorner2.x) / 2, y: (newCorner1.y + newCorner2.y) / 2 }
|
const newAxisMid = { x: (newCorner1.x + newCorner2.x) / 2, y: (newCorner1.y + newCorner2.y) / 2 }
|
||||||
const OUTSIDE_TOL = 0.5
|
const OUTSIDE_TOL = 0.5
|
||||||
const segOk = (ip) => {
|
const segOk = (ip) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user