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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion ios/HouseScan/Runtime/ScanEngine+Export.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions ios/HouseScan/Runtime/ScanEngine+Packet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion ios/HouseScan/UI/Camera/WallMarksOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ struct WallMarksOverlay: View {
let caption = ScanCopy.name(feature.kind)
let up = SIMD3<Float>(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),
Expand Down
18 changes: 18 additions & 0 deletions ios/HouseScan/UI/Copy/ScanCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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"
}
Expand All @@ -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"

Expand All @@ -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, _):
Expand Down
6 changes: 6 additions & 0 deletions ios/HouseScan/UI/Preview/DemoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions ios/HouseScan/UI/Result/ResultScene3D.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
7 changes: 6 additions & 1 deletion ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 24 additions & 3 deletions ios/HouseScanKit/Sources/HouseScanKit/Packet/PacketMarks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
}
Expand Down Expand Up @@ -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<Float>, 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<Float>, t: Double? = nil, photoIDs: [String]? = nil
Expand All @@ -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 {
Expand All @@ -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):
Expand Down
25 changes: 25 additions & 0 deletions ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Float>, 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<Float>, 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<Float>, bottom: Float?, top: Float?)
/// Two world points at the foot of a fence or hedge facing the wall.
Expand Down Expand Up @@ -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))
Comment on lines +464 to +467

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Model unknown battery depth as uncertainty

When an existing battery is marked, this export deliberately omits its footprint, so the server treats it as only the stretch of wall line in span_ft. For a deep battery near a candidate's corner, battery_clearance can therefore compute a passing distance to the wall line even though the real front face is closer and may violate the clearance. The review note disappears once the scan is submitted, so the result can present this unsafe placement as clear; preserve the unknown-depth condition as UNSURE/manual review or collect a conservative depth before allowing a passing answer.

Useful? React with 👍 / 👎.

case let .pointObject(kind, tap, bottom, top):
if let bottom { try requireNonNegative(bottom, "\(name).bottom") }
if let top { try requireNonNegative(top, "\(name).top") }
Expand Down
Loading