深度画布

This commit is contained in:
lzp
2026-03-20 13:23:00 +08:00
parent 34be403032
commit e00fe21e95
9 changed files with 378 additions and 5 deletions

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.42857 0V1.14286H12.5714V0H16V3.42857H14.8571V12.5714H16V16H12.5714V14.8571H3.42857V16H0V12.5714H1.14286V3.42857H0V0H3.42857ZM2.28571 13.7143H1.14286V14.8571H2.28571V13.7143ZM14.8571 13.7143H13.7143V14.8571H14.8571V13.7143ZM12.5714 2.28571H3.42857V3.42857H2.28571V12.5714H3.42857V13.7143H12.5714V12.5714H13.7143V3.42857H12.5714V2.28571ZM8 4.57143C8.15155 4.57143 8.2969 4.63163 8.40406 4.7388C8.51123 4.84596 8.57143 4.9913 8.57143 5.14286V7.42857H10.8571C11.0087 7.42857 11.154 7.48878 11.2612 7.59594C11.3684 7.7031 11.4286 7.84845 11.4286 8C11.4286 8.15155 11.3684 8.2969 11.2612 8.40406C11.154 8.51123 11.0087 8.57143 10.8571 8.57143H8.57143V10.8571C8.57143 11.0087 8.51123 11.154 8.40406 11.2612C8.2969 11.3684 8.15155 11.4286 8 11.4286C7.84845 11.4286 7.7031 11.3684 7.59594 11.2612C7.48878 11.154 7.42857 11.0087 7.42857 10.8571V8.57143H5.14286C4.9913 8.57143 4.84596 8.51123 4.7388 8.40406C4.63163 8.2969 4.57143 8.15155 4.57143 8C4.57143 7.84845 4.63163 7.7031 4.7388 7.59594C4.84596 7.48878 4.9913 7.42857 5.14286 7.42857H7.42857V5.14286C7.42857 4.9913 7.48878 4.84596 7.59594 4.7388C7.7031 4.63163 7.84845 4.57143 8 4.57143ZM14.8571 1.14286H13.7143V2.28571H14.8571V1.14286ZM2.28571 1.14286H1.14286V2.28571H2.28571V1.14286Z" fill="#0D0D0D"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.42857 0V1.14286H12.5714V0H16V3.42857H14.8571V12.5714H16V16H12.5714V14.8571H3.42857V16H0V12.5714H1.14286V3.42857H0V0H3.42857ZM2.28571 13.7143H1.14286V14.8571H2.28571V13.7143ZM14.8571 13.7143H13.7143V14.8571H14.8571V13.7143ZM12.5714 2.28571H3.42857V3.42857H2.28571V12.5714H3.42857V13.7143H12.5714V12.5714H13.7143V3.42857H12.5714V2.28571ZM10.8571 7.42857C11.0087 7.42857 11.154 7.48878 11.2612 7.59594C11.3684 7.7031 11.4286 7.84845 11.4286 8C11.4286 8.15155 11.3684 8.2969 11.2612 8.40406C11.154 8.51123 11.0087 8.57143 10.8571 8.57143H5.14286C4.9913 8.57143 4.84596 8.51123 4.7388 8.40406C4.63163 8.2969 4.57143 8.15155 4.57143 8C4.57143 7.84845 4.63163 7.7031 4.7388 7.59594C4.84596 7.48878 4.9913 7.42857 5.14286 7.42857H10.8571ZM14.8571 1.14286H13.7143V2.28571H14.8571V1.14286ZM2.28571 1.14286H1.14286V2.28571H2.28571V1.14286Z" fill="#0D0D0D"/>
</svg>

After

Width:  |  Height:  |  Size: 962 B

View File

