Skip to content
Merged
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
23 changes: 16 additions & 7 deletions Compositor/Document/EditorSession+Projects.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,7 @@ extension EditorSession {
let manifest = snapshot.manifest
transformEdit = nil
document = CanvasDocument(id: manifest.documentID, width: manifest.width, height: manifest.height,
layers: manifest.layers.map {
ImageLayer(id: $0.id, asset: snapshot.images[$0.id], name: $0.name,
isVisible: $0.isVisible, transform: $0.transform, parentID: $0.parentID, isGroup: $0.isGroup == true, opacity: $0.opacity ?? 1, blendMode: $0.blendMode ?? .normal, mask: snapshot.mask(for: $0), maskSourceID: $0.maskSourceID, adjustment: $0.adjustment,
shape: LayerShape.loaded($0.shape, image: snapshot.images[$0.id]?.image),
effects: $0.effects,
text: LayerText.loaded($0.text, image: snapshot.images[$0.id]?.image))
}, resolution: manifest.resolution ?? 72, guides: manifest.guides ?? [])
layers: snapshot.documentLayers, resolution: manifest.resolution ?? 72, guides: manifest.guides ?? [])
activeLayerID = manifest.activeLayerID
projectURL = url
renamingLayerID = nil
Expand Down Expand Up @@ -77,3 +71,18 @@ extension EditorSession {
createDocument(width: width, height: height, emptyLayer: true)
}
}

extension ProjectSnapshot {
/// The layers the manifest records, each with its pixels, mask, effects and live text or shape. Opening a project
/// and the edits that rebuild the document from a snapshot (canvas and image size, crop, trim) all build them here,
/// so none of them leaves part of a layer behind.
var documentLayers: [ImageLayer] {
manifest.layers.map {
ImageLayer(id: $0.id, asset: images[$0.id], name: $0.name,
isVisible: $0.isVisible, transform: $0.transform, parentID: $0.parentID, isGroup: $0.isGroup == true, opacity: $0.opacity ?? 1, blendMode: $0.blendMode ?? .normal, mask: mask(for: $0), maskSourceID: $0.maskSourceID, adjustment: $0.adjustment,
shape: LayerShape.loaded($0.shape, image: images[$0.id]?.image),
effects: $0.effects,
text: LayerText.loaded($0.text, image: images[$0.id]?.image))
}
}
}
3 changes: 2 additions & 1 deletion Compositor/Document/FloatingSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ extension EditorSession {
guard let index = document?.layers.firstIndex(where: { $0.id == source.id }) else { throw ProjectError.invalid }
document?.layers[index] = ImageLayer(id: source.id, asset: merged.asset, name: source.name, isVisible: source.isVisible,
transform: merged.transform, parentID: source.parentID, isGroup: false,
opacity: source.opacity, blendMode: source.blendMode, mask: merged.mask, maskSourceID: source.maskSourceID)
opacity: source.opacity, blendMode: source.blendMode, mask: merged.mask, maskSourceID: source.maskSourceID,
effects: source.effects)
document?.selection = moved
activeLayerID = source.id
} catch {
Expand Down
3 changes: 2 additions & 1 deletion Compositor/Document/HueSaturation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,8 @@ extension EditorSession {
document?.layers[index] = ImageLayer(id: current.id,
asset: ImportedImage(image: adjusted.image, thumbnail: adjusted.thumbnail ?? adjusted.image, name: current.name),
name: current.name, isVisible: current.isVisible, transform: current.transform, parentID: current.parentID,
isGroup: false, opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID)
isGroup: false, opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID,
effects: current.effects)
endEdit()
}

