From b5d4f74158c25ba857adbaebdbec7426ed409f17 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 16 Jun 2026 15:07:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[2294=5F4]=20=EC=BC=80=EB=9D=BC=EB=B0=94=20?= =?UTF-8?q?=EA=B3=A8=EC=A7=9C=EA=B8=B0=20=EB=B3=80=E2=86=92=EC=B2=98?= =?UTF-8?q?=EB=A7=88=20Rule1=20=E2=80=94=20=EA=B2=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EB=B2=BD=20=EC=A0=9C=EA=B1=B0+=EA=B3=A8=EC=A7=9C=EA=B8=B0=20?= =?UTF-8?q?=ED=9E=99=20=EC=83=9D=EC=84=B1+=EB=A7=88=EB=A3=A8=20=EC=A0=88?= =?UTF-8?q?=EC=82=AD/=ED=99=95=EC=9E=A5/Y=EB=B0=98=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切妻(게이블) 골짜기 변을 처마(eaves)로 전환 시: - collinear 게이블벽(HIP) 제거 후 出幅(offset) surgical 반영 - 양 코너 45° 골짜기 힙 2개 생성, 첫 교차까지 raycast - cleanupDangling: HIP dangler 만 제거, 마루(RIDGE)·VALLEY 절대 보존 - anchor 잃은 마루 끝점: 가로지르는 힙 있으면 절삭, 없으면 roofLine까지 확장 - 확장 마루가 힙 몸통 닿으면 힙 먼 끝 절삭(힙은 마루를 못 넘음) - 새 힙이 기존 마루에 직격(hitKind===RIDGE)이면 반대편 Y 반사 힙 생성 Co-Authored-By: Claude Opus 4 --- src/hooks/roofcover/useEavesGableEdit.js | 410 +++++++++++++++++++++++ 1 file changed, 410 insertions(+) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index d76900cc..35221da3 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -2769,6 +2769,416 @@ export function useEavesGableEdit(id) { logger.log('[KERAB-REVERT] applied ' + JSON.stringify({ ok, c1, c2, apex: ridgeAtMid.apex })) if (ok) return } + // [KERAB-VALLEY-EAVES-RULE1 2026-06-16] A/B타입 native 케라바 변(스냅샷·native마루·kLine 전부 없음)의 + // 첫 처마 변환 "씨앗". 양 코너에서 폴리곤 안쪽 45° 힙 2개를 그려 각자 첫 교차(다른 inner line 또는 + // 상대편 roofLine)까지 뻗는다. R3 절삭만 — 반사·힙 사이 마루 생성은 보류(다각형 넓이 의존, 추후 결정). + // 出幅은 일반 라인변경과 동일하게 기존 surgical 로 처리. 게이트: 변 위에 collinear 게이블벽 inner line + // 이 실재할 때만(= 진짜 케라바 기하). 없으면 아래 attr-only 폴백. + { + const EDGE_TOL = 0.5 + const NP = roof.points.length + let edgeIdx = -1 + for (let i = 0; i < NP; i++) { + const p = roof.points[i] + const q = roof.points[(i + 1) % NP] + if ( + (Math.hypot(p.x - c1.x, p.y - c1.y) < EDGE_TOL && Math.hypot(q.x - c2.x, q.y - c2.y) < EDGE_TOL) || + (Math.hypot(p.x - c2.x, p.y - c2.y) < EDGE_TOL && Math.hypot(q.x - c1.x, q.y - c1.y) < EDGE_TOL) + ) { + edgeIdx = i + break + } + } + // 변환되는 게이블 변의 "게이블벽" 전체를 제거한다(사용자 확정 2026-06-16). + // 게이블벽 = 변 c1↔c2(roofLine쪽) 또는 옛 出幅쪽 outerLine 과 같은 무한직선 위의 HIP 세그먼트. + // 변이 게이블일 때만 존재한 구조 → eaves 가 되면 소멸. 한 직선이 ridge 에 의해 여러 세그먼트로 + // 쪼개져 변 구간 밖(아래)까지 뻗기도 한다(예: H-9 = 변 아래 연장). 따라서 변 구간 겹침이 아니라 + // "무한직선 동일선상(양 끝점 수직거리<1)" 전체를 게이블벽으로 본다 — i5(변 위) + i10(변 아래) 모두. + // 이 전체 제거가 ridge(RG-2)의 anchor 를 끊어, 뒤의 dangling 정리에서 RG-2 까지 연쇄 제거된다. + const isCollinearWith = (l, A, B) => { + const dx = B.x - A.x + const dy = B.y - A.y + const lenSq = dx * dx + dy * dy || 1 + const perp = (px, py) => { + const t = ((px - A.x) * dx + (py - A.y) * dy) / lenSq + return Math.hypot(px - (A.x + t * dx), py - (A.y + t * dy)) + } + return perp(l.x1, l.y1) < 1.0 && perp(l.x2, l.y2) < 1.0 + } + // 옛 出幅쪽 수직벽 식별용 — surgical 전 target outerLine 끝점(=出幅 위치) 캡처. + const tA = { x: target.x1, y: target.y1 } + const tB = { x: target.x2, y: target.y2 } + // 게이블벽(HIP)만 직접 제거. native 마루(RIDGE)는 여기서 보존하되, anchor 가 끊겨 dangling 이 + // 되는 ridge(RG-2)는 뒤의 cleanupDangling 이 연쇄 제거한다(사용자 확정 2026-06-16). + const gableWallLines = (roof.innerLines || []).filter( + (l) => + l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.HIP && (isCollinearWith(l, c1, c2) || isCollinearWith(l, tA, tB)), + ) + if (edgeIdx >= 0 && gableWallLines.length > 0) { + target.set({ attributes }) + // 1) 옛 게이블벽 라인 제거 + 짝 확장 helper(extensionLine) 동반 제거. + for (const gw of gableWallLines) { + removeLine(gw) + removeOrphanExtensionsForHip(gw) + } + roof.innerLines = roof.innerLines.filter((l) => !gableWallLines.includes(l)) + // 2) 出幅 반영 (일반 라인변경과 동일 — 기존 surgical, inner line 불변). + applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true }) + // 3) 새 roofLine 코너 (인덱스는 surgical 후에도 안정). + const pts = roof.points + const nA = pts[edgeIdx] + const nB = pts[(edgeIdx + 1) % NP] + const newC1IsA = Math.hypot(nA.x - c1.x, nA.y - c1.y) <= Math.hypot(nA.x - c2.x, nA.y - c2.y) + const idxA = newC1IsA ? edgeIdx : (edgeIdx + 1) % NP + const idxB = newC1IsA ? (edgeIdx + 1) % NP : edgeIdx + const newC1 = pts[idxA] + const newC2 = pts[idxB] + // 폴리곤 중심 (inward 판정용). + let cenX = 0 + let cenY = 0 + for (const p of pts) { + cenX += p.x + cenY += p.y + } + cenX /= pts.length || 1 + cenY /= pts.length || 1 + // 코너의 두 이웃 변 방향 → 폴리곤 안쪽 45° 이등분선 (convex/reflex 모두 안쪽으로 보정). + const inwardDir = (idx) => { + const C = pts[idx] + const prev = pts[(idx - 1 + NP) % NP] + const next = pts[(idx + 1) % NP] + const n1 = Math.hypot(prev.x - C.x, prev.y - C.y) || 1 + const n2 = Math.hypot(next.x - C.x, next.y - C.y) || 1 + let dx = (prev.x - C.x) / n1 + (next.x - C.x) / n2 + let dy = (prev.y - C.y) / n1 + (next.y - C.y) / n2 + const dl = Math.hypot(dx, dy) + if (dl < 1e-6) { + // 일직선(180°) — 변 수직 안쪽 법선. + dx = -(next.y - C.y) / n2 + dy = (next.x - C.x) / n2 + } else { + dx /= dl + dy /= dl + } + if (dx * (cenX - C.x) + dy * (cenY - C.y) < 0) { + dx = -dx + dy = -dy + } + return { x: dx, y: dy } + } + // corner 에서 dir 로 ray → 첫 교차(roofLine 변 + 기존 inner line), 자기 변(t≈0) 제외. + const rayFirstHit = (P, dir) => { + const segHit = (A, B) => { + const ex = B.x - A.x + const ey = B.y - A.y + const den = dir.x * ey - dir.y * ex + if (Math.abs(den) < 1e-9) return Infinity + const t = ((A.x - P.x) * ey - (A.y - P.y) * ex) / den + const u = ((A.x - P.x) * dir.y - (A.y - P.y) * dir.x) / den + if (t > 1.0 && u >= -1e-6 && u <= 1 + 1e-6) return t + return Infinity + } + let best = Infinity + let hit = null + let hitLine = null + for (let i = 0; i < NP; i++) { + const t = segHit(pts[i], pts[(i + 1) % NP]) + if (t < best) { + best = t + hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: null, kind: 'roofLine' } + hitLine = null + } + } + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const t = segHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (t < best) { + best = t + hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: il, kind: il.name } + hitLine = il + } + } + return hit + } + const mkValleyHip = (corner, hit) => { + const arr = [corner.x, corner.y, hit.x, hit.y] + const sz = calcLinePlaneSize({ x1: arr[0], y1: arr[1], x2: arr[2], y2: arr[3] }) + const hip = new QLine(arr, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.HIP, + textMode: roof.textMode, + attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.HIP, planeSize: sz, actualSize: sz }, + }) + hip.lineName = 'kerabPatternHip' + hip.startPoint = { x: hip.x1, y: hip.y1 } + hip.endPoint = { x: hip.x2, y: hip.y2 } + return hip + } + const addHipFrom = (corner, idx) => { + const dir = inwardDir(idx) + const hit = rayFirstHit(corner, dir) + if (!hit) return { dir, hit: null, hip: null, hitLine: null, hitKind: null } + const h = mkValleyHip(corner, hit) + canvas.add(h) + h.bringToFront() + roof.innerLines.push(h) + return { dir, hit, hip: h, hitLine: hit.line, hitKind: hit.kind } + } + // 임의 방향 ray 로 골짜기 힙 1개 생성(반사 힙용). 첫 교차까지. + const drawHipRay = (from, dir) => { + const hit = rayFirstHit(from, dir) + if (!hit) return null + const h = mkValleyHip(from, hit) + canvas.add(h) + h.bringToFront() + roof.innerLines.push(h) + return { hip: h, hit } + } + // 4) 게이블 구조 잔재 정리 — anchor(게이블벽 HIP) 제거로 dangling 이 된 HIP·확장라인(H-9 등)만 제거. + // ★ 마루(RIDGE)는 절대 제거하지 않는다 — dangling 이어도 보존(사용자 확정 2026-06-16: + // "마루를 지우면 안된다"). RIDGE 는 connectivity 판정에는 포함(다른 HIP 의 접합 대상)하되 + // dangler 후보에서는 제외. VALLEY(골짜기확장)도 vExt 위에서 끝나는 정상 케이스라 제외. + // 새 골짜기 힙 그리기 *전에* 수행해야 raycast 가 깨끗한 기하에 닿아 SHORT-hit 를 피한다. + const cleanupDangling = () => { + const TOL = 2.0 + const rpts = roof.points || [] + const distToSeg = (p, a, b) => { + const ex = b.x - a.x + const ey = b.y - a.y + const l2 = ex * ex + ey * ey || 1 + let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2 + t = Math.max(0, Math.min(1, t)) + return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey)) + } + const minEdgeDist = (pt) => { + let m = Infinity + for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) { + const d = distToSeg(pt, rpts[j], rpts[i]) + if (d < m) m = d + } + return m + } + const removed = [] + for (let iter = 0; iter < 12; iter++) { + const surv = (roof.innerLines || []).filter( + (l) => + l && + l.visible !== false && + (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY), + ) + const ends = [] + for (const l of surv) { + ends.push({ x: l.x1, y: l.y1, line: l }) + ends.push({ x: l.x2, y: l.y2, line: l }) + } + // 마루(RIDGE)·VALLEY 는 dangling 이어도 제거 금지 — HIP 만 제거 후보. + const dangler = surv.find((l) => { + if (l.name !== LINE_TYPE.SUBLINE.HIP) return false + for (const p of [ + { x: l.x1, y: l.y1 }, + { x: l.x2, y: l.y2 }, + ]) { + const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL + const shared = ends.some((e) => e.line !== l && Math.hypot(e.x - p.x, e.y - p.y) < TOL) + if (!onBoundary && !shared) return true + } + return false + }) + if (!dangler) break + removeLine(dangler) + roof.innerLines = roof.innerLines.filter((l) => l !== dangler) + removeOrphanExtensionsForHip(dangler) + removed.push({ n: dangler.name, ln: dangler.lineName || '-', x1: Math.round(dangler.x1), y1: Math.round(dangler.y1), x2: Math.round(dangler.x2), y2: Math.round(dangler.y2) }) + } + return removed + } + const cleaned = cleanupDangling() + const resA = addHipFrom(newC1, idxA) + const resB = addHipFrom(newC2, idxB) + // 5b) 마루(RIDGE) 끝점 상호작용 — anchor 잃은 끝점 해소(사용자 확정 2026-06-16: "RG1(h8) 확장, RG2(b5) 절삭"). + // 규칙: 마루는 절대 삭제 안 함. anchor 잃은(=경계 미접촉 + 다른 선과 미공유) 끝점은 + // · 새 골짜기 힙이 그 마루를 가로지르면 → 교점에서 절삭(trim, 끝점을 교점으로 당김) + // · 가로지르는 힙이 없으면 → 끝점 방향으로 roofLine/다음 라인까지 확장(extend) + const resolveRidgeEndpoints = () => { + const TOL = 2.0 + const rpts = roof.points || [] + const newHips = [resA, resB].map((r) => r && r.hip).filter(Boolean) + const distToSeg = (p, a, b) => { + const ex = b.x - a.x + const ey = b.y - a.y + const l2 = ex * ex + ey * ey || 1 + let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2 + t = Math.max(0, Math.min(1, t)) + return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey)) + } + const minEdgeDist = (pt) => { + let m = Infinity + for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) { + const d = distToSeg(pt, rpts[j], rpts[i]) + if (d < m) m = d + } + return m + } + // 선분 교차(내부 교점만, 끝점 t∈(eps,1-eps)) + const segInt = (p1, p2, p3, p4) => { + const d1x = p2.x - p1.x + const d1y = p2.y - p1.y + const d2x = p4.x - p3.x + const d2y = p4.y - p3.y + const den = d1x * d2y - d1y * d2x + if (Math.abs(den) < 1e-9) return null + const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / den + const u = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / den + if (t <= 1e-6 || t >= 1 - 1e-6 || u <= 1e-6 || u >= 1 - 1e-6) return null + return { x: p1.x + t * d1x, y: p1.y + t * d1y } + } + // 절삭 전용: 마루(rp→rq)는 내부만, 힙(ha→hb)은 끝점 닿음(T자)까지 허용 + const segIntTrim = (rp, rq, ha, hb) => { + const d1x = rq.x - rp.x + const d1y = rq.y - rp.y + const d2x = hb.x - ha.x + const d2y = hb.y - ha.y + const den = d1x * d2y - d1y * d2x + if (Math.abs(den) < 1e-9) return null + const t = ((ha.x - rp.x) * d2y - (ha.y - rp.y) * d2x) / den + const u = ((ha.x - rp.x) * d1y - (ha.y - rp.y) * d1x) / den + if (t <= 1e-6 || t >= 1 - 1e-6) return null // 마루 내부만 + if (u <= 1e-6 || u >= 1 + 1e-3) return null // 힙 끝점 닿음 허용 + return { x: rp.x + t * d1x, y: rp.y + t * d1y } + } + // 힙의 먼 끝(코너=startPoint 반대쪽 endPoint)을 교차점으로 절삭 + 라벨 갱신 + const trimHipFarEnd = (hip, cross) => { + hip.set({ x2: cross.x, y2: cross.y }) + hip.endPoint = { x: cross.x, y: cross.y } + const nsz = calcLinePlaneSize({ x1: hip.x1, y1: hip.y1, x2: cross.x, y2: cross.y }) + hip.attributes = { ...hip.attributes, planeSize: nsz, actualSize: nsz } + hip.setLength?.() + hip.setCoords?.() + hip.addLengthText?.() + } + const ridges = (roof.innerLines || []).filter((l) => l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.RIDGE) + const allEnds = [] + for (const l of (roof.innerLines || []).filter( + (l) => + l && + l.visible !== false && + (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY), + )) { + allEnds.push({ x: l.x1, y: l.y1, line: l }) + allEnds.push({ x: l.x2, y: l.y2, line: l }) + } + const touched = [] + for (const rg of ridges) { + const endsOf = [ + { p: { x: rg.x1, y: rg.y1 }, q: { x: rg.x2, y: rg.y2 }, set: (pt) => rg.set({ x1: pt.x, y1: pt.y }) }, + { p: { x: rg.x2, y: rg.y2 }, q: { x: rg.x1, y: rg.y1 }, set: (pt) => rg.set({ x2: pt.x, y2: pt.y }) }, + ] + for (const { p, q, set } of endsOf) { + const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL + const shared = allEnds.some((e) => e.line !== rg && Math.hypot(e.x - p.x, e.y - p.y) < TOL) + if (onBoundary || shared) continue // anchor 있음 → 그대로 + // (a) 가로지르는 힙 탐색 → 절삭 + let trimPt = null + let trimD = Infinity + for (const h of newHips) { + const ip = segIntTrim(p, q, { x: h.x1, y: h.y1 }, { x: h.x2, y: h.y2 }) + if (!ip) continue + const d = Math.hypot(ip.x - p.x, ip.y - p.y) + if (d < trimD) { + trimD = d + trimPt = ip + } + } + if (trimPt) { + set(trimPt) + touched.push({ act: 'trim', ln: rg.lineName || '-', to: { x: Math.round(trimPt.x), y: Math.round(trimPt.y) } }) + continue + } + // (b) 확장 — q→p 방향으로 첫 교차(roofLine 변 + 다른 inner line, 자기 제외)까지 + const dx = p.x - q.x + const dy = p.y - q.y + const mag = Math.hypot(dx, dy) || 1 + const ux = dx / mag + const uy = dy / mag + const castHit = (A, B) => { + const ip = segInt({ x: q.x, y: q.y }, { x: q.x + ux * 1e5, y: q.y + uy * 1e5 }, A, B) + if (!ip) return Infinity + const t = (ip.x - p.x) * ux + (ip.y - p.y) * uy + return t > 1e-3 ? t : Infinity + } + let best = Infinity + let hit = null + let hitLine = null + for (let i = 0; i < rpts.length; i++) { + const A = rpts[i] + const B = rpts[(i + 1) % rpts.length] + const t = castHit(A, B) + if (t < best) { + best = t + hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) } + hitLine = null // roofLine 변 + } + } + for (const il of roof.innerLines || []) { + if (!il || il === rg || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const t = castHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (t < best) { + best = t + hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) } + hitLine = il + } + } + if (hit) { + set(hit) + touched.push({ act: 'extend', ln: rg.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } }) + // 확장한 마루가 힙 몸통에 닿으면 → 그 힙의 먼 끝(코너 반대쪽)을 교차점으로 절삭(힙은 마루를 넘지 못함) + if (hitLine && hitLine.name === LINE_TYPE.SUBLINE.HIP) { + trimHipFarEnd(hitLine, hit) + touched.push({ act: 'trim-hip', ln: hitLine.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } }) + } + } + } + } + return touched + } + const ridgeOps = resolveRidgeEndpoints() + // 5c) Y 반사 힙 — 새 힙이 *기존 마루(RIDGE)에 직접 부딪힌* 경우만(=raycast hit 가 RIDGE), + // 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사). + // ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16). + const reflectOps = [] + for (const res of [resA, resB]) { + if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue + const M = { x: res.hit.x, y: res.hit.y } + const rg = res.hitLine + const rlen = Math.hypot(rg.x2 - rg.x1, rg.y2 - rg.y1) || 1 + const rx = (rg.x2 - rg.x1) / rlen + const ry = (rg.y2 - rg.y1) / rlen + // 반사 방향 = reflect(역입사) 마루축 기준. w=역입사(코너→M 반대), v_ref=2(w·r̂)r̂ - w + const wx = -res.dir.x + const wy = -res.dir.y + const dot = wx * rx + wy * ry + const refDir = { x: 2 * dot * rx - wx, y: 2 * dot * ry - wy } + const drawn = drawHipRay(M, refDir) + if (drawn) { + reflectOps.push({ from: { x: Math.round(M.x), y: Math.round(M.y) }, to: { x: Math.round(drawn.hit.x), y: Math.round(drawn.hit.y) } }) + } + } + removeKerabHalfLabels(target.id) + hideOriginalLengthText(target.id, false) + canvas.renderAll() + runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap) + logger.log( + '[KERAB-VALLEY-EAVES-RULE1] applied ' + + JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, ridgeOps, reflectOps }), + ) + return + } + } // [2240 KERAB-ATTR-ONLY 2026-05-19] ridge-at-mid 없음 → forward 가 attribute-only 였음 → 동일 대칭으로 속성만 토글 target.set({ attributes }) applyKerabAttributeOnlyPattern() From 488cd540600a47a346673c06907f1b36d98526b2 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 17 Jun 2026 17:39:15 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=ED=91=9C=EC=A4=80=EC=8B=9C=EA=B3=B5II=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../floor-plan/modal/basic/step/ModuleTabContents.jsx | 3 ++- src/components/floor-plan/modal/basic/step/Trestle.jsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx b/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx index 70f1f1ab..24640917 100644 --- a/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx +++ b/src/components/floor-plan/modal/basic/step/ModuleTabContents.jsx @@ -171,8 +171,9 @@ 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: '標準施工(Ⅰ)' }, + // [표시순서변경 2026-06-17] 標準(Ⅱ) 를 標準(Ⅰ) 앞으로 — 사용자 요청 { constTp: 'WORK_LV_ID_-2', label: '標準施工(Ⅱ)', optional: true }, + { constTp: 'WORK_LV_ID_-1', label: '標準施工(Ⅰ)' }, { constTp: 'WORK_LV_ID_1', label: '標準施工' }, { constTp: 'WORK_LV_ID_3', label: '強化施工' }, { constTp: 'WORK_LV_ID_4', label: '多設施工' }, diff --git a/src/components/floor-plan/modal/basic/step/Trestle.jsx b/src/components/floor-plan/modal/basic/step/Trestle.jsx index 98a4c4ee..efe40fce 100644 --- a/src/components/floor-plan/modal/basic/step/Trestle.jsx +++ b/src/components/floor-plan/modal/basic/step/Trestle.jsx @@ -867,8 +867,9 @@ 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)` }, + // [표시순서변경 2026-06-17] 標準(II) 를 標準(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')}(I)` }, { 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') }, From 3ccd2dc9916e7d985a66c2c7955a42ba54bd4525 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 18 Jun 2026 10:33:09 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[token]=20ConvertAPI=20DWG/DXF=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=EC=9D=84=20=EC=84=9C=EB=B2=84=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=EB=A1=9C=20=EC=A0=84=ED=99=98=20=E2=80=94=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20?= =?UTF-8?q?=EB=B9=84=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 5 +++-- .env.localhost | 5 +++-- .env.production | 5 +++-- src/app/api/image/convert/route.js | 36 ++++++++++++++++++++++++++++++ src/hooks/common/useRefFiles.js | 17 +++++++------- 5 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 src/app/api/image/convert/route.js diff --git a/.env.development b/.env.development index 23ace926..f2a2f6f4 100644 --- a/.env.development +++ b/.env.development @@ -10,8 +10,9 @@ SESSION_SECRET="i3iHH1yp2/2SpQSIySQ4bpyc4g0D+zCF9FAn5xUG0+Y=" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_bV5zuYMyyIYFlOb3" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_yAS4QDalL9jgQ7vS" -NEXT_PUBLIC_CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_a0FLEK6M2oTpXInK" -NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?Secret=secret_a0FLEK6M2oTpXInK" +CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png" +CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png" +CONVERTER_API_TOKEN="mcHqQkSZP30vxCRpIWJV62ciBYT887RI" NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="https://q-order-dev.q-cells.jp/eos/login/autoLogin" NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="https://q-musubi-dev.q-cells.jp/qm/login/autoLogin" diff --git a/.env.localhost b/.env.localhost index 7762c655..7b67eab2 100644 --- a/.env.localhost +++ b/.env.localhost @@ -10,8 +10,9 @@ SESSION_SECRET="i3iHH1yp2/2SpQSIySQ4bpyc4g0D+zCF9FAn5xUG0+Y=" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_bV5zuYMyyIYFlOb3" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_yAS4QDalL9jgQ7vS" -NEXT_PUBLIC_CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_a0FLEK6M2oTpXInK" -NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?Secret=secret_a0FLEK6M2oTpXInK" +CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png" +CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png" +CONVERTER_API_TOKEN="mcHqQkSZP30vxCRpIWJV62ciBYT887RI" NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="http://q-order-stg.q-cells.jp:8120/eos/login/autoLogin" NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="http://q-musubi-stg.q-cells.jp:8120/qm/login/autoLogin" diff --git a/.env.production b/.env.production index 3d205afd..03cbbfcf 100644 --- a/.env.production +++ b/.env.production @@ -10,8 +10,9 @@ SESSION_SECRET="i3iHH1yp2/2SpQSIySQ4bpyc4g0D+zCF9FAn5xUG0+Y=" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_bV5zuYMyyIYFlOb3" # NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_yAS4QDalL9jgQ7vS" -NEXT_PUBLIC_CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_a0FLEK6M2oTpXInK" -NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?Secret=secret_a0FLEK6M2oTpXInK" +CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png" +CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png" +CONVERTER_API_TOKEN="mcHqQkSZP30vxCRpIWJV62ciBYT887RI" NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="https://q-order.q-cells.jp/eos/login/autoLogin" NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="https://q-musubi.q-cells.jp/qm/login/autoLogin" diff --git a/src/app/api/image/convert/route.js b/src/app/api/image/convert/route.js new file mode 100644 index 00000000..d5c7115d --- /dev/null +++ b/src/app/api/image/convert/route.js @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server' + +// ConvertAPI 토큰은 서버 전용 env 로만 보관 (NEXT_PUBLIC_ 아님) — 클라이언트 번들에 노출되지 않는다. +const CONVERTER_API_TOKEN = process.env.CONVERTER_API_TOKEN +const CONVERTER_URLS = { + dwg: process.env.CONVERTER_DWG_API_URL, + dxf: process.env.CONVERTER_DXF_API_URL, +} + +export async function POST(req) { + try { + const formData = await req.formData() + const file = formData.get('file') + const type = formData.get('type') + + const convertUrl = CONVERTER_URLS[type] + if (!file || !convertUrl) { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }) + } + + const upstream = new FormData() + upstream.append('file', file, file.name) + + const res = await fetch(convertUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${CONVERTER_API_TOKEN}` }, + body: upstream, + }) + + const data = await res.json() + return NextResponse.json(data, { status: res.status }) + } catch (error) { + console.error('CAD convert proxy error:', error) + return NextResponse.json({ Message: error?.message ?? 'Failed to convert CAD file' }, { status: 500 }) + } +} diff --git a/src/hooks/common/useRefFiles.js b/src/hooks/common/useRefFiles.js index 26bc6455..09e0653e 100644 --- a/src/hooks/common/useRefFiles.js +++ b/src/hooks/common/useRefFiles.js @@ -25,8 +25,6 @@ import { logger } from '@/util/logger' * @returns {object} */ export function useRefFiles() { - const converterDwgUrl = process.env.NEXT_PUBLIC_CONVERTER_DWG_API_URL - const converterDxfUrl = process.env.NEXT_PUBLIC_CONVERTER_DXF_API_URL const { getMessage } = useMessage() const [refImage, setRefImage] = useState(null) const [refFileMethod, setRefFileMethod] = useState('1') @@ -89,9 +87,9 @@ export function useRefFiles() { })) if (file.name.split('.').pop() === 'dwg') { - handleUploadConvertRefFile(file, converterDwgUrl); + handleUploadConvertRefFile(file, 'dwg'); } else if (file.name.split('.').pop() === 'dxf') { - handleUploadConvertRefFile(file, converterDxfUrl); + handleUploadConvertRefFile(file, 'dxf'); } else { if (file && ['image/png', 'image/jpg', 'image/jpeg', 'image/bmp', 'image/gif'].includes(file.type)) { handleUploadImageRefFile(file) @@ -152,7 +150,7 @@ export function useRefFiles() { * 주소로 구글 맵 이미지 다운로드하여 캔버스 배경으로 로드 */ const handleMapImageDown = async () => { - debugger; logger.log('🚀 ~ handleMapImageDown ~ handleMapImageDown:') + logger.log('🚀 ~ handleMapImageDown ~ handleMapImageDown:') if (queryRef.current.value === '' || queryRef.current.value === null) { return } @@ -250,15 +248,16 @@ export function useRefFiles() { /** * RefFile이 캐드 도면 파일일 경우 변환하여 이미지로 저장 * @param {*} file - * @param converterUrl + * @param {'dwg'|'dxf'} cadType */ - const handleUploadConvertRefFile = async (file, converterUrl) => { + const handleUploadConvertRefFile = async (file, cadType) => { try { const formData = new FormData() formData.append('file', file) + formData.append('type', cadType) - /** 캐드 도면 파일 변환 */ - const res = await post({ url: converterUrl, data: formData }) + /** 캐드 도면 변환은 서버 프록시 경유 — ConvertAPI 토큰을 클라이언트에 노출하지 않는다 */ + const res = await post({ url: `${Config().baseUrl}/api/image/convert`, data: formData }) logger.log('🚀 ~ handleUploadConvertRefFile ~ res:', res) // 변환 API 가 정상 변환 대신 에러 본문(Code/Message)을 반환한 경우 그대로 throw [DXF-CONVERT-FAIL 2026-06-15]