Merge remote-tracking branch 'origin/dev' into claude/relaxed-jang-35f22b

This commit is contained in:
sangwook yoo 2026-05-15 15:10:11 +09:00
commit 9c0871d1fb
19 changed files with 704 additions and 400 deletions

View File

@ -11,7 +11,7 @@ const s3 = new S3Client({
})
const uploadImage = async (file) => {
console.log('🚀 ~ uploadImage ~ file:', file)
// console.log('🚀 ~ uploadImage ~ file:', file)
const Body = Buffer.from(await file.arrayBuffer())
const Key = `cads/${file.name}`
const ContentType = 'image/png'
@ -49,7 +49,7 @@ export async function DELETE(req) {
try {
const searchParams = req.nextUrl.searchParams
const Key = `cads/${searchParams.get('fileName')}`
console.log('🚀 ~ DELETE ~ Key:', Key)
// console.log('🚀 ~ DELETE ~ Key:', Key)
if (!Key) {
return NextResponse.json({ error: 'fileName parameter is required' }, { status: 400 })

View File

@ -57,7 +57,7 @@ export async function DELETE(req) {
try {
const searchParams = req.nextUrl.searchParams
const Key = `maps/${searchParams.get('fileName')}`
console.log('🚀 ~ DELETE ~ Key:', Key)
// console.log('🚀 ~ DELETE ~ Key:', Key)
if (!Key) {
return NextResponse.json({ error: 'fileName parameter is required' }, { status: 400 })

View File

@ -55,7 +55,7 @@ export async function DELETE(req) {
}
const Key = `upload/${fileName}`
console.log('🚀 ~ DELETE ~ Key:', Key)
// console.log('🚀 ~ DELETE ~ Key:', Key)
await s3.send(
new DeleteObjectCommand({

View File

@ -10,6 +10,8 @@ import Header from '@/components/header/Header'
import QModal from '@/components/common/modal/QModal'
import PopupManager from '@/components/common/popupManager/PopupManager'
import ErrorBoundary from '@/components/common/ErrorBoundary'
import PageTracker from '@/components/common/PageTracker' // [PAGE-TRACKER 2026-05-14] 라우트 변경 시 콘솔/탭타이틀에 경로 표시
import ModalTracker from '@/components/common/ModalTracker' // [MODAL-TRACKER 2026-05-14] 모달/팝업 open·close 추적
import './globals.css'
import '../styles/style.scss'
@ -73,6 +75,8 @@ export default async function RootLayout({ children }) {
<ErrorBoundary>
<html lang="en">
<body>
<PageTracker />
<ModalTracker />
{headerPathname === '/login' || headerPathname === '/join' ? (
<QcastProvider>{children}</QcastProvider>
) : (

View File

@ -0,0 +1,88 @@
'use client'
// [MODAL-TRACKER 2026-05-14] QModal / PopupManager / contextPopup atom
// · /id . .
import { useEffect, useRef } from 'react'
import { useRecoilValue } from 'recoil'
import { modalState, modalContent } from '@/store/modalAtom'
import { popupState, contextPopupState } from '@/store/popupAtom'
import { logger } from '@/util/logger'
const TRACK_ENABLED = process.env.NEXT_PUBLIC_ENABLE_LOGGING === 'true'
const BADGE_OPEN = 'background:#7b1fa2;color:#fff;padding:2px 8px;border-radius:3px;font-weight:bold'
const BADGE_CLOSE = 'background:#9e9e9e;color:#fff;padding:2px 8px;border-radius:3px'
const BADGE_POPUP = 'background:#00897b;color:#fff;padding:2px 8px;border-radius:3px;font-weight:bold'
const BADGE_CTX = 'background:#ef6c00;color:#fff;padding:2px 8px;border-radius:3px;font-weight:bold'
function describeNode(node) {
if (!node) return 'null'
if (Array.isArray(node)) return `Array(${node.length})`
const t = node?.type
if (!t) return typeof node
return typeof t === 'string' ? t : t.displayName || t.name || 'Anonymous'
}
export default function ModalTracker() {
const open = useRecoilValue(modalState)
const content = useRecoilValue(modalContent)
const popup = useRecoilValue(popupState)
const ctx = useRecoilValue(contextPopupState)
const firstModal = useRef(true)
const prevPopupIds = useRef({ config: [], other: [] })
const prevCtx = useRef(null)
// QModal open/close
useEffect(() => {
if (!TRACK_ENABLED) return
if (firstModal.current) {
firstModal.current = false
return
}
if (open) {
logger.info(`%c[MODAL OPEN] ${describeNode(content)}`, BADGE_OPEN)
} else {
logger.info(`%c[MODAL CLOSE]`, BADGE_CLOSE)
}
}, [open])
// PopupManager push/pop (config + other )
useEffect(() => {
if (!TRACK_ENABLED) return
const prev = prevPopupIds.current
for (const bucket of ['config', 'other']) {
const cur = popup?.[bucket] || []
const curIds = cur.map((p) => p.id)
const prevIds = prev[bucket] || []
curIds
.filter((id) => !prevIds.includes(id))
.forEach((id) => {
const item = cur.find((p) => p.id === id)
logger.info(`%c[POPUP+ ${bucket}] ${describeNode(item?.component)} (id=${id})`, BADGE_POPUP)
})
prevIds
.filter((id) => !curIds.includes(id))
.forEach((id) => {
logger.info(`%c[POPUP- ${bucket}] (id=${id})`, BADGE_CLOSE)
})
}
prevPopupIds.current = {
config: (popup?.config || []).map((p) => p.id),
other: (popup?.other || []).map((p) => p.id),
}
}, [popup])
// contextPopup ( )
useEffect(() => {
if (!TRACK_ENABLED) return
if (ctx && !prevCtx.current) {
logger.info(`%c[CTX POPUP+] ${describeNode(ctx)}`, BADGE_CTX)
} else if (!ctx && prevCtx.current) {
logger.info(`%c[CTX POPUP-]`, BADGE_CLOSE)
}
prevCtx.current = ctx
}, [ctx])
return null
}

View File

@ -0,0 +1,28 @@
'use client'
// [PAGE-TRACKER 2026-05-14] + document.title .
// production NEXT_PUBLIC_ENABLE_LOGGING=false logger.info .
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { logger } from '@/util/logger'
const TRACK_ENABLED = process.env.NEXT_PUBLIC_ENABLE_LOGGING === 'true'
export default function PageTracker() {
const pathname = usePathname()
useEffect(() => {
if (!TRACK_ENABLED || !pathname) return
logger.info(
`%c[PAGE] ${pathname}`,
'background:#1e88e5;color:#fff;padding:2px 8px;border-radius:3px;font-weight:bold',
)
if (typeof document !== 'undefined') {
document.title = `${pathname} · HANASYS DESIGN`
}
}, [pathname])
return null
}

View File

@ -2,7 +2,7 @@ import { fabric } from 'fabric'
import { v4 as uuidv4 } from 'uuid'
import { QLine } from '@/components/fabric/QLine'
import { distanceBetweenPoints, findTopTwoIndexesByDistance, getDirectionByPoint, sortedPointLessEightPoint, sortedPoints } from '@/util/canvas-util'
import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, toGeoJSON } from '@/util/qpolygon-utils'
import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, snapNearAxisEdges, toGeoJSON } from '@/util/qpolygon-utils'
import * as turf from '@turf/turf'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js'
@ -120,6 +120,20 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
this.adjustRoofLines = []
// this.colorLines = []
// [1956-RACK-TILT 2026-05-13] 빨강 점선 폴리곤군(가대선/모듈설치면) FP drift 보정.
// - trestle, dormerTrestle: createRoofRack 출력
// - trestlePolygon: useMode.handleOuterlinesTest 출력
// - moduleSetupSurface: useModuleBasicSetting (eavesMargin/ridgeMargin/kerabaMargin 적용)
// 가로/세로 edge 의 FP drift 가 저장된 JSON 에 박혀있는 경우 복원 시 라인이 기울어 보이는 문제.
if (
options.name === 'trestle' ||
options.name === 'dormerTrestle' ||
options.name === 'trestlePolygon' ||
options.name === 'moduleSetupSurface'
) {
points = snapNearAxisEdges(points, 2)
}
// 소수점 전부 제거
points.forEach((point) => {
point.x = Number(point.x.toFixed(this.toFixed))

View File

@ -197,7 +197,7 @@ export default function ChangePasswordPop(props) {
</div>
<div className="change-password-guide">
<span>{getMessage('main.popup.login.guide1')}</span>
<span>{getMessage('main.popup.login.guide2')}</span>
{/*<span>{getMessage('main.popup.login.guide2')}</span>*/}
</div>
</div>
<div className="footer-btn-wrap">

View File

@ -23,6 +23,7 @@ import { QcastContext } from '@/app/QcastProvider'
import { useCanvasMenu } from '@/hooks/common/useCanvasMenu'
import { useSwal } from '@/hooks/useSwal'
import { sanitizeIntegerInputEvent } from '@/util/input-utils'
import { logger } from '@/util/logger'
import { CalculatorInput } from '@/components/common/input/CalcInput'
import Image from 'next/image'
@ -1008,81 +1009,115 @@ export default function StuffDetail() {
//
const setPlanReqInfo = (info) => {
form.setValue('planReqNo', info.planReqNo)
form.setValue('objectStatusId', info.building)
setSelectObjectStatusId(info.building)
form.setValue('objectName', info.title)
form.setValue('zipNo', info.zipNo)
form.setValue('address', info.address2)
prefCodeList.map((row) => {
if (row.prefName == info.address1) {
setPrefValue(row.prefId)
form.setValue('prefId', row.prefId)
form.setValue('prefName', info.address1)
}
// [PLANREQ-DEBUG 2026-05-15] import
logger.debug('[PLANREQ-DEBUG] setPlanReqInfo entry', {
session: { storeId: session?.storeId, storeLvl: session?.storeLvl },
info: { planReqNo: info?.planReqNo, saleStoreId: info?.saleStoreId, saleStoreLevel: info?.saleStoreLevel, saleStoreName: info?.saleStoreName },
saleStoreList: saleStoreList.map((s) => s.saleStoreId),
otherSaleStoreList: otherSaleStoreList.map((o) => o.saleStoreId),
})
// WL_
if (info.windSpeed !== '') {
form.setValue('standardWindSpeedId', `WL_${info.windSpeed}`)
} else {
form.setValue('standardWindSpeedId', `WL_${info.windSpeed}`)
// [PLANREQ-MATCH 2026-05-15] all-or-nothing: saleStore
const applyFields = () => {
form.setValue('planReqNo', info.planReqNo)
form.setValue('objectStatusId', info.building)
setSelectObjectStatusId(info.building)
form.setValue('objectName', info.title)
form.setValue('zipNo', info.zipNo)
form.setValue('address', info.address2)
prefCodeList.map((row) => {
if (row.prefName == info.address1) {
setPrefValue(row.prefId)
form.setValue('prefId', row.prefId)
form.setValue('prefName', info.address1)
}
})
// WL_
if (info.windSpeed !== '') {
form.setValue('standardWindSpeedId', `WL_${info.windSpeed}`)
} else {
form.setValue('standardWindSpeedId', `WL_${info.windSpeed}`)
}
form.setValue('verticalSnowCover', info.verticalSnowCover)
form.setValue('surfaceType', info.surfaceType)
if (info.surfaceType === 'Ⅱ') {
form.setValue('saltAreaFlg', true)
} else {
form.setValue('saltAreaFlg', false)
}
const installHeight = info.installHeight ? info.installHeight.split('.')[0] : ''
form.setValue('installHeight', installHeight)
form.setValue('remarks', info.remarks)
}
form.setValue('verticalSnowCover', info.verticalSnowCover)
form.setValue('surfaceType', info.surfaceType)
if (info.surfaceType === 'Ⅱ') {
form.setValue('saltAreaFlg', true)
} else {
form.setValue('saltAreaFlg', false)
}
let installHeight = info.installHeight ? info.installHeight.split('.')[0] : ''
form.setValue('installHeight', installHeight)
form.setValue('remarks', info.remarks)
if (info.saleStoreLevel === '1') {
// 1 ID: ( )
applyFields()
setSelOptions(info.saleStoreId)
form.setValue('saleStoreId', info.saleStoreId)
form.setValue('saleStoreName', info.saleStoreName)
form.setValue('saleStoreLevel', info.saleStoreLevel)
} else {
return
}
// info.saleStoreLevel !== '1' (2 ID)
if (session?.storeLvl === '2') {
const matched = otherSaleStoreList.some((o) => o.saleStoreId === info.saleStoreId)
logger.debug('[PLANREQ-DEBUG] 2차 user match check', { matched, target: info.saleStoreId })
if (!matched) {
// +
swalFire({
title: getMessage('stuff.detail.planReq.message.notMatch'),
type: 'alert',
icon: 'warning',
})
return
}
applyFields()
setOtherSelOptions(info.saleStoreId)
form.setValue('otherSaleStoreId', info.saleStoreId)
form.setValue('otherSaleStoreName', info.saleStoreName)
form.setValue('otherSaleStoreLevel', info.saleStoreLevel)
get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => {
if (res?.firstAgentId) {
const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName }
setSaleStoreList((prev) => {
const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
return exists ? prev : [...prev, firstAgent]
})
setShowSaleStoreList((prev) => {
const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
return exists ? prev : [...prev, firstAgent]
})
setSelOptions(res.firstAgentId)
form.setValue('saleStoreId', res.firstAgentId)
form.setValue('saleStoreName', res.firstAgentName)
form.setValue('saleStoreLevel', '1')
} else {
setSelOptions('')
form.setValue('saleStoreId', '')
form.setValue('saleStoreName', '')
form.setValue('saleStoreLevel', '')
}
}).catch(() => {
setSelOptions('')
form.setValue('saleStoreId', '')
form.setValue('saleStoreName', '')
form.setValue('saleStoreLevel', '')
})
return
}
// T01 / 1 user + 2 ID: firstAgent ( )
get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => {
logger.debug('[PLANREQ-DEBUG] firstAgent result', { firstAgentId: res?.firstAgentId })
if (!res?.firstAgentId) {
swalFire({
title: getMessage('stuff.detail.planReq.message.notMatch'),
type: 'alert',
icon: 'warning',
})
return
}
applyFields()
setOtherSelOptions(info.saleStoreId)
form.setValue('otherSaleStoreId', info.saleStoreId)
form.setValue('otherSaleStoreName', info.saleStoreName)
form.setValue('otherSaleStoreLevel', info.saleStoreLevel)
const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName }
setSaleStoreList((prev) => {
const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
return exists ? prev : [...prev, firstAgent]
})
setShowSaleStoreList((prev) => {
const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
return exists ? prev : [...prev, firstAgent]
})
setSelOptions(res.firstAgentId)
form.setValue('saleStoreId', res.firstAgentId)
form.setValue('saleStoreName', res.firstAgentName)
form.setValue('saleStoreLevel', '1')
}).catch(() => {
// +
swalFire({
title: getMessage('stuff.detail.planReq.message.notMatch'),
type: 'alert',
icon: 'warning',
})
})
}
//
@ -2051,13 +2086,12 @@ export default function StuffDetail() {
getOptionLabel={(x) => x.saleStoreName}
getOptionValue={(x) => x.saleStoreId}
isDisabled={
// [PLANREQ-MATCH 2026-05-15] 2 user store enable
session?.storeLvl === '1'
? otherSaleStoreList.length > 0
? false
: true
: otherSaleStoreList.length === 1
? true
: false
: false
}
isClearable={true}
value={otherSaleStoreList.filter(function (option) {
@ -2650,15 +2684,14 @@ export default function StuffDetail() {
getOptionLabel={(x) => x.saleStoreName}
getOptionValue={(x) => x.saleStoreId}
isDisabled={
// [PLANREQ-MATCH 2026-05-15] 2 user store enable (tempFlg='0' )
managementState?.tempFlg === '0'
? true
: session?.storeLvl === '1'
? otherSaleStoreList.length > 0
? false
: true
: otherSaleStoreList.length === 1
? true
: false
: false
}
isClearable={managementState?.tempFlg === '0' ? false : true}
value={otherSaleStoreList.filter(function (option) {

View File

@ -5,7 +5,7 @@ import configProduction from './config.production'
// 클라이언트에서는 이 함수를 사용하여 config 값을 참조합니다.
const Config = () => {
console.log('🚀 ~ Config ~ process.env.NEXT_PUBLIC_RUN_MODE:', process.env.NEXT_PUBLIC_RUN_MODE)
// console.log('🚀 ~ Config ~ process.env.NEXT_PUBLIC_RUN_MODE:', process.env.NEXT_PUBLIC_RUN_MODE)
switch (process.env.NEXT_PUBLIC_RUN_MODE) {
case 'local':
return configLocal

View File

@ -36,6 +36,7 @@ import { calcLineActualSize2 } from '@/util/qpolygon-utils'
import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning'
// [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 import + 호출 함께 제거
import { debugCapture } from '@/util/debugCapture'
import { logger } from '@/util/logger'
export function useRoofAllocationSetting(id) {
const canvas = useRecoilValue(canvasState)
@ -217,7 +218,7 @@ export function useRoofAllocationSetting(id) {
}))
setCurrentRoofList(normalizedRoofs)
} catch (error) {
console.error('Data fetching error:', error)
logger.error('Data fetching error:', error)
}
}
@ -521,7 +522,7 @@ export function useRoofAllocationSetting(id) {
const extLines = roofBase.lines.filter((l) => l.lineName === 'extensionLine')
if (extLines.length === 0) {
console.log(`[INTEGRATE] roofBase.id=${roofBase.id} extensionLine 없음 → skip`)
logger.log(`[INTEGRATE] roofBase.id=${roofBase.id} extensionLine 없음 → skip`)
return
}
@ -563,7 +564,7 @@ export function useRoofAllocationSetting(id) {
})
if (!sk) {
console.log(
logger.log(
`[INTEGRATE] ext 짝없음 ` +
`(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)})`
)
@ -571,8 +572,9 @@ export function useRoofAllocationSetting(id) {
}
// [2026-04-30] sk 가 이미 SK 빌드 단계에서 연장된 경우 → merge 스킵.
if (sk.__extended) {
console.log(
// [save/load 보존 2026-05-13] runtime __extended 는 저장 시 사라지므로 attributes.extended 도 함께 검사.
if (sk.__extended || sk.attributes?.extended) {
logger.log(
`[INTEGRATE] sk 이미 연장됨(id=${sk.id}) → merge 스킵, ext 는 lines 에서만 제거(canvas 유지)`
)
extLinesOnly.push(ext)
@ -617,7 +619,7 @@ export function useRoofAllocationSetting(id) {
})
mergedLine.length = totalLen
console.log(
logger.log(
`[INTEGRATE] merge ` +
`ext=(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)}) len=${extLen.toFixed(1)} ` +
`sk[${sk.lineName || sk.name}]=(${skP1.x.toFixed(1)},${skP1.y.toFixed(1)})→(${skP2.x.toFixed(1)},${skP2.y.toFixed(1)}) len=${skLen.toFixed(1)} ` +
@ -630,7 +632,7 @@ export function useRoofAllocationSetting(id) {
})
if (merged.length === 0 && extLinesOnly.length === 0) {
console.log(`[INTEGRATE] 통합 대상 없음 ext=${extLines.length}`)
logger.log(`[INTEGRATE] 통합 대상 없음 ext=${extLines.length}`)
return
}
@ -646,7 +648,7 @@ export function useRoofAllocationSetting(id) {
removedExt.forEach((l) => canvas.remove(l))
removedSk.forEach((l) => canvas.remove(l))
console.log(
logger.log(
`[INTEGRATE] 완료 roofBase.id=${roofBase.id} ` +
`ext제거=${removedExt.length} sk제거=${removedSk.length} merged=${merged.length} ` +
`extKeptInCanvas=${extLinesOnly.length} ` +
@ -660,7 +662,7 @@ export function useRoofAllocationSetting(id) {
const apply = () => {
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial)
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
console.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`)
logger.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`)
roofBases.forEach((roofBase) => {
try {
// 지붕 할당 로직에 extensionLine 추가
@ -668,31 +670,9 @@ export function useRoofAllocationSetting(id) {
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine') &&
obj.roofId === roofBase.id
)
// [ALLOC-DEBUG] canvas 픽업 스냅샷 (삭제 직전 상태)
const snapByLineName = {}
const snapByName = {}
roofEaveHelpLines.forEach((o) => {
snapByLineName[o.lineName] = (snapByLineName[o.lineName] || 0) + 1
snapByName[o.name || '?'] = (snapByName[o.name || '?'] || 0) + 1
})
console.log(
`[ALLOC-DEBUG] roofBase.id=${roofBase.id} ` +
`roofBase.lines=${roofBase.lines?.length || 0} ` +
`roofBase.innerLines=${roofBase.innerLines?.length || 0} ` +
`canvas픽업=${roofEaveHelpLines.length} ` +
`byLineName=${JSON.stringify(snapByLineName)} ` +
`byName=${JSON.stringify(snapByName)}`
)
roofEaveHelpLines.forEach((o, i) => {
console.log(
` [ALLOC-DEBUG] picked[${i}] name=${o.name} lineName=${o.lineName} ` +
`(${o.x1?.toFixed(1)},${o.y1?.toFixed(1)})→(${o.x2?.toFixed(1)},${o.y2?.toFixed(1)}) ` +
`len=${Math.hypot((o.x2 || 0) - (o.x1 || 0), (o.y2 || 0) - (o.y1 || 0)).toFixed(1)}`
)
})
// console.log('roofBase.id:', roofBase.id)
// console.log('roofEaveHelpLines:', roofEaveHelpLines)
// console.log('extensionLines found:', roofEaveHelpLines.filter(l => l.lineName === 'extensionLine'))
// logger.log('roofBase.id:', roofBase.id)
// logger.log('roofEaveHelpLines:', roofEaveHelpLines)
// logger.log('extensionLines found:', roofEaveHelpLines.filter(l => l.lineName === 'extensionLine'))
if (roofEaveHelpLines.length > 0) {
if (roofBase.lines) {
// Filter out any eaveHelpLines that are already in lines to avoid duplicates
@ -703,8 +683,8 @@ export function useRoofAllocationSetting(id) {
// extensionLine과 일반 eaveHelpLine 분리
const extensionLines = newEaveLines.filter(line => line.lineName === 'extensionLine')
const normalEaveLines = newEaveLines.filter(line => line.lineName === 'eaveHelpLine')
// console.log('extensionLines count:', extensionLines.length)
// console.log('normalEaveLines count:', normalEaveLines.length)
// logger.log('extensionLines count:', extensionLines.length)
// logger.log('normalEaveLines count:', normalEaveLines.length)
// 일반 eaveHelpLine만 Overlap 판단에 사용
const linesToKeep = roofBase.lines.filter(roofLine => {
@ -747,7 +727,7 @@ export function useRoofAllocationSetting(id) {
isPointInside(eX2, eY2, rX1, rY1, rX2, rY2);
if (isOverlapping) {
console.log('Removing overlapping line:', roofLine);
logger.log('Removing overlapping line:', roofLine);
return true; // 포개지는 경우에만 삭제
}
}
@ -758,20 +738,6 @@ export function useRoofAllocationSetting(id) {
});
// Combine remaining lines with newEaveLines
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines];
// [ALLOC-DEBUG] 병합 결과 breakdown
console.log(
`[ALLOC-DEBUG] 병합완료 roofBase.id=${roofBase.id} ` +
`linesToKeep=${linesToKeep.length} ` +
`normalEave=${normalEaveLines.length} ` +
`extension=${extensionLines.length} ` +
`total=${roofBase.lines.length}`
)
roofBase.lines.forEach((ln, i) => {
console.log(
` [ALLOC-DEBUG] merged[${i}] name=${ln.name || '?'} lineName=${ln.lineName || '?'} ` +
`(${ln.x1?.toFixed(1)},${ln.y1?.toFixed(1)})→(${ln.x2?.toFixed(1)},${ln.y2?.toFixed(1)})`
)
})
} else {
roofBase.lines = [...roofEaveHelpLines]
}
@ -811,22 +777,13 @@ export function useRoofAllocationSetting(id) {
// extensionLine + 동일직선 SK 1:1 통합 (대각선 단일길이/각도 면적 산출)
integrateExtensionLines(roofBase)
// [ALLOC-DEBUG] split 직전 최종 입력
console.log(
`[ALLOC-DEBUG] split 직전 roofBase.id=${roofBase.id} ` +
`separatePolygon=${roofBase.separatePolygon?.length || 0} ` +
`lines=${roofBase.lines?.length || 0} ` +
`innerLines=${roofBase.innerLines?.length || 0} ` +
`points=${roofBase.points?.length || 0} ` +
`${roofBase.separatePolygon?.length > 0 ? 'splitPolygonWithSeparate' : 'splitPolygonWithLines'}`
)
if (roofBase.separatePolygon.length > 0) {
splitPolygonWithSeparate(roofBase.separatePolygon)
} else {
splitPolygonWithLines(roofBase)
}
} catch (e) {
console.log(e)
logger.log(e)
canvas.discardActiveObject()
return
}

View File

@ -33,7 +33,7 @@ import {
import { QLine } from '@/components/fabric/QLine'
import { fabric } from 'fabric'
import { QPolygon } from '@/components/fabric/QPolygon'
import offsetPolygon, { calculateAngle } from '@/util/qpolygon-utils'
import offsetPolygon, { calculateAngle, snapNearAxisEdges } from '@/util/qpolygon-utils'
import { isObjectNotEmpty } from '@/util/common-utils'
import * as turf from '@turf/turf'
import { INPUT_TYPE, LINE_TYPE, Mode, POLYGON_TYPE } from '@/common/common'
@ -1505,7 +1505,16 @@ export function useMode() {
offsetPoints.push(offsetPoint)
}
return makePolygon(offsetPoints, false)
// [1956-RACK-TILT 2026-05-13] trestlePolygon(モジュール配置領域 빨강점선) FP drift 보정.
// 입력 polygon.lines 의 미세 drift 가 평균법선 offset 으로 전파되어 가로/세로 라인이
// 기울어 보이는 문제 차단. tolerance 0.5mm 보다 작은 dy/dx 만 축으로 스냅.
const _snapped = snapNearAxisEdges(
offsetPoints.map((p) => ({ x: p.x1, y: p.y1 })),
2,
)
const snappedOffsetPoints = _snapped.map((p) => ({ x1: p.x, y1: p.y }))
return makePolygon(snappedOffsetPoints, false)
}
/**
@ -1730,10 +1739,25 @@ export function useMode() {
// edge[i] 가 tiny 이면 시작 정점 vertex[i] 를 제거해 앞쪽 edge 와 병합한다.
// 이렇게 하면 남는 cleaned edge 의 line 매핑(cleanedLines[k]=lines[kept[k]]) 이
// edge[i+1] (실제 긴 edge) 의 line 을 쓰게 되므로 offset 계산이 안정해진다.
// [tiny edge axis guard 2026-05-13] 단, 제거 후 새로 생기는 엣지가 axis-aligned 인 경우에만 안전.
// 직각 노치(예: H 91mm + V 24.5mm 코너) 의 24.5mm edge 를 단순 제거하면 v[i-1]→v[i+1] 가
// 15° 대각선이 되어 inset+clipOffsetToOriginal 후 mm 단위 zigzag 토막으로 보임 (RACK-TILT).
// 원본 데이터가 wallBaseLine 의 axis-aligned 직각 노치를 보존해야 하므로, 대각선 결과가 되면 skip.
const TINY_EDGE_THRESHOLD = 30
const AXIS_TOL = 0.5
for (let i = 0; i < n; i++) {
if (edges[i].length < TINY_EDGE_THRESHOLD) {
removeIndices.add(i)
const prev = (i + n - 1) % n
const nxt = (i + 1) % n
const vPrev = vertices[prev]
const vNext = vertices[nxt]
// 새 엣지 v[prev]→v[nxt] 가 수평 또는 수직인 경우만 머지 (drift artifact 흡수).
// 그 외(직각 노치 사이의 short step) 는 진짜 geometry 이므로 보존.
const isAxisAligned =
Math.abs(vPrev.y - vNext.y) < AXIS_TOL || Math.abs(vPrev.x - vNext.x) < AXIS_TOL
if (isAxisAligned) {
removeIndices.add(i)
}
}
}
@ -5100,6 +5124,7 @@ export function useMode() {
roofs.forEach((roof, index) => {
// const offsetPolygonPoint = offsetPolygon(roof.points(), -20) //이동되서 찍을라고 바꿈
// [1956-RACK-TILT 2026-05-13] trestle name 으로 QPolygon.initialize 가 축-스냅 처리
const offsetPolygonPoint = offsetPolygon(roof.getCurrentPoints(), -20)
const trestlePoly = new QPolygon(offsetPolygonPoint, {

View File

@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil'
import { fabric } from 'fabric'
import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, isPointOnLine } from '@/util/canvas-util'
import { QPolygon } from '@/components/fabric/QPolygon'
import { isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils'
import { calcLinePlaneSize, isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils'
import { basicSettingState, flowDisplaySelector } from '@/store/settingAtom'
import { fontSelector } from '@/store/fontAtom'
import { QLine } from '@/components/fabric/QLine'
@ -1028,7 +1028,10 @@ export const usePolygon = () => {
const { intersections, startPoint, endPoint } = line
// 원본 라인의 기하학적 길이 (비율 계산용)
const originalGeomLength = Math.round(Math.hypot(line.x2 - line.x1, line.y2 - line.y1)) * 10
// [ROUND-PRECISION 2026-05-14] Math.round(hypot)*10 → calcLinePlaneSize (Big.js, ×10 후 round).
// 기존: 45.6 → Math.round(45.6)*10 = 460. 伏せ図入力 의 Big.js 경로는 456.
// 배치면 split 결과와 伏せ図入力 SK 빌더의 0.5mm boundary 가 갈리는 근본 원인.
const originalGeomLength = calcLinePlaneSize({ x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2 })
if (intersections.length === 1) {
const newLinePoint1 = [line.x1, line.y1, intersections[0].x, intersections[0].y]
@ -1050,8 +1053,9 @@ export const usePolygon = () => {
})
// 분할된 각 세그먼트의 기하학적 길이
const length1 = Math.round(Math.hypot(newLine1.x1 - newLine1.x2, newLine1.y1 - newLine1.y2)) * 10
const length2 = Math.round(Math.hypot(newLine2.x1 - newLine2.x2, newLine2.y1 - newLine2.y2)) * 10
// [ROUND-PRECISION 2026-05-14] calcLinePlaneSize 사용 — 위 originalGeomLength 와 동일 이유.
const length1 = calcLinePlaneSize({ x1: newLine1.x1, y1: newLine1.y1, x2: newLine1.x2, y2: newLine1.y2 })
const length2 = calcLinePlaneSize({ x1: newLine2.x1, y1: newLine2.y1, x2: newLine2.x2, y2: newLine2.y2 })
// 분할 시 새 sub-segment 의 planeSize/actualSize 는 새 기하학으로 직접 계산.
// 부모 비율을 쓰면 부모 좌표 drift 가 그대로 전파되어 사용자 기대 round 값과 어긋남.
@ -1131,7 +1135,8 @@ export const usePolygon = () => {
name: 'newLine',
})
const calcLength = Math.round(Math.hypot(newLine.x1 - newLine.x2, newLine.y1 - newLine.y2)) * 10
// [ROUND-PRECISION 2026-05-14] calcLinePlaneSize 사용 — Big.js ×10 후 round.
const calcLength = calcLinePlaneSize({ x1: newLine.x1, y1: newLine.y1, x2: newLine.x2, y2: newLine.y2 })
let segPlaneSize, segActualSize
if (line.attributes.planeSize && originalGeomLength > 0) {
@ -1164,7 +1169,8 @@ export const usePolygon = () => {
attributes: line.attributes,
name: 'newLine',
})
const lastCalcLength = Math.round(Math.hypot(newLine.x1 - newLine.x2, newLine.y1 - newLine.y2)) * 10
// [ROUND-PRECISION 2026-05-14] calcLinePlaneSize 사용 — Big.js ×10 후 round.
const lastCalcLength = calcLinePlaneSize({ x1: newLine.x1, y1: newLine.y1, x2: newLine.x2, y2: newLine.y2 })
let lastPlaneSize, lastActualSize
if (line.attributes.planeSize && originalGeomLength > 0) {

View File

@ -10,7 +10,7 @@ import fs from 'fs/promises'
const imageSavePath = 'public/cadImages'
const convertDwgToPng = async (fileName, data) => {
console.log('fileName', fileName)
// console.log('fileName', fileName)
try {
await fs.readdir(imageSavePath)
} catch {

View File

@ -782,6 +782,7 @@
"stuff.detail.tempSave.message2": "担当者名は全角20文字半角40文字以下で入力してください.",
"stuff.detail.tempSave.message3": "二次販売店を選択してください。",
"stuff.detail.confirm.message1": "販売店情報を変更すると、設計依頼文書番号が削除されます。変更しますか?",
"stuff.detail.planReq.message.notMatch": "設計依頼の販売店情報が一致しないため、インポートできません。",
"stuff.detail.delete.message1": "仕様が確定したものは削除できません。",
"stuff.detail.planList.title": "プランリスト",
"stuff.detail.planList.cnt": "全体",
@ -936,7 +937,7 @@
"main.popup.login.newPassword1": "新しいパスワードを入力",
"main.popup.login.newPassword2": "新しいパスワードの再入力",
"main.popup.login.placeholder": "半角10文字以内",
"main.popup.login.guide1": "初期化されたパスワードでログインした場合、パスワードを変更しなければサイト利用が可能です。",
"main.popup.login.guide1": "初回ログイン時は、パスワード変更後にサイトをご利用いただけます。",
"main.popup.login.guide2": "パスワードを変更しない場合は、ログイン画面に進みます。",
"main.popup.login.btn1": "変更",
"main.popup.login.btn2": "変更しない",

View File

@ -782,6 +782,7 @@
"stuff.detail.tempSave.message2": "담당자이름은 전각20자(반각40자) 이하로 입력해 주십시오.",
"stuff.detail.tempSave.message3": "2차 판매점을 선택해주세요.",
"stuff.detail.confirm.message1": "판매점 정보를 변경하면 설계의뢰 문서번호가 삭제됩니다. 변경하시겠습니까?",
"stuff.detail.planReq.message.notMatch": "설계의뢰의 판매점 정보가 일치하지 않아 가져올 수 없습니다.",
"stuff.detail.delete.message1": "사양이 확정된 물건은 삭제할 수 없습니다.",
"stuff.detail.planList.title": "플랜목록",
"stuff.detail.planList.cnt": "전체",
@ -936,7 +937,7 @@
"main.popup.login.newPassword1": "새 비밀번호 입력",
"main.popup.login.newPassword2": "새 비밀번호 재입력",
"main.popup.login.placeholder": "반각 10자 이내",
"main.popup.login.guide1": "초기화된 비밀번호로 로그인한 경우 비밀번호를 변경해야 사이트 이용이 가능합니다.",
"main.popup.login.guide1": "첫 로그인 시에는 비밀번호 변경 후 사이트를 이용하실 수 있습니다。",
"main.popup.login.guide2": "비밀번호를 변경하지 않을 경우 로그인 화면으로 이동합니다.",
"main.popup.login.btn1": "변경",
"main.popup.login.btn2": "변경안함",

View File

@ -1,30 +1,90 @@
// utils/logger.js
const isLoggingEnabled = process.env.NEXT_PUBLIC_ENABLE_LOGGING;
// [LOGGER-GUARD 2026-05-14] string 'false' 가 truthy 라 production 에서도 가드를 통과하던 버그 수정.
// 명시적 === 'true' 비교 → Next.js inline replacement + dead code elimination 으로 production 빌드에서 emit 호출 제거.
const isLoggingEnabled = process.env.NEXT_PUBLIC_ENABLE_LOGGING === 'true';
// [LOGGER-CALLER 2026-05-14] stack 에서 logger.js 이외의 첫 frame 을 추출해 prefix 로 붙임.
// 페이지/hook/컴포넌트 어디서 찍힌 로그인지 콘솔에서 즉시 식별 가능. webpack chunk 매칭 위해 파일명만 추출.
const FILE_LINE_RE = /([\w\-\.]+\.(?:jsx?|tsx?)):(\d+)/
// [LOGGER-FORMAT 2026-05-14] 통일 포맷: [HH:mm:ss.SSS] [LEVEL] [caller] <message>
// 레벨별 색상 배지로 콘솔에서 한눈에 구분.
const STYLE_TS = 'color:#888'
const STYLE_CALLER = 'color:#888;font-style:italic'
const STYLE_LEVEL = {
log: 'background:#616161;color:#fff;padding:1px 6px;border-radius:2px;font-weight:bold',
info: 'background:#1e88e5;color:#fff;padding:1px 6px;border-radius:2px;font-weight:bold',
warn: 'background:#fb8c00;color:#fff;padding:1px 6px;border-radius:2px;font-weight:bold',
error: 'background:#e53935;color:#fff;padding:1px 6px;border-radius:2px;font-weight:bold',
debug: 'background:#8e24aa;color:#fff;padding:1px 6px;border-radius:2px;font-weight:bold',
}
function formatTime() {
const d = new Date()
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
const ms = String(d.getMilliseconds()).padStart(3, '0')
return `${hh}:${mm}:${ss}.${ms}`
}
function getCallerTag() {
try {
const stack = new Error().stack
if (!stack) return ''
const lines = stack.split('\n')
for (let i = 1; i < lines.length; i++) {
const m = lines[i].match(FILE_LINE_RE)
if (m && m[1] !== 'logger.js') return `${m[1]}:${m[2]}`
}
} catch (_) {}
return ''
}
function emit(level, args) {
const ts = formatTime()
const caller = getCallerTag()
const levelStyle = STYLE_LEVEL[level] || STYLE_LEVEL.log
const levelLabel = level.toUpperCase()
// prefix: '[ts] %cLEVEL%c [caller] ' → styled level + dimmed caller
// user 첫 인자가 string 이면 prefix 와 합쳐 %c 호환 유지.
const prefixText = caller
? `%c[${ts}] %c${levelLabel}%c [${caller}]`
: `%c[${ts}] %c${levelLabel}%c`
const prefixStyles = [STYLE_TS, levelStyle, STYLE_CALLER]
if (typeof args[0] === 'string') {
console[level](`${prefixText} ${args[0]}`, ...prefixStyles, ...args.slice(1))
} else {
console[level](prefixText, ...prefixStyles, ...args)
}
}
export const logger = {
log: (...args) => {
if (isLoggingEnabled) {
console.log(...args);
emit('log', args);
}
},
error: (...args) => {
// 에러는 항상 로깅하거나, 또는 환경에 따라 다르게 처리
console.error(...args);
emit('error', args);
// 운영 환경에서는 서버로 에러를 보내는 코드를 추가할 수도 있음
},
warn: (...args) => {
if (isLoggingEnabled) {
console.warn(...args);
emit('warn', args);
}
},
info: (...args) => {
if (isLoggingEnabled) {
console.info(...args);
emit('info', args);
}
},
debug: (...args) => {
if (isLoggingEnabled) {
console.debug(...args);
emit('debug', args);
}
}
};
};

View File

@ -405,6 +405,57 @@ export function cleanSelfIntersectingPolygon(vertices) {
return vertices
}
/**
* [1956-RACK-TILT 2026-05-13] 거의 축에 평행한 edge 정확히 수평/수직으로 스냅.
* 빨강 점선(가대선/모듈설치면) edge FP drift 누적으로 미세 기울어져 보이는 문제 보정.
* **각도 기반 판정**: |dy|/len < sin(angleTolDeg) .
* 절대값 tolerance 아닌 각도 임계를 쓰는 이유 edge(: 576mm) 작은 누적
* drift(15mm) 실려 closing edge 흡수하면 절대값은 크지만 각도는 작음(1.5°).
* vertex 단위로 인접 edge 목표값을 적용해 코너 어긋남 방지.
* 대각(hip/ridge, 45° ) 임계 2° 보다 훨씬 크니 무영향.
*/
export function snapNearAxisEdges(vertices, angleTolDeg = 2) {
if (!vertices || vertices.length < 3) return vertices
const n = vertices.length
const sinTol = Math.sin((angleTolDeg * Math.PI) / 180)
const edgeY = new Array(n).fill(null) // 거의 수평 → 목표 y
const edgeX = new Array(n).fill(null) // 거의 수직 → 목표 x
for (let i = 0; i < n; i++) {
const a = vertices[i]
const b = vertices[(i + 1) % n]
const dx = b.x - a.x
const dy = b.y - a.y
const len = Math.sqrt(dx * dx + dy * dy)
if (len < 1) continue // 0-length edge 안전장치
const absSinDy = Math.abs(dy) / len // sin(angle from horizontal)
const absSinDx = Math.abs(dx) / len // sin(angle from vertical)
if (absSinDy < sinTol && absSinDx >= sinTol) {
edgeY[i] = (a.y + b.y) / 2 // 거의 수평
} else if (absSinDx < sinTol && absSinDy >= sinTol) {
edgeX[i] = (a.x + b.x) / 2 // 거의 수직
}
}
return vertices.map((v, i) => {
const prev = (i - 1 + n) % n
let nx = v.x
let ny = v.y
const yPrev = edgeY[prev]
const yCur = edgeY[i]
if (yPrev !== null && yCur !== null) ny = (yPrev + yCur) / 2
else if (yPrev !== null) ny = yPrev
else if (yCur !== null) ny = yCur
const xPrev = edgeX[prev]
const xCur = edgeX[i]
if (xPrev !== null && xCur !== null) nx = (xPrev + xCur) / 2
else if (xPrev !== null) nx = xPrev
else if (xCur !== null) nx = xCur
return { x: nx, y: ny }
})
}
export default function offsetPolygon(vertices, offset) {
const polygon = createPolygon(vertices)
const arcSegments = 0

File diff suppressed because it is too large Load Diff