Skip to content

Commit 3c368f0

Browse files
committed
Neptune_Render: add multilayer DXF zip export and legacy JSON load support.
1 parent 786dc0d commit 3c368f0

10 files changed

Lines changed: 775 additions & 101 deletions

File tree

scripts/migrate-paper-designs.mjs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Migrates 3DuF-Paper-Designs JSON files to the current interchange format.
3+
* Run: node scripts/migrate-paper-designs.mjs
4+
*/
5+
6+
import fs from "fs";
7+
import path from "path";
8+
import { fileURLToPath } from "url";
9+
10+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
11+
const DESIGNS_DIR = path.join(__dirname, "..", "3DuF-Paper-Designs");
12+
13+
const LAYER_NAME_TO_TYPE = {
14+
flow: "FLOW",
15+
control: "CONTROL",
16+
integration: "INTEGRATION",
17+
cells: "INTEGRATION"
18+
};
19+
20+
function componentRefToId(component) {
21+
if (component == null) return null;
22+
if (typeof component === "string") return component;
23+
if (typeof component === "object") return component.__id || component.id || null;
24+
return null;
25+
}
26+
27+
function normalizeDxfEntry(entry) {
28+
if (!entry || typeof entry !== "object") return entry;
29+
if (Object.prototype.hasOwnProperty.call(entry, "__rootObject")) {
30+
const root = entry.__rootObject || {};
31+
return { ...root, type: entry.__type || root.type };
32+
}
33+
return entry;
34+
}
35+
36+
function normalizeFeatureParams(params) {
37+
if (!params || !Object.prototype.hasOwnProperty.call(params, "orientation")) return;
38+
params.rotation = params.orientation === "V" ? 0 : 270;
39+
delete params.orientation;
40+
}
41+
42+
function normalizeFeatureEntry(feature) {
43+
if (!feature || typeof feature !== "object") return;
44+
if (!feature.macro && feature.type) feature.macro = feature.type;
45+
normalizeFeatureParams(feature.params);
46+
if (Array.isArray(feature.dxfData)) {
47+
feature.dxfData = feature.dxfData.map(normalizeDxfEntry);
48+
}
49+
}
50+
51+
function normalizeFeatureMap(features) {
52+
if (!features) return;
53+
if (Array.isArray(features)) {
54+
for (const feature of features) normalizeFeatureEntry(feature);
55+
return;
56+
}
57+
for (const key in features) normalizeFeatureEntry(features[key]);
58+
}
59+
60+
function migrateJson(json) {
61+
if (!json.params) json.params = { width: 135000, length: 85000 };
62+
if (!Array.isArray(json.components)) json.components = [];
63+
if (!Array.isArray(json.connections)) json.connections = [];
64+
if (!Array.isArray(json.valves)) json.valves = [];
65+
66+
if (!json.layers && Array.isArray(json.features) && json.features[0]?.features) {
67+
json.layers = json.features.map((layer, index) => {
68+
const layerName = String(layer.name || `layer-${index}`).toLowerCase();
69+
return {
70+
id: layer.id || `${layerName}-layer-${index}`,
71+
name: layer.name || layerName,
72+
type: layer.type || LAYER_NAME_TO_TYPE[layerName] || "FLOW",
73+
group: layer.group || "0",
74+
params: layer.params || { z_offset: 0, flip: false },
75+
features: layer.features || {}
76+
};
77+
});
78+
delete json.features;
79+
}
80+
81+
if (json.layers) {
82+
const groups = new Set(json.layers.map((layer) => String(layer.group ?? "0")));
83+
for (const group of groups) {
84+
const groupLayers = json.layers.filter((layer) => String(layer.group ?? "0") === group);
85+
if (!groupLayers.some((layer) => layer.type === "INTEGRATION")) {
86+
json.layers.push({
87+
id: `integration-layer-${group}`,
88+
name: "integration",
89+
type: "INTEGRATION",
90+
group,
91+
params: { z_offset: 0, flip: false },
92+
features: {}
93+
});
94+
}
95+
}
96+
for (const layer of json.layers) normalizeFeatureMap(layer.features);
97+
}
98+
99+
for (const component of json.components) {
100+
if (component.entity === "TEST MINT") component.entity = "PUMP";
101+
normalizeFeatureParams(component.params);
102+
if (!Array.isArray(component.ports)) component.ports = [];
103+
}
104+
105+
const defaultLayer = json.layers?.find((layer) => layer.type === "FLOW") || json.layers?.[0];
106+
for (const connection of json.connections) {
107+
if (!connection.layer && defaultLayer) connection.layer = defaultLayer.id;
108+
if (connection.source) {
109+
const sourceId = componentRefToId(connection.source.component);
110+
if (sourceId) connection.source.component = sourceId;
111+
}
112+
if (Array.isArray(connection.sinks)) {
113+
for (const sink of connection.sinks) {
114+
const sinkId = componentRefToId(sink.component);
115+
if (sinkId) sink.component = sinkId;
116+
}
117+
}
118+
}
119+
120+
return json;
121+
}
122+
123+
const files = fs.readdirSync(DESIGNS_DIR).filter((name) => name.endsWith(".json"));
124+
for (const file of files) {
125+
const fullPath = path.join(DESIGNS_DIR, file);
126+
const original = fs.readFileSync(fullPath, "utf8");
127+
const migrated = migrateJson(JSON.parse(original));
128+
const output = JSON.stringify(migrated);
129+
fs.writeFileSync(fullPath, output);
130+
const layerCount = migrated.layers?.length || 0;
131+
const featureCount = (migrated.layers || []).reduce(
132+
(sum, layer) => sum + Object.keys(layer.features || {}).length,
133+
0
134+
);
135+
console.log(`${file}: layers=${layerCount} features=${featureCount}`);
136+
}
137+
138+
console.log(`Migrated ${files.length} design files.`);
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Validates multilayer DXF export naming/content against the flow+control demo JSON.
3+
* Run: node scripts/validate-multilayer-dxf-export.js
4+
*/
5+
6+
const fs = require("fs");
7+
const path = require("path");
8+
9+
const DEMO = path.join(
10+
__dirname,
11+
"../../Neptune_2026/Microfluidics-Benchmarks/Results/Quick_Examples/flow_and_control_demo/flow_and_control_demo_fromLFR_PR.json"
12+
);
13+
14+
const UM_TO_MM = 0.001;
15+
16+
function dxfPair(code, value) {
17+
return `${code}\n${value}\n`;
18+
}
19+
20+
function writeHeader() {
21+
let out = "0\nSECTION\n2\nHEADER\n";
22+
out += dxfPair(9, "$ACADVER");
23+
out += dxfPair(1, "AC1015");
24+
out += dxfPair(9, "$INSUNITS");
25+
out += dxfPair(70, 4);
26+
out += "0\nENDSEC\n";
27+
return out;
28+
}
29+
30+
function wrapEntities(entities) {
31+
return writeHeader() + "0\nSECTION\n2\nENTITIES\n" + entities + "0\nENDSEC\n0\nEOF\n";
32+
}
33+
34+
function writeLine(a, b, layer, z = 0) {
35+
let out = "0\nLINE\n";
36+
out += dxfPair(8, layer);
37+
out += dxfPair(10, a.x);
38+
out += dxfPair(20, a.y);
39+
out += dxfPair(30, z);
40+
out += dxfPair(11, b.x);
41+
out += dxfPair(21, b.y);
42+
out += dxfPair(31, z);
43+
return out;
44+
}
45+
46+
function writeCircle(center, radius, layer) {
47+
let out = "0\nCIRCLE\n";
48+
out += dxfPair(8, layer);
49+
out += dxfPair(10, center.x);
50+
out += dxfPair(20, center.y);
51+
out += dxfPair(30, 0);
52+
out += dxfPair(40, radius);
53+
return out;
54+
}
55+
56+
function canvasUmToDxfMm(xUm, yUm, deviceHeightUm) {
57+
return { x: xUm * UM_TO_MM, y: (deviceHeightUm - yUm) * UM_TO_MM };
58+
}
59+
60+
function isMultilayerBiochip(layers) {
61+
let count = 0;
62+
for (const layer of layers) {
63+
if (layer.type === "FLOW" || layer.type === "CONTROL") {
64+
count += 1;
65+
if (count > 1) return true;
66+
}
67+
}
68+
return false;
69+
}
70+
71+
function buildMockDevice(json) {
72+
const heightUm = json.params["y-span"];
73+
const layers = json.layers.map(l => ({
74+
id: l.id,
75+
name: l.name,
76+
type: l.type,
77+
features: []
78+
}));
79+
const byId = Object.fromEntries(layers.map(l => [l.id, l]));
80+
81+
for (const comp of json.components || []) {
82+
const layer = byId[comp.layers[0]];
83+
if (!layer) continue;
84+
const entity = (comp.entity || "").toUpperCase();
85+
if (entity === "PORT") {
86+
layer.features.push({
87+
type: "Port",
88+
position: comp.params.position,
89+
portRadius: comp.params.portRadius
90+
});
91+
} else if (entity === "VALVE3D" || entity === "VALVE" || entity === "CIRCLE VALVE") {
92+
layer.features.push({
93+
type: entity === "VALVE3D" ? "Valve3D_control" : "CircleValve",
94+
position: comp.params.position,
95+
valveRadius: comp.params.valveRadius || comp.params.portRadius || 400
96+
});
97+
}
98+
// Mixer etc. are complex; DXF export currently focuses on ports/connections/valves.
99+
}
100+
101+
for (const conn of json.connections || []) {
102+
const layer = byId[conn.layer];
103+
if (!layer) continue;
104+
layer.features.push({
105+
type: "Connection",
106+
segments: conn.params.segments,
107+
height: conn.params.height || 250
108+
});
109+
}
110+
111+
return { name: json.name, heightUm, layers };
112+
}
113+
114+
function exportLayer(device, layer) {
115+
let entities = "";
116+
const layerName = layer.name || layer.type;
117+
for (const feature of layer.features) {
118+
if (feature.type === "Port" || feature.type === "Valve3D_control" || feature.type === "CircleValve") {
119+
const radius = feature.portRadius || feature.valveRadius;
120+
if (!feature.position || !radius) continue;
121+
const pt = canvasUmToDxfMm(feature.position[0], feature.position[1], device.heightUm);
122+
const suffix = feature.type === "Port" ? "_ports" : "_valves";
123+
entities += writeCircle(pt, radius * UM_TO_MM, layerName + suffix);
124+
} else if (feature.type === "Connection") {
125+
for (const seg of feature.segments || []) {
126+
const a = canvasUmToDxfMm(seg[0][0], seg[0][1], device.heightUm);
127+
const b = canvasUmToDxfMm(seg[1][0], seg[1][1], device.heightUm);
128+
entities += writeLine(a, b, layerName + "_channels", (feature.height || 250) * UM_TO_MM);
129+
}
130+
}
131+
}
132+
return wrapEntities(entities);
133+
}
134+
135+
function generateFiles(device) {
136+
const exportLayers = device.layers.filter(l => l.type === "FLOW" || l.type === "CONTROL");
137+
if (exportLayers.length <= 1) {
138+
return [{ filename: `${device.name}.dxf`, content: exportLayer(device, exportLayers[0]) }];
139+
}
140+
const files = [];
141+
let flowIndex = 0;
142+
let controlIndex = 0;
143+
for (const layer of exportLayers) {
144+
if (layer.type === "FLOW") flowIndex += 1;
145+
else controlIndex += 1;
146+
const suffix = layer.type === "CONTROL" ? `_ctrl${controlIndex}` : `_flow${flowIndex}`;
147+
files.push({
148+
filename: `${device.name}${suffix}.dxf`,
149+
content: exportLayer(device, layer)
150+
});
151+
}
152+
return files;
153+
}
154+
155+
function countEntities(dxfText, type) {
156+
const re = new RegExp(`^0\\n${type}\\n`, "gm");
157+
return (dxfText.match(re) || []).length;
158+
}
159+
160+
function main() {
161+
const json = JSON.parse(fs.readFileSync(DEMO, "utf8"));
162+
const device = buildMockDevice(json);
163+
const multilayer = isMultilayerBiochip(device.layers);
164+
const files = generateFiles(device);
165+
166+
console.log("Device:", device.name);
167+
console.log("Layers:", device.layers.map(l => `${l.name}(${l.type}) features=${l.features.length}`).join(", "));
168+
console.log("isMultilayerBiochip:", multilayer);
169+
console.log("Expected GCode blocked:", multilayer === true);
170+
console.log("DXF files:");
171+
for (const f of files) {
172+
const lines = countEntities(f.content, "LINE");
173+
const circles = countEntities(f.content, "CIRCLE");
174+
console.log(` ${f.filename}: LINE=${lines}, CIRCLE=${circles}, bytes=${f.content.length}`);
175+
}
176+
177+
const names = files.map(f => f.filename);
178+
const expect = [`${device.name}_flow1.dxf`, `${device.name}_ctrl1.dxf`];
179+
const okNames = expect.every(n => names.includes(n)) && names.length === 2;
180+
const flow = files.find(f => f.filename.endsWith("_flow1.dxf"));
181+
const ctrl = files.find(f => f.filename.endsWith("_ctrl1.dxf"));
182+
const flowOk = flow && countEntities(flow.content, "LINE") > 0 && countEntities(flow.content, "CIRCLE") > 0;
183+
const ctrlOk = ctrl && countEntities(ctrl.content, "LINE") > 0 && countEntities(ctrl.content, "CIRCLE") > 0;
184+
185+
if (!okNames || !flowOk || !ctrlOk || !multilayer) {
186+
console.error("FAIL");
187+
process.exit(1);
188+
}
189+
console.log("PASS: multilayer DXF naming and non-empty flow/control geometry");
190+
}
191+
192+
main();

