dev #874

Merged
ysCha merged 17 commits from dev into prd-deploy 2026-06-01 14:15:03 +09:00
22 changed files with 4654 additions and 1742 deletions

15
.codex/hooks.json Normal file
View File

@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); set -- $CMD; case \"$1\" in grep|egrep|fgrep|rg|ripgrep|find|fd|ack|ag) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.\"}}' || true ;; esac"
}
]
}
]
}
}

View File

@ -34,3 +34,7 @@ S3_PROFILE="dev"
#logging
NEXT_PUBLIC_ENABLE_LOGGING=true
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

View File

@ -30,3 +30,7 @@ AWS_SECRET_ACCESS_KEY="Cw87TjKwnTWRKgORGxYiFU6GUTgu25eUw4eLBNcA"
NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp"
S3_PROFILE="dev"
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

View File

@ -35,3 +35,7 @@ S3_PROFILE="dev"
#logging
# [debug auto-capture 2026-05-06] true 로 켜면 debugCapture 가 debug/*.json/*.log 로 자동 저장.
NEXT_PUBLIC_ENABLE_LOGGING=true
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

View File

@ -33,3 +33,7 @@ S3_PROFILE="prd"
#logging
NEXT_PUBLIC_ENABLE_LOGGING=false
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

6
.gitignore vendored
View File

@ -56,12 +56,16 @@ sl
# [graphify 2026-05-12] graphify 지식 그래프 산출물 (로컬 캐시, post-commit hook 으로 자동 갱신)
graphify-out/
# [playwright-mcp] Playwright MCP 임시 산출물 (스크린샷, 콘솔 로그)
.playwright-mcp/
# [local-only 2026-05-08] 모든 markdown 문서는 git 추적 제외 (로컬 전용)
# 단, 루트 README.md / CLAUDE.md / AGENTS.md, .agents 및 .claude 하위의 md 는 추적 유지
# 단, 루트 README.md / CLAUDE.md / AGENTS.md, docs, .agents 및 .claude 하위의 md 는 추적 유지
*.md
!/README.md
!/CLAUDE.md
!/AGENTS.md
!/docs/**/*.md
!.agents/**/*.md
!.claude/**/*.md

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,166 @@
# PDF 평면도 → Gemini 분석 → 캔버스 반영 구현 계획
> 작성일: 2026-05-18
> 대상 브랜치: `claude/lucid-shaw-91e51f` (dev `f32f9187` 분기)
> 모델: `gemini-3.1-pro-preview` · 좌표 단위: `mm` · 진입 위치: floor-plan 상단 툴바
---
## 1. 기능 요구 사항
1. 기본 프로세스 상에서 특정 버튼 클릭으로 사용자가 PDF 문서를 업로드한다.
2. Gemini API 로 해당 문서의 내용 중 평면도를 분석한다.
3. 분석 내용을 응답으로 전달받고 현재 프로세스(캔버스)로 전달한다.
4. 분석이 성공적으로 마무리되면 업로드한 파일은 폐기 처리한다.
---
## 2. 결정사항
| 항목 | 선택 |
|---|---|
| 워크트리 | 현재 워크트리(`lucid-shaw-91e51f`)에서 진행 |
| 캔버스 반영 형태 | 외곽선 폴리곤 좌표(JSON) |
| 버튼 위치 | floor-plan 상단 툴바 |
| API 호출 위치 | 서버 라우트 (`/api/gemini/floor-plan`) |
| Gemini 모델 | `gemini-3.1-pro-preview` |
| 좌표 단위 | `mm` |
| 툴바 아이콘 | 신규 PDF 아이콘 추가 |
---
## 3. 전체 흐름
```
[툴바 버튼] → [PdfImportModal] → POST /api/gemini/floor-plan (multipart)
Gemini File API 업로드
외곽선 좌표 JSON 응답
Gemini 파일 삭제 (finally)
응답 좌표 → 좌표 검증/정규화 → outerLinePointsState + QLine/QPolygon 생성
```
---
## 4. 핵심 파일 (수정/신규)
| 영역 | 경로 | 변경 |
|---|---|---|
| 서버 라우트 | `src/app/api/gemini/floor-plan/route.js` | **신규** — multipart 수신 + Gemini 호출 + finally 폐기 |
| 환경설정 | `src/config/config.*.js` | `GEMINI_API_KEY`, `GEMINI_MODEL` 추가 |
| 의존성 | `package.json` | `@google/generative-ai` 추가 |
| 메뉴 등록 | `src/store/menuAtom.js` (`menusState`) | `{ type: 'pdf-import', name, icon }` 항목 |
| 툴바 분기 | `src/components/floor-plan/CanvasMenu.jsx` (`onClickNav`) | `case 'pdf-import'` 추가 |
| 업로드 모달 | `src/components/floor-plan/modal/pdfImport/PdfImportModal.jsx` | **신규** — 파일 선택/진행/에러 |
| 캔버스 반영 hook | `src/hooks/pdf-import/usePdfImport.js` | **신규** — 좌표 검증 + 캔버스 반영 |
| 다국어 메시지 | 메시지 키 추가 | `pdf.import.*` (ja/ko) |
| 아이콘 | `public/` 또는 기존 아이콘 디렉토리 | PDF 임포트용 SVG 추가 |
---
## 5. 서버 라우트 설계 (`/api/gemini/floor-plan`)
### 5-1. 입력
- `multipart/form-data`
- 필드:
- `file`: PDF (≤ 20MB, MIME `application/pdf`)
- `unitHint` (옵션): `mm` | `auto`
### 5-2. 처리 단계
1. `formData.get('file')` 로 PDF 수신 → 사이즈/MIME 검증
2. `GoogleGenerativeAI(GEMINI_API_KEY)` 클라이언트 생성
3. **Gemini File API 업로드**`fileUri` 획득
4. 모델 호출 (`gemini-3.1-pro-preview`)
- 프롬프트 핵심:
- 역할: "건축 평면도 외곽선 추출 전문가"
- 출력: JSON only, 단위 mm
- 스키마: `{ outerline: [{x,y}, ...], unit: 'mm', scale?: number, confidence: 0..1, notes?: string }`
- "평면도가 아니면 `{ outerline: [], notes: '...' }` 반환"
- `response_mime_type=application/json`, `response_schema` 강제
5. JSON 파싱 → 좌표 검증
- vertex ≥ 3
- 자기교차 검출 (turf 활용)
- 0 면적 거부
6. **finally** 블록에서 `genai.files.delete(uploadedFile.name)` — 성공/실패 무관 삭제
### 5-3. 출력
- 성공: `{ outerline: [{x,y}, ...], unit: 'mm', confidence }`
- 실패: `{ error: { code, message } }`
### 5-4. 보안 / 운영
- `GEMINI_API_KEY` 는 서버 env 만 사용, 클라이언트 노출 금지
- S3 등 영구 저장소에 절대 저장하지 않음 (요구 4번: 폐기)
- 로깅: `src/util/logger.js` 만 사용, raw 응답은 `NEXT_PUBLIC_ENABLE_LOGGING=true` 일 때만 출력
---
## 6. 클라이언트 흐름
### 6-1. 모달 (`PdfImportModal`)
- 상태머신: `idle → uploading → analyzing → success | error`
- 호출: `fetch('/api/gemini/floor-plan', { method: 'POST', body: formData })`
- 성공: `usePdfImport.applyOuterline(points, unit)` 호출 후 모달 닫기
- 실패: `useSwal` 로 에러 메시지 표시
### 6-2. 캔버스 반영 hook (`usePdfImport`)
- **단위 변환**: `unit === 'mm'` 응답 → canvas px/mm 비율로 변환
- **정규화**: 시계방향 정렬, bounding box 중앙 정렬(translate)
- **기존 외곽선 분기**:
- 존재 시 `useSwal` confirm: "기존 외곽선을 덮어쓰시겠습니까?"
- 빈 캔버스 시 즉시 적용
- **적용**:
- 점들을 `name === 'outerLine'` QLine 객체들로 생성하여 캔버스에 add
- `setOuterLinePoints(points)` (recoil `outerLinePointsState`)
- `addPolygonByLines(lines)` 흐름으로 QPolygon 생성 (기존 `useRoofShapeSetting` 자산 재사용)
- `canvas.renderAll()`
---
## 7. 검증 / 엣지 케이스
| 케이스 | 대응 |
|---|---|
| PDF 가 아닌 파일 | 라우트에서 MIME 거부 |
| 평면도가 아닌 PDF | 모델이 빈 outerline 반환 → 친화적 메시지 |
| 좌표 ≤ 2개 / 자기교차 / 0면적 | 라우트에서 거부 (`SizeSetting` 가드와 일관) |
| 모델 JSON 파싱 실패 | retry 1회 후 실패 메시지 |
| Gemini 호출 실패 | finally 에서 File API 삭제 보장 |
| 캔버스 기존 외곽선 존재 | confirm 후 덮어쓰기 |
| 응답이 거대한 폴리곤(>500 vertex) | DouglasPeucker 단순화 또는 거부 |
| 업로드 파일 크기 초과 | 클라이언트/서버 양쪽 가드 |
| 네트워크 실패 | 모달에 재시도 버튼 + 파일 폐기 보장 |
---
## 8. 컨벤션 / 운영
- Prettier: single quote, **no semicolons**, tabWidth 2, printWidth 150
- 메시지/모달: `useSwal` + `useMessage` (raw `alert()` 금지)
- 다국어: `ja/ko` 양쪽 키 추가 (`pdf.import.*`)
- 경로 alias: `@/...` 우선
- 로깅: `src/util/logger.js` 만 사용
### 커밋 단위
1. `[xxxx] feat(api) — Gemini 평면도 분석 라우트 신설`
2. `[xxxx] feat(floor-plan) — PDF 임포트 모달/툴바 진입`
3. `[xxxx] feat(floor-plan) — 분석 결과 외곽선 캔버스 반영`
4. `[xxxx] chore — 다국어 메시지/lint 정리`
마지막에 lint/type/build subagent 점검.
---
## 9. 작업 순서 (To-Do)
1. `@google/generative-ai` 의존성 추가, `GEMINI_API_KEY`/`GEMINI_MODEL` env·config 매핑
2. 서버 라우트 `src/app/api/gemini/floor-plan/route.js` 작성 (Gemini 호출 + 파일 폐기)
3. `menusState``pdf-import` 항목 + PDF 아이콘 SVG 추가
4. `CanvasMenu.jsx` `onClickNav` switch 에 케이스 추가, `modalAtom` 으로 모달 오픈
5. `PdfImportModal.jsx` 작성 (idle→uploading→analyzing→success/error 상태머신)
6. `usePdfImport.js` 작성 (단위/방향 정규화, 외곽선 덮어쓰기 분기, QLine/QPolygon 반영)
7. 다국어 메시지 키 추가 (ja/ko)
8. `yarn lint` + dev에서 실제 PDF 한두 건 manual test
9. 의미 단위로 커밋

View File

@ -14,11 +14,13 @@
"start:cluster1": "env-cmd -f .env.production next start -p 5000",
"start:cluster2": "env-cmd -f .env.production next start -p 5001",
"start:dev": "env-cmd -f .env.development next start -p 5010",
"start:btob": "env-cmd -f .env.development next start -p 5060",
"lint": "next lint",
"serve": "node server.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.772.0",
"@google/generative-ai": "^0.24.1",
"ag-grid-react": "^32.0.2",
"axios": "^1.7.8",
"big.js": "^6.2.2",

View File

