diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 00000000..6bf4e05e --- /dev/null +++ b/.codex/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/.env.development b/.env.development index 357a2f7f..6b3844f8 100644 --- a/.env.development +++ b/.env.development @@ -33,4 +33,8 @@ NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp" S3_PROFILE="dev" #logging -NEXT_PUBLIC_ENABLE_LOGGING=true \ No newline at end of file +NEXT_PUBLIC_ENABLE_LOGGING=true + +#Gemini API Key +GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" +GEMINI_MODEL="gemini-3.1-pro-preview" \ No newline at end of file diff --git a/.env.local.dev b/.env.local.dev index ab789dbf..a53524ca 100644 --- a/.env.local.dev +++ b/.env.local.dev @@ -29,4 +29,8 @@ AWS_ACCESS_KEY_ID="AKIA3K4QWLZHFZRJOM2E" AWS_SECRET_ACCESS_KEY="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" NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp" -S3_PROFILE="dev" \ No newline at end of file +S3_PROFILE="dev" + +#Gemini API Key +GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" +GEMINI_MODEL="gemini-3.1-pro-preview" \ No newline at end of file diff --git a/.env.localhost b/.env.localhost index 3532ed1f..7cab2757 100644 --- a/.env.localhost +++ b/.env.localhost @@ -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" diff --git a/.env.production b/.env.production index 6e2981d9..50904369 100644 --- a/.env.production +++ b/.env.production @@ -32,4 +32,8 @@ NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp" S3_PROFILE="prd" #logging -NEXT_PUBLIC_ENABLE_LOGGING=false \ No newline at end of file +NEXT_PUBLIC_ENABLE_LOGGING=false + +#Gemini API Key +GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" +GEMINI_MODEL="gemini-3.1-pro-preview" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7f720b6a..7a1234d4 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/PDF Gemini 평면도 분석 기능 구현 계획.html b/docs/PDF Gemini 평면도 분석 기능 구현 계획.html new file mode 100644 index 00000000..a401e541 --- /dev/null +++ b/docs/PDF Gemini 평면도 분석 기능 구현 계획.html @@ -0,0 +1,1071 @@ + + + + + +PDF 평면도 → Gemini 분석 → 캔버스 반영 · 구현 계획 + + + + + + + + +
+
+ Q.CAST + docs / floor-plan / pdf-import + + v0.1 · draft + 2026 · 05 · 18 + +
+
+ +
+
Engineering Spec · Floor Plan / PDF Import
+

PDF 평면도 Gemini 분석 캔버스 반영 구현 계획

+

+ 사용자가 업로드한 평면도 PDF 를 Gemini 멀티모달 모델로 분석해 + 건물 외곽선 폴리곤 좌표(mm)를 얻고, Fabric.js 기반 floor-plan + 캔버스에 자동으로 반영한다. 업로드 파일은 분석 종료와 동시에 + Gemini File API 측에서 폐기한다. +

+ +
+
+ 모델 + gemini-3.1-pro-preview +
+
+ 좌표 단위 + mm · clockwise +
+
+ 진입 위치 + floor-plan toolbar +
+
+ 브랜치 + claude/lucid-shaw-91e51f +
+
+
+ +
+ + + +
+ + +
+
+
01
+
+
Requirements
+

기능 요구 사항

+
+
+
+
REQ-01

기본 프로세스 상에서 특정 버튼 클릭으로 사용자가 PDF 문서를 업로드한다.

+
REQ-02

Gemini API 로 해당 문서의 내용 중 평면도를 분석한다.

+
REQ-03

분석 내용을 응답으로 전달받고 현재 프로세스(캔버스)로 전달한다.

+
REQ-04

분석이 성공적으로 마무리되면 업로드한 파일은 폐기 처리한다.

+
+
+ + +
+
+
02
+
+
Decisions
+

결정사항

+
+
+
+
워크트리
현재 워크트리 lucid-shaw-91e51f 에서 진행
+
캔버스 반영 형태
외곽선 폴리곤 좌표(JSON)
+
버튼 위치
floor-plan 상단 툴바
+
API 호출 위치
서버 라우트 /api/gemini/floor-plan
+
Gemini 모델
gemini-3.1-pro-preview
+
좌표 단위
mm
+
툴바 아이콘
신규 PDF 아이콘 추가
+
+
+ + +
+
+
03
+
+
End-to-end Flow
+

전체 흐름

+
+
+ +
+
+
+ Client · UI + 툴바 버튼 클릭 +
+
+
+
+
+ Client · Modal + PdfImportModal — 파일 선택 및 업로드 +
+
+
+
+
+ Server · Route + POST /api/gemini/floor-plan (multipart) +
+
+
+
+
+ Gemini · File API + PDF 업로드 → fileUri 획득 +
+
+
+
+
+ Gemini · gemini-3.1-pro-preview + 외곽선 좌표 JSON 응답 (mm 단위) +
+
+
+
+
+ Server · finally + Gemini 파일 삭제 (성공 / 실패 무관) +
+
+
+
+
+ Client · Canvas + 좌표 검증 → outerLinePointsState + QLine / QPolygon 생성 +
+
+
+

∗ 클라이언트는 서버 라우트만 호출하며 Gemini API 키와 직접 통신하지 않는다.

+
+ + +
+
+
04
+
+
Files
+

핵심 파일 (수정 / 신규)

+
+
+ +
+
+
영역
경로
변경 내용
구분
+
+
+
서버 라우트
+
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 추가
+
신규
+
+
+
+ + +
+
+
05
+
+
Server Route · /api/gemini/floor-plan
+

서버 라우트 설계

+
+
+ +
+
+
5 · 1

입력 (Input)

+
    +
  • Content-Type · multipart/form-data
  • +
  • file · PDF (≤ 20MB, MIME application/pdf)
  • +
  • unitHint (옵션) · mm · auto
  • +
+
+ +
+
5 · 2

처리 (Pipeline)

+
    +
  • formData.get('file') → 사이즈 / MIME 검증
  • +
  • GoogleGenerativeAI(GEMINI_API_KEY) 클라이언트 생성
  • +
  • Gemini File API 업로드 → fileUri 획득
  • +
  • gemini-3.1-pro-preview 호출 · 외곽선 추출 프롬프트 + JSON 강제
  • +
  • 응답 JSON 파싱 + 좌표 검증 (vertex ≥ 3, 자기교차 / 0면적 거부)
  • +
  • finally · genai.files.delete(uploadedFile.name)
  • +
+
+ +
+
5 · 3

출력 (Output)

+
    +
  • 성공 · { outerline:[{x,y},…], unit:'mm', confidence }
  • +
  • 실패 · { error:{ code, message } }
  • +
  • 응답 스키마: { outerline, unit:'mm', scale?, confidence:0..1, notes? }
  • +
  • response_mime_type=application/json · response_schema 강제
  • +
+
+ +
+
5 · 4

보안 / 운영 (Ops)

+
    +
  • GEMINI_API_KEY 는 서버 env 만 사용 · 클라이언트 노출 금지
  • +
  • S3 등 영구 저장소에 절대 저장하지 않음 (REQ-04 폐기)
  • +
  • 로깅은 src/util/logger.js 만 사용
  • +
  • raw 응답 출력은 NEXT_PUBLIC_ENABLE_LOGGING=true 일 때만
  • +
+
+
+
+ + +
+
+
06
+
+
Client Flow
+

클라이언트 흐름

+
+
+ +
+
+

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)addPolygonByLines(lines) 재사용
  • +
  • canvas.renderAll()
  • +
+
+
+
+ + +
+
+
07
+
+
Edge Cases
+

검증 / 엣지 케이스

+
+
+ +
+
PDF 가 아닌 파일
라우트에서 MIME 거부
+
평면도가 아닌 PDF
모델이 빈 outerline 반환 → 친화적 메시지
+
좌표 ≤ 2개 / 자기교차 / 0면적
라우트에서 거부 (SizeSetting 가드와 일관)
+
모델 JSON 파싱 실패
retry 1회 후 실패 메시지
+
Gemini 호출 실패
finally 에서 File API 삭제 보장
+
캔버스 기존 외곽선 존재
confirm 후 덮어쓰기
+
응답이 거대한 폴리곤(> 500 vertex)
Douglas–Peucker 단순화 또는 거부
+
업로드 파일 크기 초과
클라이언트 / 서버 양쪽 가드
+
네트워크 실패
모달에 재시도 버튼 + 파일 폐기 보장
+
+
+ + +
+
+
08
+
+
Conventions · Ops
+

컨벤션 / 운영

+
+
+ +
+
+

Formatting

+
    +
  • Prettier · single quote · no semicolons
  • +
  • tabWidth: 2 · printWidth: 150
  • +
  • 경로 alias @/... 우선 (상대경로 체이닝 지양)
  • +
+
+
+

Messaging / I18n

+
    +
  • 모든 알림 · 다이얼로그는 useSwal + useMessage
  • +
  • raw alert() 금지
  • +
  • 다국어 키 pdf.import.* ja / ko 양쪽 추가
  • +
+
+
+

Logging

+
    +
  • console.log 직접 사용 금지
  • +
  • src/util/logger.js · NEXT_PUBLIC_ENABLE_LOGGING 게이트
  • +
  • Gemini raw 응답은 로깅 플래그 ON 시에만 출력
  • +
+
+
+

Verification

+
    +
  • 마지막에 lint / type / build subagent 점검
  • +
  • dev 에서 실제 평면도 PDF 1~2건 manual test
  • +
+
+
+ +
+
[xxxx] feat(api) — Gemini 평면도 분석 라우트 신설
+
[xxxx] feat(floor-plan) — PDF 임포트 모달 / 툴바 진입
+
[xxxx] feat(floor-plan) — 분석 결과 외곽선 캔버스 반영
+
[xxxx] chore — 다국어 메시지 / lint 정리
+
+
+ + +
+
+
09
+
+
Work Order
+

작업 순서

