diff --git a/android/app/build.gradle b/android/app/build.gradle index 89ce4d9..b330f95 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 17 - versionName "1.2.7" + versionCode 16 + versionName "1.2.6" 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 4132f34..9f2d3ec 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,8 +415,7 @@ class HelixModelPreviewActivity : Activity() { private fun loadNativeScene(file: File) { synchronized(NativeEngineGuard.LOCK) { val lib = native ?: NativeLibrary().also { native = it } - val loaded = lib.loadModel(file.absolutePath) - if (!loaded) { + if (!lib.loadModel(file.absolutePath)) { 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 f6d1f67..db0a498 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,13 +333,11 @@ class HelixSlicerModule( return } val safeName = plate.name - .replace(Regex("""[/\\:*?"<>|\s]"""), "_") + .replace(Regex("""[/\\:*?"<>|]"""), "_") .trim() .ifBlank { "plate_$plateId" } val outFile = File(reactApplicationContext.filesDir, "plate_${plateId}_$safeName.3mf") - PlateExtractor.extractPlate(file, plate, outFile) { percent, phase -> - emitExtractProgress(percent, phase) - } + PlateExtractor.extractPlate(file, plate, outFile) if (!outFile.exists() || outFile.length() == 0L) { promise.reject("E_EXTRACT", "Failed to extract plate $plateId.") return @@ -354,16 +352,6 @@ 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 72ca815..ecad74d 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,14 +1,9 @@ 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. @@ -55,8 +50,6 @@ 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 { @@ -87,368 +80,34 @@ object PlateExtractor { } /** - * 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 . + * 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]. */ - fun extractPlate( - src: File, - plate: Plate, - outFile: File, - onProgress: ((percent: Int, phase: String) -> Unit)? = null, - ): File { + fun extractPlate(src: File, plate: Plate, outFile: File): File { val keep = plate.objectIds.toHashSet() - onProgress?.invoke(2, "Opening archive…") ZipFile(src).use { zip -> - 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) + 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) } } - } 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") + out.closeEntry() } - w.write(" \n\n") - onProgress?.invoke(99, "Finalizing…") - w.flush() - zos.closeEntry() } } return outFile } - 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("\"", """) + private val COMPONENT_PATH = Regex("]*\\bp:path=\"([^\"]+)\"", RegexOption.IGNORE_CASE) + private val RELATIONSHIP = Regex("]*\\bTarget=\"([^\"]+)\"[^>]*/?>", RegexOption.IGNORE_CASE) /** * Writes a copy of [src] to [outFile] with the [index]-th removed @@ -575,40 +234,25 @@ object PlateExtractor { return "data:image/png;base64," + android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) } - /** 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 - } + /** 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] - /** - * 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 - } + 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()) } - return r - } - - private fun formatTransform(t: DoubleArray): String = t.joinToString(" ") { fmtFloat(it) } + // Never emit an empty build — fall back to the original if nothing matched. + if (kept.isEmpty()) return xml - 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) + return xml.substring(0, match.range.first) + + open + kept.toString() + "\n " + close + + xml.substring(match.range.last + 1) } private fun readEntryText(file: File, entryName: String): String? = try { diff --git a/app.json b/app.json index b6ce797..c1b5ab1 100644 --- a/app.json +++ b/app.json @@ -24,7 +24,7 @@ }, "android": { "package": "org.crabcore.u1control", - "versionCode": 17, + "versionCode": 16, "softwareKeyboardLayoutMode": "resize", "permissions": [ "android.permission.POST_NOTIFICATIONS" diff --git a/app/(tabs)/slicer.tsx b/app/(tabs)/slicer.tsx index 4094d67..b4d7abd 100644 --- a/app/(tabs)/slicer.tsx +++ b/app/(tabs)/slicer.tsx @@ -14,7 +14,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useFocusEffect, useRouter } from 'expo-router'; import { colors, spacing } from '../../constants/theme'; import { - addExtractProgressListener, clearLastSlice, extractModelPlate, getGcodeFilamentGrams, @@ -112,19 +111,6 @@ 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' }); @@ -139,11 +125,9 @@ 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; name: string } | null>(null); + const [selectedPlate, setSelectedPlate] = useState<{ id: number; path: string; 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([]); @@ -184,14 +168,6 @@ 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; @@ -509,16 +485,22 @@ export default function SliceLabScreen() { }, [modelFilePath, platesFor]); const choosePlate = useCallback( - (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' }); + 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); + } }, - [selectedPlate], + [modelFilePath, selectedPlate], ); const prepareAndSlice = useCallback(async () => { @@ -531,22 +513,11 @@ export default function SliceLabScreen() { Alert.alert('Filament', toolLoad.blockReason); return; } - 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)); + const path = selectedPlate?.path ?? download.result.filePath; + const title = selectedPlate + ? `${download.result.fileName} — ${selectedPlate.name}` + : download.result.fileName; 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, @@ -564,10 +535,6 @@ 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]); @@ -1006,6 +973,12 @@ export default function SliceLabScreen() { ); })} + {extracting ? ( + + + Preparing plate… + + ) : null} ) : null} {hasModel ? ( @@ -1024,11 +997,7 @@ export default function SliceLabScreen() { > - {extracting - ? 'Preparing plate…' - : plates.length > 1 && !selectedPlate - ? 'Pick a plate above' - : 'Prepare & Slice'} + {plates.length > 1 && !selectedPlate ? 'Pick a plate above' : 'Prepare & Slice'} ) : null} @@ -1070,36 +1039,6 @@ 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)} @@ -1402,77 +1341,6 @@ 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 7798bfd..4183710 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "u1-control", - "version": "1.2.7", + "version": "1.2.6", "license": "AGPL-3.0-or-later", "main": "expo-router/entry", "private": true, diff --git a/services/nativeSlicer.ts b/services/nativeSlicer.ts index 008892f..2cfbc3b 100644 --- a/services/nativeSlicer.ts +++ b/services/nativeSlicer.ts @@ -241,17 +241,6 @@ 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.