diff --git a/src/Shared/EditorLayer/EditorMapLayer+Edit.swift b/src/Shared/EditorLayer/EditorMapLayer+Edit.swift index f34a7a25b..e21088210 100644 --- a/src/Shared/EditorLayer/EditorMapLayer+Edit.swift +++ b/src/Shared/EditorLayer/EditorMapLayer+Edit.swift @@ -458,6 +458,54 @@ extension EditorMapLayer { mapData.endUndoGrouping() } + // MARK: Rotate direction tag + + func rotateDirectionBegin() { + guard let node = selectedNode, + let tagKey = node.technicalDirectionTagKey, + let bearing = node.direction?.location + else { return } + mapData.beginUndoGrouping() + dragState.didMove = false + directionRotateTagKey = tagKey + directionRotateInitialBearing = bearing + } + + func rotateDirectionContinue(delta: CGFloat) { + guard let node = selectedNode, + let tagKey = directionRotateTagKey, + let initialBearing = directionRotateInitialBearing + else { return } + + if dragState.didMove { + mapData.endUndoGrouping() + silentUndo = true + mapData.undo() + silentUndo = false + mapData.beginUndoGrouping() + } + dragState.didMove = true + + let deltaDegrees = Int(round(Double(-delta) * 180 / .pi)) + let bearing = ((initialBearing + deltaDegrees) % 360 + 360) % 360 + guard let value = node.directionTagValue(forBearingDegrees: bearing) else { return } + var tags = node.tags + tags[tagKey] = value + mapData.setTags(tags, for: node) + setNeedsLayout() + owner.didUpdateObject() + } + + func rotateDirectionFinish() { + mapData.endUndoGrouping() + directionRotateTagKey = nil + directionRotateInitialBearing = nil + } + + func isRotateDirectionMode() -> Bool { + directionRotateTagKey != nil + } + // MARK: Editing func adjust(_ node: OsmNode, byScreenDistance delta: CGPoint) { @@ -672,9 +720,12 @@ extension EditorMapLayer { actionList += [.STRAIGHTEN, .REVERSE, .DUPLICATE, .CREATE_RELATION] } } - } else if selectedNode != nil { + } else if let selectedNode = selectedNode { // node actionList += [.DUPLICATE] + if selectedNode.technicalDirectionTagKey != nil { + actionList.append(.ROTATE) + } } else if let selectedRelation = selectedRelation { // relation if selectedRelation.isMultipolygon() { @@ -744,7 +795,11 @@ extension EditorMapLayer { selectedRelation = newObject.isRelation() owner.placePushpinForSelection(at: nil) case .ROTATE: - guard selectedWay != nil || (selectedRelation?.isMultipolygon() ?? false) else { + let canRotateGeometry = selectedWay != nil || (selectedRelation?.isMultipolygon() ?? false) + let canRotateDirection = selectedWay == nil && + selectedRelation == nil && + selectedNode?.technicalDirectionTagKey != nil + guard canRotateGeometry || canRotateDirection else { throw EditError.text(NSLocalizedString("Only ways/multipolygons can be rotated", comment: "")) } owner.startObjectRotation() diff --git a/src/Shared/EditorLayer/EditorMapLayer.swift b/src/Shared/EditorLayer/EditorMapLayer.swift index 3259cecc3..a5bd58f09 100644 --- a/src/Shared/EditorLayer/EditorMapLayer.swift +++ b/src/Shared/EditorLayer/EditorMapLayer.swift @@ -178,6 +178,10 @@ final class EditorMapLayer: CALayer { var dragState = DragState(startPoint: .zero, didMove: false, confirmDrag: false) + /// Active while rotating a node's `direction` / `camera:direction` tag (not geometry). + var directionRotateTagKey: String? + var directionRotateInitialBearing: Int? + let objectFilters = EditorFilters() var whiteText = false { diff --git a/src/Shared/MapView.swift b/src/Shared/MapView.swift index ba0791ee6..49a66149b 100644 --- a/src/Shared/MapView.swift +++ b/src/Shared/MapView.swift @@ -269,12 +269,20 @@ final class MapView: UIView, UIGestureRecognizerDelegate, UIContextMenuInteracti // remove previous rotation in case user pressed Rotate button twice endObjectRotation() + let isDirectionRotate = editorLayer.selectedWay == nil && + editorLayer.selectedRelation == nil && + editorLayer.selectedNode?.technicalDirectionTagKey != nil + guard let rotateObjectCenter = editorLayer.selectedNode?.latLon ?? editorLayer.selectedWay?.centerPoint() ?? editorLayer.selectedRelation?.centerPoint() else { return } + + if isDirectionRotate { + editorLayer.rotateDirectionBegin() + } removePin() let rotateObjectOverlay = CAShapeLayer() let radiusInner: CGFloat = 70 @@ -303,6 +311,9 @@ final class MapView: UIView, UIGestureRecognizerDelegate, UIContextMenuInteracti func endObjectRotation() { isRotateObjectMode?.rotateObjectOverlay.removeFromSuperlayer() + if editorLayer.isRotateDirectionMode() { + editorLayer.rotateDirectionFinish() + } placePushpinForSelection() editorLayer.dragState.confirmDrag = false isRotateObjectMode = nil @@ -1048,13 +1059,21 @@ final class MapView: UIView, UIGestureRecognizerDelegate, UIContextMenuInteracti } // Rotate object on screen if rotationGesture.state == .began { - editorLayer.rotateBegin() + if !editorLayer.isRotateDirectionMode() { + editorLayer.rotateBegin() + } } else if rotationGesture.state == .changed { - editorLayer.rotateContinue(delta: rotationGesture.rotation, rotate: rotate) + if editorLayer.isRotateDirectionMode() { + editorLayer.rotateDirectionContinue(delta: rotationGesture.rotation) + } else { + editorLayer.rotateContinue(delta: rotationGesture.rotation, rotate: rotate) + } } else { // ended + if !editorLayer.isRotateDirectionMode() { + editorLayer.rotateFinish() + } endObjectRotation() - editorLayer.rotateFinish() } } diff --git a/src/iOS/Direction/OsmNode+Direction.swift b/src/iOS/Direction/OsmNode+Direction.swift index 22d735a5f..81e8d63e9 100644 --- a/src/iOS/Direction/OsmNode+Direction.swift +++ b/src/iOS/Direction/OsmNode+Direction.swift @@ -46,6 +46,35 @@ extension OsmNode { return nil } + /// Tag key (`direction` or `camera:direction`) whose value parses as a technical bearing, if any. + var technicalDirectionTagKey: String? { + for key in ["direction", "camera:direction"] { + if let value = tags[key], + OsmNode.directionFromString(value) != nil + { + return key + } + } + return nil + } + + /// Bearing in degrees clockwise from north for a point direction (`direction` length 0). + var directionPointBearing: Int? { + guard let range = direction, range.length == 0 else { return nil } + return range.location + } + + /// OSM tag value for a bearing, preserving arc span when the current direction is a range. + func directionTagValue(forBearingDegrees bearing: Int) -> String? { + guard let range = direction else { return nil } + let normalized = ((bearing % 360) + 360) % 360 + if range.length == 0 { + return "\(normalized)" + } + let end = (normalized + range.length) % 360 + return "\(normalized)-\(end)" + } + private static func directionFromString(_ string: String) -> NSRange? { if let direction = Float(string) ?? cardinalDictionary[string] { return NSMakeRange(Int(direction), 0) diff --git a/src/iOS/GoMapTests/OsmNode_DirectionTestCase.swift b/src/iOS/GoMapTests/OsmNode_DirectionTestCase.swift index e69f5eed0..45910bf10 100644 --- a/src/iOS/GoMapTests/OsmNode_DirectionTestCase.swift +++ b/src/iOS/GoMapTests/OsmNode_DirectionTestCase.swift @@ -36,6 +36,44 @@ class OsmNode_DirectionTestCase: XCTestCase { XCTAssertEqual(node.direction?.lowerBound, direction) } + func testTechnicalDirectionTagKeyPrefersDirectionOverCameraDirection() { + let node = OsmNode(asUserCreated: "") + node.constructTag("direction", value: "90") + node.constructTag("camera:direction", value: "180") + + XCTAssertEqual(node.technicalDirectionTagKey, "direction") + } + + func testTechnicalDirectionTagKeyUsesCameraDirectionWhenDirectionAbsent() { + let node = OsmNode(asUserCreated: "") + node.constructTag("camera:direction", value: "45") + + XCTAssertEqual(node.technicalDirectionTagKey, "camera:direction") + } + + func testTechnicalDirectionTagKeyIsNilForHighwayForwardBackward() { + let node = OsmNode(asUserCreated: "") + node.constructTag("highway", value: "stop") + node.constructTag("direction", value: "forward") + + XCTAssertNil(node.technicalDirectionTagKey) + XCTAssertNil(node.direction) + } + + func testDirectionTagValueFormatsPointBearing() { + let node = OsmNode(asUserCreated: "") + node.constructTag("direction", value: "10") + + XCTAssertEqual(node.directionTagValue(forBearingDegrees: 95), "95") + } + + func testDirectionTagValuePreservesRangeSpan() { + let node = OsmNode(asUserCreated: "") + node.constructTag("direction", value: "90-120") + + XCTAssertEqual(node.directionTagValue(forBearingDegrees: 0), "0-30") + } + func testDirectionShouldParseCardinalDirectionToLowerBound() { let key = "camera:direction"