From 74ecd998e433558806d1d1b32f29e51ad5e31fc0 Mon Sep 17 00:00:00 2001 From: Robert Ramsey Date: Fri, 24 Jul 2026 14:40:15 -0400 Subject: [PATCH] =?UTF-8?q?release:=20Helix=201.2.7=20=E2=80=94=20multi-pl?= =?UTF-8?q?ate=20extraction=20+=20prepare=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix multi-plate 3MF preview failing ("Native engine could not load this model") by repacking the selected plate into a minimal core-spec 3MF the strict PrusaSlicer engine accepts. Stream the extraction via XmlPullParser so no mesh is ever materialized as a Java String — avoids OOM on large files (tested: 88MB Monkey / 57MB Stitch). Inlines external part-file meshes (3D/Objects/object_N.model) and flattens component-group objects with composed transforms. Add a Prepare & Slice progress modal (real byte-counted % across both streaming passes, phase labels, rotating sayings). Defer plate extraction from card-tap to Prepare&Slice so picking a plate is instant. Bump version 1.2.6 -> 1.2.7, versionCode 16 -> 17. --- android/app/build.gradle | 4 +- .../slicing/HelixModelPreviewActivity.kt | 3 +- .../u1control/slicing/HelixSlicerModule.kt | 16 +- .../u1control/slicing/PlateExtractor.kt | 424 ++++++++++++++++-- app.json | 2 +- app/(tabs)/slicer.tsx | 186 ++++++-- package.json | 2 +- services/nativeSlicer.ts | 11 + 8 files changed, 580 insertions(+), 68 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index b330f95..89ce4d9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { applicationId 'org.crabcore.u1control' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 16 - versionName "1.2.6" + versionCode 17 + versionName "1.2.7" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixModelPreviewActivity.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixModelPreviewActivity.kt index 9f2d3ec..4132f34 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixModelPreviewActivity.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixModelPreviewActivity.kt @@ -415,7 +415,8 @@ class HelixModelPreviewActivity : Activity() { private fun loadNativeScene(file: File) { synchronized(NativeEngineGuard.LOCK) { val lib = native ?: NativeLibrary().also { native = it } - if (!lib.loadModel(file.absolutePath)) { + val loaded = lib.loadModel(file.absolutePath) + if (!loaded) { throw IllegalStateException("Native engine could not load this model.") } filamentPalette = readFilamentPalette(lib) diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt index db0a498..f6d1f67 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/HelixSlicerModule.kt @@ -333,11 +333,13 @@ class HelixSlicerModule( return } val safeName = plate.name - .replace(Regex("""[/\\:*?"<>|]"""), "_") + .replace(Regex("""[/\\:*?"<>|\s]"""), "_") .trim() .ifBlank { "plate_$plateId" } val outFile = File(reactApplicationContext.filesDir, "plate_${plateId}_$safeName.3mf") - PlateExtractor.extractPlate(file, plate, outFile) + PlateExtractor.extractPlate(file, plate, outFile) { percent, phase -> + emitExtractProgress(percent, phase) + } if (!outFile.exists() || outFile.length() == 0L) { promise.reject("E_EXTRACT", "Failed to extract plate $plateId.") return @@ -352,6 +354,16 @@ class HelixSlicerModule( } } + private fun emitExtractProgress(percent: Int, phase: String) { + val params = Arguments.createMap().apply { + putInt("percent", percent) + putString("phase", phase) + } + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("extractProgress", params) + } + @ReactMethod fun openMakerWorldDownloader( designId: String, diff --git a/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt b/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt index ecad74d..72ca815 100644 --- a/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt +++ b/android/app/src/main/java/org/crabcore/u1control/slicing/PlateExtractor.kt @@ -1,9 +1,14 @@ package org.crabcore.u1control.slicing import java.io.File +import java.io.InputStream +import java.io.OutputStreamWriter +import java.io.Writer import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory /** * Reads and splits Bambu/OrcaSlicer multi-plate 3MFs. @@ -50,6 +55,8 @@ object PlateExtractor { Regex("]*>.*?", setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE)) private val ASSEMBLE_ITEM = Regex("]*?/>", RegexOption.IGNORE_CASE) private val ASSEMBLE_ITEM_OBJECT_ID = Regex("\\bobject_id=\"(\\d+)\"", RegexOption.IGNORE_CASE) + private val COMPONENT_PATH = Regex("]*\\bp:path=\"([^\"]+)\"", RegexOption.IGNORE_CASE) + private val RELATIONSHIP = Regex("]*\\bTarget=\"([^\"]+)\"[^>]*/?>", RegexOption.IGNORE_CASE) /** Plate definitions in file order. Empty when the 3MF isn't multi-plate. */ fun readPlates(file: File): List { @@ -80,34 +87,368 @@ object PlateExtractor { } /** - * Writes a single-plate copy of [src] to [outFile]: every zip entry is copied - * verbatim except 3D/3dmodel.model, whose is filtered to the plate's - * objects. Returns [outFile]. + * Repacks a single plate of [src] into [outFile] as a MINIMAL core-spec 3MF. + * + * PrusaSlicer's loadModel() is strict: it rejects production-extension files + * (Bambu/Orca) that carry the BambuStudio namespace, Metadata plate_N.json, + * Auxiliaries blobs, etc. So we emit only the three parts a core 3MF needs + * ([Content_Types].xml, _rels/.rels, 3D/3dmodel.model), with the build + * flattened to this plate's mesh objects. + * + * STREAMING: large 3MFs hold hundreds of MB of mesh data — a single + * 3D/3dmodel.model can be ~400 MB (Bambu also splits geometry across 50 MB+ + * part files). Materializing any of that as a Java String blows the heap + * (a char[] is ~2x the byte size, and the heap is capped at 256 MB). So we + * never do: the object graph is built with a streaming XmlPullParser that + * records only structure (component children, transforms, mesh presence) — + * never vertex data — and each kept mesh is stream-copied element-by-element + * from the source zip entry straight into the output writer. Peak memory is + * bounded by the parser buffer, not the model size. + * + * Two mesh-storage shapes are supported transparently: + * - INLINE (OrcaSlicer / some Bambu): the leaf object carries its + * inside 3D/3dmodel.model's . + * - EXTERNAL part files (Bambu production extension): the mesh lives only in + * a sibling 3D/Objects/object_N.model, referenced by a mesh-less group + * object via . */ - fun extractPlate(src: File, plate: Plate, outFile: File): File { + fun extractPlate( + src: File, + plate: Plate, + outFile: File, + onProgress: ((percent: Int, phase: String) -> Unit)? = null, + ): File { val keep = plate.objectIds.toHashSet() + onProgress?.invoke(2, "Opening archive…") ZipFile(src).use { zip -> - ZipOutputStream(outFile.outputStream().buffered()).use { out -> - val entries = zip.entries() - while (entries.hasMoreElements()) { - val entry = entries.nextElement() - if (entry.isDirectory) continue - out.putNextEntry(ZipEntry(entry.name)) - if (entry.name.equals("3D/3dmodel.model", ignoreCase = true)) { - val xml = zip.getInputStream(entry).bufferedReader().use { it.readText() } - out.write(filterBuild(xml, keep).toByteArray()) - } else { - zip.getInputStream(entry).use { it.copyTo(out) } + val modelEntry = zip.getEntry("3D/3dmodel.model") + ?: throw IllegalStateException("3D/3dmodel.model not found in ${src.name}") + val modelSize = modelEntry.size.coerceAtLeast(0L) + + // PASS 1 — stream the object graph + build items (no mesh data buffered). + val graph = HashMap() + val items = ArrayList() + val rootUnit = arrayOf("millimeter", "") // [0]=unit, [1]=xml:lang + val m1 = Meter(modelSize, 3, 27, onProgress).apply { phase = "Reading model structure…" } + streamGraph(zip, modelEntry, graph, items, rootUnit, m1) + if (items.isEmpty()) throw IllegalStateException("No items in ${src.name}") + onProgress?.invoke(31, "Resolving objects…") + + // Resolve the plate's leaves: DFS from kept build items, composing 3MF + // row-vector transforms (combined = component x item). One output object + // per UNIQUE source mesh (shared geometry -> one + many s); + // items carry the composed transform so the mesh copies verbatim. + val meshOutId = LinkedHashMap() + val outItems = ArrayList() + var nextId = 0 + fun expand(objId: Int, xform: DoubleArray, printable: String?, partPath: String?, path: HashSet) { + if (objId in path) return // cycle guard (3MF forbids cycles; defensive) + val node = graph[objId] + val subs = node?.components + val hasOwnMesh = node?.hasMesh == true + if (partPath == null && !hasOwnMesh && subs != null && subs.isNotEmpty()) { + // Pure group object: recurse into its components, composing transforms. + val nextPath = HashSet(path).apply { add(objId) } + for (c in subs) { + expand(c.sub, composeTransforms(parseTransform(c.transform), xform), printable, c.partPath, nextPath) } - out.closeEntry() + } else if (hasOwnMesh || partPath != null) { + // Leaf mesh object: emit it (de-duplicated by source mesh). + val outId = meshOutId.getOrPut(MeshSrc(partPath, objId)) { ++nextId } + outItems.add(OutItem(outId, xform, printable)) + } + } + for (bi in items) { + if (bi.objid in keep) expand(bi.objid, parseTransform(bi.transform), bi.printable, null, hashSetOf()) + } + if (outItems.isEmpty()) throw IllegalStateException("Plate ${plate.id} has no printable objects") + + // PASS 2 — write the minimal 3MF, streaming each kept mesh verbatim. + ZipOutputStream(outFile.outputStream().buffered()).use { zos -> + writeEntry(zos, "[Content_Types].xml", MINIMAL_CONTENT_TYPES) + writeEntry(zos, "_rels/.rels", MINIMAL_RELS) + zos.putNextEntry(ZipEntry("3D/3dmodel.model")) + val w = OutputStreamWriter(zos, Charsets.UTF_8) + val langAttr = if (rootUnit[1].isNotEmpty()) " xml:lang=\"${rootUnit[1]}\"" else "" + w.write("\n") + w.write("\n") + w.write(" \n") + // Group output meshes by source: inline (re-read 3dmodel.model) vs each + // external part file. Then size the pass-2 progress meter against the + // bytes we will actually stream (model size if any inline leaves, plus + // each needed part file), so the bar reflects real, file-adaptive work. + val pendingInline = HashMap() + for (k in meshOutId.keys) if (k.partPath == null) pendingInline[k.objId] = meshOutId[k]!! + val byPart = LinkedHashMap>() + for ((k, outId) in meshOutId) { + val p = k.partPath ?: continue + byPart.getOrPut(p) { HashMap() }[k.objId] = outId } + var p2Total = 0L + if (pendingInline.isNotEmpty()) p2Total += modelSize + for (p in byPart.keys) { + val e = zip.getEntry(p.removePrefix("/")) ?: zip.getEntry(p) + if (e != null) p2Total += e.size.coerceAtLeast(0L) + } + val m2 = Meter(p2Total, 34, 63, onProgress).apply { phase = "Extracting geometry…" } + // Inline meshes: one more stream over 3D/3dmodel.model, copying each + // kept object's as the parser reaches it (document order). + if (pendingInline.isNotEmpty()) streamInlineMeshes(zip, modelEntry, pendingInline, w, m2) + // External part-file meshes: stream each needed part file once. + for ((partPath, objs) in byPart) streamPartMeshes(zip, partPath, objs, w, m2) + // Build items. + w.write("\n \n \n") + for (oi in outItems) { + w.write(" \n") + } + w.write(" \n\n") + onProgress?.invoke(99, "Finalizing…") + w.flush() + zos.closeEntry() } } return outFile } - private val COMPONENT_PATH = Regex("]*\\bp:path=\"([^\"]+)\"", RegexOption.IGNORE_CASE) - private val RELATIONSHIP = Regex("]*\\bTarget=\"([^\"]+)\"[^>]*/?>", RegexOption.IGNORE_CASE) + private fun writeEntry(zos: ZipOutputStream, name: String, text: String) { + zos.putNextEntry(ZipEntry(name)) + zos.write(text.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + + private val MINIMAL_CONTENT_TYPES = + """ + + + + +""" + private val MINIMAL_RELS = + """ + + + +""" + + // ---- streaming mesh extraction helpers (namespaces OFF: we copy raw qnames) ---- + + private fun newParser(input: InputStream): XmlPullParser { + val factory = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false } + val parser = factory.newPullParser() + parser.setInput(input, "UTF-8") + return parser + } + + /** Case-insensitive attribute lookup by raw qualified name (namespaces off). */ + private fun attrValue(parser: XmlPullParser, name: String): String? { + for (i in 0 until parser.attributeCount) { + if (parser.getAttributeName(i).equals(name, ignoreCase = true)) return parser.getAttributeValue(i) + } + return null + } + + /** A component's external part-file path (p:path / any *:path / bare path). */ + private fun pathAttribute(parser: XmlPullParser): String? { + for (i in 0 until parser.attributeCount) { + val n = parser.getAttributeName(i) + if (n.equals("p:path", ignoreCase = true) || n.endsWith(":path", ignoreCase = true) || n.equals("path", ignoreCase = true)) { + return parser.getAttributeValue(i) + } + } + return null + } + + private data class ComponentRef(val sub: Int, val transform: String?, val partPath: String?) + private data class ObjNode(val components: List, val hasMesh: Boolean) + private data class BuildItem(val objid: Int, val transform: String?, val printable: String?) + private data class MeshSrc(val partPath: String?, val objId: Int) + private data class OutItem(val outId: Int, val xform: DoubleArray, val printable: String?) + + /** + * Maps bytes read out of a stream to a determinate [base..base+span] percent + * window and reports it through [onProgress] (only when the integer percent + * actually changes, to keep bridge traffic down). Two meters cover the two + * streaming passes: pass 1 (graph) over [3..30], pass 2 (meshes) over [34..97]. + */ + private class Meter( + val total: Long, + val base: Int, + val span: Int, + val onProgress: ((Int, String) -> Unit)?, + ) { + var phase: String = "" + private var read = 0L + private var lastPct = -1 + fun onBytes(n: Int) { + if (n <= 0 || onProgress == null) return + read += n + val frac = if (total > 0) (read.toDouble() / total).coerceIn(0.0, 1.0) else 0.0 + val pct = (base + (span * frac)).toInt().coerceIn(0, 100) + if (pct != lastPct) { lastPct = pct; onProgress(pct, phase) } + } + } + + /** InputStream that feeds every byte read into a [Meter] (for real progress). */ + private class CountingInputStream(inner: InputStream, private val meter: Meter?) : InputStream() { + private val src = inner + override fun read(): Int { val b = src.read(); if (b != -1) meter?.onBytes(1); return b } + override fun read(b: ByteArray, off: Int, len: Int): Int { + val n = src.read(b, off, len); if (n > 0) meter?.onBytes(n); return n + } + override fun available(): Int = try { src.available() } catch (_: Throwable) { 0 } + override fun close() { src.close() } + } + + /** Streams [entry] once, recording each object's component children + mesh presence, and the build items. */ + private fun streamGraph( + zip: ZipFile, + entry: ZipEntry, + graph: HashMap, + items: ArrayList, + rootUnit: Array, + meter: Meter?, + ) { + CountingInputStream(zip.getInputStream(entry), meter).use { input -> + val parser = newParser(input.buffered()) + var ev = parser.eventType + var inBuild = false + var curObjId: Int? = null + var curComps: ArrayList? = null + var curHasMesh = false + while (ev != XmlPullParser.END_DOCUMENT) { + if (ev == XmlPullParser.START_TAG) { + when (parser.name) { + "model" -> { + attrValue(parser, "unit")?.let { if (it.isNotBlank()) rootUnit[0] = it } + attrValue(parser, "xml:lang")?.let { if (it.isNotBlank()) rootUnit[1] = it } + } + "object" -> { + curObjId = attrValue(parser, "id")?.toIntOrNull() + curComps = ArrayList() + curHasMesh = false + } + "component" -> { + val sub = attrValue(parser, "objectid")?.toIntOrNull() + if (sub != null) { + curComps?.add(ComponentRef(sub, attrValue(parser, "transform"), pathAttribute(parser))) + } + } + "mesh" -> curHasMesh = true + "build" -> inBuild = true + "item" -> if (inBuild) { + attrValue(parser, "objectid")?.toIntOrNull()?.let { id -> + items.add(BuildItem(id, attrValue(parser, "transform"), attrValue(parser, "printable"))) + } + } + } + } else if (ev == XmlPullParser.END_TAG) { + when (parser.name) { + "object" -> { + if (curObjId != null) graph[curObjId!!] = ObjNode(curComps ?: emptyList(), curHasMesh) + curObjId = null + curComps = null + } + "build" -> inBuild = false + } + } + ev = parser.next() + } + } + } + + /** Streams [entry] (3D/3dmodel.model) copying each object whose id is in [pending] (id -> outId). */ + private fun streamInlineMeshes(zip: ZipFile, entry: ZipEntry, pending: Map, w: Writer, meter: Meter?) { + CountingInputStream(zip.getInputStream(entry), meter).use { input -> + val parser = newParser(input.buffered()) + var ev = parser.eventType + while (ev != XmlPullParser.END_DOCUMENT) { + if (ev == XmlPullParser.START_TAG && parser.name == "object") { + val id = attrValue(parser, "id")?.toIntOrNull() + processObject(parser, w, if (id != null) pending[id] else null) + } + ev = parser.next() + } + } + } + + /** Streams one external part file [partPath], copying each object whose id is in [objs] (id -> outId). */ + private fun streamPartMeshes(zip: ZipFile, partPath: String, objs: Map, w: Writer, meter: Meter?) { + val entry = zip.getEntry(partPath.removePrefix("/")) ?: zip.getEntry(partPath) ?: return + CountingInputStream(zip.getInputStream(entry), meter).use { input -> + val parser = newParser(input.buffered()) + var ev = parser.eventType + while (ev != XmlPullParser.END_DOCUMENT) { + if (ev == XmlPullParser.START_TAG && parser.name == "object") { + val id = attrValue(parser, "id")?.toIntOrNull() + processObject(parser, w, if (id != null) objs[id] else null) + } + ev = parser.next() + } + } + } + + /** + * pre: parser at START_TAG "object". post: parser at END_TAG "object". + * When [outId] != null, writes `` + this object's + * first `` (stream-copied verbatim) + ``. Otherwise skips it. + */ + private fun processObject(parser: XmlPullParser, w: Writer, outId: Int?) { + if (outId == null) { skipToEndTag(parser, "object"); return } + var ev = parser.next() + while (ev != XmlPullParser.END_TAG) { + if (ev == XmlPullParser.START_TAG && parser.name == "mesh") { + w.write("\n") + copyElement(parser, w) + w.write("\n\n") + skipToEndTag(parser, "object") + return + } + ev = parser.next() + } + } + + /** + * pre: parser at START_TAG of an element. Writes that element and its full + * subtree verbatim (self-closing form preserved, so mesh output stays the + * same size as the source). post: parser at the element's END_TAG. + */ + private fun copyElement(parser: XmlPullParser, w: Writer) { + val name = parser.name + w.write('<'.code); w.write(name) + val ac = parser.attributeCount + for (i in 0 until ac) { + w.write(' '.code); w.write(parser.getAttributeName(i)); w.write("=\""); w.write(esc(parser.getAttributeValue(i))); w.write('"'.code) + } + if (parser.isEmptyElementTag) { w.write("/>"); parser.next(); return } + w.write('>'.code) + var ev = parser.next() + while (ev != XmlPullParser.END_TAG) { + when (ev) { + XmlPullParser.START_TAG -> copyElement(parser, w) + XmlPullParser.TEXT -> { val t = parser.text; if (t != null && t.isNotEmpty()) w.write(esc(t)) } + XmlPullParser.END_DOCUMENT -> return + } + ev = parser.next() + } + w.write("'.code) + } + + /** Advances until the END_TAG of [name]; leaves parser positioned there. */ + private fun skipToEndTag(parser: XmlPullParser, name: String) { + var ev = parser.next() + while (ev != XmlPullParser.END_DOCUMENT) { + if (ev == XmlPullParser.END_TAG && parser.name == name) return + ev = parser.next() + } + } + + private fun esc(s: String): String = + if (s.indexOf('"') < 0 && s.indexOf('&') < 0 && s.indexOf('<') < 0 && s.indexOf('>') < 0) s + else s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) /** * Writes a copy of [src] to [outFile] with the [index]-th removed @@ -234,25 +575,40 @@ object PlateExtractor { return "data:image/png;base64," + android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) } - /** Keeps only elements whose objectid is in [keep]. */ - private fun filterBuild(xml: String, keep: Set): String { - if (keep.isEmpty()) return xml - val match = BUILD_BLOCK.find(xml) ?: return xml - val open = match.groupValues[1] - val body = match.groupValues[2] - val close = match.groupValues[3] + /** Parse a 3MF 3×4 transform (12 row-major values); identity when absent. */ + private fun parseTransform(s: String?): DoubleArray { + val t = DoubleArray(12) + t[0] = 1.0; t[4] = 1.0; t[8] = 1.0 + if (s.isNullOrBlank()) return t + val parts = s.trim().split(Regex("\\s+")) + for (i in 0 until minOf(12, parts.size)) t[i] = parts[i].toDoubleOrNull() ?: t[i] + return t + } - val kept = StringBuilder() - for (item in ITEM.findAll(body)) { - val id = ITEM_OBJECT_ID.find(item.value)?.groupValues?.get(1)?.toIntOrNull() ?: continue - if (id in keep) kept.append("\n ").append(item.value.trim()) + /** + * Compose two 3MF transforms as augmented 4×4 matrices (row-vector + * convention). Result = A×B, i.e. "apply B first, then A" — pass + * (component, item) so a point maps component-local → object-space → world. + */ + private fun composeTransforms(a: DoubleArray, b: DoubleArray): DoubleArray { + val r = DoubleArray(12) + for (i in 0..3) { + for (j in 0..2) { + var sum = 0.0 + for (k in 0..2) sum += a[i * 3 + k] * b[k * 3 + j] + if (i == 3) sum += b[9 + j] + r[i * 3 + j] = sum + } } - // Never emit an empty build — fall back to the original if nothing matched. - if (kept.isEmpty()) return xml + return r + } - return xml.substring(0, match.range.first) + - open + kept.toString() + "\n " + close + - xml.substring(match.range.last + 1) + private fun formatTransform(t: DoubleArray): String = t.joinToString(" ") { fmtFloat(it) } + + private fun fmtFloat(v: Double): String { + if (kotlin.math.abs(v) < 1e-12) return "0" + if (v.toInt().toDouble() == v) return v.toInt().toString() + return "%.7g".format(v) } private fun readEntryText(file: File, entryName: String): String? = try { diff --git a/app.json b/app.json index c1b5ab1..b6ce797 100644 --- a/app.json +++ b/app.json @@ -24,7 +24,7 @@ }, "android": { "package": "org.crabcore.u1control", - "versionCode": 16, + "versionCode": 17, "softwareKeyboardLayoutMode": "resize", "permissions": [ "android.permission.POST_NOTIFICATIONS" diff --git a/app/(tabs)/slicer.tsx b/app/(tabs)/slicer.tsx index b4d7abd..4094d67 100644 --- a/app/(tabs)/slicer.tsx +++ b/app/(tabs)/slicer.tsx @@ -14,6 +14,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useFocusEffect, useRouter } from 'expo-router'; import { colors, spacing } from '../../constants/theme'; import { + addExtractProgressListener, clearLastSlice, extractModelPlate, getGcodeFilamentGrams, @@ -111,6 +112,19 @@ type ToolLoadInfo = { blockReason: string | null; }; +const EXTRACT_SAYINGS = [ + 'Slicing the un-sliceable…', + 'Convincing triangles to behave…', + 'Aligning the molecular lattice…', + 'Counting layers like sheep…', + 'Polishing vertices to a shine…', + 'Negotiating with the build plate…', + 'Bribing the extruder…', + 'Untangling the spaghetti code…', + 'Consulting the print gods…', + 'Hammering pixels into plastic…', +]; + export default function SliceLabScreen() { const router = useRouter(); const [result, setResult] = useState({ state: 'loading' }); @@ -125,9 +139,11 @@ export default function SliceLabScreen() { const [printStart, setPrintStart] = useState({ state: 'idle' }); const [mwAuthed, setMwAuthed] = useState(false); const [plates, setPlates] = useState([]); - const [selectedPlate, setSelectedPlate] = useState<{ id: number; path: string; name: string } | null>(null); + const [selectedPlate, setSelectedPlate] = useState<{ id: number; name: string } | null>(null); const [platesFor, setPlatesFor] = useState(null); const [extracting, setExtracting] = useState(false); + const [extractProgress, setExtractProgress] = useState<{ percent: number; phase: string } | null>(null); + const [sayingIdx, setSayingIdx] = useState(0); const [preprocessOpen, setPreprocessOpen] = useState(false); const [sendProgress, setSendProgress] = useState(0); const [perToolGrams, setPerToolGrams] = useState([]); @@ -168,6 +184,14 @@ export default function SliceLabScreen() { setFilamentSlotColors(effectiveFilamentSlotColors).catch(() => {}); }, [settingsLoaded, effectiveFilamentSlotColors]); + // Rotate the playful "sayings" while the prepare overlay is up so there's + // always motion even between native progress ticks. + useEffect(() => { + if (!extracting) return; + const id = setInterval(() => setSayingIdx((i) => (i + 1) % EXTRACT_SAYINGS.length), 2400); + return () => clearInterval(id); + }, [extracting]); + // Mirror the printer list for the native print dialog's printer picker. useEffect(() => { if (!settingsLoaded) return; @@ -485,22 +509,16 @@ export default function SliceLabScreen() { }, [modelFilePath, platesFor]); const choosePlate = useCallback( - async (plate: ModelPlate) => { - if (!modelFilePath || selectedPlate?.id === plate.id) return; - setExtracting(true); - try { - const extracted = await extractModelPlate(modelFilePath, plate.id); - setSelectedPlate({ id: plate.id, path: extracted.filePath, name: plate.name }); - setSlice({ state: 'idle' }); - setUpload({ state: 'idle' }); - setPrintStart({ state: 'idle' }); - } catch (error) { - Alert.alert('Plate', error instanceof Error ? error.message : String(error)); - } finally { - setExtracting(false); - } + (plate: ModelPlate) => { + if (selectedPlate?.id === plate.id) return; + // Selection only — extraction happens in prepareAndSlice so tapping a + // plate card is instant instead of blocking on the native repack. + setSelectedPlate({ id: plate.id, name: plate.name }); + setSlice({ state: 'idle' }); + setUpload({ state: 'idle' }); + setPrintStart({ state: 'idle' }); }, - [modelFilePath, selectedPlate], + [selectedPlate], ); const prepareAndSlice = useCallback(async () => { @@ -513,11 +531,22 @@ export default function SliceLabScreen() { Alert.alert('Filament', toolLoad.blockReason); return; } - const path = selectedPlate?.path ?? download.result.filePath; - const title = selectedPlate - ? `${download.result.fileName} — ${selectedPlate.name}` - : download.result.fileName; + let path = download.result.filePath; + let title = download.result.fileName; + setExtracting(true); + const sub = addExtractProgressListener((p) => setExtractProgress(p)); + // Yield one frame so React paints the overlay BEFORE the (possibly very + // fast) native extraction + activity launch. Without this, the native call + // resolves and the Activity covers the screen before the overlay ever + // renders — so the user only sees a dulled button, never the progress bar. + await new Promise((resolve) => setTimeout(resolve, 60)); try { + if (selectedPlate) { + const extracted = await extractModelPlate(download.result.filePath, selectedPlate.id); + path = extracted.filePath; + title = `${download.result.fileName} — ${selectedPlate.name}`; + setExtractProgress({ percent: 100, phase: 'Opening slicer…' }); + } const materialProfiles = await resolveNativeMaterialProfiles( connection === 'connected' ? activeUrl : null, filamentSlots, @@ -535,6 +564,10 @@ export default function SliceLabScreen() { ); } catch (error) { Alert.alert('Prepare & Slice', error instanceof Error ? error.message : String(error)); + } finally { + sub.remove(); + setExtractProgress(null); + setExtracting(false); } }, [activeUrl, connection, download, effectiveFilamentSlotColors, filamentSlots, toolLoad, plates, selectedPlate]); @@ -973,12 +1006,6 @@ export default function SliceLabScreen() { ); })} - {extracting ? ( - - - Preparing plate… - - ) : null} ) : null} {hasModel ? ( @@ -997,7 +1024,11 @@ export default function SliceLabScreen() { > - {plates.length > 1 && !selectedPlate ? 'Pick a plate above' : 'Prepare & Slice'} + {extracting + ? 'Preparing plate…' + : plates.length > 1 && !selectedPlate + ? 'Pick a plate above' + : 'Prepare & Slice'} ) : null} @@ -1039,6 +1070,36 @@ export default function SliceLabScreen() { ) : null} + {extracting ? ( + + + + {selectedPlate ? `Preparing ${selectedPlate.name}` : 'Preparing model'} + + {extractProgress ? ( + <> + + + + {extractProgress.percent}% + {extractProgress.phase} + {EXTRACT_SAYINGS[sayingIdx]} + + ) : ( + <> + + Opening slicer… + + )} + + + ) : null} + setPreprocessOpen(false)} @@ -1341,6 +1402,77 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: spacing.sm, }, + prepareOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(8, 10, 12, 0.82)', + justifyContent: 'center', + alignItems: 'center', + padding: spacing.lg, + }, + prepareCard: { + backgroundColor: colors.card, + borderColor: colors.border, + borderWidth: 1, + borderRadius: 14, + padding: spacing.lg, + width: '82%', + maxWidth: 340, + alignItems: 'center', + gap: spacing.md, + }, + prepareTitle: { + color: colors.text, + fontSize: 15, + lineHeight: 19, + fontWeight: '800', + textAlign: 'center', + }, + progressTrack: { + width: '100%', + height: 8, + borderRadius: 4, + backgroundColor: colors.border, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + borderRadius: 4, + backgroundColor: colors.primary, + }, + progressPct: { + color: colors.text, + fontSize: 22, + lineHeight: 26, + fontWeight: '800', + fontVariant: ['tabular-nums'], + textAlign: 'center', + }, + preparePhase: { + color: colors.subtext, + fontSize: 13, + lineHeight: 17, + fontWeight: '600', + textAlign: 'center', + }, + prepareSaying: { + color: colors.subtext, + fontSize: 12, + lineHeight: 16, + fontStyle: 'italic', + fontWeight: '500', + textAlign: 'center', + }, + prepareSub: { + color: colors.subtext, + fontSize: 12, + lineHeight: 16, + fontWeight: '600', + textAlign: 'center', + }, body: { color: colors.text, fontSize: 13, diff --git a/package.json b/package.json index 4183710..7798bfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "u1-control", - "version": "1.2.6", + "version": "1.2.7", "license": "AGPL-3.0-or-later", "main": "expo-router/entry", "private": true, diff --git a/services/nativeSlicer.ts b/services/nativeSlicer.ts index 2cfbc3b..008892f 100644 --- a/services/nativeSlicer.ts +++ b/services/nativeSlicer.ts @@ -241,6 +241,17 @@ export async function extractModelPlate(path: string, plateId: number): Promise< return nativeModule.extractPlate(path.replace(/^file:\/\//, ''), plateId); } +export type ExtractProgress = { percent: number; phase: string }; + +/** + * Subscribes to native "extractProgress" events emitted while a plate is being + * repacked (byte-counted over the two streaming passes). Returns an EmitterSubscription + * (call .remove() when done). + */ +export function addExtractProgressListener(cb: (p: ExtractProgress) => void) { + return DeviceEventEmitter.addListener('extractProgress', cb); +} + /** * Slices an STL/3MF with the native engine. Accepts a file:// uri or plain path. * onProgress receives native "HelixSliceProgress" events while the slice runs.