Merge branch 'dev' of https://git.hanasys.jp/qcast3/qcast-front into feature/redo-undo

This commit is contained in:
hyojun.choi 2026-04-13 09:55:45 +09:00
commit ab94869efd
19 changed files with 899 additions and 620 deletions

View File

@ -13,8 +13,8 @@ SESSION_SECRET="i3iHH1yp2/2SpQSIySQ4bpyc4g0D+zCF9FAn5xUG0+Y="
NEXT_PUBLIC_CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_a0FLEK6M2oTpXInK" NEXT_PUBLIC_CONVERTER_DWG_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secret=secret_a0FLEK6M2oTpXInK"
NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?Secret=secret_a0FLEK6M2oTpXInK" NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?Secret=secret_a0FLEK6M2oTpXInK"
NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="http://q-order-stg.q-cells.jp:8120/eos/login/autoLogin" NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="https://q-order-dev.q-cells.jp/eos/login/autoLogin"
NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="http://q-musubi-stg.q-cells.jp:8120/qm/login/autoLogin" NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="https://q-musubi-dev.q-cells.jp/qm/login/autoLogin"
# 테스트용 # 테스트용
# AWS_REGION="ap-northeast-2" # AWS_REGION="ap-northeast-2"

View File

@ -8,6 +8,7 @@ import { useRecoilState } from 'recoil'
import { useAxios } from '@/hooks/useAxios' import { useAxios } from '@/hooks/useAxios'
import { globalLocaleStore } from '@/store/localeAtom' import { globalLocaleStore } from '@/store/localeAtom'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Cookies from 'js-cookie'
import GlobalSpinner from '@/components/common/spinner/GlobalSpinner' import GlobalSpinner from '@/components/common/spinner/GlobalSpinner'
@ -31,6 +32,7 @@ export default function AutoLoginPage({ autoLoginParam }) {
setIsLoading(false) setIsLoading(false)
if (response.data) { if (response.data) {
const res = response.data const res = response.data
if (res.sessionId) Cookies.set('sessionId', res.sessionId)
const result = { ...res, storeLvl: res.groupId === '60000' ? '1' : '2', pwdInitYn: 'Y' } const result = { ...res, storeLvl: res.groupId === '60000' ? '1' : '2', pwdInitYn: 'Y' }
setSession(result) setSession(result)
setSessionState(result) setSessionState(result)

View File

@ -48,6 +48,7 @@ export default function Login() {
post({ url: '/api/login/v1.0/user', data: { loginId: res.data } }).then((response) => { post({ url: '/api/login/v1.0/user', data: { loginId: res.data } }).then((response) => {
setIsLoading(false) setIsLoading(false)
if (response) { if (response) {
if (response.sessionId) Cookies.set('sessionId', response.sessionId)
const result = { ...response, storeLvl: response.groupId === '60000' ? '1' : '2', pwdInitYn: 'Y' } const result = { ...response, storeLvl: response.groupId === '60000' ? '1' : '2', pwdInitYn: 'Y' }
setSession(result) setSession(result)
setSessionState(result) setSessionState(result)
@ -107,6 +108,7 @@ export default function Login() {
if (res) { if (res) {
setIsLoading(false) setIsLoading(false)
if (res.data.result.resultCode === 'S') { if (res.data.result.resultCode === 'S') {
if (res.data.sessionId) Cookies.set('sessionId', res.data.sessionId)
setSession(res.data.data) setSession(res.data.data)
setSessionState(res.data.data) setSessionState(res.data.data)
// ID SAVE , // ID SAVE ,

View File

@ -95,6 +95,34 @@ export const QLine = fabric.util.createClass(fabric.Line, {
if (this.attributes?.planeSize) { if (this.attributes?.planeSize) {
thisText.set({ planeSize: this.attributes.planeSize, text: this.attributes.planeSize.toString() }) thisText.set({ planeSize: this.attributes.planeSize, text: this.attributes.planeSize.toString() })
} }
// 라인이 이동했을 수 있으므로 위치도 재계산해서 업데이트한다.
const sX = this.scaleX
const sY = this.scaleY
const tx1 = this.left
const ty1 = this.top
const tx2 = this.left + this.width * sX
const ty2 = this.top + this.height * sY
let nLeft, nTop
if (this.direction === 'left' || this.direction === 'right') {
nLeft = (tx1 + tx2) / 2
nTop = (ty1 + ty2) / 2 + 10
} else if (this.direction === 'top' || this.direction === 'bottom') {
nLeft = (tx1 + tx2) / 2 + 10
nTop = (ty1 + ty2) / 2
} else {
// 대각선
nLeft = (tx1 + tx2) / 2
nTop = (ty1 + ty2) / 2
}
thisText.set({
left: nLeft,
top: nTop,
minX: this.left,
maxX: this.left + this.width,
minY: this.top,
maxY: this.top + this.length,
})
thisText.setCoords?.()
} else { } else {
this.setLength() this.setLength()
const scaleX = this.scaleX const scaleX = this.scaleX

View File

@ -136,9 +136,59 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
}) })
this.on('modified', () => { this.on('modified', () => {
// 드래그 종료 시점의 delta 계산 (mousedown 에서 저장한 값 사용)
const hasPreDrag = this._preDragLeft != null && this._preDragTop != null
const dxModified = hasPreDrag ? this.left - this._preDragLeft : 0
const dyModified = hasPreDrag ? this.top - this._preDragTop : 0
this.initLines() this.initLines()
this.addLengthText() this.addLengthText()
this.setCoords() this.setCoords()
// 보조선 / 자식 객체들도 같이 이동시킨다 (ROOF 가 아닌 WALL 등에서도 동작하도록)
if ((dxModified !== 0 || dyModified !== 0) && this.canvas) {
const directChildren = this.canvas.getObjects().filter((obj) => {
if (obj === this) return false
if (obj.parentId !== this.id) return false
if (obj.name === 'lengthText') return false
return true
})
const childIds = new Set(directChildren.map((c) => c.id).filter(Boolean))
directChildren.forEach((obj) => {
const next = {}
if (obj.left != null) next.left = obj.left + dxModified
if (obj.top != null) next.top = obj.top + dyModified
if (obj.x1 != null) next.x1 = obj.x1 + dxModified
if (obj.y1 != null) next.y1 = obj.y1 + dyModified
if (obj.x2 != null) next.x2 = obj.x2 + dxModified
if (obj.y2 != null) next.y2 = obj.y2 + dyModified
obj.set(next)
if (obj.startPoint) obj.startPoint = { x: obj.startPoint.x + dxModified, y: obj.startPoint.y + dyModified }
if (obj.endPoint) obj.endPoint = { x: obj.endPoint.x + dxModified, y: obj.endPoint.y + dyModified }
obj.setCoords?.()
})
let movedTextCount = 0
this.canvas.getObjects().forEach((obj) => {
if (childIds.size > 0 && childIds.has(obj.parentId)) {
obj.set({ left: (obj.left ?? 0) + dxModified, top: (obj.top ?? 0) + dyModified })
if (obj.minX != null) obj.minX += dxModified
if (obj.maxX != null) obj.maxX += dxModified
if (obj.minY != null) obj.minY += dyModified
if (obj.maxY != null) obj.maxY += dyModified
obj.setCoords?.()
movedTextCount++
}
})
this.canvas.renderAll()
// ROOF 의 경우 polygonMoved 가 같은 일을 다시 하므로, _preDragLeft 는 거기서 정리됨
if (this.name !== POLYGON_TYPE.ROOF) {
this._preDragLeft = null
this._preDragTop = null
}
}
}) })
this.on('selected', () => { this.on('selected', () => {
@ -168,9 +218,20 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
}) })
}) })
// 드래그 시작 전 위치를 기록해두고 polygonMoved 에서 델타 계산용으로 사용
this.on('mousedown', () => {
this._preDragLeft = this.left
this._preDragTop = this.top
})
//QPolygon 좌표 이동시 좌표 재계산 //QPolygon 좌표 이동시 좌표 재계산
this.on('polygonMoved', () => { this.on('polygonMoved', () => {
//폴리곤일때만 사용 //폴리곤일때만 사용
// 드래그 전 left/top 이 있으면 delta 계산, 없으면 0
const hasPreDrag = this._preDragLeft != null && this._preDragTop != null
const deltaX = hasPreDrag ? this.left - this._preDragLeft : 0
const deltaY = hasPreDrag ? this.top - this._preDragTop : 0
let matrix = this.calcTransformMatrix() let matrix = this.calcTransformMatrix()
let transformedPoints = this.get('points') let transformedPoints = this.get('points')
@ -204,6 +265,64 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
this.setCoords() this.setCoords()
this.initLines() this.initLines()
// 이동 후 기존 lengthText 가 이전 위치에 남아있지 않도록 재생성
this.addLengthText()
// 보조선(helpLine 등) 및 그 부속 객체(lengthText, pitchText 등)를 폴리곤 이동량만큼 같이 이동
if ((deltaX !== 0 || deltaY !== 0) && this.canvas) {
// drawHelpLine 이 추가하는 보조선들은 this.innerLines 배열에 들어가지 않을 수 있으므로
// canvas 에서 parentId === this.id 인 자식 객체들을 직접 찾는다.
// (단, 이 폴리곤의 outer lengthText 는 위에서 addLengthText() 로 이미 재생성했으므로 제외)
const directChildren = this.canvas.getObjects().filter((obj) => {
if (obj === this) return false
if (obj.parentId !== this.id) return false
if (obj.name === 'lengthText') return false // 이미 재생성됨
return true
})
const childIds = new Set(directChildren.map((c) => c.id).filter(Boolean))
const translateLineLike = (obj) => {
const next = {}
if (obj.left != null) next.left = obj.left + deltaX
if (obj.top != null) next.top = obj.top + deltaY
if (obj.x1 != null) next.x1 = obj.x1 + deltaX
if (obj.y1 != null) next.y1 = obj.y1 + deltaY
if (obj.x2 != null) next.x2 = obj.x2 + deltaX
if (obj.y2 != null) next.y2 = obj.y2 + deltaY
obj.set(next)
if (obj.startPoint) {
obj.startPoint = { x: obj.startPoint.x + deltaX, y: obj.startPoint.y + deltaY }
}
if (obj.endPoint) {
obj.endPoint = { x: obj.endPoint.x + deltaX, y: obj.endPoint.y + deltaY }
}
obj.setCoords?.()
}
directChildren.forEach(translateLineLike)
// 보조선의 텍스트(lengthText, pitchText 등)는 보조선 line.id 를 parentId 로 가짐 -> 같이 이동
let movedTextCount = 0
this.canvas.getObjects().forEach((obj) => {
if (childIds.size > 0 && childIds.has(obj.parentId)) {
obj.set({
left: (obj.left ?? 0) + deltaX,
top: (obj.top ?? 0) + deltaY,
})
if (obj.minX != null) obj.minX += deltaX
if (obj.maxX != null) obj.maxX += deltaX
if (obj.minY != null) obj.minY += deltaY
if (obj.maxY != null) obj.maxY += deltaY
obj.setCoords?.()
movedTextCount++
}
})
this.canvas.renderAll()
}
this._preDragLeft = null
this._preDragTop = null
}) })
// polygon.fillCell({ width: 50, height: 30, padding: 10 }) // polygon.fillCell({ width: 50, height: 30, padding: 10 })

View File

@ -18,7 +18,6 @@ import {
currentCanvasPlanState, currentCanvasPlanState,
isManualModuleLayoutSetupState, isManualModuleLayoutSetupState,
isManualModuleSetupState, isManualModuleSetupState,
moduleSetupOptionState,
toggleManualSetupModeState, toggleManualSetupModeState,
} from '@/store/canvasAtom' } from '@/store/canvasAtom'
import { loginUserStore } from '@/store/commonAtom' import { loginUserStore } from '@/store/commonAtom'
@ -50,7 +49,6 @@ export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) {
const [checkedModules, setCheckedModules] = useRecoilState(checkedModuleState) const [checkedModules, setCheckedModules] = useRecoilState(checkedModuleState)
const [roofs, setRoofs] = useState(addedRoofs) const [roofs, setRoofs] = useState(addedRoofs)
const [manualSetupMode, setManualSetupMode] = useRecoilState(toggleManualSetupModeState) const [manualSetupMode, setManualSetupMode] = useRecoilState(toggleManualSetupModeState)
const [moduleSetupOption, setModuleSetupOption] = useRecoilState(moduleSetupOptionState)
const [layoutSetup, setLayoutSetup] = useState([{}]) const [layoutSetup, setLayoutSetup] = useState([{}])
const { const {
selectedModules, selectedModules,
@ -220,12 +218,8 @@ export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) {
} }
const handleManualModuleSetup = () => { const handleManualModuleSetup = () => {
const nextManual = !isManualModuleSetup setManualSetupMode(`manualSetup_${!isManualModuleSetup}`)
setManualSetupMode(`manualSetup_${nextManual}`) setIsManualModuleSetup(!isManualModuleSetup)
setIsManualModuleSetup(nextManual)
if (nextManual) {
setModuleSetupOption((prev) => ({ ...prev, isChidori: true }))
}
} }
const handleManualModuleLayoutSetup = () => { const handleManualModuleLayoutSetup = () => {

View File

@ -130,6 +130,7 @@ export default function StepUp(props) {
if (selectedSerQty) { if (selectedSerQty) {
selectedSerQty.roofSurfaceList.forEach((roofSurface) => { selectedSerQty.roofSurfaceList.forEach((roofSurface) => {
const targetSurface = canvas.getObjects().filter((obj) => obj.id === roofSurface.roofSurfaceId)[0] const targetSurface = canvas.getObjects().filter((obj) => obj.id === roofSurface.roofSurfaceId)[0]
if (!targetSurface) return
const moduleIds = targetSurface.modules.map((module) => module.id) const moduleIds = targetSurface.modules.map((module) => module.id)
/** 기존 모듈 텍스트 삭제 */ /** 기존 모듈 텍스트 삭제 */
@ -406,37 +407,94 @@ export default function StepUp(props) {
/** /**
* 선택 핸들러 함수 추가 * 선택 핸들러 함수 추가
*/ */
/**
* 주어진 stepUpListData 기준으로 캔버스의 모든 module circuit 텍스트를 다시 그린다.
* 모든 pcsItem selected serQty 순회해 적용한다.
*/
const applyCircuitsToCanvas = (stepUpListSrc) => {
if (!stepUpListSrc?.[0]?.pcsItemList) return
/** 모든 모듈 circuit 데이터 초기화 */
canvas
.getObjects()
.filter((obj) => obj.name === POLYGON_TYPE.MODULE)
.forEach((module) => {
module.circuit = null
module.circuitNumber = null
module.pcsItemId = null
})
/** 기존 circuit 텍스트 객체 모두 제거 */
canvas
.getObjects()
.filter((obj) => obj.name === 'circuitNumber')
.forEach((text) => canvas.remove(text))
stepUpListSrc[0].pcsItemList.forEach((pcsItem) => {
const sel = pcsItem.serQtyList?.find((sq) => sq.selected)
if (!sel) return
sel.roofSurfaceList.forEach((roofSurface) => {
const targetSurface = canvas.getObjects().filter((obj) => obj.id === roofSurface.roofSurfaceId)[0]
if (!targetSurface) return
roofSurface.moduleList.forEach((module) => {
const targetModule = canvas.getObjects().find((obj) => obj.id === module.uniqueId)
if (targetModule && module.circuit !== '' && module.circuit !== null) {
const moduleCircuitText = new fabric.Text(module.circuit, {
left: targetModule.left + targetModule.width / 2,
top: targetModule.top + targetModule.height / 2,
fontFamily: circuitNumberText.fontFamily.value,
fontWeight: circuitNumberText.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
fontStyle: circuitNumberText.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
fontSize: circuitNumberText.fontSize.value,
fill: circuitNumberText.fontColor.value,
width: targetModule.width,
height: targetModule.height,
textAlign: 'center',
originX: 'center',
originY: 'center',
name: 'circuitNumber',
parentId: targetModule.id,
circuitInfo: module.pcsItemId,
visible: isDisplayCircuitNumber,
})
targetModule.circuit = moduleCircuitText
targetModule.pcsItemId = module.pcsItemId
targetModule.circuitNumber = module.circuit
canvas.add(moduleCircuitText)
}
})
})
})
canvas.renderAll()
setModuleStatisticsData()
}
const handleRowClick = (mainIdx, subIdx, applyParalQty = null) => { const handleRowClick = (mainIdx, subIdx, applyParalQty = null) => {
/** 자동 승압 설정인 경우만 실행 */ /** 자동 승압 설정인 경우만 실행 */
if (allocationType !== 'auto') return if (allocationType !== 'auto') return
let tempStepUpListData = [...stepUpListData] let tempStepUpListData = [...stepUpListData]
let selectedData = {}
tempStepUpListData[0].pcsItemList[mainIdx].serQtyList.forEach((item, index) => { tempStepUpListData[0].pcsItemList[mainIdx].serQtyList.forEach((item, index) => {
if (index === subIdx) {
selectedData = item
}
item.selected = index === subIdx item.selected = index === subIdx
}) })
/** 선택된 행 정보 저장 */ /** 선택된 행 정보 저장 */
setStepUpListData(tempStepUpListData) setStepUpListData(tempStepUpListData)
/** PCS 2개 이상 또는 첫번째 PCS 선택 시에만 실행 */ /** PCS 2개 이상 또는 첫번째 PCS 선택 시에만 실행 */
if (tempStepUpListData[0].pcsItemList.length > 1 && mainIdx === 0) { const needsRefetch =
tempStepUpListData[0].pcsItemList.length > 1 && mainIdx === 0
if (needsRefetch) {
/** 파워컨디셔너 옵션 조회 요청 파라미터 */ /** 파워컨디셔너 옵션 조회 요청 파라미터 */
const params = { const params = {
...props.getOptYn(), // Y/N ...props.getOptYn(), // Y/N
useModuleItemList: props.getUseModuleItemList(), // List useModuleItemList: props.getUseModuleItemList(), // List
roofSurfaceList: props.getRoofSurfaceList(), // roofSurfaceList: props.getRoofSurfaceList(), //
pcsItemList: props.getSelectedPcsItemList().map((pcsItem, index) => { pcsItemList: props.getSelectedPcsItemList().map((pcsItem, index) => {
/** PCS
* tempStepUpListData에서 해당 PCS 아이템 찾기
* uniqueIndex를 사용하여 매칭
*/
const matchingPcsItem = tempStepUpListData[0].pcsItemList.find((item) => item.uniqueIndex === `${pcsItem.itemId}_${index}`) const matchingPcsItem = tempStepUpListData[0].pcsItemList.find((item) => item.uniqueIndex === `${pcsItem.itemId}_${index}`)
/** 선택된 serQty 찾기 */
const selectedSerQty = matchingPcsItem?.serQtyList.find((serQty) => serQty.selected)?.serQty || 0 const selectedSerQty = matchingPcsItem?.serQtyList.find((serQty) => serQty.selected)?.serQty || 0
if (index === 0) { if (index === 0) {
return { return {
@ -453,86 +511,30 @@ export default function StepUp(props) {
} }
/** PCS가 1개 이고 2번째 또는 3번째 PCS serQty가 0인 경우는 추천 API 실행하지 않음 */ /** PCS가 1개 이고 2번째 또는 3번째 PCS serQty가 0인 경우는 추천 API 실행하지 않음 */
if (params.pcsItemList.length !== 1 && (params.pcsItemList[1]?.applySerQty !== 0 || params.pcsItemList[2]?.applySerQty) !== 0) { const skipApi = !(params.pcsItemList.length !== 1 && (params.pcsItemList[1]?.applySerQty !== 0 || params.pcsItemList[2]?.applySerQty) !== 0)
/** PCS 승압설정 정보 조회 */
if (!skipApi) {
/**
* PCS 승압설정 정보 조회. 캔버스 갱신은 응답을 받은 뒤에 수행해야
* "한박자 늦게" 반영되는 stale state 문제가 발생하지 않는다.
*/
getPcsVoltageStepUpList(params).then((res) => { getPcsVoltageStepUpList(params).then((res) => {
if (res?.result.resultCode === 'S' && res?.data) { if (res?.result.resultCode === 'S' && res?.data) {
const dataArray = Array.isArray(res.data) ? res.data : [res.data] const dataArray = Array.isArray(res.data) ? res.data : [res.data]
const stepUpListData = formatStepUpListData(dataArray) const newStepUpListData = formatStepUpListData(dataArray)
setStepUpListData(newStepUpListData)
/** PCS 승압설정 정보 SET */ applyCircuitsToCanvas(newStepUpListData)
setStepUpListData(stepUpListData)
/** PCS 옵션 조회 */
// const formattedOptCodes = formatOptionCodes(res.data.optionList)
// setOptCodes(formattedOptCodes)
// setSeletedOption(formattedOptCodes[0])
} else { } else {
swalFire({ text: getMessage('common.message.send.error') }) swalFire({ text: getMessage('common.message.send.error') })
applyCircuitsToCanvas(tempStepUpListData)
} }
}) })
return
} }
} }
/** 모듈 목록 삭제 */ /** API 재조회가 없는 경로: 현재 tempStepUpListData 로 즉시 캔버스 갱신 */
canvas applyCircuitsToCanvas(tempStepUpListData)
.getObjects()
.filter((obj) => obj.name === POLYGON_TYPE.MODULE)
.forEach((module) => {
module.circuit = null
module.circuitNumber = null
module.pcsItemId = null
})
/** 선택된 모듈 목록 추가 */
selectedData.roofSurfaceList.forEach((roofSurface) => {
const targetSurface = canvas.getObjects().filter((obj) => obj.id === roofSurface.roofSurfaceId)[0]
const moduleIds = targetSurface.modules.map((module) => {
return module.id
})
/** 모듈 목록 삭제 */
canvas
.getObjects()
.filter((obj) => moduleIds.includes(obj.parentId))
.map((text) => {
canvas.remove(text)
})
/** 모듈 목록 추가 */
canvas.renderAll()
roofSurface.moduleList.forEach((module) => {
const targetModule = canvas.getObjects().find((obj) => obj.id === module.uniqueId)
if (targetModule && module.circuit !== '' && module.circuit !== null) {
const moduleCircuitText = new fabric.Text(module.circuit, {
left: targetModule.left + targetModule.width / 2,
top: targetModule.top + targetModule.height / 2,
fontFamily: circuitNumberText.fontFamily.value,
fontWeight: circuitNumberText.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
fontStyle: circuitNumberText.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
fontSize: circuitNumberText.fontSize.value,
fill: circuitNumberText.fontColor.value,
width: targetModule.width,
height: targetModule.height,
textAlign: 'center',
originX: 'center',
originY: 'center',
name: 'circuitNumber',
parentId: targetModule.id,
circuitInfo: module.pcsItemId,
visible: isDisplayCircuitNumber,
})
targetModule.circuit = moduleCircuitText
targetModule.pcsItemId = module.pcsItemId
targetModule.circuitNumber = module.circuit
canvas.add(moduleCircuitText)
}
})
})
canvas.renderAll()
setModuleStatisticsData()
} }
/** /**

View File

@ -268,15 +268,14 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
}, { skipSideEffects: true }) }, { skipSideEffects: true })
const roofs = canvas.getObjects().filter((obj) => obj.roofMaterial?.index === 0) const roofs = canvas.getObjects().filter((obj) => obj.roofMaterial?.index === 0)
const firstRoof = roofs[0]
if (firstRoof) { roofs.forEach((roof) => {
/** 모양 패턴 설정 */ /** 모양 패턴 설정 */
setSurfaceShapePattern(firstRoof, roofDisplay.column, false, { ...roofInfo }) setSurfaceShapePattern(roof, roofDisplay.column, false, { ...roofInfo })
firstRoof.roofMaterial = { ...roofInfo } roof.roofMaterial = { ...roofInfo }
drawDirectionArrow(firstRoof) drawDirectionArrow(roof)
setPolygonLinesActualSize(firstRoof, true) setPolygonLinesActualSize(roof, true)
} })
canvas.renderAll() canvas.renderAll()
/** 지붕면 존재 여부에 따라 메뉴 설정 */ /** 지붕면 존재 여부에 따라 메뉴 설정 */

View File

@ -8,7 +8,8 @@ import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
import { dimmedStore, sessionStore } from '@/store/commonAtom' import { dimmedStore, sessionStore } from '@/store/commonAtom'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
import { checkSession, logout } from '@/lib/authActions' import { checkSession } from '@/lib/authActions'
import { useLogout } from '@/hooks/useLogout'
import QSelectBox from '@/components/common/select/QSelectBox' import QSelectBox from '@/components/common/select/QSelectBox'
@ -49,6 +50,7 @@ export default function Header(props) {
const [stuffSearch, setStuffSearch] = useRecoilState(stuffSearchState) const [stuffSearch, setStuffSearch] = useRecoilState(stuffSearchState)
const { closeAll } = usePopup() const { closeAll } = usePopup()
const { logoutProcess } = useLogout()
const { userSession } = props const { userSession } = props
const [sessionState, setSessionState] = useRecoilState(sessionStore) const [sessionState, setSessionState] = useRecoilState(sessionStore)
@ -372,14 +374,7 @@ export default function Header(props) {
<div className="sign-out-box"> <div className="sign-out-box">
<button <button
className="sign-out" className="sign-out"
onClick={() => { onClick={() => logoutProcess()}
setStuffSearch({
...stuffSearch,
code: 'DELETE',
})
logout()
router.replace('/login', undefined, { shallow: true })
}}
> >
{getMessage('header.logout')} {getMessage('header.logout')}
</button> </button>

View File

@ -5,10 +5,10 @@ import { sessionStore } from '@/store/commonAtom'
import { useRecoilState, useRecoilValue } from 'recoil' import { useRecoilState, useRecoilValue } from 'recoil'
import { useAxios } from '@/hooks/useAxios' import { useAxios } from '@/hooks/useAxios'
import { globalLocaleStore } from '@/store/localeAtom' import { globalLocaleStore } from '@/store/localeAtom'
import { login, logout, setSession } from '@/lib/authActions' import { login, setSession } from '@/lib/authActions'
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
import { QcastContext } from '@/app/QcastProvider' import { QcastContext } from '@/app/QcastProvider'
import { useRouter } from 'next/navigation' import { useLogout } from '@/hooks/useLogout'
export default function ChangePasswordPop(props) { export default function ChangePasswordPop(props) {
const globalLocaleState = useRecoilValue(globalLocaleStore) const globalLocaleState = useRecoilValue(globalLocaleStore)
@ -19,7 +19,7 @@ export default function ChangePasswordPop(props) {
const { patch } = useAxios(globalLocaleState) const { patch } = useAxios(globalLocaleState)
const { getMessage } = useMessage() const { getMessage } = useMessage()
const [sessionState, setSessionState] = useRecoilState(sessionStore) const [sessionState, setSessionState] = useRecoilState(sessionStore)
const router = useRouter() const { logoutProcess } = useLogout()
const formInitValue = { const formInitValue = {
password1: '', password1: '',
password2: '', password2: '',
@ -129,15 +129,13 @@ export default function ChangePasswordPop(props) {
} }
} else { } else {
setIsGlobalLoading(false) setIsGlobalLoading(false)
logout() logoutProcess()
router.replace('/login', undefined, { shallow: true })
console.log('code not 200 error') console.log('code not 200 error')
} }
}) })
.catch((error) => { .catch((error) => {
setIsGlobalLoading(false) setIsGlobalLoading(false)
logout() logoutProcess()
router.replace('/login', undefined, { shallow: true })
console.log('catch::::::::', error) console.log('catch::::::::', error)
}) })
} }
@ -209,10 +207,7 @@ export default function ChangePasswordPop(props) {
<button <button
type="button" type="button"
className="btn-origin grey" className="btn-origin grey"
onClick={() => { onClick={() => logoutProcess()}
logout()
router.replace('/login', undefined, { shallow: true })
}}
> >
{getMessage('main.popup.login.btn2')} {getMessage('main.popup.login.btn2')}
</button> </button>

View File

@ -1868,7 +1868,7 @@ export default function StuffDetail() {
})} })}
{/* 라디오끝 */} {/* 라디오끝 */}
<div className="input-wrap mr5" style={{ width: '545px' }}> <div className="input-wrap mr5" style={{ width: '545px' }}>
<input type="text" className="input-light" {...form.register('objectName')} /> <input type="text" className="input-light" maxLength={50} {...form.register('objectName')} />
</div> </div>
<div className="select-wrap" style={{ width: '120px' }}> <div className="select-wrap" style={{ width: '120px' }}>
<Select <Select
@ -2461,7 +2461,7 @@ export default function StuffDetail() {
})} })}
{/* 상세라디오끝 */} {/* 상세라디오끝 */}
<div className="input-wrap mr5" style={{ width: '545px' }}> <div className="input-wrap mr5" style={{ width: '545px' }}>
<input type="text" className="input-light" {...form.register('objectName')} /> <input type="text" className="input-light" maxLength={50} {...form.register('objectName')} />
</div> </div>
<div className="select-wrap" style={{ width: '120px' }}> <div className="select-wrap" style={{ width: '120px' }}>
<Select <Select

View File

@ -239,7 +239,13 @@ export function useRoofFn() {
let mousePos = canvas.getPointer(event.e) let mousePos = canvas.getPointer(event.e)
mousePos = { x: Math.round(mousePos.x), y: Math.round(mousePos.y) } mousePos = { x: Math.round(mousePos.x), y: Math.round(mousePos.y) }
const texts = canvas.getObjects().filter((obj) => obj.type === 'text' && (obj.attributes?.roofId === roof.id || obj.parentId === roof.id)) // 'text' 뿐 아니라 'textbox' (보조선의 lengthText 는 fabric.Textbox) 도 함께 제거.
// name 기반으로 필터링하는 게 더 안전하다.
const texts = canvas.getObjects().filter((obj) => {
const isTextObj = obj.type === 'text' || obj.type === 'textbox' || obj.name === 'lengthText' || obj.name === 'pitchText'
if (!isTextObj) return false
return obj.attributes?.roofId === roof.id || obj.parentId === roof.id
})
texts.forEach((text) => canvas.remove(text)) texts.forEach((text) => canvas.remove(text))
const allRoofObject = canvas const allRoofObject = canvas

View File

@ -167,9 +167,13 @@ export const useTrestle = () => {
}) })
if (isEaveBar) { if (isEaveBar) {
// 처마력바설치 true인 경우 설치 // 처마커버 설치: cvrLmtRow 값에 따라 설치 단 수 제한
// exposedBottomModules는 아래가 노출된 최하단 모듈이므로 level 체크 없이 항상 설치 // - cvrLmtRow=1: 최하단(level 1)만 설치
// - cvrLmtRow=2: 최하단과 그 위 1단(level 1~2) 설치
// - cvrLmtRow=9999: 모든 단에 설치
exposedBottomModules.forEach((module) => { exposedBottomModules.forEach((module) => {
const level = module.level
if (level > cvrLmtRow) return
const bottomPoints = findTopTwoPoints([...module.getCurrentPoints()], direction) const bottomPoints = findTopTwoPoints([...module.getCurrentPoints()], direction)
if (!bottomPoints) return if (!bottomPoints) return
const eaveBar = new fabric.Line([bottomPoints[0].x, bottomPoints[0].y, bottomPoints[1].x, bottomPoints[1].y], { const eaveBar = new fabric.Line([bottomPoints[0].x, bottomPoints[0].y, bottomPoints[1].x, bottomPoints[1].y], {
@ -799,6 +803,7 @@ export const useTrestle = () => {
const parent = canvas.getObjects().find((obj) => obj.id === surface.parentId) const parent = canvas.getObjects().find((obj) => obj.id === surface.parentId)
const { directionText, roofMaterial, moduleCompass, surfaceCompass } = parent const { directionText, roofMaterial, moduleCompass, surfaceCompass } = parent
const slope = Number(roofMaterial.pitch) const slope = Number(roofMaterial.pitch)
const angle = Number(roofMaterial.angle) || getDegreeByChon(slope)
const roofMaterialIndex = parent.roofMaterial.index const roofMaterialIndex = parent.roofMaterial.index
const { nameJp: roofMaterialIdMulti } = roofMaterial const { nameJp: roofMaterialIdMulti } = roofMaterial
const moduleSelection = moduleSelectionData?.roofConstructions?.find((construction) => construction.roofIndex === roofMaterialIndex) const moduleSelection = moduleSelectionData?.roofConstructions?.find((construction) => construction.roofIndex === roofMaterialIndex)
@ -836,7 +841,7 @@ export const useTrestle = () => {
supportMeaker, supportMeaker,
slope: +roofSizeSet === 3 ? 0 : slope, slope: +roofSizeSet === 3 ? 0 : slope,
classType: currentAngleType === 'slope' ? '0' : '1', classType: currentAngleType === 'slope' ? '0' : '1',
angle: +roofSizeSet === 3 ? 0 : getDegreeByChon(slope), angle: +roofSizeSet === 3 ? 0 : angle,
azimuth: getAzimuth(parent), azimuth: getAzimuth(parent),
moduleList, moduleList,
} }
@ -1122,7 +1127,7 @@ export const useTrestle = () => {
return return
} }
const roof = canvas.getObjects().find((obj) => obj.id === surface.parentId) const roof = canvas.getObjects().find((obj) => obj.id === surface.parentId)
const degree = getDegreeByChon(roof.roofMaterial.pitch) const degree = Number(roof.roofMaterial.angle) || getDegreeByChon(roof.roofMaterial.pitch)
rackIntvlPct = rackIntvlPct === 0 ? 1 : rackIntvlPct // 0인 경우 1로 변경 rackIntvlPct = rackIntvlPct === 0 ? 1 : rackIntvlPct // 0인 경우 1로 변경
rackIntvlPct = 100 / rackIntvlPct // 퍼센트로 변경 rackIntvlPct = 100 / rackIntvlPct // 퍼센트로 변경
const moduleLeft = lastX ?? left const moduleLeft = lastX ?? left

View File

@ -219,7 +219,27 @@ export function useEavesGableEdit(id) {
const roofBases = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.isFixed) const roofBases = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.isFixed)
// moveLine 속성 및 이동된 baseLines 좌표 보존
const savedMoveProps = {}
const savedBaseLineCoords = {}
roofBases.forEach((roof) => { roofBases.forEach((roof) => {
if (roof.moveFlowLine || roof.moveUpDown) {
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes?.roofId === roof.id)
savedMoveProps[roof.id] = {
moveFlowLine: roof.moveFlowLine,
moveUpDown: roof.moveUpDown,
moveDirect: roof.moveDirect,
moveSelectLine: roof.moveSelectLine,
movePosition: roof.movePosition,
}
if (wall?.baseLines) {
savedBaseLineCoords[roof.id] = wall.baseLines.map((bl) => ({
x1: bl.x1, y1: bl.y1, x2: bl.x2, y2: bl.y2,
startPoint: bl.startPoint ? { ...bl.startPoint } : undefined,
endPoint: bl.endPoint ? { ...bl.endPoint } : undefined,
}))
}
}
roof.innerLines.forEach((line) => { roof.innerLines.forEach((line) => {
removeLine(line) removeLine(line)
}) })
@ -234,8 +254,31 @@ export function useEavesGableEdit(id) {
wallLines.forEach((wallLine) => { wallLines.forEach((wallLine) => {
addPitchTextsByOuterLines() addPitchTextsByOuterLines()
const roof = drawRoofPolygon(wallLine) const roof = drawRoofPolygon(wallLine)
canvas?.renderAll() // moveLine 속성 복원
roof.drawHelpLine(settingModalFirstOptions) const saved = savedMoveProps[roof.id]
if (saved) {
// 이동된 baseLines 좌표 복원 (drawRoofPolygon이 리셋한 것을 되돌림)
const savedBL = savedBaseLineCoords[roof.id]
if (savedBL && roof.wall?.baseLines) {
roof.wall.baseLines.forEach((bl, i) => {
if (!savedBL[i]) return
bl.set({
x1: savedBL[i].x1, y1: savedBL[i].y1,
x2: savedBL[i].x2, y2: savedBL[i].y2,
startPoint: savedBL[i].startPoint,
endPoint: savedBL[i].endPoint,
})
})
}
// drawHelpLine 중에는 moveLine 미적용 (baseLines에 이미 반영됨)
// drawHelpLine 후 moveLine 속성 복원
canvas?.renderAll()
roof.drawHelpLine(settingModalFirstOptions)
Object.assign(roof, saved)
} else {
canvas?.renderAll()
roof.drawHelpLine(settingModalFirstOptions)
}
}) })
wallLines.forEach((wallLine) => { wallLines.forEach((wallLine) => {

44
src/hooks/useLogout.js Normal file
View File

@ -0,0 +1,44 @@
import { useRecoilState, useRecoilValue } from 'recoil'
import { sessionStore } from '@/store/commonAtom'
import { globalLocaleStore } from '@/store/localeAtom'
import { stuffSearchState } from '@/store/stuffAtom'
import { useAxios } from '@/hooks/useAxios'
import { logout } from '@/lib/authActions'
import Cookies from 'js-cookie'
/**
* 로그아웃 처리 Hook
* - 로그아웃 API 호출 성공/실패 상관없이 로그아웃 처리
* - Header, ChangePasswordPop 등에서 공통으로 사용
*/
export function useLogout() {
const [sessionState] = useRecoilState(sessionStore)
const [stuffSearch, setStuffSearch] = useRecoilState(stuffSearchState)
const globalLocaleState = useRecoilValue(globalLocaleStore)
const { promisePost } = useAxios(globalLocaleState)
const logoutProcess = async () => {
const param = {
loginId: sessionState.userId,
requestId: Cookies.get('sessionId'), // 로그인 시 저장한 sessionId
}
// 로그아웃 API 호출 (실패해도 로그아웃 진행)
try {
await promisePost({ url: '/api/login/v1.0/logout', data: param })
} catch (error) {
console.log('logout api error:', error)
}
// 물건검색 상태 초기화
setStuffSearch({
...stuffSearch,
code: 'DELETE',
})
Cookies.remove('sessionId') // sessionId 쿠키 삭제
await logout() // iron-session 세션 파기
window.location.href = '/login' // 로그인 페이지로 이동 (full reload)
}
return { logoutProcess }
}

View File

@ -1718,14 +1718,22 @@ export function useMode() {
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const next = (i + 1) % n const next = (i + 1) % n
// 인접한 두 edge가 같은 방향이면 겹치는 선 // 인접한 두 edge가 같은 방향이면 두 edge 를 하나로 합칠 수 있다.
// 이 때 제거해야 하는 정점은 두 edge 사이의 "junction" 정점 = vertex[next].
// (vertex[i] 는 edge[i-1] 의 끝이자 edge[i] 의 시작으로, 실제 코너일 수 있으므로 제거하면 안 됨.)
if (isSameDirection(edges[i].angle, edges[next].angle)) { if (isSameDirection(edges[i].angle, edges[next].angle)) {
// 짧은 쪽을 제거 대상으로 표시 removeIndices.add(next)
if (edges[i].length <= edges[next].length) { }
removeIndices.add(i) }
} else {
removeIndices.add(next) // 절대 길이가 너무 짧은 edge 는 inset offset (~20) 대비 불안정하므로 제거.
} // edge[i] 가 tiny 이면 시작 정점 vertex[i] 를 제거해 앞쪽 edge 와 병합한다.
// 이렇게 하면 남는 cleaned edge 의 line 매핑(cleanedLines[k]=lines[kept[k]]) 이
// edge[i+1] (실제 긴 edge) 의 line 을 쓰게 되므로 offset 계산이 안정해진다.
const TINY_EDGE_THRESHOLD = 30
for (let i = 0; i < n; i++) {
if (edges[i].length < TINY_EDGE_THRESHOLD) {
removeIndices.add(i)
} }
} }
@ -1747,10 +1755,29 @@ export function useMode() {
} }
function createMarginPolygon(polygon, lines, arcSegments = 0, bevelJoin = false) { function createMarginPolygon(polygon, lines, arcSegments = 0, bevelJoin = false) {
// 폴리곤의 winding 방향 보정: inwardEdgeNormal 이 CW (signedArea > 0) 가정이므로
// CCW 폴리곤은 vertex/lines 순서를 뒤집어 다시 createRoofPolygon 한 뒤 진행한다.
const rawSignedArea = polygon.edges.reduce(
(sum, edge) => sum + (edge.vertex1.x * edge.vertex2.y - edge.vertex2.x * edge.vertex1.y),
0,
)
if (rawSignedArea < 0) {
const reversedVertices = [...polygon.vertices].reverse()
const reversedLines = []
const n = lines.length
// reverse 후 edge j 는 원본 edge (n-2-j) 의 역방향에 해당
// (reverse edge 0 = v[n-1]→v[n-2] = 원본 edge n-2 역방향, ..., 마지막 edge = 원본 edge n-1 역방향)
for (let j = 0; j < n; j++) {
reversedLines.push(lines[((n - 2 - j) % n + n) % n])
}
polygon = createRoofPolygon(reversedVertices)
lines = reversedLines
}
const offsetEdges = [] const offsetEdges = []
polygon.edges.forEach((edge, i) => { polygon.edges.forEach((edge, i) => {
const offset = lines[i % lines.length].attributes.offset const offset = lines[i % lines.length].attributes.offset === 0 ? 1 : lines[i % lines.length].attributes.offset
const dx = edge.outwardNormal.x * offset const dx = edge.outwardNormal.x * offset
const dy = edge.outwardNormal.y * offset const dy = edge.outwardNormal.y * offset
offsetEdges.push(createOffsetEdge(edge, dx, dy)) offsetEdges.push(createOffsetEdge(edge, dx, dy))
@ -1762,13 +1789,10 @@ export function useMode() {
const prevEdge = offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length] const prevEdge = offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length]
const vertex = edgesIntersection(prevEdge, thisEdge) const vertex = edgesIntersection(prevEdge, thisEdge)
if (vertex) { if (vertex) {
if (!vertex.isIntersectionOutside || !bevelJoin) { // 볼록/오목 관계없이 두 offset edge 의 무한 직선 교점이 올바른 inset 꼭지점.
vertices.push({ x: vertex.x, y: vertex.y }) // isIntersectionOutside 는 offset "선분" 기준 flag 일 뿐이고, self-intersection(스파이크)
} else { // 은 이후 cleanSelfIntersectingPolygon 과 clipOffsetToOriginal 에서 처리한다.
// 둔각 + bevelJoin: offset edge 끝점 사용 vertices.push({ x: vertex.x, y: vertex.y })
vertices.push({ x: prevEdge.vertex2.x, y: prevEdge.vertex2.y })
vertices.push({ x: thisEdge.vertex1.x, y: thisEdge.vertex1.y })
}
} }
}) })
@ -1785,11 +1809,28 @@ export function useMode() {
* @returns {{vertices, edges: *[], minX: *, minY: *, maxX: *, maxY: *}} * @returns {{vertices, edges: *[], minX: *, minY: *, maxX: *, maxY: *}}
*/ */
function createPaddingPolygon(polygon, lines, arcSegments = 0, bevelJoin = false) { function createPaddingPolygon(polygon, lines, arcSegments = 0, bevelJoin = false) {
// 폴리곤의 winding 방향 보정: inwardEdgeNormal 이 CW (signedArea > 0) 가정이므로
// CCW 폴리곤은 vertex/lines 순서를 뒤집어 다시 createRoofPolygon 한 뒤 진행한다.
const rawSignedArea = polygon.edges.reduce(
(sum, edge) => sum + (edge.vertex1.x * edge.vertex2.y - edge.vertex2.x * edge.vertex1.y),
0,
)
if (rawSignedArea < 0) {
const reversedVertices = [...polygon.vertices].reverse()
const reversedLines = []
const n = lines.length
// reverse 후 edge j 는 원본 edge (n-2-j) 의 역방향에 해당
for (let j = 0; j < n; j++) {
reversedLines.push(lines[((n - 2 - j) % n + n) % n])
}
polygon = createRoofPolygon(reversedVertices)
lines = reversedLines
}
const offsetEdges = [] const offsetEdges = []
polygon.edges.forEach((edge, i) => { polygon.edges.forEach((edge, i) => {
const offset = lines[i % lines.length].attributes.offset const offset = lines[i % lines.length].attributes.offset === 0 ? 1 : lines[i % lines.length].attributes.offset
const dx = edge.inwardNormal.x * offset const dx = edge.inwardNormal.x * offset
const dy = edge.inwardNormal.y * offset const dy = edge.inwardNormal.y * offset
offsetEdges.push(createOffsetEdge(edge, dx, dy)) offsetEdges.push(createOffsetEdge(edge, dx, dy))
@ -1801,13 +1842,10 @@ export function useMode() {
const prevEdge = offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length] const prevEdge = offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length]
const vertex = edgesIntersection(prevEdge, thisEdge) const vertex = edgesIntersection(prevEdge, thisEdge)
if (vertex) { if (vertex) {
if (!vertex.isIntersectionOutside || !bevelJoin) { // 볼록/오목 관계없이 두 offset edge 의 무한 직선 교점이 올바른 inset 꼭지점.
vertices.push({ x: vertex.x, y: vertex.y }) // isIntersectionOutside 는 offset "선분" 기준 flag 일 뿐이고, self-intersection(스파이크)
} else { // 은 이후 cleanSelfIntersectingPolygon 과 clipOffsetToOriginal 에서 처리한다.
// 둔각 + bevelJoin: offset edge 끝점 사용 vertices.push({ x: vertex.x, y: vertex.y })
vertices.push({ x: prevEdge.vertex2.x, y: prevEdge.vertex2.y })
vertices.push({ x: thisEdge.vertex1.x, y: thisEdge.vertex1.y })
}
} }
}) })

View File

@ -253,13 +253,12 @@ export default class SkeletonBuilder {
} }
private static CorrectBisectorDirection(bisector: LineParametric2d, beginNextVertex: Vertex, endPreviousVertex: Vertex, beginEdge: Edge, endEdge: Edge) { private static CorrectBisectorDirection(bisector: LineParametric2d, beginNextVertex: Vertex, endPreviousVertex: Vertex, beginEdge: Edge, endEdge: Edge) {
const beginEdge2 = beginNextVertex.PreviousEdge; // Use the vertex's actual edges — chain edges can become stale
const endEdge2 = endPreviousVertex.NextEdge; // when previous iterations of MultiSplitEvent modify the LAV
const actualBeginEdge = beginNextVertex.PreviousEdge;
const actualEndEdge = endPreviousVertex.NextEdge;
if (beginEdge !== beginEdge2 || endEdge !== endEdge2) if (actualBeginEdge.Norm.Dot(actualEndEdge.Norm) < -0.97) {
throw new Error();
if (beginEdge.Norm.Dot(endEdge.Norm) < -0.97) {
const n1 = PrimitiveUtils.FromTo(endPreviousVertex.Point, bisector.A).Normalized(); const n1 = PrimitiveUtils.FromTo(endPreviousVertex.Point, bisector.A).Normalized();
const n2 = PrimitiveUtils.FromTo(bisector.A, beginNextVertex.Point).Normalized(); const n2 = PrimitiveUtils.FromTo(bisector.A, beginNextVertex.Point).Normalized();
const bisectorPrediction = this.CalcVectorBisector(n1, n2); const bisectorPrediction = this.CalcVectorBisector(n1, n2);
@ -887,7 +886,7 @@ export default class SkeletonBuilder {
private static GetEdgeInLav(lav: CircularList<Vertex>, oppositeEdge: Edge): Vertex { private static GetEdgeInLav(lav: CircularList<Vertex>, oppositeEdge: Edge): Vertex {
for (const node of lav.Iterate()) for (const node of lav.Iterate())
if (oppositeEdge === node.PreviousEdge || if (oppositeEdge === node.PreviousEdge ||
oppositeEdge === node.Previous.Next) oppositeEdge === (node.Previous as Vertex).NextEdge)
return node; return node;
return null; return null;

View File

@ -256,8 +256,11 @@ function createPaddingPolygon(polygon, offset, arcSegments = 0) {
* @returns {Array} 정리된 배열 * @returns {Array} 정리된 배열
*/ */
/** /**
* offset 폴리곤의 꼭짓점 원본 폴리곤 바깥에 나간 점을 * offset 폴리곤을 원본 폴리곤 경계 안으로 클리핑한다.
* 원본 폴리곤 경계 위의 가장 가까운 점으로 투영한다. * 삼각형/예각 꼭짓점 등에서 bevel 때문에 offset 꼭짓점이 원본 바깥으로
* 튀어나가는 경우, 점별 투영이 아니라 turf.intersect 단위 클리핑을 수행한다.
* 점별 투영은 여러 점이 원본 바깥으로 나가면 점이 제각각 가까운 변으로
* 스냅되어 폴리곤이 직사각형처럼 찌그러지는 문제가 있었음.
* @param {Array} offsetVertices - offset된 배열 [{x, y}, ...] * @param {Array} offsetVertices - offset된 배열 [{x, y}, ...]
* @param {Array} originalVertices - 원본 폴리곤 배열 [{x, y}, ...] * @param {Array} originalVertices - 원본 폴리곤 배열 [{x, y}, ...]
* @returns {Array} 클리핑된 배열 * @returns {Array} 클리핑된 배열
@ -266,14 +269,15 @@ export function clipOffsetToOriginal(offsetVertices, originalVertices) {
if (!offsetVertices || offsetVertices.length < 3) return offsetVertices if (!offsetVertices || offsetVertices.length < 3) return offsetVertices
if (!originalVertices || originalVertices.length < 3) return offsetVertices if (!originalVertices || originalVertices.length < 3) return offsetVertices
// 점이 폴리곤 내부에 있는지 판단 (ray casting) // 점이 폴리곤 내부(또는 경계)에 있는지 판단 (ray casting)
function pointInPolygon(point, polygon) { const _pointInPolygon = (point, polygon) => {
let inside = false let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y const xi = polygon[i].x,
const xj = polygon[j].x, yj = polygon[j].y yi = polygon[i].y
if (((yi > point.y) !== (yj > point.y)) && const xj = polygon[j].x,
(point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi)) { yj = polygon[j].y
if (yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi) {
inside = !inside inside = !inside
} }
} }
@ -281,7 +285,7 @@ export function clipOffsetToOriginal(offsetVertices, originalVertices) {
} }
// 점을 선분 위의 가장 가까운 점으로 투영 // 점을 선분 위의 가장 가까운 점으로 투영
function projectOnSegment(p, a, b) { const _projectOnSegment = (p, a, b) => {
const dx = b.x - a.x const dx = b.x - a.x
const dy = b.y - a.y const dy = b.y - a.y
const lenSq = dx * dx + dy * dy const lenSq = dx * dx + dy * dy
@ -291,24 +295,79 @@ export function clipOffsetToOriginal(offsetVertices, originalVertices) {
return { x: a.x + t * dx, y: a.y + t * dy } return { x: a.x + t * dx, y: a.y + t * dy }
} }
return offsetVertices.map((p) => { // 최종 결과 정점을 원본 폴리곤 경계 안으로 스냅하는 post-process.
if (pointInPolygon(p, originalVertices)) return p // 부동소수점 누적/tiny edge 때문에 아주 미세하게 바깥에 있는 점만 복원한다.
const _snapToBoundary = (verts) => {
return verts.map((p) => {
if (_pointInPolygon(p, originalVertices)) return p
let minDist = Infinity
let nearest = p
for (let i = 0; i < originalVertices.length; i++) {
const a = originalVertices[i]
const b = originalVertices[(i + 1) % originalVertices.length]
const proj = _projectOnSegment(p, a, b)
const dd = (proj.x - p.x) * (proj.x - p.x) + (proj.y - p.y) * (proj.y - p.y)
if (dd < minDist) {
minDist = dd
nearest = proj
}
}
return nearest
})
}
// 바깥에 있으면 원본 폴리곤의 가장 가까운 변 위의 점으로 이동 try {
let minDist = Infinity const offsetCoords = offsetVertices.map((p) => [p.x, p.y])
let nearest = p offsetCoords.push([offsetVertices[0].x, offsetVertices[0].y])
for (let i = 0; i < originalVertices.length; i++) {
const a = originalVertices[i] const origCoords = originalVertices.map((p) => [p.x, p.y])
const b = originalVertices[(i + 1) % originalVertices.length] origCoords.push([originalVertices[0].x, originalVertices[0].y])
const proj = projectOnSegment(p, a, b)
const dist = (proj.x - p.x) * (proj.x - p.x) + (proj.y - p.y) * (proj.y - p.y) // 자기교차가 있으면 intersect 가 실패하므로 먼저 unkink
if (dist < minDist) { let offsetPoly = turf.polygon([offsetCoords])
minDist = dist const kinked = turf.kinks(offsetPoly)
nearest = proj if (kinked.features.length > 0) {
const unkinked = turf.unkinkPolygon(offsetPoly)
if (unkinked.features.length > 0) {
offsetPoly = unkinked.features.reduce((max, f) => (turf.area(f) > turf.area(max) ? f : max))
} }
} }
return nearest
}) const origPoly = turf.polygon([origCoords])
const clipped = turf.intersect(turf.featureCollection([offsetPoly, origPoly]))
if (clipped) {
// MultiPolygon 인 경우 가장 큰 폴리곤 선택
let coords
if (clipped.geometry.type === 'MultiPolygon') {
let maxArea = -Infinity
let bestRing = null
clipped.geometry.coordinates.forEach((poly) => {
const ring = poly[0]
const feat = turf.polygon([ring])
const area = turf.area(feat)
if (area > maxArea) {
maxArea = area
bestRing = ring
}
})
coords = bestRing
} else {
coords = clipped.geometry.coordinates[0]
}
if (coords && coords.length > 0) {
const result = coords.slice(0, -1).map((c) => ({ x: c[0], y: c[1] }))
// 부동소수점 오차로 미세하게 경계 밖에 있는 점만 경계로 스냅
return _snapToBoundary(result)
}
}
} catch (e) {
console.warn('Failed to clip offset polygon to original:', e)
}
// turf 실패 fallback: 바깥으로 나간 점만 원본 경계로 스냅
return _snapToBoundary(offsetVertices)
} }
export function cleanSelfIntersectingPolygon(vertices) { export function cleanSelfIntersectingPolygon(vertices) {

File diff suppressed because it is too large Load Diff