@@ -0,0 +1,76 @@
<template>
<transition name="fade">
<div v-if="show" class="ai-selectbox-panel">
<div>
<span class="icon"><svg-icon name="dc-add" size="16" /></span>
<span class="label">Add</span>
</div>
<div>
<span class="icon"><svg-icon name="dc-remove" size="16" /></span>
<span class="label">Remove</span>
</div>
<button>创建</button>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, inject, computed, watch } from 'vue'
import depthSlider from './tools/depth-slider.vue'
import { OperationType } from '../tools/layerHelper'
const props = defineProps({
currentTool: { required: true, type: [String, null] }
})
const stateManager = inject('stateManager') as any
const toolManager = inject('toolManager') as any
const showTools = [OperationType.SELECTBOX]
const show = computed(() => showTools.includes(props.currentTool))
</script>
<style lang="less" scoped>
// 淡入淡出动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.ai-selectbox-panel {
position: absolute;
top: 8.8rem;
left: 50%;
transform: translateX(-50%);
height: 4.1rem;
padding: 0 1.7rem;
border: 0.2rem solid #ebebeb;
background: #ffffff;
border-radius: 1.1rem;
box-shadow: 0 1.66rem 2.33rem 0 rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
user-select: none;
gap: 1rem;
> div {
display: flex;
cursor: pointer;
align-items: center;
justify-content: center;
height: 2.6rem;
padding: 0 0.7rem;
border-radius: 0.3rem;
gap: 1rem;
> .label {
font-size: 1.4rem;
color: #000;
}
&.active,
&:hover {
background: rgba(235, 235, 235, 0.9);
}
}
}
</style>

View File

@@ -6,8 +6,10 @@
<template v-if="isReady"> <template v-if="isReady">
<layer-panel /> <layer-panel />
<details-panel /> <details-panel />
<depth-header-tools @export="exportCanvas" @workbench="(v) => emit('workbench', v)" />
<brush-control-panel :currentTool="toolManager.currentTool.value" /> <brush-control-panel :currentTool="toolManager.currentTool.value" />
<ai-selectbox-panel :currentTool="toolManager.currentTool.value" />
<depth-header-tools @export="exportCanvas" @workbench="(v) => emit('workbench', v)" />
<zoom <zoom
:zoom="canvasManager.currentZoom.value / 100" :zoom="canvasManager.currentZoom.value / 100"
is-home is-home
@@ -30,6 +32,7 @@
import depthHeaderTools from './components/depth-header-tools.vue' import depthHeaderTools from './components/depth-header-tools.vue'
import zoom from '../components/zoom.vue' import zoom from '../components/zoom.vue'
import brushControlPanel from './components/brush-control-panel.vue' import brushControlPanel from './components/brush-control-panel.vue'
import aiSelectboxPanel from './components/ai-selectbox-panel.vue'
// 管理器 // 管理器
import { StateManager } from './manager/StateManager' import { StateManager } from './manager/StateManager'

View File

@@ -48,7 +48,7 @@
id: config.value.canvasId || null id: config.value.canvasId || null
} }
const sData = await saveDepthCanvas(data) const sData = await saveDepthCanvas(data)
console.log(sData)
const canvasId = sData.id const canvasId = sData.id
// base64 转 file 上传转换为 url // base64 转 file 上传转换为 url
const file = base64Tofile(options.url, 'canvas.png') const file = base64Tofile(options.url, 'canvas.png')

View File

