An iOS app that turns a photo of an empty kitchen drawer into a printer-ready .gcode.3mf file with custom-fitted, color-coded organizer modules — all on-device.
- What is Drawer?
- Demo & Screenshots
- Features at a Glance
- Install
- System Architecture
- The User Journey
- Codebase Tour
- Subsystem Deep-Dives
- Data Model
- Persistence
- Glossary for Non-Coders
- Build & Run
- License
Kitchen drawers are chaotic. Cutlery slides, spices roll, batteries mingle with rubber bands. Off-the-shelf organizers are sized for some drawer — never yours. Custom inserts cost a fortune; designing your own takes CAD skills most people don't have.
Drawer turns your iPhone into an end-to-end drawer-organization studio:
- Scan an empty drawer with your phone's camera (LiDAR if your device has it; otherwise a credit-card reference for scale).
- Mark obstacles — rails, raised areas, drain holes, dishwasher screws — that the layout should avoid.
- Confirm the auto-detected dimensions on a live overlay.
- Pick a category — Utensils, Spices, Office Supplies, etc., plus your own custom-defined modules.
- The layout engine generates a custom layout where related items end up physically adjacent (forks/spoons/knives in a tight row, spice rows stacked, pen-tray + sticky notes + index cards as a writing zone) and routes around any obstacles you marked.
- Add stacked tiers if you want a 2-level organizer; the engine emits real locating-lip geometry so the top tier doesn't slide.
- Preview the result in an interactive 3D viewer with an explode toggle, or in an AR overlay positioned in your real drawer at real scale.
- Group related drawers into a kitchen plan and design your whole kitchen as one project.
- Export a printer-ready
.gcode.3mffor the Bambu Lab A1 with on-device slicing, track print progress with a Live Activity, and see filament cost computed from your $/kg setting. Modules bigger than the printer bed automatically split into snap-together pieces.
Everything happens on your phone. No cloud. No subscription.
The core technical challenges:
- Accurate measurement from a photo. Mixing ARKit LiDAR depth, Vision rectangle detection, and a credit-card scale reference fallback.
- Context-aware layout. Bin-packing is NP-hard and naive solvers will scatter related items. Drawer treats each semantic group as a cohesive block, chooses the arrangement (row / column / wrapped grid) that matches the items' natural shape, and respects user-marked keep-out zones.
- A real on-device slicer for Bambu A1. Not a third-party plugin — actual G-code generation, AMS lite tool-change planning, and the 16-file
.gcode.3mfpackage format Bambu Studio expects. - AR at real scale, on a real drawer. ARKit world tracking + plane detection + the same mesh data the print pipeline uses, so the overlay is exactly what will print.
- Mechanical interlocks for stacking. Tier-2 modules carry a smaller-footprint locating lip that drops into the tier-1 cavity — a real, physical, geometric solution to the "top tray slides around" problem.
- Auto-split for oversize. When a tray exceeds the printer bed, automatic bisection produces snap-together pieces with proper labeling so the user can assemble them in the drawer.
Add captured GIFs / screenshots here when ready. Suggested clips:
- Home screen with the Liquid Glass tab bar in motion
- LiDAR scan locking onto the drawer rim with the live quad overlay
- The blueprint view animating items in after layout generation
- The 3D preview spinning, showing per-tray colors
- A
.gcode.3mfopening in Bambu Studio with the slice intact
| Area | What It Does |
|---|---|
| Measurement | LiDAR depth scanning with stability-locked capture; falls back to a credit-card reference scale when LiDAR is unavailable; manual override always available. |
| Obstacle marking | Tag rails, drain holes, dishwasher screws, and raised areas as keep-out zones. The packer routes around them. |
| Categories | 7 built-in purposes (Utensils, Junk Drawer, Spices, Baking Tools, Office Supplies, Linens, Custom) with curated organizer catalogs per category. |
| Custom modules | Define your own modules (width × depth × name × color), persisted across drawers via the user-template store. |
| Smart Layout | Group-aware shelf packer that places related items as cohesive blocks — cutlery in tight rows, spices stacked, writing-zone items in a 2-row grid. Honors marked obstacles. |
| Multi-tier stacking | Add a tier-2 organizer above any tray. Visual drawer-map picker shows where you're stacking; side-view preview shows fit. Real locating-lip geometry keeps the top tray from sliding. |
| Edit Organizers | Add or remove individual organizers post-generation; the engine lays them out with the same context-aware logic. |
| AR Drawer Preview | ARKit-based pass-through. Tap to drop the layout on the detected floor at real scale. Tap modules to inspect dimensions, pinch to scale, twist to rotate, long-press to drag. |
| 3D View with Explode | Dedicated SceneKit 3D viewer for inspecting layouts. Tap "Explode stack" to lift tier-2 modules + their locating lips so you can see how they interlock. |
| 3D Print Preview | Interactive SceneKit viewer with auto-rotate, drag-to-rotate, pinch-to-zoom, color-coded per AMS lite slot. |
| Auto-split | Modules bigger than the printer bed automatically split into "Foo (1/2)" + "Foo (2/2)" pieces that print fine and snap together in the drawer. |
| Slicing | Native Bambu Lab A1 G-code generation: per-layer toolpaths, AMS lite color ranges, real .gcode.3mf Bambu Studio recognizes. Other printers fall back to a clean .3mf for external slicers. |
| AMS Lite | 4-slot plate, 4 coloring policies (mono, per-module, per-feature, custom), automatic per-layer filament-list computation. Real-color picker (not a Menu of black SF Symbols). |
| Filament cost | Set $/kg once, every print prep shows total cost, per-gram cost, and grams. |
| Live Activity / Print tracker | After exporting, start an in-app countdown. ActivityKit infrastructure ready for Lock Screen / Dynamic Island once a Widget Extension target is added. |
| Kitchen Plans | Group multiple drawers into a single kitchen project with per-drawer location labels. Design your entire kitchen as one design. |
| Persistence | Saved drawers, custom templates, kitchen plans, and cost setting stored in UserDefaults. Photo thumbnails included. |
| Liquid Glass UI | Real iOS 26 .glassEffect() material on the bottom tab bar, AR controls, and 3D viewer chips — not a custom approximation. |
| Animations | Symbol effects, phase animators, matched geometry, sensory feedback, staggered entry — every interaction has a small physics-based response. |
⚠️ iOS doesn't allow installing arbitrary.ipafiles by direct download. This release ships through the open-source iOS sideloading pattern: download the IPA from the Releases page, then have a sideloader re-sign it with your own Apple ID. Below are the realistic steps.
| Requirement | Detail |
|---|---|
| iOS version | 26.2 or later — Drawer uses Liquid Glass APIs that don't exist on earlier iOS |
| Device | iPhone or iPad. LiDAR-equipped models (iPhone 12 Pro and newer Pro models, iPad Pro M-series) get the best measurement experience; non-LiDAR devices fall back to a credit-card reference scan |
| Apple ID | Any Apple ID — used by the sideloader to re-sign the IPA |
| A computer | Mac or Windows PC, used once to set up the sideloader |
AltStore is a free, open-source app installer that re-signs IPAs with your Apple ID and pushes them to your iPhone. It's how most open-source iOS apps are distributed.
-
Install AltStore on your computer
- Mac: download AltServer from altstore.io, drag to Applications, launch
- Windows: download AltServer for Windows, run the installer, install iTunes + iCloud (required for the WiFi sync layer)
-
Install AltStore on your iPhone
- Plug your iPhone into your computer via USB
- In the menu bar (Mac) or system tray (Windows), click the AltServer icon → "Install AltStore" → pick your iPhone
- Sign in with your Apple ID when prompted (this is what re-signs the IPA later)
-
Trust the developer profile
- On your iPhone: Settings → General → VPN & Device Management → tap your Apple ID under "Developer App" → Trust
- You should now see the AltStore app on your home screen
-
Download the IPA
- Go to Releases on this repo
- Tap the
Drawer-vX.Y.Z.ipalink to download it to your iPhone - Tap Open in AltStore when iOS asks where to send the file
-
AltStore re-signs and installs Drawer
- AltStore takes ~1-2 minutes to re-sign the IPA with your Apple ID and push it to the phone
- When done, Drawer appears on your home screen
- Sideloadly — Mac/Windows GUI tool that's a one-shot install (no AltServer running in the background). Same Apple-ID-based re-signing, same 7-day expiry on free accounts.
- TrollStore — works only on specific iOS versions with a particular CoreTrust bug. Permanent install, no expiry, no Apple ID required. Check the TrollStore compatibility list to see if your iOS version is supported.
- AltStore PAL (EU only) — Apple-sanctioned third-party marketplace, available in the EU under the Digital Markets Act.
If you'd rather build the app yourself in Xcode (avoids the IPA + sideloader dance entirely — Xcode signs and installs directly to your phone), see Section 12 — Build & Run. You'll need an Apple ID and Xcode 26+.
- This is pre-release software. UI is polished but you should expect rough edges in measurement accuracy and slicer edge cases. Don't run a 30-hour print without first verifying the
.gcode.3mfin Bambu Studio. - No warranty, no support contract. See the MIT
LICENSE. - Sideloading uses your personal Apple ID. AltStore and Sideloadly are reputable open-source tools that don't expose your credentials, but you're trusting them with your Apple ID for the duration of the install.
- Apple may change sideloading rules. This install method has worked since 2019 but Apple controls iOS — future iOS versions could tighten or loosen sideloading. Check the AltStore project for current status.
The app is layered: SwiftUI views at the top, stateless engines and services below them, and a single persistent store at the bottom.
- Views (blue) — SwiftUI screens. They own UI state but delegate all logic to engines.
- Engines (purple) — pure functions / classes that take inputs and return outputs. No UI dependencies.
- Slicers (green) — pluggable through the
SlicerEngineprotocol. - Bambu pipeline (orange) — specialized modules that together produce a real
.gcode.3mf. - Persistence (magenta) —
DrawerStore, anObservableObjectthat loads/saves toUserDefaults.
This is what happens when a user goes from a messy drawer to a printable file.
Each step gates the next: you can't pick a purpose before confirming a measurement, and you can't slice before a layout exists.
The project is ~14,000 lines of Swift across 32 files (v0.2.0 added 5 files for the new features). Every file has one job.
Drawer/
├── DrawerApp.swift ← App entry point (17 lines, just hosts ContentView)
├── ContentView.swift ← Root UI: home tab, page TabView, Liquid Glass bar
├── DesignSystem.swift ← Shared tokens: GlassCard, PressableStyle, accent colors
│
├── CaptureView.swift ← Camera + scan UI, overlay, capture flow
├── CameraService.swift ← AVFoundation session + ARKit LiDAR scan service
├── MeasurementEngine.swift ← Vision rectangle detection + credit-card scaling
├── MeasurementReviewView.swift ← Adjust the auto-detected quad and dimensions
├── ObstacleMarkingView.swift ← Mark forbidden zones in the drawer (rails, drains, …) [v0.2]
│
├── DrawerModels.swift ← Core data types: measurement, layout, obstacles,
│ user templates, kitchen plans, saved drawer
│
├── PurposeSelectionView.swift ← Pick a category (utensils, spices, …)
├── LayoutEngine.swift ← Catalogs + group-aware packing algorithm
│ (now obstacle- and user-template-aware)
├── LayoutResultView.swift ← Blueprint + share/save/print/AR/3D actions
├── Layout3DView.swift ← Standalone 3D viewer with explode for tier stacks [v0.2]
├── SavedDrawersView.swift ← Persisted-drawers list + Kitchen Plans entry
├── KitchenPlanView.swift ← Multi-drawer kitchen project list + detail view [v0.2]
├── EditOrganizersSheet.swift ← Add/remove organizers, custom dimensions, tier stacking
│
├── PrintModels.swift ← Print-pipeline types: filament, printer, settings,
│ modules (with tier, lip, split-part metadata)
├── PrintModelGenerator.swift ← Layout → printable modules + meshes
│ (auto-split + tier-2 locating-lip generation)
├── Print3DPreview.swift ← SceneKit viewer (drag-rotate, auto-spin, AMS colors)
├── PrintPrepView.swift ← Print prep UI: AMS slots, materials, cost calculator,
│ slice & export, color picker, print tracker
├── PrintActivity.swift ← Live Activity + in-app PrintTrackerView [v0.2]
├── ARDrawerPreviewView.swift ← ARKit pass-through preview at real scale [v0.2]
│
├── SlicerEngine.swift ← Slicer protocol + DiagnosticSlicerEngine fallback
├── ThreeMFExporter.swift ← Generic .3mf for non-Bambu printers (incl. ZipWriter)
│
├── BambuA1SlicerEngine.swift ← Native A1 slicer: orchestrates layers → gcode → package
├── TrayLayerPlanner.swift ← Per-layer rectangular toolpaths + zigzag bottom infill
├── AMSLiteColorPlanner.swift ← Slot assignment + per-layer filament-list ranges
├── BambuA1GcodeEmitter.swift ← Renders HEADER_BLOCK / CONFIG_BLOCK / EXECUTABLE_BLOCK
├── BambuGcode3MFPackager.swift ← Assembles the 16-file Bambu OPC ZIP
├── BambuPackageMetadata.swift ← XML/JSON payloads (3dmodel.model, plate_1.json, etc.)
├── BambuReferenceAssets.swift ← Bambu A1 identifiers + filament catalog (PLA/PETG/…)
└── BambuThumbnailRenderer.swift ← PNG thumbnails Bambu Studio shows in the file picker
Drawer needs to know how big your drawer is, in inches. There are two ways:
- LiDAR — modern iPhones (Pro models since the 12 Pro) have a real depth sensor. Drawer projects four "rays" through the corners of the on-screen rectangle, reads the depth at each, converts those depths to 3D world points, and measures the distances between them.
- Camera + credit card — devices without LiDAR can't measure depth, so the user places a credit card flat in the drawer. The card is exactly 3.370″ × 2.125″ (ISO/IEC 7810 ID-1, the international standard). Drawer detects both rectangles, divides the card's pixel size by its real size to get a "pixels per inch" ratio, and applies that to the drawer rectangle.
Both paths can be overridden manually on the review screen.
LiDARScanService.ScanState:
.initializing → .searching → .tracking → .stable ─┐
├→ user can capture
.failed(reason) ──┘
The shutter is only enabled when scanState == .stable. To reach .stable:
- ARKit tracking quality > 0.6
- Scene-depth confidence > 0.65 at all four corners
- ≥ 6 measurements collected, with width/depth standard deviation small enough
- Detected rectangle quad is geometrically valid (positive area, all four corners visible)
The history buffer holds 12 samples; once stable, the final measurement is the average of the most recent stable samples. This kills jitter from hand shake.
| Framework | What it's used for |
|---|---|
| ARKit | ARWorldTrackingConfiguration with .sceneDepth semantics; ARFrame.smoothedSceneDepth for the depth map; ARPlaneAnchor for the floor plane (used by the height estimate) |
| Vision | VNDetectRectanglesRequest finds the drawer rim quad in the camera frame, throttled to 4 Hz; same request type with different parameters detects the credit card on the non-LiDAR path |
| AVFoundation | Photo capture + camera permission on the non-LiDAR path |
| simd | Camera-intrinsics matrix math for unprojecting 2D corners into 3D world space |
realCardWidthInches = 3.370 ← ISO/IEC 7810 ID-1
realCardHeightInches = 2.125
scaleLong = refPixelLong / 3.370
scaleShort = refPixelShort / 2.125
scale = (scaleLong + scaleShort) / 2 ← average of both axes
drawerWidthInches = drawerPixelWidth / scale
drawerDepthInches = drawerPixelHeight / scale
The MeasurementEngine enforces sanity bounds (3″–60″ width, 3″–40″ depth) and falls back to a default 15″ × 20″ × 4″ measurement on failure, surfacing an error banner so the user knows to type values in.
This is where the app earns its keep. Every other subsystem feeds in or out of LayoutEngine.
You have a drawer. You have a list of organizer modules to fit in it. The layout engine has to figure out where to put each one so that:
- They actually fit (basic geometry).
- Items that semantically belong together end up touching, in a layout that looks like how a real organizer would arrange them. Forks, spoons, and knives become a tight row of slots — not fork in one corner, knives in another.
A naive bin-packer optimizes only for area. Drawer optimizes for organization sense.
Each DrawerPurpose has a hand-curated list of OrganizerTemplates. Every template has six fields:
| Field | Purpose |
|---|---|
id |
Stable identifier (e.g. utensils.fork) |
name |
Human label (e.g. "Fork Section") |
width, height |
Physical size in inches (height = depth in the drawer) |
hue |
Visual tint on the blueprint (cosmetic) |
priority |
Used to mark "recommended" defaults (priority >= 5) |
group |
Semantic category — items in the same group are placed adjacent |
groupOrder |
Reading order within the group (1 = leftmost / topmost) |
For example, the Utensils catalog has these groups:
main_tray → large_tray (groupOrder 1)
eating → fork (1) → spoon (2) → knife (3)
cooking → spatula (1) → peeler (2) → small_tools (3)
specialty → chopstick (1)
A canonical group order is defined per purpose:
case .utensils:
return ["main_tray", "eating", "cooking", "specialty"]That ordering is what makes the drawer "read" left-to-right, top-to-bottom in a way humans expect.
Instead of packing one template at a time, the engine builds a placement unit for each group — a single rectangle that contains all the group's items in pre-arranged relative positions. The unit is then placed on the drawer floor as one cohesive block.
For a group, the engine generates six candidate arrangements:
Each arrangement uses a 0.1″ intra-group padding (vs. 0.25″ between groups). That tight inner gap is what makes a fork section look part of the same tray instead of three separate parts.
Compactness alone isn't enough. The most-compact arrangement for cutlery (3″×10″ each) is actually a vertical column at 3″ × 30.2″ (90.6 sq in) — but real cutlery trays use rows. So we score with two preferences:
score = totalW × totalH × factor
factor *= 0.75 if arrangement matches the preferred axis
factor *= 1.50 if items are rotated from their natural orientation
The preferred axis is computed from the median item shape:
- Tall-narrow items (cutlery slots, narrow holders) → prefer row so each item points back-to-front.
- Wide-short items (spice rows, pen trays) → prefer column so they stack like real spice racks.
Once each group has a winning unit, the packer treats them just like a regular shelf packer treats single items:
Each shelf is tagged with a primaryGroup — the group of the first unit dropped on it. This is what enforces "groups don't share shelves with other groups when there's room."
Recommended preset places [large_tray, fork, spoon, knife, …]. Drawer dimensions: usable area 13.5″ × 21.5″ after the 0.25″ outer padding.
Result: large_tray on shelf 1, the eating group as a tight stacked column on shelf 2 — fork directly above spoon directly above knife, separated only by 0.1″ gaps. Cutlery stays together; you'd never get fork in one row and knife in another. In a wider drawer where Row(false) fits, the same algorithm produces the canonical side-by-side cutlery arrangement instead.
| Group | Items | Drawer | Best Arrangement | Block Size |
|---|---|---|---|---|
| Eating | fork, spoon, knife | 14″ × 22″ | Row, original | 9.2″ × 10″ — real cutlery row |
| Eating (shallow) | fork, spoon, knife | 14″ × 14″ | Column, rotated | 10″ × 9.2″ — stacked rotated cutlery |
| Spice rows | row1, row2 | 16″ × 12″ | Column, original | 12″ × 6.1″ — stacked spice rack |
| Writing | pen, sticky, index | 12″ × 18″ | Wrap, original | 9.1″ × 7.1″ — pen on top, sticky + index below |
| Fasteners | clip, stapler | 12″ × 18″ | Row, original | 9.1″ × 3″ — side-by-side |
LayoutEngine.adding(_:to:) checks whether a same-group peer is already in the drawer:
- Peer present → full re-pack (so the new fork lands next to existing knife).
- No peer → cheap
previewAddingslot-finder, which is also group-aware (prefers a fresh shelf rather than slotting into a different group's zone).
regenerateLayout shuffles the group order but never the items inside a group. So:
- Sometimes the cutlery zone is above the cooking zone, sometimes below
- But fork/spoon/knife are always a tight row
Every shelf-placement decision passes through collidesWithObstacles(_:x:y:w:h:), which checks the placement rectangle against the DrawerMeasurement.obstacles list (with a 0.15″ safety pad). All four placement strategies — same-group, new shelf, cross-group, individual fallback — honor the keep-out zones, so you can mark a dishwasher screw or a raised plastic foot in ObstacleMarkingView and the engine will route around it.
UserDefinedTemplate instances live in DrawerStore.userTemplates. The layout engine pulls them into the catalog via LayoutEngine.makeTemplate(from:) and treats them as members of a user_custom group, so they cluster together visually and don't disrupt the curated catalogs.
Once the layout is set, every organizer item becomes a hollow box (an open-top tray) in 3D. The mesh-generator PrintModelGenerator walks the layout and produces a triangle mesh for each module, plus combined meshes for export.
A tray module is six surfaces:
┌────────────────┐ ← open top (no triangles)
│ │
│ (cavity) │
outer wall │ │ outer wall
│ │
│ ┌────┐ │
│ │ ▒ │←── inner cavity floor
│ ├────┤ │
│ ▒ │ ▒ │ ▒ │
│ │ │ │
└─────┴────┴─────┘
↑
bottom solid band (~1.2 mm thick)
The generator emits:
- The outer box surface without a top
- The inverted inner cavity surface without a top (normals pointing inward)
- A flat top rim connecting the outer top edge to the inner top edge
- (Implicit closed bottom from the bottom band)
A PrintableModule is fully parametric:
struct PrintableModule {
var outerWidthMm, outerDepthMm, heightMm
var originXMm, originYMm // position in the drawer
var wallThicknessMm // 1.6 mm default
var bottomThicknessMm // 1.2 mm default
var cornerRadiusMm // 2.0 mm
var tintHex // color from layout
var tier: Int // 1 = base, 2 = stacked on top
var zOffsetMm // vertical offset (tier-2 sits at baseHeight)
var splitPartIndex, splitPartCount // for auto-split modules
var originalModuleId // groups split / stacked pieces
var isLocatingLip // tier-2 anti-slide protrusion
}It also carries methods like fitsBed(_ printer) (bed-size check) and gramsForFilament(_:infillPercent:) (filament estimate using wall surface area + bottom area + cavity infill).
If a module's footprint exceeds the printer bed and settings.autoSplitOversized is enabled, PrintModelGenerator.splitOversized(_:printer:) bisects the module along its longest axis into 2+ pieces sized to fit. Each piece is labeled "Foo (1/2)", "Foo (2/2)", etc., and carries its splitPartIndex / splitPartCount / originalModuleId so the export reports them as parts of the same original tray. The split runs recursively if a single bisection isn't enough.
A tier-2 module that just sits flat on tier-1's rim would slide around. Drawer ships a real mechanical interlock: when generating modules for a tier-2 layout item, PrintModelGenerator.makeTier2Lip(...) emits a second PrintableModule directly underneath:
- Footprint inset by 2.6 mm per side from the parent's outer (clears tier-1's 1.6 mm wall + 1 mm slip-fit clearance)
- Depth of 6 mm — long enough to locate, short enough to leave the bottom 28 mm of tier-1's cavity usable for storage
- Same XY position as the tier-2 body, so during printing the two parts fuse into one continuous plastic piece
The lip is marked isLocatingLip = true so user-facing module counts (estimate summary, export-result count, 3D preview badge) skip it — it's a print-mechanism detail, not a separate object the user thinks about.
This is the most ambitious subsystem in the app. It produces a real .gcode.3mf for the Bambu Lab A1 that opens directly in Bambu Studio with a live print preview, calibration block, AMS lite tool changes, and an end-of-print ramp.
Real general-purpose slicers (PrusaSlicer, CuraEngine) are massive C++ codebases — embedding one in an iOS app means a separate native target, build system work, licensing review, and serious binary size. So Drawer ships a specialized slicer that only knows how to slice axis-aligned hollow rectangular trays — exactly the geometry the layout engine produces. Within that domain, every step is correct: real perimeter loops, real zigzag bottom infill, real per-layer filament-list ranges, real Bambu OPC package.
SlicerProvider.engine(for:) routes both Bambu Lab A1 and Bambu Lab A1 mini to the native engine; every other printer profile (Bambu P1S / X1C / H2D, generic 220 mm) falls back to DiagnosticSlicerEngine (estimates only) + ThreeMFExporter (generic 3MF you can drop into any other slicer).
For each module, for each Z step, the planner emits:
| Layer Region | Contents |
|---|---|
| All layers | Outer-wall perimeter loops (rect inset by (loop + 0.5) × lineWidth); inner-cavity perimeters (only above the bottom band) |
| Bottom band (z ≤ 1.2 mm) | Solid zigzag infill spanning the inner cavity, lines alternating axis per layer for cross-strength |
| Layer 0 only | Skirt loops around the union bounding box of all modules, 3 mm offset, 1 loop |
Every emitted path is a ToolPath carrying a feature: FeatureKind (.outerWall, .innerWall, .bottomSurface, .skirt) and a colorSlot: Int. The slot is what feeds the AMS lite tool-change logic later.
Bambu's A1 with the AMS lite has 4 filament slots. The planner supports four coloring policies:
| Policy | Behavior |
|---|---|
monoPlate |
Whole plate prints in slot 0 |
perModule |
Each module rotates through actives (round-robin) |
perFeature |
Outer walls = slot 0, inner walls = slot 1, bottom = slot 2 (when available) |
custom |
User-driven (module, feature) → slot overrides |
For the G-code, the planner computes:
computeLayerFilamentRanges— collapses contiguous layer ranges where the filament-slot set is identical, producing the<layer_filament_lists>Bambu Studio expects.computeFilamentUsage— sums extrusion volume per slot using the formulavolume = pathLength × lineWidth × layerHeight, then converts to filament length and grams via the material density (PLA=1.24, PETG=1.27, etc. fromBambuFilamentCatalog).
Bambu A1 G-code has three major blocks:
; HEADER_BLOCK_START ← filament length/weight, max Z, time prediction, density
; HEADER_BLOCK_END
; CONFIG_BLOCK_START ← bed temp, nozzle temp, layer height, AMS enable
; CONFIG_BLOCK_END
; EXECUTABLE_BLOCK_START
M73 P0 R<minutes> ← progress prediction
M201 X12000 Y12000 … ← acceleration limits
…calibration + heat-up + wipe-tower-free flush prep…
G1 X… Y… Z… E… ← every move, with relative E (M83)
…end-of-print ramp…
; EXECUTABLE_BLOCK_END
All extrusion is relative (M83) to avoid floating-point drift across thousands of moves. The emitter accumulates extrusion per move:
E = pathLength × lineWidth × layerHeight / filamentCrossSection
filamentCrossSection = π × (1.75 / 2)²
The whole gcode is hashed (MD5 via CryptoKit) and the digest goes into Metadata/plate_1.gcode.md5, which Bambu Studio verifies on load.
A .gcode.3mf is a ZIP archive of 16 OPC files in a specific order. Drawer writes them all using its own ZipWriter (a STORE-method ZIP, no compression, defined in ThreeMFExporter.swift):
[Content_Types].xml ← MIME map
Metadata/plate_1.png ← large thumbnail
Metadata/plate_1_small.png ← small thumbnail
Metadata/plate_no_light_1.png ← no-light variant
Metadata/top_1.png ← top-down thumbnail
Metadata/pick_1.png ← pick-list thumbnail
Metadata/plate_1.json ← per-plate JSON config
3D/3dmodel.model ← 3MF mesh
Metadata/project_settings.config ← printer + filament profile
Metadata/plate_1.gcode.md5 ← MD5 hash of the gcode
Metadata/plate_1.gcode ← the actual gcode
Metadata/_rels/model_settings.config.rels
Metadata/model_settings.config
Metadata/cut_information.xml
Metadata/slice_info.config ← layer ranges + filament usage
_rels/.rels ← root relationship file
Constants like BambuA1Identifiers.printerModelId = "N2S" and the per-material trayInfoIdx codes (GFA00 for Bambu PLA Basic, GFG00 for PETG HF, etc.) are reverse-engineered from real Bambu Studio exports so the file loads cleanly.
BambuReferenceAssets.swift ships a per-material profile that mirrors Bambu Studio's defaults:
| Material | Nozzle °C | Bed °C | Density g/cm³ | Max Vol Speed | Bambu Profile |
|---|---|---|---|---|---|
| PLA | 220 | 65 | 1.24 | 21 | Bambu PLA Basic @BBL A1 |
| PETG | 250 | 70 | 1.27 | 16 | Bambu PETG HF @BBL A1 |
| ABS | 250 | 90 | 1.04 | 18 | Generic ABS @BBL A1 |
| ASA | 260 | 95 | 1.07 | 16 | Generic ASA @BBL A1 |
| TPU | 230 | 45 | 1.21 | 8 | Bambu TPU 95A @BBL A1 |
These flow into both the G-code (CONFIG_BLOCK) and the project_settings.config XML.
Drawer ships two SceneKit-based 3D viewers, each tuned for a different question.
Lives inside Print Prep. Renders modules colored by their resolved AMS lite slot so the user sees the actual physical print outcome (including auto-split parts and tier-2 locating lips at reduced opacity).
Print3DPreview is a SwiftUI view wrapping SceneKit (SceneView). On every organizer / plate / assignment change, it rebuilds the scene:
The user can:
- Drag to rotate (
SceneView.allowsCameraControldoes this for free) - Pinch to zoom
- Tap "Pause" to stop the auto-rotate animation (a
SCNAction.repeatForever(rotateBy y: 2π over 22s)) - Tap "Reset" to rebuild the scene (snaps the camera back to the iso angle)
The Pause / Reset chips themselves use .glassEffect(.regular.interactive(), in: Capsule()) — real iOS 26 Liquid Glass that physically reacts to touch, sitting over the rendered scene.
Lives behind the 3D View button on the layout result screen. Purpose-built for understanding the layout — particularly when tier-2 organizers stack on tier-1 ones and the top-down blueprint can't show them clearly.
Killer feature: an Explode toggle that animates tier-2 bodies + their locating lips upward in lockstep, separating them from their tier-1 parents so you can see the interlock geometry:
- Tier-1 stays on the floor
- Tier-2 body lifts to
baseHeightMm + 60 mm - Tier-2 lip lifts to
baseHeightMm − 6 mm + 60 mm(preserving the 6 mm gap between body and lip) - A tier legend appears at the top whenever there's a tier-2 module — no clutter when not needed
The view goes through PrintModelGenerator.makeOrganizer so the geometry it shows is identical to what the print pipeline produces — including the auto-split pieces and locating lips.
The mesh uses +Z up, but SceneKit prefers +Y up. The conversion is baked into makeGeometry:
// vertex (x, y, z) ─→ SceneKit (x, z, y)
// triangle index order also flipped to keep winding consistentThe result: drawer floor stays flat (XZ plane), modules stand up correctly along Y, and node.look(at:) from the camera works without surprises.
You hold your phone over your actual drawer. Drawer detects the floor of the drawer as a horizontal plane. You tap once and the proposed layout drops into the drawer at real scale, so you can see what each tray looks like and where it'll sit.
ARDrawerPreviewView wraps an ARSCNView (SceneKit + ARKit) and runs an ARWorldTrackingConfiguration with .horizontal plane detection. As tracking improves, the SwiftUI hint banner updates ("Move the phone slowly over the drawer floor" → "Floor detected — tap to drop your layout"). The first tap raycasts to the detected plane and anchors the layout there.
Once placed, the user has four manipulation gestures, all wired through ARCoordinator:
| Gesture | Effect | API |
|---|---|---|
| Tap a module | Highlight with cyan emission glow + slide up a glass info card with the module's name, tier, and dimensions | ARSCNView.hitTest + walk parent chain to find the registered nodeToItem mapping |
| Pinch | Uniformly scale the entire layout — recalibrates if ARKit's plane scale is slightly off | UIPinchGestureRecognizer |
| Two-finger twist | Rotate the entire layout around its vertical (Y) axis | UIRotationGestureRecognizer → parent.eulerAngles.y -= rotation |
| Long-press + drag | Lift the layout off its anchor and slide it across the plane | UILongPressGestureRecognizer + view.session.raycast per .changed event |
The modules use the layout item's actual color (vibrant HSV) at 0.85 opacity — solid enough to read against busy backgrounds, translucent enough to feel like an overlay on the real-world camera feed. castsShadow = true gives each module a contact shadow on the floor plane so they don't look like they're floating.
One bug worth knowing about:
ARSCNView.scene.background.contentscontrols the camera pass-through. Setting it (even toUIColor.clear) replaces the camera with whatever you set — leading to a black void instead of the real world behind the modules. Don't touch that property.
A kitchen has many drawers. Designing them in isolation misses the bigger picture — "where do my baking supplies go relative to where I cook?". KitchenPlan lets you group multiple saved drawers into a single project, label each one with a location, and treat the whole kitchen as one design.
struct KitchenPlan {
var id: UUID
var name: String // "Main Kitchen", "Cabin Kitchen", …
var date: Date
var drawerEntries: [Entry]
struct Entry {
var id: UUID
var drawerId: UUID // SavedDrawer.id reference
var location: String // "Top drawer left of sink"
var order: Int
}
}Plans live in DrawerStore.kitchenPlans, persisted to UserDefaults. Each entry references a saved drawer by id and adds a free-form location label.
Plans don't need their own tab — there's a banner at the top of the Saved tab that shows the plan count and opens KitchenPlansListView. From there:
KitchenPlansListView ── tap plan ──▶ KitchenPlanDetailView
├── header (drawer count + total slot count)
├── per-drawer rows (thumbnail + location)
│ └── tap row ──▶ LayoutResultView
└── "Add drawer to this plan"
└── AddDrawerToPlanSheet
(only shows drawers
not already in plan)
Renaming and deleting plans live in the toolbar ellipsis.circle menu on the detail view.
After exporting a .gcode.3mf and starting the actual print on your Bambu A1, tap Track print in the print prep view to start an in-app countdown. The tracker shows current phase ("Heating bed" → "Calibrating" → "Printing" → "Finishing"), grams of filament, time remaining, and a progress bar.
PrintPrepView
↓ user taps "Track print"
PrintProgressManager.shared.start(attributes:)
├── stores PrintActivityAttributes (drawer name, printer, total grams,
│ total seconds)
├── creates ContentState (progress 0..1, remainingSeconds, statusLabel,
│ isComplete)
├── if iOS 16.1+ and Activities enabled:
│ Activity<PrintActivityAttributes>.request(...) ← Lock Screen
└── starts a 1-second Timer that ticks `tick()`
↓
tick() updates progress + statusLabel based on elapsed time;
push the new ContentState to the activity (if any) and
publish the @Published state for the SwiftUI view
↓
PrintTrackerView (in-app fallback) renders progress bar + stats + actions
To get the Live Activity rendering on the Lock Screen and Dynamic Island, add a Widget Extension target ("DrawerWidgets") in Xcode and reference PrintActivityAttributes from a shared file. Step-by-step instructions live in the header comment of Drawer/PrintActivity.swift. Until that target exists, the in-app PrintTrackerView provides equivalent visual feedback.
The Activity.request(...) call is wrapped in #if canImport(ActivityKit) and gracefully no-ops if the framework isn't available or the user has Activities disabled.
iOS 26 introduced the Liquid Glass material — a dynamic, refractive, content-aware glass effect. Drawer uses the real APIs (not a custom approximation):
HStack(spacing: 0) {
barTab(icon: "house.fill", label: "Home", index: 0)
Spacer().frame(width: 78) // cut-out for the floating FAB
barTab(icon: "archivebox.fill", label: "Saved", index: 1)
}
.padding(.horizontal, 8).padding(.vertical, 8)
.glassEffect(.regular, in: Capsule()) // ← real Liquid Glass
Button { onScan(); scanBounce += 1 } label: {
Image(systemName: "camera.viewfinder")
.symbolEffect(.bounce, value: scanBounce)
}
.glassEffect(
.regular
.tint(Color(hue: 0.6, saturation: 0.8, brightness: 0.9).opacity(0.6))
.interactive(), // ← physically reacts to touch
in: Circle()
).glassEffect(_:in:) is the iOS 26 API. Glass.regular.interactive() adds the touch-deformation behavior (the FAB visibly squishes when pressed). matchedGeometryEffect slides the selection pill between Home and Saved tabs.
| Polish | Where | API |
|---|---|---|
| Bouncy SF Symbols on tab switch | LiquidGlassBottomBar |
.symbolEffect(.bounce, value: …) |
| Continuous breathing logo | appHeader |
.phaseAnimator + .symbolEffect(.pulse.byLayer, options: .repeat(.continuous)) |
| Press squash on every button | DesignSystem.PressableStyle |
tighter spring on press, gentler spring on release |
| Selection haptics | tab change, FAB tap | .sensoryFeedback(.selection / .impact, trigger: …) |
| Success haptic on export | PrintPrepView |
.sensoryFeedback(.success, trigger: exportResult) |
| Staggered entry for rows | tips, purpose grid, saved drawers | per-index .animation(...delay: idx × 0.04) |
| Smooth tab page swipes | ContentView |
.animation(.spring(response: 0.5, dampingFraction: 0.85), value: selectedTab) |
| Spinner for regenerate | LayoutResultView |
.symbolEffect(.rotate, options: .speed(1.4), value: isRegenerating) |
| Auto-rotate 3D preview | Print3DPreview |
SCNAction.repeatForever(rotateBy y: 2π duration: 22) |
The full type graph that flows through the app:
Two distinct units live side by side in the type system:
- Inches are used everywhere on the layout side (
DrawerMeasurement,OrganizerItem). - Millimeters are used throughout the print pipeline (
PrintableModule,PrintSettings).
The conversion happens exactly once, in PrintModelGenerator.makeOrganizer, using PrintConstants.inchToMm = 25.4.
DrawerStore is an ObservableObject injected into the SwiftUI environment. It owns four persisted collections, each backed by its own UserDefaults key:
class DrawerStore: ObservableObject {
@Published var savedDrawers: [SavedDrawer] // every saved drawer
@Published var userTemplates: [UserDefinedTemplate] // custom-dimension modules (v0.2)
@Published var kitchenPlans: [KitchenPlan] // multi-drawer projects (v0.2)
@Published var costPerKg: Double = 25.0 // filament price (v0.2)
func save(_ drawer: SavedDrawer) // persistDrawers()
func addTemplate(_ template: UserDefinedTemplate) // persistTemplates()
func savePlan(_ plan: KitchenPlan) // persistPlans()
// costPerKg uses a property observer to write directly to UserDefaults
}Why UserDefaults and not Core Data / SwiftData?
- The dataset is small — a few KB per drawer plus an optional JPEG thumbnail. Even a heavy user with 50 drawers, 20 custom templates, and 5 kitchen plans stays well under 1 MB.
- Schema is flat — no relationships to traverse. Kitchen plans reference drawers by
UUID, but that's a manual lookup, not a foreign-key constraint. Codablehandles backward-compatibility cleanly.DrawerMeasurementrecognizes the legacyusedLiDAR: Boolfield from v0.0 saves and migrates it to the newsource: MeasurementSourceenum.OrganizerItem's tier/stacksOnfields andPrintableModule'stier/zOffset/splitPart*/isLocatingLipfields all default cleanly when missing, so v0.1.x saves still load on v0.2.0.
If the data ever outgrows this approach, swapping in SwiftData would only touch this one file.
| Term | Plain English |
|---|---|
| SwiftUI | Apple's modern framework for building iOS UIs by describing what the screen should look like given some state, instead of imperatively changing pixels. |
| iOS 26.2 | The version of iOS this app needs. It's required because Drawer uses Liquid Glass, which is iOS 26+. |
| Liquid Glass | The new translucent, refractive material Apple introduced in iOS 26. The bottom tab bar uses it. It "knows" what's behind it and reacts to touch. |
| ARKit | Apple's framework for augmented reality and depth sensing. Drawer uses it for LiDAR scans. |
| LiDAR | A laser-based depth sensor in newer iPhones. Lets the phone measure how far away things are with high accuracy. |
| Vision framework | Apple's image-analysis framework. Drawer uses it to find rectangles (the drawer rim, the credit-card reference) in photos. |
| SceneKit | Apple's older 3D graphics framework. Drawer uses it for the 3D preview, the 3D layout viewer, and the AR overlay because it has free camera controls (drag/pinch). |
| AR / ARKit | Augmented Reality — overlaying virtual content on the live camera feed. ARKit provides world tracking and plane detection so Drawer can find the floor of your drawer and anchor the virtual layout to it. |
| Plane detection | ARKit's ability to find flat horizontal or vertical surfaces in the camera feed (table tops, drawer floors, walls). Drawer's AR view waits for a horizontal plane before letting you tap to place. |
| ActivityKit | Apple framework introduced in iOS 16.1 for Live Activities — small persistent UI elements that show on the Lock Screen and in the Dynamic Island while a long-running task is in progress. Drawer uses it to track print progress. |
| Live Activity | An interactive widget that survives outside the app — visible on Lock Screen and Dynamic Island. Requires a Widget Extension target to render the actual UI. |
| Mesh | A 3D shape made of triangles. Each tray module is a mesh. |
| Locating lip | The small downward protrusion on the bottom of a tier-2 organizer that drops into the tier-1 cavity to keep the stack from sliding. Generated automatically and printed as a separate-but-fused piece. |
| Auto-split | When a tray's footprint is bigger than the printer bed, Drawer bisects it into two or more snap-together pieces with a labelled "(1/2)" / "(2/2)" suffix. |
| G-code | The language 3D printers speak. Every printer move (G1 X100 Y50 E0.5) is a line of G-code. |
| 3MF | The modern, ZIP-based file format that holds a 3D model + slicing settings + thumbnails, all in one file. Bambu Studio's .gcode.3mf is a 3MF with extra parts. |
| Slicer | Software that takes a 3D model and converts it into G-code by computing layer-by-layer toolpaths. |
| AMS Lite | Bambu's filament-feeding system on the A1 with up to 4 spools, allowing multi-color prints. |
| Toolpath | A sequence of points the printer's nozzle should follow, plus how much filament to extrude along the way. |
| Infill | The pattern of plastic used inside a part to give it strength without filling it solid. The trays here only have infill in the bottom band. |
| Skirt | A loose loop of plastic printed around the part(s) on layer 0 to prime the nozzle. |
| Bin packing | The classic computer-science problem of fitting rectangles into a bigger rectangle. Drawer's layout engine is a specialized variant that also respects keep-out zones (obstacles) and groups items by semantic category. |
@Published / ObservableObject |
SwiftUI's pattern for state that views automatically re-render when it changes. DrawerStore is an example. |
UserDefaults |
A built-in iOS key/value store, originally for app preferences. Small data lives here. |
- Xcode 26 or newer (ships with the iOS 26.2 SDK)
- iOS 26.2+ device or simulator (Liquid Glass APIs require it)
- A device with LiDAR (iPhone 12 Pro or later) for the best measurement experience — the app gracefully falls back to camera-only on devices without it
cd "Drawer"
xcodebuild \
-project Drawer.xcodeproj \
-scheme Drawer \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
buildOr just open Drawer.xcodeproj in Xcode and hit Cmd+R.
The project uses Xcode's synchronized file groups (PBXFileSystemSynchronizedRootGroup) — every .swift file under the Drawer/ folder is automatically part of the build target. Drop a new file in and it compiles, no .pbxproj edit needed.
Unit tests live in DrawerTests/. UI tests live in DrawerUITests/. Both run with Cmd+U.
This project is released under the MIT License — see LICENSE for the full text.
⚠️ Heads-up: the Bambu Studio reference assets inBambuReferenceAssets.swift(filament identifiers likeGFA00, the printer model idN2S, the G-code header format) are derived from inspecting Bambu's own exported files. The MIT license covers this code, not those underlying conventions — if you ship this commercially, audit those constants against Bambu's terms.
Built with SwiftUI, ARKit, Vision, SceneKit, and a lot of bin-packing math.