diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 25135c1ec..528a64ee3 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -461,21 +461,41 @@ enum FeatureKind: String, Equatable, Sendable, CaseIterable, Identifiable { case door case window case acUnit = "ac" + /// A battery already on the wall, which a new one keeps its clearance from (scene.json `battery`). + case battery + /// Any other box on the wall: a disconnect, sub-panel, EV charger or solar box (scene.json `elec_box`). + case elecBox = "elec_box" case driveway = "drive" case fence var id: String { rawValue } - /// How many taps a mark takes: two diagonal corners for a door or window, two points along - /// the near edge of a driveway or the foot of a fence, one point for a gas meter or AC unit. - /// scene.json (server/schemas/scene.schema.json) stores a driveway as a ground polygon and a - /// fence as a facing gap over a span, so both need a line, not a point. + /// How many taps a mark takes: two diagonal corners for a door, window, battery or box, two + /// points along the near edge of a driveway or the foot of a fence, one point for a gas meter + /// or AC unit. scene.json (server/schemas/scene.schema.json) stores a driveway as a ground + /// polygon and a fence as a facing gap over a span, so both need a line, not a point. var tapCount: Int { switch self { - case .door, .window, .driveway, .fence: 2 + case .door, .window, .battery, .elecBox, .driveway, .fence: 2 case .gasMeter, .acUnit: 1 } } + + /// A battery or box, tapped where it meets the wall: a tap lands on the wall's plane, so the + /// phone measures its span and heights there but not how far it stands out, and the export + /// sends no depth (`SceneFeature.box`). + var depthUnmeasured: Bool { + self == .battery || self == .elecBox + } + + /// The scene.json box kind of a battery or box; nil for every other kind. + var sceneBox: SceneBoxKind? { + switch self { + case .battery: .battery + case .elecBox: .elecBox + default: nil + } + } } struct MarkedFeature: Identifiable, Equatable, Sendable { diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index ee29e537b..f0c570117 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -449,7 +449,7 @@ extension ScanEngine: ScanActions { let ss = taps.map(\.s) let span = (ss.min() ?? 0)...(ss.max() ?? 0) switch feature.kind { - case .door, .window: + case .door, .window, .battery, .elecBox: let heights = taps.map(\.height) feature.span = span feature.bottom = max(0, heights.min() ?? 0) diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 9b239485b..1a953545e 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -36,13 +36,16 @@ extension ScanEngine { let features: [SceneFeature] = try state.features.map { feature in let points = feature.points.map { $0 - drop } switch feature.kind { - case .door, .window: + case .door, .window, .battery, .elecBox: let heights = [feature.bottom ?? 0, feature.top ?? 0] let bottom = max(0, heights.min() ?? 0) let top = max(bottom, heights.max() ?? 0) if bottom != feature.bottom || top != feature.top { RuntimeLog.engine.info("export: \(feature.kind.rawValue, privacy: .public) heights \(feature.bottom ?? .nan)...\(feature.top ?? .nan) clamped to \(bottom)...\(top)") } + if let box = feature.kind.sceneBox { + return .box(kind: box, span: feature.span, bottom: bottom, top: top) + } return .opening(kind: feature.kind == .door ? .door : .window, span: feature.span, bottom: bottom, top: top, operable: feature.kind == .window ? feature.opens : nil) case .gasMeter: diff --git a/ios/HouseScan/Runtime/ScanEngine+Packet.swift b/ios/HouseScan/Runtime/ScanEngine+Packet.swift index dda3f27b7..7c8b9078c 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Packet.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Packet.swift @@ -170,6 +170,9 @@ extension ScanEngine { feature.kind == .door ? .door : .window, id: id, span: feature.span, bottom: bottom, top: feature.top ?? bottom, operable: feature.kind == .window ? feature.opens : nil, wall: wall, frame: frame, t: t )) + case .battery, .elecBox: + let bottom = feature.bottom ?? 0 + marks.append(.box(feature.kind == .battery ? .battery : .elecBox, id: id, span: feature.span, bottom: bottom, top: feature.top ?? bottom, wall: wall, frame: frame, t: t)) case .gasMeter, .acUnit: guard let point = points.first else { continue } marks.append(.pointObject(feature.kind == .gasMeter ? .gasMeter : .ac, id: id, point: point, t: t)) diff --git a/ios/HouseScan/UI/Camera/WallMarksOverlay.swift b/ios/HouseScan/UI/Camera/WallMarksOverlay.swift index 775680e3b..8285a6c60 100644 --- a/ios/HouseScan/UI/Camera/WallMarksOverlay.swift +++ b/ios/HouseScan/UI/Camera/WallMarksOverlay.swift @@ -60,7 +60,8 @@ struct WallMarksOverlay: View { let caption = ScanCopy.name(feature.kind) let up = SIMD3(0, 1, 0) switch feature.kind { - case .door, .window: + // A battery or box is outlined where it meets the wall, the only part of it measured. + case .door, .window, .battery, .elecBox: guard let bottom = feature.bottom, let top = feature.top else { return } let corners = [ wall.world(s: feature.span.lowerBound, height: top, out: 0.01), diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 934d1587c..e2110bcec 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -324,6 +324,9 @@ enum ScanCopy { case .door: "Door" case .window: "Window" case .acUnit: "AC unit" + // "Existing", so the chip isn't read as placing the new battery. + case .battery: "Existing battery" + case .elecBox: "Box on the wall" case .driveway: "Driveway" case .fence: "Fence" } @@ -344,6 +347,8 @@ enum ScanCopy { case .door: "door.left.hand.closed" case .window: "window.vertical.closed" case .acUnit: "fan.fill" + case .battery: "minus.plus.batteryblock.fill" + case .elecBox: "bolt.square.fill" case .driveway: "car.fill" case .fence: "square.split.2x1" } @@ -361,6 +366,10 @@ enum ScanCopy { return "Assumed about \(side) by \(side)" }() + /// Under a battery or box on the review: its corners are tapped where it meets the wall, so + /// the scene sends no depth for it (`FeatureKind.depthUnmeasured`), and the homeowner is told. + static let depthNotMeasured = "How far it sticks out isn't measured" + /// The window question's way out: sent as unknown, like no answer. static let windowNotSure = "Not sure" @@ -387,6 +396,15 @@ enum ScanCopy { return Instruction(title: "Tap the \(item)'s bottom-left corner", detail: "Put the circle on it and tap Mark, or tap it on screen.") case (.door, _), (.window, _): return Instruction(title: "Now tap its top-right corner", detail: nil) + // A battery or box is tapped where it meets the wall: a tap lands on the wall's plane + // (`markFeaturePoint`), so a front corner, standing out from the wall, would land + // somewhere else along it (review of #52). + case (.battery, 0): + return Instruction(title: "Tap the battery's bottom-left corner at the wall", detail: "Use the back corner, where it meets the wall, not the front one.") + case (.elecBox, 0): + return Instruction(title: "Tap the box's bottom-left corner at the wall", detail: "A disconnect, sub-panel, EV charger or solar box. Use the back corner, where it meets the wall.") + case (.battery, _), (.elecBox, _): + return Instruction(title: "Now tap its top-right corner at the wall", detail: "The back corner again.") case (.driveway, 0): return Instruction(title: "Tap one end of the driveway's edge", detail: "Use the edge closest to the wall.") case (.driveway, _): diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index de07b2a56..d7f399f4f 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -1380,6 +1380,12 @@ final class DemoEngine: ScanActions { case .acUnit: return MarkedFeature(id: UUID(), kind: kind, span: DemoScene.acSpan, bottom: 0, top: 0.8, out: 0.25, points: [wall.world(s: 3.75, height: 0.8, out: 0.6)], opens: nil) + case .battery: + return MarkedFeature(id: UUID(), kind: kind, span: -1.1 ... -0.35, bottom: 0, top: 1.0, out: nil, + points: [wall.world(s: -1.1, height: 0), wall.world(s: -0.35, height: 1.0)], opens: nil) + case .elecBox: + return MarkedFeature(id: UUID(), kind: kind, span: 1.5...1.8, bottom: 1.1, top: 1.5, out: nil, + points: [wall.world(s: 1.5, height: 1.1), wall.world(s: 1.8, height: 1.5)], opens: nil) case .driveway: return MarkedFeature(id: UUID(), kind: kind, span: -1.0...1.0, bottom: nil, top: nil, out: 2.4, points: [wall.world(s: -1.0, height: 0, out: 2.4), wall.world(s: 1.0, height: 0, out: 2.4)], opens: nil) diff --git a/ios/HouseScan/UI/Result/ResultScene3D.swift b/ios/HouseScan/UI/Result/ResultScene3D.swift index 678583e7f..b80a3fff7 100644 --- a/ios/HouseScan/UI/Result/ResultScene3D.swift +++ b/ios/HouseScan/UI/Result/ResultScene3D.swift @@ -369,6 +369,14 @@ struct ResultScene3D: View { let back = feature.out ?? 0.3 root.addChild(box(width: 0.8, height: 0.8, depth: 0.8, center: SIMD3(centerX, 0.4, back + 0.4), material: matte(SceneColor.meter))) + case .battery, .elecBox: + let bottom = feature.bottom ?? 0 + let height = max((feature.top ?? bottom + 1) - bottom, 0.05) + // Drawn depths only, like the gas meter's and AC unit's boxes here: nobody measured how + // far it stands out, and scene.json sends none (`SceneFeature.box`). + let depth: Float = feature.kind == .battery ? 0.56 : 0.15 + root.addChild(box(width: width, height: height, depth: depth, + center: SIMD3(centerX, bottom + height / 2, depth / 2), material: matte(SceneColor.meter))) case .driveway: root.addChild(plane(width: width, depth: Self.groundDepth, center: SIMD3(centerX, 0.003, Self.groundDepth / 2), material: matte(SceneColor.driveway))) diff --git a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift index c7a9dd6a7..ffbfec7de 100644 --- a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift +++ b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift @@ -145,7 +145,7 @@ struct MarkFeaturesScreen: View { .font(Typeface.screenTitle) .foregroundStyle(.primary) .accessibilityAddTraits(.isHeader) - Text("Gas meters, doors, windows, AC units, driveways and fences all change where a battery can go.") + Text("Gas meters, doors, windows, AC units, other batteries, boxes on the wall, driveways and fences all change where a battery can go.") .font(Typeface.hint) .foregroundStyle(Palette.muted) } @@ -241,6 +241,11 @@ private struct FeatureRow: View { .foregroundStyle(Palette.muted) .accessibilityLabel(ScanCopy.acAssumedSizeSpoken) } + if feature.kind.depthUnmeasured { + Text(ScanCopy.depthNotMeasured) + .font(.subheadline) + .foregroundStyle(Palette.muted) + } if pastEnd { Label { Text(ScanCopy.featurePastEnd) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Packet/PacketMarks.swift b/ios/HouseScanKit/Sources/HouseScanKit/Packet/PacketMarks.swift index 64f097e99..177b5592a 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Packet/PacketMarks.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Packet/PacketMarks.swift @@ -3,7 +3,8 @@ import simd /// Something the homeowner marked, in the meter frame (packet/README.md, "Marks, guidance and the /// scene"). The factories fix each kind's point count: one for the meter, a wall end, a gas meter -/// or an AC unit; two for an opening (bottom-left, top-right), a drive edge or a fence. +/// or an AC unit; two for an opening or a box on the wall (bottom-left, top-right), a drive edge +/// or a fence. public struct PacketMark: Codable, Sendable, Equatable { public enum Kind: String, Codable, Sendable, CaseIterable { case meter @@ -13,13 +14,17 @@ public struct PacketMark: Codable, Sendable, Equatable { case door case window case garageDoor = "garage_door" + /// A battery already on the wall, named as scene.json's object type (`SceneBoxKind`). + case battery + /// A disconnect, sub-panel, EV charger or solar box, named as scene.json's object type. + case elecBox = "elec_box" case driveEdge = "drive_edge" case fence public var pointCount: Int { switch self { case .meter, .wallEnd, .gasMeter, .ac: 1 - case .door, .window, .garageDoor, .driveEdge, .fence: 2 + case .door, .window, .garageDoor, .battery, .elecBox, .driveEdge, .fence: 2 } } } @@ -111,6 +116,20 @@ public struct PacketMark: Codable, Sendable, Equatable { return PacketMark(id: id, kind: opening.kind, points: corners, t: t, photoIDs: photoIDs, operable: operable) } + /// A battery or other box on the wall: the bottom-left and top-right corners where it meets the + /// wall face, left being the lower s, as an opening's are. How far it stands out is not + /// measured, and the mark claims none. + public static func box( + _ box: SceneBoxKind, id: String, span: ClosedRange, bottom: Float, top: Float, + wall: SceneWall, frame: MeterFrame, t: Double? = nil, photoIDs: [String]? = nil + ) -> PacketMark { + let corners = [ + frame.point(on: wall, s: span.lowerBound, height: bottom, out: 0), + frame.point(on: wall, s: span.upperBound, height: top, out: 0), + ] + return PacketMark(id: id, kind: box == .battery ? .battery : .elecBox, points: corners, t: t, photoIDs: photoIDs) + } + /// A gas meter or AC unit tapped once; `point` is already in the meter frame. public static func pointObject( _ kind: ScenePointObjectKind, id: String, point: SIMD3, t: Double? = nil, photoIDs: [String]? = nil @@ -129,7 +148,7 @@ public struct PacketMark: Codable, Sendable, Equatable { /// The mark for one of scene.json's features, from the same inputs `SceneExport` reads. /// Point objects, fences and drive edges keep their taps (world points moved into the meter - /// frame); openings get corners from their span and heights. + /// frame); openings and boxes get corners from their span and heights. public static func from( _ feature: SceneFeature, id: String, wall: SceneWall, frame: MeterFrame, t: Double? = nil, photoIDs: [String]? = nil ) throws(PacketError) -> PacketMark { @@ -138,6 +157,8 @@ public struct PacketMark: Codable, Sendable, Equatable { return opening( kind == .door ? .door : .window, id: id, span: span, bottom: bottom, top: top, operable: operable, wall: wall, frame: frame, t: t, photoIDs: photoIDs) + case let .box(kind, span, bottom, top): + return box(kind, id: id, span: span, bottom: bottom, top: top, wall: wall, frame: frame, t: t, photoIDs: photoIDs) case let .pointObject(kind, tap, _, _): return pointObject(kind, id: id, point: frame.point(tap), t: t, photoIDs: photoIDs) case let .fence(foot): diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift index 562b91656..eded40400 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift @@ -116,10 +116,23 @@ public enum ScenePointObjectKind: String, Sendable, Equatable { case ac } +/// A box already against the wall, with the server's object types (server/schemas/scene.schema.json +/// `objects[].type`): `battery` is a battery already installed, which the server holds a new one +/// `clearances.battery_ft` from (server/solver.py `battery_clearance`); `elec_box` is a disconnect, +/// sub-panel, EV charger or solar box, which the rules treat as wall equipment +/// (server/rules.yaml `wall_equipment.types`). +public enum SceneBoxKind: String, Sendable, Equatable, CaseIterable { + case battery + case elecBox = "elec_box" +} + public enum SceneFeature: Sendable { /// A door or window on the wall. `span` is in s meters; `bottom` and `top` are meters above the /// ground. `operable` nil means the homeowner was not asked, and it is then left out. case opening(kind: SceneOpeningKind, span: ClosedRange, bottom: Float, top: Float, operable: Bool?) + /// A box against the wall, with `span`, `bottom` and `top` where it meets the wall, as an + /// opening's are. How far it stands out is not measured, so it goes out with no footprint. + case box(kind: SceneBoxKind, span: ClosedRange, bottom: Float, top: Float) /// Something tapped once that stands off the wall (gas meter, AC unit). `tap` is a world point. case pointObject(kind: ScenePointObjectKind, tap: SIMD3, bottom: Float?, top: Float?) /// Two world points at the foot of a fence or hedge facing the wall. @@ -440,6 +453,18 @@ public enum SceneExport { bottom_ft: feet(bottom), top_ft: feet(top), attrs: operable.map { SceneDocument.Attrs(operable: $0) }, source: "tap", footprint: nil, plus_minus_ft: objectError)) + case let .box(kind, span, bottom, top): + // No footprint: the taps are wall-plane hits, which say where the box meets the + // wall but not how far it stands out, and no depth is assumed for somebody else's + // unit (review of #52). Without one the server measures to the box's stretch of + // wall line (scene.schema.json `footprint`), which can understate a deep box near + // a corner. + try requireNonNegative(bottom, "\(name).bottom") + guard top >= bottom else { throw SceneExportError.topBelowBottom(field: name, bottom: bottom, top: top) } + objects.append(.init( + type: kind.rawValue, wall_id: wallIDAt((span.lowerBound + span.upperBound) / 2), span_ft: spanFeet(span), + bottom_ft: feet(bottom), top_ft: feet(top), attrs: nil, source: "tap", footprint: nil, + plus_minus_ft: objectError)) case let .pointObject(kind, tap, bottom, top): if let bottom { try requireNonNegative(bottom, "\(name).bottom") } if let top { try requireNonNegative(top, "\(name).top") } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PacketWriterTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PacketWriterTests.swift index e171be1ab..bec647e72 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/PacketWriterTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PacketWriterTests.swift @@ -7,14 +7,16 @@ import simd import Testing /// The capture packet's manifest schema, version 1.1: t3/packet at d5439cf -/// (packet/manifest.schema.json, sha256 44c9c9be…), plus one additive change made here, the -/// optional `attrs.inferred` on a wall end and `t`'s description (B-12, packet/README.md). +/// (packet/manifest.schema.json, sha256 44c9c9be…), plus two additive changes made here: the +/// optional `attrs.inferred` on a wall end and `t`'s description (B-12, packet/README.md), and +/// the mark kinds `battery` and `elec_box`, named as scene.json's object types, with the points +/// description saying they take two corners (issue #27). /// `vendoredManifestSchemaIsTheRecordedRevision` fails if the copy changes without this record. /// There is no live copy to compare with: t3/packet retired 1.1 at 6a12700 in favour of the server /// team's packet 0.4, which the writer moves to next. enum PacketSchema { static let name = "manifest.schema.json" - static let sha256 = "54d2a0d104162801a35599c5300acff09b2c2061aa8e88291f2621c04398bc27" + static let sha256 = "c8dcecb8440f36be5650d4c94e1e6888ae55f2ff652e6c9dbeee7ceb615219f4" static func validator() throws -> JSONSchemaValidator { try JSONSchemaValidator(schema: SceneSchemas.data(name)) } } @@ -556,6 +558,82 @@ struct SyntheticPacket { } } +/// A battery already on the wall and any other box on it (issue #27, ported from #52): marked by +/// the two corners where each meets the wall, sent as scene.json objects of the server's types +/// with no footprint, and as packet marks of the same names. +@Suite struct BoxMarkTests { + typealias Value = JSONSchemaValidator.Value + + /// The names are the server's: scene.json's object types (the vendored copy of + /// server/schemas/scene.schema.json, held to the server's by `vendoredCopiesMatchServer`), + /// which server/solver.py reads as `battery` for `battery_clearance` and server/rules.yaml + /// lists `elec_box` under `wall_equipment`. The packet marks use the same names. + @Test func boxKindsAreTheServersObjectTypes() throws { + #expect(SceneBoxKind.allCases.map(\.rawValue) == ["battery", "elec_box"]) + let scene = try Value.parse(SceneSchemas.data("scene.schema.json")) + let objectTypes = try #require(scene["properties"]?["objects"]?["items"]?["properties"]?["type"]?["enum"]?.array) + let manifest = try Value.parse(SceneSchemas.data(PacketSchema.name)) + let markKinds = try #require(manifest["$defs"]?["mark"]?["properties"]?["kind"]?["enum"]?.array) + for kind in SceneBoxKind.allCases { + #expect(objectTypes.contains(.string(kind.rawValue)), "scene.json has no object type \(kind.rawValue)") + #expect(markKinds.contains(.string(kind.rawValue)), "the manifest has no mark kind \(kind.rawValue)") + #expect(PacketMark.Kind(rawValue: kind.rawValue)?.pointCount == 2) + } + } + + /// Mark, packet, decode: both boxes go into a written packet as marks and in its scene.json, + /// and come back with their kinds, their corners where they meet the wall, and no footprint: + /// how far a box stands out is not measured and none is sent (review of #52). + @Test func aBoxRoundTripsThroughThePacket() throws { + let synthetic = try SyntheticPacket() + let wall = synthetic.wall + let features: [SceneFeature] = [ + .box(kind: .battery, span: 1.5...2.3, bottom: 0, top: 1.0), + .box(kind: .elecBox, span: -0.8 ... -0.5, bottom: 1.2, top: 1.6), + ] + let sceneData = try SceneExport.jsonData(SceneInput( + wall: wall, baselineS: -3...5, features: features, + coverage: SceneCoverage(leftEndMarked: true, rightEndMarked: true, wall: [], ground: []))) + #expect(try SceneSchemas.scene().validate(sceneData) == []) + + let folder = temporaryFolder("packet-box") + defer { try? FileManager.default.removeItem(at: folder) } + var writer = try PacketWriter(folder: folder, session: synthetic.session) + try writer.addPhoto(PacketPhoto( + id: "p00001", jpeg: try photoJPEG(), width: 96, height: 72, t: 100.25, pose: synthetic.frame.pose(synthetic.worldCamera(15)), + intrinsics: SIMD4(80, 80, 48, 36), tracking: .normal, sharpness: 1)) + try writer.setMarks(features.enumerated().map { index, feature in + try PacketMark.from(feature, id: "box\(index)", wall: wall, frame: synthetic.frame, t: 100.5) + }) + try writer.setScene(sceneData) + let written = try writer.finish() + + let manifestData = try Data(contentsOf: written.appendingPathComponent("manifest.json")) + #expect(try PacketSchema.validator().validate(manifestData) == []) + let manifest = try JSONDecoder().decode(PacketManifest.self, from: manifestData) + let marks = try #require(manifest.marks) + #expect(marks.map(\.kind) == [.battery, .elecBox]) + // Meter frame: x along the wall, y height less the meter's 1.5 m, z out, which is 0. + let corners: [[SIMD3]] = [[SIMD3(1.5, -1.5, 0), SIMD3(2.3, -0.5, 0)], [SIMD3(-0.8, -0.3, 0), SIMD3(-0.5, 0.1, 0)]] + for (mark, expected) in zip(marks, corners) { + #expect(mark.points.count == 2 && zip(mark.points, expected).allSatisfy { simd_distance($0, $1) < 1e-5 }, "\(mark.kind): \(mark.points)") + } + + let scenePath = try #require(manifest.scene?.path) + let objects = try #require(try Value.parse(Data(contentsOf: written.appendingPathComponent(scenePath)))["objects"]?.array) + #expect(objects.map { $0["type"]?.string } == ["battery", "elec_box"]) + for (object, feature) in zip(objects, features) { + guard case let .box(_, span, bottom, top) = feature else { continue } + #expect(object["footprint"] == nil, "a box's depth was never measured, and none is sent") + #expect(object["source"] == .string("tap") && object["attrs"] == nil) + let spanFeet = try #require(object["span_ft"]?.numbers) + let heightsFeet = [object["bottom_ft"]?.number, object["top_ft"]?.number].compactMap(\.self) + let expected = [span.lowerBound, span.upperBound, bottom, top].map { Double($0) / 0.3048 } + #expect(zip(spanFeet + heightsFeet, expected).allSatisfy { abs($0 - $1) < 2e-4 } && heightsFeet.count == 2) + } + } +} + /// A 96 x 72 JPEG in its own temporary folder, which the caller need not remove: the writer /// copies it, and the system clears the temporary directory. private func photoJPEG() throws -> URL { @@ -758,7 +836,7 @@ private func photoJPEG() throws -> URL { let counts = Dictionary(uniqueKeysWithValues: PacketMark.Kind.allCases.map { ($0.rawValue, $0.pointCount) }) #expect(counts == [ "meter": 1, "wall_end": 1, "gas_meter": 1, "ac": 1, - "door": 2, "window": 2, "garage_door": 2, "drive_edge": 2, "fence": 2, + "door": 2, "window": 2, "garage_door": 2, "battery": 2, "elec_box": 2, "drive_edge": 2, "fence": 2, ]) } @@ -803,7 +881,7 @@ private func photoJPEG() throws -> URL { @Suite struct PacketSchemaTests { @Test func vendoredManifestSchemaIsTheRecordedRevision() throws { let digest = SHA256.hash(data: try SceneSchemas.data(PacketSchema.name)).map { String(format: "%02x", $0) }.joined() - #expect(digest == PacketSchema.sha256, "Schemas/\(PacketSchema.name) is not t3/packet d5439cf plus attrs.inferred") + #expect(digest == PacketSchema.sha256, "Schemas/\(PacketSchema.name) is not t3/packet d5439cf plus attrs.inferred and the box mark kinds") } /// The schema is not a rubber stamp: a manifest missing required fields or breaking a pattern diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/manifest.schema.json b/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/manifest.schema.json index deebb59b0..028a956b6 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/manifest.schema.json +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/manifest.schema.json @@ -522,12 +522,14 @@ "door", "window", "garage_door", + "battery", + "elec_box", "drive_edge", "fence" ] }, "points": { - "description": "Meter frame, meters. One point for the meter, a wall end, a gas meter or an AC unit; two for an opening (bottom-left, top-right corners), a drive edge or a fence.", + "description": "Meter frame, meters. One point for the meter, a wall end, a gas meter or an AC unit; two for an opening (bottom-left, top-right corners), a battery or box on the wall (bottom-left, top-right corners where it meets the wall), a drive edge or a fence.", "type": "array", "minItems": 1, "items": { diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 827092d3f..5d4307e70 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -48,6 +48,7 @@ final class ScreenStatesUITests: XCTestCase { ("wallWalk-fullLegend", ["-uiDemoPhase", "wallWalk", "-uiDemoEndPreview", "-uiDemoHidden"], "wallWalk"), ("markFeatures", ["-uiDemoPhase", "markFeatures"], "markFeatures"), ("markFeatures-marking", ["-uiDemoPhase", "markFeatures", "-uiDemoMarking", "door"], "markFeatures"), + ("markFeatures-markingBox", ["-uiDemoPhase", "markFeatures", "-uiDemoMarking", "elec_box"], "markFeatures"), ("markFeatures-lostPlace", ["-uiDemoPhase", "markFeatures", "-uiDemoCoaching", "relocalizing"], "markFeatures"), ("markFeatures-groundQuestion", ["-uiDemoGroundQuestion"], "markFeatures"), ("markFeatures-groundAnswered", ["-uiDemoGroundAnswer", "mulch"], "markFeatures"), @@ -102,6 +103,8 @@ final class ScreenStatesUITests: XCTestCase { // #55: an answered permission is not asked again, so the phone only may ask. "onboarding-permissions": [("onboarding.permissions", "Your phone may ask to use the camera")], "wallWalk-hidden": [("wallTape", "2 sections hidden behind something")], + // Issue #27: a box is marked where it meets the wall. + "markFeatures-markingBox": [("instruction", "A disconnect, sub-panel, EV charger or solar box")], "wallWalk-fullLegend": [("wallTape", "2 sections hidden behind something")], "wallWalk-seeBehind": [("instruction", "Something is in front of the wall here")], // At AX5 looking past it leads, and the situation folds under Details. @@ -526,6 +529,35 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertEqual(app.buttons.matching(identifier: "action.deleteFeature").count, rowsBefore + 1) } + /// Issue #27 (ported from #52): a battery already on the wall and any other box (disconnect, + /// sub-panel, EV charger) are marked from the review by two corners where they meet the wall, + /// and each row says how far it sticks out isn't measured. + @MainActor + func testReviewMarksAnExistingBatteryAndABox() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoPhase", "markFeatures"] + app.launch() + XCTAssertTrue(element(app, "screen.markFeatures").waitForExistence(timeout: 15)) + let rowsBefore = app.buttons.matching(identifier: "action.deleteFeature").count + for (kind, prompt) in [("battery", "Tap the battery's bottom-left corner at the wall"), ("elec_box", "Tap the box's bottom-left corner at the wall")] { + tap(app, "feature.\(kind)") + let first = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", prompt)).firstMatch + XCTAssertTrue(first.waitForExistence(timeout: 5), "marking \(kind) must ask for \(prompt)") + tap(app, "action.markPoint", timeout: 5) + let second = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS 'top-right corner at the wall'")).firstMatch + XCTAssertTrue(second.waitForExistence(timeout: 5), "a \(kind) takes a second corner") + tap(app, "action.markPoint", timeout: 5) + XCTAssertTrue(element(app, "action.confirmFeatures").waitForExistence(timeout: 5), "the review must come back after the mark") + } + XCTAssertEqual(app.buttons.matching(identifier: "action.deleteFeature").count, rowsBefore + 2) + let note = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'review.feature' AND label CONTAINS %@", "How far it sticks out isn't measured")) + XCTAssertEqual(note.count, 2, "each box row says its depth isn't measured") + } + /// AX5 puts the review chips below the window. The helper must let the real tap scroll /// before XCTest computes a hit point, then Cancel must return to the unchanged review. @MainActor @@ -571,7 +603,7 @@ final class ScreenStatesUITests: XCTestCase { app.launch() XCTAssertTrue(element(app, "screen.markFeatures").waitForExistence(timeout: 15)) XCTAssertTrue(element(app, "review.lostPlace").exists, "the review must say the phone lost its place") - for kind in ["gas_meter", "door", "window", "ac", "drive", "fence"] { + for kind in ["gas_meter", "door", "window", "ac", "battery", "elec_box", "drive", "fence"] { XCTAssertFalse(element(app, "feature.\(kind)").exists, "feature.\(kind) must not be offered while the phone is lost") } XCTAssertTrue(element(app, "action.confirmFeatures").isHittable, "Looks complete must stay available")