@bpmn-nova/studio 0.3.4-preview → 0.3.5-preview

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.
@@ -1,6 +1,7 @@
1
1
  import { NODE_DEFINITIONS, edgeWaypoints, roundedPath, routeRuntimeTransition, smoothPath } from '../core/index.js';
2
2
  import { createDefaultIconRegistry, resolveNodeVisual } from '../icons/index.js';
3
3
  import { resolveNodeSubtitle, supportsNodeSubtitle } from '../node-presentation/index.js';
4
+ import { resolveNodeGeometry, resolvePresentationWaypoints } from '../node-geometry/index.js';
4
5
  import { createRuntimePresentation } from '../runtime/index.js';
5
6
  import { createRuntimeAppearance } from '../theme/index.js';
6
7
 
@@ -294,9 +295,11 @@ function addDefinitions(document, svg, theme) {
294
295
  if (/^[a-z][a-z0-9-]{0,47}$/.test(name)) markers.set(name, tone(theme, name).strong);
295
296
  }
296
297
  for (const [id, color] of markers) {
297
- const marker = svgElement(document, 'marker', { id: `nova-arrow-${id}`, viewBox: '0 0 10 10', refX: 8.5, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
298
- marker.appendChild(svgElement(document, 'path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: color }));
299
- defs.appendChild(marker);
298
+ for (const docked of [false, true]) {
299
+ const marker = svgElement(document, 'marker', { id: `nova-arrow-${id}${docked ? '-docked' : ''}`, viewBox: '0 0 10 10', refX: docked ? 10 : 8.5, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
300
+ marker.appendChild(svgElement(document, 'path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: color }));
301
+ defs.appendChild(marker);
302
+ }
300
303
  }
301
304
  svg.appendChild(defs);
302
305
  }
@@ -305,7 +308,29 @@ function nodeToneName(node) {
305
308
  return ({ userTask: 'user-task', serviceTask: 'service-task', scriptTask: 'script-task', businessRuleTask: 'rule-task', sendTask: 'message-task', receiveTask: 'message-task', manualTask: 'manual-task' })[node.type] || 'primary';
306
309
  }
307
310
 
308
- function renderDefaultNode({ document, group, node, definition, visual, presentation, subtitle: definitionSubtitle, theme, iconRegistry }) {
311
+ function renderGeometryShape(document, group, geometry, fill, stroke) {
312
+ const { outline, center, scale } = geometry;
313
+ const { width, height, radiusX, radiusY, rotation, strokeWidth } = outline;
314
+ const inset = strokeWidth / 2;
315
+ const shape = svgElement(document, 'g', { 'data-node-shape': geometry.kind, transform: `translate(${center.x} ${center.y}) rotate(${rotation * 180 / Math.PI}) scale(${scale})` });
316
+ // Paint the border inward, as CSS does. A centred SVG stroke around an inset ellipse
317
+ // is not the same outer curve (notably for data stores), so use two nested fills.
318
+ shape.appendChild(svgElement(document, 'rect', { x: -width / 2, y: -height / 2, width, height, rx: radiusX, ry: radiusY, fill: stroke, filter: geometry.kind === 'gateway' ? 'url(#nova-export-shadow)' : null }));
319
+ shape.appendChild(svgElement(document, 'rect', { x: -width / 2 + strokeWidth, y: -height / 2 + strokeWidth, width: width - 2 * strokeWidth, height: height - 2 * strokeWidth, rx: Math.max(0, radiusX - strokeWidth), ry: Math.max(0, radiusY - strokeWidth), fill }));
320
+ if (geometry.kind === 'data') {
321
+ const right = width / 2 - inset;
322
+ const top = -height / 2 + inset;
323
+ shape.appendChild(svgElement(document, 'path', { d: `M ${right - 15} ${top} L ${right} ${top + 15}`, fill: 'none', stroke, 'stroke-width': strokeWidth }));
324
+ } else if (geometry.kind === 'dataStore') {
325
+ for (const offset of [9, 19]) {
326
+ const top = -height / 2 + strokeWidth + offset;
327
+ shape.appendChild(svgElement(document, 'path', { d: `M ${-width / 2 + inset} ${top + 4.5} A ${width / 2 - inset} 4.5 0 0 1 ${width / 2 - inset} ${top + 4.5}`, fill: 'none', stroke, 'stroke-width': 1.5 }));
328
+ }
329
+ }
330
+ group.appendChild(shape);
331
+ }
332
+
333
+ function renderDefaultNode({ document, group, node, definition, geometry, visual, presentation, subtitle: definitionSubtitle, theme, iconRegistry }) {
309
334
  const colors = theme.colors;
310
335
  const statusTone = tone(theme, presentation?.status === 'rejected' ? 'danger' : presentation?.status === 'active' ? 'primary' : presentation?.status === 'completed' ? 'success' : 'neutral');
311
336
  const typeTone = tone(theme, nodeToneName(node));
@@ -319,11 +344,12 @@ function renderDefaultNode({ document, group, node, definition, visual, presenta
319
344
  appendTextLines(document, group, wrapText(node.name || definition.label, 150, 12), { x: x + width / 2, y: y + height + 20, lineHeight: 15, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
320
345
  return;
321
346
  }
322
- if (definition.kind === 'gateway') {
323
- const points = `${x + width / 2},${y + 2} ${x + width - 2},${y + height / 2} ${x + width / 2},${y + height - 2} ${x + 2},${y + height / 2}`;
324
- group.appendChild(svgElement(document, 'polygon', { points, fill: colors.surface, stroke: presentation?.status && presentation.status !== 'idle' ? statusTone.strong : colors.borderStrong, 'stroke-width': 2, filter: 'url(#nova-export-shadow)' }));
325
- appendIcon(document, group, icon, { x: x + width * 0.28, y: y + height * 0.28, width: width * 0.44, height: height * 0.44 }, colors.textSecondary);
326
- appendTextLines(document, group, wrapText(node.name || definition.label, 160, 12), { x: x + width / 2, y: y + height + 20, lineHeight: 15, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
347
+ if (['gateway', 'data', 'dataStore'].includes(definition.kind)) {
348
+ if (!geometry) return;
349
+ const stroke = definition.kind === 'gateway' && presentation?.status && presentation.status !== 'idle' ? statusTone.strong : colors.borderStrong;
350
+ renderGeometryShape(document, group, geometry, colors.surface, stroke);
351
+ appendIcon(document, group, icon, geometry.iconBounds, colors.textSecondary);
352
+ appendTextLines(document, group, wrapText(node.name || definition.label, definition.kind === 'gateway' ? 160 : 150, 12), { x: geometry.label.x, y: geometry.label.y + 12, lineHeight: geometry.label.lineHeight, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
327
353
  return;
328
354
  }
329
355
  if (definition.kind === 'participant' || definition.kind === 'lane') {
@@ -344,17 +370,6 @@ function renderDefaultNode({ document, group, node, definition, visual, presenta
344
370
  appendTextLines(document, group, wrapText(node.properties?.text || node.name || '说明', width - 24, 12), { x: x + 16, y: y + 20, lineHeight: 17, attributes: { fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
345
371
  return;
346
372
  }
347
- if (definition.kind === 'data' || definition.kind === 'dataStore') {
348
- if (definition.kind === 'data') group.appendChild(svgElement(document, 'path', { d: `M ${x + 8} ${y + 2} H ${x + width - 18} L ${x + width - 2} ${y + 18} V ${y + height - 2} H ${x + 8} Z`, fill: colors.surface, stroke: colors.borderStrong, 'stroke-width': 1.5 }));
349
- else {
350
- group.appendChild(svgElement(document, 'rect', { x: x + 4, y: y + 10, width: width - 8, height: height - 20, fill: colors.surface, stroke: colors.borderStrong, 'stroke-width': 1.5 }));
351
- group.appendChild(svgElement(document, 'ellipse', { cx: x + width / 2, cy: y + 10, rx: width / 2 - 4, ry: 9, fill: colors.surface, stroke: colors.borderStrong, 'stroke-width': 1.5 }));
352
- group.appendChild(svgElement(document, 'ellipse', { cx: x + width / 2, cy: y + height - 10, rx: width / 2 - 4, ry: 9, fill: 'none', stroke: colors.borderStrong, 'stroke-width': 1.5 }));
353
- }
354
- appendIcon(document, group, icon, { x: x + width * 0.33, y: y + height * 0.31, width: width * 0.34, height: height * 0.34 }, colors.textSecondary);
355
- appendTextLines(document, group, wrapText(node.name || definition.label, 150, 12), { x: x + width / 2, y: y + height + 20, lineHeight: 15, attributes: { 'text-anchor': 'middle', fill: colors.textSecondary, 'font-size': 12, 'font-family': theme.fontFamily } });
356
- return;
357
- }
358
373
 
359
374
  const iconSize = Math.min(36, height - 24);
360
375
  const iconX = x + 14;
@@ -401,6 +416,45 @@ function renderDefaultNode({ document, group, node, definition, visual, presenta
401
416
  }
402
417
  }
403
418
 
419
+ function prepareNodes({ document, context, options, model, presentation, theme, iconRegistry, warnings }) {
420
+ const nodeLayer = svgElement(document, 'g', { 'data-layer': 'nodes' });
421
+ const geometries = new Map();
422
+ const runtimeMode = context.mode === 'instance' || Boolean(context.runtime);
423
+ const definitionMode = context.mode === 'viewer' ? 'viewer' : 'design';
424
+ const order = { participant: 0, lane: 1, group: 2, container: 3, data: 4, dataStore: 4, annotation: 4, task: 5, gateway: 6, event: 7, boundary: 8 };
425
+ const nodes = [...(model.nodes || [])].sort((a, b) => (order[NODE_DEFINITIONS[a.type]?.kind] ?? 5) - (order[NODE_DEFINITIONS[b.type]?.kind] ?? 5));
426
+ for (const node of nodes) {
427
+ options.signal?.throwIfAborted?.();
428
+ const definition = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
429
+ const nodePresentation = presentation.getNode?.(node.id) || null;
430
+ const visual = resolveNodeVisual(node, { surface: context.mode === 'instance' ? 'viewer' : 'canvas', model: context.visualModel || model });
431
+ const group = svgElement(document, 'g', { 'data-node-id': node.id });
432
+ const customRenderer = context.nodeRenderers?.[node.type] || context.nodeRenderers?.[definition.kind];
433
+ let rendered = false;
434
+ if (typeof customRenderer === 'function') {
435
+ try {
436
+ rendered = customRenderer({ container: group, node, definition, runtimePresentation: nodePresentation, visual, model, themeSnapshot: theme, iconRegistry }) !== false;
437
+ if (!rendered) group.replaceChildren();
438
+ } catch (error) {
439
+ group.replaceChildren();
440
+ warnings.push({ code: 'custom-node-renderer-failed', message: `节点“${node.name || node.id}”的 SVG Renderer 执行失败,已使用标准视觉。`, elementId: node.id });
441
+ }
442
+ } else if (context.htmlNodeRenderers?.[node.type] || context.htmlNodeRenderers?.[definition.kind] || context.htmlNodeRenderer) {
443
+ warnings.push({ code: 'custom-node-renderer-fallback', message: `节点“${node.name || node.id}”没有 SVG Renderer,已使用标准视觉。`, elementId: node.id });
444
+ }
445
+ if (!rendered) {
446
+ const geometry = resolveNodeGeometry(node, definition);
447
+ geometries.set(node.id, geometry);
448
+ const subtitle = !runtimeMode && supportsNodeSubtitle(definition)
449
+ ? resolveNodeSubtitle({ node, definition, model, mode: definitionMode, surface: 'svg-export', resolver: context.nodeSubtitleResolver })
450
+ : undefined;
451
+ renderDefaultNode({ document, group, node, definition, geometry, visual, presentation: nodePresentation, subtitle, theme, iconRegistry });
452
+ }
453
+ nodeLayer.appendChild(group);
454
+ }
455
+ return { nodeLayer, geometries };
456
+ }
457
+
404
458
  export async function exportDiagramSvg(context = {}, options = {}) {
405
459
  options.signal?.throwIfAborted?.();
406
460
  const document = resolvedDocument(context);
@@ -435,18 +489,22 @@ export async function exportDiagramSvg(context = {}, options = {}) {
435
489
  const svg = createRoot(document, { minX, minY, width, height, title, theme, transparentBackground: options.transparentBackground === true });
436
490
  addDefinitions(document, svg, theme);
437
491
 
492
+ // Resolve custom renderers once before docking edges, without changing the final layer order.
493
+ const { nodeLayer, geometries } = prepareNodes({ document, context, options, model, presentation, theme, iconRegistry, warnings });
494
+
438
495
  const edgeLayer = svgElement(document, 'g', { 'data-layer': 'edges' });
439
496
  for (const edge of model.edges || []) {
440
497
  const points = edgeWaypoints(model, edge);
498
+ const display = resolvePresentationWaypoints(points, geometries.get(edge.source), geometries.get(edge.target));
441
499
  const edgeState = presentation.getEdge?.(edge.id) || { status: 'idle' };
442
500
  const completed = edgeState.status === 'completed';
443
501
  const stroke = completed ? tone(theme, 'success').strong : theme.colors.edge;
444
502
  const type = edge.type || 'sequenceFlow';
445
503
  const path = svgElement(document, 'path', {
446
- d: routePath(points, edge.routeStyle || model.settings?.edgeStyle || 'rounded', edge.cornerRadius ?? model.settings?.cornerRadius ?? 14),
504
+ d: routePath(display.points, edge.routeStyle || model.settings?.edgeStyle || 'rounded', edge.cornerRadius ?? model.settings?.cornerRadius ?? 14),
447
505
  fill: 'none', stroke, 'stroke-width': completed ? 2.3 : 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
448
506
  'stroke-dasharray': type === 'messageFlow' ? '8 7' : type === 'association' ? '2 6' : null,
449
- 'marker-end': type === 'association' ? null : `url(#nova-arrow-${completed ? 'success' : 'edge'})`,
507
+ 'marker-end': type === 'association' ? null : `url(#nova-arrow-${completed ? 'success' : 'edge'}${display.targetDocked ? '-docked' : ''})`,
450
508
  });
451
509
  edgeLayer.appendChild(path);
452
510
  if (edge.name) {
@@ -471,10 +529,11 @@ export async function exportDiagramSvg(context = {}, options = {}) {
471
529
  const route = routeRuntimeTransition(model, transition, { occupiedRoutes: occupied });
472
530
  if (!route) continue;
473
531
  occupied.push(route.points);
532
+ const display = resolvePresentationWaypoints(route.points, geometries.get(transition.sourceElementId), geometries.get(transition.targetElementId));
474
533
  const toneName = appearance.resolveTransition?.(transition)?.tone || (transition.type === 'reject' ? 'danger' : 'warning');
475
534
  const transitionTone = tone(theme, toneName);
476
535
  const markerTone = Object.hasOwn(theme.tones || {}, toneName) && /^[a-z][a-z0-9-]{0,47}$/.test(toneName) ? toneName : 'neutral';
477
- transitionLayer.appendChild(svgElement(document, 'path', { d: routePath(route.points, model.settings?.edgeStyle || 'rounded', model.settings?.cornerRadius || 14), fill: 'none', stroke: transitionTone.strong, 'stroke-width': 2.2, 'stroke-dasharray': '8 6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'marker-end': `url(#nova-arrow-${markerTone})`, opacity: transition.latest ? 1 : 0.42 }));
536
+ transitionLayer.appendChild(svgElement(document, 'path', { d: routePath(display.points, model.settings?.edgeStyle || 'rounded', model.settings?.cornerRadius || 14), fill: 'none', stroke: transitionTone.strong, 'stroke-width': 2.2, 'stroke-dasharray': '8 6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'marker-end': `url(#nova-arrow-${markerTone}${display.targetDocked ? '-docked' : ''})`, opacity: transition.latest ? 1 : 0.42 }));
478
537
  const label = transition.label || (transition.type === 'reject' ? '驳回' : '退回');
479
538
  const labelTypography = { fontSize: 11, fontWeight: 650, fontFamily: theme.fontFamily };
480
539
  const labelWidth = Math.max(68, Math.min(210, Math.ceil(measureText(document, label, labelTypography) + 20)));
@@ -488,45 +547,6 @@ export async function exportDiagramSvg(context = {}, options = {}) {
488
547
  svg.appendChild(transitionLayer);
489
548
  }
490
549
 
491
- const nodeLayer = svgElement(document, 'g', { 'data-layer': 'nodes' });
492
- const runtimeMode = context.mode === 'instance' || Boolean(context.runtime);
493
- const definitionMode = context.mode === 'viewer' ? 'viewer' : 'design';
494
- const order = { participant: 0, lane: 1, group: 2, container: 3, data: 4, dataStore: 4, annotation: 4, task: 5, gateway: 6, event: 7, boundary: 8 };
495
- const nodes = [...(model.nodes || [])].sort((a, b) => (order[NODE_DEFINITIONS[a.type]?.kind] ?? 5) - (order[NODE_DEFINITIONS[b.type]?.kind] ?? 5));
496
- for (const node of nodes) {
497
- options.signal?.throwIfAborted?.();
498
- const definition = NODE_DEFINITIONS[node.type] || NODE_DEFINITIONS.generic;
499
- const nodePresentation = presentation.getNode?.(node.id) || null;
500
- const visual = resolveNodeVisual(node, { surface: context.mode === 'instance' ? 'viewer' : 'canvas', model: context.visualModel || model });
501
- const group = svgElement(document, 'g', { 'data-node-id': node.id });
502
- const customRenderer = context.nodeRenderers?.[node.type] || context.nodeRenderers?.[definition.kind];
503
- let rendered = false;
504
- if (typeof customRenderer === 'function') {
505
- try {
506
- rendered = customRenderer({ container: group, node, definition, runtimePresentation: nodePresentation, visual, model, themeSnapshot: theme, iconRegistry }) !== false;
507
- if (!rendered) group.replaceChildren();
508
- } catch (error) {
509
- group.replaceChildren();
510
- warnings.push({ code: 'custom-node-renderer-failed', message: `节点“${node.name || node.id}”的 SVG Renderer 执行失败,已使用标准视觉。`, elementId: node.id });
511
- }
512
- } else if (context.htmlNodeRenderers?.[node.type] || context.htmlNodeRenderers?.[definition.kind] || context.htmlNodeRenderer) {
513
- warnings.push({ code: 'custom-node-renderer-fallback', message: `节点“${node.name || node.id}”没有 SVG Renderer,已使用标准视觉。`, elementId: node.id });
514
- }
515
- if (!rendered) {
516
- const subtitle = !runtimeMode && supportsNodeSubtitle(definition)
517
- ? resolveNodeSubtitle({
518
- node,
519
- definition,
520
- model,
521
- mode: definitionMode,
522
- surface: 'svg-export',
523
- resolver: context.nodeSubtitleResolver,
524
- })
525
- : undefined;
526
- renderDefaultNode({ document, group, node, definition, visual, presentation: nodePresentation, subtitle, theme, iconRegistry });
527
- }
528
- nodeLayer.appendChild(group);
529
- }
530
550
  svg.appendChild(nodeLayer);
531
551
  const filename = sanitizeSvgFilename(options.filename || `${model.name || model.id || 'process'}-${context.label || (context.mode === 'instance' ? '审批轨迹' : context.mode === 'viewer' ? '流程展示' : '流程设计')}`);
532
552
  return serializeArtifact(document, svg, { filename, width, height, viewBox: { x: minX, y: minY, width, height }, warnings });
@@ -0,0 +1,19 @@
1
+ import type { BpmnNode, NodeDefinition, Point } from '../core/index.js'
2
+
3
+ export interface NodeGeometry {
4
+ kind: 'gateway' | 'data' | 'dataStore'
5
+ scale: number
6
+ center: Point
7
+ outline: Readonly<{ width: number; height: number; radiusX: number; radiusY: number; rotation: number; strokeWidth: number; iconSize: number }>
8
+ bounds: { x: number; y: number; width: number; height: number }
9
+ iconBounds: { x: number; y: number; width: number; height: number }
10
+ label: Point & { lineHeight: number }
11
+ ports: Record<'left' | 'right' | 'top' | 'bottom', Point | null>
12
+ }
13
+
14
+ export function resolveNodeGeometry(node: BpmnNode, definition: NodeDefinition): NodeGeometry | null
15
+ export function resolvePresentationWaypoints(points: readonly Point[], sourceGeometry?: NodeGeometry | null, targetGeometry?: NodeGeometry | null): {
16
+ points: Point[]
17
+ sourceDocked: boolean
18
+ targetDocked: boolean
19
+ }
@@ -0,0 +1,147 @@
1
+ const EPSILON = 1e-9;
2
+ const BASE_SHAPES = Object.freeze({
3
+ gateway: Object.freeze({ width: 49, height: 49, radiusX: 7, radiusY: 7, rotation: Math.PI / 4, strokeWidth: 1.8, iconSize: 24 }),
4
+ data: Object.freeze({ width: 50, height: 62, radiusX: 4, radiusY: 4, rotation: 0, strokeWidth: 1.7, iconSize: 18 }),
5
+ dataStore: Object.freeze({ width: 56, height: 50, radiusX: 28, radiusY: 7, rotation: 0, strokeWidth: 1.7, iconSize: 16 }),
6
+ });
7
+
8
+ function finitePoint(point) {
9
+ return Number.isFinite(point?.x) && Number.isFinite(point?.y);
10
+ }
11
+
12
+ function toLocal(geometry, point) {
13
+ const x = (point.x - geometry.center.x) / geometry.scale;
14
+ const y = (point.y - geometry.center.y) / geometry.scale;
15
+ const { rotation } = geometry.outline;
16
+ return { x: x * Math.cos(rotation) + y * Math.sin(rotation), y: -x * Math.sin(rotation) + y * Math.cos(rotation) };
17
+ }
18
+
19
+ function contains(outline, point) {
20
+ const x = Math.abs(point.x);
21
+ const y = Math.abs(point.y);
22
+ const halfWidth = outline.width / 2;
23
+ const halfHeight = outline.height / 2;
24
+ if (x > halfWidth + EPSILON || y > halfHeight + EPSILON) return false;
25
+ const dx = Math.max(0, x - halfWidth + outline.radiusX) / outline.radiusX;
26
+ const dy = Math.max(0, y - halfHeight + outline.radiusY) / outline.radiusY;
27
+ return dx * dx + dy * dy <= 1 + EPSILON;
28
+ }
29
+
30
+ /** Intersect an incoming ray with the outer painted rounded rectangle, in base coordinates. */
31
+ function outlineIntersection(outline, from, toward) {
32
+ if (contains(outline, from)) return null;
33
+ const dx = toward.x - from.x;
34
+ const dy = toward.y - from.y;
35
+ if (Math.hypot(dx, dy) <= EPSILON) return null;
36
+ const { width, height, radiusX: rx, radiusY: ry } = outline;
37
+ const hw = width / 2;
38
+ const hh = height / 2;
39
+ const candidates = [];
40
+ const accept = (t, valid) => { if (Number.isFinite(t) && t >= 0 && valid) candidates.push(t); };
41
+ for (const sign of [-1, 1]) {
42
+ if (Math.abs(dx) > EPSILON) {
43
+ const t = (sign * hw - from.x) / dx;
44
+ accept(t, Math.abs(from.y + t * dy) <= hh - ry + EPSILON);
45
+ }
46
+ if (Math.abs(dy) > EPSILON) {
47
+ const t = (sign * hh - from.y) / dy;
48
+ accept(t, Math.abs(from.x + t * dx) <= hw - rx + EPSILON);
49
+ }
50
+ }
51
+ for (const sx of [-1, 1]) {
52
+ for (const sy of [-1, 1]) {
53
+ const cx = sx * (hw - rx);
54
+ const cy = sy * (hh - ry);
55
+ const x = (from.x - cx) / rx;
56
+ const y = (from.y - cy) / ry;
57
+ const vx = dx / rx;
58
+ const vy = dy / ry;
59
+ const a = vx * vx + vy * vy;
60
+ const b = 2 * (x * vx + y * vy);
61
+ const c = x * x + y * y - 1;
62
+ const discriminant = b * b - 4 * a * c;
63
+ if (discriminant < -EPSILON || a === 0) continue;
64
+ const root = Math.sqrt(Math.max(0, discriminant));
65
+ for (const t of [(-b - root) / (2 * a), (-b + root) / (2 * a)]) {
66
+ accept(t, (from.x + t * dx - cx) * sx >= -EPSILON && (from.y + t * dy - cy) * sy >= -EPSILON);
67
+ }
68
+ }
69
+ }
70
+ return candidates.length ? Math.min(...candidates) : null;
71
+ }
72
+
73
+ /** Display-only intersection. A missing intersection must never invent a new route. */
74
+ function dockPoint(geometry, from, toward) {
75
+ if (!geometry || !finitePoint(from) || !finitePoint(toward)) return null;
76
+ const localFrom = toLocal(geometry, from);
77
+ const localToward = toLocal(geometry, toward);
78
+ if (!finitePoint(localFrom) || !finitePoint(localToward)) return null;
79
+ const t = outlineIntersection(geometry.outline, localFrom, localToward);
80
+ if (t === null) return null;
81
+ const point = { x: from.x + (toward.x - from.x) * t, y: from.y + (toward.y - from.y) * t };
82
+ return finitePoint(point) ? point : null;
83
+ }
84
+
85
+ /**
86
+ * Standard gateway/data geometry shared by DOM and pure SVG. Model Bounds remain authoritative;
87
+ * scaling only affects presentation, including borders and decorations. Unsupported/invalid input
88
+ * deliberately has no geometry rather than silently rewriting a BPMN element's dimensions.
89
+ */
90
+ export function resolveNodeGeometry(node, definition) {
91
+ const base = BASE_SHAPES[definition?.kind];
92
+ if (!base || !finitePoint(node) || !Number.isFinite(node.width) || !Number.isFinite(node.height) || node.width <= 0 || node.height <= 0) return null;
93
+ const extentWidth = base.rotation ? base.width * Math.SQRT2 : base.width;
94
+ const extentHeight = base.rotation ? base.height * Math.SQRT2 : base.height;
95
+ const scale = Math.min(1, node.width / extentWidth, node.height / extentHeight);
96
+ const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
97
+ const label = { x: center.x, y: node.y + node.height + 6, lineHeight: 16 };
98
+ if (!finitePoint(center) || !finitePoint(label) || !Number.isFinite(scale) || scale <= 0) return null;
99
+ const iconSize = (node.type === 'complexGateway' ? 20 : base.iconSize) * scale;
100
+ const geometry = {
101
+ kind: definition.kind,
102
+ scale,
103
+ center,
104
+ outline: base,
105
+ bounds: { x: center.x - extentWidth * scale / 2, y: center.y - extentHeight * scale / 2, width: extentWidth * scale, height: extentHeight * scale },
106
+ iconBounds: { x: center.x - iconSize / 2, y: center.y - iconSize / 2, width: iconSize, height: iconSize },
107
+ label,
108
+ ports: {},
109
+ };
110
+ for (const [side, dx, dy] of [['left', -1, 0], ['right', 1, 0], ['top', 0, -1], ['bottom', 0, 1]]) {
111
+ const distance = Math.max(extentWidth, extentHeight) * scale;
112
+ geometry.ports[side] = dockPoint(geometry, { x: center.x + dx * distance, y: center.y + dy * distance }, center);
113
+ }
114
+ return geometry;
115
+ }
116
+
117
+ function endpointProjection(points, geometry, target) {
118
+ const start = target ? points.length - 1 : 0;
119
+ const step = target ? -1 : 1;
120
+ let next = start + step;
121
+ while (next >= 0 && next < points.length && Math.hypot(points[next].x - points[start].x, points[next].y - points[start].y) <= EPSILON) next += step;
122
+ if (next < 0 || next >= points.length) return null;
123
+ const point = dockPoint(geometry, points[next], points[start]);
124
+ return point ? { point, start, next, step } : null;
125
+ }
126
+
127
+ /** Clone routes; keep bends, collinearity and travel direction, including repeated endpoint entries. */
128
+ export function resolvePresentationWaypoints(points, sourceGeometry = null, targetGeometry = null) {
129
+ const copy = points.map((point) => ({ ...point }));
130
+ const unchanged = () => ({ points: points.map((point) => ({ ...point })), sourceDocked: false, targetDocked: false });
131
+ if (points.length < 2 || !points.every(finitePoint)) return unchanged();
132
+ const source = endpointProjection(points, sourceGeometry, false);
133
+ const target = endpointProjection(points, targetGeometry, true);
134
+ for (const projection of [source, target]) {
135
+ if (!projection) continue;
136
+ for (let index = projection.start; index !== projection.next; index += projection.step) copy[index] = { ...projection.point };
137
+ }
138
+ // Closely spaced/overlapping nodes must not reverse the first or last segment after docking.
139
+ for (const projection of [source, target]) {
140
+ if (!projection) continue;
141
+ const { start, next } = projection;
142
+ const before = { x: points[next].x - points[start].x, y: points[next].y - points[start].y };
143
+ const after = { x: copy[next].x - copy[start].x, y: copy[next].y - copy[start].y };
144
+ if (before.x * after.x + before.y * after.y <= EPSILON) return unchanged();
145
+ }
146
+ return { points: copy, sourceDocked: Boolean(source), targetDocked: Boolean(target) };
147
+ }
@@ -2,6 +2,7 @@ import { NODE_DEFINITIONS, PALETTE_GROUPS, PARALLEL_GATEWAY_PRESETS, edgeWaypoin
2
2
  import { createRuntimePresentation } from '../runtime/index.js';
3
3
  import { createDefaultIconRegistry, createIconElement, resolveNodeVisual } from '../icons/index.js';
4
4
  import { resolveNodeSubtitle, supportsNodeSubtitle } from '../node-presentation/index.js';
5
+ import { resolveNodeGeometry, resolvePresentationWaypoints } from '../node-geometry/index.js';
5
6
  import { ThemeController, applyRuntimeTone } from '../theme/index.js';
6
7
  import { exportDiagramSvg, openSvgExportPreview } from '../export-svg/index.js';
7
8
 
@@ -276,6 +277,26 @@ function nodeAccessibleLabel(title, subtitle) {
276
277
  return subtitle === null || subtitle === '' ? title : `${title}\n${subtitle}`;
277
278
  }
278
279
 
280
+ function applyNodeGeometry(shape, geometry) {
281
+ shape.classList.add('mb-node-scaled-shape');
282
+ shape.style.width = `${geometry.outline.width}px`;
283
+ shape.style.height = `${geometry.outline.height}px`;
284
+ shape.style.setProperty('--mb-node-shape-scale', String(geometry.scale));
285
+ shape.style.setProperty('--mb-node-shape-rotation', `${geometry.outline.rotation}rad`);
286
+ // CSS may quantize a 1.7px border to 1.5px; keep the document fold inside the outer outline.
287
+ if (geometry.kind === 'data') shape.style.clipPath = `inset(0 round ${geometry.outline.radiusX}px)`;
288
+ }
289
+
290
+ function geometryLabel(node, geometry) {
291
+ const label = el('div', 'mb-node-floating-label', node.name);
292
+ if (geometry) {
293
+ label.style.left = `${geometry.label.x - node.x}px`;
294
+ label.style.top = `${geometry.label.y - node.y}px`;
295
+ label.style.lineHeight = `${geometry.label.lineHeight}px`;
296
+ }
297
+ return label;
298
+ }
299
+
279
300
  function runtimeStatusIconId(presentation) {
280
301
  if (presentation?.status === 'rejected') return 'ui.statusRejected';
281
302
  if (presentation?.status === 'completed') return 'ui.statusCompleted';
@@ -364,17 +385,21 @@ export class DiagramRenderer {
364
385
  ['arrow-failed', 'var(--mb-danger)'],
365
386
  ];
366
387
  for (const [id] of markers) {
367
- const marker = svgEl('marker', { id, viewBox: '0 0 10 10', refX: '8.5', refY: '5', markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
368
- marker.appendChild(svgEl('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'context-stroke' }));
369
- defs.appendChild(marker);
388
+ for (const docked of [false, true]) {
389
+ const marker = svgEl('marker', { id: `${id}${docked ? '-docked' : ''}`, viewBox: '0 0 10 10', refX: docked ? '10' : '8.5', refY: '5', markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
390
+ marker.appendChild(svgEl('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'context-stroke' }));
391
+ defs.appendChild(marker);
392
+ }
370
393
  }
371
394
  this.edgeSvg.appendChild(defs);
372
395
 
373
396
  const runtimeDefs = svgEl('defs');
374
397
  for (const id of ['runtime-arrow-forward', 'runtime-arrow-reject', 'runtime-arrow-return', 'runtime-arrow-skip']) {
375
- const marker = svgEl('marker', { id, viewBox: '0 0 10 10', refX: '8.5', refY: '5', markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
376
- marker.appendChild(svgEl('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'context-stroke' }));
377
- runtimeDefs.appendChild(marker);
398
+ for (const docked of [false, true]) {
399
+ const marker = svgEl('marker', { id: `${id}${docked ? '-docked' : ''}`, viewBox: '0 0 10 10', refX: docked ? '10' : '8.5', refY: '5', markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', markerUnits: 'strokeWidth' });
400
+ marker.appendChild(svgEl('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: 'context-stroke' }));
401
+ runtimeDefs.appendChild(marker);
402
+ }
378
403
  }
379
404
  this.runtimeTransitionSvg.appendChild(runtimeDefs);
380
405
 
@@ -767,6 +792,7 @@ export class DiagramRenderer {
767
792
  ? presenter({ model: this.model, runtime: this.runtime, appearance: this.options.runtimeAppearance })
768
793
  : createRuntimePresentation({ model: this.model, runtime: null, appearance: this.options.runtimeAppearance });
769
794
  const subtitles = this._resolveDefinitionSubtitles();
795
+ this._nodeGeometries = new Map(this.model.nodes.map((node) => [node.id, resolveNodeGeometry(node, NODE_DEFINITIONS[node.type])]));
770
796
  this._syncSceneBounds();
771
797
  this._renderEdges();
772
798
  this._renderRuntimeTransitions();
@@ -832,8 +858,9 @@ export class DiagramRenderer {
832
858
  labelSize: { width: metrics.width, height: metrics.height },
833
859
  });
834
860
  if (!route) return;
835
- const points = route.points;
836
- occupiedRuntimeRoutes.push(points);
861
+ const display = resolvePresentationWaypoints(route.points, this._nodeGeometries.get(transition.sourceElementId), this._nodeGeometries.get(transition.targetElementId));
862
+ const points = display.points;
863
+ occupiedRuntimeRoutes.push(route.points);
837
864
  labelObstacles.push({
838
865
  x: route.labelPoint.x - metrics.width / 2,
839
866
  y: route.labelPoint.y - metrics.height / 2,
@@ -860,7 +887,7 @@ export class DiagramRenderer {
860
887
  d: pathData,
861
888
  class: 'mb-runtime-transition-path',
862
889
  fill: 'none',
863
- 'marker-end': `url(#runtime-arrow-${transition.type})`,
890
+ 'marker-end': `url(#runtime-arrow-${transition.type}${display.targetDocked ? '-docked' : ''})`,
864
891
  }));
865
892
  const labelX = route.labelPoint.x;
866
893
  const labelY = route.labelPoint.y;
@@ -900,9 +927,10 @@ export class DiagramRenderer {
900
927
  for (const edge of this.model.edges) {
901
928
  const points = edgeWaypoints(this.model, edge);
902
929
  if (points.length < 2) continue;
930
+ const display = resolvePresentationWaypoints(points, this._nodeGeometries.get(edge.source), this._nodeGeometries.get(edge.target));
903
931
  const corner = edge.cornerRadius ?? this.model.settings?.cornerRadius ?? 14;
904
932
  const routeStyle = edge.routeStyle || this.model.settings?.edgeStyle || 'rounded';
905
- const pathData = routePathData(points, routeStyle, corner);
933
+ const pathData = routePathData(display.points, routeStyle, corner);
906
934
 
907
935
  const edgeStatus = this.runtimePresentation.getEdge(edge.id).status;
908
936
  const selected = this.selection?.kind === 'edge' && this.selection.id === edge.id;
@@ -916,7 +944,7 @@ export class DiagramRenderer {
916
944
  class: 'mb-edge-path',
917
945
  fill: 'none',
918
946
  };
919
- if ((edge.type || 'sequenceFlow') === 'sequenceFlow') pathAttrs['marker-end'] = `url(#arrow${edgeStatus === 'idle' ? '' : `-${edgeStatus}`})`;
947
+ if ((edge.type || 'sequenceFlow') === 'sequenceFlow') pathAttrs['marker-end'] = `url(#arrow${edgeStatus === 'idle' ? '' : `-${edgeStatus}`}${display.targetDocked ? '-docked' : ''})`;
920
948
  const path = svgEl('path', pathAttrs);
921
949
  const hit = svgEl('path', { d: pathData, class: 'mb-edge-hit', fill: 'none' });
922
950
  let labelPosition = edgeLabelPoint(points);
@@ -1109,7 +1137,13 @@ export class DiagramRenderer {
1109
1137
  }
1110
1138
 
1111
1139
  if (def.kind === 'gateway') {
1140
+ const geometry = this._nodeGeometries.get(node.id);
1141
+ if (!geometry) {
1142
+ nodeEl.appendChild(geometryLabel(node, geometry));
1143
+ return;
1144
+ }
1112
1145
  const shape = el('div', 'mb-gateway-shape');
1146
+ applyNodeGeometry(shape, geometry);
1113
1147
  const symbol = el('span', 'mb-gateway-symbol');
1114
1148
  if (!this._renderCustomNodeContent(symbol, node, def, runtimeState, visual)) {
1115
1149
  const iconNode = visual.iconId
@@ -1131,23 +1165,19 @@ export class DiagramRenderer {
1131
1165
  nodeEl.appendChild(badge);
1132
1166
  }
1133
1167
  }
1134
- nodeEl.appendChild(el('div', 'mb-node-floating-label', node.name));
1168
+ nodeEl.appendChild(geometryLabel(node, geometry));
1135
1169
  return;
1136
1170
  }
1137
1171
 
1138
- if (def.kind === 'data') {
1139
- const page = el('div', 'mb-data-object-shape');
1140
- page.appendChild(el('span', 'mb-data-object-lines', '≡'));
1141
- nodeEl.appendChild(page);
1142
- nodeEl.appendChild(el('div', 'mb-node-floating-label', node.name));
1143
- return;
1144
- }
1145
-
1146
- if (def.kind === 'dataStore') {
1147
- const store = el('div', 'mb-data-store-shape');
1148
- store.appendChild(el('span', '', '≡'));
1149
- nodeEl.appendChild(store);
1150
- nodeEl.appendChild(el('div', 'mb-node-floating-label', node.name));
1172
+ if (def.kind === 'data' || def.kind === 'dataStore') {
1173
+ const geometry = this._nodeGeometries.get(node.id);
1174
+ if (geometry) {
1175
+ const shape = el('div', def.kind === 'data' ? 'mb-data-object-shape' : 'mb-data-store-shape');
1176
+ applyNodeGeometry(shape, geometry);
1177
+ shape.appendChild(el('span', def.kind === 'data' ? 'mb-data-object-lines' : '', '≡'));
1178
+ nodeEl.appendChild(shape);
1179
+ }
1180
+ nodeEl.appendChild(geometryLabel(node, geometry));
1151
1181
  return;
1152
1182
  }
1153
1183
 
@@ -1308,6 +1338,14 @@ export class DiagramRenderer {
1308
1338
 
1309
1339
  const appendPort = (side, { target = false, onClick } = {}) => {
1310
1340
  const port = el('button', `mb-port mb-port-${side}${target ? ' mb-port-target' : ''}`);
1341
+ const anchor = this._nodeGeometries.get(node.id)?.ports[side];
1342
+ if (anchor) {
1343
+ port.classList.add('mb-port-geometry');
1344
+ port.style.left = `${anchor.x - node.x}px`;
1345
+ port.style.top = `${anchor.y - node.y}px`;
1346
+ port.style.right = 'auto';
1347
+ port.style.bottom = 'auto';
1348
+ }
1311
1349
  port.type = 'button';
1312
1350
  port.title = target ? '连接到此节点' : (side === 'right' ? '创建连接' : '输入连接点');
1313
1351
  port.tabIndex = -1;
@@ -248,7 +248,7 @@ export class BpmnViewer {
248
248
  setModel(model: ProcessModel): void
249
249
  setRuntime(runtime: ProcessInstanceSnapshot | null): void
250
250
  refreshPresentation(): void
251
- setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: RuntimeAssetResolver | null }): void
251
+ setDisplayOptions(options: { timeline?: ViewerTimelineOptions; runtimeDetails?: RuntimeDetailsOptions; runtimeTraceOptions?: RuntimeTraceProjectionOptions; runtimeAssetResolver?: RuntimeAssetResolver | null; replace?: boolean }): void
252
252
  setProjection(projection: ViewerProjection): void
253
253
  setTheme(theme: NovaThemeInput): NovaThemeState
254
254
  setThemeMode(mode: NovaThemeMode): NovaThemeState