diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue index 4f3ecb0..fc8b2dd 100644 --- a/packages/frontend/src/App.vue +++ b/packages/frontend/src/App.vue @@ -21,7 +21,8 @@ const viewKey = computed(() => { route.name === 'drawio-editor' || route.name === 'cad-viewer' || route.name === 'model3d-viewer' || - route.name === 'tools-scanner' + route.name === 'tools-scanner' || + route.name === 'tools-bg-remove' ) { return String(route.name); } @@ -41,6 +42,7 @@ const viewKey = computed(() => { 'CadViewerView', 'Model3DViewerView', 'ToolsScannerView', + 'ToolsBgRemoveView', ]" :max="10" > diff --git a/packages/frontend/src/composables/useFileDropZone.ts b/packages/frontend/src/composables/useFileDropZone.ts new file mode 100644 index 0000000..15993e7 --- /dev/null +++ b/packages/frontend/src/composables/useFileDropZone.ts @@ -0,0 +1,41 @@ +import { ref } from 'vue'; + +/** Nested dragenter/leave safe file drop handlers for a page root. */ +export function useFileDropZone(onFiles: (files: File[]) => void) { + const dropActive = ref(false); + let depth = 0; + + function onDragEnter(event: DragEvent) { + event.preventDefault(); + depth += 1; + dropActive.value = true; + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + } + + function onDragOver(event: DragEvent) { + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + } + + function onDragLeave(event: DragEvent) { + event.preventDefault(); + depth = Math.max(0, depth - 1); + if (depth === 0) dropActive.value = false; + } + + function onDrop(event: DragEvent) { + event.preventDefault(); + depth = 0; + dropActive.value = false; + const files = Array.from(event.dataTransfer?.files ?? []); + if (files.length) onFiles(files); + } + + return { + dropActive, + onDragEnter, + onDragOver, + onDragLeave, + onDrop, + }; +} diff --git a/packages/frontend/src/config/favicons.ts b/packages/frontend/src/config/favicons.ts index aa182e2..9f50b67 100644 --- a/packages/frontend/src/config/favicons.ts +++ b/packages/frontend/src/config/favicons.ts @@ -18,6 +18,7 @@ export type FaviconKind = | 'cad' | 'model3d' | 'scanner' + | 'bgremove' | 'text' | 'csv' | 'file'; @@ -37,6 +38,8 @@ export const ICON_PATHS: Record = { cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z', model3d: 'M12 2L21 7V17L12 22L3 17V7L12 2M12 4.3L5.4 8L12 11.7L18.6 8L12 4.3M5 9.7V15.8L11 19.1V13.1L5 9.7M13 19.1L19 15.8V9.7L13 13.1V19.1Z', scanner: 'M4 4H10V6H6V10H4V4M14 4H20V10H18V6H14V4M4 14H6V18H10V20H4V14M18 14H20V20H14V18H18V14M8 8H16V16H8V8Z', + bgremove: + 'M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z', text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10', file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', @@ -55,6 +58,7 @@ export const ICON_COLORS: Record = { cad: '#5c6bc0', model3d: '#6d4c41', scanner: '#00897b', + bgremove: '#5c6bc0', text: '#607d8b', csv: '#00838f', file: '#546e7a', @@ -114,6 +118,7 @@ const EXT_KIND: Record = { webp: 'scanner', gif: 'scanner', bmp: 'scanner', + bgremove: 'bgremove', txt: 'text', }; diff --git a/packages/frontend/src/integrations/bgremove/removeBg.ts b/packages/frontend/src/integrations/bgremove/removeBg.ts new file mode 100644 index 0000000..c2653fa --- /dev/null +++ b/packages/frontend/src/integrations/bgremove/removeBg.ts @@ -0,0 +1,254 @@ +export type BgRemoveMode = 'threshold' | 'flood'; + +export interface BgRemoveOptions { + /** 0–100: how far from sampled background still counts as background */ + tolerance: number; + /** 0–100: soft edge width beyond hard threshold */ + feather: number; + mode: BgRemoveMode; +} + +export interface Rgb { + r: number; + g: number; + b: number; +} + +export const DEFAULT_BG_REMOVE_OPTIONS: BgRemoveOptions = { + tolerance: 28, + feather: 12, + mode: 'threshold', +}; + +function clampByte(value: number) { + return Math.min(255, Math.max(0, Math.round(value))); +} + +function colorDistance(a: Rgb, r: number, g: number, b: number) { + return Math.max(Math.abs(r - a.r), Math.abs(g - a.g), Math.abs(b - a.b)); +} + +function median(values: number[]) { + if (!values.length) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 + ? sorted[mid]! + : Math.round((sorted[mid - 1]! + sorted[mid]!) / 2); +} + +/** Sample corner neighborhoods and take per-channel median as background. */ +export function sampleCornerBackground( + data: Uint8ClampedArray, + width: number, + height: number, + radius = 2, +): Rgb { + const rs: number[] = []; + const gs: number[] = []; + const bs: number[] = []; + const corners: Array<[number, number]> = [ + [0, 0], + [width - 1, 0], + [0, height - 1], + [width - 1, height - 1], + ]; + + for (const [cx, cy] of corners) { + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + const x = Math.min(width - 1, Math.max(0, cx + dx)); + const y = Math.min(height - 1, Math.max(0, cy + dy)); + const i = (y * width + x) * 4; + rs.push(data[i]!); + gs.push(data[i + 1]!); + bs.push(data[i + 2]!); + } + } + } + + return { r: median(rs), g: median(gs), b: median(bs) }; +} + +function hardLimit(tolerance: number) { + return Math.round((Math.min(100, Math.max(0, tolerance)) / 100) * 180); +} + +function featherWidth(feather: number) { + return Math.round((Math.min(100, Math.max(0, feather)) / 100) * 80); +} + +function alphaForDistance( + distance: number, + hard: number, + soft: number, + originalAlpha: number, +) { + if (distance <= hard) return 0; + if (soft <= 0 || distance >= hard + soft) return originalAlpha; + const t = (distance - hard) / soft; + return clampByte(originalAlpha * t); +} + +function loadImage(source: string | Blob | HTMLImageElement | HTMLCanvasElement) { + if (source instanceof HTMLCanvasElement) { + return Promise.resolve(source); + } + if (source instanceof HTMLImageElement) { + if (source.complete && source.naturalWidth) return Promise.resolve(source); + return new Promise((resolve, reject) => { + source.onload = () => resolve(source); + source.onerror = () => + reject(new Error('이미지를 불러오지 못했습니다.')); + }); + } + + const objectUrl = + typeof source === 'string' ? null : URL.createObjectURL(source); + const src = typeof source === 'string' ? source : objectUrl!; + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + resolve(image); + }; + image.onerror = () => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + reject(new Error('이미지를 불러오지 못했습니다.')); + }; + image.src = src; + }); +} + +function drawToCanvas( + image: HTMLImageElement | HTMLCanvasElement, +): HTMLCanvasElement { + if (image instanceof HTMLCanvasElement) { + const copy = document.createElement('canvas'); + copy.width = image.width; + copy.height = image.height; + const ctx = copy.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.'); + ctx.drawImage(image, 0, 0); + return copy; + } + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth || image.width; + canvas.height = image.naturalHeight || image.height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.'); + ctx.drawImage(image, 0, 0); + return canvas; +} + +function applyThreshold( + data: Uint8ClampedArray, + bg: Rgb, + hard: number, + soft: number, +) { + for (let i = 0; i < data.length; i += 4) { + const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!); + data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!); + } +} + +function applyFlood( + data: Uint8ClampedArray, + width: number, + height: number, + bg: Rgb, + hard: number, + soft: number, +) { + const total = width * height; + const match = new Uint8Array(total); + const limit = hard + soft; + for (let p = 0; p < total; p += 1) { + const i = p * 4; + const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!); + if (d <= limit) match[p] = 1; + } + + const visited = new Uint8Array(total); + const queue = new Int32Array(total); + let head = 0; + let tail = 0; + + const seed = (x: number, y: number) => { + const p = y * width + x; + if (!match[p] || visited[p]) return; + visited[p] = 1; + queue[tail++] = p; + }; + + seed(0, 0); + seed(width - 1, 0); + seed(0, height - 1); + seed(width - 1, height - 1); + + while (head < tail) { + const p = queue[head++]!; + const x = p % width; + const y = (p / width) | 0; + if (x > 0) seed(x - 1, y); + if (x + 1 < width) seed(x + 1, y); + if (y > 0) seed(x, y - 1); + if (y + 1 < height) seed(x, y + 1); + } + + for (let p = 0; p < total; p += 1) { + if (!visited[p]) continue; + const i = p * 4; + const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!); + data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!); + } +} + +export async function removeBackgroundBySample( + source: string | Blob | HTMLImageElement | HTMLCanvasElement, + options: Partial = {}, +): Promise<{ + blob: Blob; + url: string; + width: number; + height: number; + background: Rgb; +}> { + const opts = { ...DEFAULT_BG_REMOVE_OPTIONS, ...options }; + const image = await loadImage(source); + const canvas = drawToCanvas(image); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.'); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const { data, width, height } = imageData; + const background = sampleCornerBackground(data, width, height); + const hard = hardLimit(opts.tolerance); + const soft = featherWidth(opts.feather); + + if (opts.mode === 'flood') { + applyFlood(data, width, height, background, hard, soft); + } else { + applyThreshold(data, background, hard, soft); + } + + ctx.putImageData(imageData, 0, 0); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (value) => + value + ? resolve(value) + : reject(new Error('투명 PNG를 만들지 못했습니다.')), + 'image/png', + ); + }); + + return { + blob, + url: URL.createObjectURL(blob), + width: canvas.width, + height: canvas.height, + background, + }; +} diff --git a/packages/frontend/src/router/index.ts b/packages/frontend/src/router/index.ts index 9eb2a67..82ada3d 100644 --- a/packages/frontend/src/router/index.ts +++ b/packages/frontend/src/router/index.ts @@ -49,6 +49,11 @@ const router = createRouter({ name: 'tools-scanner', component: () => import('@/views/ToolsScannerView.vue'), }, + { + path: '/tools/bg-remove', + name: 'tools-bg-remove', + component: () => import('@/views/ToolsBgRemoveView.vue'), + }, { path: '/licenses', name: 'licenses', diff --git a/packages/frontend/src/views/CadViewerView.vue b/packages/frontend/src/views/CadViewerView.vue index 3c28ee4..1181866 100644 --- a/packages/frontend/src/views/CadViewerView.vue +++ b/packages/frontend/src/views/CadViewerView.vue @@ -14,6 +14,7 @@ import { } from '@mlightcad/cad-simple-viewer'; import AppShell from '@/components/AppShell.vue'; import EditorToolbar from '@/components/EditorToolbar.vue'; +import { useFileDropZone } from '@/composables/useFileDropZone'; import { consumeViewerFiles } from '@/composables/viewerSession'; import { usePageTitle } from '@/composables/usePageTitle'; import { useSystemTheme } from '@/composables/useSystemTheme'; @@ -52,7 +53,12 @@ settings.set('isShowToolbar', true); function applyFiles(files: File[]) { const file = files.find((item) => /\.(?:dxf|dwg)$/i.test(item.name)); - if (!file) return; + if (!file) { + if (files.length) { + error.value = '.dxf 또는 .dwg 파일을 선택해 주세요.'; + } + return; + } pendingFile.value = file; if (fontReady.value) localFile.value = file; filename.value = file.name; @@ -63,6 +69,14 @@ function applyFiles(files: File[]) { : ''; } +const { + dropActive, + onDragEnter, + onDragOver, + onDragLeave, + onDrop, +} = useFileDropZone(applyFiles); + async function initialize() { applyFiles(consumeViewerFiles('cad')); try { @@ -98,10 +112,6 @@ function onFileSelected(event: Event) { const files = Array.from(input.files ?? []); input.value = ''; if (!files.length) return; - if (!files.some((file) => /\.(?:dxf|dwg)$/i.test(file.name))) { - error.value = '.dxf 또는 .dwg 파일을 선택해 주세요.'; - return; - } applyFiles(files); void nextTick(); } @@ -126,7 +136,14 @@ function onClose() {