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 1f96d08c..e990626b 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "start:cluster1": "env-cmd -f .env.production next start -p 5000", "start:cluster2": "env-cmd -f .env.production next start -p 5001", "start:dev": "env-cmd -f .env.development next start -p 5010", + "start:btob": "env-cmd -f .env.development next start -p 5060", "lint": "next lint", "serve": "node server.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.772.0", + "@google/generative-ai": "^0.24.1", "ag-grid-react": "^32.0.2", "axios": "^1.7.8", "big.js": "^6.2.2", 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/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 44dfea52..89c4448d 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -48,10 +48,21 @@ function __classifyLineForLabel(obj) { return null } +export function reattachDebugLabels(canvas, parentId) { + __attachDebugLabels(canvas, parentId) +} + function __attachDebugLabels(canvas, parentId) { if (!__isDebugLabelsEnabled()) return if (!canvas || !parentId) return + // [KERAB-LABEL-REATTACH 2026-05-29] 케라바 토글 등으로 라인이 추가/변경된 후 재호출 가능하도록 + // 같은 parentId 의 기존 __debugLabel 먼저 제거 → 카운트 reset 후 새로 부여. + canvas + .getObjects() + .filter((o) => o.name === DEBUG_LABEL_NAME && o.parentId === parentId) + .forEach((o) => canvas.remove(o)) + const counters = {} const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME) diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index ee75ea20..8c442b22 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -23,12 +23,27 @@ import { useRoofFn } from '@/hooks/common/useRoofFn' import { usePlan } from '@/hooks/usePlan' import { normalizeDecimal } from '@/util/input-utils' import { CalculatorInput } from '@/components/common/input/CalcInput' +import { usePdfImport } from '@/hooks/pdf-import/usePdfImport' +import { logger } from '@/util/logger' // [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert import { useSwal } from '@/hooks/useSwal' import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning' // [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 import + 호출 함께 제거 import { debugCapture } from '@/util/debugCapture' +const INPUT_MODE = { + SIZE_ROOF: '1', + SIZE_ACTUAL: '2', + PDF: 'pdf', +} + +const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024 + +const PDF_PAGE_MODE = { + ALL: 'all', + SPECIFY: 'specify', +} + /** * 지붕 레이아웃 */ @@ -76,15 +91,112 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla const { saveCanvas } = usePlan() // [LOW-PITCH-WARN 2026-05-06] const { swalFire } = useSwal() + const { applyOuterline } = usePdfImport() + + // PDF 업로드 관련 상태 + const [inputMode, setInputMode] = useState(INPUT_MODE.SIZE_ROOF) + const [pdfFile, setPdfFile] = useState(null) + const [pdfPageMode, setPdfPageMode] = useState(PDF_PAGE_MODE.ALL) + const [pdfFacadePages, setPdfFacadePages] = useState('') + const [pdfFloorPages, setPdfFloorPages] = useState('') + const [pdfAnalyzing, setPdfAnalyzing] = useState(false) + const pdfInputRef = useRef(null) /** - * 치수 입력방법(복시도입력/실측값입력/육지붕) + * 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드) */ const roofSizeSetArray = [ - { id: 'ra01', name: 'roofSizeSet', value: '1', message: 'modal.placement.initial.setting.size.roof' }, - { id: 'ra02', name: 'roofSizeSet', value: '2', message: 'modal.placement.initial.setting.size.actual' }, - // { id: 'ra03', name: 'roofSizeSet', value: '3', message: 'modal.placement.initial.setting.size.none.pitch' }, + { id: 'ra01', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ROOF, message: 'modal.placement.initial.setting.size.roof' }, + { id: 'ra02', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ACTUAL, message: 'modal.placement.initial.setting.size.actual' }, + // { id: 'ra-pdf', name: 'roofSizeSet', value: INPUT_MODE.PDF, message: 'modal.placement.initial.setting.size.pdf' }, ] + const handleInputModeChange = (value) => { + setInputMode(value) + if (value === INPUT_MODE.PDF) { + // PDF 모드는 내부적으로 복시도(1) 로 저장된다. + setCurrentRoof((prev) => ({ ...prev, roofSizeSet: INPUT_MODE.SIZE_ROOF })) + } else { + setCurrentRoof((prev) => ({ ...prev, roofSizeSet: value })) + } + } + + const resetPdfFile = () => { + setPdfFile(null) + if (pdfInputRef.current) pdfInputRef.current.value = '' + } + + const handlePdfFileChange = (e) => { + const next = e.target.files?.[0] + if (!next) return + if (next.type !== 'application/pdf') { + swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.mime'), type: 'alert' }) + e.target.value = '' + return + } + if (next.size > PDF_MAX_FILE_BYTES) { + swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.too.large'), type: 'alert' }) + e.target.value = '' + return + } + setPdfFile(next) + } + + const analyzePdfAndApply = async () => { + if (!pdfFile) { + swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.no.file'), type: 'alert' }) + return false + } + + const formData = new FormData() + formData.append('file', pdfFile) + formData.append('unitHint', 'mm') + formData.append('pageMode', pdfPageMode) + if (pdfPageMode === PDF_PAGE_MODE.SPECIFY) { + formData.append('facadePages', pdfFacadePages.trim()) + formData.append('floorPages', pdfFloorPages.trim()) + } + + setPdfAnalyzing(true) + let response + try { + response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData }) + } catch (e) { + logger.error('[PlacementShapeSetting] PDF analyze fetch failed', e?.message || e) + setPdfAnalyzing(false) + swalFire({ text: getMessage('pdf.import.error.network'), type: 'alert' }) + return false + } + + let payload + try { + payload = await response.json() + } catch (e) { + logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e) + setPdfAnalyzing(false) + swalFire({ text: getMessage('pdf.import.error.analyze'), type: 'alert' }) + return false + } + + setPdfAnalyzing(false) + + if (!response.ok) { + const msg = payload?.error?.message || getMessage('pdf.import.error.analyze') + swalFire({ text: msg, type: 'alert' }) + return false + } + if (payload.empty || !Array.isArray(payload.outerline) || payload.outerline.length === 0) { + swalFire({ text: payload.notes || getMessage('pdf.import.error.no.floorplan'), type: 'alert' }) + return false + } + + const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' }) + if (!applied) { + swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' }) + return false + } + return true + } + /** * 지붕각도 설정(경사/각도) */ @@ -138,7 +250,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla */ useEffect(() => { if (currentCanvasPlan?.planNo && currentRoof) { - setCurrentRoof(prev => ({ ...prev, planNo: currentCanvasPlan.planNo })) + setCurrentRoof((prev) => ({ ...prev, planNo: currentCanvasPlan.planNo })) } }, [currentCanvasPlan?.planNo]) @@ -238,6 +350,13 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla * 배치면초기설정 저장 버튼 클릭 */ const handleSaveBtn = async () => { + if (pdfAnalyzing) return + + if (inputMode === INPUT_MODE.PDF) { + const ok = await analyzePdfAndApply() + if (!ok) return + } + const roofInfo = { ...currentRoof, planNo: currentCanvasPlan?.planNo || basicSetting.planNo, @@ -273,16 +392,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla /** * 배치면초기설정 저장 (메뉴 변경/useEffect 트리거 없이) */ - basicSettingSave({ - ...basicSetting, - planNo: currentCanvasPlan?.planNo || basicSetting.planNo, - /** - * 선택된 지붕재 정보 - */ - selectedRoofMaterial: { - ...newAddedRoofs[0], + basicSettingSave( + { + ...basicSetting, + planNo: currentCanvasPlan?.planNo || basicSetting.planNo, + /** + * 선택된 지붕재 정보 + */ + selectedRoofMaterial: { + ...newAddedRoofs[0], + }, }, - }, { skipSideEffects: true }) + { skipSideEffects: true }, + ) const roofs = canvas.getObjects().filter((obj) => obj.roofMaterial?.index === 0) @@ -297,7 +419,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla /** 지붕면 존재 여부에 따라 메뉴 설정 */ const hasRoofs = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.ROOF) - + // roofSizeSet에 따라 메뉴 설정 if (currentRoof?.roofSizeSet === '2') { setSelectedMenu('surface') @@ -354,15 +476,119 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla id={item.id} name={item.name} value={item.value} - checked={String(currentRoof?.roofSizeSet) === item.value} - onChange={(e) => setCurrentRoof({ ...currentRoof, roofSizeSet: e.target.value })} + checked={inputMode === item.value} + onChange={(e) => handleInputModeChange(e.target.value)} /> ))} + {inputMode === INPUT_MODE.PDF && ( +
+ {getMessage('modal.placement.initial.setting.size.pdf.info')} +
+ )} + {inputMode === INPUT_MODE.PDF && ( + <> + + {getMessage('modal.placement.initial.setting.size.pdf.file.label')} + +
+
+ + +
+
+ + {pdfFile && !pdfAnalyzing && } +
+
+
+ {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')} +
+ + + + )}
@@ -630,10 +856,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/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index d4fc2b7e..3faee18b 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -13,6 +13,7 @@ import { getChonByDegree } from '@/util/canvas-util' import { settingModalFirstOptionsState } from '@/store/settingAtom' import { fabric } from 'fabric' import { QLine } from '@/components/fabric/QLine' +import { reattachDebugLabels } from '@/components/fabric/QPolygon' import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { findInteriorPoint } from '@/util/skeleton-utils' import { logger } from '@/util/logger' @@ -151,6 +152,11 @@ export function useEavesGableEdit(id) { } const mouseDownEvent = (e) => { + // [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시 + // discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호. + if (!e.target || e.target.name !== 'outerLine') { + return + } logger.log( '[KERAB-MOUSEDOWN] fired ' + JSON.stringify({ @@ -166,9 +172,6 @@ export function useEavesGableEdit(id) { }), ) canvas.discardActiveObject() - if (!e.target || (e.target && e.target.name !== 'outerLine')) { - return - } const target = e.target @@ -240,6 +243,20 @@ export function useEavesGableEdit(id) { // - radio 1 의 단순 변환에만 적용. JERKINHEAD/HIPANDGABLE 등 width 가 들어가는 // radio 2 변환은 파라미터 갱신 가능성이 있어 그대로 진행. if (radioTypeRef.current === '1' && target.attributes?.type === attributes?.type) { + // [KERAB-OFFSET-ONLY-RECLICK 2026-06-01] 동일 type 재클릭이지만 出幅(offset) 만 다른 경우, + // surgical 갱신(corner / 인접 inner-line 끝점 snap + attributes.offset 반영) 후 종료. + // 기존 collapse 흐름(applyKerab*Pattern, polygonPath BFS 등) 은 건너뛰어 케라바 패턴 보존. + const oldOffset = target.attributes?.offset ?? 0 + const newOffset = attributes?.offset ?? 0 + if (Math.abs(newOffset - oldOffset) > 1e-3) { + logger.log( + `[KERAB-OFFSET-ONLY-RECLICK] type=${attributes.type} 동일, offset ${oldOffset}→${newOffset} surgical 적용`, + ) + applyTargetOffsetSurgical(target, newOffset) + target.set({ attributes }) + canvas.renderAll() + return + } logger.log(`[KERAB-NOOP] 이미 ${attributes.type} → 재변환 무시`) return } @@ -277,6 +294,34 @@ export function useEavesGableEdit(id) { .getObjects() .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) logger.log('[KERAB-SIMPLE] roof check ' + JSON.stringify({ roofId: target.attributes?.roofId, roofFound: !!roof })) + // [KERAB-INNERLINES-DUMP 2026-05-27] 토글 진입/종료 시점 innerLines ridge/hip 스냅샷. + // - cascade hide / trim 으로 어떤 라인이 어디서 끊겼는지 추적용 임시 진단 로그. + const dumpInnerLineSnapshot = (label) => { + if (!roof || !Array.isArray(roof.innerLines)) return + const rows = roof.innerLines + .filter((l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE)) + .map((l) => ({ + lab: l.label || '?', + n: l.name, + ln: l.lineName || '-', + v: l.visible !== false, + x1: Math.round(l.x1 * 10) / 10, + y1: Math.round(l.y1 * 10) / 10, + x2: Math.round(l.x2 * 10) / 10, + y2: Math.round(l.y2 * 10) / 10, + })) + logger.log('[KERAB-INNERLINES-' + label + '] ' + JSON.stringify(rows)) + // [KERAB-LABEL-REATTACH 2026-05-29] AFTER 시점에서 라벨 재부착 (local 모드 한정). + // 케라바 토글로 추가/변경된 kerabPatternRidge/ExtRidge/Hip 등에도 H-/RG- 라벨 부여. + if (label === 'AFTER') { + try { + reattachDebugLabels(canvas, roof.id) + } catch (e) { + logger.warn('[KERAB-LABEL-REATTACH] failed', e) + } + } + } + dumpInnerLineSnapshot('BEFORE') // [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영. // SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존. if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0) @@ -608,9 +653,9 @@ export function useEavesGableEdit(id) { const FAR_RAY = 1e5 const start = { x: ep.sx, y: ep.sy } const rayEnd = { x: ep.sx + ux * FAR_RAY, y: ep.sy + uy * FAR_RAY } - // [KERAB-VALLEY-EXT-ALWAYS-MID 2026-05-27] 사용자 규칙: "골짜기 세로라인이 더 확장되어야 한다." - // RIDGE stopper 제거 — 무조건 polygon-wall 까지 거리의 절반 = polygon width 의 대칭중앙. - // ridge·hip 은 모두 통과 (경로 위 교차 시 trim 단계에서 절삭). + // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-27] 새 규칙: + // 1) ridge(마루) 만나면 그 점에서 정지. hip 은 통과. + // 2) ridge 못 만나면 맞은편 polygon-wall(roofLine 너머) 까지 끝까지 확장 (절반 아님). let bestPt = null let wallT = Infinity let wallHit = null @@ -625,12 +670,38 @@ export function useEavesGableEdit(id) { wallHit = ip } } - if (wallHit) { - bestPt = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 } + let ridgeStop = null + let ridgeT = Infinity + // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 골짜기 확장라인이 "원래 있던" ridge(마루) 만나면 그 점에서 정지. + // 확장으로 생긴 ridge(kerabPatternRidge/kerabPatternExtRidge 등) 는 stop 대상 아님. + // 화이트리스트: lineName === 'ridge' 만 매칭. + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName !== 'ridge') continue + const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (!ip) continue + if (!isPointOnSegment(ip, il.x1, il.y1, il.x2, il.y2)) continue + const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy + if (t < 0.5) continue + if (wallT !== Infinity && t > wallT + 0.5) continue + if (t < ridgeT) { + ridgeT = t + ridgeStop = ip + } + } + if (ridgeStop) { + bestPt = ridgeStop logger.log( - '[KERAB-VALLEY-EXT-MID] pre label=' + ep.label + - ' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' + - ' mid={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}', + '[KERAB-VALLEY-EXT-RIDGE-STOP] pre label=' + ep.label + + ' ridgeT=' + Math.round(ridgeT * 100) / 100 + + ' stop={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}', + ) + } else if (wallHit) { + bestPt = wallHit + logger.log( + '[KERAB-VALLEY-EXT-WALL] pre label=' + ep.label + + ' end={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}', ) } if (bestPt) { @@ -1431,6 +1502,13 @@ export function useEavesGableEdit(id) { // raycast 대상 = 현재 roof.innerLines 중 HIP/RIDGE (= 살아남은 hip·ridge + 새 extLines·kLine 모두 포함). // 사라진 polygonPath hip/ridge 는 이미 제거되어 자동 제외. valleyExt 자신은 push 후이므로 1mm 필터로 보호. if (valleyPlannedEndpoints.length) { + // ==================================================================== + // [KERAB-VALLEY-EXT 2026-05-28] valleyExt helper 4종 (Step B 추출). + // Phase 1 = computeValleyExtensions + drawValleyExtensions + // Phase 2 = trimByValleyExtensions + cascadeHideByValleyExtensions + // 모든 helper 는 closure 로 roof/target/canvas/roofPolygonWalls/valleyPlannedEndpoints 캡처. + // revert 계약 (lineName='kerabPatternValleyExt', __targetId, target.__valleyExtTrims) 그대로 유지. + // ==================================================================== const isOnSegV = (pt, ax, ay, bx, by, tol = 0.5) => { const sdx = bx - ax const sdy = by - ay @@ -1443,216 +1521,97 @@ export function useEavesGableEdit(id) { const py = ay + tt * sdy return Math.hypot(px - pt.x, py - pt.y) <= tol } - const valleyExtensions = [] - for (const ep of valleyPlannedEndpoints) { - const start = { x: ep.sx, y: ep.sy } - // [KERAB-VALLEY-EXT 2026-05-27] self-extension 방향만 사용 (양 끝점 둘 다 시도). - // concave corner 측 끝점 → polygon 내부로 향해 hip/ridge hit → 그려짐. - // convex 측 끝점 → polygon 외부로 새서 hit 없음 → 자동 skip. - const dx = ep.sx - ep.ox - const dy = ep.sy - ep.oy - const len = Math.hypot(dx, dy) || 1 - const ux = dx / len - const uy = dy / len - const FAR_RAY = 1e5 - const rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY } - // [KERAB-VALLEY-EXT-ALWAYS-MID 2026-05-27] 사용자 규칙: "골짜기 세로라인이 더 확장되어야 한다." - // RIDGE stopper 제거 — 무조건 polygon-wall midpoint(대칭중앙)까지. - // 경로 위 교차 hip/ridge 는 trim 단계에서 절삭 (KERAB-VALLEY-EXT-TRIM). - let bestStop = null - let wallT = Infinity - let wallHit = null - for (const w of roofPolygonWalls) { - const ip = lineLineIntersection(start, rayEnd, w.a, w.b) - if (!ip) continue - if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue - const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy - if (t < 0.5) continue - if (t < wallT) { - wallT = t - wallHit = ip - } - } - if (wallHit) { - bestStop = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 } - logger.log( - '[KERAB-VALLEY-EXT-MID] post label=' + ep.label + - ' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' + - ' mid={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}', - ) - } - if (bestStop) { - const seg = { - x1: start.x, - y1: start.y, - x2: bestStop.x, - y2: bestStop.y, - source: ep.label, - parent: ep.parent || null, - } - valleyExtensions.push(seg) - logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg)) - } else { - logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label) - } - } - // [KERAB-VALLEY-EXT-TRIM 2026-05-27] valleyExt 경로상 교차 hip/ridge 절삭. - // 각 valleyExt segment 에 대해 모든 hip/ridge 와 교차 검사 → 교차하면 valleyExt 시작점에 - // 가까운 끝점을 hitPoint 로 단축 (= corner 쪽 부분이 잘리고 너머 쪽이 보존). - // 사용자 규칙: "대칭중앙까지 가는 도중에 힙 또는 마루를 만나면 힙·마루는 절삭한다." - // cascade 도미노는 비활성 (junction 공유 hip 의도 외 절삭 부작용). - // revert 시 trimRecords 역순 복원. - const trimRecords = [] - for (const vr of valleyExtensions) { - if (!vr.source || !vr.source.startsWith('roofBase')) continue - const segA = { x: vr.x1, y: vr.y1 } - const segB = { x: vr.x2, y: vr.y2 } - // 사용자 규칙: "조건 다 필요 없고 확장하는 라인 절삭." - // trim 검사 ray = segA(corner) → wallEnd(polygon-wall). segB(대칭중앙) 은 segA-wallEnd 의 중점. - // ray 위에서 만나는 hip/ridge 의 진행방향 쪽 끝점(= u 투영값 큰 쪽) 을 ip 로 단축. - // collinear (valleyExt 와 평행+동일직선) 라인은 별도 처리 — segB 너머 끝점을 segB 로 단축. - const wallEnd = { x: 2 * segB.x - segA.x, y: 2 * segB.y - segA.y } - const dxV = wallEnd.x - segA.x - const dyV = wallEnd.y - segA.y - const lenV = Math.hypot(dxV, dyV) || 1 - const ux = dxV / lenV - const uy = dyV / lenV - const tB = (segB.x - segA.x) * ux + (segB.y - segA.y) * uy - const COLLINEAR_TOL = 1.0 - for (const il of roof.innerLines || []) { - if (!il) continue - if (il.lineName === 'kerabPatternValleyExt') continue - // 사용자 규칙: "Y 모양일때 hip 있는 쪽은 살리고 마루쪽을 절삭." + "1522 도 삭제." - // trim 대상 = ridge(마루) + 케라바 토글 생성 hip(kerabPatternHip / kerabPatternExtHip). - // 원래 지붕 hip(H-5/H-6/H-7/H-1) 은 보존. hip-presence 룰이 자기 자신 제외하므로 안전. - const isTrimCandidate = - il.name === LINE_TYPE.SUBLINE.RIDGE || - (il.name === LINE_TYPE.SUBLINE.HIP && (il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip')) - if (!isTrimCandidate) continue - if (il.visible === false) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - let ip = lineLineIntersection(segA, wallEnd, a, b) - if (ip) { - if (!isOnSegV(ip, segA.x, segA.y, wallEnd.x, wallEnd.y)) ip = null - else if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) ip = null - } - if (!ip) { - const perp1 = Math.abs((il.x1 - segA.x) * uy - (il.y1 - segA.y) * ux) - const perp2 = Math.abs((il.x2 - segA.x) * uy - (il.y2 - segA.y) * ux) - if (perp1 > COLLINEAR_TOL || perp2 > COLLINEAR_TOL) continue - const tc1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy - const tc2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy - if (Math.max(tc1, tc2) <= tB + 0.5) continue - ip = { x: segB.x, y: segB.y } - } - // 사용자 규칙: "Y 모양일때 hip 있는 쪽은 살리고 마루(ridge) 쪽을 절삭." - // ridge 의 각 끝점에 *원래 지붕* hip 끝점이 붙어있는지 검사. - // 케라바 토글 생성 hip(kerabPatternHip/ExtHip) 은 골짜기 부속물이므로 hip-presence 카운트 제외. - // hip 있는 쪽 보존, 없는 쪽 단축. 양쪽 다 원래 hip → 통째 보존(continue). - // 양쪽 다 원래 hip 없음 → Y-junction 아닌 골짜기 부속물 → 통째 hide. - const isOriginalHipEndAt = (px, py) => { - for (const other of roof.innerLines || []) { - if (!other || other === il) continue - if (other.lineName === 'kerabPatternValleyExt') continue - if (other.lineName === 'kerabPatternHip' || other.lineName === 'kerabPatternExtHip') continue - if (other.name !== LINE_TYPE.SUBLINE.HIP) continue - if (other.visible === false) continue - if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) return true - if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) return true - } - return false - } - const hip1 = isOriginalHipEndAt(il.x1, il.y1) - const hip2 = isOriginalHipEndAt(il.x2, il.y2) - let trimEnd - if (hip1 && hip2) { - logger.log('[KERAB-VALLEY-EXT-TRIM] both-hip preserve ' + JSON.stringify({ name: il.name, lineName: il.lineName })) - continue - } else if (hip1 && !hip2) { - trimEnd = 2 - } else if (!hip1 && hip2) { - trimEnd = 1 - } else { - // 양쪽 다 원래 hip 없음 — 진행벡터 fallback (cascade pass 에서 부속물 정리) - const t1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy - const t2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy - trimEnd = t1 > t2 ? 1 : 2 - } - const oldX = trimEnd === 1 ? il.x1 : il.x2 - const oldY = trimEnd === 1 ? il.y1 : il.y2 - trimRecords.push({ - line: il, - end: trimEnd, - oldPt: { x: oldX, y: oldY }, - newPt: { x: ip.x, y: ip.y }, - originalAttrs: { ...(il.attributes || {}) }, - }) - if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y }) - else il.set({ x2: ip.x, y2: ip.y }) - if (typeof il.setCoords === 'function') il.setCoords() - const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } - logger.log( - '[KERAB-VALLEY-EXT-TRIM] trim ' + - JSON.stringify({ name: il.name, lineName: il.lineName, trimEnd, oldPt: { x: oldX, y: oldY }, ip }), - ) - } - } - // 사용자 규칙: "원 라인이 절삭되었으면 그 밑으로 확장된 라인도 삭제." - // cascade pass — trim 결과 옛 끝점(oldPt) 에 끝점이 일치하는 케라바산 라인(kerabPatternHip/ExtHip/Ridge) 을 hide. - // 가드: 원래 지붕 ridge/hip(lineName 없거나 kerabPattern* 아닌 것) 은 cascade 대상 외 — junction 공유 의도 외 절삭 방지. - const isKerabSynthetic = (line) => { - if (!line || !line.lineName) return false - return ( - line.lineName === 'kerabPatternHip' || - line.lineName === 'kerabPatternExtHip' || - line.lineName === 'kerabPatternRidge' || - line.lineName === 'kerabPatternExtRidge' - ) - } - const cascadeHidden = new Set() - // BFS — cascade hide 된 라인의 다른 끝점에 붙은 케라바산 라인도 재귀 hide. - const cascadeQueue = trimRecords.map((r) => r.oldPt).filter(Boolean) - let cascadeGuard = 0 - while (cascadeQueue.length && cascadeGuard++ < 200) { - const pt = cascadeQueue.shift() - if (!pt) continue - for (const il of roof.innerLines || []) { - if (!il || il.visible === false) continue - if (cascadeHidden.has(il)) continue - if (il.lineName === 'kerabPatternValleyExt') continue - if (!isKerabSynthetic(il)) continue - const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0 - const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0 - if (!m1 && !m2) continue - cascadeHidden.add(il) - trimRecords.push({ - line: il, - hide: true, - originalAttrs: { ...(il.attributes || {}) }, - originalVisible: il.visible !== false, - }) - il.visible = false - if (typeof il.setCoords === 'function') il.setCoords() - logger.log( - '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + - JSON.stringify({ name: il.name, lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), - ) - // 다른 끝점도 cascade 시드에 추가 - cascadeQueue.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) - } - } - if (trimRecords.length) target.__valleyExtTrims = trimRecords - for (const vr of valleyExtensions) { + // ── Phase 1-a: valleyExt ray 계산 ── + // self-extension 방향만 사용 (양 끝점 둘 다 시도, concave 측만 hit). + // ridge meet first, 못 만나면 wallhit 끝까지. hip 통과. + const computeValleyExtensions = () => { + const exts = [] + for (const ep of valleyPlannedEndpoints) { + const start = { x: ep.sx, y: ep.sy } + const dx = ep.sx - ep.ox + const dy = ep.sy - ep.oy + const len = Math.hypot(dx, dy) || 1 + const ux = dx / len + const uy = dy / len + const FAR_RAY = 1e5 + const rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY } + let bestStop = null + let wallT = Infinity + let wallHit = null + for (const w of roofPolygonWalls) { + const ip = lineLineIntersection(start, rayEnd, w.a, w.b) + if (!ip) continue + if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue + const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy + if (t < 0.5) continue + if (t < wallT) { + wallT = t + wallHit = ip + } + } + let ridgeStop = null + let ridgeT = Infinity + // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (roof 측 post 경로). + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName !== 'ridge') continue + const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (!ip) continue + if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue + const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy + if (t < 0.5) continue + if (wallT !== Infinity && t > wallT + 0.5) continue + if (t < ridgeT) { + ridgeT = t + ridgeStop = ip + } + } + if (ridgeStop) { + bestStop = ridgeStop + logger.log( + '[KERAB-VALLEY-EXT-RIDGE-STOP] post label=' + ep.label + + ' ridgeT=' + Math.round(ridgeT * 100) / 100 + + ' stop={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}', + ) + } else if (wallHit) { + bestStop = wallHit + logger.log( + '[KERAB-VALLEY-EXT-WALL] post label=' + ep.label + + ' end={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}', + ) + } + if (bestStop) { + const seg = { + x1: start.x, + y1: start.y, + x2: bestStop.x, + y2: bestStop.y, + source: ep.label, + parent: ep.parent || null, + } + exts.push(seg) + logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg)) + } else { + logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label) + } + } + return exts + } + + // ── Phase 1-b: roof + wall QLine 생성 (순수 그리기, trim/hide 없음) ── + const drawValleyExtensions = (valleyExtensions) => { + for (const vr of valleyExtensions) { const pts = [vr.x1, vr.y1, vr.x2, vr.y2] const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) // [KERAB-VALLEY-EXT 2026-05-27] 지붕면 할당 통합 (option a): // roofBase 확장은 처마의 연장 → attributes.type=EAVES + parentLine + innerLines push (split 참여). // wallBase 확장은 wall layer (roof 폴리곤 밖) → 시각만, innerLines 비추가, type/parent 부여 안 함. const isRoofBase = !!(vr.source && vr.source.startsWith('roofBase')) - const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz } + // [KERAB-VALLEY-EXT 2026-05-29] wallLine ID 를 attributes 에 명시 저장. + // apply() 의 wallExt 재계산 RECALC 가 vExt.attributes.wallLine 으로 outerLine 매칭 — 보존 필수. + const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz, wallLine: target.id } if (isRoofBase) { // [KERAB-VALLEY-EXT 2026-05-27] splitPolygonWithLines 의 innerLineMapping 은 // EAVES polygonLine 을 skip 하므로(usePolygon.js:822) 처마확장은 자동 매핑이 안 된다. @@ -1680,14 +1639,421 @@ export function useEavesGableEdit(id) { canvas.add(vExt) vExt.bringToFront() if (isRoofBase) roof.innerLines.push(vExt) + + // [KERAB-VALLEY-EXT-WALL 2026-05-28] wallBase 레벨 확장 라인 — 독립 ray. + // 사용자 룰: "맞은편 roofLine 까지" — wallBase 측도 자체 시작점에서 ray 쏴서 polygon-wall hit 까지. + // roof 측 끝점과 공유 X (그건 만나라는 의미). roof 측과 동일 방향, ridge-stop 룰 동일. + // wallBase 측은 시각만 — innerLines push X, type=EAVES X, parentLine X. + if (isRoofBase && vr.parent && target) { + const rl = vr.parent + const dxR = rl.x2 - rl.x1 + const dyR = rl.y2 - rl.y1 + const lenR = Math.hypot(dxR, dyR) || 1 + const uxR2 = dxR / lenR + const uyR2 = dyR / lenR + const sT1 = (target.x1 - rl.x1) * uxR2 + (target.y1 - rl.y1) * uyR2 + const sT2 = (target.x2 - rl.x1) * uxR2 + (target.y2 - rl.y1) * uyR2 + const tStart = sT1 < sT2 ? { x: target.x1, y: target.y1 } : { x: target.x2, y: target.y2 } + const tEnd = sT1 < sT2 ? { x: target.x2, y: target.y2 } : { x: target.x1, y: target.y1 } + const wallCorner = vr.source === 'roofBase-s' ? tStart : tEnd + // 방향: vr 방향 그대로 (vr.x1,y1 → vr.x2,y2) + const vdx = vr.x2 - vr.x1 + const vdy = vr.y2 - vr.y1 + const vlen = Math.hypot(vdx, vdy) || 1 + const wUx = vdx / vlen + const wUy = vdy / vlen + const wStart = { x: wallCorner.x, y: wallCorner.y } + const wRayEnd = { x: wStart.x + wUx * 1e5, y: wStart.y + wUy * 1e5 } + // polygon-wall hit + let wWallT = Infinity + let wWallHit = null + for (const w of roofPolygonWalls) { + const ip = lineLineIntersection(wStart, wRayEnd, w.a, w.b) + if (!ip) continue + if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue + const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy + if (t < 0.5) continue + if (t < wWallT) { + wWallT = t + wWallHit = ip + } + } + // ridge stop (roof 측과 동일 룰) + let wRidgeT = Infinity + let wRidgeStop = null + // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (wallBase 측 ray). + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName !== 'ridge') continue + const ip = lineLineIntersection(wStart, wRayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (!ip) continue + if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue + const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy + if (t < 0.5) continue + if (wWallT !== Infinity && t > wWallT + 0.5) continue + if (t < wRidgeT) { + wRidgeT = t + wRidgeStop = ip + } + } + const wEnd = wRidgeStop || wWallHit + if (wEnd) { +// [KERAB-VALLEY-OVERLAP 2026-05-28] roof 1개에 split 결과 sub-roof 가 나뉜다. + // 라인은 roof.id 로만 생성 → split 후 직사각형 sub-roof 가 분리되어 나옴. + // apply() 후처리에서 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지하여 "겹침" 표현. + const buildOverlapLine = (p1, p2, suffix) => { + const lpts = [p1.x, p1.y, p2.x, p2.y] + const lsz = calcLinePlaneSize({ x1: lpts[0], y1: lpts[1], x2: lpts[2], y2: lpts[3] }) + const ln = new QLine(lpts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.VALLEY, + textMode: roof.textMode, + attributes: { + roofId: roof.id, + type: 'kerabValleyOverlapLine', + isStart: true, + pitch: roof.pitch, + planeSize: lsz, + actualSize: lsz, + }, + }) + ln.lineName = 'kerabValleyOverlapLine' + ln.roofId = roof.id + ln.__targetId = target.id + ln.__valleyExtSource = vr.source + suffix + canvas.add(ln) + ln.bringToFront() + return ln + } + // [KERAB-VALLEY-OVERLAP 2026-05-28] 평행 사각형으로 b 외곽 닫기 + (선택) 그 너머 ray 연장. + // 사용자 룰: "vStart 에서 wLine 까지 평행선 → 그 교점에서부터 wallExt 확장". + // 출발: vStart 의 wallLine(target) 위 수선의 발 = newWStart (90° 보조선). 원래 wStart(target corner) 는 사용 안 함. + // 도착: vEnd 의 동일 offset 평행이동 = wEndProj (90° 보조선, 이전과 동일). + // 사각형 4변: vStart-newWStart, newWStart-wEndProj(wallExt 본체), wEndProj-vEnd, vEnd-vStart(=vExt 이미 그려짐). + // wEnd(원 ray hit) 가 wEndProj 보다 더 멀면 wEndProj→wEnd 확장 라인 추가. + const vStart = { x: pts[0], y: pts[1] } + const vEnd = { x: pts[2], y: pts[3] } + const dxT = target.x2 - target.x1 + const dyT = target.y2 - target.y1 + const lenTSq = dxT * dxT + dyT * dyT + const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9) + const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT } + const offX = newWStart.x - vStart.x + const offY = newWStart.y - vStart.y + const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY } + // wEnd(원 ray hit) 가 wEndProj 보다 ray 방향으로 더 멀리 있는지 — newWStart 기준 투영 비교. + const tProj = (wEndProj.x - newWStart.x) * wUx + (wEndProj.y - newWStart.y) * wUy + const tEndR = (wEnd.x - newWStart.x) * wUx + (wEnd.y - newWStart.y) * wUy + buildOverlapLine(newWStart, wEndProj, '-wall') + buildOverlapLine(vStart, newWStart, '-bridge-start') + buildOverlapLine(vEnd, wEndProj, '-bridge-end') + if (tEndR > tProj + 0.5) { + buildOverlapLine(wEndProj, wEnd, '-extend') + } + logger.log( + '[KERAB-VALLEY-OVERLAP] drawn ' + + JSON.stringify({ + src: vr.source, + stop: wRidgeStop ? 'ridge' : 'wall', + newWStart: { x: Math.round(newWStart.x * 100) / 100, y: Math.round(newWStart.y * 100) / 100 }, + wEndProj: { x: Math.round(wEndProj.x * 100) / 100, y: Math.round(wEndProj.y * 100) / 100 }, + wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 }, + vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 }, + vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 }, + extended: tEndR > tProj + 0.5, + }), + ) + + // [KERAB-VALLEY-EXT-TRIM 2026-05-29] 룰 2: vExt 가 ridge 와 교차하면 + // wallExt(`:`) 측(=vExt 와 가까운 영역) ridge 부분 절삭. + // ridge 의 V apex 는 vExt 와 먼 끝점 (Y 출발점). 살아남는 쪽 = V apex 쪽. + // 방향 판정: 절삭 방향 = wallExt 중점 - vExt 중점 (wallExt 측). dot 양수면 절삭 대상. + const veMid = { x: (vStart.x + vEnd.x) / 2, y: (vStart.y + vEnd.y) / 2 } + const wsMid = { x: (newWStart.x + wEndProj.x) / 2, y: (newWStart.y + wEndProj.y) / 2 } + const bDirX = wsMid.x - veMid.x + const bDirY = wsMid.y - veMid.y + const isBSide = (px, py) => (px - veMid.x) * bDirX + (py - veMid.y) * bDirY > 0 + const trimCascadePts = [] + const newTrimRecords = [] + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + // [KERAB-VALLEY-EXT-TRIM 2026-05-29] trim 대상 = 원래 ridge/hip + 마루 확장(ExtRidge) + kLine(kerabPatternRidge). + // stop 룰(룰 1) 은 ridge 만, trim 룰(룰 2) 은 hip 도 포함 — vExt 는 hip 통과 후 절삭. + // 절삭 방향은 V apex 우선 룰 (아래) 로 결정. + const isRidge = + il.name === LINE_TYPE.SUBLINE.RIDGE && + (il.lineName === 'ridge' || il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge') + const isHip = + il.name === LINE_TYPE.SUBLINE.HIP && + (il.lineName === 'hip' || il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip') + if (!isRidge && !isHip) continue + const ip = lineLineIntersection(vStart, vEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (!ip) continue + if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue + if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue + // [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰. + // V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점. + // 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정. + // 1) 한 끝만 V apex → 그 V apex 측 살림, 반대 끝 절삭. + // 2) 양 끝 모두 V apex → vExt 와 먼 끝 살림 (가까운 끝 절삭). + // 3) 둘 다 V apex 아님 → 기존 fallback (B 측 = wallExt 측 절삭). + const isVApex = (px, py) => { + let cnt = 0 + for (const other of roof.innerLines || []) { + if (!other || other === il) continue + if (other.visible === false) continue + if (other.name !== LINE_TYPE.SUBLINE.HIP) continue + if (other.lineName !== 'kerabPatternHip' && other.lineName !== 'kerabPatternExtHip') continue + if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) cnt++ + else if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) cnt++ + if (cnt >= 2) return true + } + return false + } + const e1V = isVApex(il.x1, il.y1) + const e2V = isVApex(il.x2, il.y2) + const e1B = isBSide(il.x1, il.y1) + const e2B = isBSide(il.x2, il.y2) + let trimEnd = 0 + if (e1V && !e2V) trimEnd = 2 // e1 V apex 살림, e2 절삭 + else if (!e1V && e2V) trimEnd = 1 + else if (e1V && e2V) { + // 양 끝 모두 V apex → vExt 와 가까운 끝 절삭 + const d1 = Math.hypot(il.x1 - veMid.x, il.y1 - veMid.y) + const d2 = Math.hypot(il.x2 - veMid.x, il.y2 - veMid.y) + trimEnd = d1 < d2 ? 1 : 2 + } else { + // 둘 다 V apex 아님 → 기존 B 측 fallback + if (e1B && !e2B) trimEnd = 1 + else if (!e1B && e2B) trimEnd = 2 + else continue + } + const oldPt = trimEnd === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } + const originalAttrs = { ...(il.attributes || {}) } + if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y }) + else il.set({ x2: ip.x, y2: ip.y }) + if (typeof il.setCoords === 'function') il.setCoords() + const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) + il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } + trimCascadePts.push(oldPt) + newTrimRecords.push({ + line: il, + end: trimEnd, + oldPt, + newPt: { x: ip.x, y: ip.y }, + originalAttrs, + }) + logger.log( + '[KERAB-VALLEY-EXT-TRIM] ridge trim ' + + JSON.stringify({ lineName: il.lineName, trimEnd, oldPt, ip: { x: Math.round(ip.x * 100) / 100, y: Math.round(ip.y * 100) / 100 } }), + ) + } + // cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide + const cascadeHidden = new Set() + let cascadeGuard = 0 + while (trimCascadePts.length && cascadeGuard++ < 200) { + const pt = trimCascadePts.shift() + if (!pt) continue + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (cascadeHidden.has(il)) continue + if (il.lineName !== 'kerabPatternExtRidge') continue + const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0 + const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0 + if (!m1 && !m2) continue + cascadeHidden.add(il) + const originalVisible = il.visible !== false + const originalAttrs = { ...(il.attributes || {}) } + il.visible = false + if (typeof il.setCoords === 'function') il.setCoords() + newTrimRecords.push({ + line: il, + hide: true, + originalVisible, + originalAttrs, + }) + logger.log( + '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + + JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), + ) + trimCascadePts.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) + } + } + // [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적. + // 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리. + // 한 mousedown 에 여러 vExt 가 호출될 수 있어 concat. + if (newTrimRecords.length) { + target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords) + } + } else { + logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source) + } + } } + } + + // ── Phase 2-a: valleyExt 경로상 교차 hip/ridge 절삭 ── + // trim 대상 = ridge + 케라바 토글 생성 hip(kerabPatternHip/ExtHip). 원래 지붕 hip 보존. + // Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축. + const trimByValleyExtensions = (valleyExtensions) => { + const trimRecords = [] + for (const vr of valleyExtensions) { + if (!vr.source || !vr.source.startsWith('roofBase')) continue + const segA = { x: vr.x1, y: vr.y1 } + const segB = { x: vr.x2, y: vr.y2 } + const wallEnd = { x: 2 * segB.x - segA.x, y: 2 * segB.y - segA.y } + const dxV = wallEnd.x - segA.x + const dyV = wallEnd.y - segA.y + const lenV = Math.hypot(dxV, dyV) || 1 + const ux = dxV / lenV + const uy = dyV / lenV + const tB = (segB.x - segA.x) * ux + (segB.y - segA.y) * uy + const COLLINEAR_TOL = 1.0 + for (const il of roof.innerLines || []) { + if (!il) continue + if (il.lineName === 'kerabPatternValleyExt') continue + // trim 대상 = ridge(마루) + 케라바 토글 생성 hip(kerabPatternHip / kerabPatternExtHip). + // 원래 지붕 hip 은 보존. + const isTrimCandidate = + il.name === LINE_TYPE.SUBLINE.RIDGE || + (il.name === LINE_TYPE.SUBLINE.HIP && (il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip')) + if (!isTrimCandidate) continue + if (il.visible === false) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + let ip = lineLineIntersection(segA, wallEnd, a, b) + if (ip) { + if (!isOnSegV(ip, segA.x, segA.y, wallEnd.x, wallEnd.y)) ip = null + else if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) ip = null + } + if (!ip) { + const perp1 = Math.abs((il.x1 - segA.x) * uy - (il.y1 - segA.y) * ux) + const perp2 = Math.abs((il.x2 - segA.x) * uy - (il.y2 - segA.y) * ux) + if (perp1 > COLLINEAR_TOL || perp2 > COLLINEAR_TOL) continue + const tc1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy + const tc2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy + if (Math.max(tc1, tc2) <= tB + 0.5) continue + ip = { x: segB.x, y: segB.y } + } + // Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축. + // 양쪽 다 원래 hip 있음 → 통째 보존. 양쪽 다 없음 → 진행벡터 fallback (cascade pass 에서 정리). + const isOriginalHipEndAt = (px, py) => { + for (const other of roof.innerLines || []) { + if (!other || other === il) continue + if (other.lineName === 'kerabPatternValleyExt') continue + if (other.lineName === 'kerabPatternHip' || other.lineName === 'kerabPatternExtHip') continue + if (other.name !== LINE_TYPE.SUBLINE.HIP) continue + if (other.visible === false) continue + if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) return true + if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) return true + } + return false + } + const hip1 = isOriginalHipEndAt(il.x1, il.y1) + const hip2 = isOriginalHipEndAt(il.x2, il.y2) + let trimEnd + if (hip1 && hip2) { + logger.log('[KERAB-VALLEY-EXT-TRIM] both-hip preserve ' + JSON.stringify({ name: il.name, lineName: il.lineName })) + continue + } else if (hip1 && !hip2) { + trimEnd = 2 + } else if (!hip1 && hip2) { + trimEnd = 1 + } else { + const t1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy + const t2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy + trimEnd = t1 > t2 ? 1 : 2 + } + const oldX = trimEnd === 1 ? il.x1 : il.x2 + const oldY = trimEnd === 1 ? il.y1 : il.y2 + trimRecords.push({ + line: il, + end: trimEnd, + oldPt: { x: oldX, y: oldY }, + newPt: { x: ip.x, y: ip.y }, + originalAttrs: { ...(il.attributes || {}) }, + }) + if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y }) + else il.set({ x2: ip.x, y2: ip.y }) + if (typeof il.setCoords === 'function') il.setCoords() + const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) + il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } + logger.log( + '[KERAB-VALLEY-EXT-TRIM] trim ' + + JSON.stringify({ name: il.name, lineName: il.lineName, trimEnd, oldPt: { x: oldX, y: oldY }, ip }), + ) + } + } + return trimRecords + } + + // ── Phase 2-b: cascade hide ── + // trim oldPt 에 끝점 일치 케라바산 라인(kerabPatternHip/ExtHip/Ridge/ExtRidge) BFS hide. + // 원래 지붕 ridge/hip 은 cascade 대상 외 — junction 공유 의도 외 절삭 방지. + const cascadeHideByValleyExtensions = (trimRecords) => { + const isKerabSynthetic = (line) => { + if (!line || !line.lineName) return false + return ( + line.lineName === 'kerabPatternHip' || + line.lineName === 'kerabPatternExtHip' || + line.lineName === 'kerabPatternRidge' || + line.lineName === 'kerabPatternExtRidge' + ) + } + const cascadeHidden = new Set() + const cascadeQueue = trimRecords.map((r) => r.oldPt).filter(Boolean) + let cascadeGuard = 0 + while (cascadeQueue.length && cascadeGuard++ < 200) { + const pt = cascadeQueue.shift() + if (!pt) continue + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (cascadeHidden.has(il)) continue + if (il.lineName === 'kerabPatternValleyExt') continue + if (!isKerabSynthetic(il)) continue + const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0 + const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0 + if (!m1 && !m2) continue + cascadeHidden.add(il) + trimRecords.push({ + line: il, + hide: true, + originalAttrs: { ...(il.attributes || {}) }, + originalVisible: il.visible !== false, + }) + il.visible = false + if (typeof il.setCoords === 'function') il.setCoords() + logger.log( + '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + + JSON.stringify({ name: il.name, lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), + ) + cascadeQueue.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) + } + } + } + + // === Phase 1 + Phase 2 실행 === + const valleyExtensions = computeValleyExtensions() + drawValleyExtensions(valleyExtensions) if (valleyExtensions.length) { logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length) } + // [KERAB-VALLEY-EXT 2026-05-28] 사용자 지시: 골짜기 라인이동(trim)·라인절삭(cascade) 다 비활성, 확장만. + // 골짜기 케라바 라인 별도 룰 후속에서 재활성 예정. + // const trimRecords = trimByValleyExtensions(valleyExtensions) + // cascadeHideByValleyExtensions(trimRecords) + const trimRecords = [] + if (trimRecords.length) target.__valleyExtTrims = trimRecords } + dumpInnerLineSnapshot('AFTER') logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')') return } + dumpInnerLineSnapshot('AFTER') logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only') target.set({ attributes }) applyKerabAttributeOnlyPattern() @@ -1705,6 +2071,7 @@ export function useEavesGableEdit(id) { null, null, ) + dumpInnerLineSnapshot('AFTER') logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)') return } @@ -2268,8 +2635,11 @@ export function useEavesGableEdit(id) { // [KERAB-VALLEY-EXT 2026-05-27] forward 에서 추가됐던 처마확장(kerabPatternValleyExt) 제거. // __targetId === target.id 매칭으로 해당 target 의 처마확장만 정확히 정리. 모든 패턴 분기 공통. // roofBase 확장은 roof.innerLines 에 있고 wallBase 확장은 canvas 에만 있으므로 canvas 전체 스캔. + // [KERAB-VALLEY-OVERLAP 2026-05-28] wallBase 측은 lineName='kerabValleyOverlapLine' (본체 + 90도 보조선 2개). + // b polygon 의 lines[] 에는 apply() 단계에서 들어가므로, 여기서는 canvas 객체와 roof.innerLines 만 정리. + // b 의 lines[] 에서 제거하는 책임은 apply() 다음 사이클에서 자동 (canvas 에서 사라지므로). const valleyExtsToRemove = (canvas.getObjects() || []).filter( - (il) => il && il.lineName === 'kerabPatternValleyExt' && il.__targetId === target.id, + (il) => il && (il.lineName === 'kerabPatternValleyExt' || il.lineName === 'kerabValleyOverlapLine') && il.__targetId === target.id, ) for (const v of valleyExtsToRemove) { removeLine(v) diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 33b512b2..33fe7793 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -15,7 +15,7 @@ import { selectedRoofMaterialSelector, } from '@/store/settingAtom' import { usePopup } from '@/hooks/usePopup' -import { POLYGON_TYPE } from '@/common/common' +import { POLYGON_TYPE, LINE_TYPE } from '@/common/common' import { v4 as uuidv4 } from 'uuid' import ActualSizeSetting from '@/components/floor-plan/modal/roofAllocation/ActualSizeSetting' import { useMessage } from '@/hooks/useMessage' @@ -30,6 +30,7 @@ import { QcastContext } from '@/app/QcastProvider' import { usePlan } from '@/hooks/usePlan' import { roofsState } from '@/store/roofAtom' import { useText } from '@/hooks/useText' +import { fabric } from 'fabric' import { QLine } from '@/components/fabric/QLine' import { calcLineActualSize2 } from '@/util/qpolygon-utils' // [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert @@ -656,6 +657,251 @@ export function useRoofAllocationSetting(id) { ) } + /** + * [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 골짜기 케라바 출폭 띠 — 직사각형 sub-roof 를 인접 sub-roof 들에 union 머지. + * + * 배경: 케라바 토글 시 두 지붕면이 出幅만큼 서로 물려 겹친다는 표시. useEavesGableEdit.js wallExt 단계에서 + * 평행 사각형 4 라인(kerabValleyOverlapLine) 으로 출폭 띠를 닫고 split 단계로 진입. + * split 후 이 사각형은 별도 sub-roof X 가 됨. 의도는 X 영역이 인접 두 sub-roof 모두에 속해 겹치는 것. + * 따라서 X 의 공유 변을 가진 인접 sub-roof 들에 X 의 나머지 변(detour)을 끼워넣어 X 영역을 흡수. + * 흡수 완료 후 X 자체는 캔버스에서 제거. + * + * 식별: + * - 직사각형 X: sub.lines 의 attributes.type === 'kerabValleyOverlapLine' 가 N-1 개 이상 (4변 중 3~4) + * - 인접 sub-roof: X 의 변과 같은 두 끝점을 공유하는 다른 sub-roof + * + * 머지: 사각형 X 의 공유 변 1개를 detour 변 N-1개로 대체. 방향은 면적 증가로 검증. + */ + const mergeValleyOverlapSubRoofs = () => { + const newSubRoofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed) + if (newSubRoofs.length === 0) return + + const eq = (p, q) => Math.abs(p.x - q.x) < 0.5 && Math.abs(p.y - q.y) < 0.5 + + const rects = newSubRoofs.filter((sub) => { + if (!sub.lines || sub.lines.length < 3) return false + const cnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length + return cnt >= Math.max(3, sub.lines.length - 1) + }) + + if (rects.length === 0) return + + logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] 직사각형 sub-roof 후보=${rects.length}`) + + const signedArea = (pts) => { + let s = 0 + for (let i = 0; i < pts.length; i++) { + const a = pts[i] + const b = pts[(i + 1) % pts.length] + s += a.x * b.y - b.x * a.y + } + return s / 2 + } + + rects.forEach((X) => { + const Xpts = (X.points || []).map((p) => ({ x: p.x, y: p.y })) + const Xlines = X.lines || [] + if (Xpts.length < 3 || Xlines.length < Xpts.length) { + logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} skip — pts=${Xpts.length} lines=${Xlines.length}`) + return + } + + const Xedges = [] + for (let i = 0; i < Xpts.length; i++) { + Xedges.push({ a: Xpts[i], b: Xpts[(i + 1) % Xpts.length], idx: i }) + } + + // 인접 sub-roof — X 와 공유 변 모두 수집 (다중 공유 검출용) + const adjacencyMap = new Map() // sub → [{ subEdgeStart, xEdgeIdx }, ...] + newSubRoofs.forEach((sub) => { + if (sub === X) return + const pts = sub.points || [] + if (pts.length < 3) return + const shares = [] + for (let i = 0; i < pts.length; i++) { + const p1 = pts[i] + const p2 = pts[(i + 1) % pts.length] + for (const e of Xedges) { + if ((eq(p1, e.a) && eq(p2, e.b)) || (eq(p1, e.b) && eq(p2, e.a))) { + shares.push({ subEdgeStart: i, xEdgeIdx: e.idx }) + break + } + } + } + if (shares.length > 0) adjacencyMap.set(sub, shares) + }) + + logger.log( + `[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} pts=${Xpts.length} adjacents=${adjacencyMap.size} ` + + `shares=[${[...adjacencyMap.values()].map((s) => s.length).join(',')}]`, + ) + + if (adjacencyMap.size === 0) { + canvas.remove(X) + return + } + + adjacencyMap.forEach((shares, sub) => { + // 다중 공유 → bowtie 위험, 머지 skip + if (shares.length !== 1) { + logger.log( + `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — sharedEdges=${shares.length} (≠1, bowtie 위험)`, + ) + return + } + const { subEdgeStart, xEdgeIdx } = shares[0] + const pts = (sub.points || []).map((p) => ({ x: p.x, y: p.y })) + const oldLines = [...(sub.lines || [])] + const N = Xpts.length + + // [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] detour 방향은 sub.pts[subEdgeStart] 가 + // X.xEdge.a (=Xpts[xEdgeIdx]) 인지 X.xEdge.b (=Xpts[xEdgeIdx+1]) 인지로 결정. + // sub 외곽선 진행 방향이 xEdge 의 a→b 와 같으면 detour 는 X 의 a 쪽 이웃부터 (역방향) + // 반대면 detour 는 X 의 b 쪽 이웃부터 (정방향). + const subA = pts[subEdgeStart] + const xA = Xpts[xEdgeIdx] + const sameDir = eq(subA, xA) + + const detour = [] + const detourEdgeIdx = [] + if (sameDir) { + // a → Xpts[i-1] → Xpts[i-2] → ... → Xpts[i+2] → b + // detour vertices: N-2 개 (a, b 사이) + for (let k = 1; k <= N - 2; k++) { + detour.push(Xpts[(xEdgeIdx - k + N) % N]) + } + // detour edges: N-1 개. a→Xpts[i-1] 는 X 의 (i-1)%N 변 (반대방향). + for (let k = 1; k <= N - 1; k++) { + detourEdgeIdx.push((xEdgeIdx - k + N) % N) + } + } else { + // b → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → a 의 a→b 표기: + // a → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → b (subA=b 인 관점에서 보면 반대) + // 정확히는 subA=X.xEdge.b → detour 시작 = Xpts[(i+2)%N] (b 의 X 내 이웃) + for (let k = 2; k <= N - 1; k++) { + detour.push(Xpts[(xEdgeIdx + k) % N]) + } + for (let k = 1; k <= N - 1; k++) { + detourEdgeIdx.push((xEdgeIdx + k) % N) + } + } + + const buildCand = (verts) => { + const c = [...pts] + c.splice(subEdgeStart + 1, 0, ...verts) + return c + } + + const candA = buildCand(detour) + const oldSigned = signedArea(pts) + const aSigned = signedArea(candA) + const sameSign = (s) => oldSigned === 0 || Math.sign(s) === Math.sign(oldSigned) + const okA = sameSign(aSigned) && Math.abs(aSigned) > Math.abs(oldSigned) + 0.5 + + let finalPts = null + let finalEdges = null + if (okA) { + finalPts = candA + finalEdges = detourEdgeIdx + } else { + // fallback — 반대 방향 시도 (X 가 CW 로 들어온 경우 대비) + const detourRev = [...detour].reverse() + const detourEdgeRev = [...detourEdgeIdx].reverse() + const candB = buildCand(detourRev) + const bSigned = signedArea(candB) + const okB = sameSign(bSigned) && Math.abs(bSigned) > Math.abs(oldSigned) + 0.5 + if (okB) { + finalPts = candB + finalEdges = detourEdgeRev + logger.log( + `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} fallback reverse detour 사용 ` + + `oldSigned=${oldSigned.toFixed(0)} → ${bSigned.toFixed(0)}`, + ) + } else { + logger.log( + `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — detour 양방향 실패 ` + + `sameDir=${sameDir} oldSigned=${oldSigned.toFixed(0)} ` + + `aSigned=${aSigned.toFixed(0)} bSigned=${bSigned.toFixed(0)} ` + + `subA=(${subA.x.toFixed(1)},${subA.y.toFixed(1)}) ` + + `xA=(${xA.x.toFixed(1)},${xA.y.toFixed(1)}) ` + + `xB=(${Xpts[(xEdgeIdx + 1) % N].x.toFixed(1)},${Xpts[(xEdgeIdx + 1) % N].y.toFixed(1)})`, + ) + return + } + } + + // sub.lines 재구성 — 옛 공유 변 1개 → detour 변 N-1개 + const newSubLines = [] + for (let i = 0; i < oldLines.length; i++) { + if (i === subEdgeStart) { + for (let k = 0; k < finalEdges.length; k++) { + const xLine = Xlines[finalEdges[k]] + const p1 = finalPts[(subEdgeStart + k) % finalPts.length] + const p2 = finalPts[(subEdgeStart + k + 1) % finalPts.length] + const attrs = xLine?.attributes + ? { ...xLine.attributes } + : { type: 'kerabValleyOverlapLine', offset: 0 } + const ln = new QLine([p1.x, p1.y, p2.x, p2.y], { + stroke: sub.stroke, + strokeWidth: sub.strokeWidth, + fontSize: sub.fontSize, + attributes: attrs, + textVisible: false, + parent: sub, + parentId: sub.id, + idx: newSubLines.length + 1, + }) + ln.startPoint = p1 + ln.endPoint = p2 + newSubLines.push(ln) + } + } else { + const ln = oldLines[i] + if (ln) { + ln.idx = newSubLines.length + 1 + newSubLines.push(ln) + } + } + } + + // [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] fabric.Polygon 정식 points 갱신 패턴. + // src/util/canvas-util.js#anchorWrapper 패턴 그대로 적용 — + // _setPositionDimensions 가 width/height/pathOffset/left/top 을 재계산하므로 + // 변경 전 앵커(pts[0]) 절대좌표를 캡쳐 → 변경 후 setPositionByOrigin 으로 복원. + // 그래야 polygon 의 path 가 새 bbox 기준으로 다시 그려지면서 시각 위치도 유지됨. + const anchorIdx = 0 + const oldLocal = { + x: sub.points[anchorIdx].x - sub.pathOffset.x, + y: sub.points[anchorIdx].y - sub.pathOffset.y, + } + const absolutePoint = fabric.util.transformPoint(oldLocal, sub.calcTransformMatrix()) + + sub.points = finalPts + sub.lines = newSubLines + sub._setPositionDimensions({}) + + const strokeW = sub.strokeUniform ? sub.strokeWidth / sub.scaleX : sub.strokeWidth + const baseW = sub.width + strokeW + const baseH = sub.height + (sub.strokeUniform ? sub.strokeWidth / sub.scaleY : sub.strokeWidth) + const newX = (sub.points[anchorIdx].x - sub.pathOffset.x) / Math.max(baseW, 1e-9) + const newY = (sub.points[anchorIdx].y - sub.pathOffset.y) / Math.max(baseH, 1e-9) + sub.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5) + sub.setCoords?.() + sub.dirty = true + + logger.log( + `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} 머지 완료 ` + + `oldSigned=${oldSigned.toFixed(0)} → newSigned=${signedArea(finalPts).toFixed(0)} ` + + `pts=${finalPts.length} lines=${newSubLines.length}`, + ) + }) + + canvas.remove(X) + }) + + canvas.renderAll?.() + } + /** * 지붕면 할당 */ @@ -663,11 +909,21 @@ export function useRoofAllocationSetting(id) { const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial) const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) logger.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`) + // [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] 저장→로드 사이클을 거치면 fabric 객체 좌표/캐시가 정리되어 + // case A 가 잘 동작. 저장 X 시 그 효과가 없음. canvas.toJSON 한 번 호출하여 동일 효과 시뮬레이션. + try { + canvas.discardActiveObject() + canvas.toJSON() + } catch (e) { + logger.warn('[ALLOC] toJSON simulate failed', e) + } roofBases.forEach((roofBase) => { try { // 지붕 할당 로직에 extensionLine 추가 + // [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 출폭 띠 외곽 라인(kerabValleyOverlapLine) 도 할당 대상. + // b polygon 의 lines[] 에 추가되어 b 의 면이 출폭 띠 영역까지 확장 → a 의 sub-면과 겹침 표현. const roofEaveHelpLines = canvas.getObjects().filter((obj) => - (obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine') && + (obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') && obj.roofId === roofBase.id ) // logger.log('roofBase.id:', roofBase.id) @@ -683,6 +939,8 @@ export function useRoofAllocationSetting(id) { // extensionLine과 일반 eaveHelpLine 분리 const extensionLines = newEaveLines.filter(line => line.lineName === 'extensionLine') const normalEaveLines = newEaveLines.filter(line => line.lineName === 'eaveHelpLine') + // [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 겹침 라인 — overlap 판단 제외, 단순 결합 + const overlapLines = newEaveLines.filter(line => line.lineName === 'kerabValleyOverlapLine') // logger.log('extensionLines count:', extensionLines.length) // logger.log('normalEaveLines count:', normalEaveLines.length) @@ -737,7 +995,7 @@ export function useRoofAllocationSetting(id) { return !shouldRemove; }); // Combine remaining lines with newEaveLines - roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines]; + roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines]; } else { roofBase.lines = [...roofEaveHelpLines] } @@ -796,6 +1054,100 @@ export function useRoofAllocationSetting(id) { ) } + // [KERAB-VALLEY-OVERLAP 2026-05-29] vExt 기반 wallExt 4변 in-memory 재계산. + // case A(저장 X→케라바→할당) 에서 canvas 의 kerabValleyOverlapLine 가 silent 사라지는 문제 회피. + // vExt(kerabPatternValleyExt) 는 roof.innerLines 에 안정적으로 보존 — 거기서 직접 재계산. + // 조건: vExt 가 polygon 내부에 들어온 케이스 한정 (innerLines 에 있으면 자동 충족). + const vExtsForOverlap = (roofBase.innerLines || []).filter( + (l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false, + ) + for (const vExt of vExtsForOverlap) { + // [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] wallLineId 보장 fallback 체인. + // 1) vExt.attributes.wallLine (정상 케이스) + // 2) vExt.__targetId (useEavesGableEdit.js drawValleyExtensions 에서 백업) + // 3) vExt.parentLine.attributes.wallLine + // 4) vStart 좌표 매칭하는 roof.lines 변의 wallLine + let wallLineId = vExt.attributes?.wallLine + if (!wallLineId) wallLineId = vExt.__targetId + if (!wallLineId && vExt.parentLine) wallLineId = vExt.parentLine.attributes?.wallLine + if (!wallLineId) { + // vStart 가 끝점인 roof.lines 변에서 wallLine 추출 + const vStartP = { x: vExt.x1, y: vExt.y1 } + for (const rl of roofBase.lines || []) { + if (!rl?.attributes?.wallLine) continue + const m1 = Math.hypot(rl.x1 - vStartP.x, rl.y1 - vStartP.y) < 1 + const m2 = Math.hypot(rl.x2 - vStartP.x, rl.y2 - vStartP.y) < 1 + if (m1 || m2) { + wallLineId = rl.attributes.wallLine + break + } + } + } + if (!wallLineId) { + logger.warn('[KERAB-VALLEY-OVERLAP-RECALC] vExt skip — wallLineId 매칭 실패 (attributes/parent/좌표 모두)') + continue + } + let target = canvas.getObjects().find((o) => o.id === wallLineId && o.name === 'outerLine') + if (!target) { + const candidates = canvas.getObjects().filter((o) => o.id === wallLineId) + target = candidates[0] + if (!target) { + logger.warn('[KERAB-VALLEY-OVERLAP-RECALC] outerLine 못찾음 wallLineId=' + wallLineId?.slice(0, 8)) + continue + } + } + const vStart = { x: vExt.x1, y: vExt.y1 } + const vEnd = { x: vExt.x2, y: vExt.y2 } + const dxT = target.x2 - target.x1 + const dyT = target.y2 - target.y1 + const lenTSq = dxT * dxT + dyT * dyT + const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9) + const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT } + const offX = newWStart.x - vStart.x + const offY = newWStart.y - vStart.y + const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY } + const make = (p1, p2) => { + const lpts = [p1.x, p1.y, p2.x, p2.y] + const dx = lpts[2] - lpts[0] + const dy = lpts[3] - lpts[1] + const lsz = Math.round(Math.hypot(dx, dy) * 10) + const ln = new QLine(lpts, { + parentId: roofBase.id, + fontSize: roofBase.fontSize, + stroke: 'rgba(0,0,0,0)', + strokeWidth: 0, + name: 'valley', + visible: true, + opacity: 0, + attributes: { + roofId: roofBase.id, + type: 'kerabValleyOverlapLine', + isStart: true, + planeSize: lsz, + actualSize: lsz, + }, + }) + ln.lineName = 'kerabValleyOverlapLine' + ln.roofId = roofBase.id + return ln + } + // [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] float drift 회피 — vStart 와 가장 가까운 polygon corner 로 snap. + // 저장→로드 후엔 좌표 정수 round 로 일치하지만 저장 X 시 drift 가능 → split 알고리즘 외곽 인식 실패. + const snapToCorner = (p) => { + for (const rp of roofBase.points || []) { + const d = Math.hypot(rp.x - p.x, rp.y - p.y) + if (d < 1) return { x: rp.x, y: rp.y } + } + return p + } + const vStartSnap = snapToCorner(vStart) + const wallLn = make(newWStart, wEndProj) + const bsLn = make(vStartSnap, newWStart) + const beLn = make(vEnd, wEndProj) + roofBase.lines.push(wallLn, bsLn, beLn) + roofBase.innerLines.push(wallLn, bsLn, beLn) + } + if (roofBase.separatePolygon.length > 0) { splitPolygonWithSeparate(roofBase.separatePolygon) } else { @@ -807,6 +1159,12 @@ export function useRoofAllocationSetting(id) { const __newRoofs = canvas.getObjects().filter((o) => o.name === 'roof' && !o.isFixed) logger.log(`[KERAB-PATTERN-DIAG] 분할 후 새 sub-roof=${__newRoofs.length}`) } + + // [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지. + // 골짜기 출폭 띠 영역이 두 면 모두에 속하도록 (겹침 표현). + // 직사각형 식별: 외곽 라인의 attributes.type === 'kerabValleyOverlapLine' 가 다수인 sub-roof. + // 인접 두 sub-roof = 직사각형의 변 좌표를 공유하는 다른 sub-roof. + mergeValleyOverlapSubRoofs() } catch (e) { logger.log(e) canvas.discardActiveObject() @@ -818,6 +1176,18 @@ export function useRoofAllocationSetting(id) { canvas.remove(line) }) + // [KERAB-VALLEY-OVERLAP 2026-05-28] roofBase.lines 로 추가된 보조 라인(kerabValleyOverlapLine) 및 + // wallBase 변형 kerabPatternValleyExt(innerLines 미포함) 는 split 이후 더 이상 필요 없으므로 정리. + // 미정리 시 canvas 잔류 → sub-roof 위에 솔리드/점선으로 남는다. + const overlapLeftovers = canvas + .getObjects() + .filter( + (obj) => + (obj.lineName === 'kerabValleyOverlapLine' || obj.lineName === 'kerabPatternValleyExt') && + (obj.roofId === roofBase.id || obj?.attributes?.roofId === roofBase.id), + ) + overlapLeftovers.forEach((line) => canvas.remove(line)) + canvas.remove(roofBase) }) diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index 0a1de7c4..8e4eef8a 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -24,7 +24,7 @@ export function useCanvasEvent() { useEffect(() => { canvas?.setZoom(canvasZoom / 100) }, [canvasZoom]) - + // canvasZoom이 100으로 변경되고 shouldCenter가 true일 때만 중앙 정렬 실행 useEffect(() => { if (canvasZoom === 100 && canvas && shouldCenter) { @@ -278,27 +278,27 @@ export function useCanvasEvent() { switch (key) { case 'Delete': case 'Backspace': - handleDelete() + // handleDelete() break case 'Down': // IE/Edge에서 사용되는 값 case 'ArrowDown': // "아래 화살표" 키가 눌렸을 때의 동작입니다. - moveDown() + // moveDown() break case 'Up': // IE/Edge에서 사용되는 값 case 'ArrowUp': // "위 화살표" 키가 눌렸을 때의 동작입니다. - moveUp() + // moveUp() break case 'Left': // IE/Edge에서 사용되는 값 case 'ArrowLeft': // "왼쪽 화살표" 키가 눌렸을 때의 동작입니다. - moveLeft() + // moveLeft() break case 'Right': // IE/Edge에서 사용되는 값 case 'ArrowRight': // "오른쪽 화살표" 키가 눌렸을 때의 동작입니다. - moveRight() + // moveRight() break case 'Enter': // "enter" 또는 "return" 키가 눌렸을 때의 동작입니다. @@ -437,22 +437,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 @@ -473,50 +472,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/usePolygon.js b/src/hooks/usePolygon.js index 91596aab..5fefa914 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -9,6 +9,7 @@ import { fontSelector } from '@/store/fontAtom' import { QLine } from '@/components/fabric/QLine' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { useLine } from '@/hooks/useLine' +import { logger } from '@/util/logger' export const usePolygon = () => { const canvas = useRecoilValue(canvasState) @@ -744,6 +745,25 @@ export const usePolygon = () => { let innerLines = [...polygon.innerLines].filter((line) => line.visible) + // [SPLIT-ALLOC-DIAG 2026-06-01] 분할 graph 진입 전 innerLines 전수 dump. + // 케라바 패턴 라인(kerabPatternRidge/ExtRidge/Hip/ExtHip)과 원래 ridge(RG-2 등) 가 + // type='default' / isStart 미설정으로 graph 시드/유효점 후보에서 제외되는지 추적용. + logger.log(`[SPLIT-ALLOC-DIAG] polygon.id=${polygon.id?.slice(0, 8)} innerLines.visible=${innerLines.length} polygonLines=${polygon.lines?.length ?? 0}`) + innerLines.forEach((l, i) => { + logger.log( + ` [SPLIT-ALLOC-DIAG ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` + + `type=${l.attributes?.type ?? 'none'} isStart=${l.attributes?.isStart ?? false} ` + + `(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`, + ) + }) + ;(polygon.lines || []).forEach((l, i) => { + logger.log( + ` [SPLIT-ALLOC-DIAG outer ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` + + `type=${l.attributes?.type ?? 'none'} isStart=${l.attributes?.isStart ?? false} ` + + `(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`, + ) + }) + /*// innerLine이 세팅이 안되어있는경우 찾아서 세팅한다. if (!innerLines || innerLines.length === 0) { let innerLineTypes = Object.keys(LINE_TYPE.SUBLINE).map((key, value) => LINE_TYPE.SUBLINE[key]) 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 58f9a3ca..14c24fbc 100644 --- a/src/styles/_contents.scss +++ b/src/styles/_contents.scss @@ -17,1539 +17,1593 @@ // } // } // 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; - } - } + 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 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); + } + } } - .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; - } - } - } - .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); - } - } - } + &.active { + background-color: #383838; + button { + opacity: 1; + } } + } } - .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; - } - } - } + .canvas-side-btn-wrap { + display: flex; + align-items: center; + margin-left: auto; + .select-box { + width: 124px; + margin: 0 5px; + height: 30px; + > div { + width: 100%; } - &.active{ - top: 47px; - } - } - &.active{ - padding-bottom: 50px; - } -} - -// 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); - } - } -} -.canvas-layout{ - padding-top: 37px; - .canvas-page-list{ - position: fixed; - top: 92.8px; - left: 0; + } + .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; - } - } + &.form06 { + .name { + font-size: 13px; + } } - .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%; - } -} - -// 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; - } - } - } + 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; } - .sub-header-title{ - font-size: 16px; - color: #fff; - font-weight: 600; + 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-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; - } - } - } + &.on { + button { + background-color: #1083e3; + } } - } -} - -// 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-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;}} - } - } - } - } - .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; + width: 100px; + height: 30px; + margin: 0 5px; + span { + font-size: 13px; + color: #fff; + cursor: pointer; } - } -} -.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; + .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); + } } + } } - .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; + } + .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; - } - } - } -} - -.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; + align-items: center; + height: 100%; + .canvas-depth2-item { + display: flex; + align-items: center; + margin-right: 26px; + height: 100%; + 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; - } + padding-left: 10px; + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 1px; + height: 11px; + background-color: #d9d9d9; } - .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; + } + .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; + } + } } - &:hover{ - background-color: #F4F4F7; - } + } } -} - -.file-down-nodata{ + } + .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; - } - .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 index 480e11be..ed437bea 100644 --- a/src/util/kerab-offset-surgical.js +++ b/src/util/kerab-offset-surgical.js @@ -5,6 +5,7 @@ // 고객 회귀 검증을 위해 본 로직 전체를 useEavesGableEdit.js 에서 분리. // 호출 ON/OFF 는 useEavesGableEdit.js 상단 `ENABLE_KERAB_OFFSET_SURGICAL` 상수로 토글한다. +import { fabric } from 'fabric' import { POLYGON_TYPE } from '@/common/common' import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { logger } from '@/util/logger' @@ -104,35 +105,169 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { const nextSize = calcLinePlaneSize({ x1: nextRL.x1, y1: nextRL.y1, x2: nextRL.x2, y2: nextRL.y2 }) nextRL.attributes = { ...nextRL.attributes, planeSize: nextSize, actualSize: nextSize } - // 옛 corner 좌표에 닿아있던 inner line(hip/ridge/valley/케라바 ext) 끝점을 새 corner 로 snap. - const CORNER_SNAP_TOL = 0.5 - for (const il of roof.innerLines || []) { - if (!il) continue - if (Math.hypot(il.x1 - oldCorner1.x, il.y1 - oldCorner1.y) <= CORNER_SNAP_TOL) { - il.set({ x1: newCorner1.x, y1: newCorner1.y }) + // [KERAB-OFFSET-SHRINK-TRIM 2026-06-01] 도메인 룰: + // - 기본: skLine + ext 원본 좌표 보존. + // - 원본 끝점이 새 roofLine 변 바깥(wall 쪽)에 있으면 새 roofLine 변과의 교점으로 절삭. + // - 원본 끝점이 새 roofLine 변 안쪽으로 복귀하면 원본 좌표로 복원 (출폭 다시 늘린 케이스). + // 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제. + // 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent. + { + const newAxisMid = { x: (newCorner1.x + newCorner2.x) / 2, y: (newCorner1.y + newCorner2.y) / 2 } + const OUTSIDE_TOL = 0.5 + const segOk = (ip) => { + const minX = Math.min(newCorner1.x, newCorner2.x) - OUTSIDE_TOL + const maxX = Math.max(newCorner1.x, newCorner2.x) + OUTSIDE_TOL + const minY = Math.min(newCorner1.y, newCorner2.y) - OUTSIDE_TOL + const maxY = Math.max(newCorner1.y, newCorner2.y) + OUTSIDE_TOL + return ip.x >= minX && ip.x <= maxX && ip.y >= minY && ip.y <= maxY } - if (Math.hypot(il.x2 - oldCorner1.x, il.y2 - oldCorner1.y) <= CORNER_SNAP_TOL) { - il.set({ x2: newCorner1.x, y2: newCorner1.y }) + // [KERAB-PATTERN-CORNER-SNAP 2026-06-01] 케라바 패턴 라인(kLine/ExtRidge/Hip/ExtHip)은 + // roofLine corner 변경에 따라 끝점도 같이 이동. corner 일치뿐 아니라 옛 segment 위의 + // 중간 점(예: kLine 끝점이 옛 roofLine 변 위)도 새 segment 의 동일 t 비율 점으로 매핑. + const isKerabPatternLine = (il) => + il && + (il.lineName === 'kerabPatternRidge' || + il.lineName === 'kerabPatternExtRidge' || + il.lineName === 'kerabPatternHip' || + il.lineName === 'kerabPatternExtHip') + const oldSegDx = oldCorner2.x - oldCorner1.x + const oldSegDy = oldCorner2.y - oldCorner1.y + const oldSegLen2 = oldSegDx * oldSegDx + oldSegDy * oldSegDy || 1 + const newSegDx = newCorner2.x - newCorner1.x + const newSegDy = newCorner2.y - newCorner1.y + const mapToNewSeg = (pt) => { + const dx = pt.x - oldCorner1.x + const dy = pt.y - oldCorner1.y + const t = (dx * oldSegDx + dy * oldSegDy) / oldSegLen2 + const projX = oldCorner1.x + t * oldSegDx + const projY = oldCorner1.y + t * oldSegDy + const perpD = Math.hypot(pt.x - projX, pt.y - projY) + if (perpD > 0.5) return null + if (t < -0.05 || t > 1.05) return null + return { x: newCorner1.x + t * newSegDx, y: newCorner1.y + t * newSegDy } } - if (Math.hypot(il.x1 - oldCorner2.x, il.y1 - oldCorner2.y) <= CORNER_SNAP_TOL) { - il.set({ x1: newCorner2.x, y1: newCorner2.y }) - } - if (Math.hypot(il.x2 - oldCorner2.x, il.y2 - oldCorner2.y) <= CORNER_SNAP_TOL) { - il.set({ x2: newCorner2.x, y2: newCorner2.y }) + for (const il of roof.innerLines || []) { + if (!il) continue + + // 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점. + // 절삭/복원 흐름 skip. + if (isKerabPatternLine(il)) { + const np1 = mapToNewSeg({ x: il.x1, y: il.y1 }) + if (np1) il.set({ x1: np1.x, y1: np1.y }) + const np2 = mapToNewSeg({ x: il.x2, y: il.y2 }) + if (np2) il.set({ x2: np2.x, y2: np2.y }) + if (typeof il.setCoords === 'function') il.setCoords() + if (np1 || np2) { + logger.log( + '[KERAB-PATTERN-CORNER-SNAP] mapped ' + + JSON.stringify({ lineName: il.lineName, np1, np2, newPts: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } }), + ) + } + continue + } + + const orig = il.__shrinkOrig || { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } + const orig1 = { x: orig.x1, y: orig.y1 } + const orig2 = { x: orig.x2, y: orig.y2 } + const d1 = (orig1.x - newAxisMid.x) * nx + (orig1.y - newAxisMid.y) * ny + const d2 = (orig2.x - newAxisMid.x) * nx + (orig2.y - newAxisMid.y) * ny + + // 양 끝 모두 새 polygon 안쪽 → 원본 복원 + 백업 삭제 + if (d1 <= OUTSIDE_TOL && d2 <= OUTSIDE_TOL) { + if (il.__shrinkOrig) { + il.set({ x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }) + if (typeof il.setCoords === 'function') il.setCoords() + logger.log( + '[KERAB-OFFSET-SHRINK-TRIM] restored ' + + JSON.stringify({ lineName: il.lineName, x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }), + ) + delete il.__shrinkOrig + } + continue + } + + // 바깥 끝점 절삭 (원본 segment 와 새 roofLine 변의 교점) + let nx1 = orig.x1 + let ny1 = orig.y1 + let nx2 = orig.x2 + let ny2 = orig.y2 + let trimmed = false + if (d1 > OUTSIDE_TOL) { + const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2) + if (ip && segOk(ip)) { + nx1 = ip.x + ny1 = ip.y + trimmed = true + } + } + if (d2 > OUTSIDE_TOL) { + const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2) + if (ip && segOk(ip)) { + nx2 = ip.x + ny2 = ip.y + trimmed = true + } + } + if (!trimmed) continue + if (!il.__shrinkOrig) { + il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } + } + il.set({ x1: nx1, y1: ny1, x2: nx2, y2: ny2 }) + if (typeof il.setCoords === 'function') il.setCoords() + logger.log( + '[KERAB-OFFSET-SHRINK-TRIM] trimmed ' + + JSON.stringify({ lineName: il.lineName, orig, newPts: { x1: nx1, y1: ny1, x2: nx2, y2: ny2 } }), + ) } } // points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작. + // [KERAB-OFFSET-SURGICAL 2026-05-29] 출폭 증가 시 새 corner 가 polygon bbox 밖에 있으면 + // 외곽선이 안 그려짐. _setPositionDimensions 로 width/height/pathOffset 재계산 + 앵커 + // 절대좌표 보존(setPositionByOrigin) 으로 polygon path 가 새 영역까지 다시 그려지게 강제. const newPoints = roof.points.map((p, i) => { if (i === idx) return { x: newCorner1.x, y: newCorner1.y } if (i === (idx + 1) % N) return { x: newCorner2.x, y: newCorner2.y } return { x: p.x, y: p.y } }) + let absolutePoint = null + let anchorIdx = 0 + // 변경 대상이 아닌 첫 인덱스를 앵커로 — pathOffset 갱신 후 그 점 절대 좌표 보존. + for (let i = 0; i < roof.points.length; i++) { + if (i !== idx && i !== (idx + 1) % N) { + anchorIdx = i + break + } + } + try { + if (typeof roof.calcTransformMatrix === 'function' && roof.pathOffset) { + const oldLocal = { + x: roof.points[anchorIdx].x - roof.pathOffset.x, + y: roof.points[anchorIdx].y - roof.pathOffset.y, + } + absolutePoint = fabric.util.transformPoint(oldLocal, roof.calcTransformMatrix()) + } + } catch (e) { + absolutePoint = null + } + roof.points = newPoints roof.set({ points: newPoints, dirty: true }) + if (typeof roof._setPositionDimensions === 'function') roof._setPositionDimensions({}) + if (absolutePoint && typeof roof.setPositionByOrigin === 'function') { + const strokeW = roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleX || 1, 1e-9) : roof.strokeWidth + const baseW = (roof.width || 0) + strokeW + const baseH = (roof.height || 0) + (roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleY || 1, 1e-9) : roof.strokeWidth) + const newX = (roof.points[anchorIdx].x - roof.pathOffset.x) / Math.max(baseW, 1e-9) + const newY = (roof.points[anchorIdx].y - roof.pathOffset.y) / Math.max(baseH, 1e-9) + roof.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5) + } if (typeof roof.setCoords === 'function') roof.setCoords() // canvas 에 add 된 동일 wallLine 매칭의 eaves(외곽 roofLine) fabric 객체도 같이 갱신. + // [KERAB-OFFSET-SURGICAL 2026-05-29] outerLine 은 wall 좌표(출폭 0 기준) 유지가 원칙. + // corner 좌표(=wall + offset*normal) 로 set 하면 출폭 증가 시 외곽 처마라인이 통째로 이동해 + // 화면상 roofLine 이 안 그려진 듯 보임. outerLine 은 attributes 만 갱신, 좌표는 보존. const canvasEdgeObjs = canvas .getObjects() .filter( @@ -142,7 +277,9 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { o.attributes?.wallLine === target.id, ) for (const eo of canvasEdgeObjs) { - eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true }) + if (eo.name !== 'outerLine') { + eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true }) + } eo.attributes = { ...eo.attributes, offset: newOffset, planeSize: newSize, actualSize: newSize } if (typeof eo.setCoords === 'function') eo.setCoords() } 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 })