Files
workinf_Blender_Wasm/tools/web/check-nonmesh-roundtrip.mjs
2026-08-14 18:08:29 -04:00

340 lines
24 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const fixture = fs.readFileSync(new URL("tests/files/web/nonmesh_scene.blend", root));
function output(engine, handle, fn, owned = false) {
const dataOut = engine._malloc(4);
const lengthOut = engine._malloc(4);
try {
assert.equal(fn(handle, dataOut, lengthOut), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2];
const length = engine.HEAPU32[lengthOut >>> 2];
const bytes = engine.HEAPU8.slice(pointer, pointer + length);
if (owned) engine._web_engine_free_buffer(pointer);
return bytes;
}
finally {
engine._free(dataOut);
engine._free(lengthOut);
}
}
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally { engine._free(pointer); }
}
function command(engine, handle, payload) {
const bytes = new TextEncoder().encode(JSON.stringify(payload));
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_apply_command(handle, pointer, bytes.byteLength), 0,
`${payload.type}: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
}
finally { engine._free(pointer); }
}
function reject(engine, handle, payload, code) {
const bytes = new TextEncoder().encode(JSON.stringify(payload));
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.notEqual(engine._web_engine_apply_command(handle, pointer, bytes.byteLength), 0, `${payload.type} unexpectedly succeeded`);
assert.match(engine.UTF8ToString(engine._web_engine_last_error_message()), new RegExp(code));
}
finally { engine._free(pointer); }
}
function snapshot(engine, handle) {
return JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_get_scene_snapshot)));
}
function save(engine, handle) {
return output(engine, handle, engine._web_engine_save_blend, true);
}
function depsgraph(engine, handle) {
return JSON.parse(new TextDecoder().decode(output(engine, handle, engine._web_engine_evaluate_depsgraph)));
}
function close(actual, expected, label) {
assert.ok(Math.abs(actual - expected) < 1e-5, `${label}: ${actual} != ${expected}`);
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const before = snapshot(engine, handle);
const curve = before.nonMeshData.find((data) => data.type === "CURVE");
const surface = before.nonMeshData.find((data) => data.type === "SURFACE");
const font = before.nonMeshData.find((data) => data.type === "FONT");
const metaball = before.nonMeshData.find((data) => data.type === "METABALL");
const volume = before.nonMeshData.find((data) => data.type === "VOLUME");
assert.ok(curve?.controlPoints?.length && curve.splineOffsets?.length);
assert.ok(surface?.controlPoints?.length && surface.splineOffsets?.length);
assert.deepEqual(surface.splineDimensions, [{ u: 4, v: 4, orderU: 4, orderV: 4 }]);
assert.equal(surface.pointWeights?.length, 16);
assert.ok(font?.text && metaball?.elements?.length);
assert.ok(volume?.sourcePath && volume?.volumeProperties);
assert.equal(before.vfonts?.length, 2);
assert.ok(before.vfonts.every((resource) => resource.packed && resource.id.startsWith("vfont:")));
assert.ok(font.fontLinks);
assert.deepEqual(curve.splineTypes, ["POLY", "BEZIER"]);
assert.equal(curve.cyclicU?.length, curve.splineCount);
assert.equal(curve.handleTypes?.length, 6);
assert.equal(curve.handlePoints?.length, 18);
assert.deepEqual(curve.handlePointIndices, [4, 5, 6]);
const volumeProperties = {
displayDensity: 1.75,
interpolation: "NEAREST",
stepSize: 0.125,
velocityGrid: "velocity",
velocityScale: 1.5,
};
const volumeSourcePath = "//assets/volumes/generated-smoke.vdb";
reject(engine, handle, { type: "setVolumeProperties", dataId: volume.id, ...volumeProperties, sourcePath: "../outside.vdb" }, "NON_MESH_PROPERTY_INVALID");
command(engine, handle, { type: "setVolumeProperties", dataId: volume.id, ...volumeProperties, sourcePath: volumeSourcePath });
let changedVolume = snapshot(engine, handle).nonMeshData.find((data) => data.id === volume.id);
assert.equal(changedVolume.sourcePath, volumeSourcePath);
assert.deepEqual(changedVolume.volumeProperties, volumeProperties);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
changedVolume = snapshot(engine, handle).nonMeshData.find((data) => data.id === volume.id);
assert.equal(changedVolume.sourcePath, volume.sourcePath);
assert.deepEqual(changedVolume.volumeProperties, volume.volumeProperties);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
changedVolume = snapshot(engine, handle).nonMeshData.find((data) => data.id === volume.id);
assert.deepEqual(changedVolume.volumeProperties, volumeProperties);
command(engine, handle, { type: "renameId", id: curve.id, name: "WebCurveRenamed" });
const renamedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.type === "CURVE" && data.name === "WebCurveRenamed");
assert.equal(renamedCurve?.id, "curve:WebCurveRenamed");
const curveId = renamedCurve.id;
command(engine, handle, { type: "createCurve", curveType: "CURVE", name: "CreatedWebCurve", controlPoints: [-1, 0, 0, 0, 1, 0, 1, 0, 0], cyclic: true, resolution: 8 });
const createdCurve = snapshot(engine, handle).nonMeshData.find((data) => data.name === "CreatedWebCurve");
assert.equal(createdCurve?.type, "CURVE");
assert.equal(createdCurve?.controlPoints?.length, 9);
assert.deepEqual(createdCurve?.cyclicU, [true]);
command(engine, handle, { type: "setCurveTopology", dataId: createdCurve.id, splineTypes: ["NURBS"], cyclicU: [true], cyclicV: [false] });
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id)?.splineTypes, ["NURBS"]);
command(engine, handle, { type: "setCurveTopology", dataId: createdCurve.id, splineTypes: ["POLY"], cyclicU: [true], cyclicV: [false] });
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id)?.splineTypes, ["POLY"]);
command(engine, handle, { type: "setCurveTopology", dataId: createdCurve.id, splineTypes: ["BEZIER"], cyclicU: [true], cyclicV: [false] });
const bezierCreatedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id);
assert.deepEqual(bezierCreatedCurve?.splineTypes, ["BEZIER"]);
assert.equal(bezierCreatedCurve?.handleTypes?.length, 6);
command(engine, handle, { type: "setCurveTopology", dataId: createdCurve.id, splineTypes: ["NURBS"], cyclicU: [true], cyclicV: [false] });
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id)?.splineTypes, ["NURBS"]);
const threeSplineTypes = ["POLY", "BEZIER", "NURBS"];
const threeSplineOffsets = [0, 3, 6, 10];
const threeSplinePoints = [
-2, 0, 0, -1.5, 0.5, 0, -1, 0, 0,
-0.5, 0, 0, 0, 0.75, 0, 0.5, 0, 0,
1, 0, 0, 1.5, 0.5, 0, 2, 0.5, 0, 2.5, 0, 0,
];
const threeSplineWeights = Array(10).fill(1);
threeSplineWeights[8] = 0.8;
const threeSplineHandles = [
-0.75, 0, 0, -0.25, 0, 0,
-0.25, 0.75, 0, 0.25, 0.75, 0,
0.25, 0, 0, 0.75, 0, 0,
];
const threeSplineCommand = {
type: "setCurveSplines", dataId: createdCurve.id, splineTypes: threeSplineTypes,
splineOffsets: threeSplineOffsets, ordersU: [0, 0, 4], controlPoints: threeSplinePoints,
pointWeights: threeSplineWeights, cyclicU: [false, true, false],
handleTypes: [0, 0, 1, 1, 2, 2], handlePoints: threeSplineHandles,
};
command(engine, handle, threeSplineCommand);
let transactedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id);
assert.deepEqual(transactedCurve.splineTypes, threeSplineTypes);
assert.deepEqual(transactedCurve.splineOffsets, threeSplineOffsets);
assert.deepEqual(transactedCurve.cyclicU, [false, true, false]);
assert.equal(transactedCurve.pointCount, 10);
assert.equal(transactedCurve.handlePoints.length, 18);
close(transactedCurve.pointWeights[8], 0.8, "multi-spline NURBS weight");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineTypes, ["NURBS"]);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineTypes, threeSplineTypes);
const invalidSplineCommand = new TextEncoder().encode(JSON.stringify({ ...threeSplineCommand, splineOffsets: [1, 4, 7, 11] }));
const invalidSplinePointer = engine._malloc(invalidSplineCommand.byteLength);
try {
engine.HEAPU8.set(invalidSplineCommand, invalidSplinePointer);
assert.notEqual(engine._web_engine_apply_command(handle, invalidSplinePointer, invalidSplineCommand.byteLength), 0);
assert.match(engine.UTF8ToString(engine._web_engine_last_error_message()), /NON_MESH_PROPERTY_INVALID/);
}
finally { engine._free(invalidSplinePointer); }
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineOffsets, threeSplineOffsets);
const twoSplineHandlePoints = threeSplineHandles.slice();
twoSplineHandlePoints[1] -= 0.2;
command(engine, handle, {
type: "setCurveSplines", dataId: createdCurve.id, splineTypes: ["BEZIER", "NURBS"],
splineOffsets: [0, 3, 7], ordersU: [0, 4], controlPoints: threeSplinePoints.slice(9),
pointWeights: threeSplineWeights.slice(3), cyclicU: [false, true],
handleTypes: [0, 0, 0, 0, 0, 0], handlePoints: twoSplineHandlePoints,
});
transactedCurve = snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id);
assert.deepEqual(transactedCurve.splineTypes, ["BEZIER", "NURBS"]);
assert.deepEqual(transactedCurve.splineOffsets, [0, 3, 7]);
assert.deepEqual(transactedCurve.cyclicU, [false, true]);
close(transactedCurve.handlePoints[1], twoSplineHandlePoints[1], "bulk handle transaction");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineTypes, threeSplineTypes);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === createdCurve.id).splineTypes, ["BEZIER", "NURBS"]);
const invalidSurfaceCommand = new TextEncoder().encode(JSON.stringify({ type: "setCurveTopology", dataId: surface.id, splineTypes: ["POLY"], cyclicU: [false], cyclicV: [false] }));
const invalidSurfacePointer = engine._malloc(invalidSurfaceCommand.byteLength);
try {
engine.HEAPU8.set(invalidSurfaceCommand, invalidSurfacePointer);
assert.notEqual(engine._web_engine_apply_command(handle, invalidSurfacePointer, invalidSurfaceCommand.byteLength), 0);
assert.match(engine.UTF8ToString(engine._web_engine_last_error_message()), /NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED/);
}
finally { engine._free(invalidSurfacePointer); }
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === surface.id)?.splineTypes, ["NURBS"]);
const curvePoints = curve.controlPoints.slice();
curvePoints[0] += 0.25;
command(engine, handle, { type: "setCurveControlPoints", dataId: curveId, controlPoints: curvePoints, splineOffsets: curve.splineOffsets, resolution: (curve.resolution ?? 12) + 1 });
const surfaceDimensions = [{ u: 5, v: 4, orderU: 4, orderV: 4 }];
const surfacePoints = [];
const surfaceWeights = [];
for (let v = 0; v < 4; v++) for (let u = 0; u < 5; u++) {
surfacePoints.push(-1 + u * 0.5, v / 3, u === 2 && (v === 1 || v === 2) ? 0.45 : 0);
surfaceWeights.push(u === 2 ? 0.85 : 1);
}
command(engine, handle, { type: "setSurfaceTopology", dataId: surface.id, splineDimensions: surfaceDimensions, controlPoints: surfacePoints, pointWeights: surfaceWeights, cyclicU: [false], cyclicV: [false] });
let changedSurface = snapshot(engine, handle).nonMeshData.find((data) => data.id === surface.id);
assert.deepEqual(changedSurface.splineDimensions, surfaceDimensions);
assert.equal(changedSurface.pointCount, 20);
close(changedSurface.pointWeights[2], 0.85, "surface rational weight");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === surface.id).splineDimensions, surface.splineDimensions);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
changedSurface = snapshot(engine, handle).nonMeshData.find((data) => data.id === surface.id);
assert.deepEqual(changedSurface.splineDimensions, surfaceDimensions);
const curveCyclic = curve.cyclicU.map((value, index) => index === 0 ? !value : value);
const curveHandlePoints = curve.handlePoints.slice();
curveHandlePoints[0] += 0.25;
curveHandlePoints[15] -= 0.2;
reject(engine, handle, { type: "setCurveTopology", baseRevision: snapshot(engine, handle).revision - 1, dataId: curveId, splineTypes: curve.splineTypes, cyclicU: curveCyclic, cyclicV: curve.cyclicV, handleTypes: curve.handleTypes, handlePoints: curveHandlePoints }, "REVISION_CONFLICT");
command(engine, handle, { type: "setCurveTopology", baseRevision: snapshot(engine, handle).revision, dataId: curveId, splineTypes: curve.splineTypes, cyclicU: curveCyclic, cyclicV: curve.cyclicV, handleTypes: curve.handleTypes, handlePoints: curveHandlePoints });
const preciseHandlePosition = curveHandlePoints.slice(12, 15);
preciseHandlePosition[2] += 0.125;
command(engine, handle, { type: "setCurveHandle", dataId: curveId, pointIndex: curve.handlePointIndices[2], side: "LEFT", position: preciseHandlePosition });
close(snapshot(engine, handle).nonMeshData.find((data) => data.id === curveId).handlePoints[14], preciseHandlePosition[2], "precise handle command");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
close(snapshot(engine, handle).nonMeshData.find((data) => data.id === curveId).handlePoints[14], curveHandlePoints[14], "precise handle undo");
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
close(snapshot(engine, handle).nonMeshData.find((data) => data.id === curveId).handlePoints[14], preciseHandlePosition[2], "precise handle redo");
curveHandlePoints.splice(12, 3, ...preciseHandlePosition);
const metaballElements = metaball.elements.map((element, index) => ({ ...element, radius: index === 0 ? element.radius + 0.1 : element.radius }));
command(engine, handle, { type: "setMetaballElements", dataId: metaball.id, elements: metaballElements });
command(engine, handle, { type: "setFontBody", dataId: font.id, body: "N-015 roundtrip" });
command(engine, handle, { type: "setFontProperties", dataId: font.id, properties: { alignment: "RIGHT", alignY: "CENTER", extrude: 0.08, bevelDepth: 0.015, bevelResolution: 3, offset: 0.01, spacing: 1.2, lineDistance: 1.4, wordSpace: 1.1, shear: 0.2, fontSize: 1.25, offsetX: 0.15, offsetY: -0.1 } });
const fontLinks = { regular: font.fontLinks.bold, bold: font.fontLinks.regular, italic: font.fontLinks.boldItalic, boldItalic: font.fontLinks.italic };
command(engine, handle, { type: "setFontLinks", dataId: font.id, links: fontLinks });
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id).fontLinks, fontLinks);
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id).fontLinks, font.fontLinks);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
assert.deepEqual(snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id).fontLinks, fontLinks);
const fontCharacters = Array.from("N-015 roundtrip", (_, index) => ({ kern: index === 1 ? 0.25 : 0, materialIndex: index % 2, styleFlags: index === 0 ? 0x3 : index === 2 ? 0x14 : 0 }));
const fontTextBoxes = [
{ x: 0, y: 0, width: 3.5, height: 1.25 },
{ x: 4, y: -0.5, width: 2.25, height: 1.5 },
];
command(engine, handle, { type: "setFontAdvanced", dataId: font.id, characters: fontCharacters, textBoxes: fontTextBoxes, activeTextBox: 1 });
const changed = snapshot(engine, handle);
close(changed.nonMeshData.find((data) => data.id === curveId).controlPoints[0], curvePoints[0], "curve point");
assert.deepEqual(changed.nonMeshData.find((data) => data.id === surface.id).splineDimensions, surfaceDimensions);
close(changed.nonMeshData.find((data) => data.id === surface.id).controlPoints[8], surfacePoints[8], "surface topology point");
close(changed.nonMeshData.find((data) => data.id === metaball.id).elements[0].radius, metaballElements[0].radius, "metaball radius");
assert.equal(changed.nonMeshData.find((data) => data.id === font.id).text, "N-015 roundtrip");
assert.deepEqual(changed.nonMeshData.find((data) => data.id === curveId).cyclicU, curveCyclic);
close(changed.nonMeshData.find((data) => data.id === curveId).handlePoints[0], curveHandlePoints[0], "curve left handle");
close(changed.nonMeshData.find((data) => data.id === curveId).handlePoints[15], curveHandlePoints[15], "curve right handle");
assert.equal(changed.nonMeshData.find((data) => data.id === curveId).handleTypes[4], 0);
assert.equal(changed.nonMeshData.find((data) => data.id === font.id).fontProperties.alignment, "RIGHT");
assert.equal(changed.nonMeshData.find((data) => data.id === font.id).fontProperties.bevelResolution, 3);
assert.deepEqual(changed.nonMeshData.find((data) => data.id === font.id).fontCharacters, fontCharacters);
assert.deepEqual(changed.nonMeshData.find((data) => data.id === font.id).fontTextBoxes, fontTextBoxes);
assert.equal(changed.nonMeshData.find((data) => data.id === font.id).activeFontTextBox, 1);
assert.deepEqual(changed.nonMeshData.find((data) => data.id === font.id).fontLinks, fontLinks);
close(changed.nonMeshData.find((data) => data.id === font.id).fontProperties.spacing, 1.2, "font spacing");
close(changed.nonMeshData.find((data) => data.id === font.id).fontProperties.fontSize, 1.25, "font size");
assert.equal(engine._web_engine_undo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
const undoneProperties = snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id);
assert.equal(undoneProperties.text, "N-015 roundtrip");
assert.equal(undoneProperties.fontProperties.alignment, "RIGHT");
assert.notDeepEqual(undoneProperties.fontTextBoxes, fontTextBoxes);
assert.equal(engine._web_engine_redo(handle), 0, engine.UTF8ToString(engine._web_engine_last_error_message()));
const redoneProperties = snapshot(engine, handle).nonMeshData.find((data) => data.id === font.id);
assert.equal(redoneProperties.text, "N-015 roundtrip");
assert.equal(redoneProperties.fontProperties.alignment, "RIGHT");
assert.deepEqual(redoneProperties.fontCharacters, fontCharacters);
assert.deepEqual(redoneProperties.fontTextBoxes, fontTextBoxes);
const evaluated = depsgraph(engine, handle);
const evaluatedTypes = new Set((evaluated.nonMeshGeometries ?? []).filter((geometry) => geometry.status === "EVALUATED").map((geometry) => geometry.sourceType));
for (const type of ["CURVE", "SURFACE", "FONT", "METABALL"]) assert.ok(evaluatedTypes.has(type), `missing evaluated ${type}`);
const evaluatedSurface = evaluated.nonMeshGeometries.find((geometry) => geometry.sourceDataId === surface.id && geometry.status === "EVALUATED");
assert.ok(evaluatedSurface?.vertexCount > 256 && evaluatedSurface?.triangleCount > 450, "5x4 Surface topology did not change evaluated geometry");
for (const geometry of evaluated.nonMeshGeometries ?? []) if (geometry.status === "EVALUATED") {
assert.ok(geometry.vertexCount > 0, `${geometry.sourceType} has no evaluated vertices`);
assert.equal(geometry.positions.length, geometry.vertexCount * 3);
assert.equal(geometry.indices.length, geometry.triangleCount * 3);
}
const saved = save(engine, handle);
engine._web_engine_destroy(handle);
const reopened = engine._web_engine_create();
open(engine, reopened, saved);
const roundtripped = snapshot(engine, reopened);
const reopenedVolume = roundtripped.nonMeshData.find((data) => data.id === volume.id);
assert.equal(reopenedVolume.sourcePath, volumeSourcePath);
assert.deepEqual(reopenedVolume.volumeProperties, volumeProperties);
close(roundtripped.nonMeshData.find((data) => data.id === curveId).controlPoints[0], curvePoints[0], "reopened curve point");
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === surface.id).splineDimensions, surfaceDimensions);
assert.equal(roundtripped.nonMeshData.find((data) => data.id === surface.id).pointCount, 20);
close(roundtripped.nonMeshData.find((data) => data.id === surface.id).pointWeights[2], 0.85, "reopened surface weight");
close(roundtripped.nonMeshData.find((data) => data.id === metaball.id).elements[0].radius, metaballElements[0].radius, "reopened metaball radius");
assert.equal(roundtripped.nonMeshData.find((data) => data.id === font.id).text, "N-015 roundtrip");
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === curveId).cyclicU, curveCyclic);
close(roundtripped.nonMeshData.find((data) => data.id === curveId).handlePoints[0], curveHandlePoints[0], "reopened curve left handle");
close(roundtripped.nonMeshData.find((data) => data.id === curveId).handlePoints[15], curveHandlePoints[15], "reopened curve right handle");
assert.equal(roundtripped.nonMeshData.find((data) => data.id === curveId).handleTypes[4], 0);
assert.equal(roundtripped.nonMeshData.find((data) => data.id === font.id).fontProperties.alignment, "RIGHT");
close(roundtripped.nonMeshData.find((data) => data.id === font.id).fontProperties.spacing, 1.2, "reopened font spacing");
close(roundtripped.nonMeshData.find((data) => data.id === font.id).fontProperties.offsetY, -0.1, "reopened font offset y");
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === font.id).fontCharacters, fontCharacters);
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === font.id).fontTextBoxes, fontTextBoxes);
assert.equal(roundtripped.nonMeshData.find((data) => data.id === font.id).activeFontTextBox, 1);
assert.deepEqual(roundtripped.nonMeshData.find((data) => data.id === font.id).fontLinks, fontLinks);
assert.equal(roundtripped.nonMeshData.find((data) => data.name === "CreatedWebCurve")?.controlPoints?.length, 21);
const createdAfterReopen = roundtripped.nonMeshData.find((data) => data.name === "CreatedWebCurve");
assert.deepEqual(createdAfterReopen.splineTypes, ["BEZIER", "NURBS"]);
assert.deepEqual(createdAfterReopen.splineOffsets, [0, 3, 7]);
assert.deepEqual(createdAfterReopen.cyclicU, [false, true]);
close(createdAfterReopen.handlePoints[1], twoSplineHandlePoints[1], "reopened bulk handle transaction");
command(engine, reopened, { type: "deleteNonMeshData", dataId: createdAfterReopen.id });
assert.equal(snapshot(engine, reopened).nonMeshData.some((data) => data.id === createdAfterReopen.id), false);
engine._web_engine_destroy(reopened);
console.log("nonmesh-roundtrip-ok types=CURVE,SURFACE,FONT,METABALL,VOLUME multispline-create-delete-bulk-handle-cyclic-surface-2d-topology-font-links-volume-properties=passed undo-redo=passed save-reopen=passed");