dev #931
@ -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_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_MUSUBI_AUTO_LOGIN_URL="http://q-musubi-stg.q-cells.jp:8120/qm/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="https://q-musubi-dev.q-cells.jp/qm/login/autoLogin"
|
||||
|
||||
# 테스트용
|
||||
# AWS_REGION="ap-northeast-2"
|
||||
|
||||
@ -8,6 +8,7 @@ import { useRecoilState } from 'recoil'
|
||||
import { useAxios } from '@/hooks/useAxios'
|
||||
import { globalLocaleStore } from '@/store/localeAtom'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
import GlobalSpinner from '@/components/common/spinner/GlobalSpinner'
|
||||
|
||||
@ -31,6 +32,7 @@ export default function AutoLoginPage({ autoLoginParam }) {
|
||||
setIsLoading(false)
|
||||
if (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' }
|
||||
setSession(result)
|
||||
setSessionState(result)
|
||||
|
||||
@ -48,6 +48,7 @@ export default function Login() {
|
||||
post({ url: '/api/login/v1.0/user', data: { loginId: res.data } }).then((response) => {
|
||||
setIsLoading(false)
|
||||
if (response) {
|
||||
if (response.sessionId) Cookies.set('sessionId', response.sessionId)
|
||||
const result = { ...response, storeLvl: response.groupId === '60000' ? '1' : '2', pwdInitYn: 'Y' }
|
||||
setSession(result)
|
||||
setSessionState(result)
|
||||
@ -107,6 +108,7 @@ export default function Login() {
|
||||
if (res) {
|
||||
setIsLoading(false)
|
||||
if (res.data.result.resultCode === 'S') {
|
||||
if (res.data.sessionId) Cookies.set('sessionId', res.data.sessionId)
|
||||
setSession(res.data.data)
|
||||
setSessionState(res.data.data)
|
||||
// ID SAVE 체크되어 있는 경우, 쿠키 저장
|
||||
|
||||
@ -95,6 +95,34 @@ export const QLine = fabric.util.createClass(fabric.Line, {
|
||||
if (this.attributes?.planeSize) {
|
||||
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 {
|
||||
this.setLength()
|
||||
const scaleX = this.scaleX
|
||||
|
||||
@ -136,9 +136,59 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
})
|
||||
|
||||
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.addLengthText()
|
||||
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', () => {
|
||||
@ -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 좌표 이동시 좌표 재계산
|
||||
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 transformedPoints = this.get('points')
|
||||
@ -204,6 +265,64 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
|
||||
this.setCoords()
|
||||
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 })
|
||||
|
||||
@ -18,7 +18,6 @@ import {
|
||||
currentCanvasPlanState,
|
||||
isManualModuleLayoutSetupState,
|
||||
isManualModuleSetupState,
|
||||
moduleSetupOptionState,
|
||||
toggleManualSetupModeState,
|
||||
} from '@/store/canvasAtom'
|
||||
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 [roofs, setRoofs] = useState(addedRoofs)
|
||||
const [manualSetupMode, setManualSetupMode] = useRecoilState(toggleManualSetupModeState)
|
||||
const [moduleSetupOption, setModuleSetupOption] = useRecoilState(moduleSetupOptionState)
|
||||
const [layoutSetup, setLayoutSetup] = useState([{}])
|
||||
const {
|
||||
selectedModules,
|
||||
@ -220,12 +218,8 @@ export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) {
|
||||
}
|
||||
|
||||
const handleManualModuleSetup = () => {
|
||||
const nextManual = !isManualModuleSetup
|
||||
setManualSetupMode(`manualSetup_${nextManual}`)
|
||||
setIsManualModuleSetup(nextManual)
|
||||
if (nextManual) {
|
||||
setModuleSetupOption((prev) => ({ ...prev, isChidori: true }))
|
||||
}
|
||||
setManualSetupMode(`manualSetup_${!isManualModuleSetup}`)
|
||||
setIsManualModuleSetup(!isManualModuleSetup)
|
||||
}
|
||||
|
||||
const handleManualModuleLayoutSetup = () => {
|
||||
|
||||
@ -130,6 +130,7 @@ export default function StepUp(props) {
|
||||
if (selectedSerQty) {
|
||||
selectedSerQty.roofSurfaceList.forEach((roofSurface) => {
|
||||
const targetSurface = canvas.getObjects().filter((obj) => obj.id === roofSurface.roofSurfaceId)[0]
|
||||
if (!targetSurface) return
|
||||
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) => {
|
||||
/** 자동 승압 설정인 경우만 실행 */
|
||||
if (allocationType !== 'auto') return
|
||||
|
||||
let tempStepUpListData = [...stepUpListData]
|
||||
let selectedData = {}
|
||||
|
||||
tempStepUpListData[0].pcsItemList[mainIdx].serQtyList.forEach((item, index) => {
|
||||
if (index === subIdx) {
|
||||
selectedData = item
|
||||
}
|
||||
item.selected = index === subIdx
|
||||
})
|
||||
/** 선택된 행 정보 저장 */
|
||||
setStepUpListData(tempStepUpListData)
|
||||
|
||||
/** PCS 2개 이상 또는 첫번째 PCS 선택 시에만 실행 */
|
||||
if (tempStepUpListData[0].pcsItemList.length > 1 && mainIdx === 0) {
|
||||
const needsRefetch =
|
||||
tempStepUpListData[0].pcsItemList.length > 1 && mainIdx === 0
|
||||
|
||||
if (needsRefetch) {
|
||||
/** 파워컨디셔너 옵션 조회 요청 파라미터 */
|
||||
const params = {
|
||||
...props.getOptYn(), // 옵션 Y/N
|
||||
useModuleItemList: props.getUseModuleItemList(), // 사용된 모듈아이템 List
|
||||
roofSurfaceList: props.getRoofSurfaceList(), // 지붕면 목록
|
||||
pcsItemList: props.getSelectedPcsItemList().map((pcsItem, index) => {
|
||||
/** PCS 아이템 목록
|
||||
* tempStepUpListData에서 해당 PCS 아이템 찾기
|
||||
* uniqueIndex를 사용하여 매칭
|
||||
*/
|
||||
const matchingPcsItem = tempStepUpListData[0].pcsItemList.find((item) => item.uniqueIndex === `${pcsItem.itemId}_${index}`)
|
||||
|
||||
/** 선택된 serQty 찾기 */
|
||||
const selectedSerQty = matchingPcsItem?.serQtyList.find((serQty) => serQty.selected)?.serQty || 0
|
||||
if (index === 0) {
|
||||
return {
|
||||
@ -453,86 +511,30 @@ export default function StepUp(props) {
|
||||
}
|
||||
|
||||
/** PCS가 1개 이고 2번째 또는 3번째 PCS serQty가 0인 경우는 추천 API 실행하지 않음 */
|
||||
if (params.pcsItemList.length !== 1 && (params.pcsItemList[1]?.applySerQty !== 0 || params.pcsItemList[2]?.applySerQty) !== 0) {
|
||||
/** PCS 승압설정 정보 조회 */
|
||||
const skipApi = !(params.pcsItemList.length !== 1 && (params.pcsItemList[1]?.applySerQty !== 0 || params.pcsItemList[2]?.applySerQty) !== 0)
|
||||
|
||||
if (!skipApi) {
|
||||
/**
|
||||
* PCS 승압설정 정보 조회. 캔버스 갱신은 응답을 받은 뒤에 수행해야
|
||||
* "한박자 늦게" 반영되는 stale state 문제가 발생하지 않는다.
|
||||
*/
|
||||
getPcsVoltageStepUpList(params).then((res) => {
|
||||
if (res?.result.resultCode === 'S' && res?.data) {
|
||||
const dataArray = Array.isArray(res.data) ? res.data : [res.data]
|
||||
const stepUpListData = formatStepUpListData(dataArray)
|
||||
|
||||
/** PCS 승압설정 정보 SET */
|
||||
setStepUpListData(stepUpListData)
|
||||
|
||||
/** PCS 옵션 조회 */
|
||||
// const formattedOptCodes = formatOptionCodes(res.data.optionList)
|
||||
// setOptCodes(formattedOptCodes)
|
||||
// setSeletedOption(formattedOptCodes[0])
|
||||
const newStepUpListData = formatStepUpListData(dataArray)
|
||||
setStepUpListData(newStepUpListData)
|
||||
applyCircuitsToCanvas(newStepUpListData)
|
||||
} else {
|
||||
swalFire({ text: getMessage('common.message.send.error') })
|
||||
applyCircuitsToCanvas(tempStepUpListData)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** 모듈 목록 삭제 */
|
||||
canvas
|
||||
.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()
|
||||
/** API 재조회가 없는 경로: 현재 tempStepUpListData 로 즉시 캔버스 갱신 */
|
||||
applyCircuitsToCanvas(tempStepUpListData)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -268,15 +268,14 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
||||
}, { skipSideEffects: true })
|
||||
|
||||
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 })
|
||||
firstRoof.roofMaterial = { ...roofInfo }
|
||||
drawDirectionArrow(firstRoof)
|
||||
setPolygonLinesActualSize(firstRoof, true)
|
||||
}
|
||||
setSurfaceShapePattern(roof, roofDisplay.column, false, { ...roofInfo })
|
||||
roof.roofMaterial = { ...roofInfo }
|
||||
drawDirectionArrow(roof)
|
||||
setPolygonLinesActualSize(roof, true)
|
||||
})
|
||||
canvas.renderAll()
|
||||
|
||||
/** 지붕면 존재 여부에 따라 메뉴 설정 */
|
||||
|
||||
@ -8,7 +8,8 @@ import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
|
||||
import { dimmedStore, sessionStore } from '@/store/commonAtom'
|
||||
|
||||
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'
|
||||
|
||||
@ -49,6 +50,7 @@ export default function Header(props) {
|
||||
const [stuffSearch, setStuffSearch] = useRecoilState(stuffSearchState)
|
||||
|
||||
const { closeAll } = usePopup()
|
||||
const { logoutProcess } = useLogout()
|
||||
|
||||
const { userSession } = props
|
||||
const [sessionState, setSessionState] = useRecoilState(sessionStore)
|
||||
@ -372,14 +374,7 @@ export default function Header(props) {
|
||||
<div className="sign-out-box">
|
||||
<button
|
||||
className="sign-out"
|
||||
onClick={() => {
|
||||
setStuffSearch({
|
||||
...stuffSearch,
|
||||
code: 'DELETE',
|
||||
})
|
||||
logout()
|
||||
router.replace('/login', undefined, { shallow: true })
|
||||
}}
|
||||
onClick={() => logoutProcess()}
|
||||
>
|
||||
{getMessage('header.logout')}
|
||||
</button>
|
||||
|
||||
@ -5,10 +5,10 @@ import { sessionStore } from '@/store/commonAtom'
|
||||
import { useRecoilState, useRecoilValue } from 'recoil'
|
||||
import { useAxios } from '@/hooks/useAxios'
|
||||
import { globalLocaleStore } from '@/store/localeAtom'
|
||||
import { login, logout, setSession } from '@/lib/authActions'
|
||||
import { login, setSession } from '@/lib/authActions'
|
||||
import { useSwal } from '@/hooks/useSwal'
|
||||
import { QcastContext } from '@/app/QcastProvider'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLogout } from '@/hooks/useLogout'
|
||||
|
||||
export default function ChangePasswordPop(props) {
|
||||
const globalLocaleState = useRecoilValue(globalLocaleStore)
|
||||
@ -19,7 +19,7 @@ export default function ChangePasswordPop(props) {
|
||||
const { patch } = useAxios(globalLocaleState)
|
||||
const { getMessage } = useMessage()
|
||||
const [sessionState, setSessionState] = useRecoilState(sessionStore)
|
||||
const router = useRouter()
|
||||
const { logoutProcess } = useLogout()
|
||||
const formInitValue = {
|
||||
password1: '',
|
||||
password2: '',
|
||||
@ -129,15 +129,13 @@ export default function ChangePasswordPop(props) {
|
||||
}
|
||||
} else {
|
||||
setIsGlobalLoading(false)
|
||||
logout()
|
||||
router.replace('/login', undefined, { shallow: true })
|
||||
logoutProcess()
|
||||
console.log('code not 200 error')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsGlobalLoading(false)
|
||||
logout()
|
||||
router.replace('/login', undefined, { shallow: true })
|
||||
logoutProcess()
|
||||
console.log('catch::::::::', error)
|
||||
})
|
||||
}
|
||||
@ -209,10 +207,7 @@ export default function ChangePasswordPop(props) {
|
||||
<button
|
||||
type="button"
|
||||
className="btn-origin grey"
|
||||
onClick={() => {
|
||||
logout()
|
||||
router.replace('/login', undefined, { shallow: true })
|
||||
}}
|
||||
onClick={() => logoutProcess()}
|
||||
>
|
||||
{getMessage('main.popup.login.btn2')}
|
||||
</button>
|
||||
|
||||
@ -1868,7 +1868,7 @@ export default function StuffDetail() {
|
||||
})}
|
||||
{/* 라디오끝 */}
|
||||
<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 className="select-wrap" style={{ width: '120px' }}>
|
||||
<Select
|
||||
@ -2461,7 +2461,7 @@ export default function StuffDetail() {
|
||||
})}
|
||||
{/* 상세라디오끝 */}
|
||||
<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 className="select-wrap" style={{ width: '120px' }}>
|
||||
<Select
|
||||
|
||||
@ -239,7 +239,13 @@ export function useRoofFn() {
|
||||
let mousePos = canvas.getPointer(event.e)
|
||||
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))
|
||||
|
||||
const allRoofObject = canvas
|
||||
|
||||
@ -167,9 +167,13 @@ export const useTrestle = () => {
|
||||
})
|
||||
|
||||
if (isEaveBar) {
|
||||
// 처마력바설치 true인 경우 설치
|
||||
// exposedBottomModules는 아래가 노출된 최하단 모듈이므로 level 체크 없이 항상 설치
|
||||
// 처마커버 설치: cvrLmtRow 값에 따라 설치 단 수 제한
|
||||
// - cvrLmtRow=1: 최하단(level 1)만 설치
|
||||
// - cvrLmtRow=2: 최하단과 그 위 1단(level 1~2) 설치
|
||||
// - cvrLmtRow=9999: 모든 단에 설치
|
||||
exposedBottomModules.forEach((module) => {
|
||||
const level = module.level
|
||||
if (level > cvrLmtRow) return
|
||||
const bottomPoints = findTopTwoPoints([...module.getCurrentPoints()], direction)
|
||||
if (!bottomPoints) return
|
||||
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 { directionText, roofMaterial, moduleCompass, surfaceCompass } = parent
|
||||
const slope = Number(roofMaterial.pitch)
|
||||
const angle = Number(roofMaterial.angle) || getDegreeByChon(slope)
|
||||
const roofMaterialIndex = parent.roofMaterial.index
|
||||
const { nameJp: roofMaterialIdMulti } = roofMaterial
|
||||
const moduleSelection = moduleSelectionData?.roofConstructions?.find((construction) => construction.roofIndex === roofMaterialIndex)
|
||||
@ -836,7 +841,7 @@ export const useTrestle = () => {
|
||||
supportMeaker,
|
||||
slope: +roofSizeSet === 3 ? 0 : slope,
|
||||
classType: currentAngleType === 'slope' ? '0' : '1',
|
||||
angle: +roofSizeSet === 3 ? 0 : getDegreeByChon(slope),
|
||||
angle: +roofSizeSet === 3 ? 0 : angle,
|
||||
azimuth: getAzimuth(parent),
|
||||
moduleList,
|
||||
}
|
||||
@ -1122,7 +1127,7 @@ export const useTrestle = () => {
|
||||
return
|
||||
}
|
||||
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 = 100 / rackIntvlPct // 퍼센트로 변경
|
||||
const moduleLeft = lastX ?? left
|
||||
|
||||
@ -219,7 +219,27 @@ export function useEavesGableEdit(id) {
|
||||
|
||||
const roofBases = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.isFixed)
|
||||
|
||||
// moveLine 속성 및 이동된 baseLines 좌표 보존
|
||||
const savedMoveProps = {}
|
||||
const savedBaseLineCoords = {}
|
||||
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) => {
|
||||
removeLine(line)
|
||||
})
|
||||
@ -234,8 +254,31 @@ export function useEavesGableEdit(id) {
|
||||
wallLines.forEach((wallLine) => {
|
||||
addPitchTextsByOuterLines()
|
||||
const roof = drawRoofPolygon(wallLine)
|
||||
canvas?.renderAll()
|
||||
roof.drawHelpLine(settingModalFirstOptions)
|
||||
// moveLine 속성 복원
|
||||
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) => {
|
||||
|
||||
44
src/hooks/useLogout.js
Normal file
44
src/hooks/useLogout.js
Normal 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 }
|
||||
}
|
||||
@ -1718,14 +1718,22 @@ export function useMode() {
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
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 (edges[i].length <= edges[next].length) {
|
||||
removeIndices.add(i)
|
||||
} else {
|
||||
removeIndices.add(next)
|
||||
}
|
||||
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) {
|
||||
// 폴리곤의 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 = []
|
||||
|
||||
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 dy = edge.outwardNormal.y * offset
|
||||
offsetEdges.push(createOffsetEdge(edge, dx, dy))
|
||||
@ -1762,13 +1789,10 @@ export function useMode() {
|
||||
const prevEdge = offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length]
|
||||
const vertex = edgesIntersection(prevEdge, thisEdge)
|
||||
if (vertex) {
|
||||
if (!vertex.isIntersectionOutside || !bevelJoin) {
|
||||
vertices.push({ x: vertex.x, y: vertex.y })
|
||||
} else {
|
||||
// 둔각 + bevelJoin: offset edge 끝점 사용
|
||||
vertices.push({ x: prevEdge.vertex2.x, y: prevEdge.vertex2.y })
|
||||
vertices.push({ x: thisEdge.vertex1.x, y: thisEdge.vertex1.y })
|
||||
}
|
||||
// 볼록/오목 관계없이 두 offset edge 의 무한 직선 교점이 올바른 inset 꼭지점.
|
||||
// isIntersectionOutside 는 offset "선분" 기준 flag 일 뿐이고, self-intersection(스파이크)
|
||||
// 은 이후 cleanSelfIntersectingPolygon 과 clipOffsetToOriginal 에서 처리한다.
|
||||
vertices.push({ x: vertex.x, y: vertex.y })
|
||||
}
|
||||
})
|
||||
|
||||
@ -1785,11 +1809,28 @@ export function useMode() {
|
||||
* @returns {{vertices, edges: *[], minX: *, minY: *, maxX: *, maxY: *}}
|
||||
*/
|
||||
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 = []
|
||||
|
||||
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 dy = edge.inwardNormal.y * offset
|
||||
offsetEdges.push(createOffsetEdge(edge, dx, dy))
|
||||
@ -1801,13 +1842,10 @@ export function useMode() {
|
||||
const prevEdge = offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length]
|
||||
const vertex = edgesIntersection(prevEdge, thisEdge)
|
||||
if (vertex) {
|
||||
if (!vertex.isIntersectionOutside || !bevelJoin) {
|
||||
vertices.push({ x: vertex.x, y: vertex.y })
|
||||
} else {
|
||||
// 둔각 + bevelJoin: offset edge 끝점 사용
|
||||
vertices.push({ x: prevEdge.vertex2.x, y: prevEdge.vertex2.y })
|
||||
vertices.push({ x: thisEdge.vertex1.x, y: thisEdge.vertex1.y })
|
||||
}
|
||||
// 볼록/오목 관계없이 두 offset edge 의 무한 직선 교점이 올바른 inset 꼭지점.
|
||||
// isIntersectionOutside 는 offset "선분" 기준 flag 일 뿐이고, self-intersection(스파이크)
|
||||
// 은 이후 cleanSelfIntersectingPolygon 과 clipOffsetToOriginal 에서 처리한다.
|
||||
vertices.push({ x: vertex.x, y: vertex.y })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -253,13 +253,12 @@ export default class SkeletonBuilder {
|
||||
}
|
||||
|
||||
private static CorrectBisectorDirection(bisector: LineParametric2d, beginNextVertex: Vertex, endPreviousVertex: Vertex, beginEdge: Edge, endEdge: Edge) {
|
||||
const beginEdge2 = beginNextVertex.PreviousEdge;
|
||||
const endEdge2 = endPreviousVertex.NextEdge;
|
||||
// Use the vertex's actual edges — chain edges can become stale
|
||||
// when previous iterations of MultiSplitEvent modify the LAV
|
||||
const actualBeginEdge = beginNextVertex.PreviousEdge;
|
||||
const actualEndEdge = endPreviousVertex.NextEdge;
|
||||
|
||||
if (beginEdge !== beginEdge2 || endEdge !== endEdge2)
|
||||
throw new Error();
|
||||
|
||||
if (beginEdge.Norm.Dot(endEdge.Norm) < -0.97) {
|
||||
if (actualBeginEdge.Norm.Dot(actualEndEdge.Norm) < -0.97) {
|
||||
const n1 = PrimitiveUtils.FromTo(endPreviousVertex.Point, bisector.A).Normalized();
|
||||
const n2 = PrimitiveUtils.FromTo(bisector.A, beginNextVertex.Point).Normalized();
|
||||
const bisectorPrediction = this.CalcVectorBisector(n1, n2);
|
||||
@ -887,7 +886,7 @@ export default class SkeletonBuilder {
|
||||
private static GetEdgeInLav(lav: CircularList<Vertex>, oppositeEdge: Edge): Vertex {
|
||||
for (const node of lav.Iterate())
|
||||
if (oppositeEdge === node.PreviousEdge ||
|
||||
oppositeEdge === node.Previous.Next)
|
||||
oppositeEdge === (node.Previous as Vertex).NextEdge)
|
||||
return node;
|
||||
|
||||
return null;
|
||||
|
||||
@ -256,8 +256,11 @@ function createPaddingPolygon(polygon, offset, arcSegments = 0) {
|
||||
* @returns {Array} 정리된 점 배열
|
||||
*/
|
||||
/**
|
||||
* offset 폴리곤의 꼭짓점 중 원본 폴리곤 바깥에 나간 점을
|
||||
* 원본 폴리곤 경계 위의 가장 가까운 점으로 투영한다.
|
||||
* offset 폴리곤을 원본 폴리곤 경계 안으로 클리핑한다.
|
||||
* 삼각형/예각 꼭짓점 등에서 bevel 때문에 offset 꼭짓점이 원본 바깥으로
|
||||
* 튀어나가는 경우, 점별 투영이 아니라 turf.intersect 로 면 단위 클리핑을 수행한다.
|
||||
* 점별 투영은 여러 점이 원본 바깥으로 나가면 각 점이 제각각 가까운 변으로
|
||||
* 스냅되어 폴리곤이 직사각형처럼 찌그러지는 문제가 있었음.
|
||||
* @param {Array} offsetVertices - offset된 점 배열 [{x, y}, ...]
|
||||
* @param {Array} originalVertices - 원본 폴리곤 점 배열 [{x, y}, ...]
|
||||
* @returns {Array} 클리핑된 점 배열
|
||||
@ -266,14 +269,15 @@ export function clipOffsetToOriginal(offsetVertices, originalVertices) {
|
||||
if (!offsetVertices || offsetVertices.length < 3) return offsetVertices
|
||||
if (!originalVertices || originalVertices.length < 3) return offsetVertices
|
||||
|
||||
// 점이 폴리곤 내부에 있는지 판단 (ray casting)
|
||||
function pointInPolygon(point, polygon) {
|
||||
// 점이 폴리곤 내부(또는 경계)에 있는지 판단 (ray casting)
|
||||
const _pointInPolygon = (point, polygon) => {
|
||||
let inside = false
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x, yi = polygon[i].y
|
||||
const xj = polygon[j].x, yj = polygon[j].y
|
||||
if (((yi > point.y) !== (yj > point.y)) &&
|
||||
(point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi)) {
|
||||
const xi = polygon[i].x,
|
||||
yi = polygon[i].y
|
||||
const xj = polygon[j].x,
|
||||
yj = polygon[j].y
|
||||
if (yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi) {
|
||||
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 dy = b.y - a.y
|
||||
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 offsetVertices.map((p) => {
|
||||
if (pointInPolygon(p, originalVertices)) return p
|
||||
// 최종 결과 정점을 원본 폴리곤 경계 안으로 스냅하는 post-process.
|
||||
// 부동소수점 누적/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
|
||||
})
|
||||
}
|
||||
|
||||
// 바깥에 있으면 원본 폴리곤의 가장 가까운 변 위의 점으로 이동
|
||||
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 dist = (proj.x - p.x) * (proj.x - p.x) + (proj.y - p.y) * (proj.y - p.y)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
nearest = proj
|
||||
try {
|
||||
const offsetCoords = offsetVertices.map((p) => [p.x, p.y])
|
||||
offsetCoords.push([offsetVertices[0].x, offsetVertices[0].y])
|
||||
|
||||
const origCoords = originalVertices.map((p) => [p.x, p.y])
|
||||
origCoords.push([originalVertices[0].x, originalVertices[0].y])
|
||||
|
||||
// 자기교차가 있으면 intersect 가 실패하므로 먼저 unkink
|
||||
let offsetPoly = turf.polygon([offsetCoords])
|
||||
const kinked = turf.kinks(offsetPoly)
|
||||
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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user