Repository navigation
ios: practice meter, a drawn sample meter for scanning without one #143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: t3/ios-mvf
Are you sure you want to change the base?
Changes from all commits
6af4d8f
7f3c486
10b04e8
786bcf2
aac574f
d298dfb
dd219a0
e0e0fbf
0e9ff0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import Foundation | ||
| import HouseScanKit | ||
| import Observation | ||
| import StoreKit | ||
|
|
||
| /// The developer options sheet's settings, and whether this install may show them. | ||
| /// | ||
| /// The switch lives in the app, not in the iOS Settings app: a Settings.bundle is a file in the | ||
| /// app bundle, and a TestFlight build is the same binary the App Store later ships, so its | ||
| /// switch would show in App Store installs too. Here the sheet and the switch appear only where | ||
| /// `PracticeMeter.isAvailable` says, decided at run time from where the install came from. | ||
| @MainActor | ||
| @Observable | ||
| final class DeveloperSettings { | ||
| static let shared = DeveloperSettings(simulateAppStore: LaunchOptions().simulateAppStore) | ||
|
|
||
| /// The UserDefaults key the sheet's switch writes. A launch argument `-practiceMeter NO` | ||
| /// overrides it for one run (UserDefaults' argument domain), which the UI tests use. | ||
| static let practiceMeterKey = "practiceMeter" | ||
|
|
||
| /// Where this install came from; `.unknown` until StoreKit answers. | ||
| private(set) var environment: PracticeMeter.InstallEnvironment | ||
|
|
||
| init(simulateAppStore: Bool) { | ||
| if simulateAppStore { | ||
| environment = .appStore | ||
| return | ||
| } | ||
| #if DEBUG | ||
| environment = .development | ||
| #else | ||
| environment = .unknown | ||
| Task { [weak self] in | ||
| let found = await Self.storeEnvironment() | ||
| self?.environment = found | ||
| RuntimeLog.engine.info("install environment: \(found.rawValue, privacy: .public)") | ||
| } | ||
| #endif | ||
| } | ||
|
|
||
| /// Whether the developer options are offered at all. | ||
| var isAvailable: Bool { PracticeMeter.isAvailable(in: environment) } | ||
|
|
||
| /// Whether a scan started now is a practice scan. | ||
| var practiceMeterOn: Bool { | ||
| PracticeMeter.isOn(requested: UserDefaults.standard.bool(forKey: Self.practiceMeterKey), in: environment) | ||
| } | ||
|
|
||
| /// StoreKit's signed app transaction says which server signed it: the sandbox for TestFlight, | ||
| /// production for the App Store, Xcode for StoreKit testing. Anything unverified or missing is | ||
| /// `.unknown`, which offers nothing. | ||
| nonisolated private static func storeEnvironment() async -> PracticeMeter.InstallEnvironment { | ||
| do { | ||
| guard case .verified(let transaction) = try await AppTransaction.shared else { return .unknown } | ||
| switch transaction.environment { | ||
| case .sandbox: return .testFlight | ||
| case .production: return .appStore | ||
| case .xcode: return .development | ||
| default: return .unknown | ||
| } | ||
| } catch { | ||
| RuntimeLog.engine.error("install environment unknown: \(String(describing: error), privacy: .public)") | ||
| return .unknown | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| import CoreGraphics | ||
| import CoreText | ||
| import Foundation | ||
| import HouseScanKit | ||
| import ImageIO | ||
| import UniformTypeIdentifiers | ||
|
|
||
| /// The practice meter's picture, drawn in code: a gray socket-meter box with a glass dome, the | ||
| /// made-up maker and the fake number (`PracticeMeter.printedLines`). The camera view draws it on | ||
| /// the wall at the tap (`PracticeMeterOverlay`), and a practice close-up stores a photo of it on a | ||
| /// sided wall (`closeUpJPEG`), which the meter-number reader then reads. | ||
| /// | ||
| /// Only Core Graphics, Core Text and ImageIO, with fonts both iOS and macOS ship, so the same | ||
| /// drawing renders on either. | ||
| enum SampleMeterArt { | ||
| /// Width over height of the drawn box, as `PracticeMeter.plateSize`. | ||
| static let aspect = CGFloat(PracticeMeter.plateSize.x / PracticeMeter.plateSize.y) | ||
|
|
||
| /// The box alone on a clear background, upright, for the camera view. 600 px wide: sharp at | ||
| /// the size the close-up holds it on screen, about half the screen's width. | ||
| static let face: CGImage? = { | ||
| let width = 600 | ||
| let height = Int((CGFloat(width) / aspect).rounded()) | ||
| guard let context = uprightContext(width: width, height: height, opaque: false) else { return nil } | ||
| drawMeter(in: context, rect: CGRect(x: 0, y: 0, width: width, height: height)) | ||
| return context.makeImage() | ||
| }() | ||
|
|
||
| /// A close-up photo of the sample on a sided wall, as the camera stores one: the landscape | ||
| /// sensor image of `width` by `height` pixels, unrotated, whose upright view is it turned 90° | ||
| /// clockwise (`ImageWork.uprightThumbnail`, and the reader's EXIF `.right`). The practice | ||
| /// close-up passes the live frame's size, so the stored photo's pixels still match the | ||
| /// frame's intrinsics. Nil when a context can't be made. | ||
| static func closeUpJPEG(width: Int, height: Int) -> Data? { | ||
| // Upright, the photo is portrait: the sensor's height across, its width down. | ||
| let uprightWidth = height | ||
| let uprightHeight = width | ||
| guard width > 0, height > 0, let upright = uprightContext(width: uprightWidth, height: uprightHeight, opaque: true) else { return nil } | ||
| let size = CGSize(width: uprightWidth, height: uprightHeight) | ||
| drawWall(in: upright, size: size) | ||
| // The box fills 78% of the photo's height, as a close-up held at the gate's distance does. | ||
| let boxHeight = size.height * 0.78 | ||
| let box = CGRect(x: (size.width - boxHeight * aspect) / 2, y: size.height * 0.47 - boxHeight / 2, width: boxHeight * aspect, height: boxHeight) | ||
| drawConduit(in: upright, below: box, size: size) | ||
| upright.saveGState() | ||
| upright.setShadow(offset: CGSize(width: 0, height: box.height * 0.02), blur: box.height * 0.05, color: CGColor(gray: 0, alpha: 0.35)) | ||
| upright.addPath(boxPath(box)) | ||
| upright.setFillColor(gray: 0.6, alpha: 1) | ||
| upright.fillPath() | ||
| upright.restoreGState() | ||
| drawMeter(in: upright, rect: box) | ||
| guard let image = upright.makeImage(), | ||
| let sensor = CGContext( | ||
| data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, | ||
| space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), | ||
| bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue | ||
| ) else { return nil } | ||
| // Turned 90° counterclockwise: the inverse of `ImageWork.rotatedClockwise`, so turning the | ||
| // stored image clockwise gives the upright photo back. | ||
| sensor.translateBy(x: CGFloat(width), y: 0) | ||
| sensor.rotate(by: .pi / 2) | ||
| sensor.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) | ||
| guard let stored = sensor.makeImage() else { return nil } | ||
| return jpeg(stored) | ||
| } | ||
|
|
||
| // MARK: Drawing | ||
|
|
||
| /// An sRGB context whose user space runs y down from the top left, as the layout below is | ||
| /// written, with Core Text set to draw upright glyphs in it. | ||
| private static func uprightContext(width: Int, height: Int, opaque: Bool) -> CGContext? { | ||
| guard let context = CGContext( | ||
| data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, | ||
| space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), | ||
| bitmapInfo: (opaque ? CGImageAlphaInfo.noneSkipLast : CGImageAlphaInfo.premultipliedLast).rawValue | ||
| ) else { return nil } | ||
| context.translateBy(x: 0, y: CGFloat(height)) | ||
| context.scaleBy(x: 1, y: -1) | ||
| context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) | ||
| return context | ||
| } | ||
|
|
||
| private static func boxPath(_ box: CGRect) -> CGPath { | ||
| CGPath(roundedRect: box, cornerWidth: box.width * 0.08, cornerHeight: box.width * 0.08, transform: nil) | ||
| } | ||
|
|
||
| /// Beige lap siding: a board every 4.5% of the height, each with a shadow line at its lower | ||
| /// edge. | ||
| private static func drawWall(in context: CGContext, size: CGSize) { | ||
| context.setFillColor(red: 0.86, green: 0.83, blue: 0.77, alpha: 1) | ||
| context.fill(CGRect(origin: .zero, size: size)) | ||
| let board = size.height * 0.045 | ||
| var y = board | ||
| while y < size.height { | ||
| context.setFillColor(red: 0.74, green: 0.71, blue: 0.65, alpha: 1) | ||
| context.fill(CGRect(x: 0, y: y - board * 0.08, width: size.width, height: board * 0.08)) | ||
| context.setFillColor(red: 0.91, green: 0.89, blue: 0.84, alpha: 1) | ||
| context.fill(CGRect(x: 0, y: y, width: size.width, height: board * 0.06)) | ||
| y += board | ||
| } | ||
| } | ||
|
|
||
| /// The conduit that feeds the meter, from under the box to the bottom of the photo. | ||
| private static func drawConduit(in context: CGContext, below box: CGRect, size: CGSize) { | ||
| let pipe = CGRect(x: box.midX - box.width * 0.07, y: box.maxY - box.height * 0.05, width: box.width * 0.14, height: size.height - box.maxY + box.height * 0.05) | ||
| context.setFillColor(gray: 0.55, alpha: 1) | ||
| context.fill(pipe) | ||
| context.setFillColor(gray: 0.68, alpha: 1) | ||
| context.fill(CGRect(x: pipe.minX + pipe.width * 0.2, y: pipe.minY, width: pipe.width * 0.18, height: pipe.height)) | ||
| } | ||
|
|
||
| /// The meter box filling `rect` (2:3), in an upright context. | ||
| static func drawMeter(in context: CGContext, rect box: CGRect) { | ||
| let w = box.width | ||
| let h = box.height | ||
| func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: box.minX + x * w, y: box.minY + y * h) } | ||
| let space = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() | ||
|
|
||
| // Box: brushed gray, lighter at the top, with a dark edge and a pale inner bevel. | ||
| context.saveGState() | ||
| context.addPath(boxPath(box)) | ||
| context.clip() | ||
| if let gradient = CGGradient(colorsSpace: space, colors: [CGColor(gray: 0.80, alpha: 1), CGColor(gray: 0.62, alpha: 1)] as CFArray, locations: [0, 1]) { | ||
| context.drawLinearGradient(gradient, start: point(0.5, 0), end: point(0.5, 1), options: []) | ||
| } | ||
| context.restoreGState() | ||
| context.addPath(boxPath(box.insetBy(dx: w * 0.03, dy: w * 0.03))) | ||
| context.setStrokeColor(gray: 1, alpha: 0.35) | ||
| context.setLineWidth(w * 0.008) | ||
| context.strokePath() | ||
| context.addPath(boxPath(box.insetBy(dx: w * 0.006, dy: w * 0.006))) | ||
| context.setStrokeColor(gray: 0.38, alpha: 1) | ||
| context.setLineWidth(w * 0.012) | ||
| context.strokePath() | ||
| for (x, y) in [(0.09, 0.06), (0.91, 0.06), (0.09, 0.94), (0.91, 0.94)] { | ||
| let center = point(x, y) | ||
| let r = w * 0.025 | ||
| context.setFillColor(gray: 0.5, alpha: 1) | ||
| context.fillEllipse(in: CGRect(x: center.x - r, y: center.y - r, width: 2 * r, height: 2 * r)) | ||
| context.setStrokeColor(gray: 0.3, alpha: 1) | ||
| context.setLineWidth(r * 0.35) | ||
| context.strokeLineSegments(between: [CGPoint(x: center.x - r * 0.6, y: center.y), CGPoint(x: center.x + r * 0.6, y: center.y)]) | ||
| } | ||
|
|
||
| // Glass dome on a metal collar. | ||
| let dome = point(0.5, 0.36) | ||
| let collar = w * 0.44 | ||
| let glass = w * 0.40 | ||
| context.setFillColor(gray: 0.5, alpha: 1) | ||
| context.fillEllipse(in: CGRect(x: dome.x - collar, y: dome.y - collar, width: 2 * collar, height: 2 * collar)) | ||
| context.saveGState() | ||
| context.addEllipse(in: CGRect(x: dome.x - glass, y: dome.y - glass, width: 2 * glass, height: 2 * glass)) | ||
| context.clip() | ||
| if let gradient = CGGradient(colorsSpace: space, colors: [CGColor(gray: 0.99, alpha: 1), CGColor(gray: 0.86, alpha: 1)] as CFArray, locations: [0, 1]) { | ||
| context.drawRadialGradient(gradient, startCenter: CGPoint(x: dome.x - glass * 0.3, y: dome.y - glass * 0.35), startRadius: 0, endCenter: dome, endRadius: glass, options: []) | ||
| } | ||
| context.restoreGState() | ||
|
|
||
| // The face: maker, practice warning, display and ratings. | ||
| let lines = PracticeMeter.printedLines | ||
| drawText(lines[0], font: "HelveticaNeue-Bold", size: w * 0.07, color: CGColor(gray: 0.12, alpha: 1), centeredAt: point(0.5, 0.228), in: context) | ||
| drawText(lines[1], font: "HelveticaNeue-Bold", size: w * 0.045, color: CGColor(srgbRed: 0.78, green: 0.30, blue: 0.05, alpha: 1), centeredAt: point(0.5, 0.27), in: context) | ||
| let display = CGRect(x: box.minX + w * 0.25, y: box.minY + h * 0.3, width: w * 0.5, height: h * 0.07) | ||
| context.setFillColor(red: 0.16, green: 0.20, blue: 0.18, alpha: 1) | ||
| context.fill(display) | ||
| drawText(lines[2], font: "Menlo-Bold", size: w * 0.055, color: CGColor(srgbRed: 0.62, green: 0.93, blue: 0.70, alpha: 1), centeredAt: CGPoint(x: display.midX, y: display.midY), in: context) | ||
| drawText(lines[3], font: "HelveticaNeue", size: w * 0.045, color: CGColor(gray: 0.25, alpha: 1), centeredAt: point(0.5, 0.435), in: context) | ||
|
|
||
| // The number on its own white label under the dome, as utilities stick it on. | ||
| let label = CGRect(x: box.minX + w * 0.05, y: box.minY + h * 0.74, width: w * 0.9, height: h * 0.13) | ||
| context.addPath(CGPath(roundedRect: label, cornerWidth: w * 0.02, cornerHeight: w * 0.02, transform: nil)) | ||
| context.setFillColor(gray: 0.98, alpha: 1) | ||
| context.fillPath() | ||
| drawText(lines[4], font: "Menlo-Bold", size: w * 0.115, color: CGColor(gray: 0.08, alpha: 1), centeredAt: CGPoint(x: label.midX, y: label.midY), in: context) | ||
| } | ||
|
|
||
| /// One line centered on `center`, its baseline placed so the line's ink box is centered. | ||
| private static func drawText(_ text: String, font name: String, size: CGFloat, color: CGColor, centeredAt center: CGPoint, in context: CGContext) { | ||
| let font = CTFontCreateWithName(name as CFString, size, nil) | ||
| let attributes: [NSAttributedString.Key: Any] = [ | ||
| NSAttributedString.Key(kCTFontAttributeName as String): font, | ||
| NSAttributedString.Key(kCTForegroundColorAttributeName as String): color, | ||
| ] | ||
| let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: attributes)) | ||
| var ascent: CGFloat = 0 | ||
| var descent: CGFloat = 0 | ||
| let width = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, nil)) | ||
| context.textPosition = CGPoint(x: center.x - width / 2, y: center.y + (ascent - descent) / 2) | ||
| CTLineDraw(line, context) | ||
| } | ||
|
|
||
| private static func jpeg(_ image: CGImage) -> Data? { | ||
| let data = NSMutableData() | ||
| guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } | ||
| CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.9] as CFDictionary) | ||
| return CGImageDestinationFinalize(destination) ? data as Data : nil | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,36 @@ | ||||||||||||||||||||||||||||||||||||
| import Foundation | ||||||||||||||||||||||||||||||||||||
| import HouseScanKit | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /// Practice meter (HouseScanKit `PracticeMeter`). The meter tap is the usual one: the same raycast | ||||||||||||||||||||||||||||||||||||
| /// and refusals, since they guard the wall's geometry, and the tapped spot becomes the meter's | ||||||||||||||||||||||||||||||||||||
| /// anchor, where the camera view draws the sample (`PracticeMeterOverlay`). Only the close-up's | ||||||||||||||||||||||||||||||||||||
| /// photo changes; its pose, depth and the view coverage takes from it stay the live frame's. | ||||||||||||||||||||||||||||||||||||
| extension ScanEngine { | ||||||||||||||||||||||||||||||||||||
| /// Reads the developer switch when a scan starts; the scan keeps the answer to its end. | ||||||||||||||||||||||||||||||||||||
| func startPracticeIfOn() { | ||||||||||||||||||||||||||||||||||||
| state.isPracticeScan = DeveloperSettings.shared.practiceMeterOn | ||||||||||||||||||||||||||||||||||||
| guard state.isPracticeScan else { return } | ||||||||||||||||||||||||||||||||||||
| RuntimeLog.engine.info("practice meter: a drawn sample stands in for the meter and its close-up photo") | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /// The frame whose photo the close-up stores and the reader reads: `frame` itself, or on a | ||||||||||||||||||||||||||||||||||||
| /// practice scan `frame` with a photo of the sample in place of the camera's, at the camera | ||||||||||||||||||||||||||||||||||||
| /// image's size so the frame's intrinsics still fit it. Without a photo when the sample can't | ||||||||||||||||||||||||||||||||||||
| /// be drawn, so the save fails and the close-up asks for a retake rather than storing a photo | ||||||||||||||||||||||||||||||||||||
| /// of whatever the camera saw. | ||||||||||||||||||||||||||||||||||||
| func closeUpPhoto(_ frame: SourceFrame) async -> SourceFrame { | ||||||||||||||||||||||||||||||||||||
| guard state.isPracticeScan else { return frame } | ||||||||||||||||||||||||||||||||||||
| let size = frame.camera.imageSize | ||||||||||||||||||||||||||||||||||||
| let jpeg = await Task.detached(priority: .userInitiated) { | ||||||||||||||||||||||||||||||||||||
| SampleMeterArt.closeUpJPEG(width: Int(size.x), height: Int(size.y)) | ||||||||||||||||||||||||||||||||||||
| }.value | ||||||||||||||||||||||||||||||||||||
| var photo = frame | ||||||||||||||||||||||||||||||||||||
| if let jpeg { | ||||||||||||||||||||||||||||||||||||
| photo.jpeg = .data(jpeg) | ||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||
| RuntimeLog.engine.error("practice meter: the sample photo could not be drawn at \(Int(size.x))x\(Int(size.y))") | ||||||||||||||||||||||||||||||||||||
| photo.jpeg = .none | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return photo | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+27
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,80p' ios/HouseScan/Runtime/ScanEngine+Practice.swift
rg -n -C 8 'func saveStill|sensorDepth|func writePacket' ios/HouseScan/Runtime/KeyframeStore.swift
sed -n '490,540p' ios/HouseScan/Runtime/ScanEngine.swiftRepository: SamGu-NRX/BaseScanning Length of output: 7066 Clear depth before saving the synthetic practice image. On a practice scan, Set Suggested fix var photo = frame
if let jpeg {
+ photo.sensorDepth = nil
photo.jpeg = .data(jpeg)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,8 @@ extension ScanEngine { | |
| // Written into the built Info.plist by the "Stamp the git commit" build phase. | ||
| commit: info["HouseScanGitCommit"] as? String ?? "unknown"), | ||
| server: .init(url: (resultClient as? HTTPResultClient)?.serverURL.absoluteString), | ||
| answer: answer.map { ScanStamp.Answer($0, sample: resultClient.isSample) }) | ||
| answer: answer.map { ScanStamp.Answer($0, sample: resultClient.isSample) }, | ||
| practice: state.isPracticeScan) | ||
|
Comment on lines
+19
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a practice scan is exported through “Share scan,” this flag is written only to Useful? React with 👍 / 👎.
greptile-apps[bot] marked this conversation as resolved.
Comment on lines
+19
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
rg -nP -C5 'scan-stamp|ScanStamp\.fileName|func writePacket' ios/HouseScan/RuntimeRepository: SamGu-NRX/BaseScanning Length of output: 3658 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- packet writer ---'
sed -n '180,360p' ios/HouseScan/Runtime/ScanEngine+Packet.swift
printf '%s\n' '--- packet/archive references ---'
rg -n -P -C4 'writePacket|zip|archive|store\.directory|PacketInputs|scan\.zip|\.zip' ios/HouseScan/Runtime ios/HouseScan | head -n 320
printf '%s\n' '--- stamp definition and consumers ---'
rg -n -P -C5 'struct ScanStamp|enum ScanStamp|class ScanStamp|fileName|scan-stamp\.json' iosRepository: SamGu-NRX/BaseScanning Length of output: 41865 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- storage paths ---'
rg -n -P -C8 'packetFolder|bundleURL|var directory|let directory|class .*Store|struct .*Store|zipPacket' ios/HouseScan/Runtime ios/HouseScanKit/Sources
printf '%s\n' '--- archive implementation ---'
rg -n -P -C12 'static func zipPacket|func zipPacket|Zip|manifest\.json|PacketManifest' ios/HouseScan/Runtime ios/HouseScanKit/Sources
printf '%s\n' '--- stamp/package call order ---'
rg -n -P -C8 'writeScanStamp|saveBundle|shareableScan|bundleURL' ios/HouseScan/Runtime/ScanEngine.swift ios/HouseScan/RuntimeRepository: SamGu-NRX/BaseScanning Length of output: 42200 Include practice metadata in the shared
Add 🧰 Tools🪛 ast-grep (0.45.3)[error] 20-20: An archive entry's path/name (e.g. ZIPFoundation (zip-slip-entry-path-append-swift) 🤖 Prompt for AI Agents |
||
| let file = store.directory.appending(path: ScanStamp.fileName) | ||
| do { | ||
| try stamp.jsonData().write(to: file, options: .atomic) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On a LiDAR practice scan, copying the whole source frame here retains
sensorDepth, andKeyframeStore.saveStilllater writes that depth into the packet alongside the replacement JPEG. The depth was measured from the real wall image, while the stored pixels contain a large synthetic meter that was never present in the depth map, so packet consumers receive falsely pixel-aligned RGB and depth and can reconstruct inconsistent geometry. The originalframe.depthcan still be used by the already-createdCloseUpViewfor coverage, but the saved practice-photo copy should clearsensorDepth.Useful? React with 👍 / 👎.