@ -0,0 +1,210 @@
import { NextResponse } from 'next/server'
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai'
import { GoogleAIFileManager, FileState } from '@google/generative-ai/server'
import { logger } from '@/util/logger'
const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB
const MAX_VERTICES = 500
const DEFAULT_MODEL = 'gemini-3.1-pro-preview'
const FLOOR_PLAN_SCHEMA = {
type: SchemaType.OBJECT,
properties: {
outerline: {
type: SchemaType.ARRAY,
items: {
type: SchemaType.OBJECT,
properties: {
x: { type: SchemaType.NUMBER },
y: { type: SchemaType.NUMBER },
},
required: ['x', 'y'],
},
},
unit: { type: SchemaType.STRING },
scale: { type: SchemaType.NUMBER },
confidence: { type: SchemaType.NUMBER },
notes: { type: SchemaType.STRING },
},
required: ['outerline', 'unit'],
}
const BASE_PROMPT = `당신은 건축 평면도(주택 도면)의 외곽선 추출 전문가입니다.
첨부된 PDF에서 건물의 외곽(외벽선) 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요.
규칙:
1. 모든 좌표는 밀리미터(mm) 단위의 실측 치수로 환산해야 합니다. 도면의 축척(scale) 식별하여 적용하세요.
2. 좌표계는 도면의 좌상단을 원점으로 하고, x 우측 양수, y 하단 양수로 합니다.
3. 외곽선은 시계 반대 방향(CCW) 으로 정렬된 정점 배열로 반환합니다.
4. 자기교차가 없는 단순 폴리곤이어야 하며, 정점은 3 이상이어야 합니다.
5. PDF 평면도가 아니거나 외곽을 추출할 없으면 outerline 배열로 반환하고 notes 사유를 적습니다.
6. 응답은 반드시 단일 JSON 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요.
7. 좌표는 정수 또는 소수 첫째 자리까지 허용합니다.
스키마:
{
"outerline": [{"x": number, "y": number}, ...],
"unit": "mm",
"scale": number (선택, 도면 1mm 실제 mm),
"confidence": number (0~1),
"notes": string (선택)
}`
const buildPrompt = ({ pageMode, facadePages, floorPages } = {}) => {
if (pageMode === 'specify' && (facadePages || floorPages)) {
const hints = []
if (floorPages) hints.push(`평면도(平面図) 페이지: ${floorPages}`)
if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`)
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 외곽선은 위에서 지정한 평면도 페이지 기준으로 추출하세요.`
}
return BASE_PROMPT
}
const validateOuterline = (outerline) => {
if (!Array.isArray(outerline)) {
return { ok: false, code: 'INVALID_FORMAT', message: 'outerline 이 배열이 아닙니다.' }
}
if (outerline.length === 0) {
return { ok: true, empty: true }
}
if (outerline.length < 3) {
return { ok: false, code: 'TOO_FEW_VERTICES', message: '정점이 3개 미만입니다.' }
}
if (outerline.length > MAX_VERTICES) {
return { ok: false, code: 'TOO_MANY_VERTICES', message: `정점이 ${MAX_VERTICES} 개를 초과합니다.` }
}
for (const p of outerline) {
if (typeof p?.x !== 'number' || typeof p?.y !== 'number' || !Number.isFinite(p.x) || !Number.isFinite(p.y)) {
return { ok: false, code: 'INVALID_VERTEX', message: '정점에 숫자가 아닌 좌표가 포함되어 있습니다.' }
}
}
// 면적 0 거부 (signed area)
let area = 0
for (let i = 0; i < outerline.length; i++) {
const cur = outerline[i]
const next = outerline[(i + 1) % outerline.length]
area += cur.x * next.y - next.x * cur.y
}
if (Math.abs(area) < 1) {
return { ok: false, code: 'ZERO_AREA', message: '폴리곤 면적이 0 입니다.' }
}
return { ok: true, empty: false }
}
const waitForFileActive = async (fileManager, fileName, { timeoutMs = 60000, intervalMs = 1500 } = {}) => {
const started = Date.now()
let file = await fileManager.getFile(fileName)
while (file.state === FileState.PROCESSING) {
if (Date.now() - started > timeoutMs) {
throw new Error('Gemini 파일 처리 타임아웃')
}
await new Promise((r) => setTimeout(r, intervalMs))
file = await fileManager.getFile(fileName)
}
if (file.state !== FileState.ACTIVE) {
throw new Error(`Gemini 파일 처리 실패: ${file.state}`)
}
return file
}
export async function POST(req) {
const apiKey = process.env.GEMINI_API_KEY
const modelName = process.env.GEMINI_MODEL || DEFAULT_MODEL
if (!apiKey) {
return NextResponse.json({ error: { code: 'NO_API_KEY', message: 'GEMINI_API_KEY 가 설정되지 않았습니다.' } }, { status: 500 })
}
let uploadedFileName = null
const fileManager = new GoogleAIFileManager(apiKey)
try {
const formData = await req.formData()
const file = formData.get('file')
if (!file || typeof file === 'string') {
return NextResponse.json({ error: { code: 'NO_FILE', message: '파일이 전달되지 않았습니다.' } }, { status: 400 })
}
if (file.type !== 'application/pdf') {
return NextResponse.json({ error: { code: 'INVALID_MIME', message: 'PDF 파일만 업로드할 수 있습니다.' } }, { status: 400 })
}
if (file.size > MAX_FILE_BYTES) {
return NextResponse.json(
{ error: { code: 'FILE_TOO_LARGE', message: `파일 크기가 ${Math.round(MAX_FILE_BYTES / 1024 / 1024)}MB 를 초과합니다.` } },
{ status: 400 },
)
}
const pageMode = formData.get('pageMode') || 'all'
const facadePages = formData.get('facadePages') || ''
const floorPages = formData.get('floorPages') || ''
const buffer = Buffer.from(await file.arrayBuffer())
const uploadResult = await fileManager.uploadFile(buffer, {
mimeType: 'application/pdf',
displayName: file.name || 'floor-plan.pdf',
})
uploadedFileName = uploadResult.file.name
await waitForFileActive(fileManager, uploadedFileName)
const genAI = new GoogleGenerativeAI(apiKey)
const model = genAI.getGenerativeModel({
model: modelName,
generationConfig: {
responseMimeType: 'application/json',
responseSchema: FLOOR_PLAN_SCHEMA,
temperature: 0.1,
},
})
const prompt = buildPrompt({ pageMode, facadePages, floorPages })
const result = await model.generateContent([
{
fileData: {
fileUri: uploadResult.file.uri,
mimeType: uploadResult.file.mimeType,
},
},
{ text: prompt },
])
const raw = result.response.text()
logger.debug('[gemini/floor-plan] raw response', raw)
let parsed
try {
parsed = JSON.parse(raw)
} catch (e) {
return NextResponse.json({ error: { code: 'PARSE_FAILED', message: '응답 JSON 파싱 실패' } }, { status: 502 })
}
const validation = validateOuterline(parsed.outerline)
if (!validation.ok) {
return NextResponse.json({ error: { code: validation.code, message: validation.message } }, { status: 422 })
}
return NextResponse.json({
outerline: parsed.outerline,
unit: parsed.unit || 'mm',
scale: parsed.scale ?? null,
confidence: parsed.confidence ?? null,
notes: parsed.notes ?? null,
empty: validation.empty,
})
} catch (error) {
logger.error('[gemini/floor-plan] error', error?.message || error)
return NextResponse.json({ error: { code: 'GEMINI_FAILED', message: error?.message || 'Gemini 호출에 실패했습니다.' } }, { status: 500 })
} finally {
if (uploadedFileName) {
try {
await fileManager.deleteFile(uploadedFileName)
} catch (deleteError) {
logger.warn('[gemini/floor-plan] deleteFile failed', deleteError?.message || deleteError)
}
}
}
}

View File

@ -48,10 +48,21 @@ function __classifyLineForLabel(obj) {
return null
}
export function reattachDebugLabels(canvas, parentId) {
__attachDebugLabels(canvas, parentId)
}
function __attachDebugLabels(canvas, parentId) {
if (!__isDebugLabelsEnabled()) return
if (!canvas || !parentId) return
// [KERAB-LABEL-REATTACH 2026-05-29] 케라바 토글 등으로 라인이 추가/변경된 후 재호출 가능하도록
// 같은 parentId 의 기존 __debugLabel 먼저 제거 → 카운트 reset 후 새로 부여.
canvas
.getObjects()
.filter((o) => o.name === DEBUG_LABEL_NAME && o.parentId === parentId)
.forEach((o) => canvas.remove(o))
const counters = {}
const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME)

View File

@ -23,12 +23,27 @@ import { useRoofFn } from '@/hooks/common/useRoofFn'
import { usePlan } from '@/hooks/usePlan'
import { normalizeDecimal } from '@/util/input-utils'
import { CalculatorInput } from '@/components/common/input/CalcInput'
import { usePdfImport } from '@/hooks/pdf-import/usePdfImport'
import { logger } from '@/util/logger'
// [LOW-PITCH-WARN 2026-05-06] + alert
import { useSwal } from '@/hooks/useSwal'
import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning'
// [LOW-PITCH-DIAG 2026-05-06 TEMP] import +
import { debugCapture } from '@/util/debugCapture'
const INPUT_MODE = {
SIZE_ROOF: '1',
SIZE_ACTUAL: '2',
PDF: 'pdf',
}
const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024
const PDF_PAGE_MODE = {
ALL: 'all',
SPECIFY: 'specify',
}
/**
* 지붕 레이아웃
*/
@ -76,15 +91,112 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
const { saveCanvas } = usePlan()
// [LOW-PITCH-WARN 2026-05-06]
const { swalFire } = useSwal()
const { applyOuterline } = usePdfImport()
// PDF
const [inputMode, setInputMode] = useState(INPUT_MODE.SIZE_ROOF)
const [pdfFile, setPdfFile] = useState(null)
const [pdfPageMode, setPdfPageMode] = useState(PDF_PAGE_MODE.ALL)
const [pdfFacadePages, setPdfFacadePages] = useState('')
const [pdfFloorPages, setPdfFloorPages] = useState('')
const [pdfAnalyzing, setPdfAnalyzing] = useState(false)
const pdfInputRef = useRef(null)
/**
* 치수 입력방법(복시도입력/실측값입력/육지붕)
* 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드)
*/
const roofSizeSetArray = [
{ id: 'ra01', name: 'roofSizeSet', value: '1', message: 'modal.placement.initial.setting.size.roof' },
{ id: 'ra02', name: 'roofSizeSet', value: '2', message: 'modal.placement.initial.setting.size.actual' },
// { id: 'ra03', name: 'roofSizeSet', value: '3', message: 'modal.placement.initial.setting.size.none.pitch' },
{ id: 'ra01', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ROOF, message: 'modal.placement.initial.setting.size.roof' },
{ id: 'ra02', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ACTUAL, message: 'modal.placement.initial.setting.size.actual' },
// { id: 'ra-pdf', name: 'roofSizeSet', value: INPUT_MODE.PDF, message: 'modal.placement.initial.setting.size.pdf' },
]
const handleInputModeChange = (value) => {
setInputMode(value)
if (value === INPUT_MODE.PDF) {
// PDF (1) .
setCurrentRoof((prev) => ({ ...prev, roofSizeSet: INPUT_MODE.SIZE_ROOF }))
} else {
setCurrentRoof((prev) => ({ ...prev, roofSizeSet: value }))
}
}
const resetPdfFile = () => {
setPdfFile(null)
if (pdfInputRef.current) pdfInputRef.current.value = ''
}
const handlePdfFileChange = (e) => {
const next = e.target.files?.[0]
if (!next) return
if (next.type !== 'application/pdf') {
swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.mime'), type: 'alert' })
e.target.value = ''
return
}
if (next.size > PDF_MAX_FILE_BYTES) {
swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.too.large'), type: 'alert' })
e.target.value = ''
return
}
setPdfFile(next)
}
const analyzePdfAndApply = async () => {
if (!pdfFile) {
swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.no.file'), type: 'alert' })
return false
}
const formData = new FormData()
formData.append('file', pdfFile)
formData.append('unitHint', 'mm')
formData.append('pageMode', pdfPageMode)
if (pdfPageMode === PDF_PAGE_MODE.SPECIFY) {
formData.append('facadePages', pdfFacadePages.trim())
formData.append('floorPages', pdfFloorPages.trim())
}
setPdfAnalyzing(true)
let response
try {
response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData })
} catch (e) {
logger.error('[PlacementShapeSetting] PDF analyze fetch failed', e?.message || e)
setPdfAnalyzing(false)
swalFire({ text: getMessage('pdf.import.error.network'), type: 'alert' })
return false
}
let payload
try {
payload = await response.json()
} catch (e) {
logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e)
setPdfAnalyzing(false)
swalFire({ text: getMessage('pdf.import.error.analyze'), type: 'alert' })
return false
}
setPdfAnalyzing(false)
if (!response.ok) {
const msg = payload?.error?.message || getMessage('pdf.import.error.analyze')
swalFire({ text: msg, type: 'alert' })
return false
}
if (payload.empty || !Array.isArray(payload.outerline) || payload.outerline.length === 0) {
swalFire({ text: payload.notes || getMessage('pdf.import.error.no.floorplan'), type: 'alert' })
return false
}
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' })
if (!applied) {
swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' })
return false
}
return true
}
/**
* 지붕각도 설정(경사/각도)
*/
@ -138,7 +250,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
*/
useEffect(() => {
if (currentCanvasPlan?.planNo && currentRoof) {
setCurrentRoof(prev => ({ ...prev, planNo: currentCanvasPlan.planNo }))
setCurrentRoof((prev) => ({ ...prev, planNo: currentCanvasPlan.planNo }))
}
}, [currentCanvasPlan?.planNo])
@ -238,6 +350,13 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
* 배치면초기설정 저장 버튼 클릭
*/
const handleSaveBtn = async () => {
if (pdfAnalyzing) return
if (inputMode === INPUT_MODE.PDF) {
const ok = await analyzePdfAndApply()
if (!ok) return
}
const roofInfo = {
...currentRoof,
planNo: currentCanvasPlan?.planNo || basicSetting.planNo,
@ -273,16 +392,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
/**
* 배치면초기설정 저장 (메뉴 변경/useEffect 트리거 없이)
*/
basicSettingSave({
...basicSetting,
planNo: currentCanvasPlan?.planNo || basicSetting.planNo,
/**
* 선택된 지붕재 정보
*/
selectedRoofMaterial: {
...newAddedRoofs[0],
basicSettingSave(
{
...basicSetting,
planNo: currentCanvasPlan?.planNo || basicSetting.planNo,
/**
* 선택된 지붕재 정보
*/
selectedRoofMaterial: {
...newAddedRoofs[0],
},
},
}, { skipSideEffects: true })
{ skipSideEffects: true },
)
const roofs = canvas.getObjects().filter((obj) => obj.roofMaterial?.index === 0)
@ -354,15 +476,119 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
id={item.id}
name={item.name}
value={item.value}
checked={String(currentRoof?.roofSizeSet) === item.value}
onChange={(e) => setCurrentRoof({ ...currentRoof, roofSizeSet: e.target.value })}
checked={inputMode === item.value}
onChange={(e) => handleInputModeChange(e.target.value)}
/>
<label htmlFor={item.id}>{getMessage(item.message)}</label>
</div>
))}
</div>
{inputMode === INPUT_MODE.PDF && (
<div
className="pdf-info-text"
style={{ fontSize: '12px', lineHeight: 1.5, color: '#bbb', whiteSpace: 'pre-line', marginTop: '8px' }}
>
{getMessage('modal.placement.initial.setting.size.pdf.info')}
</div>
)}
</td>
</tr>
{inputMode === INPUT_MODE.PDF && (
<>
<tr>
<th>{getMessage('modal.placement.initial.setting.size.pdf.file.label')}</th>
<td>
<div className="img-flex-box" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div className="img-edit-wrap">
<label className={`img-edit-btn ${pdfAnalyzing ? 'no-click' : ''}`} htmlFor="pdf_import_file">
<span className="img-edit"></span>
{getMessage('modal.placement.initial.setting.size.pdf.file.upload')}
</label>
<input
ref={pdfInputRef}
type="file"
id="pdf_import_file"
accept="application/pdf"
style={{ display: 'none' }}
disabled={pdfAnalyzing}
onChange={handlePdfFileChange}
/>
</div>
<div className="img-name-wrap" style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<input
type="text"
className="input-origin al-l"
value={pdfFile?.name ?? ''}
readOnly
placeholder={getMessage('modal.placement.initial.setting.size.pdf.file.placeholder')}
style={{ flex: 1 }}
/>
{pdfFile && !pdfAnalyzing && <button className="img-check" onClick={resetPdfFile}></button>}
</div>
</div>
<div style={{ fontSize: '12px', color: '#bbb', marginTop: '4px' }}>
{getMessage('modal.placement.initial.setting.size.pdf.file.info')}
</div>
</td>
</tr>
<tr>
<th>{getMessage('modal.placement.initial.setting.size.pdf.page.label')}</th>
<td>
<div className="pop-form-radio" style={{ alignItems: 'center', gap: '12px' }}>
<div className="d-check-radio pop">
<input
type="radio"
id="pdf-page-all"
name="pdfPageMode"
value={PDF_PAGE_MODE.ALL}
checked={pdfPageMode === PDF_PAGE_MODE.ALL}
onChange={(e) => setPdfPageMode(e.target.value)}
/>
<label htmlFor="pdf-page-all">{getMessage('modal.placement.initial.setting.size.pdf.page.all')}</label>
</div>
<div className="d-check-radio pop">
<input
type="radio"
id="pdf-page-specify"
name="pdfPageMode"
value={PDF_PAGE_MODE.SPECIFY}
checked={pdfPageMode === PDF_PAGE_MODE.SPECIFY}
onChange={(e) => setPdfPageMode(e.target.value)}
/>
<label htmlFor="pdf-page-specify">{getMessage('modal.placement.initial.setting.size.pdf.page.specify')}</label>
</div>
{pdfPageMode === PDF_PAGE_MODE.SPECIFY && (
<>
<span style={{ fontSize: '13px' }}>{getMessage('modal.placement.initial.setting.size.pdf.page.facade.label')}</span>
<input
type="text"
className="input-origin"
style={{ width: '90px' }}
placeholder={getMessage('modal.placement.initial.setting.size.pdf.page.example.facade')}
value={pdfFacadePages}
onChange={(e) => setPdfFacadePages(e.target.value)}
/>
<span style={{ fontSize: '13px' }}>{getMessage('modal.placement.initial.setting.size.pdf.page.floor.label')}</span>
<input
type="text"
className="input-origin"
style={{ width: '90px' }}
placeholder={getMessage('modal.placement.initial.setting.size.pdf.page.example.floor')}
value={pdfFloorPages}
onChange={(e) => setPdfFloorPages(e.target.value)}
/>
</>
)}
</div>
<div style={{ fontSize: '12px', color: '#bbb', marginTop: '4px' }}>
{pdfPageMode === PDF_PAGE_MODE.SPECIFY
? getMessage('modal.placement.initial.setting.size.pdf.page.specify.info')
: getMessage('modal.placement.initial.setting.size.pdf.page.all.info')}
</div>
</td>
</tr>
</>
)}
<tr>
<th>
<div className="tip-wrap">
@ -630,10 +856,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
</div>
<div className="grid-btn-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '5px' }}>
<button style={{ width: 'auto' }} onClick={() => setUseCalcPad((prev) => !prev)}>
<Image src={useCalcPad ? '/static/images/common/Icon_ON_Black.png' : '/static/images/common/Icon_OFF_Black.png'} alt="toggle" width={34} height={34} />
<Image
src={useCalcPad ? '/static/images/common/Icon_ON_Black.png' : '/static/images/common/Icon_OFF_Black.png'}
alt="toggle"
width={34}
height={34}
/>
</button>
<button className="btn-frame modal act" onClick={handleSaveBtn}>
{getMessage('modal.common.save')}
<button
className={`btn-frame modal act ${pdfAnalyzing || (inputMode === INPUT_MODE.PDF && !pdfFile) ? 'no-click' : ''}`}
onClick={handleSaveBtn}
disabled={pdfAnalyzing || (inputMode === INPUT_MODE.PDF && !pdfFile)}
>
{pdfAnalyzing ? getMessage('pdf.import.button.analyzing') : getMessage('modal.common.save')}
</button>
</div>
</WithDraggable.Body>

