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");