@@ -1,4 +1,6 @@
import { fabric } from 'fabric-with-all' import { fabric } from 'fabric-with-all'
import { createId } from '../../tools/tools'
/** 智能框选工具管理器 */ /** 智能框选工具管理器 */
export class AISelectboxToolManager { export class AISelectboxToolManager {
// 管理器 // 管理器
@@ -14,6 +16,7 @@ export class AISelectboxToolManager {
this.canvasManager = options.canvasManager this.canvasManager = options.canvasManager
this.stateManager = options.stateManager this.stateManager = options.stateManager
this.layerManager = options.layerManager this.layerManager = options.layerManager
} }
mouseDownEvent(e) { mouseDownEvent(e) {
this.isDragging = true this.isDragging = true
@@ -39,11 +42,11 @@ export class AISelectboxToolManager {
var height = e.absolutePointer.y - this.startY var height = e.absolutePointer.y - this.startY
var left = this.startX var left = this.startX
var top = this.startY var top = this.startY
if(width < 0) { if (width < 0) {
left += width left += width
width = -width width = -width
} }
if(height < 0) { if (height < 0) {
top += height top += height
height = -height height = -height
} }
@@ -60,6 +63,249 @@ export class AISelectboxToolManager {
this.canvasManager.canvas.remove(this.demoObject) this.canvasManager.canvas.remove(this.demoObject)
this.canvasManager.canvas.renderAll() this.canvasManager.canvas.renderAll()
this.createSelectbox()
} }
loadImageToObject(url) {
return new Promise((resolve, reject) => {
fabric.Image.fromURL(url, (img) => {
resolve(img);
}, { crossOrigin: "anonymous" });// 防止污染
});
}
rgba = { r: 0, g: 255, b: 0, a: 200 };
selectionStyle = {
stroke: "rgba(255, 77, 71, 1)",
strokeWidth: 1.5,
strokeDashArray: [4, 4],
fill: "rgba(255, 186, 186, 0.5)",
strokeUniform: true, // 保持描边宽度不随缩放改变
// strokeLineCap: "round",// 折线端点样式
// strokeLineJoin: "bevel", // 折线连接样式
// selectable: false,
// evented: false,
excludeFromExport: true,
hoverCursor: "default",
moveCursor: "default",
};
async createSelectbox() {
const url = "http://118.31.39.42:3000/falls/1a48ed3a-1faa-4fcd-bf07-765dba1702c5.png"
const image = await this.loadImageToObject(url)
const canvas = getObjectAlphaToCanvas(image, null, 0, this.rgba);
const fobject = this.canvasManager.canvas.clipPath
// const top = fobject.top - fobject.height * scaleY / 2;
// const left = fobject.left - fobject.width * scaleX / 2;
const scaleY = fobject.scaleY
const scaleX = fobject.scaleX
const top = fobject.top
const left = fobject.left
const arr = traceImageContour(canvas);
let minX = fobject.width;
let minY = fobject.height;
const str = arr.map((v) => {
if (v.x < minX) minX = v.x;
if (v.y < minY) minY = v.y;
return `${v.x} ${v.y}`
}).join(" L ");
const path = new fabric.Path(`M ${str} z`);
path.set({
left: left + minX * scaleX,
top: top + minY * scaleY,
scaleX: scaleX,
scaleY: scaleY,
...this.selectionStyle,
});
const rect1 = new fabric.Rect({
left: 0,
top: 0,
width: 100,
height: 100,
fill: '#f00',
info: {
id: createId("rect"),
name: '矩形图层',
}
})
const rect2 = new fabric.Rect({
left: 200,
top: 200,
width: 100,
height: 100,
fill: '#ff0',
info: {
id: createId("rect"),
name: '矩形图层',
}
})
this.layerManager.createGroupLayer({
child: [rect1, rect2],
})
// this.canvasManager.canvas.add(path)
// this.canvasManager.canvas.renderAll()
}
dispose() { } dispose() { }
} }
/**
* 获取对象黑白通道画布
* @param {fabric.Object} object - 要处理的 fabric 对象
* @param {ImageData} revData - 相反的ImageData白通道的相同位置是否为透明revData为白色为透明黑色为不透明
* @param {number} diff - 差值,默认 25
* @param {Object} rgba - 自定义 rgba 值,默认 { r: 255, g: 255, b: 255, a: 255 }
* @param {boolean} isMerge - 是否合并true=合并revDatafalse=反转revData
* @returns {HTMLCanvasElement|null} 包含黑白通道的画布,或 null 如果失败
*/
export function getObjectAlphaToCanvas(object, revData, diff = 30, rgba = { r: 255, g: 255, b: 255, a: 255 }, isMerge = false) {
const image = object.getElement();
if (image.nodeName !== "IMG" && image.nodeName !== "CANVAS") {
console.warn("对象不是图片");
return null;
}
const { width, height } = image;
if (!width || !height) {
console.warn("对象没有元素");
return null;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, width, height);
const data = ctx.getImageData(0, 0, width, height);
for (let i = 0; i < data.data.length; i += 4) {
const r = data.data[i + 0];
const g = data.data[i + 1];
const b = data.data[i + 2];
const a = data.data[i + 3];
const revR = revData?.data[i + 0] || 0;
const revG = revData?.data[i + 1] || 0;
const revB = revData?.data[i + 2] || 0;
const revA = revData?.data[i + 3] || 0;
let isHave = false;
if (r || g || b || a) {
if (revR > diff || revG > diff || revB > diff || revA > diff) {
isHave = false;
} else {
isHave = true;
}
}
if (isMerge && (revR || revG || revB || revA)) isHave = true;
if (isHave) {
data.data[i + 0] = rgba.r;
data.data[i + 1] = rgba.g;
data.data[i + 2] = rgba.b;
data.data[i + 3] = rgba.a;
} else {
data.data[i + 0] = 0;
data.data[i + 1] = 0;
data.data[i + 2] = 0;
data.data[i + 3] = 0;
}
}
ctx.clearRect(0, 0, width, height);
ctx.putImageData(data, 0, 0);
return canvas;
}
/**
* 图片边界跟踪算法(透明底)
* @param {HTMLCanvasElement} canvas - canvas元素
* @param {Number} scale - 缩放比例
* @returns {Array} 边界点数组 [{x, y}, ...]
*/
export function traceImageContour(canvas) {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
// 查找起始点(第一个不透明像素)
let startX = -1;
let startY = -1;
outer: for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = (y * width + x) * 4;
if (data[index + 3] > 0) {
startX = x;
startY = y;
break outer;
}
}
}
if (startX === -1) return []; // 没有不透明像素
// Moore-Neighbor边界跟踪算法
const contour = [];
const visited = new Set();
const directions = [
[-1, 0],
[-1, -1],
[0, -1],
[1, -1],
[1, 0],
[1, 1],
[0, 1],
[-1, 1],
];
let currentX = startX;
let currentY = startY;
let backtrackDir = 4; // 起始方向:右
do {
const pointKey = `${currentX},${currentY}`;
if (!visited.has(pointKey)) {
contour.push({ x: currentX, y: currentY });
visited.add(pointKey);
}
// 从右方向开始顺时针查找
let found = false;
for (let i = 0; i < 8; i++) {
const dir = (backtrackDir + i) % 8;
const dx = directions[dir][0];
const dy = directions[dir][1];
const checkX = currentX + dx;
const checkY = currentY + dy;
if (
checkX >= 0 &&
checkX < width &&
checkY >= 0 &&
checkY < height
) {
const index = (checkY * width + checkX) * 4;
if (data[index + 3] > 0) {
currentX = checkX;
currentY = checkY;
backtrackDir = (dir + 5) % 8; // 下一个开始查找的方向
found = true;
break;
}
}
}
if (!found) break;
} while (
!(currentX === startX && currentY === startY) &&
visited.size < width * height
);
return contour;
}

