From 7811b211414f40880130f1762d69aebdcb2309bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 15:44:46 +0000 Subject: [PATCH 1/2] feat: rotate gesture updates direction/camera:direction on nodes Standalone nodes with a parsable direction or camera:direction tag now expose Rotate in the edit menu. Rotation uses the same overlay and pinch gesture as areas but writes bearing degrees to the tag instead of moving geometry, with live direction wedge preview on the map. fix: move direction-rotate state out of extension (Swift compile error) Co-Authored-By: Tobias --- .../EditorLayer/EditorMapLayer+Edit.swift | 59 ++++++++++++++++++- src/Shared/EditorLayer/EditorMapLayer.swift | 4 ++ src/Shared/MapView.swift | 25 +++++++- src/iOS/Direction/OsmNode+Direction.swift | 29 +++++++++ .../OsmNode_DirectionTestCase.swift | 38 ++++++++++++ 5 files changed, 150 insertions(+), 5 deletions(-) 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" From bbd2fb8a6b1371aacf45f279efc733be1d071274 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 05:13:57 +0000 Subject: [PATCH 2/2] refactor: apply code-review tweaks for direction-node rotate - Remove unused directionPointBearing - DRY eligibility via canRotateSelectedNodeDirection() - isRotateDirectionMode as computed property - Defer undo grouping to gesture began (match geometry rotate) - Call didUpdateObject once on finish, not each gesture frame Co-authored-by: Tobias --- .../EditorLayer/EditorMapLayer+Edit.swift | 30 ++++++++++--------- src/Shared/EditorLayer/EditorMapLayer.swift | 9 ++++++ src/Shared/MapView.swift | 18 +++++------ src/iOS/Direction/OsmNode+Direction.swift | 6 ---- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/Shared/EditorLayer/EditorMapLayer+Edit.swift b/src/Shared/EditorLayer/EditorMapLayer+Edit.swift index e21088210..dda555a24 100644 --- a/src/Shared/EditorLayer/EditorMapLayer+Edit.swift +++ b/src/Shared/EditorLayer/EditorMapLayer+Edit.swift @@ -460,17 +460,21 @@ extension EditorMapLayer { // MARK: Rotate direction tag - func rotateDirectionBegin() { + func prepareDirectionRotation() { 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 rotateDirectionBegin() { + mapData.beginUndoGrouping() + dragState.didMove = false + directionRotateUndoOpen = true + } + func rotateDirectionContinue(delta: CGFloat) { guard let node = selectedNode, let tagKey = directionRotateTagKey, @@ -493,19 +497,20 @@ extension EditorMapLayer { tags[tagKey] = value mapData.setTags(tags, for: node) setNeedsLayout() - owner.didUpdateObject() } func rotateDirectionFinish() { - mapData.endUndoGrouping() + if directionRotateUndoOpen { + mapData.endUndoGrouping() + directionRotateUndoOpen = false + } + if dragState.didMove { + owner.didUpdateObject() + } directionRotateTagKey = nil directionRotateInitialBearing = nil } - func isRotateDirectionMode() -> Bool { - directionRotateTagKey != nil - } - // MARK: Editing func adjust(_ node: OsmNode, byScreenDistance delta: CGPoint) { @@ -723,7 +728,7 @@ extension EditorMapLayer { } else if let selectedNode = selectedNode { // node actionList += [.DUPLICATE] - if selectedNode.technicalDirectionTagKey != nil { + if canRotateSelectedNodeDirection() { actionList.append(.ROTATE) } } else if let selectedRelation = selectedRelation { @@ -796,10 +801,7 @@ extension EditorMapLayer { owner.placePushpinForSelection(at: nil) case .ROTATE: let canRotateGeometry = selectedWay != nil || (selectedRelation?.isMultipolygon() ?? false) - let canRotateDirection = selectedWay == nil && - selectedRelation == nil && - selectedNode?.technicalDirectionTagKey != nil - guard canRotateGeometry || canRotateDirection else { + guard canRotateGeometry || canRotateSelectedNodeDirection() 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 a5bd58f09..033d0b931 100644 --- a/src/Shared/EditorLayer/EditorMapLayer.swift +++ b/src/Shared/EditorLayer/EditorMapLayer.swift @@ -181,6 +181,15 @@ final class EditorMapLayer: CALayer { /// Active while rotating a node's `direction` / `camera:direction` tag (not geometry). var directionRotateTagKey: String? var directionRotateInitialBearing: Int? + var directionRotateUndoOpen = false + + var isRotateDirectionMode: Bool { directionRotateTagKey != nil } + + func canRotateSelectedNodeDirection() -> Bool { + selectedWay == nil && + selectedRelation == nil && + selectedNode?.technicalDirectionTagKey != nil + } let objectFilters = EditorFilters() diff --git a/src/Shared/MapView.swift b/src/Shared/MapView.swift index 49a66149b..063e5042e 100644 --- a/src/Shared/MapView.swift +++ b/src/Shared/MapView.swift @@ -269,10 +269,6 @@ 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() @@ -280,8 +276,8 @@ final class MapView: UIView, UIGestureRecognizerDelegate, UIContextMenuInteracti return } - if isDirectionRotate { - editorLayer.rotateDirectionBegin() + if editorLayer.canRotateSelectedNodeDirection() { + editorLayer.prepareDirectionRotation() } removePin() let rotateObjectOverlay = CAShapeLayer() @@ -311,7 +307,7 @@ final class MapView: UIView, UIGestureRecognizerDelegate, UIContextMenuInteracti func endObjectRotation() { isRotateObjectMode?.rotateObjectOverlay.removeFromSuperlayer() - if editorLayer.isRotateDirectionMode() { + if editorLayer.isRotateDirectionMode { editorLayer.rotateDirectionFinish() } placePushpinForSelection() @@ -1059,18 +1055,20 @@ final class MapView: UIView, UIGestureRecognizerDelegate, UIContextMenuInteracti } // Rotate object on screen if rotationGesture.state == .began { - if !editorLayer.isRotateDirectionMode() { + if editorLayer.isRotateDirectionMode { + editorLayer.rotateDirectionBegin() + } else { editorLayer.rotateBegin() } } else if rotationGesture.state == .changed { - if editorLayer.isRotateDirectionMode() { + if editorLayer.isRotateDirectionMode { editorLayer.rotateDirectionContinue(delta: rotationGesture.rotation) } else { editorLayer.rotateContinue(delta: rotationGesture.rotation, rotate: rotate) } } else { // ended - if !editorLayer.isRotateDirectionMode() { + if !editorLayer.isRotateDirectionMode { editorLayer.rotateFinish() } endObjectRotation() diff --git a/src/iOS/Direction/OsmNode+Direction.swift b/src/iOS/Direction/OsmNode+Direction.swift index 81e8d63e9..9d3d14d45 100644 --- a/src/iOS/Direction/OsmNode+Direction.swift +++ b/src/iOS/Direction/OsmNode+Direction.swift @@ -58,12 +58,6 @@ extension OsmNode { 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 }