View File

@ -0,0 +1,176 @@
import { useSetRecoilState, useRecoilValue } from 'recoil'
import { canvasState } from '@/store/canvasAtom'
import { outerLinePointsState } from '@/store/outerLineAtom'
import { POLYGON_TYPE } from '@/common/common'
import { QLine } from '@/components/fabric/QLine'
import { usePolygon } from '@/hooks/usePolygon'
import { useSwal } from '@/hooks/useSwal'
import { useMessage } from '@/hooks/useMessage'
import { logger } from '@/util/logger'
const MM_PER_CANVAS_UNIT = 10
const MAX_DIM = 1500
const MIN_CANVAS_UNIT_LENGTH = 0.5
const toCanvasUnits = (mmPoints) => mmPoints.map((p) => ({ x: p.x / MM_PER_CANVAS_UNIT, y: p.y / MM_PER_CANVAS_UNIT }))
const polygonArea = (points) => {
let area = 0
for (let i = 0; i < points.length; i++) {
const cur = points[i]
const next = points[(i + 1) % points.length]
area += cur.x * next.y - next.x * cur.y
}
return area
}
const ensureCounterClockwise = (points) => {
// canvas 좌표계 (y 가 아래로 증가) 에서 CCW 는 signed area 가 음수가 된다.
return polygonArea(points) > 0 ? [...points].reverse() : points
}
const fitAndCenter = (points, canvas) => {
const xs = points.map((p) => p.x)
const ys = points.map((p) => p.y)
const minX = Math.min(...xs)
const minY = Math.min(...ys)
const maxX = Math.max(...xs)
const maxY = Math.max(...ys)
const width = maxX - minX
const height = maxY - minY
const targetWidth = (canvas?.getWidth?.() ?? 1000) * 0.6
const targetHeight = (canvas?.getHeight?.() ?? 1000) * 0.6
const maxAllowed = Math.min(MAX_DIM, Math.max(targetWidth, targetHeight))
let scale = 1
if (width > maxAllowed || height > maxAllowed) {
scale = Math.min(maxAllowed / width, maxAllowed / height)
}
const cx = (canvas?.getWidth?.() ?? 1000) / 2
const cy = (canvas?.getHeight?.() ?? 1000) / 2
return points.map((p) => ({
x: cx + (p.x - (minX + width / 2)) * scale,
y: cy + (p.y - (minY + height / 2)) * scale,
}))
}
export function usePdfImport() {
const canvas = useRecoilValue(canvasState)
const setOuterLinePoints = useSetRecoilState(outerLinePointsState)
const { addPolygonByLines } = usePolygon()
const { swalFire } = useSwal()
const { getMessage } = useMessage()
const hasExistingOuterLine = () => {
if (!canvas) return false
return canvas.getObjects().some((obj) => obj.name === 'outerLine' || obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF)
}
const clearExistingCanvasState = () => {
if (!canvas) return
const removable = canvas.getObjects().filter((obj) => {
return (
obj.name === 'outerLine' ||
obj.name === 'outerLinePoint' ||
obj.name === 'startPoint' ||
obj.name === 'helpGuideLine' ||
obj.name === 'lengthText' ||
obj.name === 'pitchText' ||
obj.name === POLYGON_TYPE.WALL ||
obj.name === POLYGON_TYPE.ROOF
)
})
removable.forEach((obj) => canvas.remove(obj))
}
const drawOuterLines = (points) => {
points.forEach((point, idx) => {
if (idx === 0) return
const prev = points[idx - 1]
const line = new QLine([prev.x, prev.y, point.x, point.y], {
stroke: '#000000',
strokeWidth: 3,
idx,
selectable: true,
name: 'outerLine',
})
canvas.add(line)
})
}
const applyOuterline = async (mmPoints, options = {}) => {
if (!canvas) {
logger.warn('[usePdfImport] canvas 가 준비되지 않았습니다.')
return false
}
const { unit = 'mm' } = options
if (unit !== 'mm') {
logger.warn('[usePdfImport] 지원하지 않는 단위:', unit)
swalFire({ text: getMessage('pdf.import.error.unsupported.unit'), type: 'alert' })
return false
}
if (!Array.isArray(mmPoints) || mmPoints.length < 3) {
swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' })
return false
}
let canvasPoints = toCanvasUnits(mmPoints)
canvasPoints = canvasPoints.filter((p, idx, arr) => {
if (idx === 0) return true
const prev = arr[idx - 1]
const dx = p.x - prev.x
const dy = p.y - prev.y
return Math.hypot(dx, dy) >= MIN_CANVAS_UNIT_LENGTH
})
if (canvasPoints.length < 3) {
swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' })
return false
}
canvasPoints = fitAndCenter(canvasPoints, canvas)
canvasPoints = ensureCounterClockwise(canvasPoints)
const proceed = async () => {
clearExistingCanvasState()
const closedPoints = [...canvasPoints, canvasPoints[0]]
setOuterLinePoints(closedPoints)
drawOuterLines(closedPoints)
try {
const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
if (lines.length >= 3) {
addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
}
} catch (e) {
logger.warn('[usePdfImport] wall polygon 생성 실패', e?.message || e)
}
canvas.renderAll()
}
if (hasExistingOuterLine()) {
return await new Promise((resolve) => {
swalFire({
text: getMessage('pdf.import.confirm.overwrite'),
type: 'confirm',
confirmFn: async () => {
await proceed()
resolve(true)
},
denyFn: () => resolve(false),
})
})
}
await proceed()
return true
}
return { applyOuterline, hasExistingOuterLine }
}

View File