View File

@@ -29,6 +29,17 @@ fabric.Object.prototype.toObject = function () {
return object return object
} }
fabric.Image.fromURL = (function (originalFromURL) {
return function (url, callback, imgOptions) {
// 为所有图片请求添加 crossOrigin
const options = {
crossOrigin: 'anonymous', // 关键设置
...imgOptions
};
return originalFromURL.call(this, url, callback, options);
};
})(fabric.Image.fromURL);
interface CanvasInitOptions { interface CanvasInitOptions {
canvasRef: any canvasRef: any
canvasViewWidth?: number canvasViewWidth?: number

View File

@@ -151,6 +151,37 @@ export class LayerManager {
if (isActive) this.setActiveID(emptyObject.info.id, false) if (isActive) this.setActiveID(emptyObject.info.id, false)
return emptyObject return emptyObject
} }
/** 创建组图层 */
createGroupLayer(options?: any, isRecord = true, isActive = false) {
const child = options?.child || []
delete options.child
const groupObject = new fabric.Group(child, {
subTargetCheck: true, // 关键:检测子对象
interactive: true, // 启用交互
hasControls: true,
hasBorders: true,
// // 子对象样式
// cornerColor: 'blue',
// cornerSize: 8,
// borderColor: 'green',
// // 允许子对象独立变换
// lockScalingX: false,
// lockScalingY: false,
// lockRotation: false,
info: {
id: createId("group"),
name: '组图层',
...(options?.info || {}),
}
})
// this.setLayerPosition(groupObject)
this.canvasManager.add(groupObject, isRecord)
if (isActive) this.setActiveID(groupObject.info.id, false)
return groupObject
}
/** 创建文本图层 */ /** 创建文本图层 */
async createTextLayer(text: string, options?: any) { async createTextLayer(text: string, options?: any) {
const textObject = new fabric.IText(text, { const textObject = new fabric.IText(text, {

View File

@@ -10,7 +10,7 @@
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
const url = '' const url = 'https://www.minio-api.aida.com.hk/fida-test/furniture/sketches/1a48ed3a-1faa-4fcd-bf07-765dba1702c5.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=admin%2F20260320%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260320T020948Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=7dc192bac887bce7b02c99d7037c08d9d684310f00add9b0e63b74b36ee63d37'
const openCanvas = () => { const openCanvas = () => {
myEvent.emit('openFlowCanvas', { url }) myEvent.emit('openFlowCanvas', { url })
} }