| 1 | import AppKit |
| 2 | import CoreText |
| 3 | import UniformTypeIdentifiers |
| 4 | |
| 5 | /// Disk store + renderer for storyboard boards. The raster (drawing) layer of |
| 6 | /// each board lives in memory during a session and flushes to a PNG beside the |
| 7 | /// project file — `{project}/Storyboard/NN.png`, where NN is the panel's |
| 8 | /// position in the storyboard. The shape layer lives in the project model. |
| 9 | /// Composites are cached per revision. |
| 10 | final class BoardStore { |
| 11 | /// The document context that owns this raster store. Set at construction. |
| 12 | unowned var ctx: DocumentContext! |
| 13 | |
| 14 | /// Default board background — #e8e8e8, softer than pure white. |
| 15 | static let paper = NSColor(calibratedRed: 0xE8 / 255.0, green: 0xE8 / 255.0, |
| 16 | blue: 0xE8 / 255.0, alpha: 1) |
| 17 | |
| 18 | /// Pre-2026 rasters keyed by board UUID under Application Support. Read on |
| 19 | /// demand so old projects keep their drawings; rewritten to the new |
| 20 | /// project-relative path on the next save. |
| 21 | private let legacyBoardsDir: URL |
| 22 | /// In-session source of truth for drawing layers, keyed by board id. Disk |
| 23 | /// is written only at save time (`flushRasters`), so unsaved projects keep |
| 24 | /// their drawings purely here. |
| 25 | private var rasterCache: [UUID: NSImage] = [:] |
| 26 | private var compositeCache = NSCache<NSString, NSImage>() |
| 27 | /// Bumped on every raster save so composites invalidate without touching |
| 28 | /// the model (strokes are editor-local, not undo entries). |
| 29 | private var rasterVersions: [UUID: Int] = [:] |
| 30 | |
| 31 | init() { |
| 32 | let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, |
| 33 | in: .userDomainMask)[0] |
| 34 | .appendingPathComponent("Sequencer", isDirectory: true) |
| 35 | legacyBoardsDir = appSupport.appendingPathComponent("Boards", isDirectory: true) |
| 36 | compositeCache.countLimit = 200 |
| 37 | } |
| 38 | |
| 39 | /// 1-based ordinal of a board among the project's storyboard panels, or nil |
| 40 | /// if it isn't attached to a panel yet (freshly created, mid-paste). |
| 41 | private func boardIndex(_ boardId: UUID, in model: ProjectModel) -> Int? { |
| 42 | let panels = model.orderedStoryboardPanels() |
| 43 | guard let i = panels.firstIndex(where: { $0.board?.id == boardId }) else { return nil } |
| 44 | return i + 1 |
| 45 | } |
| 46 | |
| 47 | func rasterImage(_ boardId: UUID) -> NSImage? { |
| 48 | if let img = rasterCache[boardId] { return img } |
| 49 | // Drawings are loaded into the cache from the document package when it |
| 50 | // opens (`loadRasters`); the only on-demand read left is the legacy |
| 51 | // pre-2026 store keyed by board UUID under Application Support. |
| 52 | let legacy = legacyBoardsDir.appendingPathComponent("\(boardId.uuidString).png") |
| 53 | if let img = NSImage(contentsOf: legacy) { |
| 54 | rasterCache[boardId] = img |
| 55 | return img |
| 56 | } |
| 57 | return nil |
| 58 | } |
| 59 | |
| 60 | func saveRaster(_ image: NSImage?, boardId: UUID) { |
| 61 | rasterVersions[boardId, default: 0] += 1 |
| 62 | if let image { |
| 63 | rasterCache[boardId] = image |
| 64 | } else { |
| 65 | rasterCache.removeValue(forKey: boardId) |
| 66 | } |
| 67 | // Persist lazily: strokes bypass `Store.mutate`, so nudge the document |
| 68 | // dirty and let autosave flush the raster to disk. |
| 69 | ctx.store.noteRasterChanged() |
| 70 | } |
| 71 | |
| 72 | func duplicateRaster(from: UUID, to: UUID) { |
| 73 | rasterVersions[to, default: 0] += 1 |
| 74 | if let img = rasterImage(from)?.copy() as? NSImage { |
| 75 | rasterCache[to] = img |
| 76 | } else { |
| 77 | rasterCache.removeValue(forKey: to) |
| 78 | } |
| 79 | ctx.store.noteRasterChanged() |
| 80 | } |
| 81 | |
| 82 | /// Encode a board's drawing layer as PNG bytes (clipboard transfer). |
| 83 | func rasterPNGData(_ boardId: UUID) -> Data? { |
| 84 | guard let img = rasterImage(boardId), |
| 85 | let tiff = img.tiffRepresentation, |
| 86 | let rep = NSBitmapImageRep(data: tiff) else { return nil } |
| 87 | return rep.representation(using: .png, properties: [:]) |
| 88 | } |
| 89 | |
| 90 | /// Install a drawing layer from clipboard PNG bytes. |
| 91 | func setRaster(fromPNG data: Data, boardId: UUID) { |
| 92 | guard let img = NSImage(data: data) else { return } |
| 93 | saveRaster(img, boardId: boardId) |
| 94 | } |
| 95 | |
| 96 | /// PNG data for each panel's drawing, keyed `NN.png` by storyboard order — |
| 97 | /// the contents of the document package's `Storyboard/` directory. Panels |
| 98 | /// with no drawing are omitted. |
| 99 | func rasterPNGs(of model: ProjectModel) -> [String: Data] { |
| 100 | var out: [String: Data] = [:] |
| 101 | for (i, panel) in model.orderedStoryboardPanels().enumerated() { |
| 102 | guard let board = panel.board, |
| 103 | let img = rasterImage(board.id), |
| 104 | let tiff = img.tiffRepresentation, |
| 105 | let rep = NSBitmapImageRep(data: tiff), |
| 106 | let png = rep.representation(using: .png, properties: [:]) else { continue } |
| 107 | out[String(format: "%02d.png", i + 1)] = png |
| 108 | } |
| 109 | return out |
| 110 | } |
| 111 | |
| 112 | /// Load panel drawings from a `Storyboard/` directory (inside the package, |
| 113 | /// or the legacy sibling folder during migration) into the raster cache, |
| 114 | /// mapping `NN.png` back to the board at that storyboard position. |
| 115 | func loadRasters(fromDirectory dir: URL, project: ProjectModel) { |
| 116 | let panels = project.orderedStoryboardPanels() |
| 117 | for (i, panel) in panels.enumerated() { |
| 118 | guard let board = panel.board else { continue } |
| 119 | let url = dir.appendingPathComponent(String(format: "%02d.png", i + 1)) |
| 120 | if let img = NSImage(contentsOf: url) { |
| 121 | rasterCache[board.id] = img |
| 122 | rasterVersions[board.id, default: 0] += 1 |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func invalidate(_ boardId: UUID) { |
| 128 | rasterVersions[boardId, default: 0] += 1 |
| 129 | rasterCache.removeValue(forKey: boardId) |
| 130 | } |
| 131 | |
| 132 | // MARK: - Stroke engine (shared by the editor canvas and viewer cells) |
| 133 | |
| 134 | // Board coordinates everywhere: top-left origin, y down. The engine owns |
| 135 | // the y-flip into image space so callers never think about it. |
| 136 | private var workingRasters: [UUID: NSImage] = [:] |
| 137 | /// Pre-stroke drawing captured at `beginStroke`, used by `endStroke` to push |
| 138 | /// one undo entry onto the shared `Store` timeline. |
| 139 | private var pendingStrokeBefore: [UUID: NSImage?] = [:] |
| 140 | |
| 141 | /// Raster to DISPLAY: the in-progress stroke image when one is active. |
| 142 | func displayRaster(_ boardId: UUID) -> NSImage? { |
| 143 | workingRasters[boardId] ?? rasterImage(boardId) |
| 144 | } |
| 145 | |
| 146 | private func blankRaster(size: CGSize) -> NSImage { |
| 147 | let img = NSImage(size: size) |
| 148 | img.lockFocus() |
| 149 | NSColor.clear.setFill() |
| 150 | NSRect(origin: .zero, size: size).fill() |
| 151 | img.unlockFocus() |
| 152 | return img |
| 153 | } |
| 154 | |
| 155 | func beginStroke(board: Board) { |
| 156 | // Remember the pre-stroke drawing so the commit can register one undo |
| 157 | // step on the shared timeline. |
| 158 | pendingStrokeBefore[board.id] = rasterImage(board.id)?.copy() as? NSImage |
| 159 | workingRasters[board.id] = (rasterImage(board.id)?.copy() as? NSImage) |
| 160 | ?? blankRaster(size: board.size) |
| 161 | } |
| 162 | |
| 163 | /// Add a segment in board coords. `pressure` scales the width (tablets). |
| 164 | func strokeSegment(board: Board, from a: CGPoint, to b: CGPoint, |
| 165 | width: CGFloat, color: NSColor, erase: Bool, |
| 166 | alpha: CGFloat = 1, pressure: CGFloat = 0) { |
| 167 | guard let img = workingRasters[board.id] else { return } |
| 168 | let h = board.size.height |
| 169 | // Image focus is bottom-left; board coords are top-left. |
| 170 | let a2 = CGPoint(x: a.x, y: h - a.y) |
| 171 | let b2 = CGPoint(x: b.x, y: h - b.y) |
| 172 | img.lockFocus() |
| 173 | if let ctx = NSGraphicsContext.current { |
| 174 | ctx.compositingOperation = erase ? .destinationOut : .sourceOver |
| 175 | } |
| 176 | let path = NSBezierPath() |
| 177 | path.move(to: a2) |
| 178 | // Zero-length segments (clicks) still leave a dot. |
| 179 | path.line(to: a2 == b2 ? CGPoint(x: b2.x + 0.3, y: b2.y) : b2) |
| 180 | var w = width |
| 181 | if pressure > 0.01, pressure < 0.999 { w = width * (0.35 + 1.3 * pressure) } |
| 182 | path.lineWidth = w |
| 183 | path.lineCapStyle = .round |
| 184 | path.lineJoinStyle = .round |
| 185 | (erase ? NSColor.black : color).withAlphaComponent(alpha).setStroke() |
| 186 | path.stroke() |
| 187 | img.unlockFocus() |
| 188 | workingRasters[board.id] = img |
| 189 | } |
| 190 | |
| 191 | func endStroke(board: Board) { |
| 192 | guard let img = workingRasters.removeValue(forKey: board.id) else { return } |
| 193 | saveRaster(img, boardId: board.id) |
| 194 | // Register the completed stroke on the shared undo timeline. |
| 195 | let before = pendingStrokeBefore.removeValue(forKey: board.id) ?? nil |
| 196 | ctx.store.recordRasterEdit(boardId: board.id, before: before) |
| 197 | NotificationCenter.default.post(name: .mediaStatusChanged, object: nil, userInfo: ["scene": true]) |
| 198 | } |
| 199 | |
| 200 | /// A copy of the current drawing for the undo timeline (nil = blank board). |
| 201 | func rasterSnapshot(_ boardId: UUID) -> NSImage? { |
| 202 | rasterImage(boardId)?.copy() as? NSImage |
| 203 | } |
| 204 | |
| 205 | /// Restore a drawing captured by `rasterSnapshot` (undo/redo of a stroke), |
| 206 | /// dropping any in-progress stroke and refreshing the editor + viewer. |
| 207 | func applyRaster(_ image: NSImage?, boardId: UUID) { |
| 208 | workingRasters.removeValue(forKey: boardId) |
| 209 | saveRaster(image, boardId: boardId) |
| 210 | NotificationCenter.default.post(name: .projectChanged, object: nil) |
| 211 | } |
| 212 | |
| 213 | /// Wipe the drawing layer (undoable as one stroke). |
| 214 | func clearRaster(board: Board) { |
| 215 | beginStroke(board: board) |
| 216 | workingRasters[board.id] = blankRaster(size: board.size) |
| 217 | endStroke(board: board) |
| 218 | } |
| 219 | |
| 220 | // MARK: - Rendering |
| 221 | |
| 222 | /// Flattened panel: white paper, below-raster shapes, raster, above-raster |
| 223 | /// shapes. Cached by board id + revision. |
| 224 | func composite(for board: Board) -> NSImage { |
| 225 | let rv = rasterVersions[board.id] ?? 0 |
| 226 | let key = "\(board.id.uuidString)/\(board.revision)/\(rv)" as NSString |
| 227 | // Mid-stroke: render fresh for live feedback, don't poison the cache. |
| 228 | let strokeInProgress = workingRasters[board.id] != nil |
| 229 | if !strokeInProgress, let img = compositeCache.object(forKey: key) { return img } |
| 230 | let img = NSImage(size: board.size, flipped: true) { rect in |
| 231 | Self.paper.setFill() |
| 232 | rect.fill() |
| 233 | for shape in board.shapes where !shape.aboveRaster { |
| 234 | Self.draw(shape) |
| 235 | } |
| 236 | if let raster = self.displayRaster(board.id) { |
| 237 | raster.draw(in: rect, from: .zero, operation: .sourceOver, |
| 238 | fraction: 1, respectFlipped: true, hints: nil) |
| 239 | } |
| 240 | for shape in board.shapes where shape.aboveRaster { |
| 241 | Self.draw(shape) |
| 242 | } |
| 243 | return true |
| 244 | } |
| 245 | if !strokeInProgress { compositeCache.setObject(img, forKey: key) } |
| 246 | return img |
| 247 | } |
| 248 | |
| 249 | static func rgba(_ color: NSColor) -> [Double] { |
| 250 | let c = color.usingColorSpace(.sRGB) ?? color |
| 251 | return [Double(c.redComponent), Double(c.greenComponent), |
| 252 | Double(c.blueComponent), Double(c.alphaComponent)] |
| 253 | } |
| 254 | |
| 255 | static func color(_ rgba: [Double]) -> NSColor { |
| 256 | NSColor(calibratedRed: rgba.count > 0 ? rgba[0] : 0, |
| 257 | green: rgba.count > 1 ? rgba[1] : 0, |
| 258 | blue: rgba.count > 2 ? rgba[2] : 0, |
| 259 | alpha: rgba.count > 3 ? rgba[3] : 1) |
| 260 | } |
| 261 | |
| 262 | static func path(for shape: BoardShape) -> NSBezierPath { |
| 263 | let r = shape.frame |
| 264 | switch shape.kind { |
| 265 | case .rect, .text, .image: |
| 266 | return NSBezierPath(rect: r) |
| 267 | case .oval: |
| 268 | return NSBezierPath(ovalIn: r) |
| 269 | case .triangle: |
| 270 | let p = NSBezierPath() |
| 271 | p.move(to: NSPoint(x: r.midX, y: r.minY)) |
| 272 | p.line(to: NSPoint(x: r.maxX, y: r.maxY)) |
| 273 | p.line(to: NSPoint(x: r.minX, y: r.maxY)) |
| 274 | p.close() |
| 275 | return p |
| 276 | case .star: |
| 277 | return starPath(in: r, points: max(3, shape.sides), innerRatio: 0.45) |
| 278 | case .ngon: |
| 279 | return polygonPath(in: r, sides: max(3, shape.sides)) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | static func polygonPath(in rect: CGRect, sides: Int) -> NSBezierPath { |
| 284 | let p = NSBezierPath() |
| 285 | let c = NSPoint(x: rect.midX, y: rect.midY) |
| 286 | let rx = rect.width / 2, ry = rect.height / 2 |
| 287 | for i in 0..<sides { |
| 288 | let a = -Double.pi / 2 + Double(i) * 2 * .pi / Double(sides) |
| 289 | let pt = NSPoint(x: c.x + rx * CGFloat(cos(a)), y: c.y + ry * CGFloat(sin(a))) |
| 290 | i == 0 ? p.move(to: pt) : p.line(to: pt) |
| 291 | } |
| 292 | p.close() |
| 293 | return p |
| 294 | } |
| 295 | |
| 296 | static func starPath(in rect: CGRect, points: Int, innerRatio: Double) -> NSBezierPath { |
| 297 | let p = NSBezierPath() |
| 298 | let c = NSPoint(x: rect.midX, y: rect.midY) |
| 299 | let rx = rect.width / 2, ry = rect.height / 2 |
| 300 | for i in 0..<(points * 2) { |
| 301 | let a = -Double.pi / 2 + Double(i) * .pi / Double(points) |
| 302 | let f = i.isMultiple(of: 2) ? 1.0 : innerRatio |
| 303 | let pt = NSPoint(x: c.x + rx * CGFloat(f * cos(a)), |
| 304 | y: c.y + ry * CGFloat(f * sin(a))) |
| 305 | i == 0 ? p.move(to: pt) : p.line(to: pt) |
| 306 | } |
| 307 | p.close() |
| 308 | return p |
| 309 | } |
| 310 | |
| 311 | static func boardFont(size: Double) -> NSFont { |
| 312 | NSFont(name: "AT Name Sans Standard", size: size) |
| 313 | ?? NSFont(name: "ATNameSansStandard-Regular", size: size) |
| 314 | ?? .systemFont(ofSize: size) |
| 315 | } |
| 316 | |
| 317 | static func draw(_ shape: BoardShape) { |
| 318 | let color = Self.color(shape.color) |
| 319 | switch shape.kind { |
| 320 | case .text: |
| 321 | let attrs: [NSAttributedString.Key: Any] = [ |
| 322 | .font: boardFont(size: shape.fontSize), |
| 323 | .foregroundColor: color, |
| 324 | ] |
| 325 | (shape.text.isEmpty ? "Text" : shape.text) |
| 326 | .draw(in: shape.frame, withAttributes: attrs) |
| 327 | case .image: |
| 328 | if let path = shape.imagePath, let img = NSImage(contentsOfFile: path) { |
| 329 | // Aspect-fit inside the frame. |
| 330 | let s = img.size |
| 331 | guard s.width > 0, s.height > 0 else { return } |
| 332 | let scale = min(shape.frame.width / s.width, shape.frame.height / s.height) |
| 333 | let w = s.width * scale, h = s.height * scale |
| 334 | let r = NSRect(x: shape.frame.midX - w / 2, y: shape.frame.midY - h / 2, |
| 335 | width: w, height: h) |
| 336 | // respectFlipped: image refs must not mirror in flipped contexts. |
| 337 | img.draw(in: r, from: .zero, operation: .sourceOver, |
| 338 | fraction: 1, respectFlipped: true, hints: nil) |
| 339 | } else { |
| 340 | color.withAlphaComponent(0.25).setFill() |
| 341 | NSBezierPath(rect: shape.frame).fill() |
| 342 | NSImage(systemSymbolName: "photo", accessibilityDescription: nil)? |
| 343 | .draw(in: shape.frame.insetBy(dx: shape.frame.width * 0.3, |
| 344 | dy: shape.frame.height * 0.3)) |
| 345 | } |
| 346 | default: |
| 347 | let path = Self.path(for: shape) |
| 348 | if shape.filled { |
| 349 | color.setFill() |
| 350 | path.fill() |
| 351 | } else { |
| 352 | color.setStroke() |
| 353 | path.lineWidth = 4 |
| 354 | path.stroke() |
| 355 | } |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | // MARK: - Board lifecycle helpers |
| 360 | |
| 361 | /// Deep-copy a board (fresh id, copied raster) — used by S on a |
| 362 | /// storyboard clip, which splits by duplicating the panel. |
| 363 | func duplicate(_ board: Board) -> Board { |
| 364 | var copy = board |
| 365 | copy.id = UUID() |
| 366 | copy.revision = 0 |
| 367 | duplicateRaster(from: board.id, to: copy.id) |
| 368 | return copy |
| 369 | } |
| 370 | |
| 371 | // MARK: - Panel copy/paste |
| 372 | |
| 373 | static let pasteboardType = NSPasteboard.PasteboardType("com.sequencer.storyboard-panel") |
| 374 | |
| 375 | private struct PanelTransfer: Codable { |
| 376 | var board: Board |
| 377 | var rasterPNG: Data? |
| 378 | } |
| 379 | |
| 380 | /// Copies both a flattened PNG (for other apps) and full panel metadata |
| 381 | /// (shapes + raster) for pasting into another panel. |
| 382 | func copyPanel(_ board: Board) { |
| 383 | let pb = NSPasteboard.general |
| 384 | pb.clearContents() |
| 385 | var transfer = PanelTransfer(board: board) |
| 386 | transfer.rasterPNG = rasterPNGData(board.id) |
| 387 | if let data = try? JSONEncoder().encode(transfer) { |
| 388 | pb.setData(data, forType: Self.pasteboardType) |
| 389 | } |
| 390 | let composite = composite(for: board) |
| 391 | if let tiff = composite.tiffRepresentation, |
| 392 | let rep = NSBitmapImageRep(data: tiff), |
| 393 | let png = rep.representation(using: .png, properties: [:]) { |
| 394 | pb.setData(png, forType: .png) |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | /// Returns a new board built from the pasteboard: full panel metadata |
| 399 | /// when present, else any image becomes the raster layer. |
| 400 | func panelFromPasteboard(size fallbackSize: CGSize) -> Board? { |
| 401 | let pb = NSPasteboard.general |
| 402 | if let data = pb.data(forType: Self.pasteboardType), |
| 403 | let transfer = try? JSONDecoder().decode(PanelTransfer.self, from: data) { |
| 404 | var board = transfer.board |
| 405 | board.id = UUID() |
| 406 | board.revision = 0 |
| 407 | if let png = transfer.rasterPNG { |
| 408 | setRaster(fromPNG: png, boardId: board.id) |
| 409 | } |
| 410 | return board |
| 411 | } |
| 412 | if let img = NSImage(pasteboard: pb) { |
| 413 | var board = Board() |
| 414 | board.width = Double(fallbackSize.width) |
| 415 | board.height = Double(fallbackSize.height) |
| 416 | saveRaster(scaled(img, to: board.size), boardId: board.id) |
| 417 | return board |
| 418 | } |
| 419 | return nil |
| 420 | } |
| 421 | |
| 422 | private func scaled(_ img: NSImage, to size: CGSize) -> NSImage { |
| 423 | NSImage(size: size, flipped: false) { rect in |
| 424 | let s = img.size |
| 425 | guard s.width > 0, s.height > 0 else { return true } |
| 426 | let scale = min(rect.width / s.width, rect.height / s.height) |
| 427 | let w = s.width * scale, h = s.height * scale |
| 428 | img.draw(in: NSRect(x: rect.midX - w / 2, y: rect.midY - h / 2, |
| 429 | width: w, height: h)) |
| 430 | return true |
| 431 | } |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | /// AT Name Sans lives on the NAS; copy the needed weights into Application |
| 436 | /// Support once so text keeps rendering with the share unmounted, then |
| 437 | /// register from there. |
| 438 | func registerBoardFonts() { |
| 439 | let fm = FileManager.default |
| 440 | let fontsDir = FileManager.default.urls(for: .applicationSupportDirectory, |
| 441 | in: .userDomainMask)[0] |
| 442 | .appendingPathComponent("Sequencer/Fonts", isDirectory: true) |
| 443 | try? fm.createDirectory(at: fontsDir, withIntermediateDirectories: true) |
| 444 | let source = URL(fileURLWithPath: |
| 445 | "/Volumes/clover/Documents/Font/ArrowType/AT Name Sans Standard") |
| 446 | for weight in ["Regular", "Medium", "Bold"] { |
| 447 | let name = "ATNameSansStandard-\(weight).otf" |
| 448 | let local = fontsDir.appendingPathComponent(name) |
| 449 | if !fm.fileExists(atPath: local.path) { |
| 450 | try? fm.copyItem(at: source.appendingPathComponent(name), to: local) |
| 451 | } |
| 452 | if fm.fileExists(atPath: local.path) { |
| 453 | CTFontManagerRegisterFontsForURL(local as CFURL, .process, nil) |
| 454 | } |
| 455 | } |
| 456 | } |