@ -13,6 +13,7 @@ import { getChonByDegree } from '@/util/canvas-util'
import { settingModalFirstOptionsState } from '@/store/settingAtom'
import { fabric } from 'fabric'
import { QLine } from '@/components/fabric/QLine'
import { reattachDebugLabels } from '@/components/fabric/QPolygon'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { findInteriorPoint } from '@/util/skeleton-utils'
import { logger } from '@/util/logger'
@ -151,6 +152,11 @@ export function useEavesGableEdit(id) {
}
const mouseDownEvent = (e) => {
// [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시
// discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호.
if (!e.target || e.target.name !== 'outerLine') {
return
}
logger.log(
'[KERAB-MOUSEDOWN] fired ' +
JSON.stringify({
@ -166,9 +172,6 @@ export function useEavesGableEdit(id) {
}),
)
canvas.discardActiveObject()
if (!e.target || (e.target && e.target.name !== 'outerLine')) {
return
}
const target = e.target
@ -240,6 +243,20 @@ export function useEavesGableEdit(id) {
// - radio 1 의 단순 변환에만 적용. JERKINHEAD/HIPANDGABLE 등 width 가 들어가는
// radio 2 변환은 파라미터 갱신 가능성이 있어 그대로 진행.
if (radioTypeRef.current === '1' && target.attributes?.type === attributes?.type) {
// [KERAB-OFFSET-ONLY-RECLICK 2026-06-01] 동일 type 재클릭이지만 出幅(offset) 만 다른 경우,
// surgical 갱신(corner / 인접 inner-line 끝점 snap + attributes.offset 반영) 후 종료.
// 기존 collapse 흐름(applyKerab*Pattern, polygonPath BFS 등) 은 건너뛰어 케라바 패턴 보존.
const oldOffset = target.attributes?.offset ?? 0
const newOffset = attributes?.offset ?? 0
if (Math.abs(newOffset - oldOffset) > 1e-3) {
logger.log(
`[KERAB-OFFSET-ONLY-RECLICK] type=${attributes.type} 동일, offset ${oldOffset}${newOffset} surgical 적용`,
)
applyTargetOffsetSurgical(target, newOffset)
target.set({ attributes })
canvas.renderAll()
return
}
logger.log(`[KERAB-NOOP] 이미 ${attributes.type} → 재변환 무시`)
return
}
@ -277,6 +294,34 @@ export function useEavesGableEdit(id) {
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
logger.log('[KERAB-SIMPLE] roof check ' + JSON.stringify({ roofId: target.attributes?.roofId, roofFound: !!roof }))
// [KERAB-INNERLINES-DUMP 2026-05-27] 토글 진입/종료 시점 innerLines ridge/hip 스냅샷.
// - cascade hide / trim 으로 어떤 라인이 어디서 끊겼는지 추적용 임시 진단 로그.
const dumpInnerLineSnapshot = (label) => {
if (!roof || !Array.isArray(roof.innerLines)) return
const rows = roof.innerLines
.filter((l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE))
.map((l) => ({
lab: l.label || '?',
n: l.name,
ln: l.lineName || '-',
v: l.visible !== false,
x1: Math.round(l.x1 * 10) / 10,
y1: Math.round(l.y1 * 10) / 10,
x2: Math.round(l.x2 * 10) / 10,
y2: Math.round(l.y2 * 10) / 10,
}))
logger.log('[KERAB-INNERLINES-' + label + '] ' + JSON.stringify(rows))
// [KERAB-LABEL-REATTACH 2026-05-29] AFTER 시점에서 라벨 재부착 (local 모드 한정).
// 케라바 토글로 추가/변경된 kerabPatternRidge/ExtRidge/Hip 등에도 H-/RG- 라벨 부여.
if (label === 'AFTER') {
try {
reattachDebugLabels(canvas, roof.id)
} catch (e) {
logger.warn('[KERAB-LABEL-REATTACH] failed', e)
}
}
}
dumpInnerLineSnapshot('BEFORE')
// [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영.
// SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존.
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0)
@ -608,9 +653,9 @@ export function useEavesGableEdit(id) {
const FAR_RAY = 1e5
const start = { x: ep.sx, y: ep.sy }
const rayEnd = { x: ep.sx + ux * FAR_RAY, y: ep.sy + uy * FAR_RAY }
// [KERAB-VALLEY-EXT-ALWAYS-MID 2026-05-27] 사용자 규칙: "골짜기 세로라인이 더 확장되어야 한다."
// RIDGE stopper 제거 — 무조건 polygon-wall 까지 거리의 절반 = polygon width 의 대칭중앙.
// ridge·hip 은 모두 통과 (경로 위 교차 시 trim 단계에서 절삭).
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-27] 새 규칙:
// 1) ridge(마루) 만나면 그 점에서 정지. hip 은 통과.
// 2) ridge 못 만나면 맞은편 polygon-wall(roofLine 너머) 까지 끝까지 확장 (절반 아님).
let bestPt = null
let wallT = Infinity
let wallHit = null
@ -625,12 +670,38 @@ export function useEavesGableEdit(id) {
wallHit = ip
}
}
if (wallHit) {
bestPt = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 }
let ridgeStop = null
let ridgeT = Infinity
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 골짜기 확장라인이 "원래 있던" ridge(마루) 만나면 그 점에서 정지.
// 확장으로 생긴 ridge(kerabPatternRidge/kerabPatternExtRidge 등) 는 stop 대상 아님.
// 화이트리스트: lineName === 'ridge' 만 매칭.
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.lineName !== 'ridge') continue
const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue
if (!isPointOnSegment(ip, il.x1, il.y1, il.x2, il.y2)) continue
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
if (t < 0.5) continue
if (wallT !== Infinity && t > wallT + 0.5) continue
if (t < ridgeT) {
ridgeT = t
ridgeStop = ip
}
}
if (ridgeStop) {
bestPt = ridgeStop
logger.log(
'[KERAB-VALLEY-EXT-MID] pre label=' + ep.label +
' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' +
' mid={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}',
'[KERAB-VALLEY-EXT-RIDGE-STOP] pre label=' + ep.label +
' ridgeT=' + Math.round(ridgeT * 100) / 100 +
' stop={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}',
)
} else if (wallHit) {
bestPt = wallHit
logger.log(
'[KERAB-VALLEY-EXT-WALL] pre label=' + ep.label +
' end={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}',
)
}
if (bestPt) {
@ -1431,6 +1502,13 @@ export function useEavesGableEdit(id) {
// raycast 대상 = 현재 roof.innerLines 중 HIP/RIDGE (= 살아남은 hip·ridge + 새 extLines·kLine 모두 포함).
// 사라진 polygonPath hip/ridge 는 이미 제거되어 자동 제외. valleyExt 자신은 push 후이므로 1mm 필터로 보호.
if (valleyPlannedEndpoints.length) {
// ====================================================================
// [KERAB-VALLEY-EXT 2026-05-28] valleyExt helper 4종 (Step B 추출).
// Phase 1 = computeValleyExtensions + drawValleyExtensions
// Phase 2 = trimByValleyExtensions + cascadeHideByValleyExtensions
// 모든 helper 는 closure 로 roof/target/canvas/roofPolygonWalls/valleyPlannedEndpoints 캡처.
// revert 계약 (lineName='kerabPatternValleyExt', __targetId, target.__valleyExtTrims) 그대로 유지.
// ====================================================================
const isOnSegV = (pt, ax, ay, bx, by, tol = 0.5) => {
const sdx = bx - ax
const sdy = by - ay
@ -1443,216 +1521,97 @@ export function useEavesGableEdit(id) {
const py = ay + tt * sdy
return Math.hypot(px - pt.x, py - pt.y) <= tol
}
const valleyExtensions = []
for (const ep of valleyPlannedEndpoints) {
const start = { x: ep.sx, y: ep.sy }
// [KERAB-VALLEY-EXT 2026-05-27] self-extension 방향만 사용 (양 끝점 둘 다 시도).
// concave corner 측 끝점 → polygon 내부로 향해 hip/ridge hit → 그려짐.
// convex 측 끝점 → polygon 외부로 새서 hit 없음 → 자동 skip.
const dx = ep.sx - ep.ox
const dy = ep.sy - ep.oy
const len = Math.hypot(dx, dy) || 1
const ux = dx / len
const uy = dy / len
const FAR_RAY = 1e5
const rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY }
// [KERAB-VALLEY-EXT-ALWAYS-MID 2026-05-27] 사용자 규칙: "골짜기 세로라인이 더 확장되어야 한다."
// RIDGE stopper 제거 — 무조건 polygon-wall midpoint(대칭중앙)까지.
// 경로 위 교차 hip/ridge 는 trim 단계에서 절삭 (KERAB-VALLEY-EXT-TRIM).
let bestStop = null
let wallT = Infinity
let wallHit = null
for (const w of roofPolygonWalls) {
const ip = lineLineIntersection(start, rayEnd, w.a, w.b)
if (!ip) continue
if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
if (t < 0.5) continue
if (t < wallT) {
wallT = t
wallHit = ip
}
}
if (wallHit) {
bestStop = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 }
logger.log(
'[KERAB-VALLEY-EXT-MID] post label=' + ep.label +
' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' +
' mid={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
)
}
if (bestStop) {
const seg = {
x1: start.x,
y1: start.y,
x2: bestStop.x,
y2: bestStop.y,
source: ep.label,
parent: ep.parent || null,
}
valleyExtensions.push(seg)
logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg))
} else {
logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label)
}
}
// [KERAB-VALLEY-EXT-TRIM 2026-05-27] valleyExt 경로상 교차 hip/ridge 절삭.
// 각 valleyExt segment 에 대해 모든 hip/ridge 와 교차 검사 → 교차하면 valleyExt 시작점에
// 가까운 끝점을 hitPoint 로 단축 (= corner 쪽 부분이 잘리고 너머 쪽이 보존).
// 사용자 규칙: "대칭중앙까지 가는 도중에 힙 또는 마루를 만나면 힙·마루는 절삭한다."
// cascade 도미노는 비활성 (junction 공유 hip 의도 외 절삭 부작용).
// revert 시 trimRecords 역순 복원.
const trimRecords = []
for (const vr of valleyExtensions) {
if (!vr.source || !vr.source.startsWith('roofBase')) continue
const segA = { x: vr.x1, y: vr.y1 }
const segB = { x: vr.x2, y: vr.y2 }
// 사용자 규칙: "조건 다 필요 없고 확장하는 라인 절삭."
// trim 검사 ray = segA(corner) → wallEnd(polygon-wall). segB(대칭중앙) 은 segA-wallEnd 의 중점.
// ray 위에서 만나는 hip/ridge 의 진행방향 쪽 끝점(= u 투영값 큰 쪽) 을 ip 로 단축.
// collinear (valleyExt 와 평행+동일직선) 라인은 별도 처리 — segB 너머 끝점을 segB 로 단축.
const wallEnd = { x: 2 * segB.x - segA.x, y: 2 * segB.y - segA.y }
const dxV = wallEnd.x - segA.x
const dyV = wallEnd.y - segA.y
const lenV = Math.hypot(dxV, dyV) || 1
const ux = dxV / lenV
const uy = dyV / lenV
const tB = (segB.x - segA.x) * ux + (segB.y - segA.y) * uy
const COLLINEAR_TOL = 1.0
for (const il of roof.innerLines || []) {
if (!il) continue
if (il.lineName === 'kerabPatternValleyExt') continue
// 사용자 규칙: "Y 모양일때 hip 있는 쪽은 살리고 마루쪽을 절삭." + "1522 도 삭제."
// trim 대상 = ridge(마루) + 케라바 토글 생성 hip(kerabPatternHip / kerabPatternExtHip).
// 원래 지붕 hip(H-5/H-6/H-7/H-1) 은 보존. hip-presence 룰이 자기 자신 제외하므로 안전.
const isTrimCandidate =
il.name === LINE_TYPE.SUBLINE.RIDGE ||
(il.name === LINE_TYPE.SUBLINE.HIP && (il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip'))
if (!isTrimCandidate) continue
if (il.visible === false) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
let ip = lineLineIntersection(segA, wallEnd, a, b)
if (ip) {
if (!isOnSegV(ip, segA.x, segA.y, wallEnd.x, wallEnd.y)) ip = null
else if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) ip = null
}
if (!ip) {
const perp1 = Math.abs((il.x1 - segA.x) * uy - (il.y1 - segA.y) * ux)
const perp2 = Math.abs((il.x2 - segA.x) * uy - (il.y2 - segA.y) * ux)
if (perp1 > COLLINEAR_TOL || perp2 > COLLINEAR_TOL) continue
const tc1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy
const tc2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy
if (Math.max(tc1, tc2) <= tB + 0.5) continue
ip = { x: segB.x, y: segB.y }
}
// 사용자 규칙: "Y 모양일때 hip 있는 쪽은 살리고 마루(ridge) 쪽을 절삭."
// ridge 의 각 끝점에 *원래 지붕* hip 끝점이 붙어있는지 검사.
// 케라바 토글 생성 hip(kerabPatternHip/ExtHip) 은 골짜기 부속물이므로 hip-presence 카운트 제외.
// hip 있는 쪽 보존, 없는 쪽 단축. 양쪽 다 원래 hip → 통째 보존(continue).
// 양쪽 다 원래 hip 없음 → Y-junction 아닌 골짜기 부속물 → 통째 hide.
const isOriginalHipEndAt = (px, py) => {
for (const other of roof.innerLines || []) {
if (!other || other === il) continue
if (other.lineName === 'kerabPatternValleyExt') continue
if (other.lineName === 'kerabPatternHip' || other.lineName === 'kerabPatternExtHip') continue
if (other.name !== LINE_TYPE.SUBLINE.HIP) continue
if (other.visible === false) continue
if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) return true
if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) return true
}
return false
}
const hip1 = isOriginalHipEndAt(il.x1, il.y1)
const hip2 = isOriginalHipEndAt(il.x2, il.y2)
let trimEnd
if (hip1 && hip2) {
logger.log('[KERAB-VALLEY-EXT-TRIM] both-hip preserve ' + JSON.stringify({ name: il.name, lineName: il.lineName }))
continue
} else if (hip1 && !hip2) {
trimEnd = 2
} else if (!hip1 && hip2) {
trimEnd = 1
} else {
// 양쪽 다 원래 hip 없음 — 진행벡터 fallback (cascade pass 에서 부속물 정리)
const t1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy
const t2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy
trimEnd = t1 > t2 ? 1 : 2
}
const oldX = trimEnd === 1 ? il.x1 : il.x2
const oldY = trimEnd === 1 ? il.y1 : il.y2
trimRecords.push({
line: il,
end: trimEnd,
oldPt: { x: oldX, y: oldY },
newPt: { x: ip.x, y: ip.y },
originalAttrs: { ...(il.attributes || {}) },
})
if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y })
else il.set({ x2: ip.x, y2: ip.y })
if (typeof il.setCoords === 'function') il.setCoords()
const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz }
logger.log(
'[KERAB-VALLEY-EXT-TRIM] trim ' +
JSON.stringify({ name: il.name, lineName: il.lineName, trimEnd, oldPt: { x: oldX, y: oldY }, ip }),
)
}
}
// 사용자 규칙: "원 라인이 절삭되었으면 그 밑으로 확장된 라인도 삭제."
// cascade pass — trim 결과 옛 끝점(oldPt) 에 끝점이 일치하는 케라바산 라인(kerabPatternHip/ExtHip/Ridge) 을 hide.
// 가드: 원래 지붕 ridge/hip(lineName 없거나 kerabPattern* 아닌 것) 은 cascade 대상 외 — junction 공유 의도 외 절삭 방지.
const isKerabSynthetic = (line) => {
if (!line || !line.lineName) return false
return (
line.lineName === 'kerabPatternHip' ||
line.lineName === 'kerabPatternExtHip' ||
line.lineName === 'kerabPatternRidge' ||
line.lineName === 'kerabPatternExtRidge'
)
}
const cascadeHidden = new Set()
// BFS — cascade hide 된 라인의 다른 끝점에 붙은 케라바산 라인도 재귀 hide.
const cascadeQueue = trimRecords.map((r) => r.oldPt).filter(Boolean)
let cascadeGuard = 0
while (cascadeQueue.length && cascadeGuard++ < 200) {
const pt = cascadeQueue.shift()
if (!pt) continue
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (cascadeHidden.has(il)) continue
if (il.lineName === 'kerabPatternValleyExt') continue
if (!isKerabSynthetic(il)) continue
const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0
const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0
if (!m1 && !m2) continue
cascadeHidden.add(il)
trimRecords.push({
line: il,
hide: true,
originalAttrs: { ...(il.attributes || {}) },
originalVisible: il.visible !== false,
})
il.visible = false
if (typeof il.setCoords === 'function') il.setCoords()
logger.log(
'[KERAB-VALLEY-EXT-TRIM] cascade hide ' +
JSON.stringify({ name: il.name, lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
)
// 다른 끝점도 cascade 시드에 추가
cascadeQueue.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 })
}
}
if (trimRecords.length) target.__valleyExtTrims = trimRecords
for (const vr of valleyExtensions) {
// ── Phase 1-a: valleyExt ray 계산 ──
// self-extension 방향만 사용 (양 끝점 둘 다 시도, concave 측만 hit).
// ridge meet first, 못 만나면 wallhit 끝까지. hip 통과.
const computeValleyExtensions = () => {
const exts = []
for (const ep of valleyPlannedEndpoints) {
const start = { x: ep.sx, y: ep.sy }
const dx = ep.sx - ep.ox
const dy = ep.sy - ep.oy
const len = Math.hypot(dx, dy) || 1
const ux = dx / len
const uy = dy / len
const FAR_RAY = 1e5
const rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY }
let bestStop = null
let wallT = Infinity
let wallHit = null
for (const w of roofPolygonWalls) {
const ip = lineLineIntersection(start, rayEnd, w.a, w.b)
if (!ip) continue
if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
if (t < 0.5) continue
if (t < wallT) {
wallT = t
wallHit = ip
}
}
let ridgeStop = null
let ridgeT = Infinity
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (roof 측 post 경로).
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.lineName !== 'ridge') continue
const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
if (t < 0.5) continue
if (wallT !== Infinity && t > wallT + 0.5) continue
if (t < ridgeT) {
ridgeT = t
ridgeStop = ip
}
}
if (ridgeStop) {
bestStop = ridgeStop
logger.log(
'[KERAB-VALLEY-EXT-RIDGE-STOP] post label=' + ep.label +
' ridgeT=' + Math.round(ridgeT * 100) / 100 +
' stop={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
)
} else if (wallHit) {
bestStop = wallHit
logger.log(
'[KERAB-VALLEY-EXT-WALL] post label=' + ep.label +
' end={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
)
}
if (bestStop) {
const seg = {
x1: start.x,
y1: start.y,
x2: bestStop.x,
y2: bestStop.y,
source: ep.label,
parent: ep.parent || null,
}
exts.push(seg)
logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg))
} else {
logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label)
}
}
return exts
}
// ── Phase 1-b: roof + wall QLine 생성 (순수 그리기, trim/hide 없음) ──
const drawValleyExtensions = (valleyExtensions) => {
for (const vr of valleyExtensions) {
const pts = [vr.x1, vr.y1, vr.x2, vr.y2]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
// [KERAB-VALLEY-EXT 2026-05-27] 지붕면 할당 통합 (option a):
// roofBase 확장은 처마의 연장 → attributes.type=EAVES + parentLine + innerLines push (split 참여).
// wallBase 확장은 wall layer (roof 폴리곤 밖) → 시각만, innerLines 비추가, type/parent 부여 안 함.
const isRoofBase = !!(vr.source && vr.source.startsWith('roofBase'))
const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz }
// [KERAB-VALLEY-EXT 2026-05-29] wallLine ID 를 attributes 에 명시 저장.
// apply() 의 wallExt 재계산 RECALC 가 vExt.attributes.wallLine 으로 outerLine 매칭 — 보존 필수.
const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz, wallLine: target.id }
if (isRoofBase) {
// [KERAB-VALLEY-EXT 2026-05-27] splitPolygonWithLines 의 innerLineMapping 은
// EAVES polygonLine 을 skip 하므로(usePolygon.js:822) 처마확장은 자동 매핑이 안 된다.
@ -1680,14 +1639,421 @@ export function useEavesGableEdit(id) {
canvas.add(vExt)
vExt.bringToFront()
if (isRoofBase) roof.innerLines.push(vExt)
// [KERAB-VALLEY-EXT-WALL 2026-05-28] wallBase 레벨 확장 라인 — 독립 ray.
// 사용자 룰: "맞은편 roofLine 까지" — wallBase 측도 자체 시작점에서 ray 쏴서 polygon-wall hit 까지.
// roof 측 끝점과 공유 X (그건 만나라는 의미). roof 측과 동일 방향, ridge-stop 룰 동일.
// wallBase 측은 시각만 — innerLines push X, type=EAVES X, parentLine X.
if (isRoofBase && vr.parent && target) {
const rl = vr.parent
const dxR = rl.x2 - rl.x1
const dyR = rl.y2 - rl.y1
const lenR = Math.hypot(dxR, dyR) || 1
const uxR2 = dxR / lenR
const uyR2 = dyR / lenR
const sT1 = (target.x1 - rl.x1) * uxR2 + (target.y1 - rl.y1) * uyR2
const sT2 = (target.x2 - rl.x1) * uxR2 + (target.y2 - rl.y1) * uyR2
const tStart = sT1 < sT2 ? { x: target.x1, y: target.y1 } : { x: target.x2, y: target.y2 }
const tEnd = sT1 < sT2 ? { x: target.x2, y: target.y2 } : { x: target.x1, y: target.y1 }
const wallCorner = vr.source === 'roofBase-s' ? tStart : tEnd
// 방향: vr 방향 그대로 (vr.x1,y1 → vr.x2,y2)
const vdx = vr.x2 - vr.x1
const vdy = vr.y2 - vr.y1
const vlen = Math.hypot(vdx, vdy) || 1
const wUx = vdx / vlen
const wUy = vdy / vlen
const wStart = { x: wallCorner.x, y: wallCorner.y }
const wRayEnd = { x: wStart.x + wUx * 1e5, y: wStart.y + wUy * 1e5 }
// polygon-wall hit
let wWallT = Infinity
let wWallHit = null
for (const w of roofPolygonWalls) {
const ip = lineLineIntersection(wStart, wRayEnd, w.a, w.b)
if (!ip) continue
if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue
const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy
if (t < 0.5) continue
if (t < wWallT) {
wWallT = t
wWallHit = ip
}
}
// ridge stop (roof 측과 동일 룰)
let wRidgeT = Infinity
let wRidgeStop = null
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (wallBase 측 ray).
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.lineName !== 'ridge') continue
const ip = lineLineIntersection(wStart, wRayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy
if (t < 0.5) continue
if (wWallT !== Infinity && t > wWallT + 0.5) continue
if (t < wRidgeT) {
wRidgeT = t
wRidgeStop = ip
}
}
const wEnd = wRidgeStop || wWallHit
if (wEnd) {
// [KERAB-VALLEY-OVERLAP 2026-05-28] roof 1개에 split 결과 sub-roof 가 나뉜다.
// 라인은 roof.id 로만 생성 → split 후 직사각형 sub-roof 가 분리되어 나옴.
// apply() 후처리에서 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지하여 "겹침" 표현.
const buildOverlapLine = (p1, p2, suffix) => {
const lpts = [p1.x, p1.y, p2.x, p2.y]
const lsz = calcLinePlaneSize({ x1: lpts[0], y1: lpts[1], x2: lpts[2], y2: lpts[3] })
const ln = new QLine(lpts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.VALLEY,
textMode: roof.textMode,
attributes: {
roofId: roof.id,
type: 'kerabValleyOverlapLine',
isStart: true,
pitch: roof.pitch,
planeSize: lsz,
actualSize: lsz,
},
})
ln.lineName = 'kerabValleyOverlapLine'
ln.roofId = roof.id
ln.__targetId = target.id
ln.__valleyExtSource = vr.source + suffix
canvas.add(ln)
ln.bringToFront()
return ln
}
// [KERAB-VALLEY-OVERLAP 2026-05-28] 평행 사각형으로 b 외곽 닫기 + (선택) 그 너머 ray 연장.
// 사용자 룰: "vStart 에서 wLine 까지 평행선 → 그 교점에서부터 wallExt 확장".
// 출발: vStart 의 wallLine(target) 위 수선의 발 = newWStart (90° 보조선). 원래 wStart(target corner) 는 사용 안 함.
// 도착: vEnd 의 동일 offset 평행이동 = wEndProj (90° 보조선, 이전과 동일).
// 사각형 4변: vStart-newWStart, newWStart-wEndProj(wallExt 본체), wEndProj-vEnd, vEnd-vStart(=vExt 이미 그려짐).
// wEnd(원 ray hit) 가 wEndProj 보다 더 멀면 wEndProj→wEnd 확장 라인 추가.
const vStart = { x: pts[0], y: pts[1] }
const vEnd = { x: pts[2], y: pts[3] }
const dxT = target.x2 - target.x1
const dyT = target.y2 - target.y1
const lenTSq = dxT * dxT + dyT * dyT
const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9)
const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT }
const offX = newWStart.x - vStart.x
const offY = newWStart.y - vStart.y
const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY }
// wEnd(원 ray hit) 가 wEndProj 보다 ray 방향으로 더 멀리 있는지 — newWStart 기준 투영 비교.
const tProj = (wEndProj.x - newWStart.x) * wUx + (wEndProj.y - newWStart.y) * wUy
const tEndR = (wEnd.x - newWStart.x) * wUx + (wEnd.y - newWStart.y) * wUy
buildOverlapLine(newWStart, wEndProj, '-wall')
buildOverlapLine(vStart, newWStart, '-bridge-start')
buildOverlapLine(vEnd, wEndProj, '-bridge-end')
if (tEndR > tProj + 0.5) {
buildOverlapLine(wEndProj, wEnd, '-extend')
}
logger.log(
'[KERAB-VALLEY-OVERLAP] drawn ' +
JSON.stringify({
src: vr.source,
stop: wRidgeStop ? 'ridge' : 'wall',
newWStart: { x: Math.round(newWStart.x * 100) / 100, y: Math.round(newWStart.y * 100) / 100 },
wEndProj: { x: Math.round(wEndProj.x * 100) / 100, y: Math.round(wEndProj.y * 100) / 100 },
wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 },
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
extended: tEndR > tProj + 0.5,
}),
)
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 룰 2: vExt 가 ridge 와 교차하면
// wallExt(`:`) 측(=vExt 와 가까운 영역) ridge 부분 절삭.
// ridge 의 V apex 는 vExt 와 먼 끝점 (Y 출발점). 살아남는 쪽 = V apex 쪽.
// 방향 판정: 절삭 방향 = wallExt 중점 - vExt 중점 (wallExt 측). dot 양수면 절삭 대상.
const veMid = { x: (vStart.x + vEnd.x) / 2, y: (vStart.y + vEnd.y) / 2 }
const wsMid = { x: (newWStart.x + wEndProj.x) / 2, y: (newWStart.y + wEndProj.y) / 2 }
const bDirX = wsMid.x - veMid.x
const bDirY = wsMid.y - veMid.y
const isBSide = (px, py) => (px - veMid.x) * bDirX + (py - veMid.y) * bDirY > 0
const trimCascadePts = []
const newTrimRecords = []
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] trim 대상 = 원래 ridge/hip + 마루 확장(ExtRidge) + kLine(kerabPatternRidge).
// stop 룰(룰 1) 은 ridge 만, trim 룰(룰 2) 은 hip 도 포함 — vExt 는 hip 통과 후 절삭.
// 절삭 방향은 V apex 우선 룰 (아래) 로 결정.
const isRidge =
il.name === LINE_TYPE.SUBLINE.RIDGE &&
(il.lineName === 'ridge' || il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge')
const isHip =
il.name === LINE_TYPE.SUBLINE.HIP &&
(il.lineName === 'hip' || il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip')
if (!isRidge && !isHip) continue
const ip = lineLineIntersection(vStart, vEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰.
// V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점.
// 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정.
// 1) 한 끝만 V apex → 그 V apex 측 살림, 반대 끝 절삭.
// 2) 양 끝 모두 V apex → vExt 와 먼 끝 살림 (가까운 끝 절삭).
// 3) 둘 다 V apex 아님 → 기존 fallback (B 측 = wallExt 측 절삭).
const isVApex = (px, py) => {
let cnt = 0
for (const other of roof.innerLines || []) {
if (!other || other === il) continue
if (other.visible === false) continue
if (other.name !== LINE_TYPE.SUBLINE.HIP) continue
if (other.lineName !== 'kerabPatternHip' && other.lineName !== 'kerabPatternExtHip') continue
if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) cnt++
else if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) cnt++
if (cnt >= 2) return true
}
return false
}
const e1V = isVApex(il.x1, il.y1)
const e2V = isVApex(il.x2, il.y2)
const e1B = isBSide(il.x1, il.y1)
const e2B = isBSide(il.x2, il.y2)
let trimEnd = 0
if (e1V && !e2V) trimEnd = 2 // e1 V apex 살림, e2 절삭
else if (!e1V && e2V) trimEnd = 1
else if (e1V && e2V) {
// 양 끝 모두 V apex → vExt 와 가까운 끝 절삭
const d1 = Math.hypot(il.x1 - veMid.x, il.y1 - veMid.y)
const d2 = Math.hypot(il.x2 - veMid.x, il.y2 - veMid.y)
trimEnd = d1 < d2 ? 1 : 2
} else {
// 둘 다 V apex 아님 → 기존 B 측 fallback
if (e1B && !e2B) trimEnd = 1
else if (!e1B && e2B) trimEnd = 2
else continue
}
const oldPt = trimEnd === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
const originalAttrs = { ...(il.attributes || {}) }
if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y })
else il.set({ x2: ip.x, y2: ip.y })
if (typeof il.setCoords === 'function') il.setCoords()
const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz }
trimCascadePts.push(oldPt)
newTrimRecords.push({
line: il,
end: trimEnd,
oldPt,
newPt: { x: ip.x, y: ip.y },
originalAttrs,
})
logger.log(
'[KERAB-VALLEY-EXT-TRIM] ridge trim ' +
JSON.stringify({ lineName: il.lineName, trimEnd, oldPt, ip: { x: Math.round(ip.x * 100) / 100, y: Math.round(ip.y * 100) / 100 } }),
)
}
// cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide
const cascadeHidden = new Set()
let cascadeGuard = 0
while (trimCascadePts.length && cascadeGuard++ < 200) {
const pt = trimCascadePts.shift()
if (!pt) continue
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (cascadeHidden.has(il)) continue
if (il.lineName !== 'kerabPatternExtRidge') continue
const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0
const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0
if (!m1 && !m2) continue
cascadeHidden.add(il)
const originalVisible = il.visible !== false
const originalAttrs = { ...(il.attributes || {}) }
il.visible = false
if (typeof il.setCoords === 'function') il.setCoords()
newTrimRecords.push({
line: il,
hide: true,
originalVisible,
originalAttrs,
})
logger.log(
'[KERAB-VALLEY-EXT-TRIM] cascade hide ' +
JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
)
trimCascadePts.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 })
}
}
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적.
// 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리.
// 한 mousedown 에 여러 vExt 가 호출될 수 있어 concat.
if (newTrimRecords.length) {
target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords)
}
} else {
logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source)
}
}
}
}
// ── Phase 2-a: valleyExt 경로상 교차 hip/ridge 절삭 ──
// trim 대상 = ridge + 케라바 토글 생성 hip(kerabPatternHip/ExtHip). 원래 지붕 hip 보존.
// Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축.
const trimByValleyExtensions = (valleyExtensions) => {
const trimRecords = []
for (const vr of valleyExtensions) {
if (!vr.source || !vr.source.startsWith('roofBase')) continue
const segA = { x: vr.x1, y: vr.y1 }
const segB = { x: vr.x2, y: vr.y2 }
const wallEnd = { x: 2 * segB.x - segA.x, y: 2 * segB.y - segA.y }
const dxV = wallEnd.x - segA.x
const dyV = wallEnd.y - segA.y
const lenV = Math.hypot(dxV, dyV) || 1
const ux = dxV / lenV
const uy = dyV / lenV
const tB = (segB.x - segA.x) * ux + (segB.y - segA.y) * uy
const COLLINEAR_TOL = 1.0
for (const il of roof.innerLines || []) {
if (!il) continue
if (il.lineName === 'kerabPatternValleyExt') continue
// trim 대상 = ridge(마루) + 케라바 토글 생성 hip(kerabPatternHip / kerabPatternExtHip).
// 원래 지붕 hip 은 보존.
const isTrimCandidate =
il.name === LINE_TYPE.SUBLINE.RIDGE ||
(il.name === LINE_TYPE.SUBLINE.HIP && (il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip'))
if (!isTrimCandidate) continue
if (il.visible === false) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
let ip = lineLineIntersection(segA, wallEnd, a, b)
if (ip) {
if (!isOnSegV(ip, segA.x, segA.y, wallEnd.x, wallEnd.y)) ip = null
else if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) ip = null
}
if (!ip) {
const perp1 = Math.abs((il.x1 - segA.x) * uy - (il.y1 - segA.y) * ux)
const perp2 = Math.abs((il.x2 - segA.x) * uy - (il.y2 - segA.y) * ux)
if (perp1 > COLLINEAR_TOL || perp2 > COLLINEAR_TOL) continue
const tc1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy
const tc2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy
if (Math.max(tc1, tc2) <= tB + 0.5) continue
ip = { x: segB.x, y: segB.y }
}
// Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축.
// 양쪽 다 원래 hip 있음 → 통째 보존. 양쪽 다 없음 → 진행벡터 fallback (cascade pass 에서 정리).
const isOriginalHipEndAt = (px, py) => {
for (const other of roof.innerLines || []) {
if (!other || other === il) continue
if (other.lineName === 'kerabPatternValleyExt') continue
if (other.lineName === 'kerabPatternHip' || other.lineName === 'kerabPatternExtHip') continue
if (other.name !== LINE_TYPE.SUBLINE.HIP) continue
if (other.visible === false) continue
if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) return true
if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) return true
}
return false
}
const hip1 = isOriginalHipEndAt(il.x1, il.y1)
const hip2 = isOriginalHipEndAt(il.x2, il.y2)
let trimEnd
if (hip1 && hip2) {
logger.log('[KERAB-VALLEY-EXT-TRIM] both-hip preserve ' + JSON.stringify({ name: il.name, lineName: il.lineName }))
continue
} else if (hip1 && !hip2) {
trimEnd = 2
} else if (!hip1 && hip2) {
trimEnd = 1
} else {
const t1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy
const t2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy
trimEnd = t1 > t2 ? 1 : 2
}
const oldX = trimEnd === 1 ? il.x1 : il.x2
const oldY = trimEnd === 1 ? il.y1 : il.y2
trimRecords.push({
line: il,
end: trimEnd,
oldPt: { x: oldX, y: oldY },
newPt: { x: ip.x, y: ip.y },
originalAttrs: { ...(il.attributes || {}) },
})
if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y })
else il.set({ x2: ip.x, y2: ip.y })
if (typeof il.setCoords === 'function') il.setCoords()
const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz }
logger.log(
'[KERAB-VALLEY-EXT-TRIM] trim ' +
JSON.stringify({ name: il.name, lineName: il.lineName, trimEnd, oldPt: { x: oldX, y: oldY }, ip }),
)
}
}
return trimRecords
}
// ── Phase 2-b: cascade hide ──
// trim oldPt 에 끝점 일치 케라바산 라인(kerabPatternHip/ExtHip/Ridge/ExtRidge) BFS hide.
// 원래 지붕 ridge/hip 은 cascade 대상 외 — junction 공유 의도 외 절삭 방지.
const cascadeHideByValleyExtensions = (trimRecords) => {
const isKerabSynthetic = (line) => {
if (!line || !line.lineName) return false
return (
line.lineName === 'kerabPatternHip' ||
line.lineName === 'kerabPatternExtHip' ||
line.lineName === 'kerabPatternRidge' ||
line.lineName === 'kerabPatternExtRidge'
)
}
const cascadeHidden = new Set()
const cascadeQueue = trimRecords.map((r) => r.oldPt).filter(Boolean)
let cascadeGuard = 0
while (cascadeQueue.length && cascadeGuard++ < 200) {
const pt = cascadeQueue.shift()
if (!pt) continue
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (cascadeHidden.has(il)) continue
if (il.lineName === 'kerabPatternValleyExt') continue
if (!isKerabSynthetic(il)) continue
const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0
const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0
if (!m1 && !m2) continue
cascadeHidden.add(il)
trimRecords.push({
line: il,
hide: true,
originalAttrs: { ...(il.attributes || {}) },
originalVisible: il.visible !== false,
})
il.visible = false
if (typeof il.setCoords === 'function') il.setCoords()
logger.log(
'[KERAB-VALLEY-EXT-TRIM] cascade hide ' +
JSON.stringify({ name: il.name, lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
)
cascadeQueue.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 })
}
}
}
// === Phase 1 + Phase 2 실행 ===
const valleyExtensions = computeValleyExtensions()
drawValleyExtensions(valleyExtensions)
if (valleyExtensions.length) {
logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length)
}
// [KERAB-VALLEY-EXT 2026-05-28] 사용자 지시: 골짜기 라인이동(trim)·라인절삭(cascade) 다 비활성, 확장만.
// 골짜기 케라바 라인 별도 룰 후속에서 재활성 예정.
// const trimRecords = trimByValleyExtensions(valleyExtensions)
// cascadeHideByValleyExtensions(trimRecords)
const trimRecords = []
if (trimRecords.length) target.__valleyExtTrims = trimRecords
}
dumpInnerLineSnapshot('AFTER')
logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')')
return
}
dumpInnerLineSnapshot('AFTER')
logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only')
target.set({ attributes })
applyKerabAttributeOnlyPattern()
@ -1705,6 +2071,7 @@ export function useEavesGableEdit(id) {
null,
null,
)
dumpInnerLineSnapshot('AFTER')
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
return
}
@ -2268,8 +2635,11 @@ export function useEavesGableEdit(id) {
// [KERAB-VALLEY-EXT 2026-05-27] forward 에서 추가됐던 처마확장(kerabPatternValleyExt) 제거.
// __targetId === target.id 매칭으로 해당 target 의 처마확장만 정확히 정리. 모든 패턴 분기 공통.
// roofBase 확장은 roof.innerLines 에 있고 wallBase 확장은 canvas 에만 있으므로 canvas 전체 스캔.
// [KERAB-VALLEY-OVERLAP 2026-05-28] wallBase 측은 lineName='kerabValleyOverlapLine' (본체 + 90도 보조선 2개).
// b polygon 의 lines[] 에는 apply() 단계에서 들어가므로, 여기서는 canvas 객체와 roof.innerLines 만 정리.
// b 의 lines[] 에서 제거하는 책임은 apply() 다음 사이클에서 자동 (canvas 에서 사라지므로).
const valleyExtsToRemove = (canvas.getObjects() || []).filter(
(il) => il && il.lineName === 'kerabPatternValleyExt' && il.__targetId === target.id,
(il) => il && (il.lineName === 'kerabPatternValleyExt' || il.lineName === 'kerabValleyOverlapLine') && il.__targetId === target.id,
)
for (const v of valleyExtsToRemove) {
removeLine(v)

View File

@ -15,7 +15,7 @@ import {
selectedRoofMaterialSelector,
} from '@/store/settingAtom'
import { usePopup } from '@/hooks/usePopup'
import { POLYGON_TYPE } from '@/common/common'
import { POLYGON_TYPE, LINE_TYPE } from '@/common/common'
import { v4 as uuidv4 } from 'uuid'
import ActualSizeSetting from '@/components/floor-plan/modal/roofAllocation/ActualSizeSetting'
import { useMessage } from '@/hooks/useMessage'
@ -30,6 +30,7 @@ import { QcastContext } from '@/app/QcastProvider'
import { usePlan } from '@/hooks/usePlan'
import { roofsState } from '@/store/roofAtom'
import { useText } from '@/hooks/useText'
import { fabric } from 'fabric'
import { QLine } from '@/components/fabric/QLine'
import { calcLineActualSize2 } from '@/util/qpolygon-utils'
// [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert
@ -656,6 +657,251 @@ export function useRoofAllocationSetting(id) {
)
}
/**
* [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 골짜기 케라바 출폭 직사각형 sub-roof 인접 sub-roof 들에 union 머지.
*
* 배경: 케라바 토글 지붕면이 出幅만큼 서로 물려 겹친다는 표시. useEavesGableEdit.js wallExt 단계에서
* 평행 사각형 4 라인(kerabValleyOverlapLine) 으로 출폭 띠를 닫고 split 단계로 진입.
* split 사각형은 별도 sub-roof X . 의도는 X 영역이 인접 sub-roof 모두에 속해 겹치는 .
* 따라서 X 공유 변을 가진 인접 sub-roof 들에 X 나머지 (detour) 끼워넣어 X 영역을 흡수.
* 흡수 완료 X 자체는 캔버스에서 제거.
*
* 식별:
* - 직사각형 X: sub.lines attributes.type === 'kerabValleyOverlapLine' N-1 이상 (4 3~4)
* - 인접 sub-roof: X 변과 같은 끝점을 공유하는 다른 sub-roof
*
* 머지: 사각형 X 공유 1개를 detour N-1개로 대체. 방향은 면적 증가로 검증.
*/
const mergeValleyOverlapSubRoofs = () => {
const newSubRoofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed)
if (newSubRoofs.length === 0) return
const eq = (p, q) => Math.abs(p.x - q.x) < 0.5 && Math.abs(p.y - q.y) < 0.5
const rects = newSubRoofs.filter((sub) => {
if (!sub.lines || sub.lines.length < 3) return false
const cnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length
return cnt >= Math.max(3, sub.lines.length - 1)
})
if (rects.length === 0) return
logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] 직사각형 sub-roof 후보=${rects.length}`)
const signedArea = (pts) => {
let s = 0
for (let i = 0; i < pts.length; i++) {
const a = pts[i]
const b = pts[(i + 1) % pts.length]
s += a.x * b.y - b.x * a.y
}
return s / 2
}
rects.forEach((X) => {
const Xpts = (X.points || []).map((p) => ({ x: p.x, y: p.y }))
const Xlines = X.lines || []
if (Xpts.length < 3 || Xlines.length < Xpts.length) {
logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} skip — pts=${Xpts.length} lines=${Xlines.length}`)
return
}
const Xedges = []
for (let i = 0; i < Xpts.length; i++) {
Xedges.push({ a: Xpts[i], b: Xpts[(i + 1) % Xpts.length], idx: i })
}
// 인접 sub-roof — X 와 공유 변 모두 수집 (다중 공유 검출용)
const adjacencyMap = new Map() // sub → [{ subEdgeStart, xEdgeIdx }, ...]
newSubRoofs.forEach((sub) => {
if (sub === X) return
const pts = sub.points || []
if (pts.length < 3) return
const shares = []
for (let i = 0; i < pts.length; i++) {
const p1 = pts[i]
const p2 = pts[(i + 1) % pts.length]
for (const e of Xedges) {
if ((eq(p1, e.a) && eq(p2, e.b)) || (eq(p1, e.b) && eq(p2, e.a))) {
shares.push({ subEdgeStart: i, xEdgeIdx: e.idx })
break
}
}
}
if (shares.length > 0) adjacencyMap.set(sub, shares)
})
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} pts=${Xpts.length} adjacents=${adjacencyMap.size} ` +
`shares=[${[...adjacencyMap.values()].map((s) => s.length).join(',')}]`,
)
if (adjacencyMap.size === 0) {
canvas.remove(X)
return
}
adjacencyMap.forEach((shares, sub) => {
// 다중 공유 → bowtie 위험, 머지 skip
if (shares.length !== 1) {
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — sharedEdges=${shares.length} (≠1, bowtie 위험)`,
)
return
}
const { subEdgeStart, xEdgeIdx } = shares[0]
const pts = (sub.points || []).map((p) => ({ x: p.x, y: p.y }))
const oldLines = [...(sub.lines || [])]
const N = Xpts.length
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] detour 방향은 sub.pts[subEdgeStart] 가
// X.xEdge.a (=Xpts[xEdgeIdx]) 인지 X.xEdge.b (=Xpts[xEdgeIdx+1]) 인지로 결정.
// sub 외곽선 진행 방향이 xEdge 의 a→b 와 같으면 detour 는 X 의 a 쪽 이웃부터 (역방향)
// 반대면 detour 는 X 의 b 쪽 이웃부터 (정방향).
const subA = pts[subEdgeStart]
const xA = Xpts[xEdgeIdx]
const sameDir = eq(subA, xA)
const detour = []
const detourEdgeIdx = []
if (sameDir) {
// a → Xpts[i-1] → Xpts[i-2] → ... → Xpts[i+2] → b
// detour vertices: N-2 개 (a, b 사이)
for (let k = 1; k <= N - 2; k++) {
detour.push(Xpts[(xEdgeIdx - k + N) % N])
}
// detour edges: N-1 개. a→Xpts[i-1] 는 X 의 (i-1)%N 변 (반대방향).
for (let k = 1; k <= N - 1; k++) {
detourEdgeIdx.push((xEdgeIdx - k + N) % N)
}
} else {
// b → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → a 의 a→b 표기:
// a → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → b (subA=b 인 관점에서 보면 반대)
// 정확히는 subA=X.xEdge.b → detour 시작 = Xpts[(i+2)%N] (b 의 X 내 이웃)
for (let k = 2; k <= N - 1; k++) {
detour.push(Xpts[(xEdgeIdx + k) % N])
}
for (let k = 1; k <= N - 1; k++) {
detourEdgeIdx.push((xEdgeIdx + k) % N)
}
}
const buildCand = (verts) => {
const c = [...pts]
c.splice(subEdgeStart + 1, 0, ...verts)
return c
}
const candA = buildCand(detour)
const oldSigned = signedArea(pts)
const aSigned = signedArea(candA)
const sameSign = (s) => oldSigned === 0 || Math.sign(s) === Math.sign(oldSigned)
const okA = sameSign(aSigned) && Math.abs(aSigned) > Math.abs(oldSigned) + 0.5
let finalPts = null
let finalEdges = null
if (okA) {
finalPts = candA
finalEdges = detourEdgeIdx
} else {
// fallback — 반대 방향 시도 (X 가 CW 로 들어온 경우 대비)
const detourRev = [...detour].reverse()
const detourEdgeRev = [...detourEdgeIdx].reverse()
const candB = buildCand(detourRev)
const bSigned = signedArea(candB)
const okB = sameSign(bSigned) && Math.abs(bSigned) > Math.abs(oldSigned) + 0.5
if (okB) {
finalPts = candB
finalEdges = detourEdgeRev
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} fallback reverse detour 사용 ` +
`oldSigned=${oldSigned.toFixed(0)}${bSigned.toFixed(0)}`,
)
} else {
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — detour 양방향 실패 ` +
`sameDir=${sameDir} oldSigned=${oldSigned.toFixed(0)} ` +
`aSigned=${aSigned.toFixed(0)} bSigned=${bSigned.toFixed(0)} ` +
`subA=(${subA.x.toFixed(1)},${subA.y.toFixed(1)}) ` +
`xA=(${xA.x.toFixed(1)},${xA.y.toFixed(1)}) ` +
`xB=(${Xpts[(xEdgeIdx + 1) % N].x.toFixed(1)},${Xpts[(xEdgeIdx + 1) % N].y.toFixed(1)})`,
)
return
}
}
// sub.lines 재구성 — 옛 공유 변 1개 → detour 변 N-1개
const newSubLines = []
for (let i = 0; i < oldLines.length; i++) {
if (i === subEdgeStart) {
for (let k = 0; k < finalEdges.length; k++) {
const xLine = Xlines[finalEdges[k]]
const p1 = finalPts[(subEdgeStart + k) % finalPts.length]
const p2 = finalPts[(subEdgeStart + k + 1) % finalPts.length]
const attrs = xLine?.attributes
? { ...xLine.attributes }
: { type: 'kerabValleyOverlapLine', offset: 0 }
const ln = new QLine([p1.x, p1.y, p2.x, p2.y], {
stroke: sub.stroke,
strokeWidth: sub.strokeWidth,
fontSize: sub.fontSize,
attributes: attrs,
textVisible: false,
parent: sub,
parentId: sub.id,
idx: newSubLines.length + 1,
})
ln.startPoint = p1
ln.endPoint = p2
newSubLines.push(ln)
}
} else {
const ln = oldLines[i]
if (ln) {
ln.idx = newSubLines.length + 1
newSubLines.push(ln)
}
}
}
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] fabric.Polygon 정식 points 갱신 패턴.
// src/util/canvas-util.js#anchorWrapper 패턴 그대로 적용 —
// _setPositionDimensions 가 width/height/pathOffset/left/top 을 재계산하므로
// 변경 전 앵커(pts[0]) 절대좌표를 캡쳐 → 변경 후 setPositionByOrigin 으로 복원.
// 그래야 polygon 의 path 가 새 bbox 기준으로 다시 그려지면서 시각 위치도 유지됨.
const anchorIdx = 0
const oldLocal = {
x: sub.points[anchorIdx].x - sub.pathOffset.x,
y: sub.points[anchorIdx].y - sub.pathOffset.y,
}
const absolutePoint = fabric.util.transformPoint(oldLocal, sub.calcTransformMatrix())
sub.points = finalPts
sub.lines = newSubLines
sub._setPositionDimensions({})
const strokeW = sub.strokeUniform ? sub.strokeWidth / sub.scaleX : sub.strokeWidth
const baseW = sub.width + strokeW
const baseH = sub.height + (sub.strokeUniform ? sub.strokeWidth / sub.scaleY : sub.strokeWidth)
const newX = (sub.points[anchorIdx].x - sub.pathOffset.x) / Math.max(baseW, 1e-9)
const newY = (sub.points[anchorIdx].y - sub.pathOffset.y) / Math.max(baseH, 1e-9)
sub.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
sub.setCoords?.()
sub.dirty = true
logger.log(
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} 머지 완료 ` +
`oldSigned=${oldSigned.toFixed(0)} → newSigned=${signedArea(finalPts).toFixed(0)} ` +
`pts=${finalPts.length} lines=${newSubLines.length}`,
)
})
canvas.remove(X)
})
canvas.renderAll?.()
}
/**
* 지붕면 할당
*/
@ -663,11 +909,21 @@ export function useRoofAllocationSetting(id) {
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial)
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
logger.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`)
// [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] 저장→로드 사이클을 거치면 fabric 객체 좌표/캐시가 정리되어
// case A 가 잘 동작. 저장 X 시 그 효과가 없음. canvas.toJSON 한 번 호출하여 동일 효과 시뮬레이션.
try {
canvas.discardActiveObject()
canvas.toJSON()
} catch (e) {
logger.warn('[ALLOC] toJSON simulate failed', e)
}
roofBases.forEach((roofBase) => {
try {
// 지붕 할당 로직에 extensionLine 추가
// [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 출폭 띠 외곽 라인(kerabValleyOverlapLine) 도 할당 대상.
// b polygon 의 lines[] 에 추가되어 b 의 면이 출폭 띠 영역까지 확장 → a 의 sub-면과 겹침 표현.
const roofEaveHelpLines = canvas.getObjects().filter((obj) =>
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine') &&
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') &&
obj.roofId === roofBase.id
)
// logger.log('roofBase.id:', roofBase.id)
@ -683,6 +939,8 @@ export function useRoofAllocationSetting(id) {
// extensionLine과 일반 eaveHelpLine 분리
const extensionLines = newEaveLines.filter(line => line.lineName === 'extensionLine')
const normalEaveLines = newEaveLines.filter(line => line.lineName === 'eaveHelpLine')
// [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 겹침 라인 — overlap 판단 제외, 단순 결합
const overlapLines = newEaveLines.filter(line => line.lineName === 'kerabValleyOverlapLine')
// logger.log('extensionLines count:', extensionLines.length)
// logger.log('normalEaveLines count:', normalEaveLines.length)
@ -737,7 +995,7 @@ export function useRoofAllocationSetting(id) {
return !shouldRemove;
});
// Combine remaining lines with newEaveLines
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines];
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines];
} else {
roofBase.lines = [...roofEaveHelpLines]
}
@ -796,6 +1054,100 @@ export function useRoofAllocationSetting(id) {
)
}
// [KERAB-VALLEY-OVERLAP 2026-05-29] vExt 기반 wallExt 4변 in-memory 재계산.
// case A(저장 X→케라바→할당) 에서 canvas 의 kerabValleyOverlapLine 가 silent 사라지는 문제 회피.
// vExt(kerabPatternValleyExt) 는 roof.innerLines 에 안정적으로 보존 — 거기서 직접 재계산.
// 조건: vExt 가 polygon 내부에 들어온 케이스 한정 (innerLines 에 있으면 자동 충족).
const vExtsForOverlap = (roofBase.innerLines || []).filter(
(l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false,
)
for (const vExt of vExtsForOverlap) {
// [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] wallLineId 보장 fallback 체인.
// 1) vExt.attributes.wallLine (정상 케이스)
// 2) vExt.__targetId (useEavesGableEdit.js drawValleyExtensions 에서 백업)
// 3) vExt.parentLine.attributes.wallLine
// 4) vStart 좌표 매칭하는 roof.lines 변의 wallLine
let wallLineId = vExt.attributes?.wallLine
if (!wallLineId) wallLineId = vExt.__targetId
if (!wallLineId && vExt.parentLine) wallLineId = vExt.parentLine.attributes?.wallLine
if (!wallLineId) {
// vStart 가 끝점인 roof.lines 변에서 wallLine 추출
const vStartP = { x: vExt.x1, y: vExt.y1 }
for (const rl of roofBase.lines || []) {
if (!rl?.attributes?.wallLine) continue
const m1 = Math.hypot(rl.x1 - vStartP.x, rl.y1 - vStartP.y) < 1
const m2 = Math.hypot(rl.x2 - vStartP.x, rl.y2 - vStartP.y) < 1
if (m1 || m2) {
wallLineId = rl.attributes.wallLine
break
}
}
}
if (!wallLineId) {
logger.warn('[KERAB-VALLEY-OVERLAP-RECALC] vExt skip — wallLineId 매칭 실패 (attributes/parent/좌표 모두)')
continue
}
let target = canvas.getObjects().find((o) => o.id === wallLineId && o.name === 'outerLine')
if (!target) {
const candidates = canvas.getObjects().filter((o) => o.id === wallLineId)
target = candidates[0]
if (!target) {
logger.warn('[KERAB-VALLEY-OVERLAP-RECALC] outerLine 못찾음 wallLineId=' + wallLineId?.slice(0, 8))
continue
}
}
const vStart = { x: vExt.x1, y: vExt.y1 }
const vEnd = { x: vExt.x2, y: vExt.y2 }
const dxT = target.x2 - target.x1
const dyT = target.y2 - target.y1
const lenTSq = dxT * dxT + dyT * dyT
const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9)
const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT }
const offX = newWStart.x - vStart.x
const offY = newWStart.y - vStart.y
const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY }
const make = (p1, p2) => {
const lpts = [p1.x, p1.y, p2.x, p2.y]
const dx = lpts[2] - lpts[0]
const dy = lpts[3] - lpts[1]
const lsz = Math.round(Math.hypot(dx, dy) * 10)
const ln = new QLine(lpts, {
parentId: roofBase.id,
fontSize: roofBase.fontSize,
stroke: 'rgba(0,0,0,0)',
strokeWidth: 0,
name: 'valley',
visible: true,
opacity: 0,
attributes: {
roofId: roofBase.id,
type: 'kerabValleyOverlapLine',
isStart: true,
planeSize: lsz,
actualSize: lsz,
},
})
ln.lineName = 'kerabValleyOverlapLine'
ln.roofId = roofBase.id
return ln
}
// [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] float drift 회피 — vStart 와 가장 가까운 polygon corner 로 snap.
// 저장→로드 후엔 좌표 정수 round 로 일치하지만 저장 X 시 drift 가능 → split 알고리즘 외곽 인식 실패.
const snapToCorner = (p) => {
for (const rp of roofBase.points || []) {
const d = Math.hypot(rp.x - p.x, rp.y - p.y)
if (d < 1) return { x: rp.x, y: rp.y }
}
return p
}
const vStartSnap = snapToCorner(vStart)
const wallLn = make(newWStart, wEndProj)
const bsLn = make(vStartSnap, newWStart)
const beLn = make(vEnd, wEndProj)
roofBase.lines.push(wallLn, bsLn, beLn)
roofBase.innerLines.push(wallLn, bsLn, beLn)
}
if (roofBase.separatePolygon.length > 0) {
splitPolygonWithSeparate(roofBase.separatePolygon)
} else {
@ -807,6 +1159,12 @@ export function useRoofAllocationSetting(id) {
const __newRoofs = canvas.getObjects().filter((o) => o.name === 'roof' && !o.isFixed)
logger.log(`[KERAB-PATTERN-DIAG] 분할 후 새 sub-roof=${__newRoofs.length}`)
}
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지.
// 골짜기 출폭 띠 영역이 두 면 모두에 속하도록 (겹침 표현).
// 직사각형 식별: 외곽 라인의 attributes.type === 'kerabValleyOverlapLine' 가 다수인 sub-roof.
// 인접 두 sub-roof = 직사각형의 변 좌표를 공유하는 다른 sub-roof.
mergeValleyOverlapSubRoofs()
} catch (e) {
logger.log(e)
canvas.discardActiveObject()
@ -818,6 +1176,18 @@ export function useRoofAllocationSetting(id) {
canvas.remove(line)
})
// [KERAB-VALLEY-OVERLAP 2026-05-28] roofBase.lines 로 추가된 보조 라인(kerabValleyOverlapLine) 및
// wallBase 변형 kerabPatternValleyExt(innerLines 미포함) 는 split 이후 더 이상 필요 없으므로 정리.
// 미정리 시 canvas 잔류 → sub-roof 위에 솔리드/점선으로 남는다.
const overlapLeftovers = canvas
.getObjects()
.filter(
(obj) =>
(obj.lineName === 'kerabValleyOverlapLine' || obj.lineName === 'kerabPatternValleyExt') &&
(obj.roofId === roofBase.id || obj?.attributes?.roofId === roofBase.id),
)
overlapLeftovers.forEach((line) => canvas.remove(line))
canvas.remove(roofBase)
})

