From 3371f17ac56e7ad09400d2ea0f3b8c0bb15f4b5c Mon Sep 17 00:00:00 2001 From: Naco Siren Date: Tue, 3 Nov 2026 18:06:32 -0800 Subject: [PATCH] Read the pen pressure tests' pixels without AppKit The suite covers the shared brush engine, but it read the exported canvas through NSBitmapImageRep. It now reads raw bytes through BrushRaster.copy. The one test that needs AppKit, the Mac's canvas telling a tablet's events from a mouse's, moves to TabletEventPressureTests. Co-Authored-By: Claude Opus 5.5 --- CompositorTests/PenPressureTests.swift | 53 +++++++++---------- .../TabletEventPressureTests.swift | 21 ++++++++ 2 files changed, 47 insertions(+), 27 deletions(-) create mode 100644 CompositorTests/TabletEventPressureTests.swift diff --git a/CompositorTests/PenPressureTests.swift b/CompositorTests/PenPressureTests.swift index 9699c2a32..ff912dc30 100644 --- a/CompositorTests/PenPressureTests.swift +++ b/CompositorTests/PenPressureTests.swift @@ -1,4 +1,4 @@ -import AppKit +import CoreGraphics import Testing @testable import Compositor @@ -13,18 +13,30 @@ struct PenPressureTests { return session } - /// The canvas as exported, and one pixel's alpha from it. - private func canvas(_ session: EditorSession) async throws -> NSBitmapImageRep { - NSBitmapImageRep(cgImage: try await ImageExporter.shared.render(try #require(session.projectSnapshot())).image) + /// An image's pixels as bytes, top row first, four to a pixel, read through CoreGraphics alone. + private struct Bitmap: Equatable { + let height: Int + private let width: Int + private let bytes: [UInt8] + + init(_ image: CGImage) throws { + let context = try BrushRaster.copy(image) + width = image.width + height = image.height + let start = try #require(context.data).assumingMemoryBound(to: UInt8.self) + bytes = Array(UnsafeBufferPointer(start: start, count: context.bytesPerRow * image.height)) + } + + func alpha(_ x: Int, _ y: Int) -> Int { Int(bytes[(y * width + x) * 4 + 3]) } } - private func alpha(_ bitmap: NSBitmapImageRep, _ x: Int, _ y: Int) -> Int { - var pixel = [Int](repeating: 0, count: 4) - bitmap.getPixel(&pixel, atX: x, y: y) - return pixel[3] + + /// The canvas as exported. + private func canvas(_ session: EditorSession) async throws -> Bitmap { + try Bitmap(try await ImageExporter.shared.render(try #require(session.projectSnapshot())).image) } /// How many rows of column `x` the stroke covers. - private func thickness(_ bitmap: NSBitmapImageRep, _ x: Int) -> Int { - (0.. 127 }.count + private func thickness(_ bitmap: Bitmap, _ x: Int) -> Int { + (0.. 127 }.count } /// A left-to-right stroke along y = 50, its pressure easing from `from` to `to`. @@ -55,7 +67,7 @@ struct PenPressureTests { stroke(session, from: 0.5, to: 0.5) let bitmap = try await canvas(session) // The middle of the line, well inside a soft tip's full-strength center once dabs have built up. - #expect(abs(alpha(bitmap, 150, 50) - 128) <= 4, "hardness \(hardness): \(alpha(bitmap, 150, 50))") + #expect(abs(bitmap.alpha(150, 50) - 128) <= 4, "hardness \(hardness): \(bitmap.alpha(150, 50))") } } @@ -72,7 +84,7 @@ struct PenPressureTests { let pen = session() stroke(pen, from: 0.2, to: 0.6) for other in [try await canvas(mouse), try await canvas(pen)] { - #expect(other.representation(using: .png, properties: [:]) == expected.representation(using: .png, properties: [:])) + #expect(other == expected) } } @@ -86,19 +98,6 @@ struct PenPressureTests { #expect((0..<300).map { thickness(bitmap, $0) }.max()! < 16) } - /// Only a tablet's events carry a pressure to follow; a mouse's, even pressed hard on a Force Touch trackpad, don't. - @Test func theCanvasReadsPressureFromATabletOnly() throws { - func event(tablet: Bool, pressure: Double) throws -> NSEvent { - let event = try #require(CGEvent(mouseEventSource: nil, mouseType: .leftMouseDragged, - mouseCursorPosition: .zero, mouseButton: .left)) - event.setDoubleValueField(.mouseEventPressure, value: pressure) - if tablet { event.setIntegerValueField(.mouseEventSubtype, value: Int64(CGEventMouseSubtype.tabletPoint.rawValue)) } - return try #require(NSEvent(cgEvent: event)) - } - #expect(abs(try #require(CanvasView.penPressure(try event(tablet: true, pressure: 0.3))) - 0.3) < 0.01) - #expect(CanvasView.penPressure(try event(tablet: false, pressure: 0.3)) == nil) - } - /// Without Metal, the dabs follow the pressure the same way. @Test func theSoftwareBrushFollowsThePressureToo() throws { let document = CanvasDocument(width: 300, height: 100) @@ -113,9 +112,9 @@ struct PenPressureTests { try stroke.flush() let snapshot = try stroke.paintSnapshot() // Painted only where the line went: a stroke that thins as it goes is taller at its start than its end. - let image = snapshot.asset.image, bitmap = NSBitmapImageRep(cgImage: image) + let image = snapshot.asset.image, bitmap = try Bitmap(image) let origin = snapshot.transform.origin - func rows(_ x: Int) -> Int { (0.. 127 }.count } + func rows(_ x: Int) -> Int { (0.. 127 }.count } #expect(abs(rows(40) - 40) <= 3 && rows(260) < 16) } } \ No newline at end of file diff --git a/CompositorTests/TabletEventPressureTests.swift b/CompositorTests/TabletEventPressureTests.swift new file mode 100644 index 000000000..6eccca1e3 --- /dev/null +++ b/CompositorTests/TabletEventPressureTests.swift @@ -0,0 +1,21 @@ +import AppKit +import Testing +@testable import Compositor + +/// The Mac's canvas telling a tablet's events from a mouse's. What the pressure then does to a stroke is in +/// PenPressureTests, which needs no AppKit. +@MainActor +struct TabletEventPressureTests { + /// Only a tablet's events carry a pressure to follow; a mouse's, even pressed hard on a Force Touch trackpad, don't. + @Test func theCanvasReadsPressureFromATabletOnly() throws { + func event(tablet: Bool, pressure: Double) throws -> NSEvent { + let event = try #require(CGEvent(mouseEventSource: nil, mouseType: .leftMouseDragged, + mouseCursorPosition: .zero, mouseButton: .left)) + event.setDoubleValueField(.mouseEventPressure, value: pressure) + if tablet { event.setIntegerValueField(.mouseEventSubtype, value: Int64(CGEventMouseSubtype.tabletPoint.rawValue)) } + return try #require(NSEvent(cgEvent: event)) + } + #expect(abs(try #require(CanvasView.penPressure(try event(tablet: true, pressure: 0.3))) - 0.3) < 0.01) + #expect(CanvasView.penPressure(try event(tablet: false, pressure: 0.3)) == nil) + } +}