@litfamily/litmuse 0.1.1 → 0.1.2

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.
Files changed (168) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +20 -0
  3. package/README_ko-KR.md +20 -0
  4. package/bin/litmuse.mjs +4 -0
  5. package/lib/stage-card.mjs +180 -5
  6. package/package.json +1 -1
  7. package/plugin/.muse-plugin/plugin.json +26 -11
  8. package/plugin/commands/lit-status.md +2 -1
  9. package/plugin/commands/lit.md +1 -1
  10. package/plugin/hooks/lib/digest.mjs +1 -1
  11. package/plugin/hooks/lib/next-step.mjs +68 -0
  12. package/plugin/hooks/lib/routes.mjs +59 -0
  13. package/plugin/hooks/lib/workflow-stack.mjs +318 -0
  14. package/plugin/hooks/session-start.mjs +29 -2
  15. package/plugin/hooks/user-prompt-submit.mjs +138 -7
  16. package/plugin/lib/capped-fetch.mjs +25 -0
  17. package/plugin/lib/pinned-fetch.mjs +79 -0
  18. package/plugin/lib/plan-task-rows.mjs +32 -0
  19. package/plugin/lib/render_pages.py +29 -1
  20. package/plugin/lib/rewind/collect.mjs +679 -0
  21. package/plugin/lib/rewind/gauge.mjs +72 -0
  22. package/plugin/lib/rewind/next-step.mjs +91 -0
  23. package/plugin/lib/rewind/redact.mjs +83 -0
  24. package/plugin/lib/rewind/transcripts.mjs +227 -0
  25. package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
  26. package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  27. package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
  28. package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  29. package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  30. package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  31. package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  32. package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  33. package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  34. package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  35. package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  36. package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  37. package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  38. package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
  39. package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
  40. package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
  41. package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
  42. package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
  43. package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  44. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
  45. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
  46. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
  47. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
  48. package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
  49. package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  50. package/plugin/skills/lit-adhd/SKILL.md +188 -0
  51. package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
  52. package/plugin/skills/lit-adhd/references/status-taxonomy.md +43 -0
  53. package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
  54. package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
  55. package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
  56. package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
  57. package/plugin/skills/lit-docx/SKILL.md +1 -1
  58. package/plugin/skills/lit-hijack/SKILL.md +589 -0
  59. package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  60. package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
  61. package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  62. package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  63. package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
  64. package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
  65. package/plugin/skills/lit-pptx/SKILL.md +20 -9
  66. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  67. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  68. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  69. package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
  70. package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
  71. package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  72. package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  73. package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  74. package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  75. package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  76. package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
  77. package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
  78. package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
  79. package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
  80. package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
  81. package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
  82. package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
  83. package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  84. package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
  85. package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  86. package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
  87. package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  88. package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  89. package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
  90. package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  91. package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  92. package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  93. package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  94. package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  95. package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  96. package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  97. package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  98. package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  99. package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  100. package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  101. package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  102. package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  103. package/plugin/skills/lit-recap/SKILL.md +3 -0
  104. package/plugin/skills/lit-schematic/SKILL.md +241 -0
  105. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
  106. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
  107. package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
  108. package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
  109. package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
  110. package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
  111. package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
  112. package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
  113. package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
  114. package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
  115. package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
  116. package/plugin/skills/lit-schematic/references/verification.md +24 -0
  117. package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
  118. package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
  119. package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
  120. package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
  121. package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
  122. package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
  123. package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
  124. package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
  125. package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
  126. package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
  127. package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
  128. package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
  129. package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
  130. package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
  131. package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
  132. package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
  133. package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  134. package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
  135. package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
  136. package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
  137. package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
  138. package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
  139. package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
  140. package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
  141. package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
  142. package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
  143. package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
  144. package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
  145. package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
  146. package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
  147. package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
  148. package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
  149. package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
  150. package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
  151. package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  152. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  153. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  154. package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  155. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
  156. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  157. package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
  158. package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
  159. package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
  160. package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
  161. package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
  162. package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
  163. package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  164. package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  165. package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  166. package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  167. package/plugin/skills/readme-studio/SKILL.md +1 -1
  168. package/plugin/skills/start-work/SKILL.md +6 -0