View File

@ -278,27 +278,27 @@ export function useCanvasEvent() {
switch (key) {
case 'Delete':
case 'Backspace':
handleDelete()
// handleDelete()
break
case 'Down': // IE/Edge에서 사용되는 값
case 'ArrowDown':
// "아래 화살표" 키가 눌렸을 때의 동작입니다.
moveDown()
// moveDown()
break
case 'Up': // IE/Edge에서 사용되는 값
case 'ArrowUp':
// "위 화살표" 키가 눌렸을 때의 동작입니다.
moveUp()
// moveUp()
break
case 'Left': // IE/Edge에서 사용되는 값
case 'ArrowLeft':
// "왼쪽 화살표" 키가 눌렸을 때의 동작입니다.
moveLeft()
// moveLeft()
break
case 'Right': // IE/Edge에서 사용되는 값
case 'ArrowRight':
// "오른쪽 화살표" 키가 눌렸을 때의 동작입니다.
moveRight()
// moveRight()
break
case 'Enter':
// "enter" 또는 "return" 키가 눌렸을 때의 동작입니다.
@ -437,21 +437,20 @@ export function useCanvasEvent() {
// 주요 도형 객체만 필터링 (roof, arrow 등)
const objects = allObjects.filter((obj) => {
// 주요 도형 객체만 포함
return obj.name === 'roof' ||
obj.name === 'arrow' ||
obj.name === 'module' ||
obj.name === 'dormer' ||
obj.name === 'object'
return obj.name === 'roof' || obj.name === 'arrow' || obj.name === 'module' || obj.name === 'dormer' || obj.name === 'object'
})
console.log('🔍 All objects:', allObjects.length)
console.log('🔍 Main objects for centering:', objects.length)
console.log('🔍 Main object details:', objects.map(obj => ({
name: obj.name,
type: obj.type,
visible: obj.visible,
id: obj.id
})))
console.log(
'🔍 Main object details:',
objects.map((obj) => ({
name: obj.name,
type: obj.type,
visible: obj.visible,
id: obj.id,
})),
)
if (objects.length === 0) {
console.log('❌ No main objects found for centering')
@ -507,14 +506,14 @@ export function useCanvasEvent() {
const monitorCenterY = window.innerHeight / 2
// canvas가 모니터 중앙에 오도록 스크롤 위치 계산
const targetScrollLeft = canvasScreenLeft + (canvasRect.width / 2) - monitorCenterX
const targetScrollTop = canvasScreenTop + (canvasRect.height / 2) - monitorCenterY + menuHeightOffset
const targetScrollLeft = canvasScreenLeft + canvasRect.width / 2 - monitorCenterX
const targetScrollTop = canvasScreenTop + canvasRect.height / 2 - monitorCenterY + menuHeightOffset
// 스크롤을 중앙으로 이동
window.scrollTo({
top: targetScrollTop,
left: targetScrollLeft,
behavior: 'smooth' // 부드러운 스크롤
behavior: 'smooth', // 부드러운 스크롤
})
canvas.renderAll()

View File

@ -9,6 +9,7 @@ import { fontSelector } from '@/store/fontAtom'
import { QLine } from '@/components/fabric/QLine'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import { useLine } from '@/hooks/useLine'
import { logger } from '@/util/logger'
export const usePolygon = () => {
const canvas = useRecoilValue(canvasState)
@ -744,6 +745,25 @@ export const usePolygon = () => {
let innerLines = [...polygon.innerLines].filter((line) => line.visible)
// [SPLIT-ALLOC-DIAG 2026-06-01] 분할 graph 진입 전 innerLines 전수 dump.
// 케라바 패턴 라인(kerabPatternRidge/ExtRidge/Hip/ExtHip)과 원래 ridge(RG-2 등) 가
// type='default' / isStart 미설정으로 graph 시드/유효점 후보에서 제외되는지 추적용.
logger.log(`[SPLIT-ALLOC-DIAG] polygon.id=${polygon.id?.slice(0, 8)} innerLines.visible=${innerLines.length} polygonLines=${polygon.lines?.length ?? 0}`)
innerLines.forEach((l, i) => {
logger.log(
` [SPLIT-ALLOC-DIAG ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` +
`type=${l.attributes?.type ?? 'none'} isStart=${l.attributes?.isStart ?? false} ` +
`(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`,
)
})
;(polygon.lines || []).forEach((l, i) => {
logger.log(
` [SPLIT-ALLOC-DIAG outer ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` +
`type=${l.attributes?.type ?? 'none'} isStart=${l.attributes?.isStart ?? false} ` +
`(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`,
)
})
/*// innerLine이 세팅이 안되어있는경우 찾아서 세팅한다.
if (!innerLines || innerLines.length === 0) {
let innerLineTypes = Object.keys(LINE_TYPE.SUBLINE).map((key, value) => LINE_TYPE.SUBLINE[key])

View File

@ -16,6 +16,33 @@
"header.stem": "Stem",
"plan.menu.plan.drawing": "物件情報",
"plan.menu.placement.surface.initial.setting": "配置面初期設定",
"pdf.import.button.analyzing": "解析中...",
"pdf.import.success": "外壁線をキャンバスに反映しました。",
"pdf.import.confirm.overwrite": "既存の外壁線があります。新しい外壁線で上書きしますか?",
"pdf.import.error.network": "ネットワークエラーで解析リクエストに失敗しました。",
"pdf.import.error.analyze": "PDF 解析に失敗しました。",
"pdf.import.error.no.floorplan": "平面図を認識できませんでした。別の PDF で再度お試しください。",
"pdf.import.error.too.few.vertices": "外壁線の頂点が不足しています。別の PDF で再度お試しください。",
"pdf.import.error.unsupported.unit": "未対応の座標単位です。",
"pdf.import.error.apply": "解析結果をキャンバスに反映できませんでした。",
"modal.placement.initial.setting.size.pdf": "図面ファイルアップロード",
"modal.placement.initial.setting.size.pdf.info": "住宅図面ファイルをアップロード後、全ページ解析または立面図·平面図ページ指定方式で外壁線と屋根形状を作成できます。\n住宅図面ファイルアップロードによる自動図面作成は、必ずしも正確な結果を保証しません。\n見積作成後、必ず住宅図面と出力結果が一致するか確認してください。",
"modal.placement.initial.setting.size.pdf.file.label": "住宅図面ファイルアップロード",
"modal.placement.initial.setting.size.pdf.file.upload": "アップロード",
"modal.placement.initial.setting.size.pdf.file.placeholder": "ファイルをドラッグまたはクリックでアップロード (.PDF)",
"modal.placement.initial.setting.size.pdf.file.info": "住宅図面の解析結果に応じて外壁線と屋根形状が一緒に生成されます。屋根形状を認識できない場合は外壁線のみ生成され、屋根面は手動で作成します。",
"modal.placement.initial.setting.size.pdf.page.label": "読み込みページ設定",
"modal.placement.initial.setting.size.pdf.page.all": "全ページ",
"modal.placement.initial.setting.size.pdf.page.specify": "ページ指定",
"modal.placement.initial.setting.size.pdf.page.facade.label": "立面図",
"modal.placement.initial.setting.size.pdf.page.floor.label": "平面図",
"modal.placement.initial.setting.size.pdf.page.example.facade": "例) 1, 2",
"modal.placement.initial.setting.size.pdf.page.example.floor": "例) 3, 4",
"modal.placement.initial.setting.size.pdf.page.all.info": "全ページを解析するため処理時間が長くなる場合があります。図面内の立面図·平面図ページが特定できる場合はページ指定を推奨します。",
"modal.placement.initial.setting.size.pdf.page.specify.info": "立面図·平面図が含まれるページ番号をそれぞれ入力してください。指定したページのみ読み込み素早く屋根を自動作成します。",
"modal.placement.initial.setting.size.pdf.error.mime": "PDF ファイルのみアップロード可能です。",
"modal.placement.initial.setting.size.pdf.error.too.large": "ファイルサイズが 20MB を超えています。",
"modal.placement.initial.setting.size.pdf.error.no.file": "先に PDF ファイルをアップロードしてください。",
"modal.placement.initial.setting.plan.drawing": "図面の作成方法",
"modal.placement.initial.setting.plan.drawing.size.stuff": "寸法入力による物件作成",
"modal.placement.initial.setting.plan.drawing.size.info": "※数字は[半角]入力のみ可能です。",

View File

@ -16,6 +16,33 @@
"header.stem": "Stem",
"plan.menu.plan.drawing": "물건정보",
"plan.menu.placement.surface.initial.setting": "배치면 초기설정",
"pdf.import.button.analyzing": "분석 중...",
"pdf.import.success": "외곽선이 캔버스에 반영되었습니다.",
"pdf.import.confirm.overwrite": "기존 외곽선이 있습니다. 새 외곽선으로 덮어쓰시겠습니까?",
"pdf.import.error.network": "네트워크 오류로 분석 요청에 실패했습니다.",
"pdf.import.error.analyze": "PDF 분석에 실패했습니다.",
"pdf.import.error.no.floorplan": "평면도를 인식하지 못했습니다. 다른 PDF 로 다시 시도해 주세요.",
"pdf.import.error.too.few.vertices": "외곽선 정점이 부족합니다. 다른 PDF 로 다시 시도해 주세요.",
"pdf.import.error.unsupported.unit": "지원하지 않는 좌표 단위입니다.",
"pdf.import.error.apply": "분석 결과를 캔버스에 반영하지 못했습니다.",
"modal.placement.initial.setting.size.pdf": "도면 파일 업로드",
"modal.placement.initial.setting.size.pdf.info": "주택 도면 파일 업로드 후 전체 페이지 분석 또는 입면도·평면도 페이지 지정 방식으로 외벽선과 지붕 형상을 작성할 수 있습니다.\n주택 도면 파일 업로드를 통한 자동 도면 작성은 반드시 정확한 결과라고 보장할 수 없습니다.\n견적 작성 후 반드시 주택 도면과 출력 결과가 일치하는지 확인해 주십시오.",
"modal.placement.initial.setting.size.pdf.file.label": "주택 도면 파일 업로드",
"modal.placement.initial.setting.size.pdf.file.upload": "업로드",
"modal.placement.initial.setting.size.pdf.file.placeholder": "파일을 드래그하거나 클릭하여 업로드 (.PDF)",
"modal.placement.initial.setting.size.pdf.file.info": "주택 도면 분석 결과에 따라 외벽선과 지붕형상이 함께 생성됩니다. 지붕형상을 인식하지 못한 경우 외벽선만 생성되며, 지붕면은 수동으로 작성합니다.",
"modal.placement.initial.setting.size.pdf.page.label": "읽기 페이지 설정",
"modal.placement.initial.setting.size.pdf.page.all": "전체 페이지",
"modal.placement.initial.setting.size.pdf.page.specify": "페이지 지정",
"modal.placement.initial.setting.size.pdf.page.facade.label": "입면도",
"modal.placement.initial.setting.size.pdf.page.floor.label": "평면도",
"modal.placement.initial.setting.size.pdf.page.example.facade": "예) 1, 2",
"modal.placement.initial.setting.size.pdf.page.example.floor": "예) 3, 4",
"modal.placement.initial.setting.size.pdf.page.all.info": "모든 페이지를 분석하므로 처리 시간이 길어질 수 있습니다. 도면 내 입면도·평면도 페이지가 확인되면 페이지 지정을 권장합니다.",
"modal.placement.initial.setting.size.pdf.page.specify.info": "입면도·평면도가 포함된 페이지 번호를 각각 입력해주세요. 지정한 페이지만 읽어 빠르게 지붕을 자동 작성합니다.",
"modal.placement.initial.setting.size.pdf.error.mime": "PDF 파일만 업로드할 수 있습니다.",
"modal.placement.initial.setting.size.pdf.error.too.large": "파일 크기가 20MB 를 초과합니다.",
"modal.placement.initial.setting.size.pdf.error.no.file": "PDF 파일을 먼저 업로드해 주세요.",
"modal.placement.initial.setting.plan.drawing": "도면 작성방법",
"modal.placement.initial.setting.plan.drawing.size.stuff": "치수 입력에 의한 물건 작성",
"modal.placement.initial.setting.plan.drawing.size.info": "※숫자는 [반각] 입력만 가능합니다.",

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
// 고객 회귀 검증을 위해 본 로직 전체를 useEavesGableEdit.js 에서 분리.
// 호출 ON/OFF 는 useEavesGableEdit.js 상단 `ENABLE_KERAB_OFFSET_SURGICAL` 상수로 토글한다.
import { fabric } from 'fabric'
import { POLYGON_TYPE } from '@/common/common'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { logger } from '@/util/logger'
@ -104,35 +105,169 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
const nextSize = calcLinePlaneSize({ x1: nextRL.x1, y1: nextRL.y1, x2: nextRL.x2, y2: nextRL.y2 })
nextRL.attributes = { ...nextRL.attributes, planeSize: nextSize, actualSize: nextSize }
// 옛 corner 좌표에 닿아있던 inner line(hip/ridge/valley/케라바 ext) 끝점을 새 corner 로 snap.
const CORNER_SNAP_TOL = 0.5
for (const il of roof.innerLines || []) {
if (!il) continue
if (Math.hypot(il.x1 - oldCorner1.x, il.y1 - oldCorner1.y) <= CORNER_SNAP_TOL) {
il.set({ x1: newCorner1.x, y1: newCorner1.y })
// [KERAB-OFFSET-SHRINK-TRIM 2026-06-01] 도메인 룰:
// - 기본: skLine + ext 원본 좌표 보존.
// - 원본 끝점이 새 roofLine 변 바깥(wall 쪽)에 있으면 새 roofLine 변과의 교점으로 절삭.
// - 원본 끝점이 새 roofLine 변 안쪽으로 복귀하면 원본 좌표로 복원 (출폭 다시 늘린 케이스).
// 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제.
// 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent.
{
const newAxisMid = { x: (newCorner1.x + newCorner2.x) / 2, y: (newCorner1.y + newCorner2.y) / 2 }
const OUTSIDE_TOL = 0.5
const segOk = (ip) => {
const minX = Math.min(newCorner1.x, newCorner2.x) - OUTSIDE_TOL
const maxX = Math.max(newCorner1.x, newCorner2.x) + OUTSIDE_TOL
const minY = Math.min(newCorner1.y, newCorner2.y) - OUTSIDE_TOL
const maxY = Math.max(newCorner1.y, newCorner2.y) + OUTSIDE_TOL
return ip.x >= minX && ip.x <= maxX && ip.y >= minY && ip.y <= maxY
}
if (Math.hypot(il.x2 - oldCorner1.x, il.y2 - oldCorner1.y) <= CORNER_SNAP_TOL) {
il.set({ x2: newCorner1.x, y2: newCorner1.y })
// [KERAB-PATTERN-CORNER-SNAP 2026-06-01] 케라바 패턴 라인(kLine/ExtRidge/Hip/ExtHip)은
// roofLine corner 변경에 따라 끝점도 같이 이동. corner 일치뿐 아니라 옛 segment 위의
// 중간 점(예: kLine 끝점이 옛 roofLine 변 위)도 새 segment 의 동일 t 비율 점으로 매핑.
const isKerabPatternLine = (il) =>
il &&
(il.lineName === 'kerabPatternRidge' ||
il.lineName === 'kerabPatternExtRidge' ||
il.lineName === 'kerabPatternHip' ||
il.lineName === 'kerabPatternExtHip')
const oldSegDx = oldCorner2.x - oldCorner1.x
const oldSegDy = oldCorner2.y - oldCorner1.y
const oldSegLen2 = oldSegDx * oldSegDx + oldSegDy * oldSegDy || 1
const newSegDx = newCorner2.x - newCorner1.x
const newSegDy = newCorner2.y - newCorner1.y
const mapToNewSeg = (pt) => {
const dx = pt.x - oldCorner1.x
const dy = pt.y - oldCorner1.y
const t = (dx * oldSegDx + dy * oldSegDy) / oldSegLen2
const projX = oldCorner1.x + t * oldSegDx
const projY = oldCorner1.y + t * oldSegDy
const perpD = Math.hypot(pt.x - projX, pt.y - projY)
if (perpD > 0.5) return null
if (t < -0.05 || t > 1.05) return null
return { x: newCorner1.x + t * newSegDx, y: newCorner1.y + t * newSegDy }
}
if (Math.hypot(il.x1 - oldCorner2.x, il.y1 - oldCorner2.y) <= CORNER_SNAP_TOL) {
il.set({ x1: newCorner2.x, y1: newCorner2.y })
}
if (Math.hypot(il.x2 - oldCorner2.x, il.y2 - oldCorner2.y) <= CORNER_SNAP_TOL) {
il.set({ x2: newCorner2.x, y2: newCorner2.y })
for (const il of roof.innerLines || []) {
if (!il) continue
// 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점.
// 절삭/복원 흐름 skip.
if (isKerabPatternLine(il)) {
const np1 = mapToNewSeg({ x: il.x1, y: il.y1 })
if (np1) il.set({ x1: np1.x, y1: np1.y })
const np2 = mapToNewSeg({ x: il.x2, y: il.y2 })
if (np2) il.set({ x2: np2.x, y2: np2.y })
if (typeof il.setCoords === 'function') il.setCoords()
if (np1 || np2) {
logger.log(
'[KERAB-PATTERN-CORNER-SNAP] mapped ' +
JSON.stringify({ lineName: il.lineName, np1, np2, newPts: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } }),
)
}
continue
}
const orig = il.__shrinkOrig || { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }
const orig1 = { x: orig.x1, y: orig.y1 }
const orig2 = { x: orig.x2, y: orig.y2 }
const d1 = (orig1.x - newAxisMid.x) * nx + (orig1.y - newAxisMid.y) * ny
const d2 = (orig2.x - newAxisMid.x) * nx + (orig2.y - newAxisMid.y) * ny
// 양 끝 모두 새 polygon 안쪽 → 원본 복원 + 백업 삭제
if (d1 <= OUTSIDE_TOL && d2 <= OUTSIDE_TOL) {
if (il.__shrinkOrig) {
il.set({ x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 })
if (typeof il.setCoords === 'function') il.setCoords()
logger.log(
'[KERAB-OFFSET-SHRINK-TRIM] restored ' +
JSON.stringify({ lineName: il.lineName, x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }),
)
delete il.__shrinkOrig
}
continue
}
// 바깥 끝점 절삭 (원본 segment 와 새 roofLine 변의 교점)
let nx1 = orig.x1
let ny1 = orig.y1
let nx2 = orig.x2
let ny2 = orig.y2
let trimmed = false
if (d1 > OUTSIDE_TOL) {
const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2)
if (ip && segOk(ip)) {
nx1 = ip.x
ny1 = ip.y
trimmed = true
}
}
if (d2 > OUTSIDE_TOL) {
const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2)
if (ip && segOk(ip)) {
nx2 = ip.x
ny2 = ip.y
trimmed = true
}
}
if (!trimmed) continue
if (!il.__shrinkOrig) {
il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }
}
il.set({ x1: nx1, y1: ny1, x2: nx2, y2: ny2 })
if (typeof il.setCoords === 'function') il.setCoords()
logger.log(
'[KERAB-OFFSET-SHRINK-TRIM] trimmed ' +
JSON.stringify({ lineName: il.lineName, orig, newPts: { x1: nx1, y1: ny1, x2: nx2, y2: ny2 } }),
)
}
}
// points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작.
// [KERAB-OFFSET-SURGICAL 2026-05-29] 출폭 증가 시 새 corner 가 polygon bbox 밖에 있으면
// 외곽선이 안 그려짐. _setPositionDimensions 로 width/height/pathOffset 재계산 + 앵커
// 절대좌표 보존(setPositionByOrigin) 으로 polygon path 가 새 영역까지 다시 그려지게 강제.
const newPoints = roof.points.map((p, i) => {
if (i === idx) return { x: newCorner1.x, y: newCorner1.y }
if (i === (idx + 1) % N) return { x: newCorner2.x, y: newCorner2.y }
return { x: p.x, y: p.y }
})
let absolutePoint = null
let anchorIdx = 0
// 변경 대상이 아닌 첫 인덱스를 앵커로 — pathOffset 갱신 후 그 점 절대 좌표 보존.
for (let i = 0; i < roof.points.length; i++) {
if (i !== idx && i !== (idx + 1) % N) {
anchorIdx = i
break
}
}
try {
if (typeof roof.calcTransformMatrix === 'function' && roof.pathOffset) {
const oldLocal = {
x: roof.points[anchorIdx].x - roof.pathOffset.x,
y: roof.points[anchorIdx].y - roof.pathOffset.y,
}
absolutePoint = fabric.util.transformPoint(oldLocal, roof.calcTransformMatrix())
}
} catch (e) {
absolutePoint = null
}
roof.points = newPoints
roof.set({ points: newPoints, dirty: true })
if (typeof roof._setPositionDimensions === 'function') roof._setPositionDimensions({})
if (absolutePoint && typeof roof.setPositionByOrigin === 'function') {
const strokeW = roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleX || 1, 1e-9) : roof.strokeWidth
const baseW = (roof.width || 0) + strokeW
const baseH = (roof.height || 0) + (roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleY || 1, 1e-9) : roof.strokeWidth)
const newX = (roof.points[anchorIdx].x - roof.pathOffset.x) / Math.max(baseW, 1e-9)
const newY = (roof.points[anchorIdx].y - roof.pathOffset.y) / Math.max(baseH, 1e-9)
roof.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
}
if (typeof roof.setCoords === 'function') roof.setCoords()
// canvas 에 add 된 동일 wallLine 매칭의 eaves(외곽 roofLine) fabric 객체도 같이 갱신.
// [KERAB-OFFSET-SURGICAL 2026-05-29] outerLine 은 wall 좌표(출폭 0 기준) 유지가 원칙.
// corner 좌표(=wall + offset*normal) 로 set 하면 출폭 증가 시 외곽 처마라인이 통째로 이동해
// 화면상 roofLine 이 안 그려진 듯 보임. outerLine 은 attributes 만 갱신, 좌표는 보존.
const canvasEdgeObjs = canvas
.getObjects()
.filter(
@ -142,7 +277,9 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
o.attributes?.wallLine === target.id,
)
for (const eo of canvasEdgeObjs) {
eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true })
if (eo.name !== 'outerLine') {
eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true })
}
eo.attributes = { ...eo.attributes, offset: newOffset, planeSize: newSize, actualSize: newSize }
if (typeof eo.setCoords === 'function') eo.setCoords()
}

2
startscript-btob.js Normal file
View File

@ -0,0 +1,2 @@
var exec = require('child_process').exec
exec('yarn start:btob', { windowsHide: true })