Compare commits

...

112 Commits

Author SHA1 Message Date
271b8550d5 Merge pull request 'dev' (#872) from dev into dev-deploy
Reviewed-on: #872
2026-05-29 16:09:40 +09:00
b8ea9aba78 Merge branch 'dev' of https://git.hanasys.jp/qcast3/qcast-front into hotfix/dev_yscha 2026-05-29 16:07:22 +09:00
e32d2dc3a5 [2173] 케라바 vExt — stop·V apex trim·overlap 머지·offset bbox 2026-05-29 16:02:51 +09:00
577ca47b5e 키보드 이벤트 제거 2026-05-28 16:48:36 +09:00
23dafe3b18 [2173] valleyExt — trim·cascade·ridge-stop 다 비활성 (확장만, 별도 룰 후속) 2026-05-28 13:51:07 +09:00
036c283e0c [2173] valleyExt Phase 1/2 helper 추출 + wallBase 독립 ray + ridge-stop 룰 2026-05-28 11:10:59 +09:00
sangwook yoo
f8641079e6 Disable PDF input mode option 2026-05-27 17:54:49 +09:00
sangwook yoo
dba2b16277 Merge remote-tracking branch 'origin/dev' into dev 2026-05-27 17:30:47 +09:00
sangwook yoo
379216a78a feat(btob): Add BtoB development server startup script
- Adds a new `start:btob` script to `package.json` for launching the Next.js application on port 5060 for the BtoB environment.
- Introduces `startscript-btob.js` as a convenience executable to run the `start:btob` command.
2026-05-27 17:05:11 +09:00
sangwook yoo
6a99b6de4a Merge remote-tracking branch 'origin/dev' into feature/pv-btob 2026-05-27 16:56:33 +09:00
8be7b03072 Merge pull request 'dev' (#871) from dev into prd-deploy
Reviewed-on: #871
2026-05-27 16:21:54 +09:00
sangwook yoo
79aafbd613 Merge remote-tracking branch 'origin/dev' into claude/lucid-shaw-91e51f 2026-05-27 14:41:26 +09:00
sangwook yoo
f8467879a8 Merge remote-tracking branch 'origin/dev' into claude/lucid-shaw-91e51f 2026-05-20 09:41:37 +09:00
sangwook yoo
849f7a1dc9 feat(pdf-gemini): 배치면 초기설정에 PDF 도면 업로드 옵션 추가 — Gemini 외곽선 분석
- 「배치면 초기설정」 모달 寸法入力方法 라디오에 '도면 파일 업로드' 옵션 추가
- 선택 시 PDF 파일 업로드 + 읽기 페이지 설정(전체/입면도·평면도 지정) UI 노출
- 保存 클릭 시 /api/gemini/floor-plan 호출 → 외곽선을 캔버스에 반영한 후 기존 저장 흐름 진행
- Gemini File API 업로드 + finally 파일 폐기, 정점/면적 검증, 페이지 힌트 프롬프트 주입
- usePdfImport 훅: mm→canvas 좌표 변환, CCW 정규화, 기존 외곽선 덮어쓰기 confirm 후 QLine/QPolygon 생성
- @google/generative-ai 의존성 추가, GEMINI_MODEL env 4개 환경에 동기화
- 다국어(ja/ko) 메시지 추가, .playwright-mcp/ gitignore

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:39:41 +09:00
d48c94c99e Merge pull request 'dev' (#868) from dev into prd-deploy
Reviewed-on: #868
2026-05-20 09:02:59 +09:00
sangwook yoo
c1247a95db Track docs markdown files 2026-05-19 16:20:48 +09:00
ce84468f7c Merge pull request 'dev' (#865) from dev into prd-deploy
Reviewed-on: #865
2026-05-18 17:00:30 +09:00
sangwook yoo
530832e78d Merge remote-tracking branch 'origin/dev' into claude/lucid-shaw-91e51f 2026-05-18 16:32:47 +09:00
sangwook yoo
51e6057c34 chore(codex): Add PreToolUse hook for graphify search context
Integrates a PreToolUse hook that provides context from the 'graphify' knowledge graph.
When the AI is about to use search commands (e.g., `grep`, `find`, `ripgrep`),
it will be prompted to consult `GRAPH_REPORT.md` if a graph exists, guiding it to
leverage structured knowledge before searching raw files.
2026-05-18 16:15:12 +09:00
sangwook yoo
e381ae0ebf chore(pdf-gemini): Gemini API 키 env 추가 + PDF 평면도 분석 구현 계획 문서
- .env.development / .env.production 에 GEMINI_API_KEY 추가 (PDF 평면도 분석 기능용)
- docs/ 에 PDF → Gemini → 캔버스 반영 기능 구현 계획서(html) 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:05:18 +09:00
8e7a6af3db 소스정리 2026-05-15 12:31:24 +09:00
3aa61a25c4 Merge pull request '[1423] 設計依頼 import 매핑 정책 롤백 — StuffDetail.jsx 7e9ec4e3 복원' (#857) from dev into prd-deploy
Reviewed-on: #857
2026-05-15 10:05:39 +09:00
b58af58b79 Merge pull request 'dev' (#855) from dev into prd-deploy
Reviewed-on: #855
2026-05-14 17:46:14 +09:00
f9fe9f788e Merge pull request 'dev' (#850) from dev into prd-deploy
Reviewed-on: #850
2026-05-14 14:56:30 +09:00
518b51d33f Merge pull request '[2218] 초기 비밀번호 설정 메시지 수정' (#847) from dev into prd-deploy
Reviewed-on: #847
2026-05-14 14:35:16 +09:00
aad21d6c76 Merge pull request '[2218] 초기 비밀번호 설정 메시지 수정' (#845) from dev into prd-deploy
Reviewed-on: #845
2026-05-14 13:23:51 +09:00
7ad7f3c6ac Merge pull request '[1961] split 길이 계산 Math.round(hypot)*10 → calcLinePlaneSize 통일 — 좌우대칭 hip 라벨 1mm 갈림 차단' (#843) from dev into prd-deploy
Reviewed-on: #843
2026-05-14 10:42:27 +09:00
73bc78fc7e Merge pull request '[VALLEY-SKIP] isValleyVertex 노치 top_in 잘못된 eaveHelpLine 4개 차단 — zero-length neighbor + collinear continuation 2단 가드' (#841) from dev into prd-deploy
Reviewed-on: #841
2026-05-13 16:45:05 +09:00
70f0345fb5 Merge pull request '[2210] 초기 비밀번호 설정 메시지 수정' (#839) from dev into prd-deploy
Reviewed-on: #839
2026-05-13 16:40:48 +09:00
2eee9edbc5 Merge pull request 'dev' (#837) from dev into prd-deploy
Reviewed-on: #837
2026-05-13 14:37:44 +09:00
6d4c832a54 Merge pull request 'dev' (#835) from dev into prd-deploy
Reviewed-on: #835
2026-05-13 12:21:46 +09:00
7c37cca3c7 Merge pull request '[1956-RACK-TILT] 빨강 점선(가대선/モジュール配置領域) 가로/세로 축 스냅' (#832) from dev into prd-deploy
Reviewed-on: #832
2026-05-13 10:44:19 +09:00
4ea6e77151 Merge pull request '[1961] strict y1===y2 drift 2곳 수정 — tolerance 0.5 통일' (#830) from dev into prd-deploy
Reviewed-on: #830
2026-05-12 17:27:30 +09:00
11d732e117 Merge pull request 'dev' (#828) from dev into prd-deploy
Reviewed-on: #828
2026-05-08 17:56:36 +09:00
6f22ca7f3d Merge pull request 'dev' (#826) from dev into prd-deploy
Reviewed-on: #826
2026-05-08 08:53:39 +09:00
4e78429da8 [견적서] 1차 판매점명 행 노출 조건 보정 — storeLvl === '1' 일 때만 표시 2026-05-07 14:41:37 +09:00
16f595daff Merge pull request 'cluade' (#821) from dev into prd-deploy
Reviewed-on: #821
2026-05-06 18:12:14 +09:00
6338fb3011 Merge pull request 'dev' (#819) from dev into prd-deploy
Reviewed-on: #819
2026-05-06 17:50:17 +09:00
78ccf3339e Merge pull request '補助線サイズ変更/移動/コピー 모달에 계산기패드 토글 버튼 추가' (#815) from dev into prd-deploy
Reviewed-on: #815
2026-05-04 14:16:11 +09:00
efca023f1d Merge pull request '동시 UPDATE 데드락 방어 — abort+latest, X-Idempotency-Key, 직렬화 - api 통신에러' (#813) from dev into prd-deploy
Reviewed-on: #813
2026-05-04 13:55:18 +09:00
f5beab6bd4 Merge pull request '라벨변경' (#811) from dev into prd-deploy
Reviewed-on: #811
2026-04-30 18:27:52 +09:00
c864e69b2b Merge pull request 'innerLine' (#809) from dev into prd-deploy
Reviewed-on: #809
2026-04-30 17:03:13 +09:00
be809e5891 Merge pull request 'dev' (#807) from dev into prd-deploy
Reviewed-on: #807
2026-04-30 13:09:08 +09:00
a04e7c6a1b Merge pull request '마루이동' (#805) from dev into prd-deploy
Reviewed-on: #805
2026-04-29 18:32:04 +09:00
1428de4b3c Merge pull request 'dev' (#803) from dev into prd-deploy
Reviewed-on: #803
2026-04-29 18:17:39 +09:00
75c2d16b05 Merge pull request '1961, 1969, 2118 수정' (#801) from dev into prd-deploy
Reviewed-on: #801
2026-04-29 18:04:28 +09:00
df712657c3 Merge pull request 'dev' (#799) from dev into prd-deploy
Reviewed-on: #799
2026-04-28 18:07:08 +09:00
0b27a161db 평면도 확정 시 확인 다이얼로그 추가 (버튼/Enter 키) 2026-04-27 17:49:55 +09:00
6c0145ef30 외벽선 확정 시 확인 다이얼로그 추가 (버튼/Enter 키) 2026-04-27 17:49:55 +09:00
d9bea9dcfb 잔여선 삭제 및 라벨표시(local) 2026-04-27 15:15:56 +09:00
21c9f85a51 라인줄표시, 인덱스 재설치 2026-04-27 13:49:48 +09:00
aab8ebbb05 빌드 오류 수정: debugLabels import 제거
dev-deploy 빌드에서 untracked 파일(@/util/debugLabels) 참조로 실패.
디버그 전용 라벨 부착 호출은 로컬 한정이므로 커밋 대상에서 제거.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 13:49:48 +09:00
d810b89658 라인줄표시, 인덱스 재설치 2026-04-27 13:49:48 +09:00
0b0a1786da 라인오버하면 평행라인으로 변경 2026-04-27 13:49:48 +09:00
936d490803 [1806]서까래 높이 조정에 따른 지붕 형상 불일치 - 라인오버체크2 2026-04-23 15:26:58 +09:00
b8db935518 보조라인 맞춤 2026-04-23 12:35:14 +09:00
3d142cd95c sk라인 기록 보존 2026-04-23 12:35:14 +09:00
a3e608f059 Cross Mix(DC3) 접속함을 PCS 담당 모듈 타입으로 필터링해 1건만 내려가도록 통합 로직 적용 2026-04-23 12:35:14 +09:00
558769063b PassivityCircuitAllocation에 전달하던 미사용 ref prop 제거
passivityCircuitAllocationRef (useRef) 선언 제거
원인: function component에 ref 전달 시 React warning 발생 (forwardRef 미사용)
PCS 접속함 응답에 추가된 moduleTpCd 필드를 Cross Mix 식별을 위해 프론트 상태 보존 및 getQuotationItem 제출 파라미터까지 흘려보내고, dedupe 키를 (itemId, moduleTpCd) 조합으로 변경.
2026-04-23 12:35:13 +09:00
eaf6bcb3d3 trestles[]에 subModuleTpCd 추가 (Cross Mix 식별용, 단일/동계열은 빈 값) 2026-04-23 12:35:13 +09:00
8493fb037b [1806]서까래 높이 조정에 따른 지붕 형상 불일치 - 라인오버체크 2026-04-23 12:34:10 +09:00
a4d3f7a4c9 sk라인 기록 보존 2026-04-22 16:41:55 +09:00
c872d5c0f6 [1960]출력된 [배치도·계통도]에 가대가 표시되는 경우 2026-04-17 18:07:28 +09:00
8d6c4a6504 [1806]서까래 높이 조정에 따른 지붕 형상 불일치 - 라인오버체크 2026-04-16 13:22:43 +09:00
1028d210ad 회로 설정 이상 수정 2026-04-16 13:22:43 +09:00
0d724a2ed1 Merge pull request '로그 삭제' (#782) from dev into prd-deploy
Reviewed-on: #782
2026-04-15 17:53:23 +09:00
e2ad711244 Merge pull request '외벽선 편집 및 오프셋 길이 계산 수정' (#780) from dev into prd-deploy
Reviewed-on: #780
2026-04-14 15:05:18 +09:00
af3ec6e31b Merge pull request '[1491]실내 집중형 PCS를 사용하는 회로 구성에서 접속함이 정상적으로 산출되지 않는 현상' (#778) from dev into prd-deploy
Reviewed-on: #778
2026-04-13 16:37:31 +09:00
c40d67aa34 Merge pull request 'dev' (#776) from dev into prd-deploy
Reviewed-on: #776
2026-04-13 11:07:05 +09:00
50f2667fac Merge pull request '[1971] 외벽선 작성 방향 “반시계 방향” 표시 삭제' (#774) from dev into prd-deploy
Reviewed-on: #774
2026-04-13 10:34:35 +09:00
59649c8fb7 Merge pull request '지붕 이동 시 보조선 길이 안따라 가는 현상 수정' (#772) from dev into prd-deploy
Reviewed-on: #772
2026-04-13 09:08:37 +09:00
afd00b642c Merge pull request 'dev' (#770) from dev into prd-deploy
Reviewed-on: #770
2026-04-09 18:13:53 +09:00
1e087d33c5 Merge pull request '움직인라인그대로유지offset' (#766) from dev into prd-deploy
Reviewed-on: #766
2026-04-08 16:59:41 +09:00
1935baf471 Merge pull request 'dev' (#764) from dev into prd-deploy
Reviewed-on: #764
2026-04-08 16:24:27 +09:00
e3aae27cc4 Merge pull request 'dev 관련 링크수정2' (#762) from dev into prd-deploy
Reviewed-on: #762
2026-04-08 12:52:21 +09:00
680ea847e9 Merge pull request 'dev' (#760) from dev into prd-deploy
Reviewed-on: #760
2026-04-08 12:38:54 +09:00
d341b4a52d Merge pull request 'dev' (#758) from dev into prd-deploy
Reviewed-on: #758
2026-04-08 10:22:10 +09:00
b7ba6d8fc1 [1480]사의 각도 설정을 "각도"로 했을 때 발생하는 문제 2026-04-03 14:28:00 +09:00
e0a4da21b8 [1480]사의 각도 설정을 "각도"로 했을 때 발생하는 문제 2026-04-03 14:28:00 +09:00
67267170ae Merge pull request '[1480]사의 각도 설정을 "각도"로 했을 때 발생하는 문제' (#750) from dev into prd-deploy
Reviewed-on: #750
2026-04-02 10:30:11 +09:00
76e508869c Merge pull request 'dev' (#748) from dev into prd-deploy
Reviewed-on: #748
2026-04-01 18:38:34 +09:00
e04ff156d6 Revert "수동 배치 시 자동 물떼세 배치 한다로 수정"
This reverts commit b1c35376da012c1b685f7b58a0b14b3ffddf5425.
2026-04-01 17:54:49 +09:00
9d491bd8aa 1830 모듈 설치영역 대각 있는 현상 수정 2026-04-01 17:54:49 +09:00
735fb360be Merge pull request 'dev' (#744) from dev into prd-deploy
Reviewed-on: #744
2026-04-01 13:13:43 +09:00
0483f7487a Merge pull request '[1480]사의 각도 설정을 "각도"로 했을 때 발생하는 문제2' (#740) from dev into prd-deploy
Reviewed-on: #740
2026-03-30 18:50:11 +09:00
66e1127ed0 Merge pull request '[1480]사의 각도 설정을 "각도"로 했을 때 발생하는 문제' (#738) from dev into prd-deploy
Reviewed-on: #738
2026-03-30 18:35:56 +09:00
fa1ff11690 Merge pull request 'dev' (#736) from dev into prd-deploy
Reviewed-on: #736
2026-03-30 16:38:57 +09:00
2a09afc791 Merge pull request 'dev' (#734) from dev into prd-deploy
Reviewed-on: #734
2026-03-27 16:24:45 +09:00
66d05f4f30 Merge pull request 'dev' (#732) from dev into prd-deploy
Reviewed-on: #732
2026-03-27 09:48:25 +09:00
048c30639c Merge pull request 'dev' (#729) from dev into prd-deploy
Reviewed-on: #729
2026-03-26 10:39:49 +09:00
111ce5e16c Merge pull request 'dev' (#727) from dev into prd-deploy
Reviewed-on: #727
2026-03-25 18:24:52 +09:00
56b4ee9563 [1766]방위 변경 체크박스를 넣었을 때 견적서 표기 방법에 대해(1) 2026-03-20 18:09:50 +09:00
10d9079ca5 처마커버 설치이상 현상 수정 2026-03-20 18:04:27 +09:00
8d1e661cbb Merge pull request '[1751] 기와 형태에서 처마 돌출 치수를 변경했을 때의 동작' (#724) from dev into prd-deploy
Reviewed-on: #724
2026-03-19 13:39:08 +09:00
a701f3c066 Merge pull request 'dev' (#722) from dev into prd-deploy
Reviewed-on: #722
2026-03-18 16:50:15 +09:00
6fb7caa357 Merge pull request 'dev' (#720) from dev into prd-deploy
Reviewed-on: #720
2026-03-18 16:30:00 +09:00
1fa6340d88 Merge pull request '[카톡]견적서 1차점 숨김 - 2차점 로그인' (#718) from dev into prd-deploy
Reviewed-on: #718
2026-03-18 14:32:19 +09:00
a47f96a52e Merge pull request '1525 면형상배치 치수 표기 이상' (#716) from dev into prd-deploy
Reviewed-on: #716
2026-03-18 13:24:14 +09:00
617c8ef24e Merge pull request '[1482]도면에서 흐름 방향이 좌우로 설정된 경우 표시가 일부 잘리는 현상' (#714) from dev into prd-deploy
Reviewed-on: #714
2026-03-17 09:27:31 +09:00
bbcea68c36 Merge pull request '[1482]플랜을 복사했을 때, 출력된 견적서에 [하제피치]가 표시되는 경우가 있다' (#712) from dev into prd-deploy
Reviewed-on: #712
2026-03-16 18:25:18 +09:00
431b7216c5 Merge pull request 'dev' (#709) from dev into prd-deploy
Reviewed-on: #709
2026-03-12 17:12:56 +09:00
32aa8f9b32 Merge pull request '[1470] 배치도·계통도 및 구조도의 화질 개선 cell 이미지 추가' (#707) from dev into prd-deploy
Reviewed-on: #707
2026-03-11 18:27:46 +09:00
2bad7e313a Merge pull request 'dev' (#705) from dev into prd-deploy
Reviewed-on: #705
2026-03-10 15:08:27 +09:00
aef0a41243 Merge pull request 'dev' (#703) from dev into prd-deploy
Reviewed-on: #703
2026-03-09 15:38:35 +09:00
e24c9e9814 Merge pull request 'dev' (#700) from dev into prd-deploy
Reviewed-on: #700
2026-03-09 14:28:53 +09:00
6f5513a5e1 Merge pull request 'dev' (#696) from dev into prd-deploy
Reviewed-on: #696
2026-03-09 14:24:01 +09:00
d30d92df0b Merge pull request 'dev' (#693) from dev into prd-deploy
Reviewed-on: #693
2026-03-09 13:38:14 +09:00
abc61b2a0c Merge pull request 'dev' (#691) from dev into prd-deploy
Reviewed-on: #691
2026-03-06 18:44:11 +09:00
bea4d8ac3c Merge pull request 'dev' (#688) from dev into prd-deploy
Reviewed-on: #688
2026-03-04 18:25:38 +09:00
4b136ea2ed Merge pull request '삭제' (#684) from dev into prd-deploy
Reviewed-on: #684
2026-03-03 21:00:39 +09:00
b7c84d677b Merge pull request '삭제' (#682) from dev into prd-deploy
Reviewed-on: #682
2026-03-03 20:25:34 +09:00
bd6de977bd Merge pull request '!!!' (#679) from dev into prd-deploy
Reviewed-on: #679
2026-03-03 19:55:28 +09:00
21 changed files with 4403 additions and 1727 deletions

15
.codex/hooks.json Normal file
View File

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

View File

@ -33,4 +33,8 @@ NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp"
S3_PROFILE="dev"
#logging
NEXT_PUBLIC_ENABLE_LOGGING=true
NEXT_PUBLIC_ENABLE_LOGGING=true
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

View File

@ -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"
S3_PROFILE="dev"
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

View File

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

View File

@ -32,4 +32,8 @@ NEXT_PUBLIC_AWS_S3_BASE_URL="//files.hanasys.jp"
S3_PROFILE="prd"
#logging
NEXT_PUBLIC_ENABLE_LOGGING=false
NEXT_PUBLIC_ENABLE_LOGGING=false
#Gemini API Key
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
GEMINI_MODEL="gemini-3.1-pro-preview"

6
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import { getChonByDegree } from '@/util/canvas-util'
import { settingModalFirstOptionsState } from '@/store/settingAtom'
import { fabric } from 'fabric'
import { QLine } from '@/components/fabric/QLine'
import { reattachDebugLabels } from '@/components/fabric/QPolygon'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { findInteriorPoint } from '@/util/skeleton-utils'
import { logger } from '@/util/logger'
@ -151,6 +152,11 @@ export function useEavesGableEdit(id) {
}
const mouseDownEvent = (e) => {
// [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시
// discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호.
if (!e.target || e.target.name !== 'outerLine') {
return
}
logger.log(
'[KERAB-MOUSEDOWN] fired ' +
JSON.stringify({
@ -166,9 +172,6 @@ export function useEavesGableEdit(id) {
}),
)
canvas.discardActiveObject()
if (!e.target || (e.target && e.target.name !== 'outerLine')) {
return
}
const target = e.target
@ -277,6 +280,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 +639,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 +656,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 +1488,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,209 +1507,88 @@ 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):
@ -1680,14 +1623,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 +2055,7 @@ export function useEavesGableEdit(id) {
null,
null,
)
dumpInnerLineSnapshot('AFTER')
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
return
}
@ -2268,8 +2619,11 @@ export function useEavesGableEdit(id) {
// [KERAB-VALLEY-EXT 2026-05-27] forward 에서 추가됐던 처마확장(kerabPatternValleyExt) 제거.
// __targetId === target.id 매칭으로 해당 target 의 처마확장만 정확히 정리. 모든 패턴 분기 공통.
// roofBase 확장은 roof.innerLines 에 있고 wallBase 확장은 canvas 에만 있으므로 canvas 전체 스캔.
// [KERAB-VALLEY-OVERLAP 2026-05-28] wallBase 측은 lineName='kerabValleyOverlapLine' (본체 + 90도 보조선 2개).
// b polygon 의 lines[] 에는 apply() 단계에서 들어가므로, 여기서는 canvas 객체와 roof.innerLines 만 정리.
// b 의 lines[] 에서 제거하는 책임은 apply() 다음 사이클에서 자동 (canvas 에서 사라지므로).
const valleyExtsToRemove = (canvas.getObjects() || []).filter(
(il) => il && il.lineName === 'kerabPatternValleyExt' && il.__targetId === target.id,
(il) => il && (il.lineName === 'kerabPatternValleyExt' || il.lineName === 'kerabValleyOverlapLine') && il.__targetId === target.id,
)
for (const v of valleyExtsToRemove) {
removeLine(v)

View File

@ -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?.()
}
/**
* 지붕면 할당
*/
@ -666,8 +912,10 @@ export function useRoofAllocationSetting(id) {
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 +931,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 +987,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]
}
@ -807,6 +1057,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 +1074,18 @@ export function useRoofAllocationSetting(id) {
canvas.remove(line)
})
// [KERAB-VALLEY-OVERLAP 2026-05-28] roofBase.lines 로 추가된 보조 라인(kerabValleyOverlapLine) 및
// wallBase 변형 kerabPatternValleyExt(innerLines 미포함) 는 split 이후 더 이상 필요 없으므로 정리.
// 미정리 시 canvas 잔류 → sub-roof 위에 솔리드/점선으로 남는다.
const overlapLeftovers = canvas
.getObjects()
.filter(
(obj) =>
(obj.lineName === 'kerabValleyOverlapLine' || obj.lineName === 'kerabPatternValleyExt') &&
(obj.roofId === roofBase.id || obj?.attributes?.roofId === roofBase.id),
)
overlapLeftovers.forEach((line) => canvas.remove(line))
canvas.remove(roofBase)
})

View File

@ -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()
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
// 고객 회귀 검증을 위해 본 로직 전체를 useEavesGableEdit.js 에서 분리.
// 호출 ON/OFF 는 useEavesGableEdit.js 상단 `ENABLE_KERAB_OFFSET_SURGICAL` 상수로 토글한다.
import { fabric } from 'fabric'
import { POLYGON_TYPE } from '@/common/common'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { logger } from '@/util/logger'
@ -123,16 +124,52 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
}
// 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 +179,9 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => {
o.attributes?.wallLine === target.id,
)
for (const eo of canvasEdgeObjs) {
eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true })
if (eo.name !== 'outerLine') {
eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true })
}
eo.attributes = { ...eo.attributes, offset: newOffset, planeSize: newSize, actualSize: newSize }
if (typeof eo.setCoords === 'function') eo.setCoords()
}

2
startscript-btob.js Normal file
View File

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