Expand Down
14 changes: 14 additions & 0 deletions Compositor/Document/LayerEffects.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ nonisolated struct LayerEffects: Codable, Equatable, Sendable {
&& (colorOverlay?.isValid ?? true) && (innerShadow?.isValid ?? true)
&& (outerGlow?.isValid ?? true) && (innerGlow?.isValid ?? true)
}
/// The effects for an image resampled by `factor`: every size, distance and blur in pixels scaled with it, held to
/// the ranges `isValid` accepts.
func scaled(by factor: CGFloat) -> LayerEffects {
func scale(_ value: CGFloat?, upTo limit: CGFloat) -> CGFloat { min(limit, (value ?? 0) * factor) }
var result = self
result.stroke?.size = scale(stroke?.size, upTo: 500)
result.shadow?.distance = scale(shadow?.distance, upTo: 5000)
result.shadow?.blur = scale(shadow?.blur, upTo: 500)
result.innerShadow?.distance = scale(innerShadow?.distance, upTo: 5000)
result.innerShadow?.blur = scale(innerShadow?.blur, upTo: 500)
result.outerGlow?.size = scale(outerGlow?.size, upTo: 500)
result.innerGlow?.size = scale(innerGlow?.size, upTo: 500)
return result
}
var kinds: [LayerEffectKind] { LayerEffectKind.allCases.filter { contains($0) } }
func contains(_ kind: LayerEffectKind) -> Bool {
switch kind {
Expand Down
3 changes: 2 additions & 1 deletion Compositor/Document/Levels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ extension EditorSession {
beginEdit("Levels")
document?.layers[index] = ImageLayer(id: current.id, asset: asset, name: current.name, isVisible: current.isVisible,
transform: current.transform, parentID: current.parentID, isGroup: false,
opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID)
opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID,
effects: current.effects)
endEdit()
} catch { brushError = error.localizedDescription }
}
Expand Down
3 changes: 2 additions & 1 deletion Compositor/Document/SelectionEdits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ extension EditorSession {
} else {
self.document?.layers[index] = ImageLayer(id: current.id, asset: asset, name: current.name,
isVisible: current.isVisible, transform: current.transform, parentID: current.parentID, isGroup: false,
opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID)
opacity: current.opacity, blendMode: current.blendMode, mask: current.mask, maskSourceID: current.maskSourceID,
effects: current.effects)
}
endEdit()
brushRevision += 1
Expand Down
2 changes: 1 addition & 1 deletion Compositor/IO/CanvasResizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ actor CanvasResizer {
moved.origin.x += offset.x
moved.origin.y += offset.y
return moved
}, maskLinked: layer.maskLinked, shape: layer.shape, text: layer.text))
}, maskLinked: layer.maskLinked, shape: layer.shape, effects: layer.effects, text: layer.text))
}
var images = snapshot.images
// A colored extension is separate bottom-layer content. The old canvas
Expand Down
8 changes: 5 additions & 3 deletions Compositor/IO/ImageResizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ actor ImageResizer {
guard options.width * options.height <= DocumentLimits.maxSurfacePixels else { throw ProjectError.tooLarge }
let sx = CGFloat(options.width) / CGFloat(old.width)
let sy = CGFloat(options.height) / CGFloat(old.height)
// Effects are sized in pixels, so they scale with the pixels, as Photoshop's Scale Styles does: by the mean of
// the two factors when the image is stretched. Live text and shapes are resampled into plain pixels.
let effectScale = (sx * sy).squareRoot()
manifest.guides = old.guides?.map { $0.scaled(x: sx, y: sy) }
var images: [UUID: ImportedImage] = [:]
var masks: [UUID: ImportedImage] = [:]
Expand Down Expand Up @@ -95,7 +98,7 @@ actor ImageResizer {
manifest.layers.append(ProjectLayerRecord(id: layer.id, name: layer.name, isVisible: layer.isVisible,
transform: transform, imageFile: layer.imageFile, parentID: layer.parentID, isGroup: layer.isGroup, opacity: layer.opacity, blendMode: layer.blendMode, maskFile: layer.maskFile, maskEnabled: layer.maskEnabled, maskSourceID: layer.maskSourceID, adjustment: layer.adjustment,
maskPlacement: layer.maskPlacement.map { $0.placing($0.unitToDocument.concatenating(CGAffineTransform(scaleX: sx, y: sy))) },
maskLinked: layer.maskLinked))
maskLinked: layer.maskLinked, effects: layer.effects?.scaled(by: effectScale)))
}
return ProjectSnapshot(manifest: manifest, images: images, masks: masks)
}
Expand All @@ -111,8 +114,7 @@ extension EditorSession {
beginEdit(actionName)
let m = snapshot.manifest
document = CanvasDocument(id: m.documentID, width: m.width, height: m.height,
layers: m.layers.map { ImageLayer(id: $0.id, asset: snapshot.images[$0.id], name: $0.name,
isVisible: $0.isVisible, transform: $0.transform, parentID: $0.parentID, isGroup: $0.isGroup == true, opacity: $0.opacity ?? 1, blendMode: $0.blendMode ?? .normal, mask: snapshot.mask(for: $0), maskSourceID: $0.maskSourceID, adjustment: $0.adjustment) }, resolution: m.resolution ?? 72, guides: m.guides ?? [])
layers: snapshot.documentLayers, resolution: m.resolution ?? 72, guides: m.guides ?? [])
endEdit()
viewport.fit(documentSize: document!.size)
}
Expand Down
156 changes: 156 additions & 0 deletions CompositorTests/LayerEffectsSurviveTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import AppKit
import Testing
@testable import Compositor