src/app/core/connectionTarget.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,17 @@ export default class ConnectionTarget {
6262
* @memberof ConnectionTarget
6363
*/
6464
static fromJSON(device: Device, json: ConnectionTargetInterchangeV1): ConnectionTarget {
65-
const component = device.getComponentByID(json.component);
65+
let componentId: string | null = null;
66+
const rawComponent: any = json.component;
67+
if (typeof rawComponent === "string") {
68+
componentId = rawComponent;
69+
} else if (rawComponent && typeof rawComponent === "object") {
70+
componentId = rawComponent.__id || rawComponent.id || null;
71+
}
72+
if (componentId === null) {
73+
throw new Error("Component not found");
74+
}
75+
const component = device.getComponentByID(componentId);
6676
if (component !== null) return new ConnectionTarget(component, json.port);
6777
else throw new Error("Component not found");
6878
}

src/app/core/dxfObject.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ export default class DXFObject {
7171
* @memberof DXFObject
7272
*/
7373
static fromJSON(json: JSON): DXFObject {
74+
const raw: any = json;
75+
if (raw && typeof raw === "object" && Object.prototype.hasOwnProperty.call(raw, "__rootObject")) {
76+
const root = raw.__rootObject || {};
77+
return new DXFObject({
78+
...root,
79+
type: raw.__type || root.type
80+
});
81+
}
7482
return new DXFObject(json);
7583
}
7684
}

0 commit comments

Comments
 (0)