From b9fb769e74e0710367eeea224b3defdead325522 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 5 Jun 2026 13:43:02 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[=EC=B6=9C=ED=8F=AD]=20=EC=B2=98=EB=A7=88?= =?UTF-8?q?=20=EC=B6=9C=ED=8F=AD=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20hip=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=EB=9D=BC=EC=9D=B8=20=EB=81=9D=EC=A0=90=20?= =?UTF-8?q?=EB=B6=88=EB=B3=80=20=EB=A3=B0=20=EC=A0=81=EC=9A=A9=20=E2=80=94?= =?UTF-8?q?=20CORNER-SHORTCUT/SHRINK-TRIM=20skipInnerLines=20+=2045=C2=B0?= =?UTF-8?q?=20ray-cast=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- src/hooks/roofcover/useEavesGableEdit.js | 275 ++++++++++++++++++++++- src/util/kerab-offset-surgical.js | 10 +- 2 files changed, 276 insertions(+), 9 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index c2e73567..0ae7e904 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -253,7 +253,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 @@ -2219,11 +2357,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)) { @@ -2378,9 +2610,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) } @@ -2792,6 +3024,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] }) @@ -2806,6 +3065,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) => { @@ -2856,6 +3116,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/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) => { From 02062ee4d190e4696c2634c5ae5a1ed9fe40b7cc Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 5 Jun 2026 15:51:46 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[1529]=20=EC=8B=9C=EA=B3=B5=EB=B2=95=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20constTp=20=EB=A7=A4=ED=95=91=20=E2=80=94?= =?UTF-8?q?=20WORK=5FLV=5FID=5F-2(=E6=A8=99=E6=BA=96=E6=96=BD=E5=B7=A5?= =?UTF-8?q?=E2=85=A1)=20=EC=8B=A0=EA=B7=9C=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91=20+=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EB=B0=80=EB=A6=BC=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4 --- .../modal/basic/step/ModuleTabContents.jsx | 62 ++++++++----------- .../floor-plan/modal/basic/step/Trestle.jsx | 34 +++++----- src/hooks/module/useModuleTabContents.js | 11 ++-- 3 files changed, 50 insertions(+), 57 deletions(-) diff --git a/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx b/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx index 9fd7da9a..70f1f1ab 100644 --- a/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx +++ b/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react' import { useMessage } from '@/hooks/useMessage' -import { isObjectNotEmpty } from '@/util/common-utils' import QSelectBox from '@/components/common/select/QSelectBox' import { useModuleTabContents } from '@/hooks/module/useModuleTabContents' import { useRecoilValue } from 'recoil' @@ -170,41 +169,32 @@ export default function ModuleTabContents({ tabIndex, addRoof, setAddedRoofs, ro
- - - - - + {/* 2026-06-05 공법 버튼을 배열 인덱스 대신 constTp로 매핑 — API 응답 항목 추가/순서변경(예: 標準施工(Ⅱ) WORK_LV_ID_-2) 시 인덱스 밀림 방지. 운영(구 API)엔 -2가 없어 optional 은 데이터 있을 때만 렌더 */} + {[ + { constTp: 'WORK_LV_ID_-1', label: '標準施工(Ⅰ)' }, + { constTp: 'WORK_LV_ID_-2', label: '標準施工(Ⅱ)', optional: true }, + { constTp: 'WORK_LV_ID_1', label: '標準施工' }, + { constTp: 'WORK_LV_ID_3', label: '強化施工' }, + { constTp: 'WORK_LV_ID_4', label: '多設施工' }, + { constTp: 'WORK_LV_ID_5', label: '多設施工(II)' }, + ].map(({ constTp, label, optional }) => { + const index = constructionList.findIndex((c) => c.constTp === constTp) + const item = index > -1 ? constructionList[index] : null + if (optional && !item) return null + const enabled = item && item.constPossYn === 'Y' + return ( + + ) + })}
diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index dd2e64ca..98a4c4ee 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -217,7 +217,7 @@ const Trestle = forwardRef((props, ref) => { }, [constructionList, autoSelectStep]) const getConstructionState = (index) => { - if (constructionList && constructionList.length > 0) { + if (index > -1 && constructionList && constructionList.length > 0 && constructionList[index]) { if (constructionList[index].constPossYn === 'Y') { if (trestleState && trestleState.constTp === constructionList[index].constTp) { return 'blue' @@ -865,21 +865,23 @@ const Trestle = forwardRef((props, ref) => {
- - - - - + {/* 2026-06-05 공법 버튼을 배열 인덱스 대신 constTp로 매핑 — API 응답 항목 추가/순서변경(예: 標準施工(II) WORK_LV_ID_-2) 시 인덱스 밀림 방지. 운영(구 API)엔 -2가 없어 optional 은 데이터 있을 때만 렌더 */} + {[ + { constTp: 'WORK_LV_ID_-1', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(I)` }, + { constTp: 'WORK_LV_ID_-2', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(II)`, optional: true }, + { constTp: 'WORK_LV_ID_1', label: getMessage('modal.module.basic.setting.module.standard.construction') }, + { constTp: 'WORK_LV_ID_3', label: getMessage('modal.module.basic.setting.module.enforce.construction') }, + { constTp: 'WORK_LV_ID_4', label: getMessage('modal.module.basic.setting.module.multiple.construction') }, + { constTp: 'WORK_LV_ID_5', label: `${getMessage('modal.module.basic.setting.module.multiple.construction')}(II)` }, + ].map(({ constTp, label, optional }) => { + const index = constructionList.findIndex((c) => c.constTp === constTp) + if (optional && index === -1) return null + return ( + + ) + })}
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') }) } } From 8c2edf05e78c5966711bd646f21f9d88186b058e Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 5 Jun 2026 17:53:22 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[2272]=20=ED=95=9C=EC=AA=BD=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=B2=98=EB=A7=88=20offset=20=EB=B0=98=EC=A0=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=E2=80=94=20margin/padding=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EB=A9=B4=EC=A0=81=EB=B9=84=EA=B5=90?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 골짜기(凹凸) 형상에서 offset=0 변(케라바·shed) 코너가 원본 경계에 남고 inPolygon 의 경계점 nudge 비대칭으로 inside 오판 → padding 오선택 → 처마 반전. winding·골짜기 수에 무관한 shoelace 면적 절대값 비교로 판정 교체. Co-Authored-By: Claude Opus 4 --- src/hooks/useMode.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 From 5e557708f15246a9d975232382f0cc83f025c151 Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 8 Jun 2026 11:27:34 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[server]=20PM2=20wrapper=20exec=E2=86=92spa?= =?UTF-8?q?wn=20=ED=8C=A8=EC=B9=98=20=E2=80=94=20stdio:inherit=20+=20exit/?= =?UTF-8?q?error=20=EA=B0=90=EC=A7=80=20(=EC=9A=B4=EC=98=81=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=8F=99=EA=B8=B0=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- startscript-cluster1.js | 12 ++++++++++-- startscript-cluster2.js | 12 ++++++++++-- startscript-dev.js | 12 ++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) 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) +}) From dc7a2ea95687b9ca50616cb63429c47bf467483e Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 8 Jun 2026 15:27:22 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[2243]=20=E9=85=8D=E7=BD=AE=E9=9D=A2=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=B9=98=EC=88=98=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EB=B3=80=EC=A7=88=20=EC=88=98=EC=A0=95=20=E2=80=94=20planeSize?= =?UTF-8?q?=202=EC=88=9C=EC=9C=84/2=EB=8B=A8=EA=B3=84=20=C2=B120mm=20?= =?UTF-8?q?=ED=9D=A1=EC=B0=A9=20=EC=A0=9C=EA=B1=B0,=20=EB=81=9D=EC=A0=90?= =?UTF-8?q?=20=EA=B3=B5=EC=9C=A0=EB=B3=80=EB=A7=8C=20=EC=83=81=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4 --- src/hooks/surface/usePlacementShapeDrawing.js | 100 +++--------------- 1 file changed, 14 insertions(+), 86 deletions(-) diff --git a/src/hooks/surface/usePlacementShapeDrawing.js b/src/hooks/surface/usePlacementShapeDrawing.js index efb233be..e6b05c79 100644 --- a/src/hooks/surface/usePlacementShapeDrawing.js +++ b/src/hooks/surface/usePlacementShapeDrawing.js @@ -305,25 +305,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) { @@ -334,7 +334,7 @@ export function usePlacementShapeDrawing(id) { ex2 = edge.x2, ey2 = edge.y2 - // 1순위: 양 끝점이 정확히 일치 + // 양 끝점이 정확히 일치(흡착 공유변)할 때만 상속 const forwardMatch = Math.abs(x1 - ex1) < tolerance && Math.abs(y1 - ey1) < tolerance && @@ -351,91 +351,27 @@ 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 @@ -444,7 +380,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 = @@ -453,14 +389,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 From 0980f6b0cec31e04b6910d912f97f4c407728e0e Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 8 Jun 2026 15:50:46 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[2207]=20=EC=9D=B4=EB=8F=99/=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EB=AA=A8=EB=8B=AC=20=E4=BF=9D=E5=AD=98=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=AC=B8=EA=B5=AC=20=E2=86=92=20=E7=A7=BB=E5=8B=95?= =?UTF-8?q?/=E3=82=B3=E3=83=94=E3=83=BC=20=E2=80=94=20PanelEdit=20type=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20+=20i18n=20=ED=82=A4=20=EC=8B=A0=EA=B7=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/floor-plan/modal/module/PanelEdit.jsx | 6 +++++- src/locales/ja.json | 2 ++ src/locales/ko.json | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/floor-plan/modal/module/PanelEdit.jsx b/src/components/floor-plan/modal/module/PanelEdit.jsx index baf4b101..dd2a1d2a 100644 --- a/src/components/floor-plan/modal/module/PanelEdit.jsx +++ b/src/components/floor-plan/modal/module/PanelEdit.jsx @@ -157,7 +157,11 @@ export default function PanelEdit(props) {
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": "흐름 방향 변경", From cc9aa238f24d468082bc7d3cf6c1755781fd9c12 Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 8 Jun 2026 16:01:34 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[2250]=20=EB=B0=B0=EC=B9=98=EB=A9=B4=20?= =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=82=BA=E5=A4=89=E6=9B=B4=20=ED=9B=84=20?= =?UTF-8?q?=EC=B9=98=EC=88=98=20=EB=9D=BC=EB=B2=A8=20stale=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=E2=80=94=20planeSize=20=EC=9E=AC=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20+=20=EB=9D=BC=EB=B2=A8=20=EC=9E=AC=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/surface/useSurfaceShapeBatch.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/hooks/surface/useSurfaceShapeBatch.js b/src/hooks/surface/useSurfaceShapeBatch.js index d6cb2df7..e729d10b 100644 --- a/src/hooks/surface/useSurfaceShapeBatch.js +++ b/src/hooks/surface/useSurfaceShapeBatch.js @@ -1155,6 +1155,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { drawDirectionArrow(newPolygon) newPolygon.setCoords() changeSurfaceLineType(newPolygon) + // [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, ) From 98b9ff8da0480dfee05972fd33acaebcabbdcd62 Mon Sep 17 00:00:00 2001 From: ysCha Date: Mon, 8 Jun 2026 16:48:30 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[2209]=20PCS=20Single-P=20=EB=B9=84?= =?UTF-8?q?=EB=B3=91=EC=84=A4=20=EB=B3=B5=EC=88=98=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=E2=80=94=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20selectedModels=20?= =?UTF-8?q?=EB=A6=AC=EC=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/circuitTrestle/step/PowerConditionalSelect.jsx | 5 +++++ 1 file changed, 5 insertions(+) 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 {