I need to scale a path, but instead of using the path's bounds, the transform should be performed using a larger, enclosing, arbitrary rect. (i.e. a "canvas".)
My problem is how to do that without drawing the canvas.
For example, in the following image:
the blue square indicates the native rect of the path (a rocket in this case).
the purple rect indicates the bounds of the "canvas" or parent coordinate system.
the purple rect is for positioning only; not to be drawn. The whitespace is similarly ignored.
I need to scale the canvas, and keep the transformed rocket path (for clipping operations, caching etc.). i.e. I need to lose the purple rect after transformation.
Ideally, I'd use something like the following fiction:
var canvasPath = UIBezierPath(rect: canvasRect)
canvasPath.append(rocketPath, withName: "rocket")
canvasPath.doSomeTransformOrOther()
let transformedRocketPath = canvasPath.path(forName: "rocket")
Sadly, the magical append(:withName:) and path(forName:) methods do not exist, even if UIBezierPath or CGPath treated subpaths like this. Alas!
Another approach could be to temporarily add points to the path, one for each canvas corner. Then, transform the path. Then, walk the path with CGPath's (hideous) apply(info:function:) method, rebuild the path without the canvas points, then draw the result. (Yeah, pass the vodka.)
Can anyone offer a more sane solution? Thanks for any helpi
(For those interested: this is related to a problem of how to center irregularly-shaped paths. The purple rect represents the bounds of the minimum enclosing circle. Drawing the rocket with the MEC's coordinate system will make the rocket appear more visually centered, compared to using boundingBox.)
More info: Fitting UIBezierPath/CGPath to circle
Here's my final approach to the problem: use a "canvas" rect that will be scaled along with the path. Then use the canvas rect when translating the (scaled) path. This keeps the path relative to the canvas
An example of doing this is getting a path from an SVG, and then scaling, positioning the path relative not to the path's bounds, but relative to the native dimensions (the 'viewbox') of the SVG. This in turn allows relative positioning of the path within the SVG canvas.
extension UIBezierPath
{
func scaled(usingCanvasSize canvasSize: CGSize,
scale: CGFloat) -> CGRect
{
let canvasRect = CGRect(origin: .zero, size: canvasSize)
let canvasPath = UIBezierPath(rect: canvasRect)
let scaleTransform = CGAffineTransform(scaleX: scale, y: scale)
canvasPath.apply(scaleTransform)
self.apply(scaleTransform)
return canvas.bounds
}
}
// Usage:
let nativeSVGSize: CGSize = ... // 'viewBox' size of SVG
let path = UIBezierPath(...) // From SVG, wherever.
let scale: CGFloat = 0.5
// Scale the path, get a "canvas" rect.
let canvasRect = path.scaled(usingCanvasSize: nativeSVGSize,
scale: scale)
// Use the (scaled) canvas for info about the path from now on.
// e.g. move the path using the center of the canvasRect.
let pathCenter = canvasRect.center
let destCenter = ...
let movement = CGPoint(x: destCenter.x - pathCenter.x,
y: destCenter.y - pathCenter.y)
path.apply(CGAffineTransform(translationX: movement.x,
y: movement.y))
Related
I have an app where users can add an image to a map. This is pretty straightforward. It becomes much more difficult when I want to add rotation (taken from the current map heading). The code I use to create an image is pretty straightforward:
let imageAspectRatio = Double(image.size.height / image.size.width)
let mapAspectRatio = Double(visibleMapRect.size.height / visibleMapRect.size.width)
var mapRect = visibleMapRect
if mapAspectRatio > imageAspectRatio {
// Aspect ratio of map is bigger than aspect ratio of image (map is higher than the image), take away height from the rectangle
let heightChange = mapRect.size.height - mapRect.size.width * imageAspectRatio
mapRect.size.height = mapRect.size.width * imageAspectRatio
mapRect.origin.y += heightChange / 2
} else {
// Aspect ratio of map is smaller than aspect ratio of image (map is higher than the image), take away width from the rectangle
let widthChange = mapRect.size.width - mapRect.size.height / imageAspectRatio
mapRect.size.width = mapRect.size.height / imageAspectRatio
mapRect.origin.x += widthChange / 2
}
photos.append(ImageOverlay(image: image, boundingMapRect: mapRect, rotation: cameraHeading))
The ImageOverlay class inherits from MKOverlay, which I can easily draw on the map. Here's the code for that class:
class ImageOverlay: NSObject, MKOverlay {
let image: UIImage
let boundingMapRect: MKMapRect
let coordinate: CLLocationCoordinate2D
let rotation: CLLocationDirection
init(image: UIImage, boundingMapRect: MKMapRect, rotation: CLLocationDirection) {
self.image = UIImage.fixedOrientation(for: image) ?? image
self.boundingMapRect = boundingMapRect
self.coordinate = CLLocationCoordinate2D(latitude: boundingMapRect.midX, longitude: boundingMapRect.midY)
self.rotation = rotation
}
}
I have figured out by how much I need to scale and translate the context to fit on the correct location on the map (from which it was added). I can't figure out how to rotate the context to make the image render in the correct location.
I figured out that the map rotation was in degrees, and the rotate method takes radians (took longer than I dare to admit), but the image moves around when I apply the rotation.
I use the following code to render the overlay:
override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {
guard let overlay = overlay as? ImageOverlay else {
return
}
let rect = self.rect(for: overlay.boundingMapRect)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: 0.0, y: -rect.size.height)
context.rotate(by: CGFloat(overlay.rotation * Double.pi / 180))
context.draw(overlay.image.cgImage!, in: rect)
}
How do I need to rotate this context to get the image to be aligned properly?
This is an open source project with the code here
Edit: I have tried (and failed) to use some kind of trig function. If I scale by a factor of 3 * sin(rotation) / 4 (no clue where the 3/4 comes from), I get a correct scale for some rotations, but not for others.
It sounds like you trying to rotate the object in it's local coordinates, but are actually rotating in the world coordinates. I admit I'm not familiar with this library, but the moral of the story is that order of operation on transformations matter. But it looks like you have "TranslateBy" and you are sending in zero, which might not be moving it at all? If you are trying to translate back to local you'd need to translate to local by subtracting it's current coordinates in the CLLocationCoordinate2D struct.
Translate to local coordinates if not already there (which might be X:0, y:0, you might need to subtract the current coordinate values instead of trying to set them to a specific number like 0)
Apply rotation
Translate back to world coordinates (where you had it before, originally defined as CLLocationCoordinate2D)
This should allow the image to be in the correct position but now rotated to align with the heading.
Here is a paper which explains what you are probably encountering, although more in depth and specific to the matrix/opengl, but the first slide illustrates your issue.
Transforms PDF
I'm trying to create an animation where a CALayer rectangle changes from being flush with the borders of a view to having either the left, right, or both corners rounded and the width of the rectangle is also changed.
The problem I'm having is that the animation looks distorted. If I disable the corner radius change in the animation, the distortion doesn't happen. I suspect this is because when the original path and the final path in the animation have a different number of rounded corners, the paths have a different number of control points, so the path animation behavior is undefined as per the docs.
I'm thinking that I should try and find a way to get the number of control points in the rounded rect to be equal to the number in the non-rounded rect but I'm not sure how I would do this since I haven't found a way to count the number of control points in a CGPath/UIBezierPath.
Here's the code I'm using right now, where I'm animating the path, but I'm open to changing the implementation entirely to 'cheat' by having two rectangles or something like that.
func setSelection(to color: UIColor, connectedLeft: Bool, connectedRight: Bool, animated: Bool) {
self.connectedLeft = connectedLeft
self.connectedRight = connectedRight
self.color = color
if animated {
let pathAnimation = CABasicAnimation(keyPath: "path")
let colorAnimation = CABasicAnimation(keyPath: "fillColor")
self.configure(animation: pathAnimation)
self.configure(animation: colorAnimation)
pathAnimation.toValue = self.rectPath(connectedLeft: connectedLeft, connectedRight: connectedRight).cgPath
colorAnimation.toValue = color.cgColor
let group = CAAnimationGroup()
group.animations = [pathAnimation, colorAnimation]
self.configure(animation: group)
group.delegate = self
self.rectLayer.add(group, forKey: Constants.selectionChangeAnimationKey)
} else {
self.rectLayer.fillColor = color.cgColor
self.rectLayer.path = self.rectPath(connectedLeft: connectedLeft, connectedRight: connectedRight).cgPath
}
}
private func rectPath(connectedLeft: Bool, connectedRight: Bool) -> UIBezierPath {
let spacing: CGFloat = 5
var origin = self.bounds.origin
var size = self.bounds.size
var corners = UIRectCorner()
if !connectedLeft {
origin.x += spacing
size.width -= spacing
corners.formUnion([.topLeft, .bottomLeft])
}
if !connectedRight {
size.width -= spacing
corners.formUnion([.topRight, .bottomRight])
}
let path = UIBezierPath(roundedRect: .init(origin: origin, size: size), byRoundingCorners: corners, cornerRadii: .init(width: 8, height: 8))
print(path.cgPath)
return path
}
You are correct as to why your animation doesn't work correctly.
The Core Graphics/Core Animation methods that draw arcs compose the arcs using variable numbers of cubic bezier curves depending on the angle being drawn.
I believe that a 90 degree corner is rendered as a single cubic bezier curve.
Based on a recent test I did, it seems all you need to do is have the same number of endpoints as a Bezier curve in order to get the sharp-corner-to-rounded-corner animation to draw correctly. For a rounded corner, I'm pretty sure it draws a line segment up to the rounded corner, then the arc as one Bezier curve, then the next line segment. Thus you SHOULD be able to create a rectangle by drawing each sharp corner point twice. I haven't tried it, but I think it would work.
(Based on my understanding of how arcs of circles are drawn using bezier curve, an arc of a circle is composed of a cubic bezier curve for each quarter-circle or part of a quarter circle that is drawn. So for a 90 degree bend, you just need 2 control points for a sharp corner to replace a rounded corner. If the angle is >90 degrees but less than 180, you'd want 3 control points at the corner, and if it's >180 but less than 270, you'd want 4 control points.)
Edit:
The above approach of adding each corner-point twice for a rounded rectangle SHOULD work for the rounded rectangle case (where every corner is exactly 90 degrees) but it doesn't work for irregular polygons where the angle that needs to be rounded varies. I couldn't work out how to handle that case. I already created a demo project that generated irregular polygons with any mixture of sharp and curved corners, so I adapted it to handle animation.
The project now includes a function that generates CGPaths with a settable mixture of rounded and sharp corners that will animate the transition between sharp corners and rounded corners. The function you want to look at is called roundedRectPath(rect:byRoundingCorners:cornerRadius:)
Here is the function header and in-line documentation
/**
This function works like the UIBezierPath initializer `init(roundedRect:byRoundingCorners:cornerRadii:)` It returns a CGPath a rounded rectangle with a mixture of rounded and sharp corners, as specified by the options in `corners`.
Unlike the UIBezierPath `init(roundedRect:byRoundingCorners:cornerRadii:` intitializer, The CGPath that is returned by this function will animate smoothly from rounded to non-rounded corners.
- Parameter rect: The starting rectangle who's corners you wish to round
- Parameter corners: The corners to round
- Parameter cornerRadius: The corner radius to use
- Returns: A CGPath containing for the rounded rectangle.
*/
public func roundedRectPath(rect: CGRect, byRoundingCorners corners: UIRectCorner, cornerRadius: CGFloat) -> CGPath
You can download the project from Github and try it out at this link.
Here is a GIF of the demo app in operation:
I am using SpriteKit to draw a graph (with the ability to zoom in and pan around).
When I use an SKCropNode to crop the grid of my graph it doesn't crop the desired area. It crops less, no matter if I use a rectangular SKShapeNode or a SKSpriteNode (with image) as .maskNode.
Here is my code:
//GRID
let grid = SKCropNode()
graphViewModel.graphScene.addChild(grid)
let ratio:CGFloat = 1000 / 500
let width = (graphViewModel.sceneSize.width*0.95)
let newSize = CGSize(width: width, height: width/ratio)
let origin = CGPoint(x: -newSize.width/2.0, y: 0.0)
let rectangularMask = SKShapeNode(rect: CGRect(origin: origin, size: newSize))
rectangularMask.fillColor = UIColor.lightGray
rectangularMask.zPosition = -10.0 //So it appears behind the grid, doesn't affect the cropping
grid.maskNode = rectangularMask
graphViewModel.graphScene.addChild(rectangularMask)
Here are two screenshots to illustrate what I mean:
This is the graph with its grid not being cropped.
This is the graph with the maskNode set.
The lightGray Area is the actual rectangularNode and the grid is being cut off a lot less than it ought to be.
My scene is scaled so I can zoom in without pixelating.
When I disable zooming (setting the scene's size to the view's size) then the bug disappears. Unfortunately I need zooming without any pixel artefacts.
Maybe someone has an idea how to fix this issue. It might also be a SpriteKit Bug.
I override draw rect in a custom UIView class:
override func draw(_ rect: CGRect) { ...
Then, I draw into it like this:
func drawWithGradient(path:UIBezierPath,linearGradient:LinearGradientAttributeSet) {
let context = self
context.saveGState()
path.addClip()
context.drawLinearGradient(
linearGradient.gradient,
start:linearGradient.start,
end:linearGradient.end,
options:linearGradient.options
)
context.restoreGState()
}
And that works great, the UIView fits the size of the UIBezierPath perfectly.
But, I wanted a bit more space above the path, to draw some more things. So, I moved the path down using an affine transform. (This is necessary because there are a lot of images and the amount of space will vary.)
let shiftdownAffine = CGAffineTransform(translationX: 0, y: roomAbove)
thePath.apply(shiftdownAffine)
I didn't change anything else, but the magic was gone. Although I passed the new (translated) path into drawWithGradient, the customUIView matches the size of the original UIBezierPath, while I expected it to behave exactly as though I changed the original UIBezierPath directly through drawing code. With the translated UIBezierPath, the path is drawn at the new location, but the size of the UIView is the same, the so the path gets cut off.
Can you explain why this is happening and/or how to make a translated path that would be treated the same as a drawn path?
I'm trying give my user fine selection of a point they touch on a UIImage.
I have a magnifying square in the top left corner that shows where they're touching at 2x zoom. It works well.
I'm trying to add a "crosshair" in the center of the magnifying area to make selection clearer.
With the code below no line is visible.
//Bulk of the magifying code
public override func drawRect(rect: CGRect) {
let context: CGContextRef = UIGraphicsGetCurrentContext()!
CGContextScaleCTM(context, 2, 2)
CGContextTranslateCTM(context, -self.touchPoint.x, -self.touchPoint.y)
drawLine(context)
self.viewToMagnify.layer.renderInContext(context)
}
//Code for drawing horizontal line of crosshair
private func drawLine(ctx: CGContext) {
let lineHeight: CGFloat = 3.0
let lineWidth: CGFloat = min(bounds.width, bounds.height) * 0.3
let horizontalPath = UIBezierPath()
horizontalPath.lineWidth = lineHeight
let hStart = CGPoint(x:bounds.width/2 - lineWidth/2, y:bounds.height/2)
let hEnd = CGPoint(x:bounds.width/2 + lineWidth/2,y:bounds.height/2)
horizontalPath.moveToPoint(hStart)
horizontalPath.addLineToPoint(hEnd)
UIColor.whiteColor().setStroke()
horizontalPath.stroke()
}
It's possible that the line is being drawn but too small or not where I expect it to be.
I've tried other ways of drawing the line like using CGContextAddPath
I think the issue might be related to the renderInContextView not taking my drawing into account, or I'm not adding the path to the context correctly?
The magnification code is based on GJNeilson's work, all I've done is pin the centre point of the magnifying glass to the top left and remove the mask.
I think you're drawing the line then drawing the image over it. Try calling drawLine last.
Also, the scale and translate are still active when you draw the line which may be positioning it offscreen. You might have to reset it using CGContextSaveGState and CGContextRestoreGState