Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'}\""
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
"android": {
"package": "org.crabcore.u1control",
"versionCode": 16,
"versionCode": 17,
"softwareKeyboardLayoutMode": "resize",
"permissions": [
"android.permission.POST_NOTIFICATIONS"
Expand Down
186 changes: 159 additions & 27 deletions app/(tabs)/slicer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<LoadState>({ state: 'loading' });
Expand All @@ -125,9 +139,11 @@ export default function SliceLabScreen() {
const [printStart, setPrintStart] = useState<PrintStartState>({ state: 'idle' });
const [mwAuthed, setMwAuthed] = useState(false);
const [plates, setPlates] = useState<ModelPlate[]>([]);
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<string | null>(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<number[]>([]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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,
Expand All @@ -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]);

Expand Down Expand Up @@ -973,12 +1006,6 @@ export default function SliceLabScreen() {
);
})}
</ScrollView>
{extracting ? (
<View style={styles.loadingRow}>
<ActivityIndicator color={colors.primary} />
<Text style={styles.body}>Preparing plate…</Text>
</View>
) : null}
</View>
) : null}
{hasModel ? (
Expand All @@ -997,7 +1024,11 @@ export default function SliceLabScreen() {
>
<MaterialCommunityIcons name="cube-scan" size={20} color={colors.text} />
<Text style={styles.buttonText}>
{plates.length > 1 && !selectedPlate ? 'Pick a plate above' : 'Prepare & Slice'}
{extracting
? 'Preparing plate…'
: plates.length > 1 && !selectedPlate
? 'Pick a plate above'
: 'Prepare & Slice'}
</Text>
</TouchableOpacity>
) : null}
Expand Down Expand Up @@ -1039,6 +1070,36 @@ export default function SliceLabScreen() {
) : null}
</ScrollView>

{extracting ? (
<View style={styles.prepareOverlay}>
<View style={styles.prepareCard}>
<Text style={styles.prepareTitle}>
{selectedPlate ? `Preparing ${selectedPlate.name}` : 'Preparing model'}
</Text>
{extractProgress ? (
<>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{ width: `${Math.max(2, Math.min(100, extractProgress.percent))}%` },
]}
/>
</View>
<Text style={styles.progressPct}>{extractProgress.percent}%</Text>
<Text style={styles.preparePhase}>{extractProgress.phase}</Text>
<Text style={styles.prepareSaying}>{EXTRACT_SAYINGS[sayingIdx]}</Text>
</>
) : (
<>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.prepareSub}>Opening slicer…</Text>
</>
)}
</View>
</View>
) : null}

<PrintPreprocessDialog
visible={preprocessOpen}
onClose={() => setPreprocessOpen(false)}
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
11 changes: 11 additions & 0 deletions services/nativeSlicer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down