/// A layer's effects, and a text or shape layer's live style, stay with it through the edits that rebuild the layer:
/// canvas and image size, crop, trim, Hue/Saturation, Levels, Invert and transforming a selection.
@MainActor
struct LayerEffectsSurviveTests {
private let effects = LayerEffects(stroke: StrokeEffect(size: 4), shadow: ShadowEffect(distance: 20, blur: 10),
outerGlow: OuterGlowEffect(size: 8))

/// What a layer carries beyond its pixels.
private struct Extras: Equatable {
var effects: LayerEffects?
var text: LayerTextStyle?
var shape: LayerShapeStyle?
}
private func extras(_ session: EditorSession, _ ids: [UUID]) -> [Extras?] {
ids.map { id in
session.document?.layers.first { $0.id == id }
.map { Extras(effects: $0.effects, text: $0.liveText?.style, shape: $0.liveShape?.style) }
}
}

/// A transparent document with a text layer and a shape layer, both carrying `effects`.
private func textAndShape() throws -> (session: EditorSession, ids: [UUID]) {
let session = EditorSession()
session.createDocument(width: 200, height: 120, emptyLayer: true)
session.selectTool(.type)
session.beginText(at: CGPoint(x: 30, y: 60))
var draft = try #require(session.textDraft)
draft.style.content = "Text"
#expect(session.applyText(draft))
let text = try #require(session.activeLayerID)
session.selectTool(.shape)
session.beginShape(at: CGPoint(x: 120, y: 30))
session.dragShape(to: CGPoint(x: 170, y: 80), square: false, fromCenter: false)
session.finishShape()
let shape = try #require(session.activeLayerID)
try #require(shape != text)
for id in [text, shape] {
let index = try #require(session.document?.layers.firstIndex { $0.id == id })
session.document?.layers[index].effects = effects
}
let before = extras(session, [text, shape])
try #require(before[0]?.text != nil && before[1]?.shape != nil)
return (session, [text, shape])
}

/// A gray square on a transparent document, its layer carrying `effects`.
private func pixelLayer() throws -> EditorSession {
let session = EditorSession()
session.createDocument(width: 48, height: 48)
let context = try BrushRaster.context(width: 24, height: 24, mask: false)
context.setFillColor(CGColor(srgbRed: 0.7, green: 0.4, blue: 0.2, alpha: 1))
context.fill(CGRect(x: 3, y: 3, width: 18, height: 18))
let image = try #require(context.makeImage())
session.insert(ImportedImage(image: image, thumbnail: image, name: "Sample"))
let index = try #require(session.document?.layers.firstIndex { $0.id == session.activeLayerID })
session.document?.layers[index].effects = effects
return session
}

@Test func canvasSizeKeepsEffectsTextAndShapes() async throws {
let (session, ids) = try textAndShape()
let before = extras(session, ids)
let resized = try await CanvasResizer.shared.resize(try #require(session.projectSnapshot()),
to: CanvasSizeOptions(width: 260, height: 160, anchor: 4))
session.applyDocumentSize(resized, actionName: "Canvas Size")
#expect(session.document?.width == 260)
#expect(extras(session, ids) == before)
}

@Test func cropKeepsEffectsTextAndShapes() async throws {
let (session, ids) = try textAndShape()
let before = extras(session, ids)
session.selectTool(.crop)
session.cropRect = CGRect(x: 10, y: 10, width: 180, height: 100)
await session.commitCrop()
#expect(session.document?.width == 180)
#expect(extras(session, ids) == before)
}

@Test func trimKeepsEffectsTextAndShapes() async throws {
let (session, ids) = try textAndShape()
let before = extras(session, ids)
#expect(try await session.trim())
#expect((session.document?.width ?? 200) < 200)
#expect(extras(session, ids) == before)
}

@Test func resolutionOnlyImageSizeKeepsEffectsTextAndShapes() async throws {
let (session, ids) = try textAndShape()
let before = extras(session, ids)
let resized = try await ImageResizer.shared.resize(try #require(session.projectSnapshot()),
to: ImageSizeOptions(width: 200, height: 120, resolution: 300))
session.applyImageSize(resized)
#expect(session.document?.resolution == 300)
#expect(extras(session, ids) == before)
}

/// The layer's pixels are resampled, so its effects are scaled with them, as Photoshop's Scale Styles does.
@Test func imageSizeKeepsEffectsScaledWithTheImage() async throws {
let session = try pixelLayer()
let resized = try await ImageResizer.shared.resize(try #require(session.projectSnapshot()),
to: ImageSizeOptions(width: 24, height: 24, resolution: 72))
session.applyImageSize(resized)
#expect(session.document?.width == 24)
let kept = try #require(session.activeLayer?.effects)
#expect(kept.stroke?.size == 2 && kept.outerGlow?.size == 4)
#expect(kept.shadow?.distance == 10 && kept.shadow?.blur == 5 && kept.shadow?.angle == effects.shadow?.angle)
}

@Test func hueSaturationKeepsEffects() async throws {
let session = try pixelLayer()
var settings = HueSaturationSettings()
settings.adjustments[.master] = RangeAdjustment(hue: 90)
session.beginHueSaturation()
session.updateHueSaturation(settings, preview: true)
await session.hueSaturationTask?.value
await session.commitHueSaturation()
#expect(session.history.undoName == "Hue/Saturation")
#expect(session.activeLayer?.effects == effects)
}

@Test func levelsKeepsEffects() async throws {
let session = try pixelLayer()
var settings = LevelsSettings()
settings.current = LevelRange(black: 64, gamma: 1, white: 128)
session.beginLevels()
session.updateLevels(settings, preview: true)
await session.commitLevels()
#expect(session.history.undoName == "Levels")
#expect(session.activeLayer?.effects == effects)
}

@Test func invertKeepsEffects() async throws {
let session = try pixelLayer()
await session.invertPixels()
#expect(session.history.undoName == "Invert")
#expect(session.activeLayer?.effects == effects)
}

@Test func transformingASelectionKeepsEffects() async throws {
let session = try pixelLayer()
let source = try #require(session.activeLayerID)
session.applySelection(CGPath(rect: CGRect(x: 14, y: 14, width: 10, height: 10), transform: nil), mode: .replace, name: "Select")
await session.beginSelectionTransform()
var draft = try #require(session.transformEdit?.draft)
draft.origin.x += 8
session.previewTransform(draft)
session.commitTransform()
#expect(session.history.undoName == "Transform Selection")
#expect(session.document?.layers.first { $0.id == source }?.effects == effects)
}
}
Loading