diff --git a/src/components/floor-plan/modal/circuitTrestle/step/PowerConditionalSelect.jsx b/src/components/floor-plan/modal/circuitTrestle/step/PowerConditionalSelect.jsx
index d8a517c5..0baa64f3 100644
--- a/src/components/floor-plan/modal/circuitTrestle/step/PowerConditionalSelect.jsx
+++ b/src/components/floor-plan/modal/circuitTrestle/step/PowerConditionalSelect.jsx
@@ -103,6 +103,11 @@ export default function PowerConditionalSelect(props) {
selected: s.pcsSerCd === data.pcsSerCd ? !s.selected : data.pcsSerParallelYn === 'Y' ? s.selected : false,
}
})
+ // [PCS-SINGLE-RESET 2026-06-08] 併設(병설) 아닌 Single 아이템은 1대만 적산 가능.
+ // 체크박스 전환 시 기존 추가 PCS 가 리셋되지 않아 복수 등록되던 문제 → 비병설이면 selectedModels 리셋 (SINGLE_N 과 동일).
+ if (data.pcsSerParallelYn !== 'Y') {
+ setSelectedModels([])
+ }
} else {
copySeries = series.map((s) => {
return {
diff --git a/src/components/floor-plan/modal/module/PanelEdit.jsx b/src/components/floor-plan/modal/module/PanelEdit.jsx
index 1c212a33..712b8b88 100644
--- a/src/components/floor-plan/modal/module/PanelEdit.jsx
+++ b/src/components/floor-plan/modal/module/PanelEdit.jsx
@@ -156,7 +156,11 @@ export default function PanelEdit(props) {
diff --git a/src/hooks/module/useModuleTabContents.js b/src/hooks/module/useModuleTabContents.js
index 5f82f939..7fe24f64 100644
--- a/src/hooks/module/useModuleTabContents.js
+++ b/src/hooks/module/useModuleTabContents.js
@@ -175,9 +175,10 @@ export function useModuleTabContents({ tabIndex, addRoof, setAddedRoofs, roofTab
isObjectNotEmpty(moduleConstructionSelectionData?.construction) &&
moduleConstructionSelectionData?.construction.hasOwnProperty('constPossYn') ///키가 있으면
) {
- const selectedIndex = moduleConstructionSelectionData.construction.selectedIndex
- const construction = constructionList[selectedIndex]
- if (construction.constPossYn === 'Y') {
+ // 2026-06-05 저장값 복원도 constTp 기준 — 항목 추가/순서변경으로 selectedIndex 가 어긋나는 문제 방지
+ const selectedIndex = constructionList.findIndex((c) => c.constTp === moduleConstructionSelectionData.construction.constTp)
+ const construction = selectedIndex > -1 ? constructionList[selectedIndex] : null
+ if (construction && construction.constPossYn === 'Y') {
handleConstruction(selectedIndex)
}
}
@@ -268,7 +269,7 @@ export function useModuleTabContents({ tabIndex, addRoof, setAddedRoofs, roofTab
const handleConstruction = (index) => {
if (index > -1) {
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)
isPossibleIndex.forEach((index) => {
@@ -341,7 +342,7 @@ export function useModuleTabContents({ tabIndex, addRoof, setAddedRoofs, roofTab
setSelectedConstruction(selectedConstruction)
} else {
constructionRef.current.forEach((ref) => {
- ref.classList.remove('blue')
+ if (ref) ref.classList.remove('blue')
})
}
}
diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js
index ae4edcfd..7fde855b 100644
--- a/src/hooks/roofcover/useEavesGableEdit.js
+++ b/src/hooks/roofcover/useEavesGableEdit.js
@@ -256,7 +256,145 @@ export function useEavesGableEdit(id) {
logger.log(
`[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 })
canvas.renderAll()
return
@@ -2459,11 +2597,105 @@ export function useEavesGableEdit(id) {
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
if (c1 && c2) {
- // [KERAB-OFFSET-SURGICAL 2026-05-27] revert 경로에서는 hip snapshot 좌표가 옛 corner 기준이라
- // surgical 을 pattern 호출 **후**로 미룬다. pattern 이 옛 corner 로 hip 복원 후, surgical 의
- // inner-line corner snap 이 hip 끝점도 새 corner 로 함께 이동시킨다.
+ // [KERAB-REVERT-EXTEND-45 2026-06-05] 룰: hip 은 wallbaseLine 의 45° (skeleton 이론 부정 X).
+ // 본체 inner endpoint = snapshot 그대로 (apex 위치).
+ // 본체 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 = () => {
- 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개 복원
if (Array.isArray(target.__kerabParallelHipsSnapshot)) {
@@ -2621,9 +2853,9 @@ export function useEavesGableEdit(id) {
// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 본체는 `@/util/kerab-offset-surgical` 로 분리.
// 고객 회귀 확인을 위한 토글 — ENABLE_KERAB_OFFSET_SURGICAL false 면 즉시 false 반환.
- const applyTargetOffsetSurgical = (target, newOffset) => {
+ const applyTargetOffsetSurgical = (target, newOffset, options) => {
if (!ENABLE_KERAB_OFFSET_SURGICAL) return false
- return applyKerabOffsetSurgical(canvas, target, newOffset)
+ return applyKerabOffsetSurgical(canvas, target, newOffset, options)
}
// [2240 KERAB-SINGLE-RIDGE 2026-05-19] 헬퍼 — 양 끝점 hip 페어 식별 및 ridge 단일화 적용
@@ -3046,6 +3278,33 @@ export function useEavesGableEdit(id) {
logger.log('[KERAB-VALLEY-EXT-TRIM] revert restored ' + target.__valleyExtTrims.length + ' trim(s)')
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 pts = [snap.x1, snap.y1, snap.x2, snap.y2]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
@@ -3060,6 +3319,7 @@ export function useEavesGableEdit(id) {
})
if (snap.lineName) hip.lineName = snap.lineName
if (snap.__extended) hip.__extended = snap.__extended
+ markRevertOuter(hip)
return hip
}
const buildHipToApex = (cornerPt) => {
@@ -3110,6 +3370,7 @@ export function useEavesGableEdit(id) {
})
if (snap.lineName) hip.lineName = snap.lineName
if (snap.__extended) hip.__extended = snap.__extended
+ markRevertOuter(hip)
canvas.add(hip)
hip.bringToFront()
roof.innerLines.push(hip)
diff --git a/src/hooks/surface/usePlacementShapeDrawing.js b/src/hooks/surface/usePlacementShapeDrawing.js
index d23def24..f9552a94 100644
--- a/src/hooks/surface/usePlacementShapeDrawing.js
+++ b/src/hooks/surface/usePlacementShapeDrawing.js
@@ -324,25 +324,25 @@ export function usePlacementShapeDrawing(id) {
}
}, [points])
- // 기존 도형의 변과 일치하는 경우 planeSize를 상속하는 함수
+ // 기존 도형의 변과 일치하는 경우 planeSize를 상속하는 함수.
+ // [2026-06-08] 흡착으로 끝점을 정확히 공유한 변(1순위)만 상속.
+ // 기존 2순위(끝점 비공유 평행+유사길이)/2단계 전파는 ±20mm(tolerance 2좌표=20mm) 흡착창으로
+ // 인접하지 않은 별개 屋根까지 끌어와 사용자가 입력한 치수를 변질시켜 제거. tolerance 도 5mm 로 축소.
const inheritPlaneSizeFromExistingShapes = (newPolygon) => {
- const tolerance = 2
+ const tolerance = 0.5
const existingPolygons = canvas
.getObjects()
.filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.id !== newPolygon.id && obj.lines)
if (existingPolygons.length === 0) return
- const inheritedSet = new Set()
+ let inherited = false
- // 1단계: 기존 도형의 변과 매칭하여 planeSize 상속
- newPolygon.lines.forEach((line, lineIdx) => {
+ newPolygon.lines.forEach((line) => {
const x1 = line.x1,
y1 = line.y1,
x2 = line.x2,
y2 = line.y2
- const isHorizontal = Math.abs(y1 - y2) < tolerance
- const isVertical = Math.abs(x1 - x2) < tolerance
for (const polygon of existingPolygons) {
for (const edge of polygon.lines) {
@@ -353,7 +353,7 @@ export function usePlacementShapeDrawing(id) {
ex2 = edge.x2,
ey2 = edge.y2
- // 1순위: 양 끝점이 정확히 일치
+ // 양 끝점이 정확히 일치(흡착 공유변)할 때만 상속
const forwardMatch =
Math.abs(x1 - ex1) < tolerance && Math.abs(y1 - ey1) < tolerance && Math.abs(x2 - ex2) < tolerance && Math.abs(y2 - ey2) < tolerance
const reverseMatch =
@@ -364,89 +364,25 @@ export function usePlacementShapeDrawing(id) {
if (edge.attributes.actualSize) {
line.attributes.actualSize = edge.attributes.actualSize
}
- inheritedSet.add(lineIdx)
- return
- }
-
- // 2순위: 같은 방향 + 같은 좌표 차이 (끝점 공유 불필요)
- if (isHorizontal && Math.abs(ey1 - ey2) < tolerance && Math.abs(Math.abs(x2 - x1) - Math.abs(ex2 - ex1)) < tolerance) {
- line.attributes = { ...line.attributes, planeSize: edge.attributes.planeSize }
- if (edge.attributes.actualSize) {
- line.attributes.actualSize = edge.attributes.actualSize
- }
- inheritedSet.add(lineIdx)
- return
- }
- if (isVertical && Math.abs(ex1 - ex2) < tolerance && Math.abs(Math.abs(y2 - y1) - Math.abs(ey2 - ey1)) < tolerance) {
- line.attributes = { ...line.attributes, planeSize: edge.attributes.planeSize }
- if (edge.attributes.actualSize) {
- line.attributes.actualSize = edge.attributes.actualSize
- }
- inheritedSet.add(lineIdx)
+ inherited = true
return
}
}
}
})
- // 2단계: 상속받은 변과 평행하고 좌표 차이가 같은 변에 planeSize 전파
- if (inheritedSet.size > 0) {
- newPolygon.lines.forEach((line, lineIdx) => {
- if (inheritedSet.has(lineIdx)) return
-
- const x1 = line.x1,
- y1 = line.y1,
- x2 = line.x2,
- y2 = line.y2
- const isHorizontal = Math.abs(y1 - y2) < tolerance
- const isVertical = Math.abs(x1 - x2) < tolerance
-
- for (const idx of inheritedSet) {
- const inherited = newPolygon.lines[idx]
- const ix1 = inherited.x1,
- iy1 = inherited.y1,
- ix2 = inherited.x2,
- iy2 = inherited.y2
- const iIsHorizontal = Math.abs(iy1 - iy2) < tolerance
- const iIsVertical = Math.abs(ix1 - ix2) < tolerance
-
- if (isHorizontal && iIsHorizontal) {
- if (Math.abs(Math.abs(x2 - x1) - Math.abs(ix2 - ix1)) < tolerance) {
- line.attributes = { ...line.attributes, planeSize: inherited.attributes.planeSize }
- if (inherited.attributes.actualSize) {
- line.attributes.actualSize = inherited.attributes.actualSize
- }
- inheritedSet.add(lineIdx)
- return
- }
- } else if (isVertical && iIsVertical) {
- if (Math.abs(Math.abs(y2 - y1) - Math.abs(iy2 - iy1)) < tolerance) {
- line.attributes = { ...line.attributes, planeSize: inherited.attributes.planeSize }
- if (inherited.attributes.actualSize) {
- line.attributes.actualSize = inherited.attributes.actualSize
- }
- inheritedSet.add(lineIdx)
- return
- }
- }
- }
- })
- }
-
// planeSize가 상속된 경우 길이 텍스트를 다시 렌더링
- if (inheritedSet.size > 0) {
+ if (inherited) {
addLengthText(newPolygon)
}
}
- // 기존 도형에서 매칭되는 변의 planeSize를 찾는 헬퍼 함수
+ // 기존 도형에서 매칭되는 변의 planeSize를 찾는 헬퍼 함수.
+ // [2026-06-08] 끝점이 정확히 일치하는 흡착 공유변만 상속.
const findMatchingEdgePlaneSize = (x1, y1, x2, y2) => {
- const tolerance = 2
+ const tolerance = 0.5
const existingPolygons = canvas.getObjects().filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.lines)
- const isHorizontal = Math.abs(y1 - y2) < tolerance
- const isVertical = Math.abs(x1 - x2) < tolerance
-
for (const polygon of existingPolygons) {
for (const edge of polygon.lines) {
if (!edge.attributes?.planeSize) continue
@@ -455,7 +391,7 @@ export function usePlacementShapeDrawing(id) {
ex2 = edge.x2,
ey2 = edge.y2
- // 1순위: 양 끝점 일치 (정방향/역방향)
+ // 양 끝점 일치 (정방향/역방향)
const forwardMatch =
Math.abs(x1 - ex1) < tolerance && Math.abs(y1 - ey1) < tolerance && Math.abs(x2 - ex2) < tolerance && Math.abs(y2 - ey2) < tolerance
const reverseMatch =
@@ -464,14 +400,6 @@ export function usePlacementShapeDrawing(id) {
if (forwardMatch || reverseMatch) {
return edge.attributes.planeSize
}
-
- // 2순위: 같은 방향 + 같은 좌표 차이 (끝점 공유 불필요)
- if (isHorizontal && Math.abs(ey1 - ey2) < tolerance && Math.abs(Math.abs(x2 - x1) - Math.abs(ex2 - ex1)) < tolerance) {
- return edge.attributes.planeSize
- }
- if (isVertical && Math.abs(ex1 - ex2) < tolerance && Math.abs(Math.abs(y2 - y1) - Math.abs(ey2 - ey1)) < tolerance) {
- return edge.attributes.planeSize
- }
}
}
return null
diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js
index ec3d60e9..5ee598c5 100644
--- a/src/hooks/surface/useSurfaceShapeBatch.js
+++ b/src/hooks/surface/useSurfaceShapeBatch.js
@@ -1170,8 +1170,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
delete line.attributes.actualSize
delete line.attributes.isCalculated
})
+ // [SIZE-RESIZE-PLANESIZE 2026-06-08] サイズ変更 시 좌표는 새 크기로 갱신되지만 initLines 가 옛 attributes
+ // (planeSize=구 길이) 를 그대로 물려줘 라벨이 stale 하게 남던 문제. 변경된 새 좌표 길이로 planeSize 를
+ // 강제 재계산 후 actualSize/라벨 재생성 (최초 생성 경로 line 302/320 과 동일하게 맞춤).
+ newPolygon.lines.forEach((line) => {
+ line.attributes.planeSize = line.getLength()
+ })
setPolygonLinesActualSize(newPolygon, true)
-
canvas?.renderAll()
closeAll()
addPopup(popupId, 1, )
diff --git a/src/hooks/useMode.js b/src/hooks/useMode.js
index df61fbe1..dc82c379 100644
--- a/src/hooks/useMode.js
+++ b/src/hooks/useMode.js
@@ -1921,16 +1921,19 @@ export function useMode() {
})
const polygon = createRoofPolygon(wall.points)
- const originPolygon = new QPolygon(wall.points, { fontSize: 0 })
- originPolygon.setViewLengthText(false)
let offsetPolygon
let result = createMarginPolygon(polygon, wall.lines).vertices
- //margin polygon 의 point가 기준 polygon의 밖에 있는지 판단한다.
- const allPointsOutside = result.every((point) => !originPolygon.inPolygon(point))
+ // margin polygon 이 기준 polygon 보다 바깥인지(면적 증가) 판단한다.
+ // [한쪽흐름-OFFSET-FIX 2026-06-05] 기존 점-내부판정(inPolygon)은 offset=0 변(케라바·한쪽흐름)의
+ // 코너가 원본 경계에 남고 inPolygon 의 경계점 nudge 비대칭으로 골짜기(凹凸) 형상에서 inside 오판 →
+ // padding 오선택 → 처마 offset 반전. 처마(offset>0)는 항상 바깥으로 나오므로 정답은 면적이 커지는 margin.
+ // winding·골짜기 수에 무관한 면적 절대값 비교로 판정한다.
+ const polygonArea = (pts) => Math.abs(pts.reduce((s, p, i) => { const q = pts[(i + 1) % pts.length]; return s + (p.x * q.y - q.x * p.y) }, 0))
+ const isMarginOutside = polygonArea(result) >= polygonArea(polygon.vertices)
- if (allPointsOutside) {
+ if (isMarginOutside) {
offsetPolygon = createMarginPolygon(polygon, wall.lines).vertices
} else {
offsetPolygon = createPaddingPolygon(polygon, wall.lines).vertices
diff --git a/src/locales/ja.json b/src/locales/ja.json
index 556bde5c..0eb938ad 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -453,8 +453,10 @@
"modal.row.insert.type.down": "下部挿入",
"modal.move.setting": "移動設定",
"modal.move.setting.info": "間隔を設定し、移動方向を選択します。",
+ "modal.move.save": "移動",
"modal.copy.setting": "コピー設定",
"modal.copy.setting.info": "間隔を設定し、コピー方向を選択します。",
+ "modal.copy.save": "コピー",
"modal.line.property.edit.info": "属性を変更する辺を選択してください。",
"modal.line.property.edit.selected": "選択した値",
"contextmenu.flow.direction.edit": "流れ方向の変更",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 24510705..7d0a94f0 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -453,8 +453,10 @@
"modal.row.insert.type.down": "아래쪽 삽입",
"modal.move.setting": "이동 설정",
"modal.move.setting.info": "간격을 설정하고 이동 방향을 선택하십시오.",
+ "modal.move.save": "이동",
"modal.copy.setting": "복사 설정",
"modal.copy.setting.info": "간격을 설정하고 복사 방향을 선택하십시오.",
+ "modal.copy.save": "복사",
"modal.line.property.edit.info": "속성을 변경할 변을 선택해주세요.",
"modal.line.property.edit.selected": "선택한 값",
"contextmenu.flow.direction.edit": "흐름 방향 변경",
diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js
index 67016214..f791451f 100644
--- a/src/util/kerab-offset-surgical.js
+++ b/src/util/kerab-offset-surgical.js
@@ -21,9 +21,15 @@ const lineLineIntersection = (p1, p2, p3, p4) => {
* @param canvas fabric canvas
* @param target outerLine fabric 객체 (target.attributes 는 OLD 상태로 호출되어야 함)
* @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=조건 미달 또는 변경 없음
*/
-export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
+export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}) => {
+ const { skipInnerLines = false } = options
const roof = canvas
.getObjects()
.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 변 안쪽으로 복귀하면 원본 좌표로 복원 (출폭 다시 늘린 케이스).
// 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제.
// 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent.
- {
+ if (!skipInnerLines) {
const newAxisMid = { x: (newCorner1.x + newCorner2.x) / 2, y: (newCorner1.y + newCorner2.y) / 2 }
const OUTSIDE_TOL = 0.5
const segOk = (ip) => {
diff --git a/startscript-cluster1.js b/startscript-cluster1.js
index a1c03b69..cab38749 100644
--- a/startscript-cluster1.js
+++ b/startscript-cluster1.js
@@ -1,2 +1,10 @@
-var exec = require('child_process').exec
-exec('yarn start:cluster1', { windowsHide: true })
+const { spawn } = require('child_process')
+const child = spawn('yarn start:cluster1', { stdio: 'inherit', windowsHide: true, shell: true })
+child.on('exit', (code, signal) => {
+ console.log(`[wrapper] yarn start:cluster1 exited code=${code} signal=${signal}`)
+ process.exit(code ?? 1)
+})
+child.on('error', (err) => {
+ console.error('[wrapper] spawn error:', err)
+ process.exit(1)
+})
diff --git a/startscript-cluster2.js b/startscript-cluster2.js
index 20fc5f42..f9905f54 100644
--- a/startscript-cluster2.js
+++ b/startscript-cluster2.js
@@ -1,2 +1,10 @@
-var exec = require('child_process').exec
-exec('yarn start:cluster2', { windowsHide: true })
+const { spawn } = require('child_process')
+const child = spawn('yarn start:cluster2', { stdio: 'inherit', windowsHide: true, shell: true })
+child.on('exit', (code, signal) => {
+ console.log(`[wrapper] yarn start:cluster2 exited code=${code} signal=${signal}`)
+ process.exit(code ?? 1)
+})
+child.on('error', (err) => {
+ console.error('[wrapper] spawn error:', err)
+ process.exit(1)
+})
diff --git a/startscript-dev.js b/startscript-dev.js
index 31300d78..971d9614 100644
--- a/startscript-dev.js
+++ b/startscript-dev.js
@@ -1,2 +1,10 @@
-var exec = require('child_process').exec
-exec('yarn start:dev', { windowsHide: true })
+const { spawn } = require('child_process')
+const child = spawn('yarn start:dev', { stdio: 'inherit', windowsHide: true, shell: true })
+child.on('exit', (code, signal) => {
+ console.log(`[wrapper] yarn start:dev exited code=${code} signal=${signal}`)
+ process.exit(code ?? 1)
+})
+child.on('error', (err) => {
+ console.error('[wrapper] spawn error:', err)
+ process.exit(1)
+})