+
+
+ +
+
+
@google/generative-ai 의존성 추가
+
GEMINI_API_KEY · GEMINI_MODEL env / config 매핑
+
+
+
서버 라우트 src/app/api/gemini/floor-plan/route.js 작성
+
Gemini 호출 + 파일 폐기 (try / finally)
+
+
+
menusStatepdf-import 항목 + PDF 아이콘 SVG 추가
+
기존 menusState 아이콘 컨벤션에 맞춤
+
+
+
CanvasMenu.jsx onClickNav switch 에 케이스 추가
+
modalAtom 으로 PdfImportModal 오픈
+
+
+
PdfImportModal.jsx 작성
+
idle → uploading → analyzing → success / error 상태머신
+
+
+
usePdfImport.js 작성
+
단위 / 방향 정규화 · 외곽선 덮어쓰기 분기 · QLine / QPolygon 반영
+
+
+
다국어 메시지 키 추가
+
pdf.import.* · ja / ko 양쪽
+
+
+
yarn lint + dev 환경 manual test
+
실제 평면도 PDF 1~2건으로 회귀 확인
+
+
+
의미 단위로 커밋
+
§ 8 의 commit 단위 표 참고
+
+
+
+ +
+
+ + + + + + + diff --git a/docs/PDF Gemini 평면도 분석 기능 구현 계획.md b/docs/PDF Gemini 평면도 분석 기능 구현 계획.md new file mode 100644 index 00000000..7a5db65a --- /dev/null +++ b/docs/PDF Gemini 평면도 분석 기능 구현 계획.md @@ -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) | Douglas–Peucker 단순화 또는 거부 | +| 업로드 파일 크기 초과 | 클라이언트/서버 양쪽 가드 | +| 네트워크 실패 | 모달에 재시도 버튼 + 파일 폐기 보장 | + +--- + +## 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. 의미 단위로 커밋 diff --git a/package.json b/package.json index e4045015..e990626b 100644 --- a/package.json +++ b/package.json @@ -14,12 +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:ur": "env-cmd -f .env.localhost next start -p 5050", + "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", diff --git a/src/app/api/gemini/floor-plan/route.js b/src/app/api/gemini/floor-plan/route.js new file mode 100644 index 00000000..0d34a90e --- /dev/null +++ b/src/app/api/gemini/floor-plan/route.js @@ -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) + } + } + } +} diff --git a/src/components/common/input/CalcInput.jsx b/src/components/common/input/CalcInput.jsx index a92b86c7..6a26174d 100644 --- a/src/components/common/input/CalcInput.jsx +++ b/src/components/common/input/CalcInput.jsx @@ -77,7 +77,7 @@ export const CalculatorInput = forwardRef( newDisplayValue = calculator.currentOperand setDisplayValue(newDisplayValue) - onChange(newDisplayValue) + onChange?.(newDisplayValue) requestAnimationFrame(() => { if (inputRef.current) { @@ -145,7 +145,7 @@ export const CalculatorInput = forwardRef( newDisplayValue = calculator.currentOperand setDisplayValue(newDisplayValue) if (!hasOperation) { - onChange(calculator.currentOperand) + onChange?.(calculator.currentOperand) } } else if (num === '.') { if (!calculator.currentOperand.includes('.')) { @@ -153,7 +153,7 @@ export const CalculatorInput = forwardRef( newDisplayValue = calculator.currentOperand setDisplayValue(newDisplayValue) if (!hasOperation) { - onChange(newDisplayValue) + onChange?.(newDisplayValue) } } } else if (!shouldPreventInput(calculator.currentOperand)) { @@ -161,7 +161,7 @@ export const CalculatorInput = forwardRef( newDisplayValue = calculator.currentOperand setDisplayValue(newDisplayValue) if (!hasOperation) { - onChange(newDisplayValue) + onChange?.(newDisplayValue) } } // else { @@ -169,7 +169,7 @@ export const CalculatorInput = forwardRef( // newDisplayValue = calculator.currentOperand // setDisplayValue(newDisplayValue) // if (!hasOperation) { - // onChange(newDisplayValue) + // onChange?.(newDisplayValue) // } // } } @@ -236,7 +236,7 @@ export const CalculatorInput = forwardRef( const displayValue = newValue || '0' setDisplayValue(displayValue) setHasOperation(false) - onChange(displayValue) + onChange?.(displayValue) // 커서를 텍스트 끝으로 이동하고 스크롤 처리 requestAnimationFrame(() => { if (inputRef.current) { @@ -260,7 +260,7 @@ export const CalculatorInput = forwardRef( setDisplayValue(resultStr) setHasOperation(false) // Only call onChange with the final result - onChange(resultStr) + onChange?.(resultStr) // 엔터키로 호출된 경우 포커스 설정하지 않음 if (!fromEnterKey) { @@ -282,7 +282,7 @@ export const CalculatorInput = forwardRef( const displayValue = newValue || '0' setDisplayValue(displayValue) setHasOperation(!!calculator.operation) - onChange(displayValue) + onChange?.(displayValue) // 커서를 텍스트 끝으로 이동하고 스크롤 처리 requestAnimationFrame(() => { if (inputRef.current) { @@ -337,10 +337,10 @@ export const CalculatorInput = forwardRef( calculator.currentOperand = filteredValue setHasOperation(false) // 연산자가 없는 순수 숫자일 때만 부모 컴포넌트의 onChange 호출 - onChange(filteredValue) + onChange?.(filteredValue) } - //onChange(filteredValue) + //onChange?.(filteredValue) } } @@ -485,7 +485,7 @@ export const CalculatorInput = forwardRef( onClick={() => { // const newValue = calculatorRef.current.clear() // setDisplayValue(newValue || '0') - // onChange(newValue || '0') + // onChange?.(newValue || '0') handleClear() setHasOperation(false) }} @@ -497,7 +497,7 @@ export const CalculatorInput = forwardRef( onClick={() => { // const newValue = calculatorRef.current.deleteNumber() // setDisplayValue(newValue || '0') - // onChange(newValue || '0') + // onChange?.(newValue || '0') //setHasOperation(!!calculatorRef.current.operation) handleDelete() }} @@ -532,7 +532,7 @@ export const CalculatorInput = forwardRef( } + + +
+ {getMessage('modal.placement.initial.setting.size.pdf.file.info')} +
+ + + + {getMessage('modal.placement.initial.setting.size.pdf.page.label')} + +
+
+ setPdfPageMode(e.target.value)} + /> + +
+
+ setPdfPageMode(e.target.value)} + /> + +
+ {pdfPageMode === PDF_PAGE_MODE.SPECIFY && ( + <> + {getMessage('modal.placement.initial.setting.size.pdf.page.facade.label')} + setPdfFacadePages(e.target.value)} + /> + {getMessage('modal.placement.initial.setting.size.pdf.page.floor.label')} + setPdfFloorPages(e.target.value)} + /> + + )} +
+
+ {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')} +
+ + + + )}
@@ -635,10 +861,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
-
diff --git a/src/hooks/pdf-import/usePdfImport.js b/src/hooks/pdf-import/usePdfImport.js new file mode 100644 index 00000000..01fa4fb2 --- /dev/null +++ b/src/hooks/pdf-import/usePdfImport.js @@ -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 } +} diff --git a/src/hooks/roofcover/useAuxiliaryDrawing.js b/src/hooks/roofcover/useAuxiliaryDrawing.js index e9e4a826..17a1d70a 100644 --- a/src/hooks/roofcover/useAuxiliaryDrawing.js +++ b/src/hooks/roofcover/useAuxiliaryDrawing.js @@ -622,10 +622,13 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { arrivalPoint = { ...adsorptionPoint } } + // [2240 MOUSELINE-EVENTED-FALSE 2026-05-20] selectable:false 만으로는 e.target 가로채기 차단 불가 — 클릭이 + // 십자선에 잡히는 회귀 차단. mouseLine 은 시각 표시만, 좌표 계산은 filter(name==='mouseLine') 로 접근. const horizontalLine = new fabric.Line([-1 * canvas.width, arrivalPoint.y, 2 * canvas.width, arrivalPoint.y], { stroke: 'red', strokeWidth: 1, selectable: false, + evented: false, name: 'mouseLine', }) @@ -634,6 +637,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) { stroke: 'red', strokeWidth: 1, selectable: false, + evented: false, name: 'mouseLine', }) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 57a7ed6d..db3dbb0f 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -15,7 +15,13 @@ import { useUndoRedo } from '@/hooks/useUndoRedo' import { fabric } from 'fabric' import { QLine } from '@/components/fabric/QLine' import { calcLinePlaneSize } from '@/util/qpolygon-utils' +import { findInteriorPoint } from '@/util/skeleton-utils' import { logger } from '@/util/logger' +import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical' + +// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 시 出幅 변경분 surgical 반영 기능 토글. +// false 로 두면 이번 세션 변경 전 동작 (apply/revert 시 출폭 입력값 무시) 으로 즉시 회귀. +const ENABLE_KERAB_OFFSET_SURGICAL = true // 처마.케라바 변경 export function useEavesGableEdit(id) { @@ -147,12 +153,6 @@ export function useEavesGableEdit(id) { } const mouseDownEvent = (e) => { - logger.log('[KERAB-MOUSEDOWN] fired', { - hasTarget: !!e.target, - name: e.target?.name, - type: typeRef.current, - radio: radioTypeRef.current, - }) canvas.discardActiveObject() if (!e.target || (e.target && e.target.name !== 'outerLine')) { return @@ -233,72 +233,1589 @@ export function useEavesGableEdit(id) { return } - // [2240 KERAB-SINGLE-RIDGE 2026-05-19] 대전제 1: 처마→케라바 변환 시 양 끝점에 동일 apex 의 hip 페어가 존재하면 - // hip 2개 제거 + 처마 중점→apex ridge 1개 생성으로 처리하고 기존 rebuild 흐름은 건너뜀. - // 그 외 모든 케이스(hip 0/1개, 평행, 다중 변환 후 hip 소진 등)는 attribute-only 로 처리하고 rebuild 진입 차단. + // [2240 KERAB-NEIGHBOR-GABLE 2026-05-19] 「ケラバの隣にケラバは不可」 + // 처마→케라바 변환 시, target 의 끝점을 공유하는 인접 외곽선 중 하나라도 이미 케라바(GABLE) 면 + // 모든 패턴 시도 전에 조용히 무동작 (alert 없음). + if (typeRef.current === TYPES.GABLE && radioTypeRef.current === '1') { + const isSameXY = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) <= 0.5 + const tP1 = { x: target.x1, y: target.y1 } + const tP2 = { x: target.x2, y: target.y2 } + const neighbors = canvas + .getObjects() + .filter((o) => o.name === 'outerLine' && o !== target && o.attributes?.roofId === target.attributes?.roofId) + const sharesEndpoint = (o, pt) => isSameXY({ x: o.x1, y: o.y1 }, pt) || isSameXY({ x: o.x2, y: o.y2 }, pt) + const adjGable = neighbors.find((o) => o.attributes?.type === LINE_TYPE.WALLLINE.GABLE && (sharesEndpoint(o, tP1) || sharesEndpoint(o, tP2))) + if (adjGable) { + logger.log('[KERAB-NEIGHBOR-GABLE] 인접 외곽선이 케라바 → 무동작') + return + } + } + + // [2240 KERAB-SIMPLE 2026-05-20] 사용자 설명 정직 알고리즘: + // 1) target 양 끝점에 직접 끝이 닿은 hip 2개를 찾는다 (nearestRoofPoint 안 씀) + // 2) 두 hip 직선의 무한확장 교점 = apex + // 3) apex 를 통과하는 ridge(RG-1)가 존재하면 케라바 조건 충족 + // 4) mid(target 중점) → apex 중앙선만 추가 (기존 라인 무손상) if (typeRef.current === TYPES.GABLE && radioTypeRef.current === '1') { const roof = canvas.getObjects().find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) - logger.log('[KERAB-GUARD] roof check', { - roofId: target.attributes?.roofId, - roofFound: !!roof, - innerLinesArray: Array.isArray(roof?.innerLines), - pointsArray: Array.isArray(roof?.points), - innerLinesLen: roof?.innerLines?.length, - pointsLen: roof?.points?.length, - }) - if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) { - // outerLine 끝점(벽 좌표)을 가장 가까운 roof 폴리곤 코너(offset 적용 좌표)로 스냅 - const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 }) - const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 }) - logger.log('[KERAB-GUARD] c1/c2', { - targetXY: { x1: target.x1, y1: target.y1, x2: target.x2, y2: target.y2 }, - c1, - c2, - }) - if (c1 && c2) { - const hipsAtC1 = findHipsAtPoint(roof, c1) - const hipsAtC2 = findHipsAtPoint(roof, c2) - logger.log('[KERAB-GUARD] hips at corners', { c1n: hipsAtC1.length, c2n: hipsAtC2.length }) - const pair = hipsAtC1.length > 0 && hipsAtC2.length > 0 ? findHipPairWithSharedApex(hipsAtC1, c1, hipsAtC2, c2) : null - logger.log('[KERAB-GUARD] shared-apex pair', { found: !!pair }) - if (pair) { - target.set({ attributes }) - const ok = applyKerabSingleRidgePattern(roof, target, c1, c2, pair) - logger.log('[KERAB-PATTERN] applied', { ok, c1, c2, apex: pair.apex }) - if (ok) return + logger.log( + '[KERAB-SIMPLE] roof check ' + + JSON.stringify({ + roofId: target.attributes?.roofId, + roofFound: !!roof, + }), + ) + // [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) + if (roof && Array.isArray(roof.innerLines)) { + // [KERAB-LABEL-LOOKUP 2026-05-21] QPolygon.__attachDebugLabels 와 동일 분류·카운팅 순서로 + // 캔버스 객체에 라벨(H-1, RG-2, B-3 등) 매핑. 로그에 라벨을 함께 찍기 위함. + const labelByLine = new Map() + { + const counters = {} + const objs = canvas.getObjects().filter((o) => o.parentId === roof.id && o.name !== '__debugLabel') + for (const obj of objs) { + let prefix = null + const nm = obj.name + const ln = obj.lineName + const tp = obj.attributes?.type + if (nm === 'baseLine') prefix = 'B' + else if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') prefix = 'R' + else if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) prefix = 'H' + else if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) prefix = 'RG' + else if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) prefix = 'V' + else if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) prefix = 'G' + else if (nm === LINE_TYPE.SUBLINE.VERGE || ln === LINE_TYPE.SUBLINE.VERGE) prefix = 'VG' + else if (tp === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || ln === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || nm === LINE_TYPE.WALLLINE.EAVE_HELP_LINE) + prefix = 'E' + else if (typeof obj.x1 === 'number' && typeof obj.y1 === 'number' && typeof obj.x2 === 'number' && typeof obj.y2 === 'number') + prefix = 'SK' + if (!prefix) continue + counters[prefix] = (counters[prefix] || 0) + 1 + labelByLine.set(obj, `${prefix}-${counters[prefix]}`) } - // [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] shared-apex 가 없으면 H-7/H-1 의 junction 너머 - // H-4/H-2(inner hip) 를 무한확장한 교점을 apex 로. 채택 시 outer(H-7,H-1) 제거 + - // inner(H-4,H-2) 끝을 apex 로 늘림 + mid→apex ridge. H-7/H-1 직접 확장보다 안쪽까지 중앙선이 연장됨. - const jp = findHipPairViaJunction(roof, c1, c2) - logger.log('[KERAB-GUARD] junction-extended pair', { found: !!jp, apex: jp?.apex }) - if (jp) { - target.set({ attributes }) - const ok = applyKerabJunctionExtendedPattern(roof, target, c1, c2, jp) - logger.log('[KERAB-PATTERN] applied (junction-extended)', { ok }) - if (ok) return - } - // [2240 KERAB-EXTENDED-APEX 2026-05-19] junction-extended 도 안 되면 H-7/H-1 직접 무한확장한 교점을 apex 로. - // apex 가 처마 중점 수선 위(가운데) 일 때만 채택. 채택 시 단일-ridge 패턴과 동일 처리. - const pairExt = hipsAtC1.length > 0 && hipsAtC2.length > 0 ? findHipPairWithExtendedApex(hipsAtC1, c1, hipsAtC2, c2) : null - logger.log('[KERAB-GUARD] extended-apex pair', { found: !!pairExt, apex: pairExt?.apex }) - if (pairExt) { - target.set({ attributes }) - const ok = applyKerabSingleRidgePattern(roof, target, c1, c2, pairExt) - logger.log('[KERAB-PATTERN] applied (extended)', { ok }) - if (ok) return - } - // [2240 KERAB-ATTR-ONLY 2026-05-19] hip 페어 없음(0/1개·평행·다중변환 소진·apex 가 중앙 아님 등) → 속성만 GABLE 로 변경 - target.set({ attributes }) - applyKerabAttributeOnlyPattern() - logger.log('[KERAB-ATTR-ONLY] applied (forward)', { - c1, - c2, - hipsC1: hipsAtC1.length, - hipsC2: hipsAtC2.length, - }) - return } + const labelOf = (line) => (line ? labelByLine.get(line) || '?' : null) + const t1 = { x: target.x1, y: target.y1 } + const t2 = { x: target.x2, y: target.y2 } + const h1Match = findHipAtEndpoint(roof, t1) + const h2Match = findHipAtEndpoint(roof, t2) + logger.log( + '[KERAB-SIMPLE] hip lookup ' + + JSON.stringify({ + target: labelOf(target), + t1, + t2, + h1: h1Match + ? { + label: labelOf(h1Match.hip), + near: h1Match.near, + far: h1Match.far, + dist: Math.round(h1Match.dist * 100) / 100, + } + : null, + h2: h2Match + ? { + label: labelOf(h2Match.hip), + near: h2Match.near, + far: h2Match.far, + dist: Math.round(h2Match.dist * 100) / 100, + } + : null, + }), + ) + if (h1Match && h2Match) { + // [KERAB-APEX-FAR-AS-PARALLEL 2026-05-21] lineLineIntersection 은 완전 평행(det≈0) 만 null 반환. + // 거의 평행한 두 hip 은 천문학적 좌표의 가짜 apex 를 만들어 markerApex 오염. 좌표 크기로 평행 강제 판정. + let apex = lineLineIntersection(h1Match.near, h1Match.far, h2Match.near, h2Match.far) + if (apex) { + const APEX_FAR_LIMIT = 1e5 + if (Math.abs(apex.x) > APEX_FAR_LIMIT || Math.abs(apex.y) > APEX_FAR_LIMIT) { + apex = null + } + } + + logger.log( + '[KERAB-SIMPLE] apex ' + + JSON.stringify({ + apex: apex ? { x: Math.round(apex.x * 100) / 100, y: Math.round(apex.y * 100) / 100 } : null, + parallel: !apex, + }), + ) + // [KERAB-PARALLEL-FULLALGO 2026-05-21] 평행(apex=null) 도 풀 알고리즘으로 처리. + // 폴리곤 경로 + extender 확장 + 반사/meet/apex/kLine — h1·h2 만나지 않더라도 + // 내부 라인(path hips/ridges) 은 삭제, 접점 extender 는 인쪽 확장. + // 자연 만남(condition 1) 만 단축: apex 존재 + h*.far ≈ apex. + { + const EXT_TOL = 1.0 + const isNatural = + !!apex && + Math.hypot(h1Match.far.x - apex.x, h1Match.far.y - apex.y) <= EXT_TOL && + Math.hypot(h2Match.far.x - apex.x, h2Match.far.y - apex.y) <= EXT_TOL + if (!isNatural) { + // [KERAB-POLYGON-BFS 2026-05-21] 사용자 전제 2: 내부 다각형 경계 = BFS 로 추적한 + // h1.far → h2.far 경로 + h1 + h2. 경로상 모든 hip/ridge 를 삭제 대상으로 모음. + // 직접 연결(RG-1) 뿐 아니라 비대칭(Ridge→junction→otherHip 체인) 도 한 번에 처리. + const polygonPath = traceInnerPolygonPath(roof, h1Match.far, h2Match.far, [h1Match.hip, h2Match.hip]) + logger.log( + '[KERAB-SIMPLE] polygonPath ' + + JSON.stringify({ + found: polygonPath !== null, + length: polygonPath ? polygonPath.length : 0, + lines: polygonPath + ? polygonPath.map((p) => ({ + label: labelOf(p.line), + name: p.line.name, + lineName: p.line.lineName, + x1: p.line.x1, + y1: p.line.y1, + x2: p.line.x2, + y2: p.line.y2, + })) + : null, + }), + ) + // [KERAB-VALLEY-DIAG 2026-05-27] polygonPath 라인들의 valley vertex 식별 (진단). + // apex 유무 무관 — valley 가 polygonPath 에 존재하면 valleyExt 후보 (gate 완화 2026-05-27). + // surgical 출폭 변경으로 H-3·H-2 평행성이 살짝 깨져 apex 가 폴리곤 밖 멀리 잡히는 케이스(거의 평행) + // 에서도 처마확장이 그려져야 함. 내부의 `h1FarIsValley || h2FarIsValley` 가드가 자동 skip 보장. + if (polygonPath) { + const valleyPool = [h1Match.hip, h2Match.hip, target, ...polygonPath.map((p) => p.line)] + const valleyInfo = [] + for (const line of [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)]) { + if (!line) continue + const info = findInteriorPoint(line, valleyPool) + valleyInfo.push({ + label: labelOf(line), + name: line.name, + lineName: line.lineName, + x1: Math.round(line.x1 * 100) / 100, + y1: Math.round(line.y1 * 100) / 100, + x2: Math.round(line.x2 * 100) / 100, + y2: Math.round(line.y2 * 100) / 100, + startValley: info.start, + endValley: info.end, + }) + } + logger.log('[KERAB-VALLEY-DIAG] ' + JSON.stringify(valleyInfo)) + } + // [KERAB-VALLEY-EXT 2026-05-27] 모델 교체: skeleton valley → 외곽 polygon concave corner. + // "확장" = roofLine 의 한쪽 끝점에서 self-extension (반대 끝점 → 그 끝점 방향) 으로 연장. + // concave corner 옆 끝점 self-extension 은 polygon 내부로 향함 → 첫 hip/ridge 와 hit. + // convex 측 끝점 self-extension 은 polygon 외부로 새서 hit 없음 → 자동 skip. + // 양 끝점 둘 다 후보로 push → raycast 가 valley 측을 자동 결정. + const valleyPlannedEndpoints = [] + if (polygonPath) { + const matchingRoofLine = Array.isArray(roof.lines) ? roof.lines.find((rl) => rl && rl.attributes?.wallLine === target.id) : null + logger.log( + '[KERAB-VALLEY-EXT] roofLine-match ' + + JSON.stringify({ + targetId: target.id, + targetIdx: target.idx, + found: !!matchingRoofLine, + rl: matchingRoofLine + ? { + x1: matchingRoofLine.x1, + y1: matchingRoofLine.y1, + x2: matchingRoofLine.x2, + y2: matchingRoofLine.y2, + } + : null, + }), + ) + if (matchingRoofLine) { + valleyPlannedEndpoints.push( + { + sx: matchingRoofLine.x1, + sy: matchingRoofLine.y1, + ox: matchingRoofLine.x2, + oy: matchingRoofLine.y2, + label: 'roofBase-s', + parent: matchingRoofLine, + }, + { + sx: matchingRoofLine.x2, + sy: matchingRoofLine.y2, + ox: matchingRoofLine.x1, + oy: matchingRoofLine.y1, + label: 'roofBase-e', + parent: matchingRoofLine, + }, + ) + } else { + logger.log('[KERAB-VALLEY-EXT] no matching roof.lines for target.id=' + target.id) + } + } + if (polygonPath === null) { + logger.log('[KERAB-SIMPLE] no polygon path → attr-only fallback') + target.set({ attributes }) + applyKerabAttributeOnlyPattern() + return + } + const polygonLines = [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)] + // [KERAB-EXTENDER 2026-05-21] 사용자 전제 3: 접점(h1.far, h2.far, 중간 junction) 에서 polygon path 가 아닌 + // inner line(hip OR ridge) 을 extender 로 식별. 경로상 라인은 제외. + const ext1 = findExtenderAtPoint(roof, h1Match.far, polygonLines) + const ext2 = findExtenderAtPoint(roof, h2Match.far, polygonLines) + // [KERAB-JUNCTION-EXT 2026-05-21] 중간 touch point(junction) 의 extender 도 식별 — 모든 + // 사용 가능한 extender 를 수집해 가장 가까운 meet 부터 순차 해소(H-7↔RG-2 → H-1↔roofLine 등). + const intermediatePoints = [] + for (let i = 0; i < polygonPath.length - 1; i++) { + intermediatePoints.push(polygonPath[i].to) + } + const junctionExtenders = intermediatePoints.map((jp) => { + const allAtJ = [] + for (const il of roof.innerLines || []) { + if (!il || polygonLines.includes(il)) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + const dA = Math.hypot(a.x - jp.x, a.y - jp.y) + const dB = Math.hypot(b.x - jp.x, b.y - jp.y) + if (dA <= 1.0) allAtJ.push({ line: il, near: a, far: b, isHip: il.name === LINE_TYPE.SUBLINE.HIP }) + else if (dB <= 1.0) + allAtJ.push({ + line: il, + near: b, + far: a, + isHip: il.name === LINE_TYPE.SUBLINE.HIP, + }) + } + return { jp, exts: allAtJ } + }) + logger.log( + '[KERAB-SIMPLE] extenders ' + + JSON.stringify({ + h1Far: h1Match.far, + h2Far: h2Match.far, + e1: ext1 ? { label: labelOf(ext1.line), near: ext1.near, far: ext1.far, isHip: ext1.isHip } : null, + e2: ext2 ? { label: labelOf(ext2.line), near: ext2.near, far: ext2.far, isHip: ext2.isHip } : null, + junctions: junctionExtenders.map((j) => ({ + jp: j.jp, + exts: j.exts.map((e) => ({ + label: labelOf(e.line), + near: e.near, + far: e.far, + isHip: e.isHip, + lineName: e.line.lineName, + })), + })), + }), + ) + // [KERAB-SEQ-RESOLVE 2026-05-21] 사용자 모델: 모든 접점의 extender 를 모아 인쪽 확장. + // 가장 가까운 meet 부터 순차 해소(hip-hip → apex+kLine, hip-ridge → 그 자리 stop). + // 짝 잃은 extender 는 roofLine 까지 확장. parallel 도 자동 처리. + // [KERAB-POLYGON-INSIDE-REVERT 2026-05-21] sub-polygon 내부 필터(goesIntoPolygon) 제거. + // 해당 필터가 평행 케이스의 정상 extender 까지 거름 → 회귀. RG-1 류는 no-pierce(barrier) 로 처리. + const allExtenders = [] + if (ext1) allExtenders.push({ ...ext1, sourcePoint: h1Match.far }) + if (ext2) allExtenders.push({ ...ext2, sourcePoint: h2Match.far }) + for (const j of junctionExtenders) { + for (const e of j.exts) { + allExtenders.push({ ...e, sourcePoint: j.jp }) + } + } + logger.log( + '[KERAB-SIMPLE] extenders-filtered ' + + JSON.stringify({ + accepted: allExtenders.map((e) => ({ + label: labelOf(e.line), + near: { x: Math.round(e.near.x * 100) / 100, y: Math.round(e.near.y * 100) / 100 }, + far: { x: Math.round(e.far.x * 100) / 100, y: Math.round(e.far.y * 100) / 100 }, + isHip: e.isHip, + })), + }), + ) + if (allExtenders.length === 0) { + logger.log('[KERAB-SIMPLE] no extenders — fallback attr-only') + target.set({ attributes }) + applyKerabAttributeOnlyPattern() + return + } + // 인쪽 방향 검증: extender 자연 방향(near→far)의 반대(near→inward) 와 일치해야 함. + const isInward = (ext, pt) => { + const ix = ext.near.x - ext.far.x + const iy = ext.near.y - ext.far.y + const px = pt.x - ext.near.x + const py = pt.y - ext.near.y + return ix * px + iy * py > 1e-3 + } + // [KERAB-ITER-REFLECT 2026-05-21] 반사 hip 을 1급 extender 로 풀에 추가하고 wave 반복. + // wave: 미해소 extender 들의 meet 계산 → 가장 가까운 meet 부터 해소 → + // hip+(ridge/kLine) → 반사 hip 생성하여 풀에 추가 → + // hip+hip → apex + 새 kLine 도 다음 wave 의 barrier (현재는 첫 apex 하나만 추적). + // 미해소는 roofLine fallback. + const existingKLines = (roof.innerLines || []).filter( + (il) => il && il.lineName === 'kerabPatternRidge' && !il.__noKLine && il.__targetId !== target.id && il.visible !== false, + ) + const isPointOnSegment = (pt, ax, ay, bx, by, tol = 0.5) => { + const dx = bx - ax + const dy = by - ay + const lenSq = dx * dx + dy * dy + if (lenSq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol + const t = ((pt.x - ax) * dx + (pt.y - ay) * dy) / lenSq + const margin = tol / Math.sqrt(lenSq) + if (t < -margin || t > 1 + margin) return false + const px = ax + t * dx + const py = ay + t * dy + return Math.hypot(px - pt.x, py - pt.y) <= tol + } + // [KERAB-ROOF-MAX-INWARD 2026-05-27] roof polygon wall 정의를 wave 시작 전으로 이동 (이전 L896). + // 사용자 규칙: "확장을 해도 roofLine 까지이다. 절대 통과 못한다." + // wave 의 모든 meet 후보 거리를 ext.maxInwardDist 로 제한 → roofLine 너머 meet 거부. + const roofPolygonWalls = [] + if (Array.isArray(roof.points) && roof.points.length >= 2) { + for (let i = 0; i < roof.points.length; i++) { + roofPolygonWalls.push({ + a: roof.points[i], + b: roof.points[(i + 1) % roof.points.length], + }) + } + } + const computeMaxInwardDist = (ext) => { + let best = Infinity + for (const wall of roofPolygonWalls) { + const ip = lineLineIntersection(ext.near, ext.far, wall.a, wall.b) + if (!ip) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, wall.a.x, wall.a.y, wall.b.x, wall.b.y)) continue + const d = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + if (d < 1e-3) continue + if (d < best) best = d + } + return best + } + const MAX_INWARD_TOL = 0.5 + for (const ext of allExtenders) { + ext.maxInwardDist = computeMaxInwardDist(ext) + } + // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] 골짜기확장라인 사전 계산 — hip/ridge wave 의 barrier 로 사용. + // 사용자 규칙: "힙/마루 라인은 골짜기 확장라인 및 roofLine 까지. 절대 통과 못한다." + // pre-wave 상태(polygonPath 라인 미삭제) 의 roof.innerLines 로 raycast. polygonLines 는 곧 삭제될 라인이라 stopper 제외. + // 최종 valleyExt 좌표는 L1280+ 에서 post-wave 상태로 다시 raycast → 약간의 차이 있을 수 있음 (수용). + const valleyExtPreSegs = [] + if (valleyPlannedEndpoints.length) { + for (const ep of valleyPlannedEndpoints) { + 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 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 단계에서 절삭). + let bestPt = 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 (!isPointOnSegment(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) { + bestPt = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 } + 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 + + '}', + ) + } + if (bestPt) { + valleyExtPreSegs.push({ x1: start.x, y1: start.y, x2: bestPt.x, y2: bestPt.y, label: ep.label }) + } + } + logger.log( + '[KERAB-VALLEY-EXT-PRE] ' + + JSON.stringify( + valleyExtPreSegs.map((s) => ({ + label: s.label, + from: { x: Math.round(s.x1 * 100) / 100, y: Math.round(s.y1 * 100) / 100 }, + to: { x: Math.round(s.x2 * 100) / 100, y: Math.round(s.y2 * 100) / 100 }, + })), + ), + ) + } + const extenderPool = [...allExtenders] + const resolved = new Map() + // [KERAB-MULTI-APEX 2026-05-22] hip+hip 이 90° 로 만나는 모든 apex 수집 → 각 apex 마다 kLine 1개. + // 첫 apex 는 primary(applyKerabKLinePattern), 이후 apex 는 보조 ridge. + const apexList = [] + const pendingKLines = [] + const PERP_EPS = 0.05 + const isPerpendicular = (vax, vay, vbx, vby) => { + const ma = Math.hypot(vax, vay) || 1 + const mb = Math.hypot(vbx, vby) || 1 + return Math.abs((vax * vbx + vay * vby) / (ma * mb)) < PERP_EPS + } + const computePendingKLine = (apex) => { + const ax = h2Match.near.x - h1Match.near.x + const ay = h2Match.near.y - h1Match.near.y + const aSq = ax * ax + ay * ay || 1 + const tFoot = ((apex.x - h1Match.near.x) * ax + (apex.y - h1Match.near.y) * ay) / aSq + const foot = { x: h1Match.near.x + tFoot * ax, y: h1Match.near.y + tFoot * ay } + return { x1: apex.x, y1: apex.y, x2: foot.x, y2: foot.y } + } + const pushApexIfNew = (point, callerTag = '?') => { + const dup = apexList.some((ap) => Math.hypot(ap.point.x - point.x, ap.point.y - point.y) < 0.5) + if (dup) { + logger.log('[KERAB-APEX-PUSH-DUP]', callerTag, { x: point.x?.toFixed?.(2), y: point.y?.toFixed?.(2) }) + return false + } + apexList.push({ point: { x: point.x, y: point.y } }) + const pk = computePendingKLine(point) + pendingKLines.push(pk) + logger.log('[KERAB-APEX-PUSH]', callerTag, { + apex: { x: point.x?.toFixed?.(2), y: point.y?.toFixed?.(2) }, + kLine: { + x1: pk.x1?.toFixed?.(2), + y1: pk.y1?.toFixed?.(2), + x2: pk.x2?.toFixed?.(2), + y2: pk.y2?.toFixed?.(2), + }, + apexCount: apexList.length, + }) + return true + } + // [KERAB-FIXPOINT-PHASE-A 2026-05-22] 정적 inner line 만남 시 절삭 정보 누적. + // wave 종료 후 apply 직전에 fabric line 좌표를 갱신해 그 점 너머 부분을 제거한다. + const cuts = [] + const MAX_ITER = 10 + for (let iter = 0; iter < MAX_ITER; iter++) { + const unresolved = extenderPool.filter((e) => !resolved.has(e)) + if (unresolved.length === 0) break + const meets = [] + // [KERAB-MEETS-FAR-GUARD 2026-05-21] 거의 평행한 두 extender(H-2 vs H-3 등) 의 교점은 + // 천문학적 좌표(예: ±6e5) 로 돌아오고 isInward 도 통과 → 가짜 meet 후보 등록. + // line 291 APEX_FAR_LIMIT 와 동일 임계 1e5 로 거부, 짝 잃은 ext 는 fallback 경로(roof wall)로. + const MEETS_FAR_LIMIT = 1e5 + for (let i = 0; i < unresolved.length; i++) { + for (let k = i + 1; k < unresolved.length; k++) { + const ea = unresolved[i] + const eb = unresolved[k] + const ip = lineLineIntersection(ea.near, ea.far, eb.near, eb.far) + if (!ip) continue + if (Math.abs(ip.x) > MEETS_FAR_LIMIT || Math.abs(ip.y) > MEETS_FAR_LIMIT) continue + if (!isInward(ea, ip) || !isInward(eb, ip)) continue + const dA = Math.hypot(ip.x - ea.near.x, ip.y - ea.near.y) + const dB = Math.hypot(ip.x - eb.near.x, ip.y - eb.near.y) + meets.push({ + a: ea, + b: eb, + point: ip, + minDist: Math.min(dA, dB), + bothHips: ea.isHip && eb.isHip, + }) + } + } + const kLineMeets = [] + const kLineCandidates = [...existingKLines, ...pendingKLines] + for (const ext of unresolved) { + for (const kl of kLineCandidates) { + const ip = lineLineIntersection(ext.near, ext.far, { x: kl.x1, y: kl.y1 }, { x: kl.x2, y: kl.y2 }) + if (!ip) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, kl.x1, kl.y1, kl.x2, kl.y2)) continue + const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + if (dist < 1e-3) continue + kLineMeets.push({ ext, point: ip, dist, kLine: kl }) + } + } + // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] 골짜기확장라인 segment 와의 meet — hip/ridge 가 그 점에서 정지. + // mirror/reflection 없음 (단순 정지). 거리 가장 짧은 후보면 우선 처리되어 그 너머로 못 감. + const valleyExtMeets = [] + for (const ext of unresolved) { + for (const vs of valleyExtPreSegs) { + const ip = lineLineIntersection(ext.near, ext.far, { x: vs.x1, y: vs.y1 }, { x: vs.x2, y: vs.y2 }) + if (!ip) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, vs.x1, vs.y1, vs.x2, vs.y2)) continue + const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + if (dist < 1e-3) continue + valleyExtMeets.push({ ext, point: ip, dist, valleyExtSeg: vs }) + } + } + if (valleyExtMeets.length > 0) { + logger.log( + '[KERAB-VALLEY-EXT-BARRIER] meets iter=' + + iter + + ' ' + + JSON.stringify( + valleyExtMeets.map((v) => ({ + ext: labelOf(v.ext.line) || (v.ext.isHip ? 'H' : 'R'), + point: { x: Math.round(v.point.x * 100) / 100, y: Math.round(v.point.y * 100) / 100 }, + dist: Math.round(v.dist * 100) / 100, + })), + ), + ) + } + // [KERAB-STATIC-RIDGE 2026-05-21] 정적 ridge(RG-1 등, 폴리곤 삭제대상 제외) 도 hip extender 의 거울. + const staticRidges = (roof.innerLines || []).filter( + (il) => il && il.name === LINE_TYPE.SUBLINE.RIDGE && il.lineName !== 'kerabPatternRidge' && !polygonLines.includes(il), + ) + const ridgeMeets = [] + for (const ext of unresolved) { + if (!ext.isHip) continue + for (const r of staticRidges) { + const ip = lineLineIntersection(ext.near, ext.far, { x: r.x1, y: r.y1 }, { x: r.x2, y: r.y2 }) + if (!ip) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, r.x1, r.y1, r.x2, r.y2)) continue + const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + ridgeMeets.push({ ext, point: ip, dist, ridge: r }) + } + } + // [KERAB-FIXPOINT-PHASE-A 2026-05-22] 정적 inner hip 만남 — 무너진 sub-polygon 의 경계. + // unresolved ext (hip 또는 ridge) 가 정적 hip segment 와 만나면 그 점에서 멈춤 + 절삭정보 누적. + // polygon path 라인은 제외(이 polygon 의 처리 대상). 자기 라인은 검사 루프에서 개별 제외. + const staticInnerHips = (roof.innerLines || []).filter( + (il) => il && il.name === LINE_TYPE.SUBLINE.HIP && !polygonLines.includes(il) && il.visible !== false, + ) + const staticMeets = [] + for (const ext of unresolved) { + for (const il of staticInnerHips) { + if (ext.line === il) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + const ip = lineLineIntersection(ext.near, ext.far, a, b) + if (!ip) continue + if (Math.abs(ip.x) > 1e5 || Math.abs(ip.y) > 1e5) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, a.x, a.y, b.x, b.y)) continue + const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + if (dist < 1e-3) continue + staticMeets.push({ ext, point: ip, dist, staticLine: il }) + } + } + if (staticMeets.length > 0) { + logger.log( + '[KERAB-FIXPOINT-PHASE-A] staticMeets iter=' + + iter + + ' ' + + JSON.stringify( + staticMeets.map((s) => ({ + ext: labelOf(s.ext.line) || (s.ext.isHip ? 'H' : 'R'), + staticLine: labelOf(s.staticLine) || 'H?', + point: { x: Math.round(s.point.x * 100) / 100, y: Math.round(s.point.y * 100) / 100 }, + dist: Math.round(s.dist * 100) / 100, + })), + ), + ) + } + // [KERAB-FIXPOINT-STEP2 2026-05-21] 이미 그려질 segment(resolved) 도 후보의 거울. + // 뒤늦게 도달한 extender 가 기존 segment 와 만나면 그 점에서 반사 hip 을 만든다. + // (drawn segment 자체의 절단은 Step 3 에서 처리) + // segment 양끝은 그리기 좌표(sourcePoint→stop) 와 일치시킨다. + const drawnMeets = [] + for (const ext of unresolved) { + if (!ext.isHip) continue + for (const [drawnExt, stopPt] of resolved) { + if (!drawnExt || !stopPt) continue + if (drawnExt === ext) continue + const segA = drawnExt.sourcePoint || drawnExt.near + const segB = stopPt + const ip = lineLineIntersection(ext.near, ext.far, segA, segB) + if (!ip) continue + if (Math.abs(ip.x) > 1e5 || Math.abs(ip.y) > 1e5) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, segA.x, segA.y, segB.x, segB.y)) continue + const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + if (dist < 1e-3) continue + drawnMeets.push({ + ext, + point: ip, + dist, + drawnExt, + mirrorLine: { x1: segA.x, y1: segA.y, x2: segB.x, y2: segB.y }, + }) + } + } + if (drawnMeets.length > 0) { + logger.log( + '[KERAB-FIXPOINT-STEP2] drawnMeets iter=' + + iter + + ' ' + + JSON.stringify( + drawnMeets.map((d) => ({ + ext: d.ext.line?.attributes?.label || (d.ext.isHip ? 'H' : 'R'), + point: { x: Math.round(d.point.x * 100) / 100, y: Math.round(d.point.y * 100) / 100 }, + dist: Math.round(d.dist * 100) / 100, + })), + ), + ) + } + const candidates = [] + for (const m of meets) { + candidates.push({ + kind: 'pair', + extenders: [m.a, m.b], + point: m.point, + minDist: m.minDist, + bothHips: m.bothHips, + }) + } + for (const km of kLineMeets) { + candidates.push({ + kind: 'kline', + extenders: [km.ext], + point: km.point, + minDist: km.dist, + mirrorLine: km.kLine, + }) + } + // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] valleyExt 와 만나는 hip/ridge 는 그 점에서 정지. + // mirror 없음 → L859 의 (kline/ridge/drawn) 분기에 valleyExt 추가하지 않아 reflected 생성 안 됨. + for (const vm of valleyExtMeets) { + candidates.push({ + kind: 'valleyExt', + extenders: [vm.ext], + point: vm.point, + minDist: vm.dist, + }) + } + for (const rm of ridgeMeets) { + candidates.push({ + kind: 'ridge', + extenders: [rm.ext], + point: rm.point, + minDist: rm.dist, + mirrorLine: { x1: rm.ridge.x1, y1: rm.ridge.y1, x2: rm.ridge.x2, y2: rm.ridge.y2 }, + }) + } + for (const dm of drawnMeets) { + candidates.push({ + kind: 'drawn', + extenders: [dm.ext], + point: dm.point, + minDist: dm.dist, + mirrorLine: dm.mirrorLine, + drawnExt: dm.drawnExt, + }) + } + for (const sm of staticMeets) { + candidates.push({ + kind: 'static', + extenders: [sm.ext], + point: sm.point, + minDist: sm.dist, + staticLine: sm.staticLine, + }) + } + // [KERAB-ROOF-MAX-INWARD 2026-05-27] roofLine 너머 meet 후보 제거. + // 사용자 규칙: "확장을 해도 roofLine 까지이다. 절대 통과 못한다." + // ext.maxInwardDist (inward 방향 roof wall 까지 최단거리) 를 초과하는 점은 폐기. + const candidatesBeforeCap = candidates.length + const capFilteredOut = [] + for (let ci = candidates.length - 1; ci >= 0; ci--) { + const c = candidates[ci] + let over = false + for (const e of c.extenders) { + const cap = e.maxInwardDist + if (cap === undefined || !Number.isFinite(cap)) continue + const d = Math.hypot(c.point.x - e.near.x, c.point.y - e.near.y) + if (d > cap + MAX_INWARD_TOL) { + over = true + capFilteredOut.push({ + kind: c.kind, + ext: labelOf(e.line) || (e.isHip ? 'H' : 'R'), + d: Math.round(d * 100) / 100, + cap: Math.round(cap * 100) / 100, + }) + break + } + } + if (over) candidates.splice(ci, 1) + } + if (capFilteredOut.length > 0) { + logger.log( + '[KERAB-ROOF-MAX-INWARD] iter=' + + iter + + ' filtered=' + + capFilteredOut.length + + '/' + + candidatesBeforeCap + + ' ' + + JSON.stringify(capFilteredOut), + ) + } + candidates.sort((a, b) => a.minDist - b.minDist) + const newReflected = [] + let pendingKLineCreated = false + let processedAny = false + // [KERAB-FIXPOINT-STEP1 2026-05-21] 동시성 모델: 한 iter 에 가장 가까운 후보 1개만 처리. + // 이후 외곽 for(iter) 루프가 meets/kLineMeets/ridgeMeets 를 새 상태로 재계산한다. + // 이렇게 해야 뒤늦은 교점이 이미 그려질 라인을 잘라낼 수 있다 (Step 2~3 에서 확장 예정). + for (const c of candidates) { + if (c.extenders.some((e) => resolved.has(e))) continue + for (const e of c.extenders) resolved.set(e, c.point) + processedAny = true + // [KERAB-FIXPOINT-STEP3 2026-05-21] drawn segment 절단: + // 뒤늦은 교점이 잡힌 기존 segment 의 stop 을 교점으로 갱신. + // 절단 후 길이 < 0.5 이면 resolved 에서 제거하여 라인 자체 제거. + if (c.kind === 'drawn' && c.drawnExt) { + const drawnNear = c.drawnExt.sourcePoint || c.drawnExt.near + const remain = Math.hypot(c.point.x - drawnNear.x, c.point.y - drawnNear.y) + if (remain < 0.5) { + resolved.delete(c.drawnExt) + } else { + resolved.set(c.drawnExt, c.point) + } + // [KERAB-MULTI-APEX 2026-05-22] 규칙 5 Case B: drawn-meet 두 hip 90° → kLine 신규 생성. + // ray-ray 가 drift(t<0) 로 pair 후보 미생성이어도 drawn segment 교점에서 사실상 만나는 케이스. + // H-5/H-6 같이 RG-2 양끝 발산 hip → drawn meet 으로 동일점 stop. + const ea = c.extenders[0] + const eb = c.drawnExt + if (ea?.isHip && eb?.isHip && remain >= 0.5) { + const vax = ea.far.x - ea.near.x + const vay = ea.far.y - ea.near.y + const vbx = eb.far.x - eb.near.x + const vby = eb.far.y - eb.near.y + if (isPerpendicular(vax, vay, vbx, vby)) { + const eaTag = `${labelOf(ea.line) || 'H'}${ea.__reflected ? '*' : ''}${ea.__reflectedFromPending ? '!' : ''}` + const ebTag = `${labelOf(eb.line) || 'H'}${eb.__reflected ? '*' : ''}${eb.__reflectedFromPending ? '!' : ''}` + if (ea.__reflectedFromPending || eb.__reflectedFromPending) { + logger.log('[KERAB-APEX-SKIP-PHANTOM]', `iter${iter}/drawn-meet[${eaTag}+${ebTag}]`, { + x: c.point.x?.toFixed?.(2), + y: c.point.y?.toFixed?.(2), + }) + } else if (pushApexIfNew(c.point, `iter${iter}/drawn-meet[${eaTag}+${ebTag}]`)) { + pendingKLineCreated = true + } + } + } + } + // [KERAB-FIXPOINT-PHASE-A 2026-05-22] static 만남: ext 멈춤 + 정적 라인 절삭 정보 누적. + if (c.kind === 'static' && c.staticLine) { + cuts.push({ line: c.staticLine, point: c.point }) + } + if (c.kind === 'pair' && c.bothHips) { + const [ea, eb] = c.extenders + const vax = ea.far.x - ea.near.x + const vay = ea.far.y - ea.near.y + const vbx = eb.far.x - eb.near.x + const vby = eb.far.y - eb.near.y + if (isPerpendicular(vax, vay, vbx, vby)) { + const eaTag = `${labelOf(ea.line) || (ea.isHip ? 'H' : 'R')}${ea.__reflected ? '*' : ''}${ea.__reflectedFromPending ? '!' : ''}` + const ebTag = `${labelOf(eb.line) || (eb.isHip ? 'H' : 'R')}${eb.__reflected ? '*' : ''}${eb.__reflectedFromPending ? '!' : ''}` + // [KERAB-MULTI-APEX 2026-05-22] pendingKLine 반사 자식이 끼인 pair-meet 은 phantom 이라 skip. + // primary(H-3+H-4*: H-4는 RG-2 ridge 반사로 fromPending 아님) 는 통과, + // phantom(H-16+H-2!: H-2 가 primary kLine 반사) 은 차단. + // 정적 hip 와의 90° 만남 apex 는 re-resolve 단계에서 따로 push. + if (ea.__reflectedFromPending || eb.__reflectedFromPending) { + logger.log('[KERAB-APEX-SKIP-PHANTOM]', `iter${iter}/pair-meet[${eaTag}+${ebTag}]`, { + x: c.point.x?.toFixed?.(2), + y: c.point.y?.toFixed?.(2), + }) + } else if (pushApexIfNew(c.point, `iter${iter}/pair-meet[${eaTag}+${ebTag}]`)) { + pendingKLineCreated = true + } + } + } + let hipExt = null + let mirrorLine = null + if ((c.kind === 'kline' || c.kind === 'ridge' || c.kind === 'drawn') && c.extenders[0].isHip) { + hipExt = c.extenders[0] + mirrorLine = c.mirrorLine + } else if (c.kind === 'pair') { + const [ea, eb] = c.extenders + if (ea.isHip && !eb.isHip) { + hipExt = ea + mirrorLine = { x1: eb.near.x, y1: eb.near.y, x2: eb.far.x, y2: eb.far.y } + } else if (!ea.isHip && eb.isHip) { + hipExt = eb + mirrorLine = { x1: ea.near.x, y1: ea.near.y, x2: ea.far.x, y2: ea.far.y } + } + } + if (hipExt && mirrorLine) { + const hdx = hipExt.near.x - hipExt.far.x + const hdy = hipExt.near.y - hipExt.far.y + const kdx = mirrorLine.x2 - mirrorLine.x1 + const kdy = mirrorLine.y2 - mirrorLine.y1 + const klen = Math.hypot(kdx, kdy) || 1 + const nx = -kdy / klen + const ny = kdx / klen + const dot = hdx * nx + hdy * ny + const rdx = hdx - 2 * dot * nx + const rdy = hdy - 2 * dot * ny + // [KERAB-MULTI-APEX 2026-05-22] 같은 operation 의 pendingKLine 에 반사된 자식은 + // __reflectedFromPending 태그. 이 자식의 pair-meet 으로 추가 apex 생성 차단(phantom 방지). + // 부모(hipExt) 가 이미 pending 에서 반사된 경우도 상속. + const fromPending = + hipExt.__reflectedFromPending || + (c.kind === 'kline' && + pendingKLines.some( + (pk) => pk.x1 === mirrorLine.x1 && pk.y1 === mirrorLine.y1 && pk.x2 === mirrorLine.x2 && pk.y2 === mirrorLine.y2, + )) + const reflected = { + line: hipExt.line, + near: { x: c.point.x, y: c.point.y }, + far: { x: c.point.x - rdx, y: c.point.y - rdy }, + isHip: true, + sourcePoint: { x: c.point.x, y: c.point.y }, + __reflected: true, + __reflectedFromPending: fromPending, + } + // [KERAB-ROOF-MAX-INWARD 2026-05-27] reflected ext 도 roof wall 까지 캡 계산. + reflected.maxInwardDist = computeMaxInwardDist(reflected) + newReflected.push(reflected) + } + break + } + if (!processedAny && !pendingKLineCreated) break + extenderPool.push(...newReflected) + } + // [KERAB-ROOF-FALLBACK-ANYWALL 2026-05-21] 미해소 extender 를 roof 폴리곤의 어떤 wall 이라도 + // inward 방향의 가장 가까운 wall 교점까지 확장. 반사 hip 이 target wall 과 반대쪽으로 + // 향해도 다른 wall 에서 정지. + // (roofPolygonWalls 정의는 wave 시작 전 KERAB-ROOF-MAX-INWARD 블록으로 이동됨) + // [KERAB-NO-PIERCE 2026-05-21] fallback 직진 중에도 정적 inner line(이 polygon 의 처리대상 제외) + // + 이번 wave 에 이미 그려질 ext 라인을 barrier 로 검사. 가장 가까운 hit 에서 정지하여 + // 다른 라인을 관통해 내부로 침입하는 케이스를 차단. + const barrierLines = [] + for (const il of roof.innerLines || []) { + if (!il) continue + if (polygonLines.includes(il)) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.visible === false) continue + barrierLines.push({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) + } + for (const [otherExt, otherStop] of resolved) { + barrierLines.push({ + x1: otherExt.sourcePoint.x, + y1: otherExt.sourcePoint.y, + x2: otherStop.x, + y2: otherStop.y, + }) + } + for (const pk of pendingKLines) barrierLines.push(pk) + // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] 골짜기확장라인 — fallback 단계에서도 통과 금지. + for (const vs of valleyExtPreSegs) barrierLines.push(vs) + for (const ext of extenderPool) { + if (resolved.has(ext)) continue + let bestPt = null + let bestDist = Infinity + let bestSrc = null + for (const wall of roofPolygonWalls) { + const ip = lineLineIntersection(ext.near, ext.far, wall.a, wall.b) + if (!ip) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, wall.a.x, wall.a.y, wall.b.x, wall.b.y)) continue + const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + if (dist < bestDist) { + bestDist = dist + bestPt = ip + bestSrc = { kind: 'wall', a: wall.a, b: wall.b } + } + } + for (const bl of barrierLines) { + const ip = lineLineIntersection(ext.near, ext.far, { x: bl.x1, y: bl.y1 }, { x: bl.x2, y: bl.y2 }) + if (!ip) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, bl.x1, bl.y1, bl.x2, bl.y2)) continue + const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + if (dist < 1e-3) continue + if (dist < bestDist) { + bestDist = dist + bestPt = ip + bestSrc = { kind: 'barrier', a: { x: bl.x1, y: bl.y1 }, b: { x: bl.x2, y: bl.y2 } } + } + } + logger.log( + '[KERAB-FALLBACK] ext=' + + (labelOf(ext.line) || (ext.isHip ? 'H' : 'R')) + + ' near=' + + JSON.stringify({ + x: Math.round(ext.near.x * 100) / 100, + y: Math.round(ext.near.y * 100) / 100, + }) + + ' stop=' + + (bestPt + ? JSON.stringify({ + x: Math.round(bestPt.x * 100) / 100, + y: Math.round(bestPt.y * 100) / 100, + }) + : 'null') + + ' src=' + + (bestSrc + ? bestSrc.kind + + '[' + + Math.round(bestSrc.a.x * 100) / 100 + + ',' + + Math.round(bestSrc.a.y * 100) / 100 + + '→' + + Math.round(bestSrc.b.x * 100) / 100 + + ',' + + Math.round(bestSrc.b.y * 100) / 100 + + ']' + : 'none'), + ) + if (bestPt) resolved.set(ext, bestPt) + } + // [KERAB-FIXPOINT-PHASE-A 2026-05-22] cuts 적용 — 사용자 멘탈모델: + // 절삭 방향 = static line 이 "확장되는 방향" (= inward extension 의 anchor 쪽). + // 거리 기반(만남점에 가까운 쪽) 가 아니다 — junction-extended outer hip 의 경우 + // 우연히 일치할 뿐. extension source 끝점 = 확장 anchor = junction stub = 제거. + // 반대편 = dead-end = 유지. + // (a) static cut: 확장 방향 끝점을 만남점까지 잘라낸다. + // (b) 확장 자체 purge: 같은 line 의 wave drawn(inward 확장) 제거. + // (c) cascade: 제거된 끝점을 source/stop 으로 쓰던 다른 drawn segment 도 정리. + // (d) re-resolve: stop 점이 죽은 segment(잘려나간 stub 또는 purge 된 확장) + // 위에 있던 다른 resolved entry 는 unresolved 로 되돌리고, 새 상태로 + // wall + barrier 까지 재확장한다 (Phase A의 "다음 행위"). + // [KERAB-VALLEY-EXT 2026-05-27] 골짜기확장 케이스에선 cuts 적용 skip. + // 사용자 요구: "골짜기 라인은 확장만 하라" — 처마확장만 그리고 + // 다른 hip/ridge(H-2 등) 은 일체 손대지 말 것. + // staticMeets/cuts 는 일반 케라바 알고리즘의 ext hip pattern 정리용인데, + // 골짜기 케이스에선 polygonPath 외부의 H-2 같은 라인까지 dir=junction 으로 + // 단축시키는 부작용이 있음. + const isValleyExtCase = valleyPlannedEndpoints.length > 0 + if (cuts.length > 0 && isValleyExtCase) { + logger.log('[KERAB-FIXPOINT-PHASE-A] cuts SKIPPED (valley extension case)') + } + if (cuts.length > 0 && !isValleyExtCase) { + logger.log( + '[KERAB-FIXPOINT-PHASE-A] applying cuts(initial) ' + + JSON.stringify( + cuts.map((c) => ({ + line: labelOf(c.line) || 'H?', + point: { x: Math.round(c.point.x * 100) / 100, y: Math.round(c.point.y * 100) / 100 }, + })), + ), + ) + // [KERAB-FIXPOINT-PHASE-A 2026-05-22] cuts 를 while/index 로 처리해 re-resolve + // 결과로 새로 잡힌 static meet 이 cuts 에 추가되면 같은 루프에서 처리. + let cutIdx = 0 + while (cutIdx < cuts.length) { + const cut = cuts[cutIdx++] + const line = cut.line + if (!line) continue + const a = { x: line.x1, y: line.y1 } + const b = { x: line.x2, y: line.y2 } + const dA = Math.hypot(a.x - cut.point.x, a.y - cut.point.y) + const dB = Math.hypot(b.x - cut.point.x, b.y - cut.point.y) + // 확장 방향 = 이 line 을 source 로 하는 inward extension 의 sourcePoint 쪽. + // 그 끝점 = junction = 제거 대상. 반대편(dead-end) = 유지. + let extensionAnchor = null + for (const ext of resolved.keys()) { + if (ext.line === line && ext.sourcePoint) { + extensionAnchor = ext.sourcePoint + break + } + } + let keepA + let dirSource + if (extensionAnchor) { + const dAanc = Math.hypot(a.x - extensionAnchor.x, a.y - extensionAnchor.y) + const dBanc = Math.hypot(b.x - extensionAnchor.x, b.y - extensionAnchor.y) + keepA = dAanc > dBanc // anchor 와 먼 쪽(dead-end) 유지 + dirSource = 'extension' + } else { + // priority 2: junction-vs-dead-end. 끝점에 다른 inner line 끝점이 모이면 + // junction(3점 교점 등) → 그 쪽 유지. dead-end(연결없음) 쪽 절삭. + // 둘 다 같으면 다음 priority. + const countJunction = (p) => { + let count = 0 + for (const il of roof.innerLines || []) { + if (!il || il === line) continue + if (il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (Math.hypot(il.x1 - p.x, il.y1 - p.y) < 0.5) count += 1 + if (Math.hypot(il.x2 - p.x, il.y2 - p.y) < 0.5) count += 1 + } + return count + } + const jA = countJunction(a) + const jB = countJunction(b) + if (jA !== jB) { + keepA = jA > jB // junction count 많은 쪽 유지, dead-end 쪽 절삭 + dirSource = 'junction' + } else { + // priority 3: 교점에 가까운 쪽 절삭 (먼 쪽 유지). + const TIE_EPS = 1.0 + if (Math.abs(dA - dB) > TIE_EPS) { + keepA = dA >= dB + dirSource = 'distance' + } else { + // priority 4: roofLine 에 가까운 끝점을 가진 쪽 절삭. + const pointToSegDist = (p, sa, sb) => { + const vx = sb.x - sa.x + const vy = sb.y - sa.y + const lenSq = vx * vx + vy * vy + if (lenSq < 1e-6) return Math.hypot(p.x - sa.x, p.y - sa.y) + let t = ((p.x - sa.x) * vx + (p.y - sa.y) * vy) / lenSq + if (t < 0) t = 0 + else if (t > 1) t = 1 + return Math.hypot(p.x - (sa.x + t * vx), p.y - (sa.y + t * vy)) + } + const minDistToRoof = (p) => { + let best = Infinity + for (const w of roofPolygonWalls) { + const d = pointToSegDist(p, w.a, w.b) + if (d < best) best = d + } + return best + } + const rA = minDistToRoof(a) + const rB = minDistToRoof(b) + keepA = rA > rB + dirSource = 'roofLine' + } + } + } + const removedEnd = keepA ? b : a + const remain = Math.hypot((keepA ? a : b).x - cut.point.x, (keepA ? a : b).y - cut.point.y) + if (remain < 0.5) { + if (typeof line.set === 'function') line.set({ visible: false }) + else line.visible = false + } else if (keepA) { + if (typeof line.set === 'function') line.set({ x2: cut.point.x, y2: cut.point.y }) + else { + line.x2 = cut.point.x + line.y2 = cut.point.y + } + } else { + if (typeof line.set === 'function') line.set({ x1: cut.point.x, y1: cut.point.y }) + else { + line.x1 = cut.point.x + line.y1 = cut.point.y + } + } + // purge ext.line === cut.line BEFORE deletion: capture drawn segments. + const purgedSegments = [] + let purgedLine = 0 + for (const ext of Array.from(resolved.keys())) { + if (ext.line === line) { + const stop = resolved.get(ext) + if (ext.sourcePoint && stop) { + purgedSegments.push({ + a: { x: ext.sourcePoint.x, y: ext.sourcePoint.y }, + b: { x: stop.x, y: stop.y }, + }) + } + resolved.delete(ext) + purgedLine += 1 + } + } + // cascade: removedEnd(잘려나간 stub 끝점) 에 src/stop 이 직접 붙은 drawn + // segment 만 단일 pass purge. resolved 의 stop 점은 apex/reflection 일 수도 + // 있고 inner polygon junction 일 수도 있으므로 deadPts 로 전파하지 않는다. + // junction 은 살아있는 anchor → 다른 extension(예: H-2) 을 휩쓸어선 안 됨. + let purgedCascade = 0 + for (const [ext, stop] of Array.from(resolved.entries())) { + const src = ext.sourcePoint + if (!src || !stop) continue + const srcAtRemoved = Math.hypot(src.x - removedEnd.x, src.y - removedEnd.y) < 0.5 + const stopAtRemoved = Math.hypot(stop.x - removedEnd.x, stop.y - removedEnd.y) < 0.5 + if (srcAtRemoved || stopAtRemoved) { + resolved.delete(ext) + purgedCascade += 1 + } + } + // (d) re-resolve: 죽은 segment 들 위에 stop 이 있던 resolved entry → 재확장. + // killed = static line 의 잘린 stub (cut.point → removedEnd) + + // 방금 purge 한 drawn segment 들(source → stop). + const killedSegments = [{ a: { x: cut.point.x, y: cut.point.y }, b: { x: removedEnd.x, y: removedEnd.y } }, ...purgedSegments] + const staleExts = [] + for (const [ext, stop] of Array.from(resolved.entries())) { + const onKilled = killedSegments.some((seg) => isPointOnSegment(stop, seg.a.x, seg.a.y, seg.b.x, seg.b.y, 0.5)) + if (onKilled) { + resolved.delete(ext) + staleExts.push(ext) + } + } + // 재resolve: 현재 resolved + roof.innerLines 로 barrier 재구축 후 fallback 처럼 + // 가장 가까운 wall/barrier 까지 확장. killed segment 는 barrier 에서 제외됨 + // (resolved 에서 빠졌고 fabric line 좌표가 cut 으로 갱신됨). + let reResolved = 0 + if (staleExts.length > 0) { + const barriers2 = [] + for (const il of roof.innerLines || []) { + if (!il) continue + if (polygonLines.includes(il)) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.visible === false) continue + barriers2.push({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) + } + for (const [otherExt, otherStop] of resolved) { + barriers2.push({ + x1: otherExt.sourcePoint.x, + y1: otherExt.sourcePoint.y, + x2: otherStop.x, + y2: otherStop.y, + }) + } + for (const pk of pendingKLines) barriers2.push(pk) + // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] re-resolve 단계에서도 골짜기확장라인 통과 금지. + for (const vs of valleyExtPreSegs) barriers2.push(vs) + for (const ext of staleExts) { + let bestPt = null + let bestDist = Infinity + for (const wall of roofPolygonWalls) { + const ip = lineLineIntersection(ext.near, ext.far, wall.a, wall.b) + if (!ip) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, wall.a.x, wall.a.y, wall.b.x, wall.b.y)) continue + const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + if (dist < 1e-3) continue + if (dist < bestDist) { + bestDist = dist + bestPt = ip + } + } + for (const bl of barriers2) { + const ip = lineLineIntersection( + ext.near, + ext.far, + { x: bl.x1, y: bl.y1 }, + { + x: bl.x2, + y: bl.y2, + }, + ) + if (!ip) continue + if (!isInward(ext, ip)) continue + if (!isPointOnSegment(ip, bl.x1, bl.y1, bl.x2, bl.y2)) continue + const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) + if (dist < 1e-3) continue + if (dist < bestDist) { + bestDist = dist + bestPt = ip + } + } + if (bestPt) { + resolved.set(ext, bestPt) + reResolved += 1 + } + logger.log( + '[KERAB-FIXPOINT-PHASE-A] re-resolve ext=' + + (labelOf(ext.line) || (ext.isHip ? 'H' : 'R')) + + ' near={' + + Math.round(ext.near.x * 100) / 100 + + ',' + + Math.round(ext.near.y * 100) / 100 + + '}' + + ' newStop=' + + (bestPt ? '{' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}' : 'null'), + ) + } + } + // [KERAB-FIXPOINT-PHASE-A 2026-05-22] re-resolve 후 새 stop 이 정적 inner hip + // 위면 hip+hip 만남(또는 hip+static-hip) 으로 간주 → cuts 추가 + apex/kLine. + // 같은 cuts 루프에서 다음 pass 처리(while/index). + let newCuts = 0 + for (const ext of staleExts) { + const newStop = resolved.get(ext) + if (!newStop) continue + for (const il of roof.innerLines || []) { + if (!il) continue + if (polygonLines.includes(il)) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP) continue + if (il.visible === false) continue + const already = cuts.some((c) => c.line === il && Math.hypot(c.point.x - newStop.x, c.point.y - newStop.y) < 0.5) + if (already) continue + if (!isPointOnSegment(newStop, il.x1, il.y1, il.x2, il.y2, 0.5)) continue + cuts.push({ line: il, point: { x: newStop.x, y: newStop.y } }) + newCuts += 1 + // [KERAB-MULTI-APEX 2026-05-22] re-resolve 의 정적 hip 만남(H-2반사 + H-14)이 90° 면 kLine 추가. + if (ext.isHip) { + const vax = ext.far.x - ext.near.x + const vay = ext.far.y - ext.near.y + const vbx = il.x2 - il.x1 + const vby = il.y2 - il.y1 + if (isPerpendicular(vax, vay, vbx, vby)) { + pushApexIfNew(newStop, `reresolve/ext=${labelOf(ext) || '?'}/il=${labelOf(il) || '?'}`) + } + } + break + } + } + logger.log( + '[KERAB-FIXPOINT-PHASE-A] cut applied line=' + + (labelOf(line) || 'H?') + + ' dir=' + + dirSource + + ' purgedLine=' + + purgedLine + + ' purgedCascade=' + + purgedCascade + + ' stale=' + + staleExts.length + + ' reResolved=' + + reResolved + + ' newCuts=' + + newCuts + + ' removedEnd={' + + Math.round(removedEnd.x * 100) / 100 + + ',' + + Math.round(removedEnd.y * 100) / 100 + + '}', + ) + } + } + const extLines = [] + for (const [ext, stop] of resolved) { + const fromPt = ext.sourcePoint + if (Math.hypot(stop.x - fromPt.x, stop.y - fromPt.y) < 0.5) continue + extLines.push({ from: fromPt, to: stop, isHip: ext.isHip }) + } + const drawKLine = apexList.length > 0 + const markerApex = apexList.length > 0 ? apexList[0].point : extLines.length > 0 ? extLines[0].to : null + const extraApexes = apexList.slice(1).map((ap) => ap.point) + logger.log( + '[KERAB-SIMPLE] sequential resolve ' + + JSON.stringify({ + extLines: extLines.map((e) => ({ + from: { x: Math.round(e.from.x * 100) / 100, y: Math.round(e.from.y * 100) / 100 }, + to: { x: Math.round(e.to.x * 100) / 100, y: Math.round(e.to.y * 100) / 100 }, + isHip: e.isHip, + })), + drawKLine, + markerApex: markerApex ? { x: Math.round(markerApex.x * 100) / 100, y: Math.round(markerApex.y * 100) / 100 } : null, + apexList: apexList.map((ap) => ({ + x: Math.round(ap.point.x * 100) / 100, + y: Math.round(ap.point.y * 100) / 100, + })), + }), + ) + if (markerApex) { + const pathHips = polygonPath.filter((p) => p.line.name === LINE_TYPE.SUBLINE.HIP).map((p) => p.line) + const pathRidges = polygonPath.filter((p) => p.line.name === LINE_TYPE.SUBLINE.RIDGE).map((p) => p.line) + target.set({ attributes }) + applyKerabKLinePattern( + roof, + target, + markerApex, + h1Match.near, + h2Match.near, + [h1Match.hip, h2Match.hip, ...pathHips], + pathRidges, + extLines, + drawKLine, + extraApexes, + ) + // [KERAB-VALLEY-EXT 2026-05-27] valley 처마확장 raycast + drawing — applyKerabKLinePattern 후 실행. + // raycast 대상 = 현재 roof.innerLines 중 HIP/RIDGE (= 살아남은 hip·ridge + 새 extLines·kLine 모두 포함). + // 사라진 polygonPath hip/ridge 는 이미 제거되어 자동 제외. valleyExt 자신은 push 후이므로 1mm 필터로 보호. + if (valleyPlannedEndpoints.length) { + const isOnSegV = (pt, ax, ay, bx, by, tol = 0.5) => { + const sdx = bx - ax + const sdy = by - ay + const lenSq = sdx * sdx + sdy * sdy + if (lenSq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol + const tt = ((pt.x - ax) * sdx + (pt.y - ay) * sdy) / lenSq + const margin = tol / Math.sqrt(lenSq) + if (tt < -margin || tt > 1 + margin) return false + const px = ax + tt * sdx + 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) { + 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 } + if (isRoofBase) { + // [KERAB-VALLEY-EXT 2026-05-27] splitPolygonWithLines 의 innerLineMapping 은 + // EAVES polygonLine 을 skip 하므로(usePolygon.js:822) 처마확장은 자동 매핑이 안 된다. + // 처마 라인 매핑과 동일하게 type/isStart 를 직접 부여. + baseAttrs.type = LINE_TYPE.WALLLINE.EAVES + baseAttrs.isStart = true + } + const vExt = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.VALLEY, + textMode: roof.textMode, + attributes: baseAttrs, + }) + vExt.lineName = 'kerabPatternValleyExt' + vExt.__targetId = target.id + vExt.__valleyExtSource = vr.source + if (isRoofBase && vr.parent) { + vExt.parentLine = vr.parent + // [KERAB-VALLEY-EXT 2026-05-27] direction 도 부모 roofLine 에서 상속 — flow/pitch 방향 일관. + if (vr.parent.direction) vExt.direction = vr.parent.direction + } + canvas.add(vExt) + vExt.bringToFront() + if (isRoofBase) roof.innerLines.push(vExt) + } + if (valleyExtensions.length) { + logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length) + } + } + logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')') + return + } + logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only') + target.set({ attributes }) + applyKerabAttributeOnlyPattern() + return + } + // condition 1: 자연 만남 — ext 없이 hip 제거 + kLine + target.set({ attributes }) + applyKerabKLinePattern(roof, target, apex, h1Match.near, h2Match.near, [h1Match.hip, h2Match.hip], null, null) + logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)') + return + } + } + target.set({ attributes }) + applyKerabAttributeOnlyPattern() + logger.log('[KERAB-SIMPLE] attr-only fallback') + return } } @@ -311,17 +1828,45 @@ export function useEavesGableEdit(id) { const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 }) const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 }) if (c1 && c2) { - const ridgeAtMid = findRidgeAtMidpoint(roof, c1, c2) + // [KERAB-OFFSET-SURGICAL 2026-05-27] revert 경로에서는 hip snapshot 좌표가 옛 corner 기준이라 + // surgical 을 pattern 호출 **후**로 미룬다. pattern 이 옛 corner 로 hip 복원 후, surgical 의 + // inner-line corner snap 이 hip 끝점도 새 corner 로 함께 이동시킨다. + const surgicalAfter = () => { + if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0) + } + // [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원 + if (Array.isArray(target.__kerabParallelHipsSnapshot)) { + target.set({ attributes }) + revertKerabParallelHipsPattern(roof, target) + surgicalAfter() + logger.log('[KERAB-REVERT] applied (parallel-hips) ' + JSON.stringify({ c1, c2 })) + return + } + // [KERAB-SIMPLE-REVERT 2026-05-20] kLineOnly 패턴: ridge.__targetId 로 직접 매칭 (nearestRoofPoint drift 회피) + const kLineRidge = roof.innerLines.find((il) => il && il.__patternKind === 'kLineOnly' && il.__targetId === target.id) + if (kLineRidge) { + target.set({ attributes }) + const ok = applyKerabRevertPattern(roof, target, c1, c2, { ridge: kLineRidge, apex: null }) + if (ok) surgicalAfter() + logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok })) + if (ok) return + } + // [2240 KERAB-MID-FROM-TARGET 2026-05-20] revert 도 target 중점 기준 ridge 검색 (forward 와 일관) + const tEnd1 = { x: target.x1, y: target.y1 } + const tEnd2 = { x: target.x2, y: target.y2 } + const ridgeAtMid = findRidgeAtMidpoint(roof, tEnd1, tEnd2) if (ridgeAtMid) { target.set({ attributes }) const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid) - logger.log('[KERAB-REVERT] applied', { ok, c1, c2, apex: ridgeAtMid.apex }) + if (ok) surgicalAfter() + logger.log('[KERAB-REVERT] applied ' + JSON.stringify({ ok, c1, c2, apex: ridgeAtMid.apex })) if (ok) return } // [2240 KERAB-ATTR-ONLY 2026-05-19] ridge-at-mid 없음 → forward 가 attribute-only 였음 → 동일 대칭으로 속성만 토글 target.set({ attributes }) applyKerabAttributeOnlyPattern() - logger.log('[KERAB-ATTR-ONLY] applied (revert)', { c1, c2 }) + surgicalAfter() + logger.log('[KERAB-ATTR-ONLY] applied (revert) ' + JSON.stringify({ c1, c2 })) return } } @@ -443,6 +1988,13 @@ export function useEavesGableEdit(id) { canvas?.renderAll() } + // [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 본체는 `@/util/kerab-offset-surgical` 로 분리. + // 고객 회귀 확인을 위한 토글 — ENABLE_KERAB_OFFSET_SURGICAL false 면 즉시 false 반환. + const applyTargetOffsetSurgical = (target, newOffset) => { + if (!ENABLE_KERAB_OFFSET_SURGICAL) return false + return applyKerabOffsetSurgical(canvas, target, newOffset) + } + // [2240 KERAB-SINGLE-RIDGE 2026-05-19] 헬퍼 — 양 끝점 hip 페어 식별 및 ridge 단일화 적용 const KERAB_TOL = 0.5 const isSamePt = (a, b) => Math.abs(a.x - b.x) < KERAB_TOL && Math.abs(a.y - b.y) < KERAB_TOL @@ -488,17 +2040,36 @@ export function useEavesGableEdit(id) { return null } - // [2240 KERAB-EXTENDED-APEX 2026-05-19] B-5 끝점 hip(H-7, H-1) 을 무한 확장한 교점이 - // 처마 중점 수선 위에 있으면 apex 채택. 채택 시 단일-ridge 패턴과 동일하게 hip 2개 제거 + mid→apex ridge. - const findHipPairWithExtendedApex = (hipsAtP1, p1, hipsAtP2, p2) => { - const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 } - const eVx = p2.x - p1.x - const eVy = p2.y - p1.y - const eMag = Math.hypot(eVx, eVy) || 1 + // [2240 KERAB-PARALLEL-HIPS 2026-05-19] 양 끝점 hip 페어가 평행한지 검사. + // 두 hip 방향벡터의 정규화 cross product 절대값 < 0.01 이면 평행 → 무한확장해도 교점 없음 → apex 없음. + // 이 케이스는 hip 만 제거하고 중앙선(kLine) 은 생성하지 않는다. + const findParallelHipPair = (hipsAtP1, p1, hipsAtP2, p2) => { for (const hA of hipsAtP1) { const oA = otherEndOfHip(hA, p1) - // hA 의 진행방향: p1 → oA 의 반대로(oA 너머 확장) 이 아닌, hip 자체를 무한 직선으로 본 교점 계산. - // 직선 파라미터: p1 + t*(oA - p1). t>1 이면 oA 너머(폴리곤 내부 방향). + const dAx = oA.x - p1.x + const dAy = oA.y - p1.y + const magA = Math.hypot(dAx, dAy) || 1 + for (const hB of hipsAtP2) { + if (hB === hA) continue + const oB = otherEndOfHip(hB, p2) + const dBx = oB.x - p2.x + const dBy = oB.y - p2.y + const magB = Math.hypot(dBx, dBy) || 1 + const cross = (dAx * dBy - dAy * dBx) / (magA * magB) + if (Math.abs(cross) < 0.01) return { hA, hB } + } + } + return null + } + + // [2240 KERAB-EXTENDED-APEX 2026-05-20] c1·c2 의 outer hip(H1, H2) 을 무한 확장한 교점을 apex 로 채택. + // 사용자 모델: H1, H2 가 (연장하면) 만나는 점이 apex. 처마 중점 수선 위에 있지 않아도 채택. + // (dotEaves 제약 제거 — 2026-05-20) + // 채택 조건: ① 두 hip 이 평행이 아님 ② 교점이 두 hip 끝점 너머(t,s>1) 에 위치 + const findHipPairWithExtendedApex = (hipsAtP1, p1, hipsAtP2, p2) => { + for (const hA of hipsAtP1) { + const oA = otherEndOfHip(hA, p1) + // hip 자체를 무한 직선으로 본 교점 계산. 직선 파라미터: p1 + t*(oA - p1). t>1 이면 oA 너머(폴리곤 내부 방향). const dAx = oA.x - p1.x const dAy = oA.y - p1.y for (const hB of hipsAtP2) { @@ -512,12 +2083,11 @@ export function useEavesGableEdit(id) { const py = p2.y - p1.y const t = (px * dBy - py * dBx) / denom const s = (px * dAy - py * dAx) / denom - // hip 의 진행방향(끝점 너머) 으로 만나야 함. t,s > 1 (oA, oB 너머 확장) - if (t <= 1 + 1e-3 || s <= 1 + 1e-3) continue + // [KERAB-EXTENDED-APEX-TS 2026-05-20] apex 가 hip 의 진행방향(양수 쪽) 에 있을 것만 요구. + // t,s > 1 (양쪽 모두 끝점 너머) 까지 강제하지 않음 — 한 쪽 hip 가 충분히 길어 apex 가 + // 자기 segment 안쪽에 있는 케이스도 「H1, H2 가 만났다」로 인정 (사용자 모델, 2026-05-20). + if (t <= 1e-3 || s <= 1e-3) continue const apex = { x: p1.x + t * dAx, y: p1.y + t * dAy } - // 가운데 검증: (apex - mid) 이 처마 방향과 수직(내적 ≈ 0) - const dotEaves = ((apex.x - mid.x) * eVx + (apex.y - mid.y) * eVy) / eMag - if (Math.abs(dotEaves) > KERAB_TOL) continue return { hA, hB, apex } } } @@ -613,6 +2183,170 @@ export function useEavesGableEdit(id) { return Math.hypot(p.x - px, p.y - py) <= tol } + // [2240 KERAB-SIMPLE 2026-05-20] target 끝점에서 가장 가까운 끝점을 가진 hip 검색. + // 처마 끝점(벽 좌표) vs hip 끝점(offset 좌표) 의 좌표계 차이(최대 ~50 단위) 는 거리 비교로 흡수. + // nearestRoofPoint 안 거침 — 처마가 짧을 때(<500mm) 인접 코너로 잘못 스냅되는 문제 회피. + // 동일 위치 겹치는 hip 도 좌표가 미세하게 달라 거리 최소값으로 구분 가능. + // [KERAB-SIMPLE 2026-05-20] target 끝점 pt 에 대응되는 hip 의 "near" 끝점은 + // 코너의 대각선 offset 위치 (dx, dy 둘 다 0 이 아님). 축정렬(dx=0 또는 dy=0) 후보는 + // 인접 L-노치 코너의 offset 일 가능성이 높아 제외. 대각 후보 없으면 폴백으로 최근접. + const AXIS_EPS = 1.0 + const findHipAtEndpoint = (roof, pt) => { + let bestDiag = null + let bestDiagD = Infinity + let bestAny = null + let bestAnyD = Infinity + for (const il of roof.innerLines || []) { + if (!il || il.attributes?.type !== LINE_TYPE.SUBLINE.HIP) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + const dA = Math.hypot(a.x - pt.x, a.y - pt.y) + const dB = Math.hypot(b.x - pt.x, b.y - pt.y) + const aDiag = Math.abs(a.x - pt.x) > AXIS_EPS && Math.abs(a.y - pt.y) > AXIS_EPS + const bDiag = Math.abs(b.x - pt.x) > AXIS_EPS && Math.abs(b.y - pt.y) > AXIS_EPS + if (dA < bestAnyD) { + bestAnyD = dA + bestAny = { hip: il, near: a, far: b, dist: dA } + } + if (dB < bestAnyD) { + bestAnyD = dB + bestAny = { hip: il, near: b, far: a, dist: dB } + } + if (aDiag && dA < bestDiagD) { + bestDiagD = dA + bestDiag = { hip: il, near: a, far: b, dist: dA } + } + if (bDiag && dB < bestDiagD) { + bestDiagD = dB + bestDiag = { hip: il, near: b, far: a, dist: dB } + } + } + return bestDiag || bestAny + } + + // 두 직선(p1-p2, p3-p4)의 무한확장 교점. 평행이면 null. + const lineLineIntersection = (p1, p2, p3, p4) => { + const d = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x) + if (Math.abs(d) < 1e-6) return null + const t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / d + return { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) } + } + + // [KERAB-SIMPLE-OTHERHIP 2026-05-20] pt 위치에서 excludeHip 을 제외한 또 다른 hip 검색. + // RG-1 너머에서 같은 분기점(h*.far) 에 붙어있는 hip — 확장 방향을 정의함. + const findOtherHipAtPoint = (roof, pt, excludeHip, tol = 1.0) => { + let best = null + let bestD = Infinity + for (const il of roof.innerLines || []) { + if (!il || il === excludeHip) continue + if (il.attributes?.type !== LINE_TYPE.SUBLINE.HIP) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + const dA = Math.hypot(a.x - pt.x, a.y - pt.y) + const dB = Math.hypot(b.x - pt.x, b.y - pt.y) + if (dA <= tol && dA < bestD) { + bestD = dA + best = { hip: il, near: a, far: b } + } + if (dB <= tol && dB < bestD) { + bestD = dB + best = { hip: il, near: b, far: a } + } + } + return best + } + + // [KERAB-EXTENDER 2026-05-21] 사용자 전제 3: pt(접점) 에서 polygon 경계 라인을 제외한 + // inner line(hip 또는 ridge) 을 extender 로 검색. extender 의 방향(near→far)이 확장 방향. + // 반환: { line, near, far, isHip } 또는 null. + const findExtenderAtPoint = (roof, pt, excludedLines = [], tol = 1.0) => { + const excludeSet = new Set(excludedLines) + let best = null + let bestD = Infinity + for (const il of roof.innerLines || []) { + if (!il || excludeSet.has(il)) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + const dA = Math.hypot(a.x - pt.x, a.y - pt.y) + const dB = Math.hypot(b.x - pt.x, b.y - pt.y) + const isHip = il.name === LINE_TYPE.SUBLINE.HIP + if (dA <= tol && dA < bestD) { + bestD = dA + best = { line: il, near: a, far: b, isHip } + } + if (dB <= tol && dB < bestD) { + bestD = dB + best = { line: il, near: b, far: a, isHip } + } + } + return best + } + + // [KERAB-SIMPLE-MIDRIDGE 2026-05-20] 두 hip 의 far 끝점 p1·p2 를 잇는 ridge 검색. + // 확장 apex 케이스에서 hip 사이를 잇던 RG-1 같은 마루를 제거 대상으로 식별. + const findRidgeBetweenHipFars = (roof, p1, p2, tol = KERAB_TOL) => { + for (const il of roof.innerLines || []) { + if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + const aP1 = Math.hypot(a.x - p1.x, a.y - p1.y) <= tol + const aP2 = Math.hypot(a.x - p2.x, a.y - p2.y) <= tol + const bP1 = Math.hypot(b.x - p1.x, b.y - p1.y) <= tol + const bP2 = Math.hypot(b.x - p2.x, b.y - p2.y) <= tol + if ((aP1 && bP2) || (aP2 && bP1)) return il + } + return null + } + + // [KERAB-POLYGON-BFS 2026-05-21] 사용자 전제 2: 선택한 외곽선이 속한 내부 다각형의 + // inner line 경계를 BFS 로 추적. p1=h1.far → p2=h2.far 사이의 hip/ridge 체인을 모두 반환. + // excludeLines=[h1, h2] 로 시작점 hip 자체를 통과하지 않게 하고, + // 중간 junction 이 있는 비대칭 케이스(Ridge→junction→otherHip 등)도 한 번에 식별. + // 반환: [{ line, from, to }, ...] 또는 path 가 없으면 null. + const traceInnerPolygonPath = (roof, p1, p2, excludeLines = [], tol = KERAB_TOL) => { + const same = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) <= tol + if (same(p1, p2)) return [] + const excludeSet = new Set(excludeLines) + const ptKey = (p) => `${Math.round(p.x * 10) / 10}_${Math.round(p.y * 10) / 10}` + const visited = new Set([ptKey(p1)]) + const queue = [{ point: p1, path: [] }] + while (queue.length > 0) { + const { point, path } = queue.shift() + for (const il of roof.innerLines || []) { + if (!il || excludeSet.has(il)) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + let nextPoint = null + if (same(a, point)) nextPoint = b + else if (same(b, point)) nextPoint = a + if (!nextPoint) continue + if (same(nextPoint, p2)) { + return [...path, { line: il, from: point, to: nextPoint }] + } + const key = ptKey(nextPoint) + if (visited.has(key)) continue + visited.add(key) + queue.push({ point: nextPoint, path: [...path, { line: il, from: point, to: nextPoint }] }) + } + } + return null + } + + // apex 좌표를 끝점 또는 segment 위로 통과하는 ridge 검색 (RG-1 검증). tol 은 좌표 drift 흡수용. + const findRidgePassingThrough = (roof, pt, tol = KERAB_TOL) => { + for (const il of roof.innerLines || []) { + if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + if (Math.hypot(a.x - pt.x, a.y - pt.y) <= tol) return il + if (Math.hypot(b.x - pt.x, b.y - pt.y) <= tol) return il + if (isPointOnSegment(pt, { x1: a.x, y1: a.y, x2: b.x, y2: b.y }, tol)) return il + } + return null + } + // [2240 KERAB-CENTRAL-RIDGE 2026-05-19] ridge 의 양 끝점이 ext1·ext2 각각의 segment 위에 있는지. // RG-1 은 H-4/H-2 확장선 사이에 놓인 ridge — 두 확장선이 RG-1 의 양 끝점을 각각 통과한다. const ridgeEndpointsOnBothExtensions = (ridge, ext1, ext2) => { @@ -646,6 +2380,38 @@ export function useEavesGableEdit(id) { // - 둘 다 없으면 폴백: c1↔apex, c2↔apex 신규 hip (이전 패턴 호환) const applyKerabRevertPattern = (roof, target, c1, c2, ridgeAtMid) => { const apex = ridgeAtMid.apex + // [KERAB-VALLEY-EXT 2026-05-27] forward 에서 추가됐던 처마확장(kerabPatternValleyExt) 제거. + // __targetId === target.id 매칭으로 해당 target 의 처마확장만 정확히 정리. 모든 패턴 분기 공통. + // roofBase 확장은 roof.innerLines 에 있고 wallBase 확장은 canvas 에만 있으므로 canvas 전체 스캔. + const valleyExtsToRemove = (canvas.getObjects() || []).filter( + (il) => il && il.lineName === 'kerabPatternValleyExt' && il.__targetId === target.id, + ) + for (const v of valleyExtsToRemove) { + removeLine(v) + roof.innerLines = roof.innerLines.filter((il) => il !== v) + } + if (valleyExtsToRemove.length) { + logger.log('[KERAB-VALLEY-EXT] revert removed ' + valleyExtsToRemove.length) + } + // [KERAB-VALLEY-EXT-TRIM 2026-05-27] forward 에서 절삭/도미노로 단축한 hip/ridge 끝점을 옛 좌표로 복원. + // trimRecords push 순서: 원본 trim → 도미노 cascade. 복원은 역순. + if (Array.isArray(target.__valleyExtTrims) && target.__valleyExtTrims.length) { + for (let i = target.__valleyExtTrims.length - 1; i >= 0; i--) { + const rec = target.__valleyExtTrims[i] + const il = rec.line + if (!il) continue + if (rec.hide) { + il.visible = rec.originalVisible !== false + } else { + if (rec.end === 1) il.set({ x1: rec.oldPt.x, y1: rec.oldPt.y }) + else il.set({ x2: rec.oldPt.x, y2: rec.oldPt.y }) + } + if (rec.originalAttrs) il.attributes = rec.originalAttrs + if (typeof il.setCoords === 'function') il.setCoords() + } + logger.log('[KERAB-VALLEY-EXT-TRIM] revert restored ' + target.__valleyExtTrims.length + ' trim(s)') + delete target.__valleyExtTrims + } const buildHipFromSnapshot = (snap) => { const pts = [snap.x1, snap.y1, snap.x2, snap.y2] const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) @@ -669,7 +2435,7 @@ export function useEavesGableEdit(id) { parentId: roof.id, fontSize: roof.fontSize, stroke: '#1083E3', - strokeWidth: 4, + strokeWidth: 3, name: LINE_TYPE.SUBLINE.HIP, textMode: roof.textMode, attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, @@ -683,6 +2449,62 @@ export function useEavesGableEdit(id) { hip.attributes = { ...hip.attributes, ...snap.attributes, planeSize: ns, actualSize: ns } } + // [2240 KERAB-KLINE-ONLY 2026-05-20] kLine 만 그렸던 패턴: ridge 1개 제거 + 제거됐던 hip 복원. + // forward 가 ridge add + hip 2개 remove 했으므로 revert 도 동일하게 되돌림. + if (ridgeAtMid.ridge.__patternKind === 'kLineOnly') { + const removedHips = Array.isArray(ridgeAtMid.ridge.__removedHipsSnapshot) ? ridgeAtMid.ridge.__removedHipsSnapshot : [] + const removedRidges = Array.isArray(ridgeAtMid.ridge.__removedRidgesSnapshot) ? ridgeAtMid.ridge.__removedRidgesSnapshot : [] + const extHipsCreated = Array.isArray(ridgeAtMid.ridge.__extHipsCreated) ? ridgeAtMid.ridge.__extHipsCreated : [] + // [KERAB-SIMPLE-EXTHIP 2026-05-20] forward 에서 추가됐던 ext hip 제거. + for (const eh of extHipsCreated) { + if (!eh) continue + removeLine(eh) + roof.innerLines = roof.innerLines.filter((il) => il !== eh) + } + removeLine(ridgeAtMid.ridge) + roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge) + for (const snap of removedHips) { + const sz = calcLinePlaneSize({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 }) + const hip = new QLine([snap.x1, snap.y1, snap.x2, snap.y2], { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.HIP, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...(snap.attributes || {}) }, + }) + if (snap.lineName) hip.lineName = snap.lineName + if (snap.__extended) hip.__extended = snap.__extended + canvas.add(hip) + hip.bringToFront() + roof.innerLines.push(hip) + } + // [KERAB-SIMPLE-MIDRIDGE 2026-05-20] forward 시 제거됐던 hip-사이 ridge 복원. + for (const snap of removedRidges) { + if (!snap) continue + const pts = [snap.x1, snap.y1, snap.x2, snap.y2] + const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) + const restored = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...(snap.attributes || {}) }, + }) + if (snap.lineName) restored.lineName = snap.lineName + canvas.add(restored) + restored.bringToFront() + roof.innerLines.push(restored) + } + removeKerabHalfLabels(target.id) + hideOriginalLengthText(target.id, false) + canvas.renderAll() + return true + } + // junctionExtended: ext hip 2개 + ridge 1개 제거 + 제거됐던 RG-1 + outer/inner hip 4개 복원 if (ridgeAtMid.ridge.__patternKind === 'junctionExtended') { const extHips = Array.isArray(ridgeAtMid.ridge.__patternExtHips) ? ridgeAtMid.ridge.__patternExtHips : [] @@ -703,7 +2525,7 @@ export function useEavesGableEdit(id) { parentId: roof.id, fontSize: roof.fontSize, stroke: '#1083E3', - strokeWidth: 4, + strokeWidth: 3, name: LINE_TYPE.SUBLINE.RIDGE, textMode: roof.textMode, attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes }, @@ -830,9 +2652,318 @@ export function useEavesGableEdit(id) { __extended: hip.__extended, }) + // [2240 KERAB-SIMPLE 2026-05-20] 단순 알고리즘 전용 그리기 함수. + // mid(roof corner c1·c2 중점) → apex 중앙선 추가 + 양쪽 hip(h1·h2) 제거. + // c1, c2 가 hip.near (대각 offset roof corner) 이므로 그 중점이 roofLine 위 중점이 됨. + // revert 는 __patternKind='kLineOnly' 분기 + hipSnapshot 으로 ridge 제거 + hip 2개 복원. + const applyKerabKLinePattern = (roof, target, apex, c1, c2, hipsToRemove, ridgesToRemove, extLines, drawKLine = true, extraApexes = []) => { + // [KERAB-SIMPLE-KLINE-PERP 2026-05-20] kLine = apex 에서 roofLine(c1·c2 무한확장) 으로 + // 내린 수선의 발 → apex. 시작점이 hip 교점이므로 이 직선 자체가 "hip 라인의 중앙선". + // roofLine 중앙과는 무관 (비대칭이면 빗겨 떨어진다). + // [KERAB-EXTENDER-MIXED 2026-05-21] drawKLine=false (hip+ridge mixed extender) 케이스: + // 사용자 전제 3 "hip과 마루가 만나면 그 자리에서 멈춘다" — kLine 없이 apex 까지의 ext line 만. + // revert 식별을 위해 apex 위치 zero-length invisible 마커 ridge 추가. + let ridge + if (drawKLine) { + const dx = c2.x - c1.x + const dy = c2.y - c1.y + const lenSq = dx * dx + dy * dy || 1 + const t = ((apex.x - c1.x) * dx + (apex.y - c1.y) * dy) / lenSq + const naiveFoot = { x: c1.x + t * dx, y: c1.y + t * dy } + let foot = naiveFoot + // [KERAB-KLINE-TO-WALL 2026-05-21] kLine 도 roofLine 까지 확장 — apex 에서 naiveFoot 방향으로 + // ray cast 해서 roof 폴리곤의 어떤 wall 이라도 가장 가까운 교점까지 연장. + // 비대칭 폴리곤에서 c1-c2 무한확장선이 폴리곤 밖으로 빠지는 케이스 흡수. + if (Array.isArray(roof.points) && roof.points.length >= 2) { + const fdx = naiveFoot.x - apex.x + const fdy = naiveFoot.y - apex.y + const flen2 = fdx * fdx + fdy * fdy + if (flen2 > 1e-6) { + const rayEnd = { x: apex.x + fdx, y: apex.y + fdy } + let bestPt = null + let bestDist = Infinity + for (let i = 0; i < roof.points.length; i++) { + const a = roof.points[i] + const b = roof.points[(i + 1) % roof.points.length] + const ip = lineLineIntersection(apex, rayEnd, a, b) + if (!ip) continue + const fwd = (ip.x - apex.x) * fdx + (ip.y - apex.y) * fdy + if (fwd <= 1e-3) continue + if (!isPointOnSegment(ip, { x1: a.x, y1: a.y, x2: b.x, y2: b.y })) continue + const dist = Math.hypot(ip.x - apex.x, ip.y - apex.y) + if (dist < bestDist) { + bestDist = dist + bestPt = ip + } + } + if (bestPt) foot = bestPt + } + } + // [KERAB-KLINE-STOP 2026-05-21] 새 kLine(apex→foot) 이 기존 kLine 가로지르면 가장 가까운 교점까지 클립. + const existingKLines = (roof.innerLines || []).filter( + (il) => il && il.lineName === 'kerabPatternRidge' && !il.__noKLine && il.__targetId !== target.id && il.visible !== false, + ) + if (existingKLines.length) { + const segOnLine = (pt, ax, ay, bx, by, tol = 0.5) => { + const ddx = bx - ax + const ddy = by - ay + const sq = ddx * ddx + ddy * ddy + if (sq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol + const tt = ((pt.x - ax) * ddx + (pt.y - ay) * ddy) / sq + const margin = tol / Math.sqrt(sq) + if (tt < -margin || tt > 1 + margin) return false + const px = ax + tt * ddx + const py = ay + tt * ddy + return Math.hypot(px - pt.x, py - pt.y) <= tol + } + let bestT = 1 + let bestPt = null + const adx = foot.x - apex.x + const ady = foot.y - apex.y + for (const kl of existingKLines) { + const ip = lineLineIntersection(apex, foot, { x: kl.x1, y: kl.y1 }, { x: kl.x2, y: kl.y2 }) + if (!ip) continue + if (!segOnLine(ip, kl.x1, kl.y1, kl.x2, kl.y2)) continue + const tt = (adx * (ip.x - apex.x) + ady * (ip.y - apex.y)) / (adx * adx + ady * ady || 1) + if (tt <= 1e-3 || tt > 1 + 1e-3) continue + if (tt < bestT) { + bestT = tt + bestPt = ip + } + } + if (bestPt) foot = bestPt + } + // [KERAB-NO-PIERCE 2026-05-21] kLine 도 정적 hip/ridge + 이번 ext line 가로지르면 거기서 정지. + // hipsToRemove/ridgesToRemove 는 곧 사라질 라인이므로 barrier 에서 제외. + { + const segOnLine = (pt, ax, ay, bx, by, tol = 0.5) => { + const ddx = bx - ax + const ddy = by - ay + const sq = ddx * ddx + ddy * ddy + if (sq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol + const tt = ((pt.x - ax) * ddx + (pt.y - ay) * ddy) / sq + const margin = tol / Math.sqrt(sq) + if (tt < -margin || tt > 1 + margin) return false + const px = ax + tt * ddx + const py = ay + tt * ddy + return Math.hypot(px - pt.x, py - pt.y) <= tol + } + const removingHips = new Set(Array.isArray(hipsToRemove) ? hipsToRemove : []) + const removingRidges = new Set(Array.isArray(ridgesToRemove) ? ridgesToRemove : []) + const kBarriers = [] + for (const il of roof.innerLines || []) { + if (!il) continue + if (removingHips.has(il) || removingRidges.has(il)) continue + if (il.lineName === 'kerabPatternRidge') continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.visible === false) continue + kBarriers.push({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) + } + if (Array.isArray(extLines)) { + for (const seg of extLines) { + if (!seg) continue + kBarriers.push({ x1: seg.from.x, y1: seg.from.y, x2: seg.to.x, y2: seg.to.y }) + } + } + let bestT = 1 + let bestPt = null + const adx = foot.x - apex.x + const ady = foot.y - apex.y + const aSq = adx * adx + ady * ady || 1 + for (const bl of kBarriers) { + const ip = lineLineIntersection(apex, foot, { x: bl.x1, y: bl.y1 }, { x: bl.x2, y: bl.y2 }) + if (!ip) continue + if (!segOnLine(ip, bl.x1, bl.y1, bl.x2, bl.y2)) continue + const tt = (adx * (ip.x - apex.x) + ady * (ip.y - apex.y)) / aSq + if (tt <= 1e-3 || tt > 1 + 1e-3) continue + if (tt < bestT) { + bestT = tt + bestPt = ip + } + } + if (bestPt) foot = bestPt + } + const points = [foot.x, foot.y, apex.x, apex.y] + const sz = calcLinePlaneSize({ x1: points[0], y1: points[1], x2: points[2], y2: points[3] }) + ridge = new QLine(points, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 4, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, + }) + } else { + ridge = new QLine([apex.x, apex.y, apex.x, apex.y], { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: 'rgba(0,0,0,0)', + strokeWidth: 0, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: roof.textMode, + visible: false, + selectable: false, + attributes: { roofId: roof.id, planeSize: 0, actualSize: 0 }, + }) + } + ridge.lineName = 'kerabPatternRidge' + ridge.__patternKind = 'kLineOnly' + ridge.__targetId = target.id + ridge.__noKLine = !drawKLine + canvas.add(ridge) + ridge.bringToFront() + roof.innerLines.push(ridge) + + // [KERAB-MULTI-APEX 2026-05-22] 추가 apex 각각 별도 kLine ridge — primary 의 apex→foot 방향을 reference 로 ray cast. + // c1·c2 line 에 수직 projection 으로 foot 을 잡으면 추가 apex 가 c1·c2 line 의 반대편이라 ray 방향이 뒤집힘. + // primary 의 unit direction 으로 강제 → primary 와 같은 방향(roofLine 쪽) 으로 kLine 연장. + const auxRidges = [] + if (drawKLine && Array.isArray(extraApexes) && extraApexes.length) { + // primary 의 unit direction (apex→foot, roofLine 쪽). + const primDx = c2.x - c1.x + const primDy = c2.y - c1.y + const primLenSq = primDx * primDx + primDy * primDy || 1 + const primT = ((apex.x - c1.x) * primDx + (apex.y - c1.y) * primDy) / primLenSq + const primNaiveFoot = { x: c1.x + primT * primDx, y: c1.y + primT * primDy } + const refFdx = primNaiveFoot.x - apex.x + const refFdy = primNaiveFoot.y - apex.y + const refFlen = Math.hypot(refFdx, refFdy) || 1 + const refUx = refFdx / refFlen + const refUy = refFdy / refFlen + for (const exApex of extraApexes) { + let foot = { x: exApex.x + refUx, y: exApex.y + refUy } + if (Array.isArray(roof.points) && roof.points.length >= 2) { + const rayEnd = { x: exApex.x + refUx, y: exApex.y + refUy } + let bestPt = null + let bestDist = Infinity + for (let i = 0; i < roof.points.length; i++) { + const a = roof.points[i] + const b = roof.points[(i + 1) % roof.points.length] + const ip = lineLineIntersection(exApex, rayEnd, a, b) + if (!ip) continue + const fwd = (ip.x - exApex.x) * refUx + (ip.y - exApex.y) * refUy + if (fwd <= 1e-3) continue + if (!isPointOnSegment(ip, { x1: a.x, y1: a.y, x2: b.x, y2: b.y })) continue + const dist = Math.hypot(ip.x - exApex.x, ip.y - exApex.y) + if (dist < bestDist) { + bestDist = dist + bestPt = ip + } + } + if (bestPt) foot = bestPt + } + const pts = [foot.x, foot.y, exApex.x, exApex.y] + const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) + const auxRidge = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 4, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, + }) + auxRidge.lineName = 'kerabPatternRidge' + auxRidge.__patternKind = 'kLineOnly' + auxRidge.__targetId = target.id + auxRidge.__auxApex = true + canvas.add(auxRidge) + auxRidge.bringToFront() + roof.innerLines.push(auxRidge) + auxRidges.push(auxRidge) + } + ridge.__auxApexRidges = auxRidges + } + + // [KERAB-SIMPLE-EXTHIP 2026-05-20] 확장 hip/ridge 추가 — 접점에서 newApex 까지의 연장선. + // 원본 hip + RG-1 제거 전에 먼저 그려두어야 시각적으로 끊김 없이 전환됨. + // revert 시 ridge.__extHipsCreated 참조로 제거. + // [KERAB-EXTENDER-MIXED 2026-05-21] seg.isHip 으로 hip/ridge 구분 (mixed extender 케이스). + if (Array.isArray(extLines) && extLines.length) { + const created = [] + for (const seg of extLines) { + if (!seg) continue + const pts = [seg.from.x, seg.from.y, seg.to.x, seg.to.y] + const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) + const segIsHip = seg.isHip !== false + const extLine = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: segIsHip ? LINE_TYPE.SUBLINE.HIP : LINE_TYPE.SUBLINE.RIDGE, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, + }) + extLine.lineName = segIsHip ? 'kerabPatternExtHip' : 'kerabPatternExtRidge' + extLine.__extended = true + canvas.add(extLine) + extLine.bringToFront() + roof.innerLines.push(extLine) + created.push(extLine) + } + ridge.__extHipsCreated = created + } + + // [KERAB-SIMPLE-HIP-REMOVE 2026-05-20] 양쪽 hip 제거 + revert 스냅샷 저장 + // [KERAB-POLYGON-BFS 2026-05-21] 폴리곤 경로상 path hip 도 함께 들어옴. lineName/__extended + // 까지 스냅샷에 보존해 revert 시 SK extended flag 등 메타 정보 복원. + if (Array.isArray(hipsToRemove) && hipsToRemove.length) { + const snapshot = [] + for (const hip of hipsToRemove) { + if (!hip) continue + snapshot.push({ + x1: hip.x1, + y1: hip.y1, + x2: hip.x2, + y2: hip.y2, + attributes: hip.attributes ? { ...hip.attributes } : null, + lineName: hip.lineName, + __extended: hip.__extended, + }) + removeLine(hip) + roof.innerLines = roof.innerLines.filter((il) => il !== hip) + } + ridge.__removedHipsSnapshot = snapshot + } + + // [KERAB-SIMPLE-MIDRIDGE 2026-05-20] 확장 apex 케이스에서 hip 사이를 잇던 ridge 제거 + revert 스냅샷. + if (Array.isArray(ridgesToRemove) && ridgesToRemove.length) { + const ridgeSnapshot = [] + for (const r of ridgesToRemove) { + if (!r) continue + ridgeSnapshot.push({ + x1: r.x1, + y1: r.y1, + x2: r.x2, + y2: r.y2, + attributes: r.attributes ? { ...r.attributes } : null, + lineName: r.lineName, + }) + removeLine(r) + roof.innerLines = roof.innerLines.filter((il) => il !== r) + } + ridge.__removedRidgesSnapshot = ridgeSnapshot + } + + hideOriginalLengthText(target.id, true) + removeKerabHalfLabels(target.id) + addKerabHalfLabels(target, { x: target.x1, y: target.y1 }, { x: target.x2, y: target.y2 }) + // [KERAB-SIMPLE-ROOFLABEL 2026-05-20] roofLine 위에도 half 라벨 추가 (c1·c2 기준) + addKerabHalfLabels(target, c1, c2) + canvas.renderAll() + return true + } + const applyKerabSingleRidgePattern = (roof, target, c1, c2, pair) => { - // 케라바 외곽선 중점은 roof 폴리곤 코너 c1↔c2 의 중점 - const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } + // [2240 KERAB-KLINE-ONLY 2026-05-20] 사용자 결정: 라인 삭제·생성 없이 중앙선(kLine)만 추가. + // - mid(케라바 외곽선 중점) → apex 으로 ridge 1개만 add + // - hip 페어/orphan ext/RG-1 등 기존 라인은 모두 그대로 보존 + // - revert 는 __patternKind='kLineOnly' 분기로 ridge 만 제거 + // [2240 KERAB-MID-FROM-TARGET 2026-05-20] mid 를 c1·c2(roof corner) 가 아닌 target 외곽선 실제 끝점으로 계산. + // roof.points 와 outerLine 좌표가 50 단위 drift 되어 있어도 외곽선 중점에서 정확히 출발. + const mid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 } const points = [mid.x, mid.y, pair.apex.x, pair.apex.y] const sz = calcLinePlaneSize({ x1: points[0], y1: points[1], x2: points[2], y2: points[3] }) const ridge = new QLine(points, { @@ -844,24 +2975,14 @@ export function useEavesGableEdit(id) { textMode: roof.textMode, attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, }) - // [KERAB-PATTERN-MARKER 2026-05-19] 할당 등 후속 흐름이 패턴 생성 라인을 식별할 수 있도록 ridge.lineName = 'kerabPatternRidge' - // [2240 KERAB-HIP-SNAPSHOT 2026-05-19] 원본 hip 스냅샷 ridge 에 첨부 (revert 시 원본 그대로 복원) - ridge.__originalHips = [snapshotHip(pair.hA), snapshotHip(pair.hB)] - // 불변식: ridge 한 끝점이 케라바 중점과 일치 - if (!ridgeMeetsMidpointOf(ridge, { x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y })) { + ridge.__patternKind = 'kLineOnly' + if (!ridgeMeetsMidpointOf(ridge, { x1: target.x1, y1: target.y1, x2: target.x2, y2: target.y2 })) { return false } - // 패턴이 직접 hip 페어 제거 + 짝 ext 동반 정리 (rebuild 흐름에 의존하지 않음) - removeOrphanExtensionsForHip(pair.hA) - removeOrphanExtensionsForHip(pair.hB) - removeLine(pair.hA) - removeLine(pair.hB) - roof.innerLines = roof.innerLines.filter((il) => il !== pair.hA && il !== pair.hB) canvas.add(ridge) ridge.bringToFront() roof.innerLines.push(ridge) - // 외곽선 라벨: 원본 1개 숨김 + 좌우/상하 절반 라벨 2개 추가 hideOriginalLengthText(target.id, true) removeKerabHalfLabels(target.id) addKerabHalfLabels(target, c1, c2) @@ -869,13 +2990,11 @@ export function useEavesGableEdit(id) { return true } - // [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] H-4/H-2 확장 패턴 (추가만, 기존 라인 무손상). - // 1) H-4 확장선(junction1 → apex) hip 추가 - // 2) H-2 확장선(junction2 → apex) hip 추가 - // 3) mid(B-5) → apex ridge 추가 - // 기존 outer(H-7/H-1)·inner(H-4/H-2)·RG-2 모두 그대로 유지. revert 시 추가된 3개만 제거. + // [2240 KERAB-KLINE-ONLY 2026-05-20] junction-extended 도 동일: 중앙선만 그리고 기존 라인 무손상. + // mid → jp.apex ridge 1개만 add. ext hip 생성·RG-1 제거·outer hip 제거 전부 skip. const applyKerabJunctionExtendedPattern = (roof, target, c1, c2, jp) => { - const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } + // [2240 KERAB-MID-FROM-TARGET 2026-05-20] mid = target 외곽선 실제 중점 (roof corner drift 제거). + const mid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 } const ridgePoints = [mid.x, mid.y, jp.apex.x, jp.apex.y] const ridgeSz = calcLinePlaneSize({ x1: ridgePoints[0], @@ -893,74 +3012,12 @@ export function useEavesGableEdit(id) { attributes: { roofId: roof.id, planeSize: ridgeSz, actualSize: ridgeSz }, }) ridge.lineName = 'kerabPatternRidge' - ridge.__patternKind = 'junctionExtended' - if (!ridgeMeetsMidpointOf(ridge, { x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y })) return false + ridge.__patternKind = 'kLineOnly' + if (!ridgeMeetsMidpointOf(ridge, { x1: target.x1, y1: target.y1, x2: target.x2, y2: target.y2 })) return false - const buildExtensionHip = (from, to) => { - const pts = [from.x, from.y, to.x, to.y] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - const hip = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 4, - name: LINE_TYPE.SUBLINE.HIP, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, - }) - hip.lineName = 'kerabPatternHip' - hip.__patternKind = 'junctionExtended' - return hip - } - const ext1 = buildExtensionHip(jp.junction1, jp.apex) - const ext2 = buildExtensionHip(jp.junction2, jp.apex) - ridge.__patternExtHips = [ext1, ext2] - - // [2240 KERAB-CENTRAL-RIDGE 2026-05-19] RG-1(주 마루) 제거. - // 양 끝점이 ext1·ext2 segment 위에 각각 놓인 ridge 가 RG-1. - // (H-4/H-2 확장선이 RG-1 끝점을 통과 → RG-2 등 다른 ridge 는 두 확장선과 무관) - const ridgesToRemove = roof.innerLines.filter( - (il) => - il && - il.name === LINE_TYPE.SUBLINE.RIDGE && - il.lineName !== 'kerabPatternRidge' && - il !== ridge && - ridgeEndpointsOnBothExtensions(il, ext1, ext2), - ) - logger.log('[KERAB-CENTRAL-RIDGE] lookup', { - ext1: `(${Math.round(ext1.x1)},${Math.round(ext1.y1)})→(${Math.round(ext1.x2)},${Math.round(ext1.y2)})`, - ext2: `(${Math.round(ext2.x1)},${Math.round(ext2.y1)})→(${Math.round(ext2.x2)},${Math.round(ext2.y2)})`, - removeCount: ridgesToRemove.length, - removeList: ridgesToRemove.map((il) => `(${Math.round(il.x1)},${Math.round(il.y1)})→(${Math.round(il.x2)},${Math.round(il.y2)})`), - }) - if (ridgesToRemove.length > 0) { - ridge.__removedRidgesSnapshot = ridgesToRemove.map(snapshotHip) - for (const il of ridgesToRemove) removeLine(il) - roof.innerLines = roof.innerLines.filter((il) => !ridgesToRemove.includes(il)) - } - - // [2240 KERAB-HIP-REMOVE 2026-05-19] 대전제: 케라바 외곽선 양 끝점(c1·c2) 에 붙은 hip 만 제거. - // outer1 (c1↔junction1), outer2 (c2↔junction2) — 사용자 선택 처마 양 끝점에서 만나는 hip - // inner (junction 너머) 는 보존. orphan extension 동반 정리, 스냅샷 첨부. - const hipsToRemove = [jp.outer1, jp.outer2] - ridge.__removedHipsSnapshot = hipsToRemove.map(snapshotHip) - for (const hip of hipsToRemove) { - removeOrphanExtensionsForHip(hip) - removeLine(hip) - } - roof.innerLines = roof.innerLines.filter((il) => !hipsToRemove.includes(il)) - logger.log('[KERAB-HIP-REMOVE] outer only', { - outer1: `(${Math.round(jp.outer1.x1)},${Math.round(jp.outer1.y1)})→(${Math.round(jp.outer1.x2)},${Math.round(jp.outer1.y2)})`, - outer2: `(${Math.round(jp.outer2.x1)},${Math.round(jp.outer2.y1)})→(${Math.round(jp.outer2.x2)},${Math.round(jp.outer2.y2)})`, - }) - - canvas.add(ext1) - canvas.add(ext2) canvas.add(ridge) - ext1.bringToFront() - ext2.bringToFront() ridge.bringToFront() - roof.innerLines.push(ext1, ext2, ridge) + roof.innerLines.push(ridge) hideOriginalLengthText(target.id, true) removeKerabHalfLabels(target.id) @@ -969,8 +3026,43 @@ export function useEavesGableEdit(id) { return true } - // [2240 KERAB-ATTR-ONLY 2026-05-19] 평행 hip 케이스: 외곽선 attributes.type 만 토글. - // - hip 라인/ridge/라벨 등 SK 산출물 전부 무변경 + s // [2240 KERAB-KLINE-ONLY 2026-05-20] 양 끝 hip 평행 케이스: apex 가 없어 중앙선조차 그릴 수 없음. + // "라인 삭제 금지 + 중앙선만 그리기" 정책상 hip 제거도 skip → attr-only 와 동일하게 동작. + const applyKerabParallelHipsPattern = () => { + canvas.renderAll() + return true + } + + // [2240 KERAB-PARALLEL-HIPS 2026-05-19] revert: target.__kerabParallelHipsSnapshot 기반으로 hip 2개 복원. + const revertKerabParallelHipsPattern = (roof, target) => { + const snaps = Array.isArray(target.__kerabParallelHipsSnapshot) ? target.__kerabParallelHipsSnapshot : [] + snaps.forEach((snap) => { + if (!snap) return + const pts = [snap.x1, snap.y1, snap.x2, snap.y2] + const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) + const hip = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 4, + name: LINE_TYPE.SUBLINE.HIP, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes }, + }) + + if (snap.lineName) hip.lineName = snap.lineName + if (snap.__extended) hip.__extended = snap.__extended + canvas.add(hip) + hip.bringToFront() + roof.innerLines.push(hip) + }) + delete target.__kerabParallelHipsSnapshot + canvas.renderAll() + return true + } + + // [2240 KERAB-ATTR-ONLY 2026-05-19] fallback: hip 페어가 1·2·3·평행 어디에도 해당 안 될 때 (hip 0/1 개, apex 가 중앙 아님 등). + // - 외곽선 attributes.type 만 토글, hip 라인/ridge/라벨 등 SK 산출물 전부 무변경 // - 호출자가 이미 target.set({ attributes }) 적용한 상태로 들어옴 → 렌더만 갱신 // - forward/revert 양방향 동일 처리 (대칭) const applyKerabAttributeOnlyPattern = () => { diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index 351fb8cc..50e1e65d 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -26,7 +26,7 @@ export function useCanvasEvent() { useEffect(() => { canvas?.setZoom(canvasZoom / 100) }, [canvasZoom]) - + // canvasZoom이 100으로 변경되고 shouldCenter가 true일 때만 중앙 정렬 실행 useEffect(() => { if (canvasZoom === 100 && canvas && shouldCenter) { @@ -287,23 +287,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" 키가 눌렸을 때의 동작입니다. @@ -450,22 +454,21 @@ 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') return @@ -486,50 +489,50 @@ export function useCanvasEvent() { const objectsCenterX = (minX + maxX) / 2 const objectsCenterY = (minY + maxY) / 2 - + // 화면 중앙 좌표 (메뉴 높이 고려하여 Y 위치 조정) const screenCenterX = canvas.getWidth() / 2 const menuHeightOffset = 80 // 위쪽 메뉴 높이만큼 위로 조정 const screenCenterY = canvas.getHeight() / 2 - menuHeightOffset - + // 객체 중심을 화면 중앙으로 이동 const deltaX = screenCenterX - objectsCenterX const deltaY = screenCenterY - objectsCenterY - + // canvas viewport transform 조정 const viewportTransform = canvas.viewportTransform viewportTransform[4] += deltaX viewportTransform[5] += deltaY - + canvas.setViewportTransform(viewportTransform) - + // 브라우저 스크롤을 중앙으로 조정하여 전체 canvas를 모니터 중앙에 위치 const canvasElement = canvas.getElement() const canvasRect = canvasElement.getBoundingClientRect() - + // 현재 스크롤 위치 const currentScrollTop = window.pageYOffset || document.documentElement.scrollTop const currentScrollLeft = window.pageXOffset || document.documentElement.scrollLeft - + // canvas의 현재 화면 위치 const canvasScreenTop = canvasRect.top + currentScrollTop const canvasScreenLeft = canvasRect.left + currentScrollLeft - + // 모니터 중앙 const monitorCenterX = window.innerWidth / 2 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() } diff --git a/src/hooks/useEvent.js b/src/hooks/useEvent.js index 5a5dfac2..9bc4e434 100644 --- a/src/hooks/useEvent.js +++ b/src/hooks/useEvent.js @@ -385,10 +385,13 @@ export function useEvent() { const lineBottom = visibleBottom + padding // 가로선 (수평선) + // [2240 MOUSELINE-EVENTED-FALSE 2026-05-20] selectable:false 만으로는 e.target 가로채기 차단 불가 — outerLine 클릭이 + // 십자선에 잡히는 회귀 차단. mouseLine 은 시각 표시만, 좌표 계산은 filter(name==='mouseLine') 로 접근. const horizontalLine = new fabric.Line([lineLeft, pointer.y, lineRight, pointer.y], { stroke: 'red', strokeWidth: 1, selectable: false, + evented: false, direction: 'horizontal', name: 'mouseLine', }) @@ -398,6 +401,7 @@ export function useEvent() { stroke: 'red', strokeWidth: 1, selectable: false, + evented: false, direction: 'vertical', name: 'mouseLine', }) diff --git a/src/hooks/useMode.js b/src/hooks/useMode.js index af134ece..df61fbe1 100644 --- a/src/hooks/useMode.js +++ b/src/hooks/useMode.js @@ -284,10 +284,13 @@ export function useMode() { } // 가로선을 그립니다. + // [2240 MOUSELINE-EVENTED-FALSE 2026-05-20] selectable:false 만으로는 e.target 가로채기 차단 불가 — 클릭이 + // 십자선에 잡히는 회귀 차단. mouseLine 은 시각 표시만, 좌표 계산은 filter(name==='mouseLine') 로 접근. const horizontalLine = new fabric.Line([0, newY, 2 * canvas.width, newY], { stroke: 'red', strokeWidth: 1, selectable: false, + evented: false, name: 'mouseLine', }) @@ -296,6 +299,7 @@ export function useMode() { stroke: 'red', strokeWidth: 1, selectable: false, + evented: false, name: 'mouseLine', }) diff --git a/src/locales/ja.json b/src/locales/ja.json index ba535999..88e88a2a 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -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": "※数字は[半角]入力のみ可能です。", diff --git a/src/locales/ko.json b/src/locales/ko.json index a596bffe..956d552d 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -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": "※숫자는 [반각] 입력만 가능합니다.", diff --git a/src/styles/_contents.scss b/src/styles/_contents.scss index fd70eb5e..a3f97928 100644 --- a/src/styles/_contents.scss +++ b/src/styles/_contents.scss @@ -17,1566 +17,1845 @@ // } // } // CanvasMenu -.canvas-menu-wrap{ - position: fixed; - top: 46px; - left: 0; - display: block; - width: 100%; - min-width: 1280px; - padding-bottom: 0; - background-color: #383838; - transition: padding .17s ease-in-out; +.canvas-menu-wrap { + position: fixed; + top: 46px; + left: 0; + display: block; + width: 100%; + min-width: 1280px; + padding-bottom: 0; + background-color: #383838; + transition: padding 0.17s ease-in-out; + z-index: 999; + + .canvas-menu-inner { + position: relative; + display: flex; + align-items: center; + padding: 0 40px 0 20px; + background-color: #2c2c2c; + height: 46.8px; z-index: 999; - .canvas-menu-inner{ - position: relative; + + .canvas-menu-list { + display: flex; + align-items: center; + height: 100%; + + .canvas-menu-item { display: flex; align-items: center; - padding: 0 40px 0 20px; - background-color: #2C2C2C; - height: 46.8px; - z-index: 999; - .canvas-menu-list{ - display: flex; - align-items: center; - height: 100%; - .canvas-menu-item{ - display: flex; - align-items: center; - height: 100%; - button{ - display: flex; - align-items: center; - font-size: 12px; - height: 100%; - color: #fff; - font-weight: 600; - padding: 15px 20px; - opacity: 0.55; - transition: all .17s ease-in-out; - .menu-icon{ - display: block; - width: 14px; - height: 14px; - background-repeat: no-repeat; - background-position: center; - background-size: contain; - margin-right: 10px; - &.con00{background-image: url(/static/images/canvas/menu_icon00.svg);} - &.con01{background-image: url(/static/images/canvas/menu_icon01.svg);} - &.con02{background-image: url(/static/images/canvas/menu_icon02.svg);} - &.con03{background-image: url(/static/images/canvas/menu_icon03.svg);} - &.con04{background-image: url(/static/images/canvas/menu_icon04.svg);} - &.con05{background-image: url(/static/images/canvas/menu_icon05.svg);} - &.con06{background-image: url(/static/images/canvas/menu_icon06.svg);} - } - } - &.active{ - background-color: #383838; - button{ - opacity: 1; - } - } - } - } - .canvas-side-btn-wrap{ - display: flex; - align-items: center; - margin-left: auto; - .select-box{ - width: 124px; - margin: 0 5px; - height: 30px; - > div{ - width: 100%; - } - } - .btn-from{ - display: flex; - align-items: center; - gap: 5px; - button{ - display: block; - width: 30px; - height: 30px; - border-radius: 2px; - background-color: #3D3D3D; - background-position: center; - background-repeat: no-repeat; - background-size: 15px 15px; - transition: all .17s ease-in-out; - &.btn01{background-image: url(../../public/static/images/canvas/side_icon03.svg);} - &.btn02{background-image: url(../../public/static/images/canvas/side_icon02.svg);} - &.btn03{background-image: url(../../public/static/images/canvas/side_icon01.svg);} - &.btn04{background-image: url(../../public/static/images/canvas/side_icon04.svg);} - &.btn05{background-image: url(../../public/static/images/canvas/side_icon05.svg);} - &.btn06{background-image: url(../../public/static/images/canvas/side_icon06.svg);} - &.btn07{background-image: url(../../public/static/images/canvas/side_icon07.svg);} - &.btn08{background-image: url(../../public/static/images/canvas/side_icon08.svg);} - &.btn09{background-image: url(../../public/static/images/canvas/side_icon09.svg);} - &.btn10{background-image: url(../../public/static/images/canvas/side_icon10.svg); background-size: 15px 14px;} - &:hover{ - background-color: #1083E3; - } - &.active{ - background-color: #1083E3; - } - } - } - .ico-btn-from{ - display: flex; - align-items: center; - gap: 5px; - button{ - .ico{ - display: block; - width: 15px; - height: 15px; - background-repeat: no-repeat; - background-position: center; - background-size: contain; - &.ico01{background-image: url(../../public/static/images/canvas/ico-flx01.svg);} - &.ico02{background-image: url(../../public/static/images/canvas/ico-flx02.svg);} - &.ico03{background-image: url(../../public/static/images/canvas/ico-flx03.svg);} - &.ico04{background-image: url(../../public/static/images/canvas/ico-flx04.svg);} - &.ico05{background-image: url(../../public/static/images/canvas/ico-flx05.svg);} - } - .name{ - font-size: 12px; - color: #fff; - } - } - &.form06{ - .name{ - font-size: 13px; - } - } - } - .undo-redo-wrap{ - display: flex; - gap: 2px; - margin-right: 5px; - button{ - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 2px; - background: #373737; - color: #fff; - cursor: pointer; - transition: all .17s ease-in-out; - &:hover:not(.disabled){ - background: #4B4B4B; - } - &.disabled{ - opacity: 0.3; - cursor: default; - } - } - } - .vertical-horizontal{ - display: flex; - min-width: 170px; - height: 28px; - margin-right: 5px; - border-radius: 2px; - background: #373737; - line-height: 28px; - overflow: hidden; - span{ - padding: 0 10px; - font-size: 13px; - color: #fff; - } - button{ - margin-left: auto; - height: 100%; - background-color: #4B4B4B; - font-size: 13px; - font-weight: 400; - color: #fff; - padding: 0 7.5px; - transition: all .17s ease-in-out; - } - &.on{ - button{ - background-color: #1083E3; - } - } - } - .size-control{ - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - background-color: #3D3D3D; - border-radius: 2px; - width: 100px; - height: 30px; - margin: 0 5px; - span{ - font-size: 13px; - color: #fff; - cursor: pointer; - } - .control-btn{ - display: block; - width: 12px; - height: 12px; - background-repeat: no-repeat; - background-size: cover; - background-position: center; - &.minus{ - background-image: url(../../public/static/images/canvas/minus.svg); - } - &.plus{ - background-image: url(../../public/static/images/canvas/plus.svg); - } - } - } - } - } - .canvas-depth2-wrap{ - position: absolute; - top: -100%; - left: 0; - background-color: #383838; - width: 100%; - height: 50px; - transition: all .17s ease-in-out; - .canvas-depth2-inner{ - display: flex; - align-items: center; - padding: 0 40px; - height: 100%; - .canvas-depth2-list{ - display: flex; - align-items: center ; - height: 100%; - .canvas-depth2-item{ - display: flex; - align-items: center; - margin-right: 26px; - height: 100%; - button{ - position: relative; - opacity: 0.55; - color: #fff; - font-size: 12px; - font-weight: normal; - height: 100%; - padding-right: 12px; - } - &.active{ - button{ - opacity: 1; - font-weight: 600; - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 5px; - height: 8px; - background: url(../../public/static/images/canvas/depth2-arr.svg) no-repeat center; - } - } - } - } - } - .canvas-depth2-btn-list{ - display: flex; - align-items: center; - margin-left: auto; - height: 100%; - .depth2-btn-box{ - display: flex; - align-items: center; - margin-right: 34px; - height: 100%; - transition: all .17s ease-in-out; - button{ - position: relative; - font-size: 12px; - font-weight: 400; - height: 100%; - color: #fff; - padding-right: 12px; - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 5px; - height: 8px; - background: url(../../public/static/images/canvas/depth2-arr.svg) no-repeat center; - } - } - &:last-child{ - margin-right: 0; - } - &.mouse{ - opacity: 0.55; - } - } - } - } - &.active{ - top: 47px; - } - } - &.active{ - padding-bottom: 50px; - } -} + height: 100%; -// canvas-layout -.canvas-content{ - padding-top: 46.8px; - transition: all .17s ease-in-out; - .canvas-frame{ - height: calc(100vh - 129.3px); - } - &.active{ - padding-top: calc(46.8px + 50px); - .canvas-frame{ - height: calc(100vh - 179.4px); + button { + display: flex; + align-items: center; + font-size: 12px; + height: 100%; + color: #fff; + font-weight: 600; + padding: 15px 20px; + opacity: 0.55; + transition: all 0.17s ease-in-out; + + .menu-icon { + display: block; + width: 14px; + height: 14px; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + margin-right: 10px; + + &.con00 { + background-image: url(/static/images/canvas/menu_icon00.svg); + } + + &.con01 { + background-image: url(/static/images/canvas/menu_icon01.svg); + } + + &.con02 { + background-image: url(/static/images/canvas/menu_icon02.svg); + } + + &.con03 { + background-image: url(/static/images/canvas/menu_icon03.svg); + } + + &.con04 { + background-image: url(/static/images/canvas/menu_icon04.svg); + } + + &.con05 { + background-image: url(/static/images/canvas/menu_icon05.svg); + } + + &.con06 { + background-image: url(/static/images/canvas/menu_icon06.svg); + } + } } + + &.active { + background-color: #383838; + + button { + opacity: 1; + } + } + } } -} -.canvas-layout{ - padding-top: 37px; - .canvas-page-list{ - position: fixed; - top: 92.8px; - left: 0; + + .canvas-side-btn-wrap { + display: flex; + align-items: center; + margin-left: auto; + + .select-box { + width: 124px; + margin: 0 5px; + height: 30px; + + > div { + width: 100%; + } + } + + .btn-from { display: flex; - background-color: #1C1C1C; - border-top: 1px solid #000; - width: 100%; - min-width: 1280px; - transition: all .17s ease-in-out; - z-index: 99; - &.active{ - top: calc(92.8px + 50px); + align-items: center; + gap: 5px; + + button { + display: block; + width: 30px; + height: 30px; + border-radius: 2px; + background-color: #3d3d3d; + background-position: center; + background-repeat: no-repeat; + background-size: 15px 15px; + transition: all 0.17s ease-in-out; + + &.btn01 { + background-image: url(../../public/static/images/canvas/side_icon03.svg); + } + + &.btn02 { + background-image: url(../../public/static/images/canvas/side_icon02.svg); + } + + &.btn03 { + background-image: url(../../public/static/images/canvas/side_icon01.svg); + } + + &.btn04 { + background-image: url(../../public/static/images/canvas/side_icon04.svg); + } + + &.btn05 { + background-image: url(../../public/static/images/canvas/side_icon05.svg); + } + + &.btn06 { + background-image: url(../../public/static/images/canvas/side_icon06.svg); + } + + &.btn07 { + background-image: url(../../public/static/images/canvas/side_icon07.svg); + } + + &.btn08 { + background-image: url(../../public/static/images/canvas/side_icon08.svg); + } + + &.btn09 { + background-image: url(../../public/static/images/canvas/side_icon09.svg); + } + + &.btn10 { + background-image: url(../../public/static/images/canvas/side_icon10.svg); + background-size: 15px 14px; + } + + &:hover { + background-color: #1083e3; + } + + &.active { + background-color: #1083e3; + } } - .canvas-id{ - display: flex; - align-items: center; - padding: 9.6px 20px; + } + + .ico-btn-from { + display: flex; + align-items: center; + gap: 5px; + + button { + .ico { + display: block; + width: 15px; + height: 15px; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + + &.ico01 { + background-image: url(../../public/static/images/canvas/ico-flx01.svg); + } + + &.ico02 { + background-image: url(../../public/static/images/canvas/ico-flx02.svg); + } + + &.ico03 { + background-image: url(../../public/static/images/canvas/ico-flx03.svg); + } + + &.ico04 { + background-image: url(../../public/static/images/canvas/ico-flx04.svg); + } + + &.ico05 { + background-image: url(../../public/static/images/canvas/ico-flx05.svg); + } + } + + .name { font-size: 12px; color: #fff; - background-color: #1083E3; + } } - .canvas-plane-wrap{ - display: flex; - align-items: center; - max-width: calc(100% - 45px); - .canvas-page-box{ - display: flex; - align-items: center; - background-color: #1c1c1c; - padding: 9.6px 20px; - border-right:1px solid #000; - min-width: 0; - transition: all .17s ease-in-out; - span{ - display: flex; - align-items: center; - width: 100%; - font-size: 12px; - font-family: 'Pretendard', sans-serif; - color: #AAA; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - .close{ - flex: none; - display: block; - width: 7px; - height: 8px; - margin-left: 15px; - background: url(../../public/static/images/canvas/plan_close_gray.svg)no-repeat center; - background-size: cover; - } - &.on{ - background-color: #fff; - span{ - font-weight: 600; - color: #101010; - } - .close{ - background: url(../../public/static/images/canvas/plan_close_black.svg)no-repeat center; - } - &:hover{ - background-color: #fff; - } - } - &:hover{ - background-color: #000; - } - } - } - .plane-add{ - display: flex; - align-items: center; - justify-content: center; - width: 45px; - padding: 13.5px 0; - background-color: #1C1C1C; - border-right: 1px solid #000; - transition: all .17s ease-in-out; - span{ - display: block; - width: 9px; - height: 9px; - background: url(../../public/static/images/canvas/plane_add.svg)no-repeat center; - background-size: cover; - } - &:hover{ - background-color: #000; - } - } - } -} -.canvas-frame{ - position: relative; - // height: calc(100% - 36.5px); - background-color: #F4F4F7; - overflow: auto; - transition: all .17s ease-in-out; - // &::-webkit-scrollbar { - // width: 10px; - // height: 10px; - // background-color: #fff; - // } - // &::-webkit-scrollbar-thumb { - // background-color: #C1CCD7; - // border-radius: 30px; - // } - // &::-webkit-scrollbar-track { - // background-color: #fff; - // } - .canvas-container{ - margin: 0 auto; - background-color: #fff; - } - canvas{ - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } -} + &.form06 { + .name { + font-size: 13px; + } + } + } -// sub-page -.sub-header{ - position: fixed; - top: 46px; - left: 0; - width: 100%; - min-width: 1280px; - height: 46px; - border-bottom: 1px solid #000; - background: #2C2C2C; - z-index: 999; - .sub-header-inner{ + .vertical-horizontal { display: flex; - align-items: center; - height: 100%; - padding: 0 100px; - .sub-header-title-wrap{ - display: flex; - align-items: center; - .title-item{ - position: relative; - padding: 0 24px; - a{ - display: flex; - align-items: center; - .icon{ - width: 22px; - height: 22px; - margin-right: 8px; - background-repeat: no-repeat; - background-position: center; - background-size: cover; - &.drawing{background-image: url(../../public/static/images/main/drawing_icon.svg);} - } - } - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 1px; - height: 16px; - background-color: #D9D9D9; - } - &:first-child{ - padding-left: 0; - } - &:last-child{ - padding-right: 0; - &:after{ - display: none; - } - } - } - } - .sub-header-title{ - font-size: 16px; - color: #fff; - font-weight: 600; - } - .sub-header-location{ - margin-left: auto; - display: flex; - align-items: center; - .location-item{ - position: relative; - display: flex; - align-items: center; - padding: 0 10px; - span{ - display: flex; - font-size: 12px; - color: #AAA; - font-weight: normal; - cursor: default; - } - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 4px; - height: 6px; - background: url(../../public/static/images/main/loaction_arr.svg)no-repeat center; - } - &:first-child{ - padding-left: 0; - } - &:last-child{ - padding-right: 0; - span{ - color: #fff; - } - &:after{ - display: none; - } - } - } - } - } -} + min-width: 170px; + height: 28px; + margin-right: 5px; + border-radius: 2px; + background: #373737; + line-height: 28px; + overflow: hidden; -// sub content -.sub-content{ - padding-top: 46px; - .sub-content-inner{ - max-width: 1760px; - margin: 0 auto; - padding: 20px 20px 0; - .sub-content-box{ - margin-bottom: 20px; - &:last-child{ - margin-bottom: 0; - } + span { + padding: 0 10px; + font-size: 13px; + color: #fff; } - } - &.estimate{ - display: flex; - flex-direction: column; - padding-top: 0; - .sub-content-inner{ - flex: 1; - width: 100%; + + button { + margin-left: auto; + height: 100%; + background-color: #4b4b4b; + font-size: 13px; + font-weight: 400; + color: #fff; + padding: 0 7.5px; + transition: all 0.17s ease-in-out; } - } -} -.sub-table-box{ - padding: 20px; - border-radius: 6px; - border: 1px solid #E9EAED; - background: #FFF; - box-shadow: 0px 3px 30px 0px rgba(0, 0, 0, 0.02); - .table-box-title-wrap{ - display: flex; - align-items: center; - margin-bottom: 15px; - .title-wrap{ - display: flex; - align-items: center; - h3{ - display: block; - font-size: 15px; - color: #101010; - font-weight: 600; - margin-right: 14px; - &.product{ - margin-right: 10px; - } - } - .estimate-check-btn{ - position: relative; - display: flex; - align-items: center; - padding-left: 10px; - &::before{ - content: ''; - position: absolute; - top: 50%; - left: 0; - transform: translateY(-50%); - width: 1px; - height: 11px; - background-color: #D9D9D9; - } - } - .product_tit{ - position: relative; - font-size: 15px; - font-weight: 600; - color: #1083E3; - padding-left: 10px; - &::before{ - content: ''; - position: absolute; - top: 50%; - left: 0; - transform: translateY(-50%); - width: 1px; - height: 11px; - background-color: #D9D9D9; - } - } - .option{ - padding-left: 5px; - font-size: 13px; - color: #101010; - font-weight: 400; - } - .info-wrap{ - display: flex; - align-items: center; - li{ - position: relative; - padding: 0 6px; - font-size: 12px; - color: #101010; - font-weight: normal; - span{ - font-weight: 600; - &.red{ - color: #E23D70; - } - } - &:after{ - content: ''; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 1px; - height: 11px; - background-color: #D9D9D9; - } - &:first-child{padding-left: 0;} - &:last-child{padding-right: 0;&::after{display: none;}} - } - } + + &.on { + button { + background-color: #1083e3; + } } - } - .left-unit-box{ - margin-left: auto; - display: flex; - align-items: center; - } - .promise-title-wrap{ - display: flex; - align-items: center; - margin-bottom: 15px; - .promise-gudie{ - margin-bottom: 0; - } - } - .promise-gudie{ - display: block; - font-size: 13px; - font-weight: 700; - color: #101010; - margin-bottom: 20px; - } - .important{ - color: #f00; - } - .sub-center-footer{ + } + + .size-control { display: flex; align-items: center; justify-content: center; - margin-top: 20px; - } - .sub-right-footer{ - display: flex; - align-items: center; - justify-content: flex-end; - margin-top: 20px; - } -} -.pagination-wrap{ - margin-top: 24px; -} - -.infomation-wrap{ - margin-bottom: 30px; -} - -.infomation-box-wrap{ - display: flex; - gap: 10px; - .sub-table-box{ - flex: 1 ; - } - .info-title{ - font-size: 14px; - font-weight: 500; - color: #344356; - margin-bottom: 10px; - } - .info-inner{ - position: relative; - font-size: 13px; - color: #344356; - .copy-ico{ - position: absolute; - bottom: 0; - right: 0; - width: 16px; - height: 16px; - background: url(../../public/static/images/sub/copy_ico.svg)no-repeat center; - background-size: cover; - } - } -} - -// 견적서 -.estimate-list-wrap{ - display: flex; - align-items: center; - margin-bottom: 10px; - &.one{ - .estimate-box{ - &:last-child{ - flex: 1; - min-width: unset; - } - } - } - .estimate-box{ - flex: 1 ; - display: flex; - align-items: center; - &:last-child{ - flex: none; - min-width: 220px; - } - .estimate-tit{ - width: 105px; - height: 30px; - line-height: 30px; - background-color: #F4F4F7; - border-radius: 100px; - text-align: center; - font-size: 13px; - font-weight: 500; - color: #344356; - } - .estimate-name{ - font-size: 13px; - color: #344356; - margin-left: 14px; - font-weight: 400; - &.blue{ - font-size: 16px; - font-weight: 700; - color: #1083E3; - } - &.red{ - font-size: 16px; - font-weight: 700; - color: #D72A2A; - } - } - } - &:last-child{ - margin-bottom: 0; - } -} - -// file drag box -.drag-file-guide{ - font-size: 13px; - font-weight: 400; - color: #45576F; - margin-left: 5px; -} -.btn-area{ - padding-bottom: 15px; - border-bottom: 1px solid #ECF0F4; - .file-upload{ - display: inline-block; - height: 30px; - background-color: #94A0AD; - padding: 0 10px; + gap: 10px; + background-color: #3d3d3d; border-radius: 2px; - font-size: 13px; - line-height: 30px; - color: #fff; - font-weight: 500; - cursor: pointer; - transition: background .15s ease-in-out; - &:hover{ - background-color: #607F9A; - } - } -} -.drag-file-box{ - padding: 10px; - .drag-file-area{ - position: relative; - margin-top: 15px; - p{ - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 13px; - color: #ccc; - font-weight: 400; - cursor: default; - } - } - .file-list{ - min-height: 52px; - .file-item{ - margin-bottom: 15px; - span{ - position: relative; - font-size: 13px; - color: #45576F; - font-weight: 400; - white-space: nowrap; - padding-right: 55px; - cursor: pointer; - button{ - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - width: 15px; - height: 15px; - background: url(../../public/static/images/sub/file_delete.svg)no-repeat center; - background-size: cover; - } - } - &:last-child{ - margin-bottom: 0; - } - .file-item-wrap{ - display: flex; - align-items: center; - gap: 30px; - .return-wrap{ - display: flex; - align-items: center; - } - .return{ - padding: 0; - font-size: 13px; - color: #B0BCCD; - text-decoration: line-through; - } - .return-btn{ - flex: none; - position: relative; - top: 0; - left: 0; - transform: none; - display: flex; - align-items: center; - height: 24px; - padding: 0 9px; - margin-left: 10px; - background: none; - border: 1px solid #B0BCCD; - border-radius: 2px; - font-size: 12px; - color: #B0BCCD; - font-weight: 500; - .return-ico{ - display: block; - width: 14px; - height: 14px; - background: url(../../public/static/images/canvas/return-btn.svg)no-repeat center; - background-size: contain; - margin-right: 5px; - } - } - } - } - } -} + width: 100px; + height: 30px; + margin: 0 5px; -.estimate-arr-btn{ - display: block; - width: 20px; - height: 20px; - background-color: #94A0AD; - border: 1px solid #94A0AD; - background-position: center; - background-repeat: no-repeat; - background-image: url(../../public/static/images/canvas/estiment_arr.svg); - background-size: 11px 7px; - border-radius: 2px; - &.up{ - rotate: 180deg; - } - &.on{ - background-color: #fff; - border-color: #C2D0DD; - background-image: url(../../public/static/images/canvas/estiment_arr_color.svg) - } -} -.estimate-check-wrap{ - .estimate-check-inner{ - display: block; - } - &.hide{ - border-bottom: 1px solid #ECF0F4; - margin-bottom: 15px; - .estimate-check-inner{ - display: none; + span { + font-size: 13px; + color: #fff; + cursor: pointer; } - } -} -.special-note-check-wrap{ - display: grid; - grid-template-columns: repeat(5, 1fr); - border-radius: 3px; - margin-bottom: 30px; - .special-note-check-item{ - padding: 14px 10px; - border: 1px solid #ECF0F4; - margin-top: -1px; - margin-right: -1px; - &.act{ - background-color: #F7F9FA; - } - .special-note-check-box{ - display: flex; - align-items: center; - .check-name{ - font-size: 13px; - color: #45576F; - cursor: pointer; - line-height: 1.3; - } - } - } -} + .control-btn { + display: block; + width: 12px; + height: 12px; + background-repeat: no-repeat; + background-size: cover; + background-position: center; -.calculation-estimate{ - border: 1px solid #ECF0F4; - border-radius: 3px; - padding: 24px; - height: 170px; - overflow-y: auto; - margin-bottom: 30px; - dl{ - margin-bottom: 35px; - &:last-child{ - margin-bottom: 0; - } - dt{ - font-size: 13px; - font-weight: 600; - color: #1083E3; - margin-bottom: 15px; - } - dd{ - font-size: 12px; - font-weight: 400; - color: #45576F; - margin-bottom: 8px; - &:last-child{ - margin-bottom: 0; - } - } - } - &::-webkit-scrollbar { - width: 4px; - background-color: transparent; - } - &::-webkit-scrollbar-thumb { - background-color: #d9dee2; - } - &::-webkit-scrollbar-track { - background-color: transparent; - } -} -.esimate-wrap{ - margin-bottom: 20px; -} + &.minus { + background-image: url(../../public/static/images/canvas/minus.svg); + } -.estimate-product-option{ - display: flex; - align-items: center; - margin-bottom: 15px; - .product-price-wrap{ - display: flex; - align-items: center; - .product-price-tit{ - font-size: 13px; - font-weight: 400; - color: #45576F; - margin-right: 10px; - } - .select-wrap{ - width: 150px; + &.plus { + background-image: url(../../public/static/images/canvas/plus.svg); + } } + } } - .product-edit-wrap{ - display: flex; - align-items: center; - margin-left: auto; - .product-edit-explane{ - display: flex; - align-items: center; - margin-right: 15px; - .explane-item{ - position: relative; - display: flex; - align-items: center; - padding: 0 10px; - font-size: 12px; - font-weight: 400; - span{ - width: 20px; - height: 20px; - margin-right: 5px; - background-size: cover; - background-repeat: no-repeat; - background-position: center; - } - &:before{ - content: ''; - position: absolute; - top: 50%; - left: 0; - transform: translateY(-50%); - width: 1px; - height: 12px; - background-color: #D9D9D9; - } - &:first-child{ - padding-left: 0; - &::before{ - display: none; - } - } - &:last-child{ - padding-right: 0; - } - &.item01{ - color: #3BBB48; - span{ - background-image: url(../../public/static/images/sub/open_ico.svg); - } - } - &.item02{ - color: #909000; - span{ - background-image: url(../../public/static/images/sub/change_ico.svg); - } - } - &.item03{ - color: #0191C9; - span{ - background-image: url(../../public/static/images/sub/attachment_ico.svg); - } - } - &.item04{ - color: #F16A6A; - span{ - background-image: url(../../public/static/images/sub/click_check_ico.svg); - } - } - } - } - .product-edit-btn{ - display: flex; - align-items: center; - button{ - display: flex; - align-items: center; - span{ - width: 13px; - height: 13px; - margin-right: 5px; - background-size: cover; - &.plus{ - background: url(../../public/static/images/sub/plus_btn.svg)no-repeat center; - } - &.minus{ - background: url(../../public/static/images/sub/minus_btn.svg)no-repeat center; - } - } - } - } - } -} + } -// 발전시물레이션 -.chart-wrap{ - display: flex; - gap: 20px; + .canvas-depth2-wrap { + position: absolute; + top: -100%; + left: 0; + background-color: #383838; width: 100%; - .sub-table-box{ - height: 100%; - } - .chart-inner{ - flex: 1; - .chart-box{ - margin-bottom: 30px; - } - } - .chart-table-wrap{ + height: 50px; + transition: all 0.17s ease-in-out; + + .canvas-depth2-inner { + display: flex; + align-items: center; + padding: 0 40px; + height: 100%; + + .canvas-depth2-list { display: flex; - flex-direction: column; - flex: none; - width: 650px; - .sub-table-box{ - flex: 1; - &:first-child{ - margin-bottom: 20px; - } - } - } -} + align-items: center; + height: 100%; -.chart-month-table{ - table{ - table-layout: fixed; - border-collapse:collapse; - border: 1px solid #ECF0F4; - border-radius: 4px; - thead{ - th{ - padding: 4.5px 0; - border-bottom: 1px solid #ECF0F4; - text-align: center; - font-size: 13px; - color: #45576F; - font-weight: 500; - background-color: #F8F9FA; - } - } - tbody{ - td{ - font-size: 13px; - color: #45576F; - text-align: center; - padding: 4.5px 0; - } - } - } -} + .canvas-depth2-item { + display: flex; + align-items: center; + margin-right: 26px; + height: 100%; -.simulation-guide-wrap{ - display: flex; - padding: 20px; - .simulation-tit-wrap{ - flex: none; - padding-right: 40px; - border-right: 1px solid #EEEEEE; - span{ - display: block; + button { position: relative; - padding-left: 60px; - font-size: 15px; - color: #14324F; - font-weight: 600; - &::before{ + opacity: 0.55; + color: #fff; + font-size: 12px; + font-weight: normal; + height: 100%; + padding-right: 12px; + } + + &.active { + button { + opacity: 1; + font-weight: 600; + + &:after { content: ''; position: absolute; top: 50%; - left: 0; + right: 0; transform: translateY(-50%); - width: 40px; - height: 40px; - background: url(../../public/static/images/sub/simulation_guide.svg)no-repeat center; - background-size: cover; + width: 5px; + height: 8px; + background: url(../../public/static/images/canvas/depth2-arr.svg) no-repeat center; + } } + } } - } - .simulation-guide-box{ - flex: 1; - padding-left: 40px; - dl{ - margin-bottom: 25px; - dt{ - font-size: 13px; - color: #101010; - font-weight: 600; - margin-bottom: 5px; - } - dd{ - font-size: 12px; - color: #45576F; - font-weight: 400; - line-height: 24px; - } - &:last-child{ - margin-bottom: 0; - } - } - ul, ol{ - list-style: unset; - } - } -} + } -.module-total{ - display: flex; - align-items: center; - background-color: #F8F9FA; - padding: 9px 0; - margin-right: 4px; - border: 1px solid #ECF0F4; - border-top: none; - .total-title{ - flex: 1; - text-align: center; - font-size: 13px; - color: #344356; - font-weight: 500; - } - .total-num{ - flex: none; - width: 121px; - text-align: center; - font-size: 15px; - color: #344356; - font-weight: 500; - } -} - -// 물건상세 -.information-help-wrap{ - display: flex; - padding: 24px; - background-color: #F4F4F4; - border-radius: 4px; - margin-bottom: 15px; - .information-help-tit-wrap{ - position: relative; + .canvas-depth2-btn-list { display: flex; align-items: center; - padding-right: 40px; - border-right: 1px solid #E0E0E3; - .help-tit-icon{ - width: 40px; - height: 40px; - border-radius: 50%; - margin-right: 10px; - background: #fff url(../../public/static/images/sub/information_help.svg)no-repeat center; - background-size: 20px 20px; - } - .help-tit{ - font-size: 13px; - font-weight: 600; - color: #45576F; - } - } - .information-help-guide{ - padding-left: 40px; - span{ - display: block; + margin-left: auto; + height: 100%; + + .depth2-btn-box { + display: flex; + align-items: center; + margin-right: 34px; + height: 100%; + transition: all 0.17s ease-in-out; + + button { + position: relative; font-size: 12px; font-weight: 400; - color: #45576F; - margin-bottom: 7px; - &:last-child{ - margin-bottom: 0; + height: 100%; + color: #fff; + padding-right: 12px; + + &:after { + content: ''; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 5px; + height: 8px; + background: url(../../public/static/images/canvas/depth2-arr.svg) no-repeat center; } + } + + &:last-child { + margin-right: 0; + } + + &.mouse { + opacity: 0.55; + } } + } } + + &.active { + top: 47px; + } + } + + &.active { + padding-bottom: 50px; + } } -.community-search-warp{ +// canvas-layout +.canvas-content { + padding-top: 46.8px; + transition: all 0.17s ease-in-out; + + .canvas-frame { + height: calc(100vh - 129.3px); + } + + &.active { + padding-top: calc(46.8px + 50px); + + .canvas-frame { + height: calc(100vh - 179.4px); + } + } +} + +.canvas-layout { + padding-top: 37px; + + .canvas-page-list { + position: fixed; + top: 92.8px; + left: 0; + display: flex; + background-color: #1c1c1c; + border-top: 1px solid #000; + width: 100%; + min-width: 1280px; + transition: all 0.17s ease-in-out; + z-index: 99; + + &.active { + top: calc(92.8px + 50px); + } + + .canvas-id { + display: flex; + align-items: center; + padding: 9.6px 20px; + font-size: 12px; + color: #fff; + background-color: #1083e3; + } + + .canvas-plane-wrap { + display: flex; + align-items: center; + max-width: calc(100% - 45px); + + .canvas-page-box { + display: flex; + align-items: center; + background-color: #1c1c1c; + padding: 9.6px 20px; + border-right: 1px solid #000; + min-width: 0; + transition: all 0.17s ease-in-out; + + span { + display: flex; + align-items: center; + width: 100%; + font-size: 12px; + font-family: 'Pretendard', sans-serif; + color: #aaa; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .close { + flex: none; + display: block; + width: 7px; + height: 8px; + margin-left: 15px; + background: url(../../public/static/images/canvas/plan_close_gray.svg) no-repeat center; + background-size: cover; + } + + &.on { + background-color: #fff; + + span { + font-weight: 600; + color: #101010; + } + + .close { + background: url(../../public/static/images/canvas/plan_close_black.svg) no-repeat center; + } + + &:hover { + background-color: #fff; + } + } + + &:hover { + background-color: #000; + } + } + } + + .plane-add { + display: flex; + align-items: center; + justify-content: center; + width: 45px; + padding: 13.5px 0; + background-color: #1c1c1c; + border-right: 1px solid #000; + transition: all 0.17s ease-in-out; + + span { + display: block; + width: 9px; + height: 9px; + background: url(../../public/static/images/canvas/plane_add.svg) no-repeat center; + background-size: cover; + } + + &:hover { + background-color: #000; + } + } + } +} + +.canvas-frame { + position: relative; + // height: calc(100% - 36.5px); + background-color: #f4f4f7; + overflow: auto; + transition: all 0.17s ease-in-out; + // &::-webkit-scrollbar { + // width: 10px; + // height: 10px; + // background-color: #fff; + // } + // &::-webkit-scrollbar-thumb { + // background-color: #C1CCD7; + // border-radius: 30px; + // } + // &::-webkit-scrollbar-track { + // background-color: #fff; + // } + .canvas-container { + margin: 0 auto; + background-color: #fff; + } + + canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +// sub-page +.sub-header { + position: fixed; + top: 46px; + left: 0; + width: 100%; + min-width: 1280px; + height: 46px; + border-bottom: 1px solid #000; + background: #2c2c2c; + z-index: 999; + + .sub-header-inner { display: flex; - flex-direction: column; align-items: center; - padding: 10px 0 30px 0; - border-bottom: 1px solid #E5E5E5; - margin-bottom: 24px; - .community-search-box{ + height: 100%; + padding: 0 100px; + + .sub-header-title-wrap { + display: flex; + align-items: center; + + .title-item { + position: relative; + padding: 0 24px; + + a { + display: flex; + align-items: center; + + .icon { + width: 22px; + height: 22px; + margin-right: 8px; + background-repeat: no-repeat; + background-position: center; + background-size: cover; + + &.drawing { + background-image: url(../../public/static/images/main/drawing_icon.svg); + } + } + } + + &:after { + content: ''; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 1px; + height: 16px; + background-color: #d9d9d9; + } + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + + &:after { + display: none; + } + } + } + } + + .sub-header-title { + font-size: 16px; + color: #fff; + font-weight: 600; + } + + .sub-header-location { + margin-left: auto; + display: flex; + align-items: center; + + .location-item { position: relative; display: flex; align-items: center; - width: 580px; - height: 45px; - padding: 0 45px 0 20px; - margin-bottom: 20px; - border-radius: 2px; - border: 1px solid #101010; - .community-input{ - width: 100%; - height: 100%; - font-size: 13px; - font-weight: 400; - color: #101010; - &::placeholder{ - color: #C8C8C8; - } + padding: 0 10px; + + span { + display: flex; + font-size: 12px; + color: #aaa; + font-weight: normal; + cursor: default; } - .community-search-ico{ - position: absolute; - top: 50%; - right: 20px; - transform: translateY(-50%); - flex: none; - width: 21px; - height: 100%; - background: url(../../public/static/images/sub/community_search.svg)no-repeat center; - background-size: 21px 21px; - z-index: 3; - } - } - .community-search-keyword{ - font-size: 13px; - font-weight: 400; - color: #45576F; - span{ - font-weight: 600; - color: #F16A6A; + + &:after { + content: ''; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 4px; + height: 6px; + background: url(../../public/static/images/main/loaction_arr.svg) no-repeat center; } + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + + span { + color: #fff; + } + + &:after { + display: none; + } + } + } } + } } -// 자료 다운로드 -.file-down-list{ - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 14px; - .file-down-item{ +// sub content +.sub-content { + padding-top: 46px; + + .sub-content-inner { + max-width: 1760px; + margin: 0 auto; + padding: 20px 20px 0; + + .sub-content-box { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + } + + &.estimate { + display: flex; + flex-direction: column; + padding-top: 0; + + .sub-content-inner { + flex: 1; + width: 100%; + } + } +} + +.sub-table-box { + padding: 20px; + border-radius: 6px; + border: 1px solid #e9eaed; + background: #fff; + box-shadow: 0px 3px 30px 0px rgba(0, 0, 0, 0.02); + + .table-box-title-wrap { + display: flex; + align-items: center; + margin-bottom: 15px; + + .title-wrap { + display: flex; + align-items: center; + + h3 { + display: block; + font-size: 15px; + color: #101010; + font-weight: 600; + margin-right: 14px; + + &.product { + margin-right: 10px; + } + } + + .estimate-check-btn { + position: relative; display: flex; align-items: center; - padding: 24px; - border-radius: 4px; - border: 1px solid #E5E5E5; - background: #FFF; - transition: all .15s ease-in-out; - .file-item-info{ - .item-num{ - display: inline-block; - padding: 6px 17.5px; - border-radius: 60px; - background-color: #F4F4F7; - font-size: 13px; - font-weight: 600; - color: #101010; - margin-bottom: 15px; - } - .item-name{ - font-size: 16px; - color: #101010; - font-weight: 500; - margin-bottom: 13px; - } - .item-date{ - font-size: 13px; - font-weight: 400; - color: #344356; - } - } - .file-down-box{ - display: flex; - align-items: center; - flex: none; - margin-left: auto; - height: 100%; - .file-down-btn{ - width: 36px; - height: 36px; - background: url(../../public/static/images/sub/file_down_btn.svg)no-repeat center; - background-size: cover; - } - } - &:hover{ - background-color: #F4F4F7; - } - } -} + padding-left: 10px; -.file-down-nodata{ + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 1px; + height: 11px; + background-color: #d9d9d9; + } + } + + .product_tit { + position: relative; + font-size: 15px; + font-weight: 600; + color: #1083e3; + padding-left: 10px; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 1px; + height: 11px; + background-color: #d9d9d9; + } + } + + .option { + padding-left: 5px; + font-size: 13px; + color: #101010; + font-weight: 400; + } + + .info-wrap { + display: flex; + align-items: center; + + li { + position: relative; + padding: 0 6px; + font-size: 12px; + color: #101010; + font-weight: normal; + + span { + font-weight: 600; + + &.red { + color: #e23d70; + } + } + + &:after { + content: ''; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 1px; + height: 11px; + background-color: #d9d9d9; + } + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + + &::after { + display: none; + } + } + } + } + } + } + + .left-unit-box { + margin-left: auto; + display: flex; + align-items: center; + } + + .promise-title-wrap { + display: flex; + align-items: center; + margin-bottom: 15px; + + .promise-gudie { + margin-bottom: 0; + } + } + + .promise-gudie { + display: block; + font-size: 13px; + font-weight: 700; + color: #101010; + margin-bottom: 20px; + } + + .important { + color: #f00; + } + + .sub-center-footer { display: flex; align-items: center; justify-content: center; - width: 100%; - height: 148px; - padding: 24px; - border-radius: 4px; - border: 1px solid #E5E5E5; - font-size: 16px; + margin-top: 20px; + } + + .sub-right-footer { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 20px; + } +} + +.pagination-wrap { + margin-top: 24px; +} + +.infomation-wrap { + margin-bottom: 30px; +} + +.infomation-box-wrap { + display: flex; + gap: 10px; + + .sub-table-box { + flex: 1; + } + + .info-title { + font-size: 14px; font-weight: 500; color: #344356; + margin-bottom: 10px; + } + + .info-inner { + position: relative; + font-size: 13px; + color: #344356; + + .copy-ico { + position: absolute; + bottom: 0; + right: 0; + width: 16px; + height: 16px; + background: url(../../public/static/images/sub/copy_ico.svg) no-repeat center; + background-size: cover; + } + } +} + +// 견적서 +.estimate-list-wrap { + display: flex; + align-items: center; + margin-bottom: 10px; + + &.one { + .estimate-box { + &:last-child { + flex: 1; + min-width: unset; + } + } + } + + .estimate-box { + flex: 1; + display: flex; + align-items: center; + + &:last-child { + flex: none; + min-width: 220px; + } + + .estimate-tit { + width: 105px; + height: 30px; + line-height: 30px; + background-color: #f4f4f7; + border-radius: 100px; + text-align: center; + font-size: 13px; + font-weight: 500; + color: #344356; + } + + .estimate-name { + font-size: 13px; + color: #344356; + margin-left: 14px; + font-weight: 400; + + &.blue { + font-size: 16px; + font-weight: 700; + color: #1083e3; + } + + &.red { + font-size: 16px; + font-weight: 700; + color: #d72a2a; + } + } + } + + &:last-child { + margin-bottom: 0; + } +} + +// file drag box +.drag-file-guide { + font-size: 13px; + font-weight: 400; + color: #45576f; + margin-left: 5px; +} + +.btn-area { + padding-bottom: 15px; + border-bottom: 1px solid #ecf0f4; + + .file-upload { + display: inline-block; + height: 30px; + background-color: #94a0ad; + padding: 0 10px; + border-radius: 2px; + font-size: 13px; + line-height: 30px; + color: #fff; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease-in-out; + + &:hover { + background-color: #607f9a; + } + } +} + +.drag-file-box { + padding: 10px; + + .drag-file-area { + position: relative; + margin-top: 15px; + + p { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 13px; + color: #ccc; + font-weight: 400; + cursor: default; + } + } + + .file-list { + min-height: 52px; + + .file-item { + margin-bottom: 15px; + + span { + position: relative; + font-size: 13px; + color: #45576f; + font-weight: 400; + white-space: nowrap; + padding-right: 55px; + cursor: pointer; + + button { + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 15px; + height: 15px; + background: url(../../public/static/images/sub/file_delete.svg) no-repeat center; + background-size: cover; + } + } + + &:last-child { + margin-bottom: 0; + } + + .file-item-wrap { + display: flex; + align-items: center; + gap: 30px; + + .return-wrap { + display: flex; + align-items: center; + } + + .return { + padding: 0; + font-size: 13px; + color: #b0bccd; + text-decoration: line-through; + } + + .return-btn { + flex: none; + position: relative; + top: 0; + left: 0; + transform: none; + display: flex; + align-items: center; + height: 24px; + padding: 0 9px; + margin-left: 10px; + background: none; + border: 1px solid #b0bccd; + border-radius: 2px; + font-size: 12px; + color: #b0bccd; + font-weight: 500; + + .return-ico { + display: block; + width: 14px; + height: 14px; + background: url(../../public/static/images/canvas/return-btn.svg) no-repeat center; + background-size: contain; + margin-right: 5px; + } + } + } + } + } +} + +.estimate-arr-btn { + display: block; + width: 20px; + height: 20px; + background-color: #94a0ad; + border: 1px solid #94a0ad; + background-position: center; + background-repeat: no-repeat; + background-image: url(../../public/static/images/canvas/estiment_arr.svg); + background-size: 11px 7px; + border-radius: 2px; + + &.up { + rotate: 180deg; + } + + &.on { + background-color: #fff; + border-color: #c2d0dd; + background-image: url(../../public/static/images/canvas/estiment_arr_color.svg); + } +} + +.estimate-check-wrap { + .estimate-check-inner { + display: block; + } + + &.hide { + border-bottom: 1px solid #ecf0f4; + margin-bottom: 15px; + + .estimate-check-inner { + display: none; + } + } +} + +.special-note-check-wrap { + display: grid; + grid-template-columns: repeat(5, 1fr); + border-radius: 3px; + margin-bottom: 30px; + + .special-note-check-item { + padding: 14px 10px; + border: 1px solid #ecf0f4; + margin-top: -1px; + margin-right: -1px; + + &.act { + background-color: #f7f9fa; + } + + .special-note-check-box { + display: flex; + align-items: center; + + .check-name { + font-size: 13px; + color: #45576f; + cursor: pointer; + line-height: 1.3; + } + } + } +} + +.calculation-estimate { + border: 1px solid #ecf0f4; + border-radius: 3px; + padding: 24px; + height: 170px; + overflow-y: auto; + margin-bottom: 30px; + + dl { + margin-bottom: 35px; + + &:last-child { + margin-bottom: 0; + } + + dt { + font-size: 13px; + font-weight: 600; + color: #1083e3; + margin-bottom: 15px; + } + + dd { + font-size: 12px; + font-weight: 400; + color: #45576f; + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } + } + } + + &::-webkit-scrollbar { + width: 4px; + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: #d9dee2; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } +} + +.esimate-wrap { + margin-bottom: 20px; +} + +.estimate-product-option { + display: flex; + align-items: center; + margin-bottom: 15px; + + .product-price-wrap { + display: flex; + align-items: center; + + .product-price-tit { + font-size: 13px; + font-weight: 400; + color: #45576f; + margin-right: 10px; + } + + .select-wrap { + width: 150px; + } + } + + .product-edit-wrap { + display: flex; + align-items: center; + margin-left: auto; + + .product-edit-explane { + display: flex; + align-items: center; + margin-right: 15px; + + .explane-item { + position: relative; + display: flex; + align-items: center; + padding: 0 10px; + font-size: 12px; + font-weight: 400; + + span { + width: 20px; + height: 20px; + margin-right: 5px; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + } + + &:before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 1px; + height: 12px; + background-color: #d9d9d9; + } + + &:first-child { + padding-left: 0; + + &::before { + display: none; + } + } + + &:last-child { + padding-right: 0; + } + + &.item01 { + color: #3bbb48; + + span { + background-image: url(../../public/static/images/sub/open_ico.svg); + } + } + + &.item02 { + color: #909000; + + span { + background-image: url(../../public/static/images/sub/change_ico.svg); + } + } + + &.item03 { + color: #0191c9; + + span { + background-image: url(../../public/static/images/sub/attachment_ico.svg); + } + } + + &.item04 { + color: #f16a6a; + + span { + background-image: url(../../public/static/images/sub/click_check_ico.svg); + } + } + } + } + + .product-edit-btn { + display: flex; + align-items: center; + + button { + display: flex; + align-items: center; + + span { + width: 13px; + height: 13px; + margin-right: 5px; + background-size: cover; + + &.plus { + background: url(../../public/static/images/sub/plus_btn.svg) no-repeat center; + } + + &.minus { + background: url(../../public/static/images/sub/minus_btn.svg) no-repeat center; + } + } + } + } + } +} + +// 발전시물레이션 +.chart-wrap { + display: flex; + gap: 20px; + width: 100%; + + .sub-table-box { + height: 100%; + } + + .chart-inner { + flex: 1; + + .chart-box { + margin-bottom: 30px; + } + } + + .chart-table-wrap { + display: flex; + flex-direction: column; + flex: none; + width: 650px; + + .sub-table-box { + flex: 1; + + &:first-child { + margin-bottom: 20px; + } + } + } +} + +.chart-month-table { + table { + table-layout: fixed; + border-collapse: collapse; + border: 1px solid #ecf0f4; + border-radius: 4px; + + thead { + th { + padding: 4.5px 0; + border-bottom: 1px solid #ecf0f4; + text-align: center; + font-size: 13px; + color: #45576f; + font-weight: 500; + background-color: #f8f9fa; + } + } + + tbody { + td { + font-size: 13px; + color: #45576f; + text-align: center; + padding: 4.5px 0; + } + } + } +} + +.simulation-guide-wrap { + display: flex; + padding: 20px; + + .simulation-tit-wrap { + flex: none; + padding-right: 40px; + border-right: 1px solid #eeeeee; + + span { + display: block; + position: relative; + padding-left: 60px; + font-size: 15px; + color: #14324f; + font-weight: 600; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 40px; + height: 40px; + background: url(../../public/static/images/sub/simulation_guide.svg) no-repeat center; + background-size: cover; + } + } + } + + .simulation-guide-box { + flex: 1; + padding-left: 40px; + + dl { + margin-bottom: 25px; + + dt { + font-size: 13px; + color: #101010; + font-weight: 600; + margin-bottom: 5px; + } + + dd { + font-size: 12px; + color: #45576f; + font-weight: 400; + line-height: 24px; + } + + &:last-child { + margin-bottom: 0; + } + } + + ul, + ol { + list-style: unset; + } + } +} + +.module-total { + display: flex; + align-items: center; + background-color: #f8f9fa; + padding: 9px 0; + margin-right: 4px; + border: 1px solid #ecf0f4; + border-top: none; + + .total-title { + flex: 1; + text-align: center; + font-size: 13px; + color: #344356; + font-weight: 500; + } + + .total-num { + flex: none; + width: 121px; + text-align: center; + font-size: 15px; + color: #344356; + font-weight: 500; + } +} + +// 물건상세 +.information-help-wrap { + display: flex; + padding: 24px; + background-color: #f4f4f4; + border-radius: 4px; + margin-bottom: 15px; + + .information-help-tit-wrap { + position: relative; + display: flex; + align-items: center; + padding-right: 40px; + border-right: 1px solid #e0e0e3; + + .help-tit-icon { + width: 40px; + height: 40px; + border-radius: 50%; + margin-right: 10px; + background: #fff url(../../public/static/images/sub/information_help.svg) no-repeat center; + background-size: 20px 20px; + } + + .help-tit { + font-size: 13px; + font-weight: 600; + color: #45576f; + } + } + + .information-help-guide { + padding-left: 40px; + + span { + display: block; + font-size: 12px; + font-weight: 400; + color: #45576f; + margin-bottom: 7px; + + &:last-child { + margin-bottom: 0; + } + } + } +} + +.community-search-warp { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 0 30px 0; + border-bottom: 1px solid #e5e5e5; + margin-bottom: 24px; + + .community-search-box { + position: relative; + display: flex; + align-items: center; + width: 580px; + height: 45px; + padding: 0 45px 0 20px; + margin-bottom: 20px; + border-radius: 2px; + border: 1px solid #101010; + + .community-input { + width: 100%; + height: 100%; + font-size: 13px; + font-weight: 400; + color: #101010; + + &::placeholder { + color: #c8c8c8; + } + } + + .community-search-ico { + position: absolute; + top: 50%; + right: 20px; + transform: translateY(-50%); + flex: none; + width: 21px; + height: 100%; + background: url(../../public/static/images/sub/community_search.svg) no-repeat center; + background-size: 21px 21px; + z-index: 3; + } + } + + .community-search-keyword { + font-size: 13px; + font-weight: 400; + color: #45576f; + + span { + font-weight: 600; + color: #f16a6a; + } + } +} + +// 자료 다운로드 +.file-down-list { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + + .file-down-item { + display: flex; + align-items: center; + padding: 24px; + border-radius: 4px; + border: 1px solid #e5e5e5; + background: #fff; + transition: all 0.15s ease-in-out; + + .file-item-info { + .item-num { + display: inline-block; + padding: 6px 17.5px; + border-radius: 60px; + background-color: #f4f4f7; + font-size: 13px; + font-weight: 600; + color: #101010; + margin-bottom: 15px; + } + + .item-name { + font-size: 16px; + color: #101010; + font-weight: 500; + margin-bottom: 13px; + } + + .item-date { + font-size: 13px; + font-weight: 400; + color: #344356; + } + } + + .file-down-box { + display: flex; + align-items: center; + flex: none; + margin-left: auto; + height: 100%; + + .file-down-btn { + width: 36px; + height: 36px; + background: url(../../public/static/images/sub/file_down_btn.svg) no-repeat center; + background-size: cover; + } + } + + &:hover { + background-color: #f4f4f7; + } + } +} + +.file-down-nodata { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 148px; + padding: 24px; + border-radius: 4px; + border: 1px solid #e5e5e5; + font-size: 16px; + font-weight: 500; + color: #344356; } //신규물건 등록 -.product-input-wrap{ - display: flex; - align-items: center; - width: 200px; - height: 30px; - background-color: #FAFAFA; - border: 1px solid #EEE; - padding: 0 10px; - input{ - font-size: 13px; - font-weight: 400; - color: #999999; - padding: 0; - width: 100%; - height: 100%; - flex: 1 ; - background-color: inherit; - } - .product-delete{ - flex: none; - display: block; - width: 15px; - height: 100%; - background: url(../../public/static/images/sub/product-del.svg)no-repeat center; - background-size: 15px 15px; - } +.product-input-wrap { + display: flex; + align-items: center; + width: 200px; + height: 30px; + background-color: #fafafa; + border: 1px solid #eee; + padding: 0 10px; + + input { + font-size: 13px; + font-weight: 400; + color: #999999; + padding: 0; + width: 100%; + height: 100%; + flex: 1; + background-color: inherit; + } + + .product-delete { + flex: none; + display: block; + width: 15px; + height: 100%; + background: url(../../public/static/images/sub/product-del.svg) no-repeat center; + background-size: 15px 15px; + } } @media screen and (max-width: 1800px) { - .canvas-menu-wrap{ - .canvas-menu-inner{ - .canvas-menu-list{ - .canvas-menu-item button{ - .menu-icon{ - margin-right: 5px; - } - } - .canvas-menu-item{ - button{ - padding: 15px 15px; - font-size: 11px; - } - } - } + .canvas-menu-wrap { + .canvas-menu-inner { + .canvas-menu-list { + .canvas-menu-item button { + .menu-icon { + margin-right: 5px; + } } - .canvas-depth2-wrap{ - .canvas-depth2-inner{ - .canvas-depth2-list{ - .canvas-depth2-item{ - button{ - font-size: 11px; - } - } - } - } + + .canvas-menu-item { + button { + padding: 15px 15px; + font-size: 11px; + } } - } + } + } + + .canvas-depth2-wrap { + .canvas-depth2-inner { + .canvas-depth2-list { + .canvas-depth2-item { + button { + font-size: 11px; + } + } + } + } + } + } } @media screen and (max-width: 1600px) { - .canvas-menu-wrap{ - .canvas-menu-inner{ - .canvas-menu-list{ - .canvas-menu-item button{ - .menu-icon{ - display: none; - } - } - } + .canvas-menu-wrap { + .canvas-menu-inner { + .canvas-menu-list { + .canvas-menu-item button { + .menu-icon { + display: none; + } } - } - .canvas-content{ - .canvas-frame{ - height: calc(100vh - 129.5px); - } - &.active{ - .canvas-frame{ - height: calc(100vh - 179.5px); - } - } - } + } + } + } + .canvas-content { + .canvas-frame { + height: calc(100vh - 129.5px); + } + + &.active { + .canvas-frame { + height: calc(100vh - 179.5px); + } + } + } } @media screen and (max-width: 1500px) { - .canvas-menu-wrap{ - .canvas-menu-inner{ - .canvas-menu-list{ - .canvas-menu-item{ - button{ - padding: 15px 10px; - font-size: 10px; - } - } - } - .canvas-side-btn-wrap{ - .btn-from{ - gap: 3px; - } - .undo-redo-wrap{ - margin-right: 3px; - } - .vertical-horizontal{ - margin-right: 3px; - min-width: 150px; - } - .select-box{ - width: 100px; - margin: 0 3px; - } - .size-control{ - width: 90px; - margin: 0 3px; - } - } + .canvas-menu-wrap { + .canvas-menu-inner { + .canvas-menu-list { + .canvas-menu-item { + button { + padding: 15px 10px; + font-size: 10px; + } } + } + + .canvas-side-btn-wrap { + .btn-from { + gap: 3px; + } + + .vertical-horizontal { + margin-right: 3px; + min-width: 150px; + } + + .select-box { + width: 100px; + margin: 0 3px; + } + + .size-control { + width: 90px; + margin: 0 3px; + } + } } - .sub-header{ - .sub-header-inner{ - .sub-header-title{ - font-size: 15px; + } + .sub-header { + .sub-header-inner { + .sub-header-title { + font-size: 15px; + } + + .sub-header-title-wrap { + .title-item { + a { + .icon { + width: 20px; + height: 20px; } - .sub-header-title-wrap{ - .title-item{ - a{ - .icon{ - width: 20px; - height: 20px; - } - } - } - } - } - } - + } + } + } + } + } } diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js new file mode 100644 index 00000000..480e11be --- /dev/null +++ b/src/util/kerab-offset-surgical.js @@ -0,0 +1,157 @@ +// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 forward/revert 시 出幅(offset) 변경분만 +// target 의 matchingRoofLine + 인접 2 corner roof.points + corner 에 닿은 inner line 끝점 +// + lengthText 라벨을 surgical 갱신. SK 재실행 없이 다른 벽의 케라바 패턴 보존. +// +// 고객 회귀 검증을 위해 본 로직 전체를 useEavesGableEdit.js 에서 분리. +// 호출 ON/OFF 는 useEavesGableEdit.js 상단 `ENABLE_KERAB_OFFSET_SURGICAL` 상수로 토글한다. + +import { POLYGON_TYPE } from '@/common/common' +import { calcLinePlaneSize } from '@/util/qpolygon-utils' +import { logger } from '@/util/logger' + +const lineLineIntersection = (p1, p2, p3, p4) => { + const d = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x) + if (Math.abs(d) < 1e-6) return null + const t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / d + return { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) } +} + +/** + * @param canvas fabric canvas + * @param target outerLine fabric 객체 (target.attributes 는 OLD 상태로 호출되어야 함) + * @param newOffset 새 출폭 (canvas 단위) + * @returns true=적용됨 / false=조건 미달 또는 변경 없음 + */ +export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { + const roof = canvas + .getObjects() + .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) + if (!roof || !Array.isArray(roof.lines) || !Array.isArray(roof.points)) return false + const idx = roof.lines.findIndex((rl) => rl && rl.attributes?.wallLine === target.id) + if (idx < 0) return false + const matchingRL = roof.lines[idx] + const oldOffset = matchingRL.attributes?.offset ?? 0 + if (Math.abs(newOffset - oldOffset) < 1e-3) return false + + const N = roof.lines.length + const prevRL = roof.lines[(idx - 1 + N) % N] + const nextRL = roof.lines[(idx + 1) % N] + + // target wall edge 의 outward unit normal — matchingRL 현재 위치로 sign 추정 (oldOffset=0 시 centroid 폴백). + const tdx = target.x2 - target.x1 + const tdy = target.y2 - target.y1 + const tL = Math.hypot(tdx, tdy) || 1 + let nx = -tdy / tL + let ny = tdx / tL + const tMid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 } + if (Math.abs(oldOffset) > 1e-6) { + const mMid = { x: (matchingRL.x1 + matchingRL.x2) / 2, y: (matchingRL.y1 + matchingRL.y2) / 2 } + if ((mMid.x - tMid.x) * nx + (mMid.y - tMid.y) * ny < 0) { + nx = -nx + ny = -ny + } + } else { + let cx = 0 + let cy = 0 + for (const p of roof.points) { + cx += p.x + cy += p.y + } + cx /= roof.points.length + cy /= roof.points.length + if ((tMid.x - cx) * nx + (tMid.y - cy) * ny < 0) { + nx = -nx + ny = -ny + } + } + + // 새 matchingRL axis 와 prev/nextRL 무한확장 교점. + const newAxisP1 = { x: target.x1 + nx * newOffset, y: target.y1 + ny * newOffset } + const newAxisP2 = { x: target.x2 + nx * newOffset, y: target.y2 + ny * newOffset } + const newCorner1 = lineLineIntersection( + { x: prevRL.x1, y: prevRL.y1 }, + { x: prevRL.x2, y: prevRL.y2 }, + newAxisP1, + newAxisP2, + ) + const newCorner2 = lineLineIntersection( + newAxisP1, + newAxisP2, + { x: nextRL.x1, y: nextRL.y1 }, + { x: nextRL.x2, y: nextRL.y2 }, + ) + if (!newCorner1 || !newCorner2) { + logger.log('[KERAB-OFFSET-SURGICAL] intersect failed — skip surgical update') + return false + } + + const oldCorner1 = { x: roof.points[idx].x, y: roof.points[idx].y } + const oldCorner2 = { x: roof.points[(idx + 1) % N].x, y: roof.points[(idx + 1) % N].y } + + matchingRL.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y }) + prevRL.set({ x2: newCorner1.x, y2: newCorner1.y }) + nextRL.set({ x1: newCorner2.x, y1: newCorner2.y }) + + const newSize = calcLinePlaneSize({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y }) + matchingRL.attributes = { + ...matchingRL.attributes, + offset: newOffset, + planeSize: newSize, + actualSize: newSize, + } + const prevSize = calcLinePlaneSize({ x1: prevRL.x1, y1: prevRL.y1, x2: prevRL.x2, y2: prevRL.y2 }) + prevRL.attributes = { ...prevRL.attributes, planeSize: prevSize, actualSize: prevSize } + 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 }) + } + if (Math.hypot(il.x2 - oldCorner1.x, il.y2 - oldCorner1.y) <= CORNER_SNAP_TOL) { + il.set({ x2: newCorner1.x, y2: newCorner1.y }) + } + 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 }) + } + } + + // points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작. + 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 } + }) + roof.points = newPoints + roof.set({ points: newPoints, dirty: true }) + if (typeof roof.setCoords === 'function') roof.setCoords() + + // canvas 에 add 된 동일 wallLine 매칭의 eaves(외곽 roofLine) fabric 객체도 같이 갱신. + const canvasEdgeObjs = canvas + .getObjects() + .filter( + (o) => + o.parentId === roof.id && + (o.name === 'eaves' || o.lineName === 'roofLine' || o.name === 'outerLine') && + 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 }) + eo.attributes = { ...eo.attributes, offset: newOffset, planeSize: newSize, actualSize: newSize } + if (typeof eo.setCoords === 'function') eo.setCoords() + } + if (typeof roof.addLengthText === 'function') roof.addLengthText() + + canvas.renderAll() + logger.log( + '[KERAB-OFFSET-SURGICAL] applied ' + + JSON.stringify({ idx, oldOffset, newOffset, oldCorner1, newCorner1, oldCorner2, newCorner2 }), + ) + return true +} diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index fd63b2af..eb0a0306 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -5155,7 +5155,7 @@ function getTurnDirection(p1, p2, p3) { /** * 현재 점(point)을 기준으로 연결된 이전 라인과 다음 라인을 찾아 골짜기 여부 판단 */ -function isValleyVertex(targetPoint, connectedLine, allLines, isStartVertex) { +export function isValleyVertex(targetPoint, connectedLine, allLines, isStartVertex) { const tolerance = 0.1; const connectedLineData = { @@ -5250,7 +5250,7 @@ function isValleyVertex(targetPoint, connectedLine, allLines, isStartVertex) { return crossProduct > 0; } -function findInteriorPoint(line, polygonLines) { +export function findInteriorPoint(line, polygonLines) { const x1 = line.x1 ?? line.get?.('x1'); const y1 = line.y1 ?? line.get?.('y1'); const x2 = line.x2 ?? line.get?.('x2'); diff --git a/startscript-btob.js b/startscript-btob.js new file mode 100644 index 00000000..61ff89c1 --- /dev/null +++ b/startscript-btob.js @@ -0,0 +1,2 @@ +var exec = require('child_process').exec +exec('yarn start:btob', { windowsHide: true })