Setting the file. One moment. Industrial Mesh · Dreambase Industrial Schematics · DreambaseAI/skills · Skills Docs — line 120
This file
- Number
- 4.3
- Position
- 3 of 5
- Type
- JavaScript
- Size
- 16 KB
- Lines
- 372
scripts/industrial-mesh.mjs
JavaScript·372 lines·16 KB
;
14
15const DEFAULTS = {
16 width: 1600,
17 height: 1100,
18 cx: null, // default width/2
19 cy: null, // default height*0.48
20 outerR: 420,
21 innerR: 200, // 0 = pie instead of donut
22 depth: 110, // extrusion height; 0 = flat 2D mesh
23 rotate: { x: 62, y: 0, z: -14 }, // degrees, applied Rx -> Ry -> Rz
24 perspective: 1600, // camera distance in px; 0 = orthographic
25 segments: 5, // count, or [{ share, label, sub, explode }]
26 gapDeg: 2.5, // angular gap between segments
27 fill: "hybrid", // wire (transparent) | hybrid (dark translucent + mesh) | cel (flat shaded)
28 fillOpacity: 0.88, // used by hybrid
29 theme: "phosphor", // phosphor | ember | mono | { hue: "#4ADE80" }
30 grid: { radial: 6, rings: 1, zRings: 1 }, // mesh density (degrees / counts)
31 bg: null, // default from theme; "transparent" to skip
32 glow: true,
33 grain: 0.04,
34};
35
36// ---------------------------------------------------------------- color
37
38function hexToHsl(hex) {
39 const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
40 if (!m) fail(`bad hex color "${hex}"`);
41 const n = parseInt(m[1], 16);
42 const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
43 const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
44 let h = 0;
45 if (d) {
46 if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
47 else if (max === g) h = ((b - r) / d + 2) * 60;
48 else h = ((r - g) / d + 4) * 60;
49 }
50 const l = (max + min) / 2;
51 const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
52 return { h, s, l };
53}
54
55function hslToHex(h, s, l) {
56 const f = (n) => {
57 const k = (n + h / 30) % 12;
58 const c = l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1));
59 return Math.round(c * 255).toString(16).padStart(2, "0");
60 };
61 return `#${f(0)}${f(8)}${f(4)}`;
62}
63
64// Everything in a scene derives from ONE hue — lightness/saturation vary, hue never does.
65function deriveTheme(hueHex) {
66 const { h } = hexToHsl(hueHex);
67 return {
68 line: hslToHex(h, 0.75, 0.62),
69 lineDim: hslToHex(h, 0.4, 0.4),
70 rim: hslToHex(h, 0.8, 0.82),
71 fillShadow: hslToHex(h, 0.3, 0.05),
72 fillMid: hslToHex(h, 0.32, 0.09),
73 fillLight: hslToHex(h, 0.3, 0.14),
74 bg: hslToHex(h, 0.3, 0.025),
75 };
76}
77
78const THEMES = {
79 phosphor: deriveTheme("#4ADE80"), // terminal-green hologram
80 ember: {
81 // family-B neutral steels with warm peach rim (reference machinery palette)
82 line: "#8F8C88", lineDim: "#55524F", rim: "#F6D9BC",
83 fillShadow: "#262422", fillMid: "#35312E", fillLight: "#423D39", bg: "#141210",
84 },
85 mono: {
86 line: "#F2F0ED", lineDim: "#8F8C88", rim: "#FFFFFF",
87 fillShadow: "#101010", fillMid: "#1A1A1A", fillLight: "#242424", bg: "#0B0B0C",
88 },
89};
90
91// ----------------------------------------------------------------- math
92
93function fail(msg) {
94 process.stderr.write(`industrial-mesh: ${msg}\n`);
95 process.exit(1);
96}
97
98const rad = (d) => (d * Math.PI) / 180;
99const fmt = (n) => (Math.round(n * 100) / 100).toString();
100const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
101
102// Screen-style frame: x right, y down, z toward the viewer.
103function rotXYZ(p, r) {
104 let { x, y, z } = p;
105 let c = Math.cos(rad(r.x)), s = Math.sin(rad(r.x));
106 [y, z] = [y * c - z * s, y * s + z * c];
107 c = Math.cos(rad(r.y)); s = Math.sin(rad(r.y));
108 [x, z] = [x * c + z * s, -x * s + z * c];
109 c = Math.cos(rad(r.z)); s = Math.sin(rad(r.z));
110 [x, y] = [x * c - y * s, x * s + y * c];
111 return { x, y, z };
112}
113
114const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
115function norm(v) {
116 const l = Math.hypot(v.x, v.y, v.z) || 1;
117 return { x: v.x / l, y: v.y / l, z: v.z / l };
118}
119
120function makeProjector(cfg) {
121 return (p) => {
122 const q = rotXYZ(p, cfg.rotate);
123 const f = cfg.perspective > 0 ? cfg.perspective / (cfg.perspective - q.z) : 1;
124 return { x: cfg.cx + q.x * f, y: cfg.cy + q.y * f, z: q.z };
125 };
126}
127
128// Object-space point on the donut: angle 0 = 12 o'clock, clockwise; z=0 is
129// the top surface, z=-depth the bottom. ex/ey = explode offset in-plane.
130function pt(angleDeg, r, z, ex = 0, ey = 0) {
131 const a = rad(angleDeg - 90);
132 return { x: r * Math.cos(a) + ex, y: r * Math.sin(a) + ey, z };
133}
134
135// ----------------------------------------------------------------- build
136
137function renderDonut(raw) {
138 const cfg = { ...DEFAULTS, ...raw };
139 cfg.rotate = { ...DEFAULTS.rotate, ...(raw.rotate || {}) };
140 cfg.grid = { ...DEFAULTS.grid, ...(raw.grid || {}) };
141 cfg.cx = cfg.cx ?? cfg.width / 2;
142 cfg.cy = cfg.cy ?? cfg.height * 0.48;
143 const T =
144 typeof cfg.theme === "string"
145 ? THEMES[cfg.theme] || fail(`unknown theme "${cfg.theme}" (phosphor|ember|mono or {hue})`)
146 : deriveTheme(cfg.theme.hue);
147 const bg = cfg.bg ?? T.bg;
148 const P = makeProjector(cfg);
149 const rotDir = (v) => rotXYZ(v, cfg.rotate); // rotate a direction (normals)
150 const L = norm({ x: -0.45, y: -0.6, z: 0.66 }); // light: upper-left, toward viewer
151 const isPie = cfg.innerR <= 1;
152 const flat = cfg.depth <= 0;
153 const opaque = cfg.fill === "cel";
154 const wire = cfg.fill !== "cel"; // wire + hybrid both draw the full mesh
155
156 // segments -> [{a0, a1, label, sub, ex, ey}]
157 const rawSegs =
158 typeof cfg.segments === "number"
159 ? Array.from({ length: cfg.segments }, () => ({ share: 1 / cfg.segments }))
160 : cfg.segments;
161 const total = rawSegs.reduce((s, x) => s + (x.share ?? 1 / rawSegs.length), 0);
162 let cursor = 0;
163 const segs = rawSegs.map((s) => {
164 const sweep = ((s.share ?? 1 / rawSegs.length) / total) * 360;
165 const a0 = cursor + cfg.gapDeg / 2, a1 = cursor + sweep - cfg.gapDeg / 2;
166 cursor += sweep;
167 const mid = rad((a0 + a1) / 2 - 90), ex = (s.explode || 0) * Math.cos(mid), ey = (s.explode || 0) * Math.sin(mid);
168 return { ...s, a0, a1, ex, ey };
169 });
170
171 const faces = []; // { pts:[{x,y,z}...], n, zAvg }
172 const lines = []; // { pts, weight, base, zAvg, color }
173 const step = Math.max(2, cfg.grid.radial);
174
175 const arc = (r, z, a0, a1, ex, ey) => {
176 const out = [];
177 const n = Math.max(3, Math.ceil((a1 - a0) / step));
178 for (let i = 0; i <= n; i++) out.push(P(pt(a0 + ((a1 - a0) * i) / n, r, z, ex, ey)));
179 return out;
180 };
181 const zAvg = (pts) => pts.reduce((s, p) => s + p.z, 0) / pts.length;
182 const pushLine = (pts, weight, base, color) => lines.push({ pts, weight, base, color, zAvg: zAvg(pts) });
183 const pushFace = (pts, n) => faces.push({ pts, n, zAvg: zAvg(pts) });
184
185 for (const s of segs) {
186 const { a0, a1, ex, ey } = s;
187 const topN = rotDir({ x: 0, y: 0, z: 1 });
188
189 // --- faces
190 const topPts = [...arc(cfg.outerR, 0, a0, a1, ex, ey), ...(isPie ? [P(pt(0, 0, 0, ex, ey))] : arc(cfg.innerR, 0, a1, a0, ex, ey))];
191 pushFace(topPts, topN);
192 if (!flat) {
193 pushFace([...arc(cfg.outerR, -cfg.depth, a0, a1, ex, ey), ...(isPie ? [P(pt(0, 0, -cfg.depth, ex, ey))] : arc(cfg.innerR, -cfg.depth, a1, a0, ex, ey))], { x: -topN.x, y: -topN.y, z: -topN.z });
194 const nSlices = Math.max(3, Math.ceil((a1 - a0) / step));
195 for (const [r, inward] of isPie ? [[cfg.outerR, false]] : [[cfg.outerR, false], [cfg.innerR, true]]) {
196 for (let i = 0; i < nSlices; i++) {
197 const b0 = a0 + ((a1 - a0) * i) / nSlices, b1 = a0 + ((a1 - a0) * (i + 1)) / nSlices;
198 const m = rad((b0 + b1) / 2 - 90);
199 const nrm = rotDir({ x: Math.cos(m) * (inward ? -1 : 1), y: Math.sin(m) * (inward ? -1 : 1), z: 0 });
200 const quad = [P(pt(b0, r, 0, ex, ey)), P(pt(b1, r, 0, ex, ey)), P(pt(b1, r, -cfg.depth, ex, ey)), P(pt(b0, r, -cfg.depth, ex, ey))];
201 quad.n = nrm;
202 pushFace(quad, nrm);
203 // rim light: lit outer-wall quads get a bright top edge (cel/hybrid)
204 if (!inward && cfg.fill !== "wire" && dot(nrm, L) > 0.55)
205 pushLine([quad[0], quad[1]], 1.4, 1, T.rim);
206 }
207 }
208 for (const [a, sign] of [[a0, -1], [a1, 1]]) {
209 const t = rad(a - 90);
210 const nrm = rotDir({ x: -Math.sin(t) * -sign, y: Math.cos(t) * -sign, z: 0 });
211 pushFace([P(pt(a, isPie ? 0 : cfg.innerR, 0, ex, ey)), P(pt(a, cfg.outerR, 0, ex, ey)), P(pt(a, cfg.outerR, -cfg.depth, ex, ey)), P(pt(a, isPie ? 0 : cfg.innerR, -cfg.depth, ex, ey))], nrm);
212 }
213 }
214
215 // --- mesh lines (wire + hybrid; cel keeps only a faint top outline)
216 if (wire) {
217 pushLine(arc(cfg.outerR, 0, a0, a1, ex, ey), 1.4, 1, T.line);
218 if (!isPie) pushLine(arc(cfg.innerR, 0, a0, a1, ex, ey), 1.4, 0.9, T.line);
219 if (!flat) {
220 pushLine(arc(cfg.outerR, -cfg.depth, a0, a1, ex, ey), 1, 0.55, T.line);
221 if (!isPie) pushLine(arc(cfg.innerR, -cfg.depth, a0, a1, ex, ey), 1, 0.5, T.line);
222 }
223 for (const a of [a0, a1]) {
224 const rIn = isPie ? 0 : cfg.innerR;
225 pushLine([P(pt(a, rIn, 0, ex, ey)), P(pt(a, cfg.outerR, 0, ex, ey))], 1.2, 0.9, T.line);
226 if (!flat) {
227 pushLine([P(pt(a, cfg.outerR, 0, ex, ey)), P(pt(a, cfg.outerR, -cfg.depth, ex, ey))], 1.2, 0.8, T.line);
228 if (!isPie) pushLine([P(pt(a, rIn, 0, ex, ey)), P(pt(a, rIn, -cfg.depth, ex, ey))], 1.2, 0.7, T.line);
229 }
230 }
231 // interior grid — dimmer than structural edges
232 for (let a = a0 + step; a < a1; a += step) {
233 pushLine([P(pt(a, isPie ? 0 : cfg.innerR, 0, ex, ey)), P(pt(a, cfg.outerR, 0, ex, ey))], 0.6, 0.3, T.line);
234 if (!flat) {
235 pushLine([P(pt(a, cfg.outerR, 0, ex, ey)), P(pt(a, cfg.outerR, -cfg.depth, ex, ey))], 0.6, 0.3, T.line);
236 if (!isPie) pushLine([P(pt(a, cfg.innerR, 0, ex, ey)), P(pt(a, cfg.innerR, -cfg.depth, ex, ey))], 0.6, 0.25, T.line);
237 }
238 }
239 for (let i = 1; i <= cfg.grid.rings; i++) {
240 const r = cfg.innerR + ((cfg.outerR - cfg.innerR) * i) / (cfg.grid.rings + 1);
241 pushLine(arc(r, 0, a0, a1, ex, ey), 0.6, 0.3, T.line);
242 }
243 if (!flat)
244 for (let i = 1; i <= cfg.grid.zRings; i++) {
245 const z = (-cfg.depth * i) / (cfg.grid.zRings + 1);
246 pushLine(arc(cfg.outerR, z, a0, a1, ex, ey), 0.6, 0.3, T.line);
247 if (!isPie) pushLine(arc(cfg.innerR, z, a0, a1, ex, ey), 0.6, 0.25, T.line);
248 }
249 } else {
250 pushLine(arc(cfg.outerR, 0, a0, a1, ex, ey), 0.8, 0.35, T.line);
251 }
252 }
253
254 // --- assemble svg
255 faces.sort((a, b) => a.zAvg - b.zAvg); // painter: far first
256 const zs = lines.map((l) => l.zAvg);
257 const zMin = Math.min(...zs, 0), zMax = Math.max(...zs, 1);
258 const depthOpa = (z) => 0.35 + 0.65 * ((z - zMin) / (zMax - zMin || 1));
259
260 const faceSvg = faces
261 .filter((f) => !(opaque && f.n.z <= 0)) // backface cull only when fully opaque
262 .map((f) => {
263 const b = dot(f.n, L);
264 const fill = cfg.fill === "wire" ? "none" : b > 0.5 ? T.fillLight : b > 0.12 ? T.fillMid : T.fillShadow;
265 if (fill === "none") return "";
266 const o = opaque ? 1 : f.n.z > 0 ? cfg.fillOpacity : cfg.fillOpacity * 0.55;
267 const d = `M ${f.pts.map((p) => `${fmt(p.x)} ${fmt(p.y)}`).join(" L ")} Z`;
268 return `<path d="${d}" fill="${fill}" opacity="${fmt(o)}"/>`;
269 })
270 .join("\n ");
271
272 const lineSvg = lines
273 .sort((a, b) => a.zAvg - b.zAvg)
274 .map((l) => {
275 const d = `M ${l.pts.map((p) => `${fmt(p.x)} ${fmt(p.y)}`).join(" L ")}`;
276 return `<path d="${d}" fill="none" stroke="${l.color}" stroke-width="${l.weight}" opacity="${fmt(l.base * depthOpa(l.zAvg))}" stroke-linecap="round"/>`;
277 })
278 .join("\n ");
279
280 // --- callout leaders
281 const callouts = [];
282 for (const s of segs) {
283 if (!s.label) continue;
284 const aMid = (s.a0 + s.a1) / 2;
285 const a = P(pt(aMid, cfg.outerR, 0, s.ex, s.ey));
286 const right = a.x >= cfg.cx;
287 const dx = right ? 1 : -1;
288 const dy = a.y >= cfg.cy ? 55 : -55; // route away from the object, never across it
289 const e1 = { x: a.x + 70 * dx, y: a.y + dy };
290 const e2 = { x: e1.x + 130 * dx, y: e1.y };
291 callouts.push(`<!-- callout: ${esc(s.label)} -->
292 <circle cx="${fmt(a.x)}" cy="${fmt(a.y)}" r="3" fill="${T.line}" filter="url(#glow)"/>
293 <circle cx="${fmt(a.x)}" cy="${fmt(a.y)}" r="6.5" fill="none" stroke="${T.line}" stroke-width="1" opacity="0.6"/>
294 <path d="M ${fmt(a.x)} ${fmt(a.y)} L ${fmt(e1.x)} ${fmt(e1.y)} L ${fmt(e2.x)} ${fmt(e2.y)}" fill="none" stroke="${T.line}" stroke-width="1" opacity="0.7"/>
295 <text x="${fmt(right ? e1.x + 8 : e1.x - 8)}" y="${fmt(e1.y - 8)}" fill="${T.line}" font-family="${MONO}" font-size="13" letter-spacing="2.4" text-anchor="${right ? "start" : "end"}">${esc(String(s.label).toUpperCase())}</text>
296 ${s.sub ? `<text x="${fmt(right ? e1.x + 8 : e1.x - 8)}" y="${fmt(e1.y + 18)}" fill="${T.lineDim}" font-family="${MONO}" font-size="11" letter-spacing="2" text-anchor="${right ? "start" : "end"}">${esc(String(s.sub).toUpperCase())}</text>` : ""}`);
297 }
298
299 return `<svg xmlns="http://www.w3.org/2000/svg" width="${cfg.width}" height="${cfg.height}" viewBox="0 0 ${cfg.width} ${cfg.height}" role="img">
300 <defs>
301 <filter id="bloom" x="-30%" y="-30%" width="160%" height="160%">
302 <feGaussianBlur stdDeviation="1.6" result="b"/>
303 <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
304 </filter>
305 <filter id="glow" x="-300%" y="-300%" width="700%" height="700%">
306 <feGaussianBlur stdDeviation="4" result="b1"/>
307 <feMerge><feMergeNode in="b1"/><feMergeNode in="SourceGraphic"/></feMerge>
308 </filter>
309 <filter id="grain">
310 <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/>
311 <feColorMatrix type="saturate" values="0"/>
312 <feComponentTransfer><feFuncA type="linear" slope="${cfg.grain}" intercept="0"/></feComponentTransfer>
313 </filter>
314 </defs>
315 ${bg !== "transparent" ? `<rect width="100%" height="100%" fill="${bg}"/>` : ""}
316 <!-- faces (painter-sorted, far to near) -->
317 <g>
318 ${faceSvg}
319 </g>
320 <!-- mesh lines${cfg.glow ? " (phosphor bloom)" : ""} -->
321 <g${cfg.glow ? ` filter="url(#bloom)"` : ""}>
322 ${lineSvg}
323 </g>
324 <!-- callouts -->
325 <g>
326 ${callouts.join("\n ")}
327 </g>
328 ${cfg.grain ? `<rect width="100%" height="100%" filter="url(#grain)"/>` : ""}
329</svg>`;
330}
331
332// ------------------------------------------------------------------- cli
333
334const HELP = `industrial-mesh.mjs — holographic 2D/3D mesh SVG scaffolds
335
336 node industrial-mesh.mjs donut <config.json|'{json}'> [-o out.svg]
337
338donut config (defaults shown):
339${JSON.stringify(DEFAULTS, null, 2)}
340
341 segments: 5 -> equal unlabeled segments
342 segments: [{ "share": 0.178, "label": "Segment 01", "sub": "17.8%", "explode": 60 }, ...]
343 innerR: 0 -> pie | depth: 0 -> flat 2D mesh
344 rotate: euler degrees applied Rx -> Ry -> Rz (x tilts away, z spins in-plane)
345 perspective: camera distance px (0 = orthographic)
346 fill: "wire" (transparent) | "hybrid" (dark translucent + mesh) | "cel" (flat shaded)
347 theme: "phosphor" | "ember" | "mono" | { "hue": "#4ADE80" }
348`;
349
350const argv = process.argv.slice(2);
351if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
352 process.stdout.write(HELP);
353 process.exit(0);
354}
355if (argv[0] !== "donut") fail(`unknown mode "${argv[0]}" (expected: donut)`);
356let outPath = null;
357const oi = argv.indexOf("-o");
358if (oi !== -1) outPath = argv[oi + 1] || fail("-o requires a path");
359const cfgArg = argv[1] && argv[1] !== "-o" ? argv[1] : "{}";
360let cfg;
361try {
362 cfg = cfgArg.trim().startsWith("{") ? JSON.parse(cfgArg) : JSON.parse(readFileSync(cfgArg, "utf8"));
363} catch (e) {
364 fail(`could not parse config: ${e.message}`);
365}
366const svg = renderDonut(cfg);
367if (outPath) {
368 writeFileSync(outPath, svg);
369 process.stderr.write(`wrote ${outPath}\n`);
370} else {
371 process.stdout.write(svg);
372}