add .rsla and .vsla generic raster and vector sla output formats

This commit is contained in:
Stewart Allen 2026-05-14 12:25:22 -04:00
commit 95eadc60df
12 changed files with 427 additions and 35 deletions

View file

@ -48,6 +48,8 @@
"kiri/mode/sla/driver",
"kiri/mode/sla/x_cxdlp",
"kiri/mode/sla/x_photon",
"kiri/mode/sla/x_rsla",
"kiri/mode/sla/x_vsla",
"kiri/mode/sla/export",
"kiri/mode/sla/slice",
"kiri/core/codec",

View file

@ -99,6 +99,9 @@ function device_from_code(code,mode) {
bedBelt: valueOf(set.bed_belt, false),
resolutionX: valueOf(set.resolution_x, 1600),
resolutionY: valueOf(set.resolution_y, 900),
slaFormat: valueOf(set.sla_format, ''),
slaFileExt: valueOf(set.sla_file_ext, ''),
slaFormatVersion: valueOf(set.sla_format_version, 1),
deviceZMax: valueOf(set.z_move_max, 0),
gcodeTime: valueOf(set.time_factor, 1),
maxHeight: valueOf(set.build_height, 150),
@ -367,7 +370,10 @@ export const conf = {
bedHeight: 1.5,
maxHeight: 150,
resolutionX: 1600,
resolutionY: 900
resolutionY: 900,
slaFormat: "",
slaFileExt: "",
slaFormatVersion: 1
},
// process defaults SLA:Process
p:{

View file

@ -185,6 +185,8 @@ function setDeviceCode(code, devicename) {
}
}
let allowSLADeviceEdit = mode !== 'SLA' || dev.slaFormat === 'vsla' || dev.slaFormat === 'rsla';
// disable editing for non-local devices
[
// ui.deviceName,
@ -226,14 +228,14 @@ function setDeviceCode(code, devicename) {
ui.extOffsetX,
ui.extOffsetY
].forEach(function(e) {
e.disabled = !local;
e.disabled = !local || !allowSLADeviceEdit;
});
ui.devices.save.disabled = !local;
ui.devices.save.disabled = !local || !allowSLADeviceEdit;
ui.devices.delete.disabled = !local;
ui.devices.rename.disabled = !local;
ui.devices.export.disabled = !local;
ui.devices.add.style.display = mode === 'SLA' ? 'none' : '';
ui.devices.add.style.display = allowSLADeviceEdit ? '' : 'none';
if (local) {
ui.devices.add.innerText = "copy";

View file

@ -0,0 +1,15 @@
{
"no_clone": false,
"cmd": {},
"settings": {
"bed_width": 120,
"bed_depth": 68,
"bed_height": 1,
"max_height": 150,
"resolution_x": 2560,
"resolution_y": 1440,
"sla_format": "rsla",
"sla_file_ext": "rsla",
"sla_format_version": 1
}
}

View file

@ -0,0 +1,15 @@
{
"no_clone": false,
"cmd": {},
"settings": {
"bed_width": 120,
"bed_depth": 68,
"bed_height": 1,
"max_height": 150,
"resolution_x": 2560,
"resolution_y": 1440,
"sla_format": "vsla",
"sla_file_ext": "vsla",
"sla_format_version": 1
}
}

View file

@ -63,21 +63,14 @@ export function printDownload(output, api, names) {
$('print-layers').value = layers;
$('print-time').value = `${print_hrs}:${print_min}:${print_sec}`;
switch (device.deviceName) {
case 'Anycubic.Photon':
download.innerText += " .photon";
download.onclick = () => { saveFile(api, file, ".photon") };
break;
case 'Anycubic.Photon.S':
download.innerText += " .photons";
download.onclick = () => { saveFile(api, file, ".photons") };
break;
case 'Creality.Halot.Sky':
default:
download.innerText += " .cxdlp";
download.onclick = () => { saveFile(api, file, ".cxdlp") };
break;
}
let ext = device.slaFileExt || ({
'Anycubic.Photon': 'photon',
'Anycubic.Photon.S': 'photons'
}[device.deviceName]) || 'cxdlp';
ext = ext.startsWith(".") ? ext : `.${ext}`;
download.innerText = `${api.language.current.ex_dwnl || 'download'} ${ext}`;
download.onclick = () => { saveFile(api, file, ext) };
}
function saveFile(api, file, ext) {

View file

@ -3,6 +3,8 @@
import { CXDLP } from './x_cxdlp.js';
import { photon } from './x_photon.js';
import { SLA } from './init-work.js';
import { RSLA } from './x_rsla.js';
import { VSLA } from './x_vsla.js';
/**
* DRIVER CONTRACT - runs in worker
@ -33,16 +35,30 @@ export function sla_export(print, online, ondone) {
layermax = Math.max(widget.slices.length);
});
let isPhoton = false;
let format = device.slaFormat || ({
'Anycubic.Photon': 'photon',
'Anycubic.Photon.S': 'photons',
}[device.deviceName]) || 'cxdlp';
switch (device.deviceName) {
case 'Anycubic.Photon':
case 'Anycubic.Photon.S':
isPhoton = true;
break;
if (format === 'vsla') {
return VSLA.encode(print, (progress, message) => {
online({ progress, message });
}).then(output => {
let { file, layers, volume } = output;
ondone({ width, height, file, layers, volume }, [file]);
});
}
if (isPhoton) {
if (format === 'rsla') {
return RSLA.encode(print, (progress, message) => {
online({ progress, message });
}).then(output => {
let { file, layers, volume } = output;
ondone({ width, height, file, layers, volume }, [file]);
});
}
if (format === 'photon' || format === 'photons') {
let legacyMode = SLA.legacy || alias > 1,
part1 = legacyMode ? 0.25 : 0.85,
part2 = (1 - part1),
@ -73,9 +89,9 @@ export function sla_export(print, online, ondone) {
}
let exp_func = {
'Anycubic.Photon': photon.generatePhoton,
'Anycubic.Photon.S': photon.generatePhotons,
}[device.deviceName] || photon.generatePhoton;
photon: photon.generatePhoton,
photons: photon.generatePhotons,
}[format] || photon.generatePhoton;
let file = exp_func(print, {
width: width,

View file

@ -25,7 +25,7 @@ function init(worker) {
async function sla_prepare(widgets, settings, update) {
self.kiri_worker.current.print = newPrint(settings, widgets);
if (!SLA.wasm) {
fetch('/wasm/kiri-sla.wasm')
SLA.wasmPromise = SLA.wasmPromise || fetch('/wasm/kiri-sla.wasm')
.then(response => response.arrayBuffer())
.then(bytes => WebAssembly.instantiate(bytes, {
env: {
@ -44,6 +44,7 @@ async function sla_prepare(widgets, settings, update) {
rle_encode: exports.rle_encode
};
});
await SLA.wasmPromise;
}
update(1);
}

View file

@ -1,5 +1,7 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { SLA } from './init-work.js';
const default_values = {
magic1: 'CXSW3DV2',
magic2: 'CXSW3DV2',
@ -392,7 +394,7 @@ CXDLP.render = function(params) {
}
});
let wasm = kiri.driver.SLA.wasm;
let wasm = SLA.wasm;
let imagelen = width * height;
let writer = new self.DataWriter(new DataView(wasm.memory.buffer), imagelen);
writer.writeU16(width, true);

View file

@ -0,0 +1,144 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { JSZip } from '../../../../ext/jszip-esm.js';
import { PNG } from '../../../../ext/pngjs.esm.js';
import { photon } from './x_photon.js';
const FORMAT = "rsla";
const MIME = "application/vnd.gridspace.rsla+zip";
function encode(print, progress) {
let { settings, widgets } = print;
let { device, process } = settings;
let width = device.resolutionX;
let height = device.resolutionY;
let scaleX = width / device.bedWidth;
let scaleY = height / device.bedDepth;
let layermax = 0;
widgets = widgets.filter(w => !w.track.ignore && !w.meta.disabled);
widgets.forEach(widget => {
layermax = Math.max(layermax, widget.slices.length);
});
let layers = [];
let volume = 0;
let zip = new JSZip();
for (let index=0; index<layermax; index++) {
let rendered = photon.renderLayerWasm({
index,
width,
height,
widgets,
scaleX,
scaleY,
masks: []
});
if (rendered.end) {
break;
}
let file = `layers/${index.toString().padStart(6, "0")}.png`;
let layer = {
index,
z: round(process.slaFirstOffset + process.slaSlice * index),
area: round(rendered.area),
file
};
volume += rendered.area * process.slaSlice;
layers.push(layer);
zip.file(file, imageToPNG(rendered.image, width, height));
if (progress) progress((index / layermax) * 0.85, "raster_gen");
}
let manifest = createManifest({ device, process, layers, volume });
zip.file("manifest.json", JSON.stringify(manifest, null, 2));
return zip.generateAsync({
type: "arraybuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
streamFiles: true,
mimeType: MIME
}, meta => {
if (progress) progress(0.85 + (meta.percent / 100) * 0.15, "zip_gen");
}).then(file => {
return { file, layers: layers.length, volume };
});
}
function createManifest(params) {
let { device, process, layers, volume } = params;
return {
format: FORMAT,
version: device.slaFormatVersion || 1,
units: "mm",
coordinateSystem: {
layerImage: "top-left",
model: "bed-center",
x: "right",
y: "back",
z: "up"
},
bed: {
width: device.bedWidth,
depth: device.bedDepth,
height: device.maxHeight || device.bedHeight
},
resolution: {
x: device.resolutionX,
y: device.resolutionY
},
layerHeight: process.slaSlice,
layerCount: layers.length,
volume: round(volume),
raster: {
encoding: "png",
color: "grayscale",
cure: "nonzero",
white: "cure",
black: "off"
},
process: {
bottomLayers: process.slaBaseLayers,
exposure: process.slaLayerOn,
bottomExposure: process.slaBaseOn,
lightOffDelay: process.slaLayerOff,
bottomLightOffDelay: process.slaBaseOff,
liftHeight: process.slaPeelDist,
bottomLiftHeight: process.slaBasePeelDist,
liftSpeed: process.slaPeelLiftRate * 60,
bottomLiftSpeed: process.slaBasePeelLiftRate * 60,
retractSpeed: process.slaPeelDropRate * 60,
firstLayerOffset: process.slaFirstOffset,
antiAlias: process.slaAntiAlias
},
layers
};
}
function imageToPNG(image, width, height) {
let data = new Uint8Array(width * height);
for (let x=0; x<width; x++) {
for (let y=0; y<height; y++) {
data[y * width + x] = image[x * height + y] ? 255 : 0;
}
}
return PNG.sync.write({ width, height, data }, {
colorType: 0,
inputColorType: 0,
inputHasAlpha: false
});
}
function round(value) {
return Number.parseFloat(Number(value || 0).toFixed(5));
}
export const RSLA = {
encode
};

View file

@ -0,0 +1,193 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { JSZip } from '../../../../ext/jszip-esm.js';
const FORMAT = "vsla";
const MIME = "application/vnd.gridspace.vsla+zip";
function encode(print, progress) {
let { settings, widgets } = print;
let { device, process } = settings;
let layermax = 0;
widgets = widgets.filter(w => !w.track.ignore && !w.meta.disabled);
widgets.forEach(widget => {
layermax = Math.max(layermax, widget.slices.length);
});
let layers = [];
let volume = 0;
for (let index=0; index<layermax; index++) {
let layer = collectLayer({ index, widgets, process });
volume += layer.area * process.slaSlice;
layers.push(layer);
if (progress) progress((index / layermax) * 0.7, "vector_gen");
}
while (layers.length && layers.last().polygons.length === 0) {
layers.pop();
}
let manifest = createManifest({ device, process, layers, volume });
let zip = new JSZip();
zip.file("manifest.json", JSON.stringify(manifest, null, 2));
layers.forEach(layer => {
zip.file(layer.file, createLayerSVG({ device, layer }));
});
return zip.generateAsync({
type: "arraybuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
streamFiles: true,
mimeType: MIME
}, meta => {
if (progress) progress(0.7 + (meta.percent / 100) * 0.3, "zip_gen");
}).then(file => {
return { file, layers: layers.length, volume };
});
}
function collectLayer(params) {
let { index, widgets, process } = params;
let polygons = [];
let area = 0;
let z = process.slaFirstOffset + process.slaSlice * index;
widgets.forEach(widget => {
let slice = widget.slices[index];
if (!slice) return;
let polys = slice.unioned;
if (!polys) polys = slice.tops.map(t => t.poly);
polys = polys ? polys.slice() : [];
if (slice.supports) polys.appendAll(slice.supports);
polygons.appendAll(polys.map(poly => {
let moved = poly.clone(true).move(widget.track.pos);
area += moved.areaDeep();
return moved;
}));
if (slice.z !== undefined) {
z = slice.z;
}
});
return {
index,
z: round(z),
area: round(area),
file: `layers/${index.toString().padStart(6, "0")}.svg`,
polygons
};
}
function createManifest(params) {
let { device, process, layers, volume } = params;
let layerList = layers.map(layer => {
return {
index: layer.index,
z: layer.z,
area: layer.area,
file: layer.file,
empty: layer.polygons.length === 0
};
});
return {
format: FORMAT,
version: device.slaFormatVersion || 1,
units: "mm",
coordinateSystem: {
layerSvg: "top-left",
model: "bed-center",
x: "right",
y: "back",
z: "up"
},
cure: {
geometry: "filled-svg-paths",
fill: "#000",
fillRule: "evenodd",
background: "transparent"
},
bed: {
width: device.bedWidth,
depth: device.bedDepth,
height: device.maxHeight || device.bedHeight
},
resolution: {
x: device.resolutionX,
y: device.resolutionY
},
layerHeight: process.slaSlice,
layerCount: layers.length,
volume: round(volume),
process: {
bottomLayers: process.slaBaseLayers,
exposure: process.slaLayerOn,
bottomExposure: process.slaBaseOn,
lightOffDelay: process.slaLayerOff,
bottomLightOffDelay: process.slaBaseOff,
liftHeight: process.slaPeelDist,
bottomLiftHeight: process.slaBasePeelDist,
liftSpeed: process.slaPeelLiftRate * 60,
bottomLiftSpeed: process.slaBasePeelLiftRate * 60,
retractSpeed: process.slaPeelDropRate * 60,
firstLayerOffset: process.slaFirstOffset,
antiAlias: process.slaAntiAlias
},
layers: layerList
};
}
function createLayerSVG(params) {
let { device, layer } = params;
let width = round(device.bedWidth);
let depth = round(device.bedDepth);
let body = layer.polygons.map(poly => {
return ` <path fill="#000" fill-rule="evenodd" d="${polyToPath(poly, device)}"/>`;
}).join("\n");
return [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="${width}mm" height="${depth}mm" viewBox="0 0 ${width} ${depth}" data-layer="${layer.index}" data-z="${layer.z}">`,
body,
`</svg>`,
``
].join("\n");
}
function polyToPath(poly, device) {
let parts = [];
appendPoly(parts, poly, device);
return parts.join(" ");
}
function appendPoly(parts, poly, device) {
appendPath(parts, poly, device);
if (poly.inner) {
poly.inner.forEach(inner => appendPoly(parts, inner, device));
}
}
function appendPath(parts, poly, device) {
let points = poly.points || [];
if (points.length === 0) return;
points.forEach((point, index) => {
let x = round(point.x + device.bedWidth / 2);
let y = round(device.bedDepth - (point.y + device.bedDepth / 2));
parts.push(`${index === 0 ? "M" : "L"}${x},${y}`);
});
parts.push("Z");
}
function round(value) {
return Number.parseFloat(Number(value || 0).toFixed(5));
}
export const VSLA = {
encode
};

View file

@ -556,7 +556,7 @@ const dispatch = {
});
},
export(data, send) {
async export(data, send) {
const mode = data.settings.mode;
const driver = drivers[mode];
@ -566,7 +566,7 @@ const dispatch = {
}
let output;
driver.export(current.print, (line, direct) => {
let result = driver.export(current.print, (line, direct) => {
send.data({line}, direct);
}, (done) => {
// SLA workaround
@ -574,6 +574,9 @@ const dispatch = {
}, (debug) => {
send.data({debug});
});
if (result && result.then) {
await result;
}
const {
bounds,