dev #931
@ -1,7 +1,7 @@
|
||||
# 용마루(skeleton) 경로 외곽 지붕선 편집 기능 — 설계
|
||||
|
||||
- 작성일: 2026-06-01
|
||||
- 대상 브랜치: `feature/redo-undo`
|
||||
- 대상 브랜치: `feature/roofline-edit`
|
||||
- 관련 파일: `src/util/skeleton-utils.js`, `src/util/qpolygon-utils.js`, `src/hooks/useContextMenu.js`
|
||||
|
||||
## 1. 배경 / 문제
|
||||
@ -66,13 +66,13 @@ splitRoofLinesToInnerLines(roof, canvas, textMode) → QLine[]
|
||||
- **`drawRoofByAttribute` 교체**: 인라인 블록을 함수 호출로 교체. 순수 추출이므로 변별 경로 동작 불변.
|
||||
|
||||
### 3-2. skeleton 경로 변경
|
||||
- `createInnerLinesFromSkeleton` 의 `isOuterEdge` 숨김 처리(`:1636-1643` 의 `visible:false`, `:1624` 의 `selectable:false`) 제거.
|
||||
- 내부선(용마루/추녀) 생성 완료 후 `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류.
|
||||
- `createInnerLinesFromSkeleton` 의 `isOuterEdge`(SK eaves) 숨김 처리는 **유지**. (§9 정정: SK eaves 라인은 baseLine 기준이라, 노출하면 처마변 split 결과와 offset 만큼 어긋나 이중 표시됨. 처마 외곽선은 아래 split 으로 별도 innerLine 화.)
|
||||
- **추녀 연장(`drawBaselineToRooflineHelpers`, `:1285`) 완료 후** `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 추녀 종점이 처마변 위에 있는 상태라 split 정합 성립, 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. (⚠️ 연장 **전**에 호출하면 종점이 baseLine 위라 분할점을 못 잡음 — §9 참조)
|
||||
- `drawHelpLine` 재호출 시마다 자동 재실행되어 편집 후 재그리기에도 외곽 지붕선 유지.
|
||||
|
||||
## 4. 리스크 & 구현 전 검증
|
||||
|
||||
1. **SK 추녀 끝점 ↔ `roof.lines` 정합성**: split 은 "innerLine 끝점이 `roof.lines` 위에 있다"(`isPointOnLineNew`, tol 0.1)를 전제로 한다. SK 가 만든 hip 끝점이 처마 외곽선 위에 실제로 떨어지는지 **실측 확인 필요**. 안 맞으면 split 이 헛돌아 외곽선이 통째로 roofLine 1개로만 그려진다. → 구현 첫 단계에서 검증.
|
||||
1. **SK 추녀 끝점 ↔ `roof.lines` 정합성** — ✅ **검증 완료 (2026-06-12, §9 참조)**: **연장 후 정합 OK**. SK 골조는 `wall.baseLines` 기준이라 추녀 끝점이 처음엔 baseLine 코너에 있으나(연장 전 `onRoof=false`), `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 **연장**해 종점을 처마변 위로 옮긴다(연장 후 4코너 전부 `diffRoof=0`, `onRoof=true`). → **split 을 연장 다음에 호출하면 정합 자동 성립**. (초기 측정이 연장 전에 찍혀 "구조적 불일치"로 오판했음 — §9-0 참조)
|
||||
2. **순환 import**: `qpolygon-utils ↔ skeleton-utils` 상호 import 여부 점검. 순환이면 split 함수를 제3의 모듈(예: canvas-util)로 추출하거나 의존성 정리.
|
||||
3. **기존 `isOuterEdge` roofLine 제거 영향**: 숨긴 라인을 참조하는 곳(치수 계산, 플랜 저장/복원 등)이 있는지 확인 후 제거.
|
||||
|
||||
@ -159,3 +159,41 @@ opts.degreeOf?: (parentLine) => number // 부모 외곽변 → 경사각(도).
|
||||
### 8-5. 리스크
|
||||
- **분할점 부재**: 한쪽흐름은 내부 보조선이 없어 외곽변이 1:1 통짜 라인으로 그려진다 → 부모 변 type 매칭이 단순(분할 시에도 조각은 부모 type 상속). split 의 끝점 정합성(`isPointOnLineNew`, tol 0.1) 자체는 분할점이 없으면 무의미하므로 `else` 통짜 경로(`:5493`)로 안전하게 떨어진다.
|
||||
- **`isGable` 판정원**: `drawShedRoof` 의 `gables` 분류(`:1653`)와 동일 기준(`attributes.type === GABLE`)을 써야 보정 대상이 일치.
|
||||
|
||||
---
|
||||
|
||||
## 9. SK 끝점 ↔ 처마변 정합 — 실측 결과 (2026-06-12, 측정 시점 정정)
|
||||
|
||||
§4 리스크1 을 임시 계측(`skeleton-utils.js` 의 `logSkRoofLineAlignment`)으로 추녀 연장 **전/후** 2단계 실측한 결과.
|
||||
|
||||
### 9-0. ⚠️ 측정 시점 정정 (중요)
|
||||
1차 측정은 `createInnerLinesFromSkeleton` **직후**(`:1279`, 추녀 연장 전)에 찍혀, 추녀 종점이 baseLine 코너에 있는 상태(`onRoof=false`, `perpRoof=offset`)만 보고 **"구조적 불일치 → 투영 보정 필요"로 오판**했다. 실제로는 그 직후 `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 연장한다. 연장 **후**(`afterExt`) 측정에서 정합이 성립함을 확인 → 아래는 정정된 결론. **(투영 보정·SK 입력 교체·연장 보정 설계는 모두 폐기. 불필요했음.)**
|
||||
|
||||
### 9-1. 실측 결과 (용마루 우진각, 변별 offset 50px)
|
||||
처마변 x∈[357.5,1147.6]·y∈[115.2,636.7] vs baseLine x∈[407.5,1097.6]·y∈[165.2,586.7] (전 변 50px 안쪽).
|
||||
|
||||
| 추녀 끝점 | beforeExt (`:1279`, 연장 전) | afterExt (`:1285`, 연장 후) |
|
||||
|---|---|---|
|
||||
| **바깥** 끝점(4코너) | baseLine 코너 — `onRoof=false`, `diffRoof=22.4`, `onBase=true` | **처마 코너로 연장 — `onRoof=true`, `diffRoof=0`** |
|
||||
| 안쪽·용마루(내부점) | `onRoof=false` (260.7) | `onRoof=false` (260.7) — 내부점이라 정상 |
|
||||
|
||||
예: 추녀 바깥 끝 `(407.5,165.2)`(baseLine) → `(357.5,115.2)`(처마 코너) 로 연장. 4코너 전부 `diffRoof=0`, `onRoof=true`.
|
||||
|
||||
### 9-2. 추녀 연장 메커니즘 (이미 구현됨)
|
||||
`drawBaselineToRooflineHelpers`(`:685`, 플래그 `DRAW_BASELINE_TO_ROOFLINE_HELPER=true`, `:25`):
|
||||
- `hipLines` 추출(`:734`) → baseLine 코너에 닿는 hip 만(`CORNER_EPS`, `:773`) → 추녀 진행 방향으로 ray cast(`rayHitSegment`, `:693`) → 처마변 교점까지 hip 좌표를 **연장**(`:861-865`).
|
||||
- `actualSize` 비율 보정(`:871`) + `__extended`/`attributes.extended` 플래그로 save/load 영속(`:879`).
|
||||
- 즉 "추녀는 roof.lines 까지 연결되는 선" 이 이미 코드에 있다. SK 골조 자체는 baseLine 기준(동기화 유지) + 추녀 종점만 처마변까지 연장.
|
||||
|
||||
### 9-3. 결론 — split 은 추녀 연장 **다음**에 호출
|
||||
- 추녀 종점은 연장 후 처마변 위(`onRoof=true`, `diffRoof=0`)에 정확히 앉으므로, **split/외곽선 innerLine화의 정합은 깨지지 않는다.**
|
||||
- **유일한 요건**: `splitRoofLinesToInnerLines` 호출을 `createInnerLinesFromSkeleton` 직후가 아니라 **`drawBaselineToRooflineHelpers`(`:1285`) 다음에** 둘 것. §3-2 가 호출 위치를 연장 전으로 잡은 것이 유일한 함정이었음.
|
||||
- **단순 우진각**: 추녀가 처마 **코너**(변 끝점)에 닿으므로 split 분할점 없음 → 처마 4변 통짜 innerLine(정상).
|
||||
- **복합 형상**(L자·凸자): 추녀가 처마변 **중간**에 닿으면 그 점에서 분할. 연장 후 종점이 처마변 위이므로 분할점으로 정상 인식.
|
||||
|
||||
### 9-4. 검증 기준 (구현 시)
|
||||
- split 을 연장 후 호출 → 처마 4변이 innerLine 으로 등록, select+우클릭 편집 가능.
|
||||
- 복합 형상: 처마변 중간 접점에서 분할, 각 조각 독립 편집.
|
||||
- offset=0 / offset≠0 양쪽 동작.
|
||||
- 마루이동/벽이동 후 재그리기에도 유지(연장은 `__extended`/`attributes.extended` 로 영속).
|
||||
- **임시 계측(`logSkRoofLineAlignment` 정의 + 호출부 3곳 `:1279`/`:1285`/`:1413`) 제거** 후 커밋.
|
||||
|
||||
@ -40,7 +40,7 @@ class ErrorBoundary extends React.Component {
|
||||
<p><strong>Timestamp:</strong> {new Date().toISOString()}</p>
|
||||
<p><strong>Error:</strong> {this.state.error && this.state.error.toString()}</p>
|
||||
<p><strong>Stack Trace:</strong></p>
|
||||
<pre>{this.state.errorInfo.componentStack}</pre>
|
||||
<pre>{this.state.errorInfo?.componentStack}</pre>
|
||||
</details>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}
|
||||
|
||||
@ -558,6 +558,12 @@ export function useCommonUtils() {
|
||||
if (object.id) {
|
||||
const group = canvas.getObjects().filter((obj) => obj.id === object.id)
|
||||
group.forEach((obj) => canvas?.remove(obj))
|
||||
// QLine 보조선은 lengthText 를 this.text(단수)에 저장해 아래 texts(배열) 제거에 안 걸린다.
|
||||
// lengthText 는 parentId === line.id 로 연결되므로 parentId 매칭으로 함께 제거 (삭제 시 text 잔존 방지).
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((obj) => obj.parentId === object.id && obj.name === 'lengthText')
|
||||
.forEach((obj) => canvas?.remove(obj))
|
||||
}
|
||||
|
||||
if (object.type === 'group') {
|
||||
|
||||
@ -39,6 +39,7 @@ export function useRoofShapePassivitySetting(id) {
|
||||
const isFix = useRef(false)
|
||||
const isConverted = useRef(false)
|
||||
const initLines = useRef([])
|
||||
const removedWalls = useRef([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { closePopup } = usePopup()
|
||||
@ -70,6 +71,7 @@ export function useRoofShapePassivitySetting(id) {
|
||||
addCanvasMouseEventListener('mouse:down', mouseDown)
|
||||
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
|
||||
|
||||
removedWalls.current = wallLines.map((wall) => ({ wall, index: canvas.getObjects().indexOf(wall) }))
|
||||
canvas?.remove(...wallLines)
|
||||
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
@ -221,9 +223,32 @@ export function useRoofShapePassivitySetting(id) {
|
||||
isConverted.current = true
|
||||
|
||||
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||
const exceptObjs = canvas.getObjects().filter((obj) => obj.name !== 'outerLine' && obj.parent?.name !== 'outerLine')
|
||||
const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
|
||||
if (!isFix.current) {
|
||||
// 저장 없이 닫을 경우 진입 시점 스냅샷으로 롤백 — 캔버스를 진입 전 상태 그대로 되돌린다
|
||||
lines.forEach((line, idx) => {
|
||||
const init = initLines.current[idx]
|
||||
if (!init) {
|
||||
return
|
||||
}
|
||||
init.visible ? showLine(line) : hideLine(line)
|
||||
line.set({ stroke: init.stroke, strokeWidth: init.strokeWidth, selectable: init.selectable })
|
||||
line.attributes = init.attributes
|
||||
})
|
||||
|
||||
// 진입 시 삭제했던 WALL 폴리곤 원본을 원래 z-순서 그대로 재삽입
|
||||
removedWalls.current.forEach(({ wall, index }) => {
|
||||
canvas.insertAt(wall, index)
|
||||
})
|
||||
removedWalls.current = []
|
||||
|
||||
canvas.renderAll()
|
||||
return
|
||||
}
|
||||
|
||||
const exceptObjs = canvas.getObjects().filter((obj) => obj.name !== 'outerLine' && obj.parent?.name !== 'outerLine')
|
||||
|
||||
let checkedAllSetting = true
|
||||
|
||||
lines.forEach((line) => {
|
||||
@ -241,29 +266,26 @@ export function useRoofShapePassivitySetting(id) {
|
||||
canvas.remove(obj)
|
||||
})
|
||||
|
||||
// 저장 시에는 자동 설정과 동일하게 외벽선을 타입별 색상으로 표시
|
||||
lines.forEach((line) => {
|
||||
hideLine(line)
|
||||
let stroke
|
||||
if (line.attributes?.type === LINE_TYPE.WALLLINE.EAVES || line.attributes?.type === LINE_TYPE.WALLLINE.HIPANDGABLE) {
|
||||
stroke = '#45CD7D'
|
||||
} else if (line.attributes?.type === LINE_TYPE.WALLLINE.GABLE || line.attributes?.type === LINE_TYPE.WALLLINE.JERKINHEAD) {
|
||||
stroke = '#3FBAE6'
|
||||
} else {
|
||||
stroke = '#000000'
|
||||
}
|
||||
line.set({ stroke, strokeWidth: 4, selectable: false, visible: true })
|
||||
})
|
||||
|
||||
let wall
|
||||
|
||||
if (isFix.current) {
|
||||
wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
|
||||
} else {
|
||||
// 그냥 닫을 경우 처리
|
||||
wall = addPolygonByLines([...initLines.current], {
|
||||
name: POLYGON_TYPE.WALL,
|
||||
fill: 'transparent',
|
||||
stroke: 'black',
|
||||
})
|
||||
lines.forEach((line, idx) => {
|
||||
line.attributes = initLines.current[idx].attributes
|
||||
})
|
||||
}
|
||||
|
||||
const wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
|
||||
wall.lines = [...lines]
|
||||
|
||||
// 기존 그려진 지붕이 없다면
|
||||
// 완료 한 경우에는 지붕까지 그려줌
|
||||
addPitchTextsByOuterLines()
|
||||
const roof = drawRoofPolygon(wall)
|
||||
addLengthText(roof)
|
||||
|
||||
if (isFix.current) {
|
||||
// 완료 한 경우에는 지붕까지 그려줌
|
||||
@ -273,6 +295,12 @@ export function useRoofShapePassivitySetting(id) {
|
||||
const roof = drawRoofPolygon(wall)
|
||||
addLengthText(roof)
|
||||
}
|
||||
// 외벽선은 WALL/ROOF 폴리곤 바로 위에 배치 — 맨 앞(bringToFront)으로 올리면
|
||||
// offset 0인 변에서 같은 좌표에 그려진 보조선의 클릭을 가로채 선택을 막는다
|
||||
const polygonTopIdx = canvas
|
||||
.getObjects()
|
||||
.reduce((top, obj, idx) => (obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ? idx : top), -1)
|
||||
lines.forEach((line) => canvas.moveTo(line, polygonTopIdx))
|
||||
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
@ -55,6 +55,11 @@ export function useRoofShapeSetting(id) {
|
||||
|
||||
const isFixRef = useRef(false)
|
||||
|
||||
// 저장 없이 닫을 때 롤백할 진입 시점 스냅샷
|
||||
const initLinesRef = useRef([])
|
||||
const removedWallsRef = useRef([])
|
||||
const pitchTextLineIdsRef = useRef(new Set())
|
||||
|
||||
const pitchRef = useRef(null)
|
||||
const shedPitchRef = useRef(null)
|
||||
const jerkinHeadPitchRef = useRef(null)
|
||||
@ -104,14 +109,46 @@ export function useRoofShapeSetting(id) {
|
||||
closePopup(id)
|
||||
return
|
||||
}
|
||||
return () => {
|
||||
if (!isFixRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
initLinesRef.current = outerLines.map((line) => ({ ...line }))
|
||||
pitchTextLineIdsRef.current = new Set(
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((obj) => obj.name === 'pitchText')
|
||||
.map((obj) => obj.parentId),
|
||||
)
|
||||
|
||||
return () => {
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
const pitchTexts = canvas.getObjects().filter((obj) => obj.name === 'pitchText')
|
||||
|
||||
if (!isFixRef.current) {
|
||||
// 저장 없이 닫을 경우 진입 시점 스냅샷으로 롤백 — 캔버스를 진입 전 상태 그대로 되돌린다
|
||||
canvas.remove(...pitchTexts)
|
||||
outerLines.forEach((line, idx) => {
|
||||
const init = initLinesRef.current[idx]
|
||||
if (!init) {
|
||||
return
|
||||
}
|
||||
init.visible ? showLine(line) : hideLine(line)
|
||||
line.set({ stroke: init.stroke, strokeWidth: init.strokeWidth, selectable: init.selectable })
|
||||
line.attributes = init.attributes
|
||||
if (pitchTextLineIdsRef.current.has(line.id)) {
|
||||
addPitchText(line)
|
||||
}
|
||||
})
|
||||
|
||||
// 변별로 설정 진입 시 삭제했던 WALL 폴리곤 원본을 원래 z-순서 그대로 재삽입
|
||||
removedWallsRef.current.forEach(({ wall, index }) => {
|
||||
canvas.insertAt(wall, index)
|
||||
})
|
||||
removedWallsRef.current = []
|
||||
|
||||
canvas.discardActiveObject()
|
||||
canvas.renderAll()
|
||||
return
|
||||
}
|
||||
|
||||
canvas.remove(...pitchTexts)
|
||||
outerLines.forEach((line) => {
|
||||
let stroke, strokeWidth
|
||||
@ -130,12 +167,20 @@ export function useRoofShapeSetting(id) {
|
||||
line.set({
|
||||
stroke,
|
||||
strokeWidth,
|
||||
selectable: false,
|
||||
visible: true,
|
||||
})
|
||||
|
||||
addPitchText(line)
|
||||
line.bringToFront()
|
||||
}
|
||||
})
|
||||
|
||||
// 외벽선은 WALL/ROOF 폴리곤 바로 위에 배치 — 맨 앞(bringToFront)으로 올리면
|
||||
// offset 0인 변에서 같은 좌표에 그려진 보조선의 클릭을 가로채 선택을 막는다
|
||||
const polygonTopIdx = canvas
|
||||
.getObjects()
|
||||
.reduce((top, obj, idx) => (obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ? idx : top), -1)
|
||||
outerLines.forEach((line) => canvas.moveTo(line, polygonTopIdx))
|
||||
canvas.renderAll()
|
||||
}
|
||||
}, [])
|
||||
@ -161,10 +206,15 @@ export function useRoofShapeSetting(id) {
|
||||
|
||||
useEffect(() => {
|
||||
if (shapeNum === 4) {
|
||||
canvas?.remove(canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL))
|
||||
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL)
|
||||
if (wall && removedWallsRef.current.length === 0) {
|
||||
removedWallsRef.current = [{ wall, index: canvas.getObjects().indexOf(wall) }]
|
||||
}
|
||||
canvas?.remove(wall)
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
outerLines.forEach((line) => {
|
||||
showLine(line)
|
||||
line.set({ selectable: true })
|
||||
line.bringToFront()
|
||||
})
|
||||
|
||||
|
||||
@ -148,7 +148,7 @@ export const useLine = () => {
|
||||
|
||||
// 기존 pitchText가 있으면 삭제
|
||||
if (pitchText.length > 0) {
|
||||
canvas.remove(pitchText)
|
||||
canvas.remove(...pitchText)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1628,6 +1628,10 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
|
||||
drawRoofPlane(backward)
|
||||
})
|
||||
roof.innerLines.push(...ridgeLines, ...innerLines)
|
||||
// 처마선 innerLine 이 외곽선/치수를 전담하므로 roof 원본 외곽(stroke) 숨김 + 지붕선 치수(roof lengthText) 제거.
|
||||
// 외벽선(wall) 치수는 유지. (박공은 split 공용 함수를 안 거치므로 여기서 직접 처리)
|
||||
roof.set({ stroke: 'transparent' })
|
||||
removeRoofLengthText(roof, canvas)
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((obj) => obj.name === 'check')
|
||||
@ -1719,6 +1723,16 @@ export const drawShedRoof = (roofId, canvas, textMode) => {
|
||||
|
||||
const maxLine = pitchSizeLines.reduce((prev, current) => (prev.length > current.length ? prev : current), pitchSizeLines[0])
|
||||
canvas.add(maxLine)
|
||||
|
||||
// [docs §8] 한쪽흐름 외곽 처마선 — 내부 보조선이 없어 각 처마변이 통짜 innerLine 으로 그려진다.
|
||||
// 경사변(gable)은 평면길이≠실제길이 라서 degreeOf 로 shedDegree 를 주입 → drawHipLine(plane/cos) 으로 보정.
|
||||
// roof 원본 외곽(stroke) 숨김은 splitRoofLinesToInnerLines 가 담당.
|
||||
roof.innerLines = roof.innerLines || []
|
||||
splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode, {
|
||||
degreeOf: (parentLine) => (parentLine.attributes?.type === LINE_TYPE.WALLLINE.GABLE ? shedDegree : 0),
|
||||
})
|
||||
// 지붕선 치수(roof lengthText) 제거는 split 내부 removeRoofLengthText 가 담당. 외벽선(wall) 치수는 유지.
|
||||
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
@ -5429,7 +5443,52 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
//추가된 하단 지붕 라인 innerLines에 추가.
|
||||
innerLines.push(...downRoofLines)
|
||||
|
||||
//지붕선에 따라 라인추가 작업 처리.
|
||||
//지붕선에 따라 라인추가 작업 처리. (split 공용 함수 — docs §3-1)
|
||||
splitRoofLinesToInnerLines(roof, innerLines, canvas, textMode)
|
||||
|
||||
//지붕 innerLines에 추가.
|
||||
roof.innerLines = innerLines
|
||||
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((object) => object.name === 'check')
|
||||
.forEach((object) => canvas.remove(object))
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* 지붕선(roof.lines)의 원본 변 길이 text(lengthText)를 제거한다.
|
||||
* 지붕선 치수는 그 위에 그려진 보조선(처마선 innerLine)의 치수로 대체되므로 roof.lines 자체 치수는 숨긴다.
|
||||
* ※ 외벽선(wall) 치수는 유지한다. (변별/박공/용마루/한쪽흐름 공통)
|
||||
*/
|
||||
export const removeRoofLengthText = (roof, canvas) => {
|
||||
canvas
|
||||
?.getObjects()
|
||||
.filter((o) => o.name === 'lengthText' && o.parentId === roof.id)
|
||||
.forEach((o) => canvas.remove(o))
|
||||
}
|
||||
|
||||
/**
|
||||
* roof.lines(처마 외곽선)를 innerLine 끝점 기준으로 split 해 편집 가능한 innerLine(hip/roofLine)으로 만들고,
|
||||
* 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽선(stroke)은 숨긴다(transparent).
|
||||
* 변별/용마루 경로 공용. innerLines 에 결과를 in-place push 하고 반환한다.
|
||||
* (용마루 경로는 추녀 연장 후 호출해야 종점이 처마변 위에 있어 분할점이 잡힌다 — docs §9)
|
||||
* @param {fabric.Object} roof - 대상 지붕 (roof.lines 사용)
|
||||
* @param {Array<QLine>} innerLines - 내부선 배열 (split 결과가 여기에 in-place 추가됨)
|
||||
* @param {fabric.Canvas} canvas
|
||||
* @param {string} textMode
|
||||
* @param {{degreeOf?: (parentLine) => number}} [opts] - degreeOf: 부모 외곽변 → 경사각(도).
|
||||
* 한쪽흐름 경사변(gable)처럼 평면길이≠실제길이인 변을 통짜로 그릴 때 drawHipLine 으로 실제길이 보정(plane/cos) — docs §8.
|
||||
* 미전달 시 기존 동작(분할점 인접 pitch 기반).
|
||||
* @returns {Array<QLine>} innerLines
|
||||
*/
|
||||
export const splitRoofLinesToInnerLines = (roof, innerLines, canvas, textMode, opts = {}) => {
|
||||
const { degreeOf } = opts
|
||||
// 분할점이 처마변 끝점(코너)과 이 거리(px) 이내면 분할에서 제외.
|
||||
// SK 추녀 연장이 처마 코너에 닿을 때 ray cast 부동소수 오차(관측 0.02px)로 코너와 미세하게
|
||||
// 어긋나면, 기존 almostEqual(1e-10)은 "끝점 아님"으로 오판 → 0 길이 조각 생성. END_EPS 로 흡수.
|
||||
// 변별 경로의 진짜 분할점은 끝점에서 수백 px 떨어져 있어 영향 없음.
|
||||
const END_EPS = 1.0
|
||||
const innerLinesPoints = []
|
||||
innerLines.forEach((line) => {
|
||||
const hasCoord1 = innerLinesPoints.find((p) => p.x === line.x1 && p.y === line.y1)
|
||||
@ -5464,17 +5523,15 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
if (hasOverlapLine) return
|
||||
|
||||
innerLinesPoints.forEach((point) => {
|
||||
if (
|
||||
isPointOnLineNew(currentLine, point) &&
|
||||
!(almostEqual(currentLine.x1, point.x) && almostEqual(currentLine.y1, point.y)) &&
|
||||
!(almostEqual(currentLine.x2, point.x) && almostEqual(currentLine.y2, point.y))
|
||||
) {
|
||||
const distance = Math.sqrt((point.x - currentLine.x1) ** 2 + (point.y - currentLine.y1) ** 2)
|
||||
splitPoint.push({ point, distance })
|
||||
const distToStart = Math.hypot(point.x - currentLine.x1, point.y - currentLine.y1)
|
||||
const distToEnd = Math.hypot(point.x - currentLine.x2, point.y - currentLine.y2)
|
||||
// 변 위 점이면서 양 끝점(코너)에서 END_EPS 보다 멀 때만 진짜 분할점으로 채택.
|
||||
if (isPointOnLineNew(currentLine, point) && distToStart > END_EPS && distToEnd > END_EPS) {
|
||||
splitPoint.push({ point, distance: distToStart })
|
||||
}
|
||||
})
|
||||
if (splitPoint.length > 0) {
|
||||
splitPoint.sort((a, b) => a[1] - b[1])
|
||||
splitPoint.sort((a, b) => a.distance - b.distance)
|
||||
let startPoint = { x: currentLine.x1, y: currentLine.y1 }
|
||||
for (let i = 0; i < splitPoint.length; i++) {
|
||||
const point = splitPoint[i].point
|
||||
@ -5491,18 +5548,22 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
innerLines.push(drawHipLine([startPoint.x, startPoint.y, currentLine.x2, currentLine.y2], canvas, roof, textMode, nextDegree, nextDegree))
|
||||
}
|
||||
} else {
|
||||
innerLines.push(drawRoofLine([currentLine.x1, currentLine.y1, currentLine.x2, currentLine.y2], canvas, roof, textMode))
|
||||
// 분할점 없는 통짜 변. degreeOf 가 주어지면(한쪽흐름) 경사변은 drawHipLine(plane/cos) 으로 실제길이 보정.
|
||||
const deg = degreeOf ? degreeOf(currentLine) : 0
|
||||
if (deg > 0) {
|
||||
innerLines.push(drawHipLine([currentLine.x1, currentLine.y1, currentLine.x2, currentLine.y2], canvas, roof, textMode, deg, deg))
|
||||
} else {
|
||||
innerLines.push(drawRoofLine([currentLine.x1, currentLine.y1, currentLine.x2, currentLine.y2], canvas, roof, textMode))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
//지붕 innerLines에 추가.
|
||||
roof.innerLines = innerLines
|
||||
// 처마선 innerLine 이 외곽선/치수를 전담하므로 roof 원본 외곽(stroke) 숨김 + 지붕선 치수(roof lengthText) 제거.
|
||||
// (안 숨기면 처마선 삭제 시 roof 기본 stroke:'black'(usePolygon.js) 노출. 외벽선 wall 치수는 유지)
|
||||
roof.set({ stroke: 'transparent' })
|
||||
removeRoofLengthText(roof, canvas)
|
||||
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((object) => object.name === 'check')
|
||||
.forEach((object) => canvas.remove(object))
|
||||
canvas.renderAll()
|
||||
return innerLines
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||
import { SkeletonBuilder } from '@/lib/skeletons'
|
||||
import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils'
|
||||
import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, splitRoofLinesToInnerLines, toGeoJSON } from '@/util/qpolygon-utils'
|
||||
import { QLine } from '@/components/fabric/QLine'
|
||||
import { getDegreeByChon } from '@/util/canvas-util'
|
||||
import Big from 'big.js'
|
||||
@ -1283,6 +1283,10 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
|
||||
// gap 을 시각화. innerLines 와 별개 collection. 기존 working code (processInBoth 등) 무영향.
|
||||
drawBaselineToRooflineHelpers(roof, canvas, changRoofLinePoints, roofLinePoints)
|
||||
|
||||
// [docs §9-3] 추녀 연장 후 처마변(roof.lines)을 split → 편집 가능한 innerLine(hip/roofLine)으로 등록.
|
||||
// 단순 우진각: 추녀가 처마 코너(변 끝점)에 닿아 분할점 없음 → 처마 4변 통짜. 복합 형상: 중간 접점에서 분할.
|
||||
splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode)
|
||||
|
||||
// 캔버스에 스켈레톤 상태 저장
|
||||
if (!canvas.skeletonStates) {
|
||||
canvas.skeletonStates = {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user