dsh-drawai 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -19,7 +19,13 @@ import { readdir } from 'node:fs/promises'
19
19
  import { isAbsolute, resolve as resolvePath, join as joinPath } from 'node:path'
20
20
  import { applyDocToMxfile, buildMxfile, contentHash, parseMxfile } from './mxfile.js'
21
21
  import { decodeRaster } from './raster.js'
22
- import { TRACE_DEFAULTS, traceRasterToSvg } from './trace-image.js'
22
+ import {
23
+ TRACE_DEFAULTS,
24
+ TRACE_PRESET_NAMES,
25
+ resolveTraceOptions,
26
+ summarizeTraceForAi,
27
+ traceRasterToSvg,
28
+ } from './trace-image.js'
23
29
  import {
24
30
  ARROW_KINDS,
25
31
  DASH_KINDS,
@@ -408,8 +414,17 @@ function fontColorFieldOf(style) {
408
414
  const DEFAULT_SVG_W = 80
409
415
  const DEFAULT_SVG_H = 80
410
416
 
411
- /** 一段内嵌 SVG 标记的长度上限。超了直接拒:一个单元格几十万字符会把文件和上下文一起撑爆。 */
412
- const SVG_MARKUP_LIMIT = 60000
417
+ /**
418
+ * 一段内嵌 SVG 标记的长度上限。超了直接拒:一个单元格几十万字符会把文件和上下文一起撑爆。
419
+ *
420
+ * 为什么是 250000:**这是整图矢量化精细度的直接瓶颈**。实测一张 1024×1536 的立绘,
421
+ * grid 800 的原始产出就要 249630 字符 —— 卡在 60000 时管线只能压掉 5.4 倍(eps 抬到 3.2、
422
+ * 小环大量丢弃,环保留从 10235 掉到 2049),于是"脸和细节糊"就成了必然。
423
+ * 放到 250000:同一张图 grid 1000 只压 2 次、环保留 5455(翻 2.7 倍)。
424
+ * 再往上(不压缩约 327000 字符)文件会到 440KB/节点、读回时上下文开销约 8~9 万 token,
425
+ * 收益递减,所以停在 250000。要更细请用 cropX/cropY/cropW/cropH 做局部(那不受这个上限的边际影响)。
426
+ */
427
+ const SVG_MARKUP_LIMIT = 250000
413
428
  /** traceImage 能读的图片字节上限(够任何截图,又不至于让一次调用把宿主内存吃干)。 */
414
429
  const TRACE_IMAGE_MAX_BYTES = 32 * 1024 * 1024
415
430
  /** diagram_read 里回显 svg 的长度上限(超了截断并注明总长)。 */
@@ -625,6 +640,54 @@ function styleNote(style) {
625
640
  return bits.length === 0 ? '' : ' [' + bits.join(', ') + ']'
626
641
  }
627
642
 
643
+ /**
644
+ * 解析 traceImage 的裁剪矩形(cropX / cropY / cropW / cropH,都相对原图像素坐标)。
645
+ *
646
+ * 为什么要裁剪:矢量化的工作分辨率上限由 grid(默认 499px 宽)决定 —— 整张图画进去时,
647
+ * 一张 1024 宽的立绘里"脸"只有约 38px 宽、眼睛 3~4px,量化后五官必然消失。
648
+ * 裁出局部再描,同一块区域就占满工作图宽度,脸能有 190px —— 这是唯一能真正改善
649
+ * "面部特征缺失"的手段(调 colors / protectLuma 都无效:已经实测扫过参数)。
650
+ *
651
+ * 只写 cropW/cropH 时默认从左上角裁;一个字段都不给 = 整图(返回 null)。
652
+ * 裁剪后的 SVG viewBox 会平移回原图像素系,所以节点位置仍与原图对齐。
653
+ */
654
+ function traceCropRect(op, where) {
655
+ const given = ['cropX', 'cropY', 'cropW', 'cropH'].filter((key) => has(op, key))
656
+ if (given.length === 0) return null
657
+ const box = {
658
+ x: Math.round(numberOr(op.cropX, 0)),
659
+ y: Math.round(numberOr(op.cropY, 0)),
660
+ w: Math.round(numberOr(op.cropW, 0)),
661
+ h: Math.round(numberOr(op.cropH, 0)),
662
+ }
663
+ if (box.w <= 0 || box.h <= 0) {
664
+ throw new Error(
665
+ where + ': 裁剪区域要给出正数 cropW / cropH(当前 ' + box.w + '×' + box.h +
666
+ ');cropX / cropY 缺省为 0(左上角)。只给 cropX/cropY 不是裁剪,给全 cropX/cropY/cropW/cropH。',
667
+ )
668
+ }
669
+ if (box.x < 0 || box.y < 0) {
670
+ throw new Error(where + ': cropX / cropY 不能是负数(当前 ' + box.x + ',' + box.y + ')')
671
+ }
672
+ return box
673
+ }
674
+
675
+ /** 裁出 raster 的 [x, x+w) × [y, y+h) 一块(RGBA8,逐行拷)。越界会报错,不静默补白。 */
676
+ function cropRaster(raster, box, where) {
677
+ if (box.x + box.w > raster.width || box.y + box.h > raster.height) {
678
+ throw new Error(
679
+ where + ': 裁剪区域超出图片范围(图片 ' + raster.width + '×' + raster.height +
680
+ ',要裁 x=' + box.x + ' y=' + box.y + ' ' + box.w + '×' + box.h + ')',
681
+ )
682
+ }
683
+ const out = new Uint8Array(box.w * box.h * 4)
684
+ for (let row = 0; row < box.h; row += 1) {
685
+ const from = ((box.y + row) * raster.width + box.x) * 4
686
+ out.set(raster.data.subarray(from, from + box.w * 4), row * box.w * 4)
687
+ }
688
+ return { width: box.w, height: box.h, data: out }
689
+ }
690
+
628
691
  /**
629
692
  * 施加 ops。语义校验在这里:任何指向不存在节点的边、任何未知 id,都直接抛错并列出已知节点,
630
693
  * 于是失败发生在写盘之前 —— 不会画出半张烂图。
@@ -762,6 +825,7 @@ async function applyOps(doc, ops, out, opctx) {
762
825
  //
763
826
  // 这一步要读像素(I/O),所以整条 applyOps 是 async 的 —— 别把它当成同步 op 来加。
764
827
  const where = 'ops[' + i + '] traceImage'
828
+ const traceCrop = traceCropRect(op, where)
765
829
  if (opctx === undefined || opctx === null) throw new Error(where + ': 这个上下文不支持读图片(内部错误)')
766
830
  const rawImage = typeof op.image === 'string' && op.image.length > 0 ? op.image : undefined
767
831
  if (rawImage === undefined) {
@@ -774,26 +838,65 @@ async function applyOps(doc, ops, out, opctx) {
774
838
  } catch (error) {
775
839
  throw new Error(where + ': ' + messageOf(error))
776
840
  }
777
- const traced = traceRasterToSvg(raster, {
778
- colors: numberOr(op.colors, TRACE_DEFAULTS.colors),
779
- grid: numberOr(op.grid, TRACE_DEFAULTS.grid),
780
- scale: numberOr(op.scale, TRACE_DEFAULTS.scale),
781
- epsilon: numberOr(op.epsilon, TRACE_DEFAULTS.epsilon),
782
- precision: numberOr(op.precision, TRACE_DEFAULTS.precision),
783
- minArea: numberOr(op.minArea, TRACE_DEFAULTS.minArea),
784
- bgBright: numberOr(op.bgBright, TRACE_DEFAULTS.bgBright),
785
- bgTol: numberOr(op.bgTol, TRACE_DEFAULTS.bgTol),
786
- seeds: numberOr(op.seeds, TRACE_DEFAULTS.seeds),
787
- maxBytes: Math.min(numberOr(op.maxBytes, TRACE_DEFAULTS.maxBytes), SVG_MARKUP_LIMIT - 2000),
788
- })
841
+ // 裁剪:只把这一块交给矢量化,于是这块区域能占满整个工作分辨率(见 traceCropRect 的说明)。
842
+ let tracedFrom = raster
843
+ if (traceCrop !== null) {
844
+ try {
845
+ tracedFrom = cropRaster(raster, traceCrop, where)
846
+ } catch (error) {
847
+ throw new Error(where + ': ' + messageOf(error))
848
+ }
849
+ }
850
+ // 显式字段覆盖预设:没写的键用 undefined,让 resolveTraceOptions / 默认值接手
851
+ // 没给 maxBytes 时按上限走:细则由这个预算决定(预算越小,管线越要抬 epsilon、丢小环)
852
+ const rawTraceBudget = has(op, 'maxBytes')
853
+ ? numberOr(op.maxBytes, TRACE_DEFAULTS.maxBytes)
854
+ : SVG_MARKUP_LIMIT - 2000
855
+ const rawTrace = {
856
+ preset: typeof op.preset === 'string' ? op.preset : TRACE_DEFAULTS.preset,
857
+ colors: has(op, 'colors') ? op.colors : undefined,
858
+ colorsDark: has(op, 'colorsDark') ? op.colorsDark : undefined,
859
+ colorsFill: has(op, 'colorsFill') ? op.colorsFill : undefined,
860
+ grid: has(op, 'grid') ? op.grid : undefined,
861
+ gridH: has(op, 'gridH') ? op.gridH : undefined,
862
+ scale: has(op, 'scale') ? op.scale : undefined,
863
+ epsilon: has(op, 'epsilon') ? op.epsilon : undefined,
864
+ precision: has(op, 'precision') ? op.precision : undefined,
865
+ minArea: has(op, 'minArea') ? op.minArea : undefined,
866
+ minRegionArea: has(op, 'minRegionArea') ? op.minRegionArea : undefined,
867
+ protectLuma: has(op, 'protectLuma') ? op.protectLuma : undefined,
868
+ majorityContrast: has(op, 'majorityContrast') ? op.majorityContrast : undefined,
869
+ bgBright: has(op, 'bgBright') ? op.bgBright : undefined,
870
+ bgTol: has(op, 'bgTol') ? op.bgTol : undefined,
871
+ bgColor: has(op, 'bgColor') ? op.bgColor : undefined,
872
+ seeds: has(op, 'seeds') ? op.seeds : undefined,
873
+ maxBytes: Math.min(rawTraceBudget, SVG_MARKUP_LIMIT - 2000),
874
+ }
875
+ // 清掉 undefined,避免 Object.assign 把预设盖成 undefined
876
+ for (const key of Object.keys(rawTrace)) {
877
+ if (rawTrace[key] === undefined) delete rawTrace[key]
878
+ }
879
+ let resolved
880
+ try {
881
+ resolved = resolveTraceOptions(rawTrace)
882
+ } catch (error) {
883
+ throw new Error(where + ': ' + messageOf(error))
884
+ }
885
+ const traced = traceRasterToSvg(tracedFrom, resolved.options)
789
886
  const metrics = traced.metrics
790
887
  if (traced.svg.length > SVG_MARKUP_LIMIT) {
791
888
  throw new Error(
792
889
  where + ': 描出来的 SVG 太长(' + traced.svg.length + ' 字符,上限 ' + SVG_MARKUP_LIMIT +
793
- ')。调小 grid(当前 ' + metrics.work.width + ')、调大 epsilon(当前 ' + op.epsilon + ')、或减少 colors。',
890
+ ')。调小 grid(当前 ' + metrics.work.width + ')、调大 epsilon、减少 colors,或 preset:"simple"。' +
891
+ (metrics.tuneHint ? ' 建议:' + metrics.tuneHint : ''),
794
892
  )
795
893
  }
796
- // 尺寸:默认 1:1(画布坐标 = 原图像素坐标),w/h 显式给了就用给的
894
+ // 尺寸:默认 1:1(画布坐标 = 原图像素坐标),w/h 显式给了就用给的。
895
+ //
896
+ // 裁剪块的 SVG 里路径坐标是**裁剪局部**的(viewBox = 0 0 cropW cropH)—— 别去平移 viewBox:
897
+ // 路径不会跟着走,内容会整体跑出可视区(实测渲染出空白)。要让它落在原图对应的位置上,
898
+ // 靠的是节点框的**比例**:节点 w/h ≈ 原图尺寸 × (裁剪块尺寸 / 该块缩放),
899
+ // 配合 imageAspect=0 拉伸铺满,局部坐标系就正好铺到原图那一块。
797
900
  const w = numberOr(op.w, raster.width)
798
901
  const h = numberOr(op.h, raster.height)
799
902
  const synthetic = {
@@ -801,7 +904,7 @@ async function applyOps(doc, ops, out, opctx) {
801
904
  svg: traced.svg,
802
905
  w: w,
803
906
  h: h,
804
- keys: { imageAspect: 0 }, // 拉伸铺满:viewBox 就是原图像素系,节点框按它 1:1 画
907
+ keys: { imageAspect: 0 }, // 拉伸铺满:局部 viewBox 按节点框等比铺开
805
908
  }
806
909
  if (typeof op.id === 'string') synthetic.id = op.id
807
910
  if (typeof op.as === 'string') synthetic.as = op.as
@@ -809,15 +912,33 @@ async function applyOps(doc, ops, out, opctx) {
809
912
  if (typeof op.label === 'string') synthetic.label = op.label
810
913
  if (has(op, 'x')) synthetic.x = op.x
811
914
  if (has(op, 'y')) synthetic.y = op.y
915
+ // 裁剪块没给坐标时,落在原图里它该在的位置(否则会堆到原点压住整图)
916
+ if (traceCrop !== null && has(op, 'x') === false) synthetic.x = traceCrop.x
917
+ if (traceCrop !== null && has(op, 'y') === false) synthetic.y = traceCrop.y
812
918
  // 就地插一条 addNode 紧随其后(读像素是 async 的,所以这条 op 不能在解析参数时先展开)。
813
919
  // 注意:这里改的必须是**我们自己那份** ops —— 调用方(execute)已经 slice 过,
814
920
  // 因为宿主给的 arguments 是深冻结的,直接 splice 会抛 "object is not extensible"。
815
921
  ops.splice(i + 1, 0, synthetic)
922
+ // 节点 id:显式 id / as 别名会在下一条 addNode 里落地;这里先记 as 或 id 方便回执
923
+ const nodeRef = typeof op.id === 'string' ? op.id : typeof op.as === 'string' ? op.as : undefined
924
+ if (traceCrop !== null) metrics.crop = { x: traceCrop.x, y: traceCrop.y, w: traceCrop.w, h: traceCrop.h }
925
+ if (out !== undefined && out !== null) {
926
+ if (!Array.isArray(out.trace)) out.trace = []
927
+ out.trace.push(summarizeTraceForAi(metrics, { image: rawImage, nodeId: nodeRef }))
928
+ }
816
929
  notes.push(
817
- '🖼 ' + rawImage + ' → 逐像素矢量化:' + metrics.palette.length + ' 色 / ' + metrics.paths + ' 条 path / ' +
930
+ '🖼 ' + rawImage +
931
+ (traceCrop === null ? '' : ' 裁剪 ' + traceCrop.w + '×' + traceCrop.h + '@' + traceCrop.x + ',' + traceCrop.y) +
932
+ ' → 逐像素矢量化 preset=' + metrics.preset + ':' +
933
+ metrics.palette.length + ' 色 / ' + metrics.paths + ' 条 path / ' +
818
934
  metrics.loopsKept + ' 个轮廓环 / ' + metrics.svgChars + ' 字符' +
819
- '(工作图 ' + metrics.work.width + '×' + metrics.work.height + ',像素覆盖' +
820
- (metrics.exactPixelCoverage === true ? '精确' : '不精确 ' + metrics.drawnPixels + '/' + metrics.totalPixels) + ')',
935
+ '(工作图 ' + metrics.work.width + '×' + metrics.work.height +
936
+ ', scale ' + metrics.scaleApplied +
937
+ (metrics.sizeNotes ? '; ' + metrics.sizeNotes : '') +
938
+ ',像素覆盖' +
939
+ (metrics.exactPixelCoverage === true ? '精确' : '不精确 ' + metrics.drawnPixels + '/' + metrics.totalPixels) +
940
+ ')' +
941
+ (metrics.tuneHint ? '\n 调参:' + metrics.tuneHint : ''),
821
942
  )
822
943
  continue
823
944
  }
@@ -1848,7 +1969,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1848
1969
  {op:"setLayer", id|ids, layer}
1849
1970
  {op:"setLayerProps", layer, name?, visible?, locked?}
1850
1971
  {op:"export", format?:"svg"|"png", name?}
1851
- {op:"traceImage", image, w?, h?, x?, y?, colors?, grid?, scale?, epsilon?, minArea?, as?, layer?}
1972
+ {op:"traceImage", image, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, cropX?, cropY?, cropW?, cropH?, as?, layer?}
1852
1973
  {op:"move", id|ids, dx?, dy?, x?, y?}
1853
1974
  {op:"remove", id|ids}
1854
1975
  {op:"highlight", ids:["n1","n2"]}
@@ -1874,17 +1995,25 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1874
1995
  pending → done(带文件路径)/ downloaded / failed。看到 pending 说明画布还没刷新 ——
1875
1996
  别反复请求,等下一次 read 再看(或提醒用户打开那张画布)。
1876
1997
  - **把一张位图"描"成矢量图用 traceImage**:{op:"traceImage", image:"ref.png", x:40, y:40}。
1877
- 它**真的读那张图的像素**(宿主里跑的管线:四边泛洪去背景 → k-means 定调色板 →
1878
- 3× 超采样归类 + 众数滤波 → 按颜色分连通域、沿像素格边取闭合轮廓 → RDP 简化 →
1879
- 每色一条 nonzero path,按面积降序铺),建成一个 svg 内容节点。
1998
+ 它**真的读那张图的像素**,建成一个 svg 内容节点。
1880
1999
  **不要用"手写一段近似的 svg"冒充复刻** —— 形状词汇表表达不了的照片/插画就直接用这个 op。
1881
- · 默认 **w/h = 原图像素尺寸**、viewBox = 原图像素坐标系,所以画布坐标就是图片坐标(1:1 铺满)。
1882
- · 要更简洁:colors 调小(默认 12)、scale:2 或 3(先降采样)、grid 调小(工作宽度)。
1883
- 要更保真:epsilon 调小(默认 2px)、minArea 调小(默认 10px²)。
1884
- · 输出会告诉你要点:色数 / 路径数 / 轮廓环数 / SVG 字符数 / **像素覆盖是否精确**
1885
- (精确 = 每色轮廓面积之和等于图像总像素,没有像素丢失或重复)。
1886
- · 单次上限:SVG 标记 60000 字符(超了会自己收紧 epsilon 与碎块阈值,仍超就报错让你调参)。
1887
- · 输入支持 PNG(自带解码器,零依赖)与 JPEG/GIF/WebP(需要宿主装了 sharp)。
2000
+ · 默认 **w/h = 原图像素尺寸**、viewBox = 原图像素坐标系(1:1 铺满)。
2001
+ · **先选 preset,再按需覆盖单字段**(合并:默认 ← preset ← 显式字段):
2002
+ - preset:"lineart" 插画/线稿(多暗色配额、严保细线)
2003
+ - preset:"photo" 照片/渐变
2004
+ - preset:"simple" 图标/简单图(少色+降采样,省字符)
2005
+ - 不写 = "default"
2006
+ · 调参闭环:看返回值里的 **trace[]**(结构化指标 + hint),不满意就 **remove 旧节点再 traceImage**(改 preset/字段)。
2007
+ 常见:字符紧 → scale:2 / 加大 epsilon / preset:"simple";线糊断 → preset:"lineart" 或提高 protectLuma/colorsDark。
2008
+ · 单次上限:SVG 标记 250000 字符(整图的精细度主要由它决定:预算紧时管线会抬 epsilon、丢小环,
2009
+ 把细节压掉——想要更细也可以给 maxBytes 顶到上限,或干脆用下面的裁剪只描局部);
2010
+ 输入 PNG(零依赖)与 JPEG/GIF/WebP(需 sharp)。
2011
+ · **五官/小物件糊成一团时,别只调参 —— 用 cropX/cropY/cropW/cropH 裁出那一块单独描**:
2012
+ 工作分辨率上限是 grid(默认 499px 宽),整张立绘塞进去时"脸"只占三四十像素,眼睛只有 3~4px,
2013
+ 量化后五官必然消失(实测把 colors 拉满 24、protectLuma 提到 235 也救不回来)。
2014
+ 裁出头部再描,同一块就占满工作图宽度 —— 脸能有一两百像素,五官才留得住。
2015
+ 裁剪块的坐标在**裁剪局部系**里,所以要按比例给它 w/h(= 整张图的 w/h × 裁剪块宽高 / 整块原尺寸),
2016
+ x/y 不写时落在原图对应位置,可与整图那块叠着放(顺序在后 = 画在上面)。
1888
2017
 
1889
2018
  - **改一组用 ids**:{op:"setStyle", ids:["n1","n2","n5"], style:"green"} —— setStyle / setLabel /
1890
2019
  move / remove 都收 ids,不要为了"把这几个改成绿色"发 12 个 op。批量 move 只收 dx/dy
@@ -1945,7 +2074,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
1945
2074
  (那就把 w/h 设成图形的比例,否则四周会留白)。
1946
2075
  · svg 节点**可以没有 label**(画图不带字);要图下面带文字就照常给 label,它画在图上。
1947
2076
  · 颜色写在 SVG 自己身上(fill/stroke)—— 配色参数对它是无效的(图片不受 fillColor/strokeColor 影响)。
1948
- · 内容上限 60000 字符;**别放位图/base64**(那是图片不是图形,本画布画不出来)。
2077
+ · 内容上限 250000 字符;**别放位图/base64**(那是图片不是图形,本画布画不出来)。
1949
2078
  · 改内容只能整段替换(没有"改某一根线"的 op):先 read 拿回当前 svg,改完整段写回。
1950
2079
  - style:调色板名 plain|blue|green|orange|yellow|red|purple|grey,或直接给一段 style 串
1951
2080
  - dash:solid|dashed|dotted;arrow:end(单向)|both(双向)|none(无)|start(反向)
@@ -2768,7 +2897,7 @@ export function apply(ctx) {
2768
2897
  '{op:"setEdge", id, from?, to?, fromPoint?, toPoint?} / {op:"order", id|ids, to:"front"|"back"|"up"|"down"} / ' +
2769
2898
  '{op:"duplicate", id|ids, dx?, dy?, withEdges?, layer?, as?} / {op:"addLayer", name?, as?} / {op:"setLayer", id|ids, layer} / {op:"setLayerProps", layer, name?, visible?, locked?} / ' +
2770
2899
  '{op:"export", format?:"svg"|"png", name?} / ' +
2771
- '{op:"traceImage", image, w?, h?, x?, y?, colors?, grid?, scale?, epsilon?, minArea?, as?, layer?} / ' +
2900
+ '{op:"traceImage", image, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, as?, layer?} / ' +
2772
2901
  '{op:"move", id|ids, dx?, dy?, x?, y?} / {op:"remove", id|ids} / {op:"highlight", ids:[...]};节点 id 省略时自动分配。' +
2773
2902
  '**改接一条边**用 setEdge(换 from/to 或某一端改成自由点)—— id、标签、标签位置、折点、端点约束全都留着;' +
2774
2903
  '不要 remove + addEdge 重画(那会把这些丢掉)。' +
@@ -2779,12 +2908,14 @@ export function apply(ctx) {
2779
2908
  '**图层**用 addLayer/setLayer/setLayerProps(加层、把单元移到别的层、改名/显示/隐藏/锁定;隐藏≠删除)。' +
2780
2909
  '**导出**用 export:format:"svg" 会让画布渲染一张 SVG 落在 .drawio 旁边、format:"png" 是浏览器下载;' +
2781
2910
  '这一步由画布执行,返回值里的 export.status 是 pending/done/downloaded/failed(pending = 画布还没刷新)。' +
2782
- '**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", x, y} 会真的去读那张图的像素' +
2783
- '(泛洪去背景 → k-means 定调色板 → 3× 超采样归类 → 按颜色取像素级闭合轮廓 → 每色一条 nonzero path),' +
2784
- '建成一个 svg 内容节点,**不是让模型凭印象重画**。默认 w/h = 原图像素尺寸、viewBox = 原图像素坐标系(1:1)。' +
2785
- '可调:colors(默认 12,少=更简洁)、grid(工作宽度,调小=更粗更快)、scale(先降采样几倍)、' +
2786
- 'epsilon(轮廓简化容差 px,默认 2,调大=更省字符)、minArea(小于这个面积的碎块并进最相似邻色)。' +
2787
- '返回的 summary 里带"色数/路径数/轮廓环/像素覆盖是否精确",据此判断质量。' +
2911
+ '**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", x, y} 会真的读像素建成 svg 节点,' +
2912
+ '**不是凭印象重画**。默认 w/h = 原图像素尺寸(1:1)。' +
2913
+ '**调参**:先选 preset(' + TRACE_PRESET_NAMES.join('|') + ';lineart=插画线稿、photo=照片、simple=省字符、default=默认),' +
2914
+ '再按需覆盖 colors/colorsDark/scale/epsilon/grid/gridH/minArea/minRegionArea/protectLuma/majorityContrast/bgColor 等。' +
2915
+ '合并顺序:默认 ← preset ← 显式字段。' +
2916
+ '返回值里的 **trace[]** 是结构化指标(svgChars/protectPixels/options/hint 等);' +
2917
+ '质量不够就根据 hint remove 旧节点再 trace(改 preset 或字段)。' +
2918
+ '字符紧→scale:2/加大epsilon/preset:simple;线糊断→preset:lineart 或提高 protectLuma/colorsDark。' +
2788
2919
  '**一次改一组**就写 ids:[...](setStyle/setLabel/move/remove 都收,move 的批量只收 dx/dy 相对位移)—— 用户说"把这几个换成绿色"时不要逐个发 op。' +
2789
2920
  '**引用刚建的那个**用 as:"名字":{op:"addNode", label:"开始", as:"start"} 之后同一个 ops 数组里 from/to/id 可以直接写 "start",不必猜自动分配的 n7。' +
2790
2921
  '**画图形(图标/logo/示意图)用 svg**:addNode {shape:"svg", svg:"<svg viewBox=…>…</svg>", w, h} 让节点的内容就是那段 SVG 标记' +
@@ -2876,6 +3007,64 @@ export function apply(ctx) {
2876
3007
  error: { type: 'string' },
2877
3008
  },
2878
3009
  },
3010
+ // 本次调用里每次 traceImage 一条:给模型读指标再调参(additionalProperties:false 必须声明)
3011
+ trace: {
3012
+ type: 'array',
3013
+ items: {
3014
+ type: 'object',
3015
+ additionalProperties: false,
3016
+ properties: {
3017
+ preset: { type: 'string' },
3018
+ image: { type: 'string' },
3019
+ nodeId: { type: 'string' },
3020
+ // 只描了原图的一块时才有:那块在原图里的位置与尺寸(像素)
3021
+ crop: {
3022
+ type: 'object',
3023
+ additionalProperties: false,
3024
+ properties: {
3025
+ x: { type: 'number' },
3026
+ y: { type: 'number' },
3027
+ w: { type: 'number' },
3028
+ h: { type: 'number' },
3029
+ },
3030
+ },
3031
+ source: {
3032
+ type: 'object',
3033
+ additionalProperties: false,
3034
+ properties: {
3035
+ width: { type: 'number' },
3036
+ height: { type: 'number' },
3037
+ },
3038
+ },
3039
+ work: {
3040
+ type: 'object',
3041
+ additionalProperties: false,
3042
+ properties: {
3043
+ width: { type: 'number' },
3044
+ height: { type: 'number' },
3045
+ scale: { type: 'number' },
3046
+ },
3047
+ },
3048
+ scaleApplied: { type: 'number' },
3049
+ sizeNotes: { type: 'string' },
3050
+ colors: { type: 'number' },
3051
+ colorsDark: { type: 'number' },
3052
+ colorsFill: { type: 'number' },
3053
+ protectPixels: { type: 'number' },
3054
+ majoritySkipped: { type: 'number' },
3055
+ paths: { type: 'number' },
3056
+ loopsKept: { type: 'number' },
3057
+ loopsProtectedKept: { type: 'number' },
3058
+ regionsProtected: { type: 'number' },
3059
+ svgChars: { type: 'number' },
3060
+ budget: { type: 'number' },
3061
+ attempts: { type: 'number' },
3062
+ exactPixelCoverage: { type: 'boolean' },
3063
+ options: { type: 'json' },
3064
+ hint: { type: 'string' },
3065
+ },
3066
+ },
3067
+ },
2879
3068
  },
2880
3069
  },
2881
3070
  render: function (args, value) {
@@ -2886,6 +3075,24 @@ export function apply(ctx) {
2886
3075
  }
2887
3076
  if (Array.isArray(value.changed) && value.changed.length > 0) bits.push('改动 ' + value.changed.join(', '))
2888
3077
  if (Array.isArray(value.removed) && value.removed.length > 0) bits.push('删除 ' + value.removed.join(', '))
3078
+ if (Array.isArray(value.trace) && value.trace.length > 0) {
3079
+ bits.push(
3080
+ '矢量化 ' +
3081
+ value.trace
3082
+ .map(function (t) {
3083
+ return (
3084
+ (t.image || '?') +
3085
+ ' preset=' +
3086
+ (t.preset || 'default') +
3087
+ ' ' +
3088
+ t.svgChars +
3089
+ '字' +
3090
+ (t.hint ? ' | ' + t.hint : '')
3091
+ )
3092
+ })
3093
+ .join(';'),
3094
+ )
3095
+ }
2889
3096
  return [
2890
3097
  {
2891
3098
  type: 'text',
@@ -3001,6 +3208,7 @@ export function apply(ctx) {
3001
3208
  created: [],
3002
3209
  changed: [],
3003
3210
  removed: [],
3211
+ ...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
3004
3212
  ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
3005
3213
  summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
3006
3214
  }
@@ -3052,6 +3260,7 @@ export function apply(ctx) {
3052
3260
  created: Array.isArray(out.created) ? out.created : [],
3053
3261
  changed: Array.isArray(out.changed) ? out.changed : [],
3054
3262
  removed: Array.isArray(out.removed) ? out.removed : [],
3263
+ ...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
3055
3264
  ...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
3056
3265
  summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
3057
3266
  }
package/lib/raster.js CHANGED
@@ -13,6 +13,8 @@
13
13
  */
14
14
 
15
15
  import { inflateSync } from 'node:zlib'
16
+ import { createRequire } from 'node:module'
17
+ import { pathToFileURL } from 'node:url'
16
18
 
17
19
  /** PNG 的 8 字节签名。 */
18
20
  const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
@@ -267,24 +269,70 @@ export function looksLikeRaster(bytes) {
267
269
  return false
268
270
  }
269
271
 
272
+ /**
273
+ * sharp 可能装在哪:包名 + DSH profile 里那份(本机是这么装的)。
274
+ *
275
+ * 为什么本机路径写死:sharp 是可选依赖,没装它的部署不该因为我们 import 不到就崩;
276
+ * 而宿主进程的 cwd 是**用户的工作区**、不在插件的 node_modules 链上,所以裸包名
277
+ * 在这种部署里注定解析不到(package.json 里 sharp 也不是依赖)。
278
+ */
279
+ const SHARP_CANDIDATES = [
280
+ 'sharp',
281
+ 'C:/Users/86476/AppData/Roaming/npm/node_modules/@deepseek-ai/dsh/node_modules/sharp',
282
+ ]
283
+
284
+ /**
285
+ * 把候选解成**可 import 的入口文件 URL**。
286
+ *
287
+ * 这里踩过两个坑,都只能在真机上才发现:
288
+ * · 动态 import 的说明符必须是 URL 或裸包名,**给文件系统路径会抛
289
+ * ERR_UNSUPPORTED_ESM_URL_SCHEME**(`C:/…/sharp` 和 `C:/…/sharp/dist/index.cjs` 都是);
290
+ * · 给**目录**还会抛 ERR_UNSUPPORTED_DIR_IMPORT —— ESM 不认目录,哪怕那个目录里有 package.json
291
+ * (sharp 0.35 是 CJS,main 指向 ./dist/index.cjs)。
292
+ * 所以:先用 createRequire 的 require.resolve 解出**入口文件**,再用 pathToFileURL 转成
293
+ * file:// URL。require.resolve 只用来"定位"、不用来加载任何东西;也正因为它认 package.json 的
294
+ * exports/main,这条不依赖 sharp 内部目录布局(比写死 /dist/index.cjs 耐升级)。
295
+ */
296
+ function resolveSharpImport() {
297
+ const urls = []
298
+ const req = createRequire(import.meta.url)
299
+ const add = (candidate) => {
300
+ try {
301
+ const entry = req.resolve(candidate)
302
+ const url = pathToFileURL(entry).href
303
+ if (urls.indexOf(url) < 0) urls.push(url)
304
+ } catch (error) {
305
+ /* 这个候选不在(没装 / 解析不到),换下一个 */
306
+ }
307
+ }
308
+ for (const candidate of SHARP_CANDIDATES) add(candidate)
309
+ // 兜底:require.resolve 那条也可能失败(例如导出图把它藏了),此时按目录试常见入口。
310
+ // pathToFileURL 会把目录路径照常编成 file:// URL,import 再按 package.json 的 main 解析。
311
+ for (const candidate of SHARP_CANDIDATES) {
312
+ if (candidate === 'sharp') continue
313
+ add(candidate)
314
+ add(candidate + '/dist/index.cjs')
315
+ }
316
+ // 去重:兜底可能补出与前一条相同的 URL(entry 与 /dist/index.cjs 同址时)
317
+ const unique = urls.filter((value, index) => urls.indexOf(value) === index)
318
+ return unique
319
+ }
320
+
270
321
  /**
271
322
  * 非 PNG 的解码回退:**可选** sharp(宿主没装就返回 null,由调用方给一句人话报错)。
272
323
  *
273
324
  * 用动态 import 而不是顶层 import:没装 sharp 的部署不该连模块都加载不了。
274
- * 路径依次试:包名、DSH profile 里的那份(本机是这么装的)。
275
325
  */
276
326
  async function decodeWithSharp(bytes) {
277
- const candidates = [
278
- 'sharp',
279
- 'C:/Users/86476/AppData/Roaming/npm/node_modules/@deepseek-ai/dsh/node_modules/sharp',
280
- ]
281
- for (const spec of candidates) {
327
+ for (const spec of resolveSharpImport()) {
282
328
  let sharp
283
329
  try {
284
- sharp = (await import(spec)).default
330
+ const mod = await import(spec)
331
+ sharp = mod.default !== undefined && mod.default !== null ? mod.default : mod
285
332
  } catch (error) {
286
333
  continue
287
334
  }
335
+ if (typeof sharp !== 'function') continue
288
336
  try {
289
337
  const got = await sharp(Buffer.from(bytes)).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
290
338
  return { width: got.info.width, height: got.info.height, data: got.data }
@@ -5,11 +5,12 @@
5
5
  * 这里只有**像素进、SVG 出**,不碰文件系统、不碰 ctx:宿主工具与命令行脚本共用同一份。
6
6
  *
7
7
  * 管线(每一步都能在返回的 metrics 里看到数字):
8
- * ① 四边泛洪判背景(近白 / 近灰 / 全透明)→ 不画截图底色
9
- * ② k-means 定调色板(K 个前景色 + 背景单独占第 0 号)
10
- * ③ 3× 超采样逐样本归类 → 3×3 众数滤波 → 降回原分辨率 → <minArea 的碎块并入邻色
11
- * ④ 每色按连通域取闭合轮廓:像素**格边**上的有向图 + 左转跟随(区域恒在左侧)
12
- * ⑤ RDP 简化 + 相对增量编码 → 每色一条 <path fill-rule="nonzero">
8
+ * ① 工作图尺寸 = src/scale,再套 grid(宽)/ gridH(高)上限;透明像素合成到 bgColor
9
+ * ② 四边泛洪判背景(近白 / 近灰 / 全透明)→ 不画截图底色
10
+ * ③ 分层 k-means(colorsDark 暗色 + colorsFill 填充)定调色板;暗像素进保护区
11
+ * ④ 3× 超采样归类 → 条件众数(高对比跳过)→ despeckle / minRegionArea(保护区不并)
12
+ * ⑤ 每色按连通域取闭合轮廓:像素**格边**上的有向图 + 左转跟随(区域恒在左侧)
13
+ * ⑥ RDP + 预算阶梯(受保护环不因 minLoopArea 丢弃)→ 每色一条 <path fill-rule="nonzero">
13
14
  *
14
15
  * 为什么是"格边跟随"而不是 marching squares:标签图在鞍点(对角相接)处 marching squares
15
16
  * 要人为定连通性,而格边有向图本身就无歧义 —— 每条边只属于一个区域,走完一圈恰好回到起点。
@@ -21,6 +22,11 @@ function clamp(v, lo, hi) {
21
22
  return v < lo ? lo : v > hi ? hi : v
22
23
  }
23
24
 
25
+ /** 相对亮度(0..255),用于线稿/保护区与众数对比。 */
26
+ function lumaRgb(r, g, b) {
27
+ return 0.299 * r + 0.587 * g + 0.114 * b
28
+ }
29
+
24
30
  /* ────────────────────────── ① 背景 ────────────────────────── */
25
31
 
26
32
  /**
@@ -305,12 +311,21 @@ function traceLoops(comp, width, height, regionCount) {
305
311
  * · 并进**颜色最接近**的邻色,而不是"面积最多的"—— 边缘的过渡色应该并回它看起来最像的那一侧;
306
312
  * · **不连锁**:一轮里对固定快照判一次,避免小块并进小块滚雪球。
307
313
  */
308
- function despeckle(labels, width, height, palette, minArea) {
314
+ /**
315
+ * 按颜色分块合并碎块。
316
+ * @param {Uint8Array|null} protectMask 非空时:块内受保护像素过半,或该 label 本身是保护色 → 不合并
317
+ * @param {Uint8Array|null} protectLabels 长度为 palette.length;1 = 该色永不被合并
318
+ */
319
+ function despeckle(labels, width, height, palette, minArea, protectMask, protectLabels) {
320
+ if (!(minArea > 0)) {
321
+ return { mergedPixels: 0, blocksMerged: 0, blocks: 0, protectedSkipped: 0 }
322
+ }
309
323
  const colorCount = palette.length
310
324
  const comp = new Int32Array(width * height).fill(-1)
311
325
  const stack = []
312
326
  let mergedPixels = 0
313
327
  let blocksMerged = 0
328
+ let protectedSkipped = 0
314
329
 
315
330
  // 先把每块 4 连通区域找出来(一次,不随合并改变)
316
331
  const blocks = []
@@ -353,6 +368,18 @@ function despeckle(labels, width, height, palette, minArea) {
353
368
  if (block.pixels.length >= minArea) continue
354
369
  // 背景(label 0)不参与:它是"没画东西的地方",并它等于把图啃出洞
355
370
  if (palette[block.label] === undefined) continue
371
+ if (protectLabels !== null && protectLabels !== undefined && protectLabels[block.label] === 1) {
372
+ protectedSkipped += 1
373
+ continue
374
+ }
375
+ if (protectMask !== null && protectMask !== undefined) {
376
+ let prot = 0
377
+ for (let k = 0; k < block.pixels.length; k += 1) if (protectMask[block.pixels[k]] === 1) prot += 1
378
+ if (prot * 2 >= block.pixels.length) {
379
+ protectedSkipped += 1
380
+ continue
381
+ }
382
+ }
356
383
  hist.fill(0)
357
384
  for (let k = 0; k < block.pixels.length; k += 1) {
358
385
  const p = block.pixels[k]
@@ -392,7 +419,109 @@ function despeckle(labels, width, height, palette, minArea) {
392
419
  mergedPixels += pixels.length
393
420
  blocksMerged += 1
394
421
  }
395
- return { mergedPixels: mergedPixels, blocksMerged: blocksMerged, blocks: blocks.length }
422
+ return {
423
+ mergedPixels: mergedPixels,
424
+ blocksMerged: blocksMerged,
425
+ blocks: blocks.length,
426
+ protectedSkipped: protectedSkipped,
427
+ }
428
+ }
429
+
430
+ /**
431
+ * 分层调色板:暗像素单独 k-means(colorsDark),其余走 colorsFill。
432
+ * colorsDark=0 时退回单次 k-means(与旧行为一致)。
433
+ */
434
+ function buildPalette(work, fgIndex, colors, colorsDark, colorsFill, protectLuma, seeds) {
435
+ const darkK = Math.max(0, Math.min(colors - 1, colorsDark))
436
+ const fillK = Math.max(1, colors - darkK)
437
+ // 显式 colorsFill 时以它为准(仍保证总和 = colors)
438
+ const useFill = colorsFill > 0 ? Math.max(1, Math.min(colors, colorsFill)) : fillK
439
+ const useDark = colorsFill > 0 ? Math.max(0, colors - useFill) : darkK
440
+
441
+ if (useDark === 0 || !(protectLuma > 0)) {
442
+ const samples = new Float64Array(fgIndex.length * 3)
443
+ for (let i = 0; i < fgIndex.length; i += 1) {
444
+ const o = fgIndex[i] * 4
445
+ samples[i * 3] = work[o]
446
+ samples[i * 3 + 1] = work[o + 1]
447
+ samples[i * 3 + 2] = work[o + 2]
448
+ }
449
+ let best = null
450
+ for (let s = 1; s <= seeds; s += 1) {
451
+ const got = kmeans(samples, colors, 24, s * 2654435761)
452
+ if (best === null || got.inertia < best.inertia) best = got
453
+ }
454
+ return {
455
+ cents: best.cent,
456
+ colorsDark: 0,
457
+ colorsFill: colors,
458
+ darkSamples: 0,
459
+ fillSamples: fgIndex.length,
460
+ }
461
+ }
462
+
463
+ const darkIdx = []
464
+ const fillIdx = []
465
+ for (let i = 0; i < fgIndex.length; i += 1) {
466
+ const p = fgIndex[i]
467
+ const o = p * 4
468
+ if (lumaRgb(work[o], work[o + 1], work[o + 2]) <= protectLuma) darkIdx.push(p)
469
+ else fillIdx.push(p)
470
+ }
471
+
472
+ // 某一侧为空时,把配额并给另一侧,避免空簇
473
+ let kDark = useDark
474
+ let kFill = useFill
475
+ if (darkIdx.length === 0) {
476
+ kFill = colors
477
+ kDark = 0
478
+ } else if (fillIdx.length === 0) {
479
+ kDark = colors
480
+ kFill = 0
481
+ }
482
+
483
+ const cents = []
484
+ function runK(indexes, k, seedBase) {
485
+ if (k <= 0 || indexes.length === 0) return
486
+ const samples = new Float64Array(indexes.length * 3)
487
+ for (let i = 0; i < indexes.length; i += 1) {
488
+ const o = indexes[i] * 4
489
+ samples[i * 3] = work[o]
490
+ samples[i * 3 + 1] = work[o + 1]
491
+ samples[i * 3 + 2] = work[o + 2]
492
+ }
493
+ const kk = Math.min(k, indexes.length)
494
+ let best = null
495
+ for (let s = 1; s <= seeds; s += 1) {
496
+ const got = kmeans(samples, kk, 24, seedBase + s * 2654435761)
497
+ if (best === null || got.inertia < best.inertia) best = got
498
+ }
499
+ for (let c = 0; c < best.cent.length; c += 1) cents.push(best.cent[c])
500
+ }
501
+ runK(darkIdx, kDark, 1)
502
+ runK(fillIdx, kFill, 100)
503
+ // 极端情况下簇数不足:用全体像素补满
504
+ if (cents.length < colors) {
505
+ const samples = new Float64Array(fgIndex.length * 3)
506
+ for (let i = 0; i < fgIndex.length; i += 1) {
507
+ const o = fgIndex[i] * 4
508
+ samples[i * 3] = work[o]
509
+ samples[i * 3 + 1] = work[o + 1]
510
+ samples[i * 3 + 2] = work[o + 2]
511
+ }
512
+ const need = colors - cents.length
513
+ const got = kmeans(samples, Math.min(colors, fgIndex.length), 24, 999 * 2654435761)
514
+ for (let c = 0; c < got.cent.length && cents.length < colors; c += 1) cents.push(got.cent[c])
515
+ void need
516
+ }
517
+
518
+ return {
519
+ cents: cents.slice(0, colors),
520
+ colorsDark: kDark,
521
+ colorsFill: kFill,
522
+ darkSamples: darkIdx.length,
523
+ fillSamples: fillIdx.length,
524
+ }
396
525
  }
397
526
 
398
527
  /* ────────────────────────── ⑤ 简化与编码 ────────────────────────── */
@@ -525,50 +654,249 @@ function encodeLoop(points, precision) {
525
654
 
526
655
  /* ────────────────────────── 门面 ────────────────────────── */
527
656
 
657
+ /** 把 bgColor 规范成 [r,g,b](数组或 #rrggbb;认不出就白)。 */
658
+ function parseBgColor(raw) {
659
+ if (Array.isArray(raw) && raw.length >= 3) {
660
+ return [
661
+ clamp(Math.round(Number(raw[0])), 0, 255),
662
+ clamp(Math.round(Number(raw[1])), 0, 255),
663
+ clamp(Math.round(Number(raw[2])), 0, 255),
664
+ ]
665
+ }
666
+ if (typeof raw === 'string') {
667
+ const m = /^#?([0-9a-fA-F]{6})$/.exec(raw.trim())
668
+ if (m) {
669
+ const n = parseInt(m[1], 16)
670
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
671
+ }
672
+ }
673
+ return [255, 255, 255]
674
+ }
675
+
676
+ /**
677
+ * 工作图尺寸:先按 scale 降,再用 grid(宽)/ gridH(高,0=不限)做上限;超限等比压。
678
+ * @returns {{width:number,height:number,scaleRequested:number,scaleApplied:number,grid:number,gridH:number,sizeNotes:string}}
679
+ */
680
+ function resolveWorkSize(srcWidth, srcHeight, options) {
681
+ const scaleRequested = Number(options.scale)
682
+ const scaleRounded = Math.round(scaleRequested)
683
+ const scaleApplied = scaleRounded >= 1 ? scaleRounded : 1
684
+ const grid = Math.max(16, Math.round(Number(options.grid)))
685
+ const gridHRaw = Number(options.gridH)
686
+ const gridH = gridHRaw > 0 ? Math.max(16, Math.round(gridHRaw)) : 0
687
+
688
+ const notes = []
689
+ if (!(scaleRequested >= 1) || scaleRounded !== scaleApplied) {
690
+ notes.push('scale clamped to ' + scaleApplied + ' (requested ' + scaleRequested + ')')
691
+ }
692
+
693
+ let width = Math.max(1, Math.round(srcWidth / scaleApplied))
694
+ let height = Math.max(1, Math.round(srcHeight / scaleApplied))
695
+ if (scaleApplied > 1) notes.push('scaleApplied ' + scaleApplied)
696
+
697
+ const capH = gridH > 0 ? gridH : Infinity
698
+ if (width > grid || height > capH) {
699
+ const factor = Math.min(grid / width, capH / height)
700
+ const nextW = Math.max(16, Math.round(width * factor))
701
+ const nextH = Math.max(16, Math.round(height * factor))
702
+ const parts = []
703
+ if (width > grid) parts.push('width')
704
+ if (height > capH) parts.push('height')
705
+ notes.push('grid capped ' + parts.join('+') + ' (' + width + '×' + height + ' → ' + nextW + '×' + nextH + ')')
706
+ width = nextW
707
+ height = nextH
708
+ }
709
+
710
+ return {
711
+ width: width,
712
+ height: height,
713
+ scaleRequested: scaleRequested,
714
+ scaleApplied: scaleApplied,
715
+ grid: grid,
716
+ gridH: gridH,
717
+ sizeNotes: notes.join('; '),
718
+ }
719
+ }
720
+
528
721
  /** 矢量化的默认参数(宿主工具与命令行共用)。 */
529
722
  export const TRACE_DEFAULTS = {
530
- colors: 12, // 前景调色板色数(背景另占 1 色)
531
- grid: 499, // 按这个宽度做;高度按原图比例算,够大就原尺寸
532
- scale: 1, // 先按 scale×scale 面积平均降采样,再做管线(=1 表示不降)
723
+ preset: 'default', // 命名预设,见 TRACE_PRESETS;显式字段覆盖预设
724
+ colors: 12, // 前景调色板色数(背景另占 1 色)= colorsDark + colorsFill(auto)
725
+ colorsDark: 3, // 暗色配额(线稿);0 = 不分层,全体一次 k-means
726
+ colorsFill: 0, // 填充色配额;0 = 自动 colors - colorsDark
727
+ grid: 499, // 工作图宽度上限(px);与 gridH 一起做等比封顶
728
+ gridH: 0, // 工作图高度上限(px);0 = 不限高(只卡宽,兼容旧行为)
729
+ scale: 1, // 真降采样因子:work ≈ src/scale,再套 grid/gridH(=1 表示不按 scale 降)
533
730
  epsilon: 2.0, // RDP 简化容差(像素)
534
731
  precision: 1, // 坐标小数位
535
- minArea: 10, // 小于这个面积(像素²)的碎块并进邻色
732
+ minArea: 10, // despeckle:小于这个面积(像素²)的碎块并进邻色;0 = 关
733
+ minRegionArea: 0, // despeckle 之后再按面积并区域;0 = 关(与 minArea 分开调)
734
+ protectLuma: 110, // 亮度 ≤ 此值的前景像素受保护(不 despeckle / 不 ladder 丢环);0 = 关
735
+ majorityContrast: 40, // 局部亮度差 ≥ 此值则跳过众数滤波(保线稿);0 = 全图滤波(旧行为)
536
736
  bgBright: 238, // 判"近白"的亮度下限
537
737
  bgTol: 10, // 判"近灰"的通道差上限
738
+ bgColor: [255, 255, 255], // 透明/半透明像素合成底色(再进泛洪与 k-means)
538
739
  seeds: 3, // k-means 重启次数(取 inertia 最小)
539
- maxBytes: 50000, // SVG 上限(drawio style 里的 data URI 要塞得下;60000 是硬上限,留余量)
740
+ // SVG 上限(换算成 drawio style 里的 data URI 要塞得下)。宿主的硬上限是 250000 字符,
741
+ // 且它自己会再留 2000 的余量 —— 所以这里直接对齐到 248000:整图的精细度就由这个预算决定
742
+ // (预算越小,管线越要抬 epsilon、丢小环)。命令行想更省字符可显式传 --budget。
743
+ maxBytes: 248000,
540
744
  includeLabels: false, // 额外返回"去碎块后"的标签图(调试/比对用;会额外占 width*height 字节的 base64)
541
745
  }
542
746
 
747
+ /**
748
+ * 命名预设:AI 先选场景,再按需覆盖单个字段。
749
+ * 合并顺序:TRACE_DEFAULTS ← preset ← 显式字段。
750
+ */
751
+ export const TRACE_PRESETS = {
752
+ default: {},
753
+ /** 插画 / 线稿:多留暗色、严保细线、轮廓更细 */
754
+ lineart: {
755
+ colorsDark: 4,
756
+ protectLuma: 130,
757
+ majorityContrast: 32,
758
+ minArea: 4,
759
+ epsilon: 1.5,
760
+ precision: 2,
761
+ },
762
+ /** 照片 / 渐变:少暗色配额、碎块并多一点、轮廓可粗 */
763
+ photo: {
764
+ colors: 14,
765
+ colorsDark: 1,
766
+ protectLuma: 80,
767
+ majorityContrast: 55,
768
+ minArea: 16,
769
+ epsilon: 2.5,
770
+ },
771
+ /** 图标 / 简单截图:少色 + 降采样,压字符数 */
772
+ simple: {
773
+ colors: 6,
774
+ colorsDark: 2,
775
+ scale: 2,
776
+ grid: 320,
777
+ epsilon: 3,
778
+ minArea: 20,
779
+ protectLuma: 100,
780
+ },
781
+ }
782
+
783
+ /** 合法预设名列表(给报错 / 提示用)。 */
784
+ export const TRACE_PRESET_NAMES = Object.keys(TRACE_PRESETS)
785
+
786
+ /**
787
+ * 合并默认值 + 预设 + 显式覆盖。未知 preset 抛错(让 AI 立刻改,而不是静默当 default)。
788
+ * @returns {{options:object, preset:string}}
789
+ */
790
+ export function resolveTraceOptions(rawOptions) {
791
+ const raw = rawOptions === undefined || rawOptions === null ? {} : rawOptions
792
+ const presetName = typeof raw.preset === 'string' && raw.preset.length > 0 ? raw.preset : 'default'
793
+ if (TRACE_PRESETS[presetName] === undefined) {
794
+ throw new Error(
795
+ '未知 trace preset "' + presetName + '";可用:' + TRACE_PRESET_NAMES.join(', '),
796
+ )
797
+ }
798
+ const options = Object.assign({}, TRACE_DEFAULTS, TRACE_PRESETS[presetName], raw, { preset: presetName })
799
+ return { options: options, preset: presetName }
800
+ }
801
+
802
+ /**
803
+ * 根据 metrics 生成一句调参建议(给 AI 读,不是给用户的长文)。
804
+ */
805
+ export function hintTraceTune(metrics) {
806
+ if (metrics === undefined || metrics === null) return ''
807
+ const hints = []
808
+ const budget = Number(metrics.budget) || TRACE_DEFAULTS.maxBytes
809
+ const chars = Number(metrics.svgChars) || 0
810
+ if (chars > budget * 0.9 || Number(metrics.attempts) > 1) {
811
+ hints.push('字符紧/触发预算阶梯 → 试 scale:2、加大 epsilon、减小 colors/grid,或 preset:"simple"')
812
+ }
813
+ if (Number(metrics.protectPixels) === 0 && metrics.preset !== 'photo') {
814
+ hints.push('无暗像素保护区 → 插画线稿可试 preset:"lineart" 或提高 protectLuma')
815
+ }
816
+ if (Number(metrics.colorsDark) === 0 && Number(metrics.darkSamples) === 0 && metrics.preset === 'lineart') {
817
+ hints.push('lineart 但没分到暗样本 → 提高 protectLuma 或检查原图对比度')
818
+ }
819
+ if (Number(metrics.majoritySkipped) === 0 && Number(metrics.majorityContrast) > 0) {
820
+ hints.push('众数未跳过任何像素 → 图对比偏低,可减小 majorityContrast 或改 preset')
821
+ }
822
+ if (chars < budget * 0.35 && Number(metrics.scaleApplied) >= 2) {
823
+ hints.push('字符很富余且已降采样 → 可 scale:1 或减小 epsilon 换保真')
824
+ }
825
+ if (hints.length === 0) {
826
+ hints.push('指标正常;线稿糊/断可试 preset:"lineart";要更省字符用 preset:"simple"')
827
+ }
828
+ return hints.join(';')
829
+ }
830
+
831
+ /**
832
+ * 压缩给 AI 的结构化调参回执(diagram_apply.trace[])。
833
+ */
834
+ export function summarizeTraceForAi(metrics, extra) {
835
+ const base = {
836
+ preset: metrics.preset,
837
+ image: extra && extra.image !== undefined ? extra.image : undefined,
838
+ nodeId: extra && extra.nodeId !== undefined ? extra.nodeId : undefined,
839
+ // 只描了原图的一块时才有:那块在原图里的位置与尺寸(像素)——
840
+ // 模型据此知道"这次只覆盖了局部",以及该按原图坐标把它摆到哪。
841
+ crop: metrics.crop,
842
+ source: metrics.source,
843
+ work: metrics.work,
844
+ scaleApplied: metrics.scaleApplied,
845
+ sizeNotes: metrics.sizeNotes,
846
+ colors: metrics.colors,
847
+ colorsDark: metrics.colorsDark,
848
+ colorsFill: metrics.colorsFill,
849
+ protectPixels: metrics.protectPixels,
850
+ majoritySkipped: metrics.majoritySkipped,
851
+ paths: metrics.paths,
852
+ loopsKept: metrics.loopsKept,
853
+ loopsProtectedKept: metrics.loopsProtectedKept,
854
+ regionsProtected: metrics.regionsProtected,
855
+ svgChars: metrics.svgChars,
856
+ budget: metrics.budget,
857
+ attempts: metrics.attempts,
858
+ exactPixelCoverage: metrics.exactPixelCoverage,
859
+ options: metrics.optionsApplied,
860
+ hint: metrics.tuneHint,
861
+ }
862
+ return base
863
+ }
864
+
543
865
  /**
544
866
  * 位图 → SVG 标记。
545
867
  *
546
868
  * @param {{width:number,height:number,data:Buffer|Uint8Array}} raster RGBA8
547
- * @param {object} [rawOptions] 见 TRACE_DEFAULTS
869
+ * @param {object} [rawOptions] 见 TRACE_DEFAULTS / TRACE_PRESETS(可用 preset)
548
870
  * @returns {{svg:string, metrics:object}}
549
871
  */
550
872
  export function traceRasterToSvg(raster, rawOptions) {
551
- const options = Object.assign({}, TRACE_DEFAULTS, rawOptions === undefined || rawOptions === null ? {} : rawOptions)
873
+ const resolved = resolveTraceOptions(rawOptions)
874
+ const options = resolved.options
875
+ const preset = resolved.preset
552
876
  const colors = Math.max(2, Math.min(24, Math.round(options.colors)))
553
- const grid = Math.max(16, Math.round(options.grid))
554
- const scale = Math.max(1, Math.round(options.scale))
555
- const minArea = Math.max(1, Math.round(options.minArea))
877
+ const colorsDark = Math.max(0, Math.min(colors, Math.round(options.colorsDark)))
878
+ const colorsFillOpt = Math.max(0, Math.min(colors, Math.round(options.colorsFill)))
879
+ const minArea = Math.max(0, Math.round(options.minArea))
880
+ const minRegionArea = Math.max(0, Math.round(options.minRegionArea))
881
+ const protectLuma = Math.max(0, Number(options.protectLuma))
882
+ const majorityContrast = Math.max(0, Number(options.majorityContrast))
556
883
  const seeds = Math.max(1, Math.round(options.seeds))
884
+ const bgColor = parseBgColor(options.bgColor)
557
885
 
558
886
  const srcWidth = raster.width
559
887
  const srcHeight = raster.height
560
888
  const rgba = raster.data
561
889
 
562
- // 目标尺寸:宽度向 grid 靠(够大就原尺寸),高度按比例(至少 16px)
563
- let width = srcWidth
564
- let height = srcHeight
565
- if (grid < srcWidth) {
566
- width = grid
567
- height = Math.max(16, Math.round((srcHeight * grid) / srcWidth))
568
- }
890
+ const sized = resolveWorkSize(srcWidth, srcHeight, options)
891
+ const width = sized.width
892
+ const height = sized.height
893
+ const scale = sized.scaleApplied
569
894
 
570
- // 降采样(面积平均)+ 缩放:一次写完,后面只认 width×height 的工作图
895
+ // 降采样(面积平均)+ 把每个源像素先合成到 bgColor:一次写完,后面只认不透明的 width×height 工作图
571
896
  const work = new Uint8Array(width * height * 4)
897
+ const bgR = bgColor[0]
898
+ const bgG = bgColor[1]
899
+ const bgB = bgColor[2]
572
900
  for (let y = 0; y < height; y += 1) {
573
901
  const y0 = Math.floor((y * srcHeight) / height)
574
902
  const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * srcHeight) / height))
@@ -583,10 +911,14 @@ export function traceRasterToSvg(raster, rawOptions) {
583
911
  for (let yy = y0; yy < y1; yy += 1) {
584
912
  for (let xx = x0; xx < x1; xx += 1) {
585
913
  const o = (yy * srcWidth + xx) * 4
586
- r += rgba[o]
587
- g += rgba[o + 1]
588
- b += rgba[o + 2]
589
- a += rgba[o + 3]
914
+ const srcA = rgba[o + 3]
915
+ const af = srcA / 255
916
+ const inv = 1 - af
917
+ // RGB 先合成到 bgColor,避免半透明边缘带着脏 RGB;alpha 原样平均留给 nearWhite(a<16)
918
+ r += rgba[o] * af + bgR * inv
919
+ g += rgba[o + 1] * af + bgG * inv
920
+ b += rgba[o + 2] * af + bgB * inv
921
+ a += srcA
590
922
  n += 1
591
923
  }
592
924
  }
@@ -622,31 +954,47 @@ export function traceRasterToSvg(raster, rawOptions) {
622
954
  for (let i = 0; i < width * height; i += 1) if (isBg[i] === 0) fgIndex.push(i)
623
955
  if (fgIndex.length === 0) throw new Error('这张图整幅都是背景色(近白/近灰),没有可画的像素')
624
956
 
625
- let best = null
626
- {
627
- const samples = new Float64Array(fgIndex.length * 3)
957
+ // 保护区:暗前景像素(线稿)—— despeckle / ladder 都不动它们
958
+ const protectMask = new Uint8Array(width * height)
959
+ let protectPixels = 0
960
+ if (protectLuma > 0) {
628
961
  for (let i = 0; i < fgIndex.length; i += 1) {
629
- const o = fgIndex[i] * 4
630
- samples[i * 3] = work[o]
631
- samples[i * 3 + 1] = work[o + 1]
632
- samples[i * 3 + 2] = work[o + 2]
633
- }
634
- for (let s = 1; s <= seeds; s += 1) {
635
- const got = kmeans(samples, colors, 24, s * 2654435761)
636
- if (best === null || got.inertia < best.inertia) best = got
962
+ const p = fgIndex[i]
963
+ const o = p * 4
964
+ if (lumaRgb(work[o], work[o + 1], work[o + 2]) <= protectLuma) {
965
+ protectMask[p] = 1
966
+ protectPixels += 1
967
+ }
637
968
  }
638
969
  }
970
+
971
+ const built = buildPalette(work, fgIndex, colors, colorsDark, colorsFillOpt, protectLuma, seeds)
639
972
  const palette = [bgMean]
640
- for (let c = 0; c < best.cent.length; c += 1) {
641
- palette.push([clamp(Math.round(best.cent[c][0]), 0, 255), clamp(Math.round(best.cent[c][1]), 0, 255), clamp(Math.round(best.cent[c][2]), 0, 255)])
973
+ for (let c = 0; c < built.cents.length; c += 1) {
974
+ palette.push([
975
+ clamp(Math.round(built.cents[c][0]), 0, 255),
976
+ clamp(Math.round(built.cents[c][1]), 0, 255),
977
+ clamp(Math.round(built.cents[c][2]), 0, 255),
978
+ ])
642
979
  }
643
980
  const colorCount = palette.length
981
+ const protectLabels = new Uint8Array(colorCount)
982
+ if (protectLuma > 0) {
983
+ for (let c = 1; c < colorCount; c += 1) {
984
+ const p = palette[c]
985
+ if (lumaRgb(p[0], p[1], p[2]) <= protectLuma) protectLabels[c] = 1
986
+ }
987
+ }
644
988
 
645
- // ③ 超采样归类(双线性)→ 众数滤波 → 降回原分辨率
989
+ // ③ 超采样归类(双线性)→ 条件众数滤波 → 降回原分辨率
646
990
  const ss = 3
647
991
  const sw = width * ss
648
992
  const sh = height * ss
649
993
  const gridLabels = new Uint8Array(sw * sh)
994
+ const paletteLuma = new Float64Array(colorCount)
995
+ for (let c = 0; c < colorCount; c += 1) {
996
+ paletteLuma[c] = lumaRgb(palette[c][0], palette[c][1], palette[c][2])
997
+ }
650
998
  const px = [0, 0, 0]
651
999
  for (let y = 0; y < sh; y += 1) {
652
1000
  const fy = (y + 0.5) / ss - 0.5
@@ -687,8 +1035,32 @@ export function traceRasterToSvg(raster, rawOptions) {
687
1035
 
688
1036
  const scratch = new Uint8Array(gridLabels.length)
689
1037
  const hist = new Int32Array(colorCount + 1)
1038
+ let majoritySkipped = 0
1039
+ let majorityApplied = 0
690
1040
  for (let y = 0; y < sh; y += 1) {
691
1041
  for (let x = 0; x < sw; x += 1) {
1042
+ const self = gridLabels[y * sw + x]
1043
+ // 局部对比高(线稿边缘)→ 跳过众数,避免细线被邻色吃掉
1044
+ if (majorityContrast > 0) {
1045
+ let minL = paletteLuma[self]
1046
+ let maxL = minL
1047
+ for (let dy = -1; dy <= 1; dy += 1) {
1048
+ const yy = y + dy
1049
+ if (yy < 0 || yy >= sh) continue
1050
+ for (let dx = -1; dx <= 1; dx += 1) {
1051
+ const xx = x + dx
1052
+ if (xx < 0 || xx >= sw) continue
1053
+ const L = paletteLuma[gridLabels[yy * sw + xx]]
1054
+ if (L < minL) minL = L
1055
+ if (L > maxL) maxL = L
1056
+ }
1057
+ }
1058
+ if (maxL - minL >= majorityContrast) {
1059
+ scratch[y * sw + x] = self
1060
+ majoritySkipped += 1
1061
+ continue
1062
+ }
1063
+ }
692
1064
  hist.fill(0)
693
1065
  for (let dy = -1; dy <= 1; dy += 1) {
694
1066
  const yy = y + dy
@@ -699,7 +1071,7 @@ export function traceRasterToSvg(raster, rawOptions) {
699
1071
  hist[gridLabels[yy * sw + xx]] += 1
700
1072
  }
701
1073
  }
702
- let bi = gridLabels[y * sw + x]
1074
+ let bi = self
703
1075
  let bc = 0
704
1076
  for (let c = 0; c < colorCount; c += 1) {
705
1077
  if (hist[c] > bc) {
@@ -708,6 +1080,7 @@ export function traceRasterToSvg(raster, rawOptions) {
708
1080
  }
709
1081
  }
710
1082
  scratch[y * sw + x] = bi
1083
+ majorityApplied += 1
711
1084
  }
712
1085
  }
713
1086
  gridLabels.set(scratch)
@@ -732,7 +1105,40 @@ export function traceRasterToSvg(raster, rawOptions) {
732
1105
  }
733
1106
  }
734
1107
 
735
- const speckle = despeckle(labels, width, height, palette, minArea)
1108
+ // 背景色(label 0)只给泛洪底色用,且最终不画 path。
1109
+ // 白衣白底时,角色内部的白也会被最近邻归到 palette[0] —— 若不改回前景色,
1110
+ // 跳过绘制 label 0 就会把裙子/头发抠成透明洞。
1111
+ let fgRelabeledFromBg = 0
1112
+ let bgForced = 0
1113
+ for (let i = 0; i < width * height; i += 1) {
1114
+ if (isBg[i] === 1) {
1115
+ if (labels[i] !== 0) bgForced += 1
1116
+ labels[i] = 0
1117
+ continue
1118
+ }
1119
+ if (labels[i] !== 0) continue
1120
+ const o = i * 4
1121
+ let bi = 1
1122
+ let bd = Infinity
1123
+ for (let c = 1; c < colorCount; c += 1) {
1124
+ const p = palette[c]
1125
+ const dr = work[o] - p[0]
1126
+ const dg = work[o + 1] - p[1]
1127
+ const db = work[o + 2] - p[2]
1128
+ const d = dr * dr + dg * dg + db * db
1129
+ if (d < bd) {
1130
+ bd = d
1131
+ bi = c
1132
+ }
1133
+ }
1134
+ labels[i] = bi
1135
+ fgRelabeledFromBg += 1
1136
+ }
1137
+
1138
+ const maskOrNull = protectPixels > 0 ? protectMask : null
1139
+ const labelsOrNull = protectLuma > 0 ? protectLabels : null
1140
+ const speckle = despeckle(labels, width, height, palette, minArea, maskOrNull, labelsOrNull)
1141
+ const regionMerge = despeckle(labels, width, height, palette, minRegionArea, maskOrNull, labelsOrNull)
736
1142
  const labelDump = options.includeLabels === true
737
1143
  ? {
738
1144
  width: width, height: height, colors: colorCount,
@@ -741,21 +1147,24 @@ export function traceRasterToSvg(raster, rawOptions) {
741
1147
  }
742
1148
  : null
743
1149
 
744
- // ④ 连通域 + 轮廓
1150
+ // ④ 连通域 + 轮廓(记下区域是否受保护,供 ladder 保留细环)
745
1151
  const comp = new Int32Array(width * height).fill(-1)
746
1152
  const regionLabel = []
747
1153
  const regionArea = []
1154
+ const regionProtected = []
748
1155
  const stack = []
749
1156
  for (let i = 0; i < width * height; i += 1) {
750
1157
  if (comp[i] !== -1) continue
751
1158
  const label = labels[i]
752
1159
  const id = regionLabel.length
753
1160
  let area = 0
1161
+ let prot = protectLabels[label] === 1 ? 1 : 0
754
1162
  comp[i] = id
755
1163
  stack.push(i)
756
1164
  while (stack.length > 0) {
757
1165
  const p = stack.pop()
758
1166
  area += 1
1167
+ if (protectMask[p] === 1) prot = 1
759
1168
  const x = p % width
760
1169
  const y = (p - x) / width
761
1170
  for (let dy = -1; dy <= 1; dy += 1) {
@@ -774,19 +1183,26 @@ export function traceRasterToSvg(raster, rawOptions) {
774
1183
  }
775
1184
  regionLabel.push(label)
776
1185
  regionArea.push(area)
1186
+ regionProtected.push(prot)
777
1187
  }
778
1188
 
779
1189
  const loopsByRegion = traceLoops(comp, width, height, regionLabel.length)
780
1190
 
781
- // ⑤ 每色一条 path:外环(面积<0,顺时针)+ 洞(>0,反向)
1191
+ // ⑤ 每色一条 path:外环(面积<0,顺时针)+ 洞(>0,反向);环带 protected 标记
782
1192
  const byColor = []
783
1193
  for (let c = 0; c < colorCount; c += 1) byColor.push({ outer: [], hole: [], area: 0 })
784
1194
  loopsByRegion.forEach(function (loops, id) {
785
1195
  const label = regionLabel[id]
786
1196
  const bucket = byColor[label]
1197
+ const protectedLoop = regionProtected[id] === 1
787
1198
  for (let i = 0; i < loops.length; i += 1) {
788
- if (loops[i].area < 0) bucket.outer.push(loops[i])
789
- else bucket.hole.push(loops[i])
1199
+ const tagged = {
1200
+ points: loops[i].points,
1201
+ area: loops[i].area,
1202
+ protected: protectedLoop,
1203
+ }
1204
+ if (loops[i].area < 0) bucket.outer.push(tagged)
1205
+ else bucket.hole.push(tagged)
790
1206
  }
791
1207
  bucket.area += regionArea[id]
792
1208
  })
@@ -806,8 +1222,9 @@ export function traceRasterToSvg(raster, rawOptions) {
806
1222
  let attempts = 0
807
1223
  let pathCount = 0
808
1224
  let loopCount = 0
1225
+ let loopsProtectedKept = 0
809
1226
  let svg = ''
810
- // 超预算就逐步收紧:先抬 RDP 容差,再丢小洞
1227
+ // 超预算就逐步收紧:先抬 RDP 容差,再丢小洞(受保护环永不因 minLoopArea 丢弃)
811
1228
  const ladder = [
812
1229
  { epsilon: options.epsilon, minLoopArea: 4 },
813
1230
  { epsilon: Math.max(options.epsilon, 2.5), minLoopArea: 4 },
@@ -821,19 +1238,25 @@ export function traceRasterToSvg(raster, rawOptions) {
821
1238
  let body = ''
822
1239
  pathCount = 0
823
1240
  loopCount = 0
1241
+ loopsProtectedKept = 0
824
1242
  for (let k = 0; k < order.length; k += 1) {
1243
+ // label 0 = 泛洪标出的截图底色:不画(SVG 该处透明)。
1244
+ // 若画成 path,角色左右两块互不连通的底色会进同一条 nonzero path,
1245
+ // 绕向抵消时会出现"一侧透明、一侧实心白"的假背景(插画发丝顶天立地时必现)。
1246
+ if (order[k] === 0) continue
825
1247
  const bucket = byColor[order[k]]
826
1248
  let d = ''
827
1249
  const all = bucket.outer.concat(bucket.hole)
828
1250
  for (let i = 0; i < all.length; i += 1) {
829
1251
  const loop = all[i]
830
- if (Math.abs(loop.area) < plan.minLoopArea) continue
1252
+ if (!loop.protected && Math.abs(loop.area) < plan.minLoopArea) continue
831
1253
  // 洞反向 → nonzero 里挖空
832
1254
  const points = loop.area > 0 ? loop.points.slice().reverse() : loop.points
833
1255
  const seg = encodeLoop(simplifyClosed(points, plan.epsilon), options.precision)
834
1256
  if (seg === null) continue
835
1257
  d += seg
836
1258
  loopCount += 1
1259
+ if (loop.protected) loopsProtectedKept += 1
837
1260
  }
838
1261
  if (d === '') continue
839
1262
  body += "<path fill='" + hex(palette[order[k]]) + "' fill-rule='nonzero' d='" + d + "'/>"
@@ -854,33 +1277,74 @@ export function traceRasterToSvg(raster, rawOptions) {
854
1277
  let paletteArea = 0
855
1278
  for (let c = 0; c < colorCount; c += 1) paletteArea += byColor[c].area
856
1279
 
1280
+ const optionsApplied = {
1281
+ preset: preset,
1282
+ colors: colors,
1283
+ colorsDark: colorsDark,
1284
+ colorsFill: colorsFillOpt,
1285
+ grid: sized.grid,
1286
+ gridH: sized.gridH,
1287
+ scale: scale,
1288
+ epsilon: options.epsilon,
1289
+ precision: options.precision,
1290
+ minArea: minArea,
1291
+ minRegionArea: minRegionArea,
1292
+ protectLuma: protectLuma,
1293
+ majorityContrast: majorityContrast,
1294
+ }
1295
+
1296
+ const metrics = {
1297
+ preset: preset,
1298
+ source: { width: srcWidth, height: srcHeight },
1299
+ work: { width: width, height: height, scale: scale },
1300
+ scaleRequested: sized.scaleRequested,
1301
+ scaleApplied: sized.scaleApplied,
1302
+ grid: sized.grid,
1303
+ gridH: sized.gridH,
1304
+ bgColor: bgColor.slice(),
1305
+ sizeNotes: sized.sizeNotes,
1306
+ colors: colorCount - 1,
1307
+ colorsDark: built.colorsDark,
1308
+ colorsFill: built.colorsFill,
1309
+ darkSamples: built.darkSamples,
1310
+ fillSamples: built.fillSamples,
1311
+ protectLuma: protectLuma,
1312
+ protectPixels: protectPixels,
1313
+ fgRelabeledFromBg: fgRelabeledFromBg,
1314
+ bgForced: bgForced,
1315
+ majorityContrast: majorityContrast,
1316
+ majoritySkipped: majoritySkipped,
1317
+ majorityApplied: majorityApplied,
1318
+ palette: palette.map(hex),
1319
+ backgroundPixels: (function () {
1320
+ let n = 0
1321
+ for (let i = 0; i < width * height; i += 1) if (isBg[i] === 1) n += 1
1322
+ return n
1323
+ })(),
1324
+ foregroundPixels: fgIndex.length,
1325
+ despeckle: speckle,
1326
+ regionMerge: regionMerge,
1327
+ regions: regionLabel.length,
1328
+ regionsProtected: regionProtected.reduce(function (n, v) { return n + v }, 0),
1329
+ loops: loopsTotal.value,
1330
+ paths: pathCount,
1331
+ loopsKept: loopCount,
1332
+ loopsProtectedKept: loopsProtectedKept,
1333
+ drawnPixels: covered,
1334
+ totalPixels: width * height,
1335
+ exactPixelCoverage: covered === width * height,
1336
+ svgChars: svg.length,
1337
+ budget: options.maxBytes,
1338
+ attempts: attempts,
1339
+ paletteAreaSum: paletteArea,
1340
+ optionsApplied: optionsApplied,
1341
+ }
1342
+ metrics.tuneHint = hintTraceTune(metrics)
1343
+
857
1344
  return {
858
1345
  svg: svg,
859
1346
  labels: labelDump,
860
- metrics: {
861
- source: { width: srcWidth, height: srcHeight },
862
- work: { width: width, height: height, scale: scale },
863
- colors: colorCount - 1,
864
- palette: palette.map(hex),
865
- backgroundPixels: (function () {
866
- let n = 0
867
- for (let i = 0; i < width * height; i += 1) if (isBg[i] === 1) n += 1
868
- return n
869
- })(),
870
- foregroundPixels: fgIndex.length,
871
- despeckle: speckle,
872
- regions: regionLabel.length,
873
- loops: loopsTotal.value,
874
- paths: pathCount,
875
- loopsKept: loopCount,
876
- drawnPixels: covered,
877
- totalPixels: width * height,
878
- exactPixelCoverage: covered === width * height,
879
- svgChars: svg.length,
880
- budget: options.maxBytes,
881
- attempts: attempts,
882
- paletteAreaSum: paletteArea,
883
- },
1347
+ metrics: metrics,
884
1348
  }
885
1349
  }
886
1350
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-drawai",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "DSH 右侧栏的可编辑画布 + AI 语义绘图工具(diagram_apply / diagram_read),直接读写原生 .drawio 文件",
6
6
  "license": "MIT",