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