@@ -5,6 +5,10 @@
< div class = "hud" >
< span v-if = "info" > {{ info }} < / span >
< template v-if = "editable" >
< span class = "dim-input" > W < el-input-number v-model = "sizeW" :min="300" :step="10" size="small"
controls -position = " right " style = "width:104px" @change ="onSizeInput" / > < / span >
< span class = "dim-input" > H < el-input-number v-model = "sizeH" :min="300" :step="10" size="small"
controls -position = " right " style = "width:104px" @change ="onSizeInput" / > < / span >
< el-button size = "small" : type = "tool === 'v' ? 'primary' : ''" @click ="arm('v')" > + 竖梃 < / el -button >
< el-button size = "small" : type = "tool === 'h' ? 'primary' : ''" @click ="arm('h')" > + 横梃 < / el -button >
< el-button size = "small" :disabled = "!undoStack.length" @click ="undo" > 撤销 < / el -button >
@@ -17,9 +21,11 @@
< script setup lang = "ts" >
/**
* 二维窗型画布(自研实现)
* 查看模式 :渲染玻璃区域/外框/扇开向/尺寸标注, 滚轮缩放、拖拽平移、双击复位
* 编辑模式(传入 editable):点击区域添加竖梃/横梃(多边形实时分割),支持撤销;
* 通过 expose 的 exportDrawing() 取回修改后的 drawing_data
* 查看:渲染玻璃区域/外框/扇开向/尺寸标注; 滚轮缩放、拖拽平移、双击复位
* 编辑( editable):
* - 尺寸:W/H 输入框 或 拖拽外框右缘/下缘/右下角手柄 → 全部几何按比例缩放(型材料宽不变)
* - 分格:+竖梃/+横梃 → 点击玻璃区域完成分割(两次交叉点法),支持撤销
* - exportDrawing() 取回修改后的 drawing_data; change 事件通知父组件
*/
import { onMounted , ref , watch } from 'vue'
import { ElMessage } from 'element-plus'
@@ -40,8 +46,11 @@ let dd: DrawingData | null = null
let modelBox = { x0 : 0 , y0 : 0 , x1 : 100 , y1 : 100 }
const undoStack : string [ ] = [ ]
const tool = ref < '' | 'v' | 'h' > ( '' )
const sizeW = ref ( 0 )
const sizeH = ref ( 0 )
let resizeDrag : null | { axis : 'w' | 'h' | 'wh' ; startW : number ; startH : number ; startX : number ; startY : number ; snapshot : string } = null
// 几何转换缓存(render 时更新,点击 命中用)
// 几何转换缓存(render 时更新,命中测试 用)
let lastTransform = { scale : 1 , midX : 0 , midY : 0 , W : 0 , H : 0 }
function toModel ( sx : number , sy : number ) : Pt {
@@ -49,6 +58,63 @@ function toModel(sx: number, sy: number): Pt {
return { x : ( sx - W / 2 - view . cx ) / scale + midX , y : ( H / 2 + view . cy - sy ) / scale + midY }
}
// ============ 参数化尺寸缩放:全部几何按比例缩放,型材料宽(ew)不变 ============
function scaleAll ( sx : number , sy : number ) {
if ( ! dd ) return
const sc = ( p : Pt ) => { p . x *= sx ; p . y *= sy }
for ( const shape of dd . sm ) {
for ( const r of ( shape . polygon ? . polygon ? ? [ ] ) ) {
for ( const seg of r ) { sc ( seg . ps ) ; sc ( seg . pe ) }
}
for ( const s of ( ( shape . mm ? ? = { sp : [ ] } ) . sp ? ? [ ] ) ) sc ( s . line . pt )
for ( const sub of ( shape . sm ? ? [ ] ) as SashShape [ ] ) {
if ( sub . bd ) { sc ( sub . bd . from ) ; sc ( sub . bd . to ) }
}
}
}
function resizeTo ( newW : number , newH : number , silent = false ) {
if ( ! dd ) return
newW = Math . max ( 300 , Math . round ( newW ) )
newH = Math . max ( 300 , Math . round ( newH ) )
const w = modelBox . x1 - modelBox . x0 , h = modelBox . y1 - modelBox . y0
if ( w < 10 || h < 10 ) return
if ( Math . abs ( newW - w ) < 0.5 && Math . abs ( newH - h ) < 0.5 ) { syncSizeInputs ( ) ; return }
scaleAll ( newW / w , newH / h )
computeModelBox ( )
syncSizeInputs ( )
render ( )
if ( ! silent ) emit ( 'change' )
}
function syncSizeInputs ( ) {
sizeW . value = Math . round ( modelBox . x1 - modelBox . x0 )
sizeH . value = Math . round ( modelBox . y1 - modelBox . y0 )
}
function onSizeInput ( ) {
if ( ! dd ) return
undoStack . push ( JSON . stringify ( dd ) )
resizeTo ( sizeW . value , sizeH . value )
}
/** 命中测试:屏幕点是否落在整体外框右缘/下缘/右下角的拖拽缩放带 */
function hitResizeHandle ( sx : number , sy : number ) : 'w' | 'h' | 'wh' | null {
const { scale , midX , midY , W , H } = lastTransform
const x1 = W / 2 + ( modelBox . x1 - midX ) * scale + view . cx
const y1 = H / 2 - ( modelBox . y0 - midY ) * scale + view . cy
const TOL = 8
const nearRight = Math . abs ( sx - x1 ) < TOL && sy <= y1 + TOL
const nearBottom = Math . abs ( sy - y1 ) < TOL && sx <= x1 + TOL
if ( nearRight && nearBottom ) return 'wh'
if ( nearRight ) return 'w'
if ( nearBottom ) return 'h'
return null
}
// ============ 渲染 ============
function render ( ) {
const cv = cvRef . value , wrap = wrapRef . value
if ( ! cv || ! wrap ) return
@@ -70,23 +136,43 @@ function render() {
const tx = ( x : number ) => W / 2 + ( x - midX ) * scale + view . cx
const ty = ( y : number ) => H / 2 - ( y - midY ) * scale + view . cy
const glass = '#d3ddda' , glassAlpha = 'rgba(211,221,218,0.6)'
const glassAlpha = 'rgba(211,221,218,0.6)'
const profile = '#5c6166'
ctx . lineJoin = 'round'
for ( const shape of dd . sm ) drawFrame ( ctx , shape , tx , ty , { glass , glassAlpha , profile } )
for ( const shape of dd . sm ) drawFrame ( ctx , shape , tx , ty , { glassAlpha , profile } )
// 编辑模式:右下角缩放手柄
if ( props . editable ) {
const x1 = tx ( modelBox . x1 ) , y1 = ty ( modelBox . y0 )
ctx . fillStyle = '#409eff'
ctx . fillRect ( x1 - 5 , y1 - 5 , 10 , 10 )
ctx . strokeStyle = '#fff'
ctx . lineWidth = 1.5
ctx . strokeRect ( x1 - 5 , y1 - 5 , 10 , 10 )
ctx . setLineDash ( [ 4 , 4 ] )
ctx . strokeStyle = 'rgba(64,158,255,0.55)'
ctx . lineWidth = 1
ctx . beginPath ( )
ctx . moveTo ( x1 + 6 , y1 ) ; ctx . lineTo ( x1 + 28 , y1 )
ctx . moveTo ( x1 , y1 + 6 ) ; ctx . lineTo ( x1 , y1 + 28 )
ctx . stroke ( )
ctx . setLineDash ( [ ] )
}
info . value = dd . sm . length > 1 ? ` 窗型 · ${ dd . sm . length } 个窗体组合 ` : '窗型'
}
function drawFrame (
ctx : CanvasRenderingContext2D , shape : FrameShape ,
tx : ( x : number ) => number , ty : ( y : number ) => number ,
color : { glass : string ; glassAlpha : string ; profile : string } ,
color : { glassAlpha : string ; profile : string } ,
) {
const regionsRaw = shape . polygon ? . polygon ? ? [ ]
const regions = regionsRaw . map ( regionPoints ) . filter ( pts => pts . length >= 3 )
const cm = shape . cm ? ? { }
// 玻璃区域
for ( const pts of regions ) {
ctx . beginPath ( )
pts . forEach ( ( p , i ) => ( i ? ctx . lineTo ( tx ( p . x ) , ty ( p . y ) ) : ctx . moveTo ( tx ( p . x ) , ty ( p . y ) ) ) )
@@ -101,6 +187,7 @@ function drawFrame(
ctx . stroke ( )
}
// 外框
if ( regions . length ) {
const bb = bboxOf ( regions . flat ( ) )
const x0 = tx ( bb . x0 ) , y0 = ty ( bb . y1 ) , x1 = tx ( bb . x1 ) , y1 = ty ( bb . y0 )
@@ -115,11 +202,73 @@ function drawFrame(
ctx . strokeRect ( x0 + inset , y0 + inset , ( x1 - x0 ) - inset * 2 , ( y1 - y0 ) - inset * 2 )
}
// ---- 扇样式推断(真实分格还原)----
// 优先级: 有效bd > polyId映射区域(双扇再对半) > 帧bbox按扇数竖向均分
const regionBBs = regions . map ( pts => bboxOf ( pts ) )
type Glyph = { x0 : number ; y0 : number ; x1 : number ; y1 : number ; od : number ; openToward ? : string ; isDoor ? : boolean }
const glyphs : Glyph [ ] = [ ]
const bdRect = ( bd : NonNullable < SashShape [ ' bd ' ] > ) => {
const r = {
x0 : Math . min ( bd . from . x , bd . to . x ) , y0 : Math . min ( bd . from . y , bd . to . y ) ,
x1 : Math . max ( bd . from . x , bd . to . x ) , y1 : Math . max ( bd . from . y , bd . to . y ) ,
}
return ! bd . hidden && r . x1 - r . x0 > 10 && r . y1 - r . y0 > 10 ? r : null
}
const pushGlyphs = ( r : { x0 : number ; y0 : number ; x1 : number ; y1 : number } ,
entries : any [ ] , sub : SashShape ) => {
for ( const e of entries ) {
glyphs . push ( {
x0 : r . x0 , y0 : r . y0 , x1 : r . x1 , y1 : r . y1 ,
od : ( e . od ? ? sub . od ? ? 90 ) as number ,
openToward : ( e . openToward ? ? sub . openToward ) as string | undefined ,
isDoor : Boolean ( e . isDoor ? ? sub . isDoor ) ,
} )
}
}
const unmatched : { sub : SashShape ; entries : any [ ] } [ ] = [ ]
for ( const sub of ( shape . sm ? ? [ ] ) as SashShape [ ] ) {
const bd = sub . bd
if ( ! bd || bd . hidden ) continue
const bx0 = tx ( Math . min ( bd . from . x , bd . to . x ) ) , bx1 = tx ( Math . max ( bd . from . x , bd . to . x ) )
const by0 = ty ( Math . max ( bd . from . y , bd . to . y ) ) , by1 = ty ( Math . min ( bd . from . y , bd . to . y ) )
const raw = ( sub . sashes ? . length ? sub . sashes : [ sub ] ) as any [ ]
const entries = raw . filter ( e => e && e . enabled !== false )
if ( ! entries . length ) continue
const rect = sub . bd ? bdRect ( sub . bd ) : null
if ( rect ) { pushGlyphs ( rect , entries , sub ) ; continue }
const idx = sub . polyId ? . idx
const rb = idx != null && regionBBs [ idx ] ? regionBBs [ idx ] : null
if ( rb ) {
if ( entries . length === 2 ) {
const w = rb . x1 - rb . x0 , h = rb . y1 - rb . y0
const halves = w >= h
? [ { ... rb , x1 : ( rb . x0 + rb . x1 ) / 2 } , { ... rb , x0 : ( rb . x0 + rb . x1 ) / 2 } ]
: [ { ... rb , y1 : ( rb . y0 + rb . y1 ) / 2 } , { ... rb , y0 : ( rb . y0 + rb . y1 ) / 2 } ]
entries . forEach ( ( e , k ) => pushGlyphs ( halves [ k ] , [ e ] , sub ) )
} else {
pushGlyphs ( rb , entries , sub )
}
} else {
unmatched . push ( { sub , entries } )
}
}
// 兜底:帧 bbox 按剩余扇数竖向均分
if ( unmatched . length && regions . length ) {
const fb = bboxOf ( regions . flat ( ) )
const K = unmatched . reduce ( ( s , u ) => s + u . entries . length , 0 )
const colW = ( fb . x1 - fb . x0 ) / K
let i = 0
for ( const u of unmatched ) {
for ( const e of u . entries ) {
glyphs . push ( {
x0 : fb . x0 + i * colW , x1 : fb . x0 + ( i + 1 ) * colW , y0 : fb . y0 , y1 : fb . y1 ,
od : ( e . od ? ? u . sub . od ? ? 90 ) as number ,
openToward : ( e . openToward ? ? u . sub . openToward ) as string | undefined ,
isDoor : Boolean ( e . isDoor ? ? u . sub . isDoor ) ,
} )
i ++
}
}
}
// 绘制扇:虚线内框 + 开向三角(inward 空心 / outward 实心)
for ( const g of glyphs ) {
const bx0 = tx ( g . x0 ) , bx1 = tx ( g . x1 ) , by0 = ty ( g . y1 ) , by1 = ty ( g . y0 )
const w = bx1 - bx0 , h = by1 - by0
if ( w < 4 || h < 4 ) continue
ctx . save ( )
@@ -128,7 +277,7 @@ function drawFrame(
ctx . lineWidth = 1.3
ctx . strokeRect ( bx0 + 2 , by0 + 2 , w - 4 , h - 4 )
ctx . setLineDash ( [ ] )
const ang = ( ( sub . od ? ? 0 ) * Math . PI ) / 180
const ang = ( ( g . od || 90 ) * Math . PI ) / 180
const cx = ( bx0 + bx1 ) / 2 , cy = ( by0 + by1 ) / 2
const L = Math . min ( w , h ) * 0.32
const ax = cx + Math . cos ( ang ) * L , ay = cy - Math . sin ( ang ) * L
@@ -139,19 +288,22 @@ function drawFrame(
ctx . lineTo ( cx - wx , cy - wy )
ctx . lineTo ( cx + wx , cy + wy )
ctx . closePath ( )
ctx . fillStyle = 'rgba(70,76,82,0.25)'
ctx . fillStyle = g . openToward === 'inward' ? 'rgba(255,255,255,0.55)' : 'rgba(70,76,82,0.25)'
ctx . fill ( )
ctx . strokeStyle = 'rgba(70,76,82,0.95)'
ctx . lineWidth = 1.4
ctx . stroke ( )
if ( sub . isDoor ) {
if ( g . isDoor ) {
ctx . fillStyle = 'rgba(70,76,82,0.85)'
ctx . font = '10px sans-serif'
ctx . fillText ( '门' , cx - 6 , by1 - 6 )
ctx . textAlign = 'center'
ctx . fillText ( '门' , cx , by1 - 6 )
ctx . textAlign = 'left'
}
ctx . restore ( )
}
// 尺寸标注(宽/高,随几何自动更新)
if ( regions . length ) {
const bb = bboxOf ( regions . flat ( ) )
const x0 = tx ( bb . x0 ) , x1 = tx ( bb . x1 ) , y0 = ty ( bb . y1 ) , y1 = ty ( bb . y0 )
@@ -174,7 +326,7 @@ function drawFrame(
}
}
// ================= 编辑:区域分割 ===== ============
// ============ 编辑:区域分割(两次交叉点法) ============
function pointInPoly ( p : Pt , poly : Pt [ ] ) : boolean {
let inside = false
@@ -186,7 +338,6 @@ function pointInPoly(p: Pt, poly: Pt[]): boolean {
return inside
}
/** 按垂直/水平线分割简单多边形(两次交叉点法,适用于矩形/凸区域——样板库区域均为此类) */
function splitPoly ( region : Segment [ ] , axis : 'v' | 'h' , pos : number ) :
{ a : Segment [ ] ; b : Segment [ ] } | null {
const pts = regionPoints ( region )
@@ -206,11 +357,10 @@ function splitPoly(region: Segment[], axis: 'v' | 'h', pos: number):
} )
}
}
if ( crossings . length !== 2 ) return null // 0或多次穿越:不支持(非凸/贴边)
if ( crossings . length !== 2 ) return null
const [ c1 , c2 ] = crossings
if ( c1 . i === c2 . i ) return null // 同一条边穿越两次,退化
if ( c1 . i === c2 . i ) return null
// 沿边界收集两侧点列
const collect = ( from : typeof c1 , to : typeof c2 ) : Pt [ ] => {
const ps : Pt [ ] = [ from . p ]
for ( let k = ( from . i + 1 ) % n ; ; k = ( k + 1 ) % n ) {
@@ -231,8 +381,10 @@ function splitPoly(region: Segment[], axis: 'v' | 'h', pos: number):
return { a : toSegs ( polyA ) , b : toSegs ( polyB ) }
}
function mkSeg ( a : Pt , b : Pt ) : Segment { return { ps : a , pe : b , name : 'segment' } }
function onClick ( e : MouseEvent ) {
if ( ! editable ( ) || ! tool . value || ! dd ) return
if ( ! props . editable || ! tool . value || ! dd ) return
const rect = cvRef . value ! . getBoundingClientRect ( )
const click = toModel ( e . clientX - rect . left , e . clientY - rect . top )
undoStack . push ( JSON . stringify ( dd ) )
@@ -246,24 +398,21 @@ function onClick(e: MouseEvent) {
const pos = axis === 'v' ? click . x : click . y
const split = splitPoly ( regions [ ri ] , axis , pos )
if ( ! split ) { undoStack . pop ( ) ; ElMessage . warning ( '分割位置太靠近边缘' ) ; return }
// 生成两段新区域的闭合段链
const chain = ( segs : Segment [ ] ) : Segment [ ] => {
// 简单闭合:在首尾间补一段
const first = segs [ 0 ] . ps , last = segs [ segs . length - 1 ] . pe
if ( Math . abs ( first . x - last . x ) > 0.01 || Math . abs ( first . y - last . y ) > 0.01 ) segs . push ( mkSeg ( last , first ) )
return segs
}
const newRegions = [ chain ( split . a ) , chain ( split . b ) ]
const newRegions = [ split . a , split . b ]
regions . splice ( ri , 1 , ... newRegions )
// 追加分割线记录(与目标站 mm.sp 同构)
const mid = axis === 'v' ? { x : pos , y : ( pts . reduce ( ( s , p ) => s + p . y , 0 ) / pts . length ) } : { x : ( pts . reduce ( ( s , p ) => s + p . x , 0 ) / pts . length ) , y : pos }
// 追加分割线记录(与 shapeManager 的 mm.sp 同构)
const centroid = pts . reduce ( ( s , p ) => ( { x : s . x + p . x / pts . length , y : s . y + p . y / pts . length } ) , { x : 0 , y : 0 } )
const mid : Pt = axis === 'v' ? { x : pos , y : centroid . y } : { x : centroid . x , y : pos }
const norm = axis === 'v' ? { x : 1 , y : 0 } : { x : 0 , y : - 1 }
; ( shape . mm ? ? = { sp : [ ] } ) . sp . push ( {
const mm = ( shape . mm ? ? = { sp : [ ] } )
mm . sp = mm . sp ? ? [ ]
mm . sp . push ( {
line : { pt : mid , norm , name : 'line' } ,
polyId : { idx : ri , pos : - 1 } ,
alignType : 0 , equalSplit : 0 , kw : axis === 'v' , ws : false , reinforced : false , db : false ,
} as any )
computeModelBox ( )
syncSizeInputs ( )
render ( )
emit ( 'change' )
return
@@ -271,15 +420,13 @@ function onClick(e: MouseEvent) {
}
}
function mkSeg ( a : Pt , b : Pt ) : Segment { return { ps : a , pe : b , name : 'segment' } }
function editable ( ) { return ! ! props . editable }
function arm ( t : 'v' | 'h' ) { tool . value = tool . value === t ? '' : t ; info . value = tool . value ? '点击玻璃区域完成分割' : '' }
function undo ( ) {
if ( ! undoStack . length || ! dd ) return
const snap = undoStack . pop ( ) !
dd = JSON . parse ( snap )
dd = JSON . parse ( undoStack . pop ( ) ! )
computeModelBox ( )
syncSizeInputs ( )
render ( )
emit ( 'change' )
}
@@ -289,6 +436,8 @@ function exportDrawing(): string | null {
}
defineExpose ( { exportDrawing } )
// ============ 视图控制与交互 ============
function computeModelBox ( ) {
if ( ! dd ) return
const pts : Pt [ ] = [ ]
@@ -314,15 +463,57 @@ function onWheel(e: WheelEvent) {
view . scale = Math . min ( 40 , Math . max ( 0.2 , view . scale * factor ) )
render ( )
}
function onDown ( e : MouseEvent ) { if ( ! tool . value ) drag = { x : e . clientX , y : e . clientY } }
function onDown ( e : MouseEvent ) {
const rect = cvRef . value ! . getBoundingClientRect ( )
if ( props . editable && ! tool . value ) {
const hit = hitResizeHandle ( e . clientX - rect . left , e . clientY - rect . top )
if ( hit ) {
undoStack . push ( JSON . stringify ( dd ) )
resizeDrag = { axis : hit , startW : modelBox . x1 - modelBox . x0 , startH : modelBox . y1 - modelBox . y0 ,
startX : e . clientX , startY : e . clientY , snapshot : '' }
return
}
}
drag = { x : e . clientX , y : e . clientY }
}
function onMove ( e : MouseEvent ) {
// 拖拽缩放
if ( resizeDrag ) {
const dpx = e . clientX - resizeDrag . startX
const dpy = e . clientY - resizeDrag . startY
const { scale } = lastTransform
const dw = resizeDrag . axis === 'h' ? 0 : dpx / scale
const dh = resizeDrag . axis === 'w' ? 0 : - dpy / scale
resizeTo ( resizeDrag . startW + dw , resizeDrag . startH + dh , true ) // 拖拽中静默,松手后统一提交
return
}
// 光标提示
if ( props . editable && ! tool . value && cvRef . value ) {
const rect = cvRef . value . getBoundingClientRect ( )
const hit = hitResizeHandle ( e . clientX - rect . left , e . clientY - rect . top )
cvRef . value . style . cursor = hit === 'wh' ? 'nwse-resize' : hit === 'w' ? 'ew-resize' : hit === 'h' ? 'ns-resize' : 'grab'
}
if ( ! drag ) return
view . cx += e . clientX - drag . x
view . cy += e . clientY - drag . y
drag = { x : e . clientX , y : e . clientY }
render ( )
}
function onUp ( ) { drag = null }
function onUp ( ) {
if ( resizeDrag ) {
// 拖拽过程中 resizeTo 走 silent;松手时统一判定是否真变了
const changed = Math . abs ( ( modelBox . x1 - modelBox . x0 ) - resizeDrag . startW ) > 0.5 ||
Math . abs ( ( modelBox . y1 - modelBox . y0 ) - resizeDrag . startH ) > 0.5
if ( ! changed ) undoStack . pop ( ) // 尺寸未变,撤销点作废
resizeDrag = null
if ( changed ) emit ( 'change' )
return
}
drag = null
}
onMounted ( ( ) => {
watch ( ( ) => props . drawingData , ( ) => {
@@ -330,6 +521,7 @@ onMounted(() => {
dd = props . drawingData ? parseDrawing ( props . drawingData ) : null
undoStack . length = 0
computeModelBox ( )
syncSizeInputs ( )
reset ( )
} catch {
info . value = '数据解析失败'
@@ -347,5 +539,6 @@ onMounted(() => {
canvas { width : 100 % ; height : 100 % ; display : block ; cursor : grab ; }
canvas : active { cursor : grabbing ; }
. hud { position : absolute ; right : 10 px ; top : 10 px ; display : flex ; gap : 8 px ; align - items : center ;
background : rgba ( 255 , 255 , 255 , 0.9 ) ; border - radius : 6 px ; padding : 2 px 8 px ; font - size : 12 px ; color : # 606266 ; }
background : rgba ( 255 , 255 , 255 , 0.92 ) ; border - radius : 6 px ; padding : 2 px 8 px ; font - size : 12 px ; color : # 606266 ; }
. dim - input { display : inline - flex ; align - items : center ; gap : 4 px ; color : # 409 eff ; font - weight : 600 ; }
< / style >