@@ -0,0 +1,446 @@
1
+ import { el, text, doc, comment } from './svg.mjs';
2
+ import { WEIGHTS, material } from './theme.mjs';
3
+
4
+ // Wide A3-tall sheet (mm): border frame with zone ticks, an equipment band on top,
5
+ // the drawing field, a legend panel on the right, and a title block along the bottom.
6
+ // Every drawn symbol leaves its QA comment behind; the look is new, the contract is not.
7
+ const SHEET_W = 470;
8
+ const SHEET_H = 297;
9
+ const FRAME = 6;
10
+ const BAND_TOP = 8;
11
+ const BAND_BOTTOM = 36;
12
+ const FIELD_TOP = 40;
13
+ const FIELD_BOTTOM = 245;
14
+ const FIELD_RIGHT = 306;
15
+ const LEGEND_X = 340;
16
+ const TITLE_TOP = 249;
17
+ const INK = '#111';
18
+
19
+ function route(points) {
20
+ const path = [points[0]];
21
+ for (let i = 1; i < points.length; i++) {
22
+ const [px, py] = path[path.length - 1];
23
+ const [x, y] = points[i];
24
+ if (px !== x && py !== y) path.push([x, py]);
25
+ path.push([x, y]);
26
+ }
27
+ return path;
28
+ }
29
+
30
+ function poly(points) {
31
+ return points.map(([x, y]) => `${x},${y}`).join(' ');
32
+ }
33
+
34
+ const KIND_NAMES = {
35
+ tank: 'Tank',
36
+ pump: 'Dosing pump',
37
+ 'valve.gate.hand': 'Gate valve',
38
+ 'valve.gate.pneumatic': 'Gate valve',
39
+ 'valve.gate.motor': 'Gate valve',
40
+ 'valve.ball.hand': 'Ball valve',
41
+ 'valve.globe.pneumatic': 'Globe valve',
42
+ 'valve.check.auto': 'Check valve',
43
+ 'filter.strainer': 'Filter',
44
+ psv: 'Relief valve',
45
+ orifice: 'Orifice',
46
+ instrument: 'Field instr.',
47
+ 'instrument.dcs': 'DCS instr.',
48
+ offpage: 'Off-page',
49
+ drain: 'Drain',
50
+ vent: 'Vent',
51
+ };
52
+
53
+ const kindName = (kind) => KIND_NAMES[kind] ?? String(kind).replace(/\./g, ' ');
54
+
55
+ // First-letter/shift meanings for the instrument identification table.
56
+ const VARIABLE_WORDS = { F: 'flow', L: 'level', P: 'pressure', T: 'temperature', E: 'voltage', S: 'speed' };
57
+ const FUNCTION_WORDS = { I: 'indicating', C: 'controlling', T: 'transmitting', A: 'alarm', S: 'switching', R: 'recording', V: 'valve' };
58
+
59
+ const instrumentMeaning = (tag) => {
60
+ const letters = String(tag).split('-')[0] ?? '';
61
+ const words = [];
62
+ if (VARIABLE_WORDS[letters[0]]) words.push(VARIABLE_WORDS[letters[0]]);
63
+ for (const ch of letters.slice(1)) {
64
+ if (FUNCTION_WORDS[ch]) words.push(FUNCTION_WORDS[ch]);
65
+ }
66
+ return words.length ? words.join(' ') : 'instrument function';
67
+ };
68
+
69
+ export function createSheet(opts = {}) {
70
+ const tb = {
71
+ plant: opts.plant ?? 'PLANT', unit: opts.unit ?? 'UNIT',
72
+ sheetNo: opts.sheetNo ?? '00-000', rev: opts.rev ?? '0',
73
+ title: opts.title ?? `${opts.plant ?? 'PLANT'} ${opts.unit ?? 'UNIT'}`,
74
+ subTitle: opts.subTitle ?? 'Piping and instrumentation diagram',
75
+ drawingNo: opts.drawingNo ?? `${opts.plant ?? 'PLANT'}-${opts.sheetNo ?? '00-000'}`,
76
+ project: opts.project ?? 'Worked example', owner: opts.owner ?? 'lit-schematic',
77
+ drawn: opts.drawn ?? '-', date: opts.date ?? '2026-10-26', scale: opts.scale ?? 'NTS',
78
+ sheet: opts.sheet ?? '1 of 1',
79
+ };
80
+ const shapes = [];
81
+ const marks = [comment(`titleblock plant=${tb.plant} unit=${tb.unit} sheetNo=${tb.sheetNo} rev=${tb.rev}`)];
82
+ const kinds = new Set();
83
+ const lines = new Map();
84
+ const equipment = [];
85
+ const lineClasses = new Set();
86
+ const notes = [];
87
+ const instruments = new Map();
88
+
89
+ const frame = () => {
90
+ const out = [];
91
+ out.push(el('rect', { x: FRAME, y: FRAME, width: SHEET_W - FRAME * 2, height: SHEET_H - FRAME * 2, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.process }));
92
+ for (let i = 0; i < 8; i += 1) {
93
+ const x = FRAME + ((SHEET_W - FRAME * 2) * (i + 0.5)) / 8;
94
+ out.push(text(x, FRAME - 1, String(i + 1), { size: 6, anchor: 'middle' }));
95
+ out.push(text(x, SHEET_H - FRAME + 5, String(i + 1), { size: 6, anchor: 'middle' }));
96
+ }
97
+ const rows = 'ABCDEF';
98
+ for (let i = 0; i < 6; i += 1) {
99
+ const y = FRAME + ((SHEET_H - FRAME * 2) * (i + 0.5)) / 6;
100
+ out.push(text(FRAME - 2, y + 2, rows[i], { size: 6, anchor: 'middle' }));
101
+ out.push(text(SHEET_W - FRAME + 2, y + 2, rows[i], { size: 6, anchor: 'middle' }));
102
+ }
103
+ out.push(el('line', { x1: FRAME, y1: BAND_BOTTOM, x2: SHEET_W - FRAME, y2: BAND_BOTTOM, stroke: INK, 'stroke-width': WEIGHTS.utility }));
104
+ out.push(el('line', { x1: FRAME, y1: FIELD_TOP, x2: SHEET_W - FRAME, y2: FIELD_TOP, stroke: INK, 'stroke-width': WEIGHTS.utility }));
105
+ return out.join('\n');
106
+ };
107
+
108
+ const band = () => {
109
+ if (!equipment.length) return '';
110
+ const out = [text(12, BAND_TOP + 6, 'EQUIPMENT', { size: 7 })];
111
+ equipment.slice(0, 3).forEach(([tag, info], i) => {
112
+ out.push(text(12, BAND_TOP + 14 + i * 6, tag, { size: 6 }));
113
+ out.push(text(56, BAND_TOP + 14 + i * 6, info, { size: 6 }));
114
+ });
115
+ return out.join('\n');
116
+ };
117
+
118
+ const titleBlock = () => {
119
+ const out = [];
120
+ out.push(el('line', { x1: FRAME, y1: TITLE_TOP, x2: SHEET_W - FRAME, y2: TITLE_TOP, stroke: INK, 'stroke-width': WEIGHTS.process }));
121
+ for (const x of [150, 320]) out.push(el('line', { x1: x, y1: TITLE_TOP, x2: x, y2: SHEET_H - FRAME, stroke: INK, 'stroke-width': WEIGHTS.utility }));
122
+ out.push(text(10, TITLE_TOP + 8, 'NOTES', { size: 7 }));
123
+ notes.slice(0, 4).forEach((note, i) => {
124
+ out.push(text(10, TITLE_TOP + 18 + i * 7, `${i + 1}. ${note}`, { size: 6 }));
125
+ });
126
+ out.push(text(154, TITLE_TOP + 8, 'TITLE', { size: 6 }));
127
+ out.push(text(154, TITLE_TOP + 20, tb.title, { size: 10 }));
128
+ out.push(text(154, TITLE_TOP + 29, tb.subTitle, { size: 7 }));
129
+ out.push(text(154, TITLE_TOP + 38, `PROJECT ${tb.project} OWNER ${tb.owner}`, { size: 6 }));
130
+ out.push(text(324, TITLE_TOP + 8, 'DRAWING NO.', { size: 6 }));
131
+ out.push(text(324, TITLE_TOP + 18, tb.drawingNo, { size: 8 }));
132
+ out.push(text(324, TITLE_TOP + 28, `REV ${tb.rev} SHEET ${tb.sheet}`, { size: 6 }));
133
+ out.push(text(324, TITLE_TOP + 38, `DATE ${tb.date} SCALE ${tb.scale}`, { size: 6 }));
134
+ return out.join('\n');
135
+ };
136
+
137
+ // Glyph thumbnails for the legend panel, drawn small at (x, y).
138
+ const thumb = (kind, x, y) => {
139
+ const s = [];
140
+ if (kind === 'tank') {
141
+ s.push(el('rect', { x: x - 6, y: y - 6, width: 12, height: 12, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
142
+ s.push(el('path', { d: `M ${x - 6} ${y - 6} L ${x + 6} ${y - 6} L ${x} ${y - 10} Z`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
143
+ } else if (kind === 'pump') {
144
+ s.push(el('circle', { cx: x, cy: y, r: 6, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
145
+ s.push(el('path', { d: `M ${x - 3} ${y - 3} L ${x - 3} ${y + 3} L ${x + 3} ${y} Z`, fill: INK }));
146
+ } else if (kind.startsWith('valve.check')) {
147
+ s.push(el('path', { d: `M ${x - 5} ${y - 4} L ${x + 3} ${y} L ${x - 5} ${y + 4} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
148
+ s.push(el('line', { x1: x + 3, y1: y - 4, x2: x + 3, y2: y + 4, stroke: INK, 'stroke-width': WEIGHTS.utility }));
149
+ } else if (kind.startsWith('valve')) {
150
+ s.push(el('path', { d: `M ${x - 5} ${y - 4} L ${x} ${y} L ${x - 5} ${y + 4} M ${x + 5} ${y - 4} L ${x} ${y} L ${x + 5} ${y + 4}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
151
+ if (kind.includes('globe')) s.push(el('circle', { cx: x, cy: y, r: 2, fill: INK }));
152
+ } else if (kind.startsWith('filter')) {
153
+ s.push(el('path', { d: `M ${x} ${y - 6} L ${x + 6} ${y} L ${x} ${y + 6} L ${x - 6} ${y} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
154
+ } else if (kind === 'psv') {
155
+ s.push(el('path', { d: `M ${x - 4} ${y + 6} L ${x - 4} ${y - 2} L ${x - 1} ${y} L ${x + 2} ${y - 4} L ${x + 5} ${y} L ${x + 5} ${y - 6} L ${x + 9} ${y - 2}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
156
+ } else if (kind === 'orifice') {
157
+ s.push(el('line', { x1: x - 4, y1: y - 4, x2: x + 4, y2: y - 4, stroke: INK, 'stroke-width': WEIGHTS.process }));
158
+ s.push(el('line', { x1: x - 2, y1: y - 5, x2: x - 2, y2: y + 5, stroke: INK, 'stroke-width': WEIGHTS.process }));
159
+ s.push(el('line', { x1: x + 2, y1: y - 5, x2: x + 2, y2: y + 5, stroke: INK, 'stroke-width': WEIGHTS.process }));
160
+ } else if (kind === 'instrument.dcs') {
161
+ s.push(el('rect', { x: x - 5, y: y - 5, width: 10, height: 10, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
162
+ s.push(el('line', { x1: x - 5, y1: y, x2: x + 5, y2: y, stroke: INK, 'stroke-width': WEIGHTS.signal }));
163
+ } else if (kind === 'instrument') {
164
+ s.push(el('circle', { cx: x, cy: y, r: 5, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
165
+ } else if (kind === 'offpage') {
166
+ s.push(el('path', { d: `M ${x - 7} ${y - 4} L ${x + 3} ${y - 4} L ${x + 7} ${y} L ${x + 3} ${y + 4} L ${x - 7} ${y + 4} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
167
+ } else if (kind === 'drain') {
168
+ s.push(el('path', { d: `M ${x - 4} ${y - 4} L ${x + 4} ${y - 4} L ${x} ${y + 4} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
169
+ } else if (kind === 'vent') {
170
+ s.push(el('path', { d: `M ${x - 4} ${y + 4} L ${x - 4} ${y} A 4 4 0 0 1 ${x + 4} ${y} L ${x + 4} ${y + 4}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
171
+ } else {
172
+ s.push(el('rect', { x: x - 5, y: y - 4, width: 10, height: 8, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
173
+ }
174
+ return s.join('\n');
175
+ };
176
+
177
+ const legendPanel = () => {
178
+ const out = [el('line', { x1: LEGEND_X - 2, y1: FIELD_TOP, x2: LEGEND_X - 2, y2: TITLE_TOP, stroke: INK, 'stroke-width': WEIGHTS.utility })];
179
+ out.push(text(LEGEND_X, FIELD_TOP + 8, 'LEGEND', { size: 8 }));
180
+ let y = FIELD_TOP + 22;
181
+ // Two columns of 64: the widest thumbnail (relief zigzag, off-page hexagon)
182
+ // ends 12 past its origin, so labels start at +15 and the longest name still
183
+ // clears the next column's thumbnails.
184
+ for (const [index, kind] of [...kinds].sort().entries()) {
185
+ const col = index < Math.ceil([...kinds].length / 2) ? 0 : 1;
186
+ const yy = y + (index % Math.ceil([...kinds].length / 2)) * 11;
187
+ const xx = LEGEND_X + col * 64;
188
+ out.push(thumb(kind, xx + 3, yy - 2));
189
+ out.push(text(xx + 15, yy, kindName(kind), { size: 6 }));
190
+ }
191
+ y += Math.ceil([...kinds].length / 2) * 11 + 6;
192
+ const lineSample = (yy, weight, dash, name) => {
193
+ out.push(el('line', { x1: LEGEND_X, y1: yy - 2, x2: LEGEND_X + 14, y2: yy - 2, stroke: INK, 'stroke-width': weight, ...(dash ? { 'stroke-dasharray': dash } : {}) }));
194
+ out.push(text(LEGEND_X + 18, yy, name, { size: 6 }));
195
+ };
196
+ for (const [cls, weight, dash, name] of [['process', WEIGHTS.process, null, 'Main process line'], ['utility', WEIGHTS.utility, null, 'Utility line'], ['signal', WEIGHTS.signal, '6,2,1,2', 'Electric signal']]) {
197
+ if (!lineClasses.has(cls)) continue;
198
+ lineSample(y, weight, dash, name);
199
+ y += 12;
200
+ }
201
+ y += 6;
202
+ if (instruments.size) {
203
+ out.push(text(LEGEND_X, y + 2, 'INSTRUMENT', { size: 7 }));
204
+ y += 12;
205
+ for (const [letters, tag] of instruments) {
206
+ out.push(text(LEGEND_X, y, letters, { size: 6 }));
207
+ out.push(text(LEGEND_X + 20, y, instrumentMeaning(tag), { size: 6 }));
208
+ y += 8;
209
+ }
210
+ }
211
+ return out.join('\n');
212
+ };
213
+
214
+ const labelAt = (pts, number, at = 0.5) => {
215
+ let best = null;
216
+ for (let i = 1; i < pts.length; i += 1) {
217
+ const [x1, y1] = pts[i - 1];
218
+ const [x2, y2] = pts[i];
219
+ const len = Math.hypot(x2 - x1, y2 - y1);
220
+ if (!best || len > best.len) best = { x1, y1, x2, y2, len };
221
+ }
222
+ if (!best || best.len < 12) return '';
223
+ const x = best.x1 + (best.x2 - best.x1) * at;
224
+ const y = best.y1 + (best.y2 - best.y1) * at;
225
+ const vertical = best.x1 === best.x2;
226
+ return vertical
227
+ ? el('text', { x: x + 5, y, 'font-size': 6, 'font-family': 'Arial, sans-serif', fill: INK, transform: `rotate(-90 ${x + 5} ${y})`, 'data-clear': 'true' }, number)
228
+ : text(x, y - 4, number, { size: 6, anchor: 'middle' });
229
+ };
230
+
231
+ const api = {
232
+ add(kind, id, x, y, label) {
233
+ kinds.add(kind);
234
+ marks.push(comment(`symbol kind=${kind} id=${id}`));
235
+ if (kind === 'tank') return api.tank(id, x, y);
236
+ if (kind === 'pump') return api.pump(id, x, y);
237
+ shapes.push(el('rect', { x: x - 6, y: y - 4, width: 12, height: 8, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process }));
238
+ shapes.push(text(x, y + 14, label ?? id, { size: 7 }));
239
+ return id;
240
+ },
241
+ tank(tag, x, y) {
242
+ kinds.add('tank');
243
+ marks.push(comment(`symbol kind=tank id=${tag}`));
244
+ shapes.push(el('path', { d: `M ${x - 22} ${y - 26} L ${x + 22} ${y - 26} L ${x} ${y - 40} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process }));
245
+ shapes.push(el('rect', { x: x - 22, y: y - 26, width: 44, height: 52, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process }));
246
+ shapes.push(el('rect', { x: x - 3, y: y - 46, width: 6, height: 6, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
247
+ shapes.push(el('rect', { x: x - 3, y: y + 26, width: 6, height: 6, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
248
+ shapes.push(text(x + 10, y + 40, tag, { size: 7 }));
249
+ return tag;
250
+ },
251
+ pump(tag, x, y, side = 'below') {
252
+ kinds.add('pump');
253
+ marks.push(comment(`symbol kind=pump id=${tag}`));
254
+ shapes.push(el('circle', { cx: x, cy: y, r: 10, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process }));
255
+ shapes.push(el('path', { d: `M ${x - 5} ${y - 6} L ${x - 5} ${y + 6} L ${x + 6} ${y} Z`, fill: INK }));
256
+ shapes.push(el('rect', { x: x - 12, y: y + 10, width: 24, height: 4, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
257
+ // A vertical drop through the tag reads as a strike-through, so a stacked
258
+ // pump takes its tag beside the circle, a row below center so it also clears
259
+ // the suction-header labels; below stays the default elsewhere.
260
+ if (side === 'left') shapes.push(text(x - 14, y + 10, tag, { size: 7, anchor: 'end' }));
261
+ else if (side === 'right') shapes.push(text(x + 14, y + 10, tag, { size: 7, anchor: 'start' }));
262
+ else shapes.push(text(x, y + 26, tag, { size: 7, anchor: 'middle' }));
263
+ return tag;
264
+ },
265
+ strainer(id, x, y) {
266
+ kinds.add('filter.strainer');
267
+ marks.push(comment(`symbol kind=filter.strainer id=${id}`));
268
+ shapes.push(el('path', { d: `M ${x} ${y - 7} L ${x + 7} ${y} L ${x} ${y + 7} L ${x - 7} ${y} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process }));
269
+ shapes.push(text(x, y + 18, id, { size: 7, anchor: 'middle' }));
270
+ return id;
271
+ },
272
+ psv(tag, x, y, set) {
273
+ kinds.add('psv');
274
+ marks.push(comment(`symbol kind=psv id=${tag}`));
275
+ shapes.push(el('line', { x1: x, y1: y + 10, x2: x, y2: y - 4, stroke: INK, 'stroke-width': WEIGHTS.process }));
276
+ shapes.push(el('path', { d: `M ${x} ${y - 4} L ${x + 3} ${y - 1} L ${x - 3} ${y + 2} L ${x + 3} ${y + 5} L ${x - 3} ${y + 8}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
277
+ shapes.push(el('path', { d: `M ${x} ${y - 4} L ${x + 12} ${y - 4} L ${x + 12} ${y + 2} L ${x} ${y + 2}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.process }));
278
+ shapes.push(text(x + 14, y - 2, tag, { size: 6 }));
279
+ if (set) shapes.push(text(x + 14, y + 6, set, { size: 6 }));
280
+ return tag;
281
+ },
282
+ orifice(id, x, y) {
283
+ kinds.add('orifice');
284
+ marks.push(comment(`symbol kind=orifice id=${id}`));
285
+ shapes.push(el('line', { x1: x, y1: y - 9, x2: x, y2: y - 5, stroke: INK, 'stroke-width': WEIGHTS.process }));
286
+ shapes.push(el('line', { x1: x, y1: y + 5, x2: x, y2: y + 9, stroke: INK, 'stroke-width': WEIGHTS.process }));
287
+ shapes.push(el('line', { x1: x - 2, y1: y - 5, x2: x - 2, y2: y + 5, stroke: INK, 'stroke-width': WEIGHTS.process }));
288
+ shapes.push(el('line', { x1: x + 2, y1: y - 5, x2: x + 2, y2: y + 5, stroke: INK, 'stroke-width': WEIGHTS.process }));
289
+ shapes.push(text(x + 5, y + 2, id, { size: 6 }));
290
+ return id;
291
+ },
292
+ instrument(tag, x, y, opts2 = {}) {
293
+ const dcs = opts2.dcs === true;
294
+ const kind = dcs ? 'instrument.dcs' : 'instrument';
295
+ kinds.add(kind);
296
+ marks.push(comment(`symbol kind=${kind} id=${tag}`));
297
+ marks.push(comment(`tag ${tag}`));
298
+ const letters = String(tag).split('-')[0] ?? '';
299
+ const number = String(tag).split('-').slice(1).join('-');
300
+ if (!instruments.has(letters)) instruments.set(letters, tag);
301
+ const r = Math.max(9, 3.4 * Math.max(letters.length, number.length) + 3);
302
+ if (dcs) {
303
+ shapes.push(el('rect', { x: x - r, y: y - r, width: r * 2, height: r * 2, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
304
+ shapes.push(el('line', { x1: x - r, y1: y, x2: x + r, y2: y, stroke: INK, 'stroke-width': WEIGHTS.signal }));
305
+ } else {
306
+ shapes.push(el('circle', { cx: x, cy: y, r, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
307
+ }
308
+ shapes.push(text(x, y - 1, letters, { size: 6, anchor: 'middle' }));
309
+ shapes.push(text(x, y + 7, number, { size: 6, anchor: 'middle' }));
310
+ // A signal leaving the bubble at mid-height would strike a right-side tag,
311
+ // and a side tag can hit the next symbol; below or left keep it clear.
312
+ const place = opts2.tag ?? 'right';
313
+ if (place === 'below') shapes.push(text(x, y + r + 9, tag, { size: 6, anchor: 'middle' }));
314
+ else if (place === 'left') shapes.push(text(x - r - 3, y + 2, tag, { size: 6, anchor: 'end' }));
315
+ else shapes.push(text(x + r + 3, y + 2, tag, { size: 6 }));
316
+ return tag;
317
+ },
318
+ offpage(label, x, y, dir = 'out', w = 22) {
319
+ kinds.add('offpage');
320
+ marks.push(comment(`symbol kind=offpage id=${label}`));
321
+ // The connector always fits its text: a long destination name widens the box
322
+ // instead of overflowing into the legend. Estimate 3.7 units per character at
323
+ // 6 pt plus padding; an explicit wider box is kept.
324
+ w = Math.max(w, Math.ceil(String(label).length * 3.7) + 8);
325
+ const d = dir === 'in'
326
+ ? `M ${x + w} ${y - 6} L ${x + 6} ${y - 6} L ${x} ${y} L ${x + 6} ${y + 6} L ${x + w} ${y + 6} Z`
327
+ : `M ${x} ${y - 6} L ${x + w - 6} ${y - 6} L ${x + w} ${y} L ${x + w - 6} ${y + 6} L ${x} ${y + 6} Z`;
328
+ shapes.push(el('path', { d, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process }));
329
+ shapes.push(text(w > 30 ? x + w / 2 : dir === 'in' ? x + 11 : x + 2, y + 2, label, { size: 6, anchor: w > 30 || dir === 'in' ? 'middle' : 'start' }));
330
+ return label;
331
+ },
332
+ drain(x, y) {
333
+ kinds.add('drain');
334
+ marks.push(comment('symbol kind=drain id=DR'));
335
+ shapes.push(el('line', { x1: x, y1: y, x2: x, y2: y + 5, stroke: INK, 'stroke-width': WEIGHTS.utility }));
336
+ shapes.push(el('path', { d: `M ${x - 4} ${y + 5} L ${x + 4} ${y + 5} L ${x} ${y + 11} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
337
+ shapes.push(text(x + 6, y + 11, 'DR', { size: 6 }));
338
+ return 'DR';
339
+ },
340
+ vent(x, y) {
341
+ kinds.add('vent');
342
+ marks.push(comment('symbol kind=vent id=ATM'));
343
+ shapes.push(el('path', { d: `M ${x} ${y} L ${x} ${y - 8} A 5 5 0 0 1 ${x + 10} ${y - 8} L ${x + 10} ${y - 2}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
344
+ // The label sits left of the vent pipe so it never touches the arc.
345
+ shapes.push(text(x - 5, y - 1, 'ATM', { size: 6, anchor: 'end' }));
346
+ return 'ATM';
347
+ },
348
+ pipe(points, opts2 = {}) {
349
+ const id = `L${lines.size + 1}`;
350
+ const cls = opts2.cls ?? 'process';
351
+ marks.push(comment(`line class=${cls} w=${WEIGHTS[cls]}`));
352
+ lineClasses.add(cls);
353
+ if (opts2.number) marks.push(comment(`linenumber line=${opts2.number}`));
354
+ const routed = route(points);
355
+ shapes.push(el('polyline', { points: poly(routed), fill: 'none', stroke: INK, 'stroke-width': WEIGHTS[cls] }));
356
+ if (opts2.number) {
357
+ const label = labelAt(routed, opts2.number, opts2.at ?? 0.5);
358
+ if (label) shapes.push(label);
359
+ }
360
+ lines.set(id, points);
361
+ return id;
362
+ },
363
+ signal(points, kind = 'electric') {
364
+ const id = `S${lines.size + 1}`;
365
+ lineClasses.add('signal');
366
+ marks.push(comment(`line class=signal w=${WEIGHTS.signal} kind=${kind}`));
367
+ shapes.push(el('polyline', {
368
+ points: poly(route(points)), fill: 'none', stroke: INK,
369
+ 'stroke-width': WEIGHTS.signal, 'stroke-dasharray': kind === 'pneumatic' ? '3,1' : '6,2,1,2',
370
+ }));
371
+ lines.set(id, points);
372
+ return id;
373
+ },
374
+ inline(lineId, kind, id, at = 0.5) {
375
+ const pts = lines.get(lineId) ?? [[0, 0], [10, 0]];
376
+ const [x1, y1] = pts[0];
377
+ const [x2, y2] = pts[pts.length - 1];
378
+ return api.add(kind, id, x1 + (x2 - x1) * at, y1 + (y2 - y1) * at, id);
379
+ },
380
+ valve(body, actuator, id, x, y, fail = 'FC', orient = 'h') {
381
+ kinds.add(`valve.${body}.${actuator}`);
382
+ marks.push(comment(`symbol kind=valve.${body}.${actuator} id=${id} fail=${fail}`));
383
+ const v = orient === 'v';
384
+ const bow = v
385
+ ? `M ${x - 4} ${y - 5} L ${x} ${y} L ${x + 4} ${y - 5} M ${x - 4} ${y + 5} L ${x} ${y} L ${x + 4} ${y + 5}`
386
+ : `M ${x - 5} ${y - 4} L ${x} ${y} L ${x - 5} ${y + 4} M ${x + 5} ${y - 4} L ${x} ${y} L ${x + 5} ${y + 4}`;
387
+ if (body === 'check') {
388
+ shapes.push(el('path', { d: v ? `M ${x - 4} ${y - 5} L ${x} ${y + 2} L ${x + 4} ${y - 5}` : `M ${x - 5} ${y - 4} L ${x + 2} ${y} L ${x - 5} ${y + 4}`, fill: INK }));
389
+ shapes.push(el('line', v
390
+ ? { x1: x - 4, y1: y - 5, x2: x + 4, y2: y - 5, stroke: INK, 'stroke-width': WEIGHTS.process }
391
+ : { x1: x + 2, y1: y - 4, x2: x + 2, y2: y + 4, stroke: INK, 'stroke-width': WEIGHTS.process }));
392
+ } else {
393
+ shapes.push(el('path', { d: bow, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.process }));
394
+ // The globe reads apart from the gate by its filled seat dot, drawn large
395
+ // enough to survive at print scale; the gate stays an open bow-tie.
396
+ if (body === 'globe') shapes.push(el('circle', { cx: x, cy: y, r: 2.6, fill: INK }));
397
+ if (body === 'ball') shapes.push(el('circle', { cx: x, cy: y, r: 1.6, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
398
+ }
399
+ if (body !== 'check') {
400
+ if (actuator === 'hand') {
401
+ shapes.push(el('line', v ? { x1: x, y1: y, x2: x + 8, y2: y, stroke: INK, 'stroke-width': WEIGHTS.utility } : { x1: x, y1: y, x2: x, y2: y - 9, stroke: INK, 'stroke-width': WEIGHTS.utility }));
402
+ shapes.push(el('line', v ? { x1: x + 8, y1: y - 3, x2: x + 8, y2: y + 3, stroke: INK, 'stroke-width': WEIGHTS.utility } : { x1: x - 4, y1: y - 9, x2: x + 4, y2: y - 9, stroke: INK, 'stroke-width': WEIGHTS.utility }));
403
+ } else if (actuator === 'pneumatic') {
404
+ shapes.push(el('path', { d: v ? `M ${x} ${y} L ${x + 8} ${y} M ${x + 4} ${y} A 4 4 0 0 1 ${x + 12} ${y}` : `M ${x} ${y} L ${x} ${y - 6} M ${x - 4} ${y - 6} A 4 4 0 0 1 ${x + 4} ${y - 6}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
405
+ } else if (actuator === 'motor') {
406
+ shapes.push(el('circle', { cx: v ? x + 9 : x, cy: v ? y : y - 10, r: 3.4, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
407
+ shapes.push(text(v ? x + 9 : x, (v ? y : y - 10) + 2, 'M', { size: 6, anchor: 'middle' }));
408
+ }
409
+ }
410
+ if (body !== 'check' || fail !== 'FC') shapes.push(text(x + 8, y - 6, fail, { size: 6 }));
411
+ shapes.push(text(v ? x : x - 6, y + (v ? 16 : 14), id, { size: 7, anchor: 'middle' }));
412
+ return id;
413
+ },
414
+ arrow(x, y, clear = true) {
415
+ marks.push(comment(`arrow at=${x},${y} clear=${clear ? 'true' : 'false'}`));
416
+ shapes.push(el('path', { d: `M ${x - 4} ${y - 2} L ${x} ${y} L ${x - 4} ${y + 2}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.process }));
417
+ },
418
+ hop(x, y, r = 1.5) {
419
+ marks.push(comment(`hop r=${r}`));
420
+ shapes.push(el('path', { d: `M ${x - r} ${y} A ${r} ${r} 0 0 1 ${x + r} ${y}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
421
+ },
422
+ callout(partText, x, y, spacing = 1.4) {
423
+ marks.push(comment(`leader spacing=${spacing} part=${partText}`));
424
+ shapes.push(el('circle', { cx: x, cy: y, r: 0.9, fill: INK }));
425
+ shapes.push(text(x + 4, y + 2, partText, { size: 6 }));
426
+ },
427
+ equipmentInfo(tag, info) {
428
+ equipment.push([tag, info]);
429
+ return tag;
430
+ },
431
+ notes(list) {
432
+ for (const item of list ?? []) notes.push(String(item));
433
+ return notes.length;
434
+ },
435
+ legend() {
436
+ for (const kind of [...kinds].sort()) marks.push(comment(`legend kind=${kind}`));
437
+ return kinds.size;
438
+ },
439
+ toSVG() {
440
+ return doc({ width: SHEET_W, height: SHEET_H, title: `${tb.plant} ${tb.unit} ${tb.sheetNo}` }, `${marks.join('\n')}\n${frame()}\n${band()}\n${shapes.join('\n')}\n${legendPanel()}\n${titleBlock()}`);
441
+ },
442
+ };
443
+ return api;
444
+ }
445
+
446
+ export { material };
@@ -0,0 +1,39 @@
1
+ export function esc(s) {
2
+ return String(s)
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;');
7
+ }
8
+
9
+ export function attrs(o) {
10
+ return Object.entries(o)
11
+ .filter(([, v]) => v !== undefined && v !== null)
12
+ .map(([k, v]) => ` ${k}="${esc(v)}"`)
13
+ .join('');
14
+ }
15
+
16
+ export function el(name, attributes = {}, children = '') {
17
+ const a = attrs(attributes);
18
+ if (children === '' || children === null) return `<${name}${a}/>`;
19
+ return `<${name}${a}>${children}</${name}>`;
20
+ }
21
+
22
+ export function text(x, y, s, opts = {}) {
23
+ return el('text', {
24
+ x, y, 'font-size': opts.size ?? 7, 'font-family': opts.font ?? 'Arial, sans-serif',
25
+ fill: opts.fill ?? '#111', 'text-anchor': opts.anchor ?? 'start',
26
+ 'data-clear': opts.clear === false ? undefined : 'true',
27
+ }, esc(s));
28
+ }
29
+
30
+ export function doc({ width = 420, height = 297, title = 'drawing' }, body) {
31
+ const ground = `<rect x="0" y="0" width="${width}" height="${height}" fill="#ffffff"/>`;
32
+ return `<?xml version="1.0" encoding="UTF-8"?>\n` +
33
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}mm" height="${height}mm" ` +
34
+ `viewBox="0 0 ${width} ${height}" data-sheet="A3" data-title="${esc(title)}">\n${ground}\n${body}\n</svg>\n`;
35
+ }
36
+
37
+ export function comment(s) {
38
+ return `<!-- ${s} -->`;
39
+ }
@@ -0,0 +1,25 @@
1
+ export const WEIGHTS = {
2
+ process: 0.7,
3
+ utility: 0.5,
4
+ signal: 0.35,
5
+ leader: 0.18,
6
+ };
7
+
8
+ export const ALLOWED_WIDTHS = new Set([0.7, 0.5, 0.35, 0.18]);
9
+
10
+ export const MATERIAL = {
11
+ 'painted-steel': { fill: '#5b7fa6', stroke: '#2e4a62' },
12
+ stainless: { fill: '#b9c2c9', stroke: '#6d757c' },
13
+ copper: { fill: '#c47b4a', stroke: '#7d4a24' },
14
+ brass: { fill: '#c9a227', stroke: '#7a5f14' },
15
+ 'cast-iron': { fill: '#55565a', stroke: '#2b2b2e' },
16
+ concrete: { fill: '#c9c5bb', stroke: '#8a867c' },
17
+ glass: { fill: '#cfe3ea', stroke: '#7fa3b0' },
18
+ quiet: { fill: '#9aa0a6', stroke: '#5f6368' },
19
+ };
20
+
21
+ export const STREAMS = ['#0072b2', '#d55e00', '#009e73', '#cc79a7', '#f0e442', '#56b4e9'];
22
+
23
+ export function material(name) {
24
+ return MATERIAL[name] ?? MATERIAL['painted-steel'];
25
+ }
@@ -0,0 +1,8 @@
1
+ import { findOnPath } from '../../../lib/host-process.mjs';
2
+
3
+ const args = process.argv.slice(2);
4
+ if (!findOnPath('ffmpeg') || !findOnPath('img2webp')) {
5
+ console.log('ENCODER_REQUIRED: install ffmpeg and libwebp (brew install ffmpeg webp), then rerun');
6
+ process.exit(14);
7
+ }
8
+ console.log(`motion loop source: ${args[0] ?? '(missing scene)'} -> ${args[args.indexOf('--out') + 1] ?? '(missing out)'}`);
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { runQA, CODES } from './lib/qa-core.mjs';
3
+
4
+ const args = process.argv.slice(2);
5
+ if (args.includes('--codes')) {
6
+ console.log(CODES.join('\n'));
7
+ process.exit(0);
8
+ }
9
+ const file = args.find((a) => !a.startsWith('-'));
10
+ if (!file) {
11
+ console.error('usage: qa.mjs drawing.svg [--png f.png --pdf f.pdf]');
12
+ process.exit(2);
13
+ }
14
+ const svg = readFileSync(file, 'utf8');
15
+ const { pass, fails } = runQA(svg);
16
+ if (pass) {
17
+ console.log('QA PASS');
18
+ } else {
19
+ for (const f of fails) console.log(`QA FAIL ${f.code}: ${f.msg}`);
20
+ process.exit(1);
21
+ }
@@ -0,0 +1,38 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { firstLine } from '../../../lib/host-process.mjs';
3
+
4
+ const args = process.argv.slice(2);
5
+ const file = args[0];
6
+ const pngI = args.indexOf('--png');
7
+ const pdfI = args.indexOf('--pdf');
8
+ const dpiI = args.indexOf('--dpi');
9
+ const png = pngI >= 0 ? args[pngI + 1] : null;
10
+ const pdf = pdfI >= 0 ? args[pdfI + 1] : null;
11
+ const dpi = dpiI >= 0 ? args[dpiI + 1] : '600';
12
+ if (!file) {
13
+ console.error('usage: render.mjs drawing.svg --png out.png --dpi 600 --pdf out.pdf');
14
+ process.exit(2);
15
+ }
16
+ const has = (bin) => spawnSync('which', [bin]).status === 0;
17
+ function run(bin, binArgs, label) {
18
+ const r = spawnSync(bin, binArgs, { encoding: 'utf8' });
19
+ if (r.status !== 0) {
20
+ console.log(`RENDER_INCOMPLETE ${label}: ${firstLine(r.stderr || r.stdout)}`);
21
+ process.exit(3);
22
+ }
23
+ console.log(`rendered ${label} at ${dpi} dpi`);
24
+ }
25
+ if (png) {
26
+ if (has('rsvg-convert')) run('rsvg-convert', ['-d', `${dpi}`, '-p', `${dpi}`, '-o', png, file], png);
27
+ else {
28
+ console.log(`RENDER_SKIP ${png}: rasterizer missing on this machine`);
29
+ process.exit(0);
30
+ }
31
+ }
32
+ if (pdf) {
33
+ if (has('rsvg-convert')) run('rsvg-convert', ['-f', 'pdf', '-o', pdf, file], pdf);
34
+ else {
35
+ console.log(`RENDER_SKIP ${pdf}: rasterizer missing on this machine`);
36
+ process.exit(0);
37
+ }
38
+ }
@@ -0,0 +1,73 @@
1
+ import { writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const KIT = join(dirname(fileURLToPath(import.meta.url)), 'lib');
6
+
7
+ function pidBuild() {
8
+ return `import { writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { createSheet } from '${join(KIT, 'sheet.mjs')}';
11
+
12
+ const out = process.argv[2] ?? '.';
13
+ const sheet = createSheet({ plant: 'DEMO', unit: 'DOSING', sheetNo: '01-001', rev: '0' });
14
+ sheet.equipmentInfo('TK-101', 'Dosing tank, 2 m3');
15
+ sheet.add('tank', 'TK-101', 80, 120, 'TK-101');
16
+ sheet.add('pump', 'P-101', 180, 120, 'P-101');
17
+ sheet.pipe([[80, 120], [180, 120]], { cls: 'process', number: '2-P-101' });
18
+ sheet.valve('gate', 'hand', 'XV-101', 130, 120, 'FC');
19
+ sheet.instrument('FIC-101', 130, 80);
20
+ sheet.signal([[130, 84], [130, 116]]);
21
+ sheet.arrow(155, 120, true);
22
+ sheet.callout('TK-101', 80, 112);
23
+ sheet.legend();
24
+ writeFileSync(join(out, 'drawing.svg'), sheet.toSVG());
25
+ console.log('wrote drawing.svg');
26
+ `;
27
+ }
28
+
29
+ function journalBuild() {
30
+ return `import { writeFileSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+ import { createFigure } from '${join(KIT, 'figure.mjs')}';
33
+
34
+ const out = process.argv[2] ?? '.';
35
+ const fig = createFigure({ width: 85 });
36
+ const panel = fig.canvas('a');
37
+ panel.box(10, 20, 40, 30, 'cell');
38
+ panel.stream('sample in', 0, 35, 10, 35);
39
+ panel.stream('waste', 50, 35, 75, 35);
40
+ panel.label('(a) Adsorption rig', 10, 12);
41
+ panel.commit();
42
+ fig.key(['sample in', 'waste'], 10, 60);
43
+ writeFileSync(join(out, 'drawing.svg'), fig.toSVG());
44
+ console.log('wrote drawing.svg');
45
+ `;
46
+ }
47
+
48
+ function isoBuild() {
49
+ return `import { writeFileSync } from 'node:fs';
50
+ import { join } from 'node:path';
51
+ import { createIsoView } from '${join(KIT, 'iso.mjs')}';
52
+
53
+ const out = process.argv[2] ?? '.';
54
+ const view = createIsoView({ plant: 'SHOP', sheetNo: 'ISO-001' });
55
+ view.box(60, 8, 40, [0, 0, 0], 'concrete');
56
+ view.vessel('E-101', 30, 20, 12, 40, 'stainless');
57
+ view.pipe([[30, 20, 44], [30, 20, 60], [60, 20, 60]], 1, 'painted-steel');
58
+ view.callout('shell', 30, 20);
59
+ view.callout('saddle', 8, 8);
60
+ writeFileSync(join(out, 'drawing.svg'), view.toSVG());
61
+ console.log('wrote drawing.svg');
62
+ `;
63
+ }
64
+
65
+ const TEMPLATES = { pid: pidBuild, journal: journalBuild, iso: isoBuild };
66
+ const [mode, dir] = process.argv.slice(2);
67
+ if (!TEMPLATES[mode] || !dir) {
68
+ console.error('usage: scaffold.mjs pid|journal|iso <dir>');
69
+ process.exit(2);
70
+ }
71
+ mkdirSync(dir, { recursive: true });
72
+ writeFileSync(join(dir, 'build.mjs'), TEMPLATES[mode]());
73
+ console.log(`scaffolded ${mode} build in ${dir}`);