├── .gitignore ├── Wrapper ├── Inkling │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── dark.png │ │ │ ├── light.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ └── Inkling.swift └── Inkling.xcodeproj │ ├── project.xcworkspace │ └── contents.xcworkspacedata │ ├── xcshareddata │ └── xcschemes │ │ └── Inkling.xcscheme │ └── project.pbxproj ├── App ├── package.json ├── .prettierrc ├── src │ ├── app │ │ ├── Config.ts │ │ ├── gestures │ │ │ ├── effects │ │ │ │ ├── CreateLead.ts │ │ │ │ ├── CreateLinear.ts │ │ │ │ └── CreateGizmo.ts │ │ │ ├── PenToggle.ts │ │ │ ├── Stroke.ts │ │ │ ├── StrokeGroup.ts │ │ │ ├── Gizmo.ts │ │ │ ├── EmptySpace.ts │ │ │ ├── MetaToggle.ts │ │ │ ├── Erase.ts │ │ │ ├── Preset.ts │ │ │ ├── Token.ts │ │ │ ├── Handle.ts │ │ │ └── Pluggable.ts │ │ ├── meta │ │ │ ├── Pluggable.ts │ │ │ ├── Token.ts │ │ │ ├── Wire.ts │ │ │ ├── PropertyPicker.ts │ │ │ ├── NumberToken.ts │ │ │ ├── LinearToken.ts │ │ │ └── Gizmo.ts │ │ ├── App.ts │ │ ├── Store.ts │ │ ├── ink │ │ │ ├── Stroke.ts │ │ │ ├── Lead.ts │ │ │ ├── StrokeGroup.ts │ │ │ └── Handle.ts │ │ ├── VarMover.ts │ │ ├── Perf.ts │ │ ├── Deserialize.ts │ │ ├── gui │ │ │ ├── PenToggle.ts │ │ │ └── MetaToggle.ts │ │ ├── GameObject.ts │ │ ├── Gesture.ts │ │ ├── Input.ts │ │ ├── Svg.ts │ │ └── NativeEvents.ts │ └── lib │ │ ├── types.ts │ │ ├── polygon.ts │ │ ├── bounding_box.ts │ │ ├── Averager.ts │ │ ├── SignedDistance.ts │ │ ├── math.ts │ │ ├── arc.ts │ │ ├── line.ts │ │ ├── TransformationMatrix.ts │ │ ├── fit.ts │ │ ├── vec.ts │ │ ├── helpers.ts │ │ └── g9.ts ├── vite.config.js ├── tsconfig.json ├── index.html ├── TODO.md └── style.css └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | xcuserdata 4 | package-lock.json 5 | dist -------------------------------------------------------------------------------- /Wrapper/Inkling/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /App/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "devDependencies": { 4 | "typescript": "^5.6.3", 5 | "vite": "^5.4.8" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /App/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": false, 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /App/src/app/Config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | renderDebugPerf: false, 3 | presentationMode: true, 4 | fingerOfGod: true, 5 | fallback: false 6 | } 7 | -------------------------------------------------------------------------------- /Wrapper/Inkling/Assets.xcassets/AppIcon.appiconset/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/inkling/HEAD/Wrapper/Inkling/Assets.xcassets/AppIcon.appiconset/dark.png -------------------------------------------------------------------------------- /Wrapper/Inkling/Assets.xcassets/AppIcon.appiconset/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/inkling/HEAD/Wrapper/Inkling/Assets.xcassets/AppIcon.appiconset/light.png -------------------------------------------------------------------------------- /Wrapper/Inkling.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /App/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | 3 | export default defineConfig({ 4 | server: { 5 | host: true 6 | }, 7 | build: { 8 | minify: false, 9 | cssMinify: false, 10 | target: "esnext" 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /App/src/app/gestures/effects/CreateLead.ts: -------------------------------------------------------------------------------- 1 | import { EventContext } from "../../Gesture" 2 | import Lead from "../../ink/Lead" 3 | 4 | export function createLead(ctx: EventContext): Lead { 5 | const lead = Lead.create(ctx.event.position) 6 | ctx.root.adopt(lead) 7 | return lead 8 | } 9 | -------------------------------------------------------------------------------- /App/src/app/gestures/effects/CreateLinear.ts: -------------------------------------------------------------------------------- 1 | import { EventContext } from "../../Gesture" 2 | import LinearToken from "../../meta/LinearToken" 3 | 4 | export function createLinear(ctx: EventContext): LinearToken { 5 | const linear = LinearToken.create() 6 | ctx.root.adopt(linear) 7 | linear.position = ctx.event.position 8 | return linear 9 | } 10 | -------------------------------------------------------------------------------- /App/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "isolatedModules": true, 5 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 6 | "module": "Preserve", 7 | "noEmit": true, 8 | "noImplicitAny": true, 9 | "strict": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "target": "ESNext" 12 | }, 13 | "include": ["src/**/*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /App/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface Position { 2 | x: number 3 | y: number 4 | } 5 | 6 | export interface PositionWithPressure extends Position { 7 | pressure: number 8 | } 9 | 10 | export function isPosition(value: any): value is Position { 11 | return value instanceof Object && typeof value.x === "number" && typeof value.y === "number" 12 | } 13 | 14 | export function isBoolean(value: any): value is boolean { 15 | return typeof value === "boolean" 16 | } 17 | -------------------------------------------------------------------------------- /Wrapper/Inkling/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.700", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /App/src/app/gestures/effects/CreateGizmo.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../../Gesture" 2 | import Handle from "../../ink/Handle" 3 | import Gizmo from "../../meta/Gizmo" 4 | import { touchHandleHelper } from "../Handle" 5 | 6 | export function createGizmo(ctx: EventContext): Gesture { 7 | const a = ctx.root.adopt(Handle.create({ ...ctx.event.position })) 8 | a.getAbsorbedByNearestHandle() 9 | const b = ctx.root.adopt(Handle.create({ ...ctx.event.position })) 10 | ctx.root.adopt(Gizmo.create(a, b)) 11 | return touchHandleHelper(b) 12 | } 13 | -------------------------------------------------------------------------------- /App/src/app/gestures/PenToggle.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../Gesture" 2 | import PenToggle, { aPenToggle } from "../gui/PenToggle" 3 | import { Root } from "../Root" 4 | 5 | export function penToggleFingerActions(ctx: EventContext): Gesture | void { 6 | const penToggle = ctx.root.find({ 7 | what: aPenToggle, 8 | near: ctx.event.position, 9 | recursive: false, 10 | tooFar: 50 11 | }) 12 | 13 | if (penToggle) { 14 | return new Gesture("Pen Toggle Finger Actions", { 15 | endedTap(ctx) { 16 | PenToggle.toggle() 17 | } 18 | }) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /App/src/app/gestures/Stroke.ts: -------------------------------------------------------------------------------- 1 | import { aStroke } from "../ink/Stroke" 2 | import { EventContext, Gesture } from "../Gesture" 3 | import MetaToggle from "../gui/MetaToggle" 4 | 5 | export function strokeAddHandles(ctx: EventContext): Gesture | void { 6 | if (MetaToggle.active) { 7 | const stroke = ctx.root.find({ 8 | what: aStroke, 9 | near: ctx.event.position, 10 | tooFar: 50 11 | }) 12 | 13 | if (stroke) { 14 | return new Gesture("Add Handles", { 15 | endedTap(ctx) { 16 | stroke.becomeGroup() 17 | } 18 | }) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /App/src/lib/polygon.ts: -------------------------------------------------------------------------------- 1 | import Line from "./line" 2 | import { Position } from "./types" 3 | import Vec from "./vec" 4 | 5 | export function closestPointOnPolygon(polygon: Array, pos: Position) { 6 | let closestPoint = polygon[0] 7 | let closestDistance = Infinity 8 | 9 | for (let idx = 0; idx < polygon.length - 1; idx++) { 10 | const p1 = polygon[idx] 11 | const p2 = polygon[idx + 1] 12 | const pt = Line.closestPoint(Line(p1, p2), pos) 13 | const distance = Vec.dist(pt, pos) 14 | if (distance < closestDistance) { 15 | closestPoint = pt 16 | closestDistance = distance 17 | } 18 | } 19 | 20 | return closestPoint 21 | } 22 | -------------------------------------------------------------------------------- /App/src/lib/bounding_box.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "./types" 2 | 3 | export function boundingBoxFromStrokes(strokes: Position[][]) { 4 | let minX = Infinity 5 | let maxX = -Infinity 6 | let minY = Infinity 7 | let maxY = -Infinity 8 | 9 | for (const stroke of strokes) { 10 | for (const pt of stroke) { 11 | if (pt.x < minX) { 12 | minX = pt.x 13 | } 14 | if (pt.x > maxX) { 15 | maxX = pt.x 16 | } 17 | 18 | if (pt.y < minY) { 19 | minY = pt.y 20 | } 21 | if (pt.y > maxY) { 22 | maxY = pt.y 23 | } 24 | } 25 | } 26 | 27 | return { 28 | minX, 29 | maxX, 30 | minY, 31 | maxY, 32 | width: maxX - minX, 33 | height: maxY - minY 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /App/src/app/gestures/StrokeGroup.ts: -------------------------------------------------------------------------------- 1 | import { aStrokeGroup } from "../ink/StrokeGroup" 2 | import { EventContext, Gesture } from "../Gesture" 3 | import MetaToggle from "../gui/MetaToggle" 4 | 5 | export function strokeGroupRemoveHandles(ctx: EventContext): Gesture | void { 6 | if (MetaToggle.active) { 7 | const strokeGroup = ctx.root.find({ 8 | what: aStrokeGroup, 9 | near: ctx.event.position, 10 | tooFar: 20 11 | }) 12 | 13 | if (strokeGroup) { 14 | return new Gesture("Remove Handles", { 15 | endedTap(ctx) { 16 | if ( 17 | strokeGroup.a.canonicalInstance.absorbedHandles.size === 0 && 18 | strokeGroup.b.canonicalInstance.absorbedHandles.size === 0 19 | ) { 20 | strokeGroup.breakApart() 21 | } 22 | } 23 | }) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Wrapper/Inkling/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "light.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "dark.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "dark.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /App/src/app/meta/Pluggable.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "../../lib/types" 2 | import { Variable } from "../Constraints" 3 | import Gizmo from "./Gizmo" 4 | import NumberToken from "./NumberToken" 5 | import PropertyPicker from "./PropertyPicker" 6 | 7 | export interface Pluggable { 8 | readonly id: number 9 | readonly plugVars: { value: Variable } | { distance: Variable; angleInDegrees: Variable } 10 | getPlugPosition(id: PlugId): Position 11 | } 12 | 13 | export type PlugId = "center" | "input" | "output" 14 | export type VariableId = "value" | "distance" | "angleInDegrees" 15 | 16 | export type Connection = 17 | | { 18 | obj: NumberToken 19 | plugId: "center" 20 | variableId: "value" 21 | } 22 | | { 23 | obj: PropertyPicker 24 | plugId: "input" | "output" 25 | variableId: "value" 26 | } 27 | | { 28 | obj: Gizmo 29 | plugId: "center" 30 | variableId: "distance" | "angleInDegrees" 31 | } 32 | -------------------------------------------------------------------------------- /App/src/app/App.ts: -------------------------------------------------------------------------------- 1 | import { forDebugging, onEveryFrame } from "../lib/helpers" 2 | import * as constraints from "./Constraints" 3 | import * as Input from "./Input" 4 | import Events, { Event, InputState } from "./NativeEvents" 5 | import { endPerf, startPerf } from "./Perf" 6 | import { Root } from "./Root" 7 | import SVG from "./Svg" 8 | import VarMover from "./VarMover" 9 | 10 | Root.reset() 11 | forDebugging("root", Root.current) 12 | 13 | // This is a pretzel, because the interface between NativeEvents and Input is a work in progress. 14 | const events = new Events((event: Event, state: InputState) => { 15 | Input.applyEvent({ event, state, events, root: Root.current, pseudo: false, pseudoCount: 0, pseudoTouches: {} }); // prettier-ignore 16 | }) 17 | 18 | onEveryFrame((dt, t) => { 19 | startPerf() 20 | SVG.clearNow(t) 21 | events.update() 22 | VarMover.update(dt, t) 23 | constraints.solve(Root.current) 24 | Root.current.render(dt, t) 25 | Input.render() 26 | endPerf() 27 | }) 28 | -------------------------------------------------------------------------------- /App/src/app/gestures/Gizmo.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../Gesture" 2 | import MetaToggle from "../gui/MetaToggle" 3 | import { aGizmo } from "../meta/Gizmo" 4 | 5 | const tapDist = 50 6 | 7 | export function gizmoCycleConstraints(ctx: EventContext): Gesture | void { 8 | if (MetaToggle.active) { 9 | // TODO: We only want to perform this gesture on a tap near the center of the gizmo. 10 | // But for other gestures, we want to perform them when any part of the gizmo is touched. 11 | // The current GameObject.find() method doesn't seemingly allow for this sort of distinction, 12 | // where different find() calls need a different distanceToPoint() implementation. 13 | const gizmo = ctx.root.find({ 14 | what: aGizmo, 15 | that: (g) => g.centerDistanceToPoint(ctx.event.position) < tapDist 16 | }) 17 | 18 | if (gizmo) { 19 | return new Gesture("Cycle Constraints", { 20 | endedTap(ctx) { 21 | gizmo.cycleConstraints() 22 | } 23 | }) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /App/src/app/Store.ts: -------------------------------------------------------------------------------- 1 | export type Storable = null | boolean | number | string | Storable[] | { [key: string]: Storable } 2 | 3 | type InitArgs = { 4 | name: string 5 | isValid: (value: Storable) => value is T 6 | def: T 7 | } 8 | 9 | const initializedNames = new Set() 10 | 11 | export default { 12 | init({ name, isValid, def }: InitArgs) { 13 | // This check is meant to avoid accidental redundant calls. 14 | // If you find a case where doing redundant calls makes sense, 15 | // feel free to remove this check, and replace it with a check 16 | // to ensure the redundant calls have the same isValid type. 17 | if (initializedNames.has(name)) { 18 | throw new Error(`Store.init() was called more than once for name: ${name}`) 19 | } 20 | initializedNames.add(name) 21 | 22 | const result = this.get(name) 23 | return isValid(result) ? result : def 24 | }, 25 | 26 | set(key: string, val: T) { 27 | localStorage.setItem(key, JSON.stringify(val)) 28 | return val 29 | }, 30 | 31 | get(key: string): any { 32 | return JSON.parse(localStorage.getItem(key) || "null") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /App/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /App/src/app/meta/Token.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from "../GameObject" 2 | import { Position } from "../../lib/types" 3 | 4 | import { signedDistanceToBox } from "../../lib/SignedDistance" 5 | import Vec from "../../lib/vec" 6 | import { Root } from "../Root" 7 | 8 | export default abstract class Token extends GameObject { 9 | static withId(id: number) { 10 | const token = Root.current.find({ what: aToken, that: (t) => t.id === id }) 11 | if (token == null) { 12 | throw new Error("coudln't find token w/ id " + id) 13 | } 14 | return token 15 | } 16 | 17 | position: Position = { x: 100, y: 100 } 18 | width = 90 19 | height = 30 20 | 21 | constructor(readonly id: number) { 22 | super() 23 | } 24 | 25 | onTap() { 26 | // Override as needed. 27 | // We want all tokens to have this, even if it's a noop, to simplify gesture code. 28 | // We may eventually want to consider moving this method into GameObject. 29 | } 30 | 31 | distanceToPoint(pos: Position) { 32 | return signedDistanceToBox(this.position.x, this.position.y, this.width, this.height, pos.x, pos.y) 33 | } 34 | 35 | midPoint() { 36 | return Vec.add(this.position, Vec.half(Vec(this.width, this.height))) 37 | } 38 | 39 | render(dt: number, t: number): void { 40 | // NO-OP 41 | } 42 | } 43 | 44 | export const aToken = (gameObj: GameObject) => (gameObj instanceof Token ? gameObj : null) 45 | -------------------------------------------------------------------------------- /App/src/lib/Averager.ts: -------------------------------------------------------------------------------- 1 | // Averager 2 | // Makes it easy to get the average of a series of numbers. 3 | // Useful for, eg, smoothing positional input over successive frames. 4 | 5 | export default class Averager { 6 | public result = 0 7 | private tally = 0 8 | 9 | // limit (required) — the number of values to retain and average 10 | // values (optional) — a seed array of values to start with 11 | constructor(public limit: number, public values: number[] = []) { 12 | if (limit <= 1) throw new Error("You shouldn't use Averager with such a small limit") 13 | this.reset(values) 14 | } 15 | 16 | public reset(values: number[] = []) { 17 | if (values.length > this.limit) throw new Error("Too many values passed to Averager") 18 | this.values = values 19 | this.tally = this.values.reduce((a: number, b: number) => a + b, 0) 20 | this.result = this.tally / Math.max(1, this.values.length) 21 | } 22 | 23 | public add(value: number) { 24 | if ("number" !== typeof value) throw new Error("Averager only accepts numbers") 25 | this.tally += value 26 | this.values.push(value) 27 | while (this.values.length > this.limit) this.tally -= this.values.shift() ?? 0 28 | this.result = this.tally / this.values.length 29 | return this.result 30 | } 31 | 32 | public reduce(fn: (a: number, b: number) => number, initial = 0) { 33 | return this.values.reduce(fn, initial) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /App/src/lib/SignedDistance.ts: -------------------------------------------------------------------------------- 1 | export function signedDistanceToBox( 2 | xA: number, 3 | yA: number, 4 | widthA: number, 5 | heightA: number, 6 | xB: number, 7 | yB: number 8 | ): number { 9 | // Calculate the half dimensions of the box A 10 | const halfWidthA = widthA / 2 11 | const halfHeightA = heightA / 2 12 | 13 | // Calculate the center of the box A 14 | const centerXA = xA + halfWidthA 15 | const centerYA = yA + halfHeightA 16 | 17 | // Calculate the difference vector between the centers of A and B 18 | const dx = xB - centerXA 19 | const dy = yB - centerYA 20 | 21 | // Calculate the absolute difference between the centers 22 | const absDx = Math.abs(dx) 23 | const absDy = Math.abs(dy) 24 | 25 | // Calculate the distances along the x and y axes 26 | const distX = Math.max(absDx - halfWidthA, 0) 27 | const distY = Math.max(absDy - halfHeightA, 0) 28 | 29 | // Calculate the signed distance 30 | const signedDistance = Math.sqrt(distX * distX + distY * distY) 31 | 32 | // Determine the sign of the distance based on the relative position of B to A 33 | if (dx < -halfWidthA) { 34 | return signedDistance // B is to the left of A 35 | } else if (dx > halfWidthA) { 36 | return signedDistance // B is to the right of A 37 | } else if (dy < -halfHeightA) { 38 | return signedDistance // B is above A 39 | } else if (dy > halfHeightA) { 40 | return signedDistance // B is below A 41 | } else { 42 | return -signedDistance // B is inside A 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /App/src/app/gestures/EmptySpace.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../Gesture" 2 | import Stroke from "../ink/Stroke" 3 | import { createLinear } from "./effects/CreateLinear" 4 | import { createGizmo } from "./effects/CreateGizmo" 5 | import { createLead } from "./effects/CreateLead" 6 | import MetaToggle from "../gui/MetaToggle" 7 | 8 | export function metaDrawInk(ctx: EventContext): Gesture | void { 9 | if (MetaToggle.active && ctx.pseudoCount === 1) { 10 | const stroke = ctx.root.adopt(new Stroke()) 11 | return new Gesture("Draw Ink", { 12 | moved(ctx) { 13 | stroke.points.push(ctx.event.position) 14 | } 15 | }) 16 | } 17 | } 18 | 19 | export function emptySpaceDrawInk(ctx: EventContext): Gesture | void { 20 | if (!MetaToggle.active) { 21 | const stroke = ctx.root.adopt(new Stroke()) 22 | return new Gesture("Draw Ink", { 23 | moved(ctx) { 24 | stroke.points.push(ctx.event.position) 25 | } 26 | }) 27 | } 28 | } 29 | 30 | export function emptySpaceCreateGizmoOrLinear(ctx: EventContext): Gesture | void { 31 | if (MetaToggle.active) { 32 | return new Gesture("Create Gizmo or Linear", { 33 | dragged(ctx) { 34 | return createGizmo(ctx) 35 | }, 36 | ended(ctx) { 37 | createLinear(ctx) 38 | } 39 | }) 40 | } 41 | } 42 | 43 | export function emptySpaceEatLead(ctx: EventContext) { 44 | if (ctx.pseudoCount >= 3) { 45 | return new Gesture("Eat Lead", { 46 | endedTap(ctx) { 47 | createLead(ctx) 48 | } 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /App/src/app/gestures/MetaToggle.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../Gesture" 2 | import MetaToggle, { aMetaToggle } from "../gui/MetaToggle" 3 | import { Root } from "../Root" 4 | 5 | declare global { 6 | function cycleTheme(): void 7 | } 8 | 9 | export function metaToggleFingerActions(ctx: EventContext): Gesture | void { 10 | const metaToggle = ctx.root.find({ 11 | what: aMetaToggle, 12 | near: ctx.event.position, 13 | recursive: false, 14 | tooFar: 50 15 | }) 16 | 17 | const dragThreshold = 100 18 | 19 | if (metaToggle) { 20 | return new Gesture("Meta Toggle Finger Actions", { 21 | moved(ctx) { 22 | if (ctx.state.dragDist > dragThreshold) { 23 | metaToggle.dragTo(ctx.event.position) 24 | } 25 | }, 26 | ended(ctx) { 27 | if (ctx.state.dragDist <= dragThreshold) { 28 | if (ctx.pseudo) { 29 | cycleTheme() 30 | } else { 31 | MetaToggle.toggle() 32 | } 33 | } else { 34 | metaToggle.snapToCorner() 35 | } 36 | } 37 | }) 38 | } 39 | } 40 | 41 | export function metaToggleIgnorePencil(ctx: EventContext): Gesture | void { 42 | if ( 43 | ctx.root.find({ 44 | what: aMetaToggle, 45 | near: ctx.event.position, 46 | recursive: false, 47 | tooFar: 35 48 | }) 49 | ) { 50 | // This gesture exists just to block other gestures from running when a pencil touch begins on the Meta Toggle 51 | Root.reset() 52 | return new Gesture("Ignore Pencil", {}) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /App/src/app/ink/Stroke.ts: -------------------------------------------------------------------------------- 1 | import SVG from "../Svg" 2 | import { Position } from "../../lib/types" 3 | import { GameObject } from "../GameObject" 4 | import { distanceToPath } from "../../lib/helpers" 5 | import StrokeGroup from "./StrokeGroup" 6 | 7 | export type SerializedStroke = { 8 | type: "Stroke" 9 | points: Position[] 10 | } 11 | 12 | export default class Stroke extends GameObject { 13 | protected element = SVG.add("polyline", SVG.inkElm, { class: "stroke" }) 14 | 15 | constructor(public points: Position[] = []) { 16 | super() 17 | } 18 | 19 | static deserialize(v: SerializedStroke): Stroke { 20 | return new Stroke(v.points) 21 | } 22 | 23 | serialize(): SerializedStroke { 24 | return { type: "Stroke", points: this.points } 25 | } 26 | 27 | updatePath(newPoints: Array) { 28 | this.points = newPoints 29 | } 30 | 31 | render() { 32 | SVG.update(this.element, { points: SVG.points(this.points) }) 33 | } 34 | 35 | becomeGroup() { 36 | if (!(this.parent instanceof StrokeGroup)) { 37 | // Heisenbug warning: putting "new StrokeGroup(...)" in a local variable 38 | // breaks this! WAT 39 | return this.parent?.adopt(new StrokeGroup(new Set([this]))) 40 | } 41 | return undefined 42 | } 43 | 44 | distanceToPoint(point: Position) { 45 | return distanceToPath(point, this.points) 46 | } 47 | 48 | remove() { 49 | this.element.remove() 50 | super.remove() 51 | } 52 | } 53 | 54 | export const aStroke = (gameObj: GameObject) => (gameObj instanceof Stroke ? gameObj : null) 55 | -------------------------------------------------------------------------------- /App/src/app/VarMover.ts: -------------------------------------------------------------------------------- 1 | import { Variable } from "./Constraints" 2 | 3 | interface Move { 4 | variable: Variable 5 | unlockWhenDone: boolean 6 | initialValue: number 7 | finalValue: number 8 | durationSeconds: number 9 | easeFn: (t: number) => number 10 | initialTime: number 11 | done: boolean 12 | } 13 | 14 | let moves: Move[] = [] 15 | 16 | function move( 17 | variable: Variable, 18 | finalValue: number, 19 | durationSeconds: number, 20 | easeFn: (t: number) => number = (t) => t 21 | ) { 22 | // cancel any moves that are already in progress and affect this variable 23 | moves = moves.filter((move) => move.variable.canonicalInstance !== variable.canonicalInstance) 24 | 25 | moves.push({ 26 | variable, 27 | unlockWhenDone: !variable.isLocked, 28 | initialValue: variable.value, 29 | finalValue, 30 | durationSeconds, 31 | easeFn, 32 | initialTime: 0, 33 | done: false 34 | }) 35 | } 36 | 37 | function update(dt: number, t: number) { 38 | for (const move of moves) { 39 | const { variable, unlockWhenDone, initialValue, finalValue, durationSeconds, easeFn, done } = move 40 | 41 | if (done) { 42 | if (unlockWhenDone) { 43 | variable.unlock() 44 | } 45 | moves.splice(moves.indexOf(move), 1) 46 | continue 47 | } 48 | 49 | if (move.initialTime === 0) { 50 | move.initialTime = t 51 | } 52 | const t0 = move.initialTime 53 | 54 | const pct = Math.min((t - t0) / durationSeconds, 1) 55 | variable.lock(initialValue + (finalValue - initialValue) * easeFn(pct)) 56 | 57 | if (pct === 1) { 58 | move.done = true 59 | } 60 | } 61 | } 62 | 63 | export default { 64 | move, 65 | update 66 | } 67 | -------------------------------------------------------------------------------- /App/src/app/gestures/Erase.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../Gesture" 2 | import { rand } from "../../lib/math" 3 | import SVG from "../Svg" 4 | import { Position } from "../../lib/types" 5 | import { GameObject } from "../GameObject" 6 | import Stroke from "../ink/Stroke" 7 | import StrokeGroup from "../ink/StrokeGroup" 8 | import MetaToggle from "../gui/MetaToggle" 9 | import Lead from "../ink/Lead" 10 | import { Root } from "../Root" 11 | 12 | export function erase(ctx: EventContext): Gesture | void { 13 | if (ctx.pseudoCount === 2) { 14 | return new Gesture("Erase", { 15 | moved(ctx) { 16 | spawn(ctx.event.position) 17 | 18 | const gos = ctx.root.findAll({ 19 | what: MetaToggle.active ? aMetaErasable : aConcreteErasable, 20 | near: ctx.event.position, 21 | tooFar: 10 22 | }) 23 | 24 | for (const go of gos) { 25 | if (!(go instanceof MetaToggle)) { 26 | go.remove() 27 | } 28 | } 29 | } 30 | }) 31 | } 32 | } 33 | 34 | function spawn(p: Position) { 35 | const elm = SVG.add("g", SVG.guiElm, { 36 | class: "eraser", 37 | transform: `${SVG.positionToTransform(p)} rotate(${rand(0, 360)}) ` 38 | }) 39 | SVG.add("line", elm, { y2: 6 }) 40 | elm.onanimationend = () => elm.remove() 41 | } 42 | 43 | const concreteErasables = [StrokeGroup, Stroke, Lead, MetaToggle] 44 | export const aConcreteErasable = (gameObj: GameObject) => 45 | concreteErasables.some((cls) => gameObj instanceof cls) ? gameObj : null 46 | 47 | const metaNonErasables = [StrokeGroup, Stroke] 48 | export const aMetaErasable = (gameObj: GameObject) => 49 | metaNonErasables.some((cls) => gameObj instanceof cls) ? null : gameObj 50 | -------------------------------------------------------------------------------- /App/src/app/Perf.ts: -------------------------------------------------------------------------------- 1 | import Averager from "../lib/Averager" 2 | import Config from "./Config" 3 | 4 | const elm: HTMLElement = document.createElement("div") 5 | elm.id = "perf" 6 | document.body.append(elm) 7 | 8 | let start = 0 9 | let frameTime = new Averager(10) 10 | const msToPct = (60 / 1000) * 100 11 | const window: number[] = [] 12 | const windowSize = 3 * 60 // collect n seconds worth of history for our percentile figures 13 | let peak = 0 14 | 15 | const numericSort = (a: number, b: number) => a - b 16 | 17 | export function startPerf() { 18 | if (Config.renderDebugPerf) { 19 | frameTime.add(performance.now() - start) 20 | start = performance.now() 21 | } 22 | } 23 | 24 | export function endPerf() { 25 | if (Config.renderDebugPerf) { 26 | const end = performance.now() 27 | 28 | // What percentage of our tick time did we spend between the call to start() and end()? 29 | const percentage = (end - start) * msToPct 30 | 31 | // Add this value to our history window 32 | window.unshift(percentage) 33 | 34 | // Remove the oldest value from our window 35 | window.splice(windowSize) 36 | 37 | // Keep track of the highest value we've ever seen (not windowed) 38 | if (percentage > peak) peak = percentage 39 | 40 | // Compute the 75th and 100th percentile values of the window 41 | const sorted = window.toSorted(numericSort) 42 | const p75 = sorted[Math.ceil(sorted.length * 0.75) - 1] 43 | const p100 = sorted[sorted.length - 1] 44 | 45 | // Get the outstanding writes 46 | // const writes = core.getOutstandingWrites() 47 | 48 | const fps = Math.round(1000 / frameTime.result) 49 | 50 | // Display the stats 51 | elm.innerText = `${p75 | 0}% • ${p100 | 0}% • ${peak | 0}% ◼ ${fps}fps` 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /App/src/app/gestures/Preset.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../Gesture" 2 | import Vec from "../../lib/vec" 3 | import Averager from "../../lib/Averager" 4 | import { Root } from "../Root" 5 | 6 | export function edgeSwipe(ctx: EventContext): Gesture | void { 7 | const leftEdge = ctx.event.position.x < 20 8 | const rightEdge = ctx.event.position.x > window.innerWidth - 20 9 | if (!(leftEdge || rightEdge)) return 10 | 11 | // Use this to make sure we only activate the pan when the user is actually pulling the page edge 12 | // toward the center of the screen, not (eg) swiping down from the top or out toward the edge. 13 | const desiredDir = leftEdge ? Vec(1, 0) : Vec(-1, 0) 14 | 15 | // We take the dot product of desiredDir and the actual dir, but it's noisy, so we smooth it 16 | const dirDotAverage = new Averager(10, Array(10).fill(0)) 17 | 18 | let done = false 19 | let lastPos = ctx.event.position 20 | 21 | return new Gesture("Checking for Edge Swipe", { 22 | moved(ctx) { 23 | if (done) return 24 | 25 | const pos = ctx.event.position 26 | // Wait until we actually move far enough for it to count as a drag. 27 | // Then, check if the drag is in the desired direction. 28 | // If so, actually begin a pan. Otherwise, keep checking. 29 | if (Vec.equal(pos, lastPos)) return 30 | const draggingInDir = Vec.normalize(Vec.sub(pos, lastPos)) 31 | lastPos = pos 32 | 33 | const dot = Vec.dot(desiredDir, draggingInDir) 34 | const smoothDot = dirDotAverage.add(dot) 35 | 36 | if (smoothDot > 0.5) { 37 | if (leftEdge && ctx.event.position.x > window.innerWidth / 2) { 38 | done = true 39 | return Root.prevPreset() 40 | } 41 | 42 | if (rightEdge && ctx.event.position.x < window.innerWidth / 2) { 43 | done = true 44 | return Root.nextPreset() 45 | } 46 | } 47 | } 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /App/src/app/Deserialize.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from "./GameObject" 2 | import MetaToggle, { SerializedMetaToggle } from "./gui/MetaToggle" 3 | import PenToggle, { SerializedPenToggle } from "./gui/PenToggle" 4 | import Handle, { SerializedHandle } from "./ink/Handle" 5 | import Lead, { SerializedLead } from "./ink/Lead" 6 | import Stroke, { SerializedStroke } from "./ink/Stroke" 7 | import StrokeGroup, { SerializedStrokeGroup } from "./ink/StrokeGroup" 8 | import Gizmo, { SerializedGizmo } from "./meta/Gizmo" 9 | import LinearToken, { SerializedLinearToken } from "./meta/LinearToken" 10 | import NumberToken, { SerializedNumberToken } from "./meta/NumberToken" 11 | import PropertyPicker, { SerializedPropertyPicker } from "./meta/PropertyPicker" 12 | import Wire, { SerializedWire } from "./meta/Wire" 13 | import { Root, SerializedRoot } from "./Root" 14 | 15 | export type SerializedGameObject = 16 | | SerializedGizmo 17 | | SerializedHandle 18 | | SerializedLead 19 | | SerializedLinearToken 20 | | SerializedMetaToggle 21 | | SerializedNumberToken 22 | | SerializedPenToggle 23 | | SerializedPropertyPicker 24 | | SerializedRoot 25 | | SerializedStroke 26 | | SerializedStrokeGroup 27 | | SerializedWire 28 | 29 | export function deserialize(v: SerializedGameObject): GameObject { 30 | // prettier-ignore 31 | switch (v.type) { 32 | case "Gizmo": return Gizmo.deserialize(v) 33 | case "Handle": return Handle.deserialize(v) 34 | case "Lead": return Lead.deserialize(v) 35 | case "LinearToken": return LinearToken.deserialize(v) 36 | case "MetaToggle": return MetaToggle.deserialize(v) 37 | case "NumberToken": return NumberToken.deserialize(v) 38 | case "PenToggle": return PenToggle.deserialize(v) 39 | case "PropertyPicker": return PropertyPicker.deserialize(v) 40 | case "Root": return Root.deserialize(v) 41 | case "Stroke": return Stroke.deserialize(v) 42 | case "StrokeGroup": return StrokeGroup.deserialize(v) 43 | case "Wire": return Wire.deserialize(v) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /App/src/lib/math.ts: -------------------------------------------------------------------------------- 1 | // Math 2 | // The JS Math APIs aren't great. Here's a few extras that are nice to have. 3 | 4 | export const TAU = Math.PI * 2 5 | 6 | export const isZero = (v: number) => Number.EPSILON > Math.abs(v) 7 | 8 | export const isNonZero = (v: number) => !isZero(v) 9 | 10 | export const avg = (a: number, b: number) => (a + b) / 2 11 | 12 | export const clip = (v: number, min = 0, max = 1) => Math.max(min, Math.min(v, max)) 13 | 14 | export const lerpN = (input: number, outputMin = 0, outputMax = 1, doClip = false) => { 15 | let output = input * (outputMax - outputMin) + outputMin 16 | if (doClip) { 17 | output = clip(output, outputMin, outputMax) 18 | } 19 | return output 20 | } 21 | 22 | // The args should be: input, inputMin, inputMax, outputMin, outputMax, doClip 23 | // prettier-ignore 24 | export const lerp = (i: number, im = 0, iM = 1, om = 0, oM = 1, doClip = true) => { 25 | if (im === iM) { return om } // Avoids a divide by zero 26 | if (im > iM) { [im, iM, om, oM] = [iM, im, oM, om] } 27 | if (doClip) { i = clip(i, im, iM) } 28 | i -= im 29 | i /= iM - im 30 | return lerpN(i, om, oM, false) 31 | } 32 | 33 | export const rand = (min = -1, max = 1) => lerpN(Math.random(), min, max) 34 | 35 | export const randInt = (min: number, max: number) => Math.round(rand(min, max)) 36 | 37 | export const roundTo = (input: number, precision: number) => { 38 | // Using the reciprocal avoids floating point errors. Eg: 3/10 is fine, but 3*0.1 is wrong. 39 | const p = 1 / precision 40 | return Math.round(input * p) / p 41 | } 42 | 43 | export const easeInOut = (t: number) => { 44 | const ease = (t: number) => Math.pow(t, 3) 45 | return t < 0.5 ? lerp(ease(t * 2), 0, 1, 0, 0.5) : lerp(ease((1 - t) * 2), 1, 0, 0.5, 1) 46 | } 47 | 48 | export function nearestMultiple(n: number, m: number) { 49 | return Math.round(n / m) * m 50 | } 51 | 52 | /** Returns the equivalent angle in the range [0, 2pi) */ 53 | export function normalizeAngle(angle: number) { 54 | return ((angle % TAU) + TAU) % TAU 55 | } 56 | -------------------------------------------------------------------------------- /App/src/app/ink/Lead.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "../../lib/types" 2 | import SVG from "../Svg" 3 | import Vec from "../../lib/vec" 4 | import { GameObject } from "../GameObject" 5 | import Handle, { SerializedHandle } from "./Handle" 6 | import Stroke from "./Stroke" 7 | import StrokeGroup from "./StrokeGroup" 8 | 9 | export type SerializedLead = { 10 | type: "Lead" 11 | handle: SerializedHandle 12 | } 13 | 14 | export default class Lead extends GameObject { 15 | static create(position: Position) { 16 | return new Lead(Handle.create(position)) 17 | } 18 | 19 | stroke?: Stroke 20 | lastPos?: Position 21 | 22 | private readonly elm = SVG.add("circle", SVG.handleElm, { r: 3, fill: "black" }) 23 | 24 | constructor(readonly handle: Handle) { 25 | super() 26 | this.adopt(handle) 27 | handle.getAbsorbedByNearestHandle() 28 | } 29 | 30 | distanceToPoint(point: Position) { 31 | return this.handle.distanceToPoint(point) 32 | } 33 | 34 | serialize(): SerializedLead { 35 | return { 36 | type: "Lead", 37 | handle: this.handle.serialize() 38 | } 39 | } 40 | 41 | static deserialize(v: SerializedLead) { 42 | return new Lead(Handle.deserialize(v.handle)) 43 | } 44 | 45 | render(dt: number, t: number) { 46 | this.lastPos ??= Vec.clone(this.handle.position) 47 | 48 | if (!Vec.equal(this.handle.position, this.lastPos)) { 49 | this.lastPos = Vec.clone(this.handle.position) 50 | if (this.stroke == null || this.stroke.parent == null || this.stroke.parent instanceof StrokeGroup) { 51 | this.stroke?.remove() 52 | this.stroke = this.root.adopt(new Stroke()) 53 | } 54 | this.stroke.points.push(Vec.clone(this.handle.position)) 55 | } 56 | 57 | SVG.update(this.elm, { 58 | transform: SVG.positionToTransform(this.handle.position) 59 | }) 60 | this.handle.render(dt, t) 61 | } 62 | 63 | override remove() { 64 | this.stroke?.remove() 65 | this.handle.remove() 66 | this.elm.remove() 67 | super.remove() 68 | } 69 | } 70 | 71 | export const aLead = (gameObj: GameObject) => (gameObj instanceof Lead ? gameObj : null) 72 | -------------------------------------------------------------------------------- /App/src/app/gestures/Token.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../Gesture" 2 | import { aToken } from "../meta/Token" 3 | import Vec from "../../lib/vec" 4 | import { aNumberToken } from "../meta/NumberToken" 5 | import MetaToggle from "../gui/MetaToggle" 6 | import LinearToken from "../meta/LinearToken" 7 | 8 | export function tokenMoveOrToggleConstraint(ctx: EventContext): Gesture | void { 9 | if (MetaToggle.active) { 10 | const token = ctx.root.find({ 11 | what: aToken, 12 | near: ctx.event.position 13 | }) 14 | const tappableToken = ctx.root.find({ 15 | what: aToken, 16 | near: ctx.event.position, 17 | that: (go) => !(go instanceof LinearToken) 18 | }) 19 | 20 | if (token) { 21 | const offset = Vec.sub(token.position, ctx.event.position) 22 | 23 | return new Gesture("Touch Token", { 24 | dragged(ctx) { 25 | token.position = Vec.add(ctx.event.position, offset) 26 | }, 27 | endedTap(ctx) { 28 | tappableToken?.onTap() 29 | } 30 | }) 31 | } 32 | } 33 | } 34 | 35 | export function numberTokenScrub(ctx: EventContext): Gesture | void { 36 | if (MetaToggle.active && ctx.pseudo) { 37 | const token = ctx.root.find({ 38 | what: aNumberToken, 39 | near: ctx.event.position 40 | }) 41 | 42 | if (token) { 43 | const v = token.getVariable() 44 | const wasLocked = v.isLocked 45 | let initialY = ctx.event.position.y 46 | let initialValue = v.value 47 | let fingers = 0 48 | 49 | return new Gesture("Scrub Number Token", { 50 | moved(ctx) { 51 | if (fingers !== ctx.pseudoCount) { 52 | fingers = ctx.pseudoCount 53 | initialValue = v.value 54 | initialY = ctx.event.position.y 55 | } 56 | const delta = initialY - ctx.event.position.y 57 | const m = 1 / Math.pow(10, fingers - 1) 58 | const value = Math.round((initialValue + delta * m) / m) * m 59 | token.getVariable().lock(value, true) 60 | }, 61 | ended(ctx) { 62 | if (!wasLocked) { 63 | token.getVariable().unlock() 64 | } 65 | } 66 | }) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /App/src/lib/arc.ts: -------------------------------------------------------------------------------- 1 | // Arc, defined by angles in radians 2 | 3 | import { Position } from "./types" 4 | import Vec from "./vec" 5 | 6 | interface Arc { 7 | center: Position 8 | radius: number 9 | startAngle: number 10 | endAngle: number 11 | clockwise: boolean 12 | } 13 | 14 | function Arc(center: Position, radius: number, startAngle: number, endAngle: number, clockwise = true): Arc { 15 | return { center, radius, startAngle, endAngle, clockwise } 16 | } 17 | 18 | export default Arc 19 | 20 | Arc.len = (arc: Arc) => { 21 | const { radius, startAngle, endAngle } = arc 22 | 23 | // Calculate the arc length using the formula: arc length = radius * angle 24 | const length = radius * Math.abs(endAngle - startAngle) 25 | 26 | // Return the arc length 27 | return length 28 | } 29 | 30 | interface Circle { 31 | center: Position 32 | radius: number 33 | } 34 | 35 | Arc.distToPointCircle = (circle: Circle, point: Position) => { 36 | const distance = Vec.dist(circle.center, point) 37 | return Math.abs(distance - circle.radius) 38 | } 39 | 40 | Arc.spreadPointsAlong = (arc: Arc, n: number) => { 41 | const points: Position[] = [] 42 | 43 | const innerAngle = Arc.directedInnerAngle(arc) 44 | const angleStep = innerAngle / (n - 1) 45 | 46 | for (let i = 0; i < n; i++) { 47 | const angle = arc.startAngle + angleStep * i 48 | const offset = Vec(arc.radius * Math.cos(angle), arc.radius * Math.sin(angle)) 49 | points.push(Vec.add(arc.center, offset)) 50 | } 51 | 52 | return points 53 | } 54 | 55 | // Computes the inner angle moving in correct direction (positive if clockwise, negative if counter clockwise) 56 | Arc.directedInnerAngle = (arc: Arc) => { 57 | const difference = arc.endAngle - arc.startAngle 58 | if (arc.clockwise && difference < 0) { 59 | return 2 * Math.PI - Math.abs(difference) 60 | } else if (!arc.clockwise && difference > 0) { 61 | return -2 * Math.PI + Math.abs(difference) 62 | } else { 63 | return difference 64 | } 65 | } 66 | 67 | Arc.points = (arc: Arc) => { 68 | console.log(arc) 69 | 70 | const start = Vec.add(arc.center, Vec.polar(arc.startAngle, arc.radius)) 71 | const end = Vec.add(arc.center, Vec.polar(arc.endAngle, arc.radius)) 72 | 73 | return { start, end } 74 | } 75 | -------------------------------------------------------------------------------- /App/src/app/gui/PenToggle.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "../../lib/types" 2 | import SVG from "../Svg" 3 | import { GameObject } from "../GameObject" 4 | import Vec from "../../lib/vec" 5 | import { TAU, lerpN, rand, randInt } from "../../lib/math" 6 | import Config from "../Config" 7 | 8 | const radius = 20 9 | const padding = 30 10 | 11 | export type SerializedPenToggle = { 12 | type: "PenToggle" 13 | position: Position 14 | } 15 | 16 | export const aPenToggle = (gameObj: GameObject) => (gameObj instanceof PenToggle ? gameObj : null) 17 | 18 | export default class PenToggle extends GameObject { 19 | static active = false 20 | 21 | static toggle(doToggle = !PenToggle.active) { 22 | PenToggle.active = doToggle 23 | document.documentElement.toggleAttribute("pen-mode", PenToggle.active) 24 | } 25 | 26 | private readonly element: SVGElement 27 | active = false 28 | 29 | serialize(): SerializedPenToggle { 30 | return { 31 | type: "PenToggle", 32 | position: this.position 33 | } 34 | } 35 | 36 | static deserialize(v: SerializedPenToggle): PenToggle { 37 | return new PenToggle(v.position) 38 | } 39 | 40 | constructor(public position = { x: padding, y: padding }) { 41 | super() 42 | 43 | this.element = SVG.add("g", SVG.guiElm, { 44 | ...this.getAttrs() // This avoids an unstyled flash on first load 45 | }) 46 | 47 | SVG.add("circle", this.element, { 48 | r: 10 49 | }) 50 | } 51 | 52 | distanceToPoint(point: Position) { 53 | return Vec.dist(this.position, point) 54 | } 55 | 56 | remove() { 57 | this.element.remove() 58 | } 59 | 60 | private getAttrs() { 61 | const classes: string[] = ["pen-toggle"] 62 | this.position = { x: window.innerWidth / 2, y: 20 } 63 | 64 | if (PenToggle.active) { 65 | classes.push("active") 66 | } 67 | 68 | if (Config.fallback) { 69 | classes.push("showing") 70 | } 71 | 72 | return { 73 | class: classes.join(" "), 74 | style: `translate: ${this.position.x}px ${this.position.y}px` 75 | } 76 | } 77 | 78 | render() { 79 | if (this.active != PenToggle.active) { 80 | this.active = PenToggle.active 81 | } 82 | 83 | SVG.update(this.element, this.getAttrs()) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /App/src/app/GameObject.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "../lib/types" 2 | import { SerializedGameObject } from "./Deserialize" 3 | 4 | const DEFAULT_TOO_FAR = 20 5 | 6 | export interface FindOptions { 7 | what(gameObj: GameObject): T | null 8 | that?(gameObj: T): boolean 9 | recursive?: boolean 10 | near?: Position 11 | tooFar?: number 12 | } 13 | 14 | interface ForEachOptions extends FindOptions { 15 | do(gameObj: T): void 16 | } 17 | 18 | export abstract class GameObject { 19 | parent: GameObject | null = null 20 | readonly children = new Set() 21 | 22 | abstract serialize(): SerializedGameObject 23 | static deserialize(v: SerializedGameObject): GameObject { 24 | throw new Error("Override me") 25 | } 26 | 27 | // TODO: remove this, and just say Root.current anywhere it's used 28 | get root(): GameObject { 29 | let p: GameObject = this 30 | while (p.parent) { 31 | p = p.parent 32 | } 33 | return p 34 | } 35 | 36 | adopt(child: T): T { 37 | child.parent?.children.delete(child) 38 | this.children.add(child) 39 | child.parent = this 40 | return child 41 | } 42 | 43 | remove() { 44 | // TODO: remove my children here? 45 | this.parent?.children.delete(this) 46 | this.parent = null 47 | } 48 | 49 | abstract render(dt: number, t: number): void 50 | 51 | abstract distanceToPoint(point: Position): number | null 52 | 53 | find(options: FindOptions): T | null { 54 | const { what, that, recursive, near: pos, tooFar = DEFAULT_TOO_FAR } = options 55 | let nearestDist = tooFar 56 | let ans: T | null = null 57 | this.forEach({ 58 | what, 59 | that, 60 | recursive, 61 | do(gameObj) { 62 | if (pos) { 63 | const dist = gameObj.distanceToPoint(pos) 64 | if (dist !== null && dist <= nearestDist) { 65 | ans = gameObj 66 | nearestDist = dist 67 | } 68 | } else { 69 | if (ans === null) { 70 | ans = gameObj 71 | } 72 | } 73 | } 74 | }) 75 | return ans 76 | } 77 | 78 | findAll(options: FindOptions) { 79 | const ans = [] as T[] 80 | this.forEach({ 81 | ...options, 82 | do(gameObj) { 83 | ans.push(gameObj) 84 | } 85 | }) 86 | return ans 87 | } 88 | 89 | forEach(options: ForEachOptions) { 90 | const { what, that, recursive = true, near: pos, tooFar = DEFAULT_TOO_FAR, do: doFn } = options 91 | 92 | for (const gameObj of this.children) { 93 | if (recursive) { 94 | gameObj.forEach(options) 95 | } 96 | 97 | const narrowedGameObj = what(gameObj) 98 | if (!narrowedGameObj || (that && !that(narrowedGameObj))) { 99 | continue 100 | } 101 | 102 | if (pos) { 103 | const dist = narrowedGameObj.distanceToPoint(pos) 104 | if (dist === null || dist >= tooFar) { 105 | continue 106 | } 107 | } 108 | 109 | doFn.call(this, narrowedGameObj) 110 | } 111 | } 112 | } 113 | 114 | export const aGameObject = (gameObj: GameObject) => gameObj 115 | -------------------------------------------------------------------------------- /App/src/app/meta/Wire.ts: -------------------------------------------------------------------------------- 1 | import { distanceToPath } from "../../lib/helpers" 2 | import { Position } from "../../lib/types" 3 | import Vec from "../../lib/vec" 4 | import * as constraints from "../Constraints" 5 | import { Constraint, Variable } from "../Constraints" 6 | import { GameObject } from "../GameObject" 7 | import SVG from "../Svg" 8 | import Gizmo from "./Gizmo" 9 | import NumberToken from "./NumberToken" 10 | import { Connection, PlugId, VariableId } from "./Pluggable" 11 | import PropertyPicker from "./PropertyPicker" 12 | import Token from "./Token" 13 | 14 | type SerializedConnection = { 15 | objId: number 16 | type: "gizmo" | "token" 17 | plugId: PlugId 18 | variableId: VariableId 19 | } 20 | 21 | export type SerializedWire = { 22 | type: "Wire" 23 | a: SerializedConnection 24 | b?: SerializedConnection 25 | constraintId?: number 26 | toPosition?: Position 27 | } 28 | 29 | export default class Wire extends GameObject { 30 | constraint?: Constraint 31 | private b?: Connection 32 | toPosition?: Position 33 | 34 | private readonly elm = SVG.add("polyline", SVG.wiresElm, { points: "", class: "wire" }) 35 | 36 | constructor(private a: Connection) { 37 | super() 38 | } 39 | 40 | private static getObjById(type: "gizmo" | "token", id: number) { 41 | return type === "gizmo" ? Gizmo.withId(id) : (Token.withId(id) as NumberToken | PropertyPicker) 42 | } 43 | 44 | static deserializeConnection({ objId, type, plugId, variableId }: SerializedConnection): Connection { 45 | return { obj: this.getObjById(type, objId), plugId, variableId } as Connection 46 | } 47 | 48 | static deserialize(v: SerializedWire): Wire { 49 | const w = new Wire(null as unknown as Connection) 50 | return w 51 | } 52 | 53 | deserializeConstraint(v: SerializedWire) { 54 | this.a = Wire.deserializeConnection(v.a) 55 | this.b = v.b && Wire.deserializeConnection(v.b) 56 | this.toPosition = v.toPosition 57 | if (v.constraintId) this.constraint = constraints.Constraint.withId(v.constraintId) 58 | } 59 | 60 | serializeConnection(c: Connection): SerializedConnection { 61 | const type = c.obj instanceof Gizmo ? "gizmo" : "token" 62 | return { objId: c.obj.id, type, plugId: c.plugId, variableId: c.variableId } 63 | } 64 | 65 | serialize(): SerializedWire { 66 | return { 67 | type: "Wire", 68 | constraintId: this.constraint?.id, 69 | a: this.serializeConnection(this.a), 70 | b: this.b && this.serializeConnection(this.b), 71 | toPosition: this.toPosition 72 | } 73 | } 74 | 75 | distanceToPoint(point: Position) { 76 | return distanceToPath(point, this.getPoints()) 77 | } 78 | 79 | private getPoints() { 80 | const a = this.a.obj.getPlugPosition(this.a.plugId) 81 | const b = this.toPosition ?? this.b!.obj.getPlugPosition(this.b!.plugId) 82 | return [a, b] 83 | } 84 | 85 | render(): void { 86 | SVG.update(this.elm, { points: SVG.points(this.getPoints()) }) 87 | } 88 | 89 | isCollapsable() { 90 | const [p1, p2] = this.getPoints() 91 | return p1 && p2 && Vec.dist(p1, p2) < 10 92 | } 93 | 94 | attachEnd(b: Connection) { 95 | this.b = b 96 | this.toPosition = undefined 97 | } 98 | 99 | remove(): void { 100 | this.elm.remove() 101 | this.constraint?.remove() 102 | super.remove() 103 | } 104 | } 105 | 106 | export const aWire = (g: GameObject) => (g instanceof Wire ? g : null) 107 | -------------------------------------------------------------------------------- /App/src/app/meta/PropertyPicker.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "../../lib/types" 2 | import Vec from "../../lib/vec" 3 | import * as constraints from "../Constraints" 4 | import { Variable } from "../Constraints" 5 | import { GameObject } from "../GameObject" 6 | import { generateId } from "../Root" 7 | import SVG from "../Svg" 8 | import { Pluggable } from "./Pluggable" 9 | import Token from "./Token" 10 | 11 | const TAB_SIZE = 5 12 | 13 | function PropertyPickerPath(pos: Position, w: number, h: number) { 14 | return ` 15 | M ${pos.x + TAB_SIZE} ${pos.y} 16 | L ${pos.x + w} ${pos.y} 17 | L ${pos.x + w} ${pos.y + h} 18 | L ${pos.x + TAB_SIZE} ${pos.y + h} 19 | L ${pos.x} ${pos.y + h / 2} 20 | L ${pos.x + TAB_SIZE} ${pos.y} 21 | ` 22 | } 23 | 24 | export type SerializedPropertyPicker = { 25 | type: "PropertyPicker" 26 | id: number 27 | propertyName: PropertyName 28 | variableId: number 29 | position: Position 30 | } 31 | 32 | type PropertyName = "distance" | "angleInDegrees" 33 | 34 | export default class PropertyPicker extends Token implements Pluggable { 35 | static create(propertyName: PropertyName, value = 0) { 36 | const variable = constraints.variable(value) 37 | const picker = new PropertyPicker(generateId(), propertyName, variable, Vec(100, 100)) 38 | variable.represents = { 39 | object: picker, 40 | property: "value" 41 | } 42 | return picker 43 | } 44 | 45 | protected readonly boxElement = SVG.add("path", SVG.metaElm, { 46 | d: PropertyPickerPath(this.position, this.width, this.height), 47 | class: "property-picker-box" 48 | }) 49 | 50 | protected readonly textElement = SVG.add("text", SVG.metaElm, { 51 | x: this.position.x + 5 + TAB_SIZE, 52 | y: this.position.y + 21, 53 | class: "property-picker-text" 54 | }) 55 | 56 | readonly plugVars: { value: Variable } 57 | 58 | private constructor( 59 | id: number, 60 | readonly propertyName: PropertyName, 61 | readonly variable: Variable, 62 | position: Position 63 | ) { 64 | super(id) 65 | SVG.update(this.textElement, { content: propertyName.replace("InDegrees", "").replace("distance", "length") }) 66 | this.width = this.textElement.getComputedTextLength() + 10 + TAB_SIZE 67 | this.plugVars = { value: variable } 68 | this.position = position 69 | } 70 | 71 | getPlugPosition(id: string): Position { 72 | return id === "input" ? Vec.add(this.position, Vec(0, this.height / 2)) : this.midPoint() 73 | } 74 | 75 | static deserialize(v: SerializedPropertyPicker): PropertyPicker { 76 | return new PropertyPicker(v.id, v.propertyName, Variable.withId(v.variableId), v.position) 77 | } 78 | 79 | serialize(): SerializedPropertyPicker { 80 | return { 81 | type: "PropertyPicker", 82 | id: this.id, 83 | propertyName: this.propertyName, 84 | variableId: this.variable.id, 85 | position: this.position 86 | } 87 | } 88 | 89 | render() { 90 | SVG.update(this.boxElement, { 91 | d: PropertyPickerPath(this.position, this.width, this.height) 92 | }) 93 | 94 | SVG.update(this.textElement, { 95 | x: this.position.x + 5 + TAB_SIZE, 96 | y: this.position.y + 21 97 | }) 98 | } 99 | 100 | remove() { 101 | this.boxElement.remove() 102 | this.textElement.remove() 103 | this.variable.remove() 104 | super.remove() 105 | } 106 | } 107 | 108 | export const aPropertyPicker = (gameObj: GameObject) => (gameObj instanceof PropertyPicker ? gameObj : null) 109 | -------------------------------------------------------------------------------- /Wrapper/Inkling.xcodeproj/xcshareddata/xcschemes/Inkling.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 58 | 59 | 60 | 61 | 65 | 66 | 67 | 68 | 74 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /App/src/app/meta/NumberToken.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "../../lib/types" 2 | import * as constraints from "../Constraints" 3 | import { Variable } from "../Constraints" 4 | import { GameObject } from "../GameObject" 5 | import { generateId } from "../Root" 6 | import SVG from "../Svg" 7 | import { Pluggable } from "./Pluggable" 8 | import Token from "./Token" 9 | 10 | export type SerializedNumberToken = { 11 | type: "NumberToken" 12 | id: number 13 | position: Position 14 | variableId: number 15 | } 16 | 17 | export default class NumberToken extends Token implements Pluggable { 18 | static create(value = 1) { 19 | const variable = constraints.variable(value) 20 | const object = NumberToken._create(generateId(), variable) 21 | variable.represents = { 22 | object, 23 | property: "number-token-value" 24 | } 25 | return object 26 | } 27 | 28 | static _create(id: number, variable: Variable) { 29 | return new NumberToken(id, variable) 30 | } 31 | 32 | // Rendering stuff 33 | private lastRenderedValue = "" 34 | protected readonly elm = SVG.add("g", SVG.metaElm, { class: "number-token" }) 35 | protected readonly boxElm = SVG.add("rect", this.elm, { class: "token-box", height: this.height }) 36 | protected readonly wholeElm = SVG.add("text", this.elm, { class: "token-text" }) 37 | protected readonly fracElm = SVG.add("text", this.elm, { class: "token-frac-text" }) 38 | 39 | readonly plugVars: { value: Variable } 40 | 41 | constructor(id: number, readonly variable: Variable) { 42 | super(id) 43 | this.plugVars = { value: variable } 44 | } 45 | 46 | getPlugPosition(id: string): Position { 47 | return this.midPoint() 48 | } 49 | 50 | static deserialize(v: SerializedNumberToken): NumberToken { 51 | const nt = this._create(v.id, Variable.withId(v.variableId)) 52 | nt.position = v.position 53 | return nt 54 | } 55 | 56 | serialize(): SerializedNumberToken { 57 | return { 58 | type: "NumberToken", 59 | id: this.id, 60 | position: this.position, 61 | variableId: this.variable.id 62 | } 63 | } 64 | 65 | render(dt: number, t: number): void { 66 | SVG.update(this.elm, { 67 | transform: SVG.positionToTransform(this.position), 68 | "is-locked": this.getVariable().isLocked 69 | }) 70 | 71 | // getComputedTextLength() is slow, so we're gonna do some dirty checking here 72 | const newValue = this.variable.value.toFixed(2) 73 | 74 | if (newValue === this.lastRenderedValue) return 75 | 76 | this.lastRenderedValue = newValue 77 | 78 | let [whole, frac] = newValue.split(".") 79 | 80 | if (whole === "-0") whole = "0" 81 | 82 | SVG.update(this.wholeElm, { content: whole, visibility: "visible" }) 83 | SVG.update(this.fracElm, { content: frac, visibility: "visible" }) 84 | 85 | const wholeWidth = this.wholeElm.getComputedTextLength() 86 | const fracWidth = this.fracElm.getComputedTextLength() 87 | 88 | this.width = 5 + wholeWidth + 2 + fracWidth + 5 89 | 90 | SVG.update(this.boxElm, { width: this.width }) 91 | SVG.update(this.fracElm, { dx: wholeWidth + 2 }) 92 | } 93 | 94 | getVariable() { 95 | return this.variable 96 | } 97 | 98 | onTap() { 99 | this.getVariable().toggleLock() 100 | } 101 | 102 | remove() { 103 | this.variable.remove() 104 | this.elm.remove() 105 | super.remove() 106 | } 107 | } 108 | 109 | export const isNumberToken = (token: Token | null): token is NumberToken => token instanceof NumberToken 110 | export const aNumberToken = (gameObj: GameObject) => (gameObj instanceof NumberToken ? gameObj : null) 111 | -------------------------------------------------------------------------------- /App/TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## UX Improvements 4 | 5 | - P1: fix clock demo (it broke w/ new finger constraint) 6 | (Alex) 7 | 8 | - P1: finger constraint needs to feel better (e.g., when manipulating gizmo w/ locked distance and/or angle) 9 | (Alex) 10 | 11 | - P1: (Possibly repeat of above) — Make a gizmo with 1 locked handle, locked angle. Drag the free handle. It moves and flickers. 12 | (Alex) 13 | 14 | - P1: when breaking apart handles, put a temp pin constraint 15 | on left over handle 16 | 17 | - P1: when erasing inside a formula, call a different method (not remove) 18 | (Marcel) 19 | 20 | - P2: When removing a canonicalInstance handle, pick one of the absorbed handles to become the new canonicalInstance 21 | 22 | - P2: tokens snap to each other, and can be moved together w/ a single finger 23 | (e.g., two number tokens, or a number token right next to a property picker) 24 | (Marcel) 25 | 26 | - P2: more fluid gesture to get a wire out of a property picker: 27 | ATM you have to first pick a property (w/ tap) then drag out the wire. 28 | We should be able to do this in a single gesture. 29 | (Marcel) 30 | 31 | - P4: in meta mode, should you be able to break apart ink handles? 32 | 33 | - P4: should we be able to delete a handle w/ a pin if it's not 34 | connected to anything? (right now we can't) 35 | 36 | - P1292: When using KB+M, a way to put a finger down and leave it there (and remove it later) 37 | 38 | ## Bugs 39 | 40 | - P1/2: can't erase a connection between a number outside a formula and inside 41 | (deleting the line does nothing) 42 | 43 | - P1/2: Wires to formula cells render behind the formula box. This is bad. 44 | (Ivan) 45 | 46 | - P2: when writing a label in the formula editor, sometimes the label.display in LabelToken is undefined and errors. 47 | (Marcel) 48 | 49 | - P#wontfix: It's possible to erase 1 handle from a strokegroup. We'll "fix" this by implementing Components. 50 | (Ivan) 51 | 52 | ## Formulas / Wires / Meta 53 | 54 | - P2/3: using / in formulas causes gradient errors 55 | More generally, need to fix unsatisfiable formula constraints 56 | (Alex) 57 | 58 | - P2/3: "orange" numbers for results of spreadsheet formulas 59 | (this info needs to be in Variable so Tokens can render...) 60 | (opposite of locked number tokens: cannot be changed / scrubbed) 61 | (Alex) 62 | 63 | - P2/3: tweaks to property picker token design 64 | (Ivan) 65 | 66 | - P3/4: toggle formula "equals" <==> "arrow" (spreadsheet/diode mode) 67 | (Alex) 68 | 69 | ## Constraints 70 | 71 | - P2: Should we make it impossible to have both handles in a stroke group or gizmo absorb one another? 72 | 73 | - P2/3: consider unlocking a locked variable instead of pausing a constraint 74 | when solver gives up -- this would be easier to understand since 75 | the variables' locked/unlocked state is always shown on the canvas. 76 | (As opposed to, say, pausing a PolarVector constraint which leaves 77 | the Gizmo that owns it looking like a functional Gizmo when it isn't.) 78 | (Alex) 79 | 80 | - P4: constraints should only have weak refs to handles 81 | 82 | ## Clean-up 83 | 84 | - P3: If tapping and dragging perform different actions to the same object, 85 | they need to be handled by the same gesture, because they both need to claim the touch. 86 | This is awkward! For instance, in meta, tapping empty space currently creates a formula, 87 | and dragging empty space creates a gizmo. (In this example, "empty space" is the object). 88 | (We will probably replace both of these with some sort of "create seed" gesture. Set that aside for a sec.) 89 | We have a separate gesture for creating a gizmo by dragging on a handle. 90 | This is bad. 91 | (Ivan) 92 | 93 | - P3: Idea: We can continue tracking a "hold still" timer to check for dead touches, but only actually 94 | perform the reap when we toggle in/out of meta mode, which should make reaping less awful. 95 | -------------------------------------------------------------------------------- /App/src/app/gestures/Handle.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../Gesture" 2 | import Handle, { aCanonicalHandle } from "../ink/Handle" 3 | import * as constraints from "../Constraints" 4 | import StrokeGroup from "../ink/StrokeGroup" 5 | import Vec from "../../lib/vec" 6 | import SVG from "../Svg" 7 | import { Position } from "../../lib/types" 8 | import { createGizmo } from "./effects/CreateGizmo" 9 | import MetaToggle from "../gui/MetaToggle" 10 | 11 | const handleTouchDist = 40 12 | 13 | export function handleCreateGizmo(ctx: EventContext): Gesture | void { 14 | if (MetaToggle.active && ctx.root.find({ what: aCanonicalHandle, near: ctx.event.position })) { 15 | return createGizmo(ctx) 16 | } 17 | } 18 | 19 | export function handleGoAnywhere(ctx: EventContext): Gesture | void { 20 | const handle = ctx.root.find({ 21 | what: aCanonicalHandle, 22 | near: ctx.event.position, 23 | tooFar: handleTouchDist 24 | }) 25 | 26 | if (handle && ctx.pseudoCount >= 4) { 27 | return new Gesture("Go Anywhere", { 28 | began() { 29 | handle.canonicalInstance.toggleGoesAnywhere() 30 | } 31 | }) 32 | } 33 | } 34 | 35 | export function handleBreakOff(ctx: EventContext): Gesture | void { 36 | const handle = ctx.root.find({ 37 | what: aCanonicalHandle, 38 | near: ctx.event.position, 39 | tooFar: handleTouchDist 40 | }) 41 | 42 | if (handle && ctx.pseudoCount >= 3 && handle.canonicalInstance.absorbedHandles.size > 0) { 43 | const handles = [...handle.canonicalInstance.absorbedHandles] 44 | touchHandleHelper(handle.breakOff(handles[handles.length - 1])) 45 | } 46 | } 47 | 48 | export function handleMoveOrTogglePin(ctx: EventContext): Gesture | void { 49 | let handle = ctx.root.find({ 50 | what: aCanonicalHandle, 51 | near: ctx.event.position, 52 | tooFar: handleTouchDist 53 | }) 54 | 55 | if (handle) { 56 | return touchHandleHelper(handle) 57 | } 58 | } 59 | 60 | export function touchHandleHelper(handle: Handle): Gesture { 61 | let lastPos = Vec.clone(handle) 62 | let offset: Position 63 | 64 | return new Gesture("Handle Move or Toggle Constraints", { 65 | moved(ctx) { 66 | // touchHandleHelper is sometimes called from another gesture, after began, 67 | // so we need to do our initialization lazily. 68 | offset ??= Vec.sub(handle.position, ctx.event.position) 69 | 70 | handle.position = Vec.add(ctx.event.position, offset) 71 | lastPos = Vec.clone(handle) 72 | 73 | constraints.finger(handle) 74 | 75 | if ( 76 | ctx.pseudoCount === 2 && 77 | handle.parent instanceof StrokeGroup && 78 | handle.canonicalInstance.absorbedHandles.size === 0 79 | ) { 80 | handle.parent.generatePointData() 81 | } 82 | }, 83 | ended(ctx) { 84 | handle.getAbsorbedByNearestHandle() 85 | constraints.finger(handle).remove() 86 | 87 | // Tune: you must tap a little more precisely to toggle a pin than drag a handle 88 | // TODO: This creates a small tap deadzone between the stroke (to toggle handles) and the handle (to toggle pin), because the handle claims the gesture but doesn't do anything with it 89 | const tappedPrecisely = Vec.dist(handle, ctx.event.position) < 20 90 | if (!ctx.state.drag && MetaToggle.active && tappedPrecisely) { 91 | handle.togglePin() 92 | } 93 | }, 94 | render() { 95 | const count = Math.pow(Vec.dist(handle.position, lastPos), 1 / 3) 96 | let c = count 97 | while (--c > 0) { 98 | let v = Vec.sub(handle.position, lastPos) 99 | v = Vec.add(lastPos, Vec.mulS(v, c / count)) 100 | SVG.now("circle", { 101 | cx: v.x, 102 | cy: v.y, 103 | r: 4, 104 | class: "desire" 105 | }) 106 | } 107 | } 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /App/src/app/ink/StrokeGroup.ts: -------------------------------------------------------------------------------- 1 | import { farthestPair } from "../../lib/helpers" 2 | import TransformationMatrix from "../../lib/TransformationMatrix" 3 | import { Position } from "../../lib/types" 4 | import { deserialize, SerializedGameObject } from "../Deserialize" 5 | import { GameObject } from "../GameObject" 6 | import Handle from "./Handle" 7 | import Stroke, { aStroke } from "./Stroke" 8 | 9 | export type SerializedStrokeGroup = { 10 | type: "StrokeGroup" 11 | children: SerializedGameObject[] 12 | } 13 | 14 | export default class StrokeGroup extends GameObject { 15 | private pointData: Position[][] 16 | 17 | // These strong references are OK b/c a and b will always be my children 18 | readonly a: Handle 19 | readonly b: Handle 20 | 21 | constructor(strokes: Set, a?: Handle, b?: Handle) { 22 | super() 23 | 24 | for (const stroke of strokes) { 25 | this.adopt(stroke) 26 | } 27 | 28 | // Generate Handles 29 | if (a == null || b == null) { 30 | const points = this.strokes.flatMap((stroke) => stroke.points) 31 | ;[a, b] = farthestPair(points).map((pos) => Handle.create(pos)) 32 | } 33 | a.getAbsorbedByNearestHandle() 34 | b.getAbsorbedByNearestHandle() 35 | this.a = this.adopt(a) 36 | this.b = this.adopt(b) 37 | this.pointData = this.generatePointData() 38 | } 39 | 40 | static deserialize(v: SerializedStrokeGroup): StrokeGroup { 41 | const strokes = new Set() 42 | const handles: Handle[] = [] 43 | for (const c of v.children) { 44 | if (c.type === "Handle") { 45 | handles.push(deserialize(c) as Handle) 46 | } else if (c.type === "Stroke") { 47 | strokes.add(deserialize(c) as Stroke) 48 | } 49 | } 50 | const [a, b] = handles 51 | return new StrokeGroup(strokes, a, b) 52 | } 53 | 54 | serialize(): SerializedStrokeGroup { 55 | return { 56 | type: "StrokeGroup", 57 | children: Array.from(this.children).map((c) => c.serialize()) 58 | } 59 | } 60 | 61 | generatePointData() { 62 | const transform = TransformationMatrix.fromLine(this.a.position, this.b.position).inverse() 63 | this.pointData = this.strokes.map((stroke) => stroke.points.map((p) => transform.transformPoint(p))) 64 | return this.pointData 65 | } 66 | 67 | get strokes(): Stroke[] { 68 | return this.findAll({ what: aStroke, recursive: false }) 69 | } 70 | 71 | private updatePaths() { 72 | const transform = TransformationMatrix.fromLine(this.a.position, this.b.position) 73 | 74 | for (const [i, stroke] of this.strokes.entries()) { 75 | const newPoints = this.pointData[i].map((p) => transform.transformPoint(p)) 76 | stroke.updatePath(newPoints) 77 | } 78 | } 79 | 80 | distanceToPoint(pos: Position) { 81 | let minDistance: number | null = null 82 | for (const stroke of this.strokes) { 83 | const dist = stroke.distanceToPoint(pos) 84 | if (dist === null) { 85 | continue 86 | } else if (minDistance === null) { 87 | minDistance = dist 88 | } else { 89 | minDistance = Math.min(minDistance, dist) 90 | } 91 | } 92 | return minDistance 93 | } 94 | 95 | render(dt: number, t: number) { 96 | // TODO: Ivan to speed this up if necessary 97 | this.updatePaths() 98 | 99 | for (const child of this.children) { 100 | child.render(dt, t) 101 | } 102 | } 103 | 104 | breakApart() { 105 | const strokes = [] 106 | let stroke 107 | while ((stroke = this.strokes.pop())) { 108 | strokes.push(stroke) 109 | this.parent?.adopt(stroke) 110 | } 111 | this.remove() 112 | return strokes 113 | } 114 | 115 | remove() { 116 | this.a.remove() 117 | this.b.remove() 118 | for (const s of this.strokes) { 119 | s.remove() 120 | } 121 | super.remove() 122 | } 123 | } 124 | 125 | export const aStrokeGroup = (gameObj: GameObject) => (gameObj instanceof StrokeGroup ? gameObj : null) 126 | -------------------------------------------------------------------------------- /App/src/app/Gesture.ts: -------------------------------------------------------------------------------- 1 | import Events, { Event, InputState, EventState, TouchId } from "./NativeEvents" 2 | import SVG from "./Svg" 3 | import { Root } from "./Root" 4 | 5 | export type EventContext = { 6 | event: Event // The current event we're processing. 7 | state: InputState // The current state of the pencil or finger that generated this event. 8 | events: Events // The full NativeEvents instance, so we can look at other the pencils/fingers. 9 | root: Root // The root of the scene graph 10 | pseudo: boolean 11 | pseudoCount: number 12 | pseudoTouches: Record 13 | } 14 | 15 | type GestureAPI = Partial<{ 16 | claim: "pencil" | "finger" | "fingers" | PredicateFn 17 | pseudo: boolean 18 | began: EventHandler 19 | moved: EventHandler 20 | dragged: EventHandler 21 | ended: EventHandler 22 | endedTap: EventHandler 23 | endedDrag: EventHandler 24 | done: VoidFn 25 | render: VoidFn 26 | }> 27 | 28 | type VoidFn = () => void 29 | type PredicateFn = (ctx: EventContext) => boolean 30 | type EventHandler = (ctx: EventContext) => EventHandlerResult 31 | // TODO: should this be a type parameter of EventHandler? 32 | type EventHandlerResult = Gesture | any 33 | type EventHandlerName = EventState | "dragged" | "endedTap" | "endedDrag" 34 | 35 | export class Gesture { 36 | private touches: Record = {} 37 | 38 | constructor(public label: string, public api: GestureAPI) {} 39 | 40 | claimsTouch(ctx: EventContext): boolean { 41 | const typeIsPencil = ctx.event.type === "pencil" 42 | const typeIsFinger = ctx.event.type === "finger" 43 | const oneFinger = ctx.events.fingerStates.length === 1 44 | const typeMatchesClaim = this.api.claim === ctx.event.type 45 | const claimIsFingers = this.api.claim === "fingers" 46 | 47 | // claim "pencil" to match the pencil 48 | if (typeMatchesClaim && typeIsPencil) { 49 | return true 50 | } 51 | 52 | // claim "finger" to match only one finger 53 | if (typeMatchesClaim && typeIsFinger && oneFinger) { 54 | return true 55 | } 56 | 57 | // claim "fingers" to match all subsequent fingers 58 | if (typeIsFinger && claimIsFingers) { 59 | return true 60 | } 61 | 62 | // Custom claim function 63 | if (this.api.claim instanceof Function) { 64 | return this.api.claim(ctx) 65 | } 66 | 67 | return false 68 | } 69 | 70 | applyEvent(ctx: EventContext) { 71 | let eventHandlerName: EventHandlerName = ctx.event.state 72 | 73 | // Synthetic "dragged" event 74 | if (eventHandlerName === "moved" && ctx.state.drag && this.api.dragged) { 75 | eventHandlerName = "dragged" 76 | } 77 | 78 | // Synthetic "endedTap" event 79 | if (eventHandlerName === "ended" && !ctx.state.drag && this.api.endedTap) { 80 | eventHandlerName = "endedTap" 81 | } 82 | 83 | // Synthetic "endedDrag" event 84 | if (eventHandlerName === "ended" && ctx.state.drag && this.api.endedDrag) { 85 | eventHandlerName = "endedDrag" 86 | } 87 | 88 | // Run the event handler 89 | const result = this.api[eventHandlerName]?.call(this, ctx) 90 | 91 | // Track which touches we've claimed, and run the `done` handler when they're all released 92 | if (ctx.event.state !== "ended") { 93 | this.touches[ctx.event.id] = ctx.event 94 | } else { 95 | delete this.touches[ctx.event.id] 96 | if (Object.keys(this.touches).length === 0) { 97 | this.api.done?.call(this) 98 | } 99 | } 100 | 101 | return result 102 | } 103 | 104 | render() { 105 | this.api.render?.call(this) 106 | } 107 | 108 | debugRender() { 109 | for (const id in this.touches) { 110 | const event = this.touches[id] 111 | const elm = SVG.now("g", { 112 | class: "gesture", 113 | transform: SVG.positionToTransform(event.position) 114 | }) 115 | SVG.add("circle", elm, { r: event.type === "pencil" ? 3 : 16 }) 116 | // SVG.add("text", elm, { content: this.label }) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /App/src/app/meta/LinearToken.ts: -------------------------------------------------------------------------------- 1 | import SVG from "../Svg" 2 | import Token from "./Token" 3 | import { GameObject } from "../GameObject" 4 | import NumberToken, { SerializedNumberToken } from "./NumberToken" 5 | import Vec from "../../lib/vec" 6 | import * as constraints from "../Constraints" 7 | import { generateId } from "../Root" 8 | import { deserialize } from "../Deserialize" 9 | import { Position } from "../../lib/types" 10 | 11 | export type SerializedLinearToken = { 12 | type: "LinearToken" 13 | id: number 14 | position: Position 15 | y: SerializedNumberToken 16 | m: SerializedNumberToken 17 | x: SerializedNumberToken 18 | b: SerializedNumberToken 19 | } 20 | 21 | export default class LinearToken extends Token { 22 | static create() { 23 | const lt = this._create( 24 | generateId(), 25 | NumberToken.create(0), 26 | NumberToken.create(1), 27 | NumberToken.create(0), 28 | NumberToken.create(0) 29 | ) 30 | lt.m.variable.lock() 31 | lt.b.variable.lock() 32 | const formula = constraints.linearFormula(lt.m.variable, lt.x.variable, lt.b.variable) 33 | constraints.equals(lt.y.variable, formula.result) 34 | lt.render(0, 0) 35 | return lt 36 | } 37 | 38 | static _create(id: number, y: NumberToken, m: NumberToken, x: NumberToken, b: NumberToken) { 39 | return new LinearToken(id, y, m, x, b) 40 | } 41 | 42 | width = 222 43 | height = 34 44 | 45 | private readonly elm = SVG.add("g", SVG.metaElm, { class: "linear-token" }) 46 | private readonly boxElm = SVG.add("rect", this.elm, { 47 | class: "hollow-box", 48 | x: -2, 49 | y: -2, 50 | width: this.width, 51 | height: this.height 52 | }) 53 | private readonly eq = SVG.add("text", this.elm, { class: "token-text", content: "=" }) 54 | private readonly times = SVG.add("text", this.elm, { class: "token-text", content: "×" }) 55 | private readonly plus = SVG.add("text", this.elm, { class: "token-text", content: "+" }) 56 | 57 | constructor( 58 | id: number, 59 | readonly y: NumberToken, 60 | readonly m: NumberToken, 61 | readonly x: NumberToken, 62 | readonly b: NumberToken 63 | ) { 64 | super(id) 65 | this.adopt(y) 66 | this.adopt(m) 67 | this.adopt(x) 68 | this.adopt(b) 69 | } 70 | 71 | static deserialize(v: SerializedLinearToken): LinearToken { 72 | const lt = this._create( 73 | v.id, 74 | deserialize(v.y) as NumberToken, 75 | deserialize(v.m) as NumberToken, 76 | deserialize(v.x) as NumberToken, 77 | deserialize(v.b) as NumberToken 78 | ) 79 | lt.position = v.position 80 | return lt 81 | } 82 | 83 | serialize(): SerializedLinearToken { 84 | return { 85 | type: "LinearToken", 86 | id: this.id, 87 | position: this.position, 88 | y: this.y.serialize(), 89 | m: this.m.serialize(), 90 | x: this.x.serialize(), 91 | b: this.b.serialize() 92 | } 93 | } 94 | 95 | render(dt: number, t: number): void { 96 | SVG.update(this.elm, { 97 | transform: SVG.positionToTransform(this.position) 98 | }) 99 | 100 | let p = { x: 0, y: 0 } 101 | this.m.position = Vec.add(this.position, p) 102 | p.x += this.m.width 103 | 104 | SVG.update(this.times, { transform: SVG.positionToTransform(p) }) 105 | p.x += 25 106 | 107 | this.x.position = Vec.add(this.position, p) 108 | p.x += this.x.width 109 | 110 | SVG.update(this.plus, { transform: SVG.positionToTransform(p) }) 111 | p.x += 25 112 | 113 | this.b.position = Vec.add(this.position, p) 114 | p.x += this.b.width + 5 115 | 116 | SVG.update(this.eq, { transform: SVG.positionToTransform(p) }) 117 | p.x += 25 118 | 119 | this.y.position = Vec.add(this.position, p) 120 | p.x += this.y.width 121 | 122 | this.width = p.x 123 | SVG.update(this.boxElm, { width: this.width }) 124 | 125 | for (const child of this.children) { 126 | child.render(dt, t) 127 | } 128 | } 129 | 130 | remove() { 131 | this.y.remove() 132 | this.m.remove() 133 | this.x.remove() 134 | this.b.remove() 135 | this.elm.remove() 136 | super.remove() 137 | } 138 | } 139 | 140 | export const aLinearToken = (gameObj: GameObject) => (gameObj instanceof LinearToken ? gameObj : null) 141 | -------------------------------------------------------------------------------- /App/src/lib/line.ts: -------------------------------------------------------------------------------- 1 | // Line 2 | // This is a collection of functions related to line segments written by Marcel with help of ChatGPT 3 | 4 | import { isZero } from "./math" 5 | import { Position } from "./types" 6 | import Vec from "./vec" 7 | 8 | interface Line { 9 | a: Position 10 | b: Position 11 | } 12 | 13 | function Line(a: Position, b: Position): Line { 14 | return { a, b } 15 | } 16 | 17 | export default Line 18 | 19 | Line.len = (l: Line) => Vec.dist(l.a, l.b) 20 | 21 | Line.directionVec = (l: Line) => Vec.normalize(Vec.sub(l.b, l.a)) 22 | 23 | // Returns intersection if the line segments overlap, or null if they don't 24 | Line.intersect = (l1: Line, l2: Line): Position | null => { 25 | const { a: p1, b: p2 } = l1 26 | const { a: q1, b: q2 } = l2 27 | 28 | const dx1 = p2.x - p1.x 29 | const dy1 = p2.y - p1.y 30 | const dx2 = q2.x - q1.x 31 | const dy2 = q2.y - q1.y 32 | 33 | const determinant = dx1 * dy2 - dy1 * dx2 34 | if (determinant === 0) { 35 | // The lines are parallel or coincident 36 | return null 37 | } 38 | 39 | const dx3 = p1.x - q1.x 40 | const dy3 = p1.y - q1.y 41 | 42 | const t = (dx3 * dy2 - dy3 * dx2) / determinant 43 | const u = (dx1 * dy3 - dy1 * dx3) / determinant 44 | 45 | if (t >= 0 && t <= 1 && u >= 0 && u <= 1) { 46 | // The segments intersect at a point 47 | const intersectionX = p1.x + t * dx1 48 | const intersectionY = p1.y + t * dy1 49 | return { x: intersectionX, y: intersectionY } 50 | } 51 | 52 | // The segments do not intersect 53 | return null 54 | } 55 | 56 | // Always returns intersection point even if the line segments don't overlap 57 | Line.intersectAnywhere = (l1: Line, l2: Line): Position | null => { 58 | const { a: p1, b: p2 } = l1 59 | const { a: q1, b: q2 } = l2 60 | 61 | const dx1 = p2.x - p1.x 62 | const dy1 = p2.y - p1.y 63 | const dx2 = q2.x - q1.x 64 | const dy2 = q2.y - q1.y 65 | 66 | const determinant = dx1 * dy2 - dy1 * dx2 67 | 68 | if (determinant === 0) { 69 | // The lines are parallel or coincident 70 | return null 71 | } 72 | 73 | const dx3 = p1.x - q1.x 74 | const dy3 = p1.y - q1.y 75 | 76 | const t = (dx3 * dy2 - dy3 * dx2) / determinant 77 | // Alex commented out this line b/c that variable was never used. Bug? 78 | // const u = (dx1 * dy3 - dy1 * dx3) / determinant; 79 | 80 | const intersectionX = p1.x + t * dx1 81 | const intersectionY = p1.y + t * dy1 // should u be used here instead of t? 82 | 83 | return { x: intersectionX, y: intersectionY } 84 | } 85 | 86 | // Get point along slope 87 | // TODO: make this work for vertical lines, too 88 | Line.getYforX = (line: Line, x: number): number => { 89 | // Extract the coordinates of points a and b 90 | const { a, b } = line 91 | const { x: x1, y: y1 } = a 92 | const { x: x2, y: y2 } = b 93 | 94 | // Calculate the slope of the line 95 | const slope = (y2 - y1) / (x2 - x1) 96 | 97 | // Calculate the y-coordinate for the given x-coordinate 98 | const y = slope * (x - x1) + y1 99 | 100 | return y 101 | } 102 | 103 | // Get point along slope 104 | // TODO: make this work for vertical lines, too 105 | Line.getXforY = (line: Line, y: number) => { 106 | // Extract the coordinates of points a and b 107 | const { a, b } = line 108 | const { x: x1, y: y1 } = a 109 | const { x: x2, y: y2 } = b 110 | 111 | // Calculate the slope of the line 112 | const slope = (y2 - y1) / (x2 - x1) 113 | 114 | // Calculate the x-coordinate for the given y-coordinate 115 | const x = (y - y1) / slope + x1 116 | 117 | return x 118 | } 119 | 120 | Line.distToPoint = (line: Line, point: Position) => Vec.dist(point, Line.closestPoint(line, point)) 121 | 122 | Line.closestPoint = (line: Line, point: Position, strict = true) => { 123 | const { a, b } = line 124 | 125 | // Calculate vector AB and AP 126 | const AB = Vec.sub(b, a) 127 | const AP = Vec.sub(point, a) 128 | 129 | // Special case for when a === b, w/o which we get NaNs. 130 | if (isZero(AB.x) && isZero(AB.y)) { 131 | return a 132 | } 133 | 134 | // Calculate the projection of AP onto AB 135 | const projection = Vec.dot(AP, AB) / Vec.dot(AB, AB) 136 | 137 | // Check if the projection is outside the line segment 138 | if (strict && projection <= 0) { 139 | return a 140 | } else if (strict && projection >= 1) { 141 | return b 142 | } else { 143 | return Vec.add(a, Vec.mulS(AB, projection)) 144 | } 145 | } 146 | 147 | Line.spreadPointsAlong = (line: Line, n: number) => { 148 | const segLength = Line.len(line) / n 149 | const offsetSeg = Vec.mulS(Line.directionVec(line), segLength) 150 | const points: Position[] = [] 151 | for (let i = 0; i < n; i++) { 152 | points.push(Vec.add(line.a, Vec.mulS(offsetSeg, i))) 153 | } 154 | return points 155 | } 156 | -------------------------------------------------------------------------------- /App/src/lib/TransformationMatrix.ts: -------------------------------------------------------------------------------- 1 | // Marcel's carefully written Transformation Matrix Library 2 | // There are three types of method: 3 | // Statefull transforms: that change the matrix 4 | // Getters: that return a value 5 | // Transform other things: For transforming points etc. 6 | 7 | import Line from "./line" 8 | import { Position } from "./types" 9 | import Vec from "./vec" 10 | 11 | const DEGREES_TO_RADIANS = Math.PI / 180 12 | const RADIANS_TO_DEGREES = 180 / Math.PI 13 | 14 | export default class TransformationMatrix { 15 | a = 1 16 | b = 0 17 | c = 0 18 | d = 1 19 | e = 0 20 | f = 0 21 | 22 | private constructor() {} 23 | 24 | reset() { 25 | this.a = 1 26 | this.b = 0 27 | this.c = 0 28 | this.d = 1 29 | this.e = 0 30 | this.f = 0 31 | } 32 | 33 | // STATEFUL TRANSFORMS 34 | 35 | transform(a2: number, b2: number, c2: number, d2: number, e2: number, f2: number) { 36 | const { a: a1, b: b1, c: c1, d: d1, e: e1, f: f1 } = this 37 | 38 | this.a = a1 * a2 + c1 * b2 39 | this.b = b1 * a2 + d1 * b2 40 | this.c = a1 * c2 + c1 * d2 41 | this.d = b1 * c2 + d1 * d2 42 | this.e = a1 * e2 + c1 * f2 + e1 43 | this.f = b1 * e2 + d1 * f2 + f1 44 | 45 | return this 46 | } 47 | 48 | rotate(angle: number) { 49 | const cos = Math.cos(angle) 50 | const sin = Math.sin(angle) 51 | this.transform(cos, sin, -sin, cos, 0, 0) 52 | return this 53 | } 54 | 55 | rotateDegrees(angle: number) { 56 | this.rotate(angle * DEGREES_TO_RADIANS) 57 | return this 58 | } 59 | 60 | scale(sx: number, sy: number) { 61 | this.transform(sx, 0, 0, sy, 0, 0) 62 | return this 63 | } 64 | 65 | skew(sx: number, sy: number) { 66 | this.transform(1, sy, sx, 1, 0, 0) 67 | return this 68 | } 69 | 70 | translate(tx: number, ty: number) { 71 | this.transform(1, 0, 0, 1, tx, ty) 72 | return this 73 | } 74 | 75 | flipX() { 76 | this.transform(-1, 0, 0, 1, 0, 0) 77 | return this 78 | } 79 | 80 | flipY() { 81 | this.transform(1, 0, 0, -1, 0, 0) 82 | return this 83 | } 84 | 85 | inverse() { 86 | const { a, b, c, d, e, f } = this 87 | 88 | const dt = a * d - b * c 89 | 90 | this.a = d / dt 91 | this.b = -b / dt 92 | this.c = -c / dt 93 | this.d = a / dt 94 | this.e = (c * f - d * e) / dt 95 | this.f = -(a * f - b * e) / dt 96 | 97 | return this 98 | } 99 | 100 | // GETTERS 101 | 102 | getInverse() { 103 | const { a, b, c, d, e, f } = this 104 | 105 | const m = new TransformationMatrix() 106 | const dt = a * d - b * c 107 | 108 | m.a = d / dt 109 | m.b = -b / dt 110 | m.c = -c / dt 111 | m.d = a / dt 112 | m.e = (c * f - d * e) / dt 113 | m.f = -(a * f - b * e) / dt 114 | 115 | return m 116 | } 117 | 118 | getPosition() { 119 | return { x: this.e, y: this.f } 120 | } 121 | 122 | getRotation() { 123 | const E = (this.a + this.d) / 2 124 | const F = (this.a - this.d) / 2 125 | const G = (this.c + this.b) / 2 126 | const H = (this.c - this.b) / 2 127 | 128 | const a1 = Math.atan2(G, F) 129 | const a2 = Math.atan2(H, E) 130 | 131 | const phi = (a2 + a1) / 2 132 | return -phi * RADIANS_TO_DEGREES 133 | } 134 | 135 | getScale() { 136 | const E = (this.a + this.d) / 2 137 | const F = (this.a - this.d) / 2 138 | const G = (this.c + this.b) / 2 139 | const H = (this.c - this.b) / 2 140 | 141 | const Q = Math.sqrt(E * E + H * H) 142 | const R = Math.sqrt(F * F + G * G) 143 | 144 | return { 145 | scaleX: Q + R, 146 | scaleY: Q - R 147 | } 148 | } 149 | 150 | // TRANSFORM OTHER THINGS 151 | 152 | transformMatrix(m2: TransformationMatrix) { 153 | const { a: a1, b: b1, c: c1, d: d1, e: e1, f: f1 } = this 154 | 155 | const a2 = m2.a 156 | const b2 = m2.b 157 | const c2 = m2.c 158 | const d2 = m2.d 159 | const e2 = m2.e 160 | const f2 = m2.f 161 | 162 | const m = new TransformationMatrix() 163 | m.a = a1 * a2 + c1 * b2 164 | m.b = b1 * a2 + d1 * b2 165 | m.c = a1 * c2 + c1 * d2 166 | m.d = b1 * c2 + d1 * d2 167 | m.e = a1 * e2 + c1 * f2 + e1 168 | m.f = b1 * e2 + d1 * f2 + f1 169 | 170 | return m 171 | } 172 | 173 | transformPoint

(p: P): P { 174 | const { x, y } = p 175 | const { a, b, c, d, e, f } = this 176 | 177 | return { 178 | ...p, // to get the other properties 179 | x: x * a + y * c + e, 180 | y: x * b + y * d + f 181 | } 182 | } 183 | 184 | transformLine(l2: Line): Line { 185 | return { 186 | a: this.transformPoint(l2.a), 187 | b: this.transformPoint(l2.b) 188 | } 189 | } 190 | 191 | // factory methods 192 | 193 | static identity(): TransformationMatrix { 194 | return new TransformationMatrix() 195 | } 196 | 197 | static fromLineTranslateRotate(a: Position, b: Position) { 198 | const line = Vec.sub(b, a) 199 | 200 | const m = new TransformationMatrix() 201 | m.translate(a.x, a.y) 202 | m.rotate(Vec.angle(line)) 203 | return m 204 | } 205 | 206 | static fromLine(a: Position, b: Position) { 207 | const line = Vec.sub(b, a) 208 | const length = Vec.len(line) 209 | 210 | const m = new TransformationMatrix() 211 | m.translate(a.x, a.y) 212 | m.rotate(Vec.angle(line)) 213 | m.scale(length, length) 214 | 215 | return m 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /App/src/app/gui/MetaToggle.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "../../lib/types" 2 | import SVG from "../Svg" 3 | import { GameObject } from "../GameObject" 4 | import Vec from "../../lib/vec" 5 | import { TAU, lerpN, rand, randInt } from "../../lib/math" 6 | 7 | const radius = 20 8 | const padding = 30 9 | 10 | export type SerializedMetaToggle = { 11 | type: "MetaToggle" 12 | position: Position 13 | } 14 | 15 | export const aMetaToggle = (gameObj: GameObject) => (gameObj instanceof MetaToggle ? gameObj : null) 16 | 17 | type Splat = { 18 | elm: SVGElement 19 | delay: number 20 | translate: number 21 | rotate: number 22 | squish: number 23 | flip: string 24 | } 25 | 26 | function randomAngles() { 27 | const angles: number[] = [] 28 | for (let i = 0; i < rand(5, 8); i++) { 29 | angles.push(rand(0, 360)) 30 | } 31 | return angles 32 | } 33 | 34 | function randomSplat(angle: number) { 35 | const ran = rand(0, 1) 36 | const curve = ran ** 6 37 | return { 38 | delay: ran * 0.17, 39 | translate: 12 / lerpN(curve, 1, 0.5) ** 2, 40 | rotate: curve < 0.1 ? rand(0, 360) : angle, 41 | squish: rand(0, 0.7) * curve, 42 | flip: rand() < 0 ? "normal" : "reverse" 43 | } 44 | } 45 | 46 | export default class MetaToggle extends GameObject { 47 | static active = false 48 | 49 | static toggle(doToggle = !MetaToggle.active) { 50 | MetaToggle.active = doToggle 51 | document.documentElement.toggleAttribute("meta-mode", MetaToggle.active) 52 | } 53 | 54 | private readonly element: SVGElement 55 | private splats: Splat[] = [] 56 | dragging = false 57 | active = false 58 | 59 | serialize(): SerializedMetaToggle { 60 | return { 61 | type: "MetaToggle", 62 | position: this.position 63 | } 64 | } 65 | 66 | static deserialize(v: SerializedMetaToggle): MetaToggle { 67 | return new MetaToggle(v.position) 68 | } 69 | 70 | constructor(public position = { x: padding, y: padding }) { 71 | super() 72 | 73 | this.element = SVG.add("g", SVG.guiElm, { 74 | ...this.getAttrs() // This avoids an unstyled flash on first load 75 | }) 76 | 77 | SVG.add("circle", this.element, { class: "outer", r: radius }) 78 | SVG.add("circle", this.element, { class: "inner", r: radius }) 79 | const splatsElm = SVG.add("g", this.element, { class: "splats" }) 80 | 81 | const angles = randomAngles() 82 | 83 | for (let i = 0; i < 50; i++) { 84 | const points: Position[] = [] 85 | const steps = 5 86 | for (let s = 0; s < steps; s++) { 87 | const a = (s / steps) * TAU 88 | const d = rand(0, 4) 89 | points.push(Vec.polar(a, d)) 90 | } 91 | points[steps] = points[0] 92 | const splat: Splat = { 93 | elm: SVG.add("g", splatsElm, { 94 | class: "splat" 95 | }), 96 | ...randomSplat(angles[randInt(0, angles.length - 1)]) 97 | } 98 | this.splats.push(splat) 99 | SVG.add("polyline", splat.elm, { points: SVG.points(points) }) 100 | } 101 | SVG.add("circle", this.element, { class: "secret", r: radius }) 102 | this.resplat() 103 | this.snapToCorner() 104 | } 105 | 106 | resplat() { 107 | const angles = randomAngles() 108 | this.splats.forEach((splat) => { 109 | const s = randomSplat(angles[randInt(0, angles.length - 1)]) 110 | splat.translate = s.translate 111 | splat.rotate = s.rotate 112 | splat.squish = s.squish 113 | SVG.update(splat.elm, { 114 | style: ` 115 | --delay: ${splat.delay}s; 116 | --translate: ${splat.translate}px; 117 | --rotate: ${splat.rotate}deg; 118 | --scaleX: ${1 + splat.squish}; 119 | --scaleY: ${1 - splat.squish}; 120 | --flip: ${splat.flip}; 121 | ` 122 | }) 123 | }) 124 | } 125 | 126 | distanceToPoint(point: Position) { 127 | return Vec.dist(this.position, point) 128 | } 129 | 130 | dragTo(position: Position) { 131 | this.dragging = true 132 | this.position = position 133 | } 134 | 135 | remove() { 136 | this.element.remove() 137 | } 138 | 139 | snapToCorner() { 140 | this.dragging = false 141 | 142 | const windowSize = Vec(window.innerWidth, window.innerHeight) 143 | 144 | // x and y will be exactly 0 or 1 145 | const normalizedPosition = Vec.round(Vec.div(this.position, windowSize)) 146 | 147 | // x and y will be exactly in a screen corner 148 | const cornerPosition = Vec.mul(normalizedPosition, windowSize) 149 | 150 | // x and y will be exactly 1 (left&top) or -1 (right&bottom) 151 | const sign = Vec.addS(Vec.mulS(normalizedPosition, -2), 1) 152 | 153 | // Inset from the corner 154 | this.position = Vec.add(cornerPosition, Vec.mulS(sign, padding)) 155 | 156 | this.resplat() 157 | } 158 | 159 | private getAttrs() { 160 | const classes: string[] = ["meta-toggle"] 161 | 162 | if (MetaToggle.active) { 163 | classes.push("active") 164 | } 165 | 166 | if (this.dragging) { 167 | classes.push("dragging") 168 | } 169 | 170 | return { 171 | class: classes.join(" "), 172 | style: `translate: ${this.position.x}px ${this.position.y}px` 173 | } 174 | } 175 | 176 | render() { 177 | if (this.active != MetaToggle.active) { 178 | this.active = MetaToggle.active 179 | this.resplat() 180 | } 181 | 182 | SVG.update(this.element, this.getAttrs()) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /App/src/lib/fit.ts: -------------------------------------------------------------------------------- 1 | import Arc from "./arc" 2 | import Line from "./line" 3 | import { Position } from "./types" 4 | import Vec from "./vec" 5 | 6 | export interface LineFit { 7 | type: "line" 8 | line: Line 9 | fitness: number 10 | length: number 11 | } 12 | 13 | function line(stroke: Position[]): LineFit | null { 14 | if (stroke.length === 0) { 15 | return null 16 | } 17 | 18 | const line = Line(Vec.clone(stroke[0]), Vec.clone(stroke[stroke.length - 1])) 19 | 20 | let totalDist = 0 21 | for (let i = 1; i < stroke.length - 1; i++) { 22 | totalDist += Line.distToPoint(line, stroke[i]) 23 | } 24 | 25 | const length = Line.len(line) 26 | 27 | return { 28 | type: "line", 29 | line, 30 | length, 31 | fitness: length === 0 ? 1 : totalDist / length 32 | } 33 | } 34 | 35 | export interface ArcFit { 36 | type: "arc" 37 | arc: Arc 38 | fitness: number 39 | length: number 40 | } 41 | 42 | function arc(points: Position[]): ArcFit | null { 43 | if (points.length < 3) { 44 | return null 45 | } 46 | 47 | const simplified = innerTriangle(points) 48 | const [a, b, c] = simplified 49 | 50 | if (!b) { 51 | return null 52 | } 53 | 54 | const { x: x1, y: y1 } = a 55 | const { x: x2, y: y2 } = b 56 | const { x: x3, y: y3 } = c 57 | 58 | const D = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) 59 | const centerX = 60 | ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / D 61 | const centerY = 62 | ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / D 63 | const radius = Math.sqrt((x1 - centerX) * (x1 - centerX) + (y1 - centerY) * (y1 - centerY)) 64 | 65 | const startAngle = Math.atan2(y1 - centerY, x1 - centerX) 66 | const endAngle = Math.atan2(y3 - centerY, x3 - centerX) 67 | 68 | // Compute winding order 69 | const ab = Vec.sub(a, b) 70 | const bc = Vec.sub(b, c) 71 | const clockwise = Vec.cross(ab, bc) > 0 72 | 73 | const arc = Arc(Vec(centerX, centerY), radius, startAngle, endAngle, clockwise) 74 | 75 | // Compute fitness 76 | const arcDist = Arc.len(arc) 77 | 78 | let totalDist = 0 79 | for (const p of points) { 80 | totalDist += Arc.distToPointCircle(arc, p) 81 | } 82 | 83 | return { 84 | type: "arc", 85 | arc, 86 | fitness: totalDist / arcDist, 87 | length: arcDist 88 | } 89 | } 90 | 91 | function innerTriangle(points: Position[]): [Position, Position, Position] { 92 | const start = points[0] 93 | const end = points[points.length - 1] 94 | 95 | let largestDistance = -1 96 | let farthestIndex = -1 97 | 98 | for (let i = 0; i < points.length; i++) { 99 | const point = points[i] 100 | const dist = Line.distToPoint(Line(start, end), point) 101 | if (dist > largestDistance) { 102 | largestDistance = dist 103 | farthestIndex = i 104 | } 105 | } 106 | 107 | return [start, points[farthestIndex], end] 108 | } 109 | 110 | interface Circle { 111 | center: Position 112 | radius: number 113 | startAngle: number 114 | endAngle: number 115 | clockwise: boolean 116 | } 117 | 118 | export interface CircleFit { 119 | type: "circle" 120 | circle: Circle 121 | fitness: number 122 | } 123 | 124 | function circle(points: Position[]): CircleFit | null { 125 | if (points.length < 3) { 126 | return null 127 | } 128 | 129 | // Do a basic circular regression 130 | const n = points.length 131 | let sumX = 0 132 | let sumY = 0 133 | let sumX2 = 0 134 | let sumY2 = 0 135 | let sumXY = 0 136 | let sumX3 = 0 137 | let sumY3 = 0 138 | let sumXY2 = 0 139 | let sumX2Y = 0 140 | 141 | for (const point of points) { 142 | const { x, y } = point 143 | sumX += x 144 | sumY += y 145 | sumX2 += x * x 146 | sumY2 += y * y 147 | sumXY += x * y 148 | sumX3 += x * x * x 149 | sumY3 += y * y * y 150 | sumXY2 += x * y * y 151 | sumX2Y += x * x * y 152 | } 153 | 154 | const C = n * sumX2 - sumX * sumX 155 | const D = n * sumXY - sumX * sumY 156 | const E = n * sumX3 + n * sumXY2 - (sumX2 + sumY2) * sumX 157 | const G = n * sumY2 - sumY * sumY 158 | const H = n * sumX2Y + n * sumY3 - (sumX2 + sumY2) * sumY 159 | 160 | const a = (H * D - E * G) / (C * G - D * D) 161 | const b = (H * C - E * D) / (D * D - G * C) 162 | const c = -(a * sumX + b * sumY + sumX2 + sumY2) / n 163 | 164 | // Construct circle 165 | const center = Vec(-a / 2, -b / 2) 166 | const radius = Math.sqrt(center.x * center.x + center.y * center.y - c) 167 | 168 | // Compute angles 169 | const startAngle = Math.atan2(points[0].y - center.y, points[0].x - center.x) 170 | const endAngle = Math.atan2(points[points.length - 1].y - center.y, points[points.length - 1].x - center.x) 171 | 172 | // Determine winding order 173 | // Compute winding order 174 | const ab = Vec.sub(points[0], points[1]) 175 | const bc = Vec.sub(points[1], points[2]) 176 | const clockwise = Vec.cross(ab, bc) > 0 177 | 178 | const circle = { center, radius, startAngle, endAngle, clockwise } 179 | 180 | // check fitness 181 | let totalDist = 0 182 | for (const p of points) { 183 | totalDist += Arc.distToPointCircle(circle, p) 184 | } 185 | const circumference = 2 * Math.PI * radius 186 | const fitness = totalDist / circumference 187 | 188 | return { type: "circle", circle, fitness } 189 | } 190 | 191 | export default { 192 | line, 193 | arc, 194 | circle 195 | } 196 | -------------------------------------------------------------------------------- /App/src/app/Input.ts: -------------------------------------------------------------------------------- 1 | import Config from "./Config" 2 | import { EventContext, Gesture } from "./Gesture" 3 | import { emptySpaceCreateGizmoOrLinear, emptySpaceDrawInk, emptySpaceEatLead, metaDrawInk } from "./gestures/EmptySpace" 4 | import { erase } from "./gestures/Erase" 5 | import { gizmoCycleConstraints } from "./gestures/Gizmo" 6 | import { handleBreakOff, handleCreateGizmo, handleGoAnywhere, handleMoveOrTogglePin } from "./gestures/Handle" 7 | import { metaToggleFingerActions, metaToggleIgnorePencil } from "./gestures/MetaToggle" 8 | import { penToggleFingerActions } from "./gestures/PenToggle" 9 | import { pluggableCreateWire } from "./gestures/Pluggable" 10 | import { edgeSwipe } from "./gestures/Preset" 11 | import { strokeAddHandles } from "./gestures/Stroke" 12 | import { strokeGroupRemoveHandles } from "./gestures/StrokeGroup" 13 | import { numberTokenScrub, tokenMoveOrToggleConstraint } from "./gestures/Token" 14 | import { Event, TouchId } from "./NativeEvents" 15 | import SVG from "./Svg" 16 | 17 | const gestureCreators = { 18 | finger: [ 19 | penToggleFingerActions, 20 | metaToggleFingerActions, 21 | // 22 | handleGoAnywhere, 23 | numberTokenScrub, 24 | handleBreakOff, 25 | // 26 | tokenMoveOrToggleConstraint, 27 | handleMoveOrTogglePin, 28 | gizmoCycleConstraints, 29 | // 30 | edgeSwipe, 31 | // 32 | strokeGroupRemoveHandles, 33 | strokeAddHandles 34 | ], 35 | pencil: [ 36 | penToggleFingerActions, 37 | metaToggleIgnorePencil, 38 | erase, 39 | // 40 | emptySpaceEatLead, 41 | pluggableCreateWire, 42 | handleCreateGizmo, 43 | // 44 | // metaDrawInk, 45 | emptySpaceCreateGizmoOrLinear, 46 | emptySpaceDrawInk 47 | ] 48 | } 49 | 50 | const pseudoTouches: Record = {} 51 | const gesturesByTouchId: Record = {} 52 | 53 | // This function is called by NativeEvent (via App) once for every event sent from Swift. 54 | export function applyEvent(ctx: EventContext) { 55 | // Terminology: 56 | // Event — a single finger or pencil event sent to us from Swift, either "began", "moved", or "ended". 57 | // Touch — a series of finger or pencil events (from "began" to "ended) with a consistent TouchId. 58 | // Gesture — a class instance that "claims" one or more touches and then receives all their events. 59 | // Gesture Creator — a function that looks at a "began" event to decide whether to create a new Gesture for it. 60 | // Pseudo — a finger touch that's not claimed by any gesture 61 | // Pseudo Gesture — a gesture that's only created when some pseudo touches exist. 62 | 63 | // Key Assumption #1: The pencil will always match a gesture. 64 | // Key Assumption #2: A finger will always match a gesture or become a pseudo. 65 | 66 | // STEP ZERO — Update existing pseudo touches, or prepare pseudo-related state. 67 | if (pseudoTouches[ctx.event.id]) { 68 | if (ctx.event.state === "ended") { 69 | delete pseudoTouches[ctx.event.id] 70 | } else { 71 | pseudoTouches[ctx.event.id] = ctx.event 72 | } 73 | return 74 | } 75 | ctx.pseudoTouches = pseudoTouches 76 | ctx.pseudoCount = Object.keys(pseudoTouches).length + ctx.events.forcePseudo 77 | ctx.pseudo = ctx.pseudoCount > 0 78 | 79 | // STEP ONE — Try to match this event to a gesture that previously claimed this touch. 80 | const gestureForTouch = gesturesByTouchId[ctx.event.id] 81 | if (gestureForTouch) { 82 | runGesture(gestureForTouch, ctx) 83 | if (ctx.event.state === "ended") { 84 | delete gesturesByTouchId[ctx.event.id] 85 | } 86 | return 87 | } 88 | 89 | // Key Assumption #3: every touch is claimed by a gesture or pseudo right from the "began". 90 | // So if we didn't match an existing gesture/pseudo above, and the event isn't a "began", we're done. 91 | if (ctx.event.state !== "began") { 92 | return 93 | } 94 | 95 | // STEP TWO — see if any existing gestures want to claim this new touch. 96 | // (There's no sense of priority here; gestures are checked in creation order. Might need to revise this.) 97 | for (const id in gesturesByTouchId) { 98 | const gesture = gesturesByTouchId[id] 99 | if (gesture.claimsTouch(ctx)) { 100 | gesturesByTouchId[ctx.event.id] = gesture 101 | runGesture(gesture, ctx) 102 | return 103 | } 104 | } 105 | 106 | // STEP THREE — try to create a new gesture for this touch. 107 | for (const gestureCreator of gestureCreators[ctx.event.type]) { 108 | const gesture = gestureCreator(ctx) 109 | if (gesture) { 110 | gesturesByTouchId[ctx.event.id] = gesture 111 | runGesture(gesture, ctx) 112 | return 113 | } 114 | } 115 | 116 | // STEP FOUR — track this touch as a candidate for a pseudo-mode. 117 | if (ctx.event.type === "finger") { 118 | pseudoTouches[ctx.event.id] = ctx.event 119 | return 120 | } 121 | 122 | // If we made it here and the touch hasn't been handled… so be it. 123 | } 124 | 125 | function runGesture(gesture: Gesture, ctx: EventContext) { 126 | const result = gesture.applyEvent(ctx) 127 | 128 | if (result instanceof Gesture) { 129 | // Replace the old gesture with the new gesture 130 | for (const id in gesturesByTouchId) { 131 | if (gesturesByTouchId[id] === gesture) { 132 | gesturesByTouchId[id] = result 133 | } 134 | } 135 | // Run the new gesture immediately 136 | runGesture(result, ctx) 137 | } 138 | } 139 | 140 | export function render() { 141 | for (const id in gesturesByTouchId) { 142 | gesturesByTouchId[id].render() 143 | } 144 | 145 | if (Config.presentationMode) { 146 | for (const id in gesturesByTouchId) { 147 | gesturesByTouchId[id].debugRender() 148 | } 149 | 150 | for (const id in pseudoTouches) { 151 | const event = pseudoTouches[id] 152 | SVG.now("circle", { 153 | class: "pseudo-touch", 154 | cx: event.position.x, 155 | cy: event.position.y, 156 | r: 16 157 | }) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /App/src/app/gestures/Pluggable.ts: -------------------------------------------------------------------------------- 1 | import { EventContext, Gesture } from "../Gesture" 2 | import NumberToken, { aNumberToken } from "../meta/NumberToken" 3 | import MetaToggle from "../gui/MetaToggle" 4 | import PropertyPicker, { aPropertyPicker } from "../meta/PropertyPicker" 5 | import { Connection } from "../meta/Pluggable" 6 | import Gizmo, { aGizmo } from "../meta/Gizmo" 7 | import Wire from "../meta/Wire" 8 | import { Variable } from "../Constraints" 9 | import Vec from "../../lib/vec" 10 | import { GameObject } from "../GameObject" 11 | import SVG from "../Svg" 12 | import * as constraints from "../Constraints" 13 | import { Root } from "../Root" 14 | 15 | export function pluggableCreateWire(ctx: EventContext): Gesture | void { 16 | if (MetaToggle.active) { 17 | const near = ctx.event.position 18 | 19 | const numberToken = ctx.root.find({ what: aNumberToken, near }) 20 | if (numberToken) return maybeCreateWire({ obj: numberToken, plugId: "center", variableId: "value" }) 21 | 22 | const propertyPicker = ctx.root.find({ what: aPropertyPicker, near }) 23 | if (propertyPicker) return maybeCreateWire({ obj: propertyPicker, plugId: "output", variableId: "value" }) 24 | 25 | // We can't use `near` because Gizmo's distance is calculated to the line, not just the center 26 | const gizmo = ctx.root.find({ what: aGizmo, that: (g) => g.centerDistanceToPoint(ctx.event.position) < 30 }) 27 | if (gizmo) return maybeCreateWire({ obj: gizmo, plugId: "center", variableId: "distance" }) 28 | } 29 | } 30 | 31 | const maybeCreateWire = (from: Connection): Gesture => 32 | new Gesture("Maybe Create Wire", { dragged: (ctx) => createWire(from, ctx) }) 33 | 34 | function createWire(from: Connection, ctx: EventContext): Gesture { 35 | const wire = new Wire(from) 36 | ctx.root.adopt(wire) 37 | 38 | return new Gesture("Create Wire", { 39 | moved(ctx) { 40 | wire.toPosition = ctx.event.position 41 | }, 42 | 43 | ended(ctx) { 44 | const near = ctx.event.position 45 | const that = (go: GameObject) => go !== from.obj 46 | 47 | // Wire from NumberToken or PropertyPicker 48 | if (from.obj instanceof NumberToken || from.obj instanceof PropertyPicker) { 49 | // Wire to NumberToken 50 | const numberToken = ctx.root.find({ what: aNumberToken, that, near }) as NumberToken | null 51 | if (numberToken) return attachWire(wire, { obj: numberToken, plugId: "center", variableId: "value" }) 52 | 53 | // Wire to PropertyPicker 54 | const propertyPicker = ctx.root.find({ what: aPropertyPicker, that, near }) as PropertyPicker | null 55 | if (propertyPicker) return attachWire(wire, { obj: propertyPicker, plugId: "output", variableId: "value" }) 56 | 57 | // Wire to Empty Space 58 | return createNumberToken(ctx, wire) 59 | } 60 | 61 | // Wire from Gizmo 62 | if (from.obj instanceof Gizmo) { 63 | // Wire to Gizmo 64 | const fromGizmo = from.obj 65 | const toGizmo = ctx.root.find({ what: aGizmo, that, near, tooFar: 30 }) as Gizmo | null 66 | if (toGizmo) { 67 | // Prevent the Gizmo we're wiring from from moving 68 | const preLengthLock = fromGizmo.distance.isLocked 69 | const preAngleLock = fromGizmo.angleInDegrees.isLocked 70 | if (!preLengthLock) fromGizmo.distance.lock() 71 | if (!preAngleLock) fromGizmo.angleInDegrees.lock() 72 | 73 | // Make a second wire for the angle 74 | const angleFrom: Connection = { obj: fromGizmo, plugId: "center", variableId: "angleInDegrees" } 75 | const angleTo: Connection = { obj: toGizmo, plugId: "center", variableId: "angleInDegrees" } 76 | attachWire(ctx.root.adopt(new Wire(angleFrom)), angleTo) 77 | 78 | // Attach the distance wire 79 | attachWire(wire, { obj: toGizmo, plugId: "center", variableId: "distance" }) 80 | 81 | constraints.solve(Root.current) 82 | 83 | if (!preLengthLock) fromGizmo.distance.unlock() 84 | if (!preAngleLock) fromGizmo.angleInDegrees.unlock() 85 | 86 | return 87 | } 88 | 89 | // Wire to Empty Space 90 | return createPropertyPicker(ctx, wire, from.obj) 91 | } 92 | 93 | throw new Error("Dunno how we even") 94 | } 95 | }) 96 | } 97 | 98 | function createPropertyPicker(ctx: EventContext, wire: Wire, fromObj: Gizmo) { 99 | const distValue = fromObj.plugVars.distance.value 100 | const distPicker = ctx.root.adopt(PropertyPicker.create("distance", distValue)) 101 | distPicker.position = Vec.add(ctx.event.position, Vec(0, 10)) 102 | attachWire(wire, { obj: distPicker, plugId: "input", variableId: "value" }) 103 | 104 | // Make a second wire 105 | const angleFrom: Connection = { obj: fromObj, plugId: "center", variableId: "angleInDegrees" } 106 | const angleValue = fromObj.plugVars.angleInDegrees.value 107 | const anglePicker = ctx.root.adopt(PropertyPicker.create("angleInDegrees", angleValue)) 108 | anglePicker.position = Vec.add(ctx.event.position, Vec(0, -30)) 109 | const angleTo: Connection = { obj: anglePicker, plugId: "input", variableId: "value" } 110 | attachWire(ctx.root.adopt(new Wire(angleFrom)), angleTo) 111 | } 112 | 113 | function createNumberToken(ctx: EventContext, wire: Wire) { 114 | const n = ctx.root.adopt(NumberToken.create()) 115 | attachWire(wire, { obj: n, plugId: "center", variableId: "value" }) 116 | // Force a render, which computes the token width 117 | n.render(0, 0) 118 | // Position the token so that it's centered on the pencil 119 | n.position = Vec.sub(ctx.event.position, Vec.half(Vec(n.width, n.height))) 120 | // Re-add the wire, so it renders after the token (avoids a flicker) 121 | ctx.root.adopt(wire) 122 | } 123 | 124 | function attachWire(wire: Wire, to: Connection) { 125 | // A wire between two single variables 126 | const from = wire.a 127 | const a = from.obj.plugVars[from.variableId] as Variable 128 | const b = to.obj.plugVars[to.variableId] as Variable 129 | 130 | wire.attachEnd(to) 131 | wire.constraint = constraints.equals(b, a) 132 | } 133 | -------------------------------------------------------------------------------- /App/src/app/Svg.ts: -------------------------------------------------------------------------------- 1 | import { clip } from "../lib/math" 2 | import { Position, PositionWithPressure } from "../lib/types" 3 | import Vec from "../lib/vec" 4 | 5 | type Attributes = Record 6 | 7 | const NS = "http://www.w3.org/2000/svg" 8 | 9 | const gizmoElm = document.querySelector("#gizmo") as SVGSVGElement 10 | const handleElm = document.querySelector("#handle") as SVGSVGElement 11 | const inkElm = document.querySelector("#ink") as SVGSVGElement 12 | const constraintElm = document.querySelector("#constraint") as SVGSVGElement 13 | const boxElm = document.querySelector("#box") as SVGSVGElement 14 | const wiresElm = document.querySelector("#wires") as SVGSVGElement 15 | const metaElm = document.querySelector("#meta") as SVGSVGElement 16 | const labelElm = document.querySelector("#label") as SVGSVGElement 17 | const guiElm = document.querySelector("#gui") as SVGSVGElement 18 | const nowElm = document.querySelector("#now") as SVGGElement 19 | 20 | function add(type: "text", parent: SVGElement, attributes?: Attributes): SVGTextElement 21 | function add(type: string, parent: SVGElement, attributes?: Attributes): SVGElement 22 | function add(type: string, parent: SVGElement, attributes: Attributes = {}) { 23 | return parent.appendChild(update(document.createElementNS(NS, type), attributes)) 24 | } 25 | 26 | /** 27 | * Use the sugar attribute `content` to set innerHTML. 28 | * E.g.: SVG.update(myTextElm, { content: 'hello' }) 29 | */ 30 | function update(elm: T, attributes: Attributes) { 31 | Object.entries(attributes).forEach(([key, value]) => { 32 | const cache = ((elm as any).__cache ||= {}) 33 | if (cache[key] === value) { 34 | return 35 | } 36 | cache[key] = value 37 | 38 | const boolish = typeof value === "boolean" || value === null || value === undefined 39 | 40 | if (key === "content") { 41 | elm.innerHTML = "" + value 42 | } else if (boolish) { 43 | value ? elm.setAttribute(key, "") : elm.removeAttribute(key) 44 | } else { 45 | elm.setAttribute(key, "" + value) 46 | } 47 | }) 48 | return elm 49 | } 50 | 51 | // Store the current time whenever SVG.clearNow() is called, so that elements 52 | // created by SVG.now() will live for a duration relative to that time. 53 | let lastTime = 0 54 | 55 | /** 56 | * Puts an element on the screen for a brief moment, after which it's automatically deleted. 57 | * This allows for immediate-mode rendering — super useful for debug visuals. 58 | * By default, elements are removed whenever SVG.clearNow() is next called (typically every frame). 59 | * Include a `life` attribute to specify a minimum duration until the element is removed. 60 | */ 61 | function now(type: string, attributes: Attributes) { 62 | const life = +(attributes.life || 0) 63 | delete attributes.life 64 | 65 | const elm = add(type, nowElm, attributes) 66 | 67 | ;(elm as any).__expiry = lastTime + life 68 | 69 | return elm 70 | } 71 | 72 | /** 73 | * Called every frame by App, but feel free to call it more frequently if needed 74 | * (E.g.: at the top of a loop body, so that only elements from the final iteration are shown). 75 | * Passing `currentTime` allows elements with a "life" to not be cleared until their time has passed. 76 | */ 77 | function clearNow(currentTime = Infinity) { 78 | if (isFinite(currentTime)) { 79 | lastTime = currentTime 80 | } 81 | 82 | for (const elm of Array.from(nowElm.children)) { 83 | const expiry = (elm as any).__expiry || 0 84 | if (currentTime > expiry) { 85 | elm.remove() 86 | } 87 | } 88 | } 89 | 90 | /** 91 | * Helps you build a polyline from Positions (or arrays of Positions). 92 | * E.g.: SVG.now('polyline', { points: SVG.points(stroke.points), stroke: '#00F' }); 93 | * E.g.: SVG.now('polyline', { points: SVG.points(pos1, pos2, posArr), stroke: '#F00' }); 94 | */ 95 | function points(...positions: Array) { 96 | return positions.flat().map(positionToPointsString).join(" ") 97 | } 98 | 99 | // TODO: This function is probably the #1 perf hotspot in the codebase. 100 | function positionToPointsString(p: Position) { 101 | return p.x + " " + p.y 102 | } 103 | 104 | /** Returns a `translate(x y)` string that can be used for the 'transform' attribute. */ 105 | function positionToTransform(p: Position) { 106 | return `translate(${p.x} ${p.y})` 107 | } 108 | 109 | /** 110 | * Helps you build the path for a semicircular arc, which is normally a huge pain. 111 | * NB: Can only draw up to a half circle when mirror is false. 112 | */ 113 | function arcPath( 114 | center: Position, // Center of the (semi)circle 115 | radius: number, // Radius of the (semi)circle 116 | angle: number, // Direction to start the arc. Radians, 0 is rightward. 117 | rotation: number, // Arc size of the (semi)circle. 0 to PI radians. 118 | mirror = true // Mirror the arc across the start. Required to draw more than a half-circle. 119 | ) { 120 | // Values outside this range produce nonsense arcs 121 | rotation = clip(rotation, -Math.PI, Math.PI) 122 | 123 | const S = Vec.add(center, Vec.polar(angle, radius)) 124 | let path = "" 125 | 126 | if (mirror) { 127 | const B = Vec.add(center, Vec.polar(angle - rotation, radius)) 128 | path += `M ${B.x}, ${B.y} A ${radius},${radius} 0 0,1 ${S.x}, ${S.y}` 129 | } else { 130 | path += `M ${S.x}, ${S.y}` 131 | } 132 | 133 | const A = Vec.add(center, Vec.polar(angle + rotation, radius)) 134 | path += `A ${radius},${radius} 0 0,1 ${A.x}, ${A.y}` 135 | 136 | return path 137 | } 138 | 139 | /** Returns a string that can be used as the 'd' attribute of an SVG path element. */ 140 | function path(points: Position[] | PositionWithPressure[]) { 141 | return points.map((p, idx) => `${idx === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ") 142 | } 143 | 144 | const statusElement = add("text", guiElm, { class: "status-text" }) 145 | 146 | let statusHideTimeMillis = 0 147 | 148 | function showStatus(content: string, time = 3_000) { 149 | update(statusElement, { content, "is-visible": true }) 150 | statusHideTimeMillis = performance.now() + time 151 | setTimeout(() => { 152 | if (performance.now() >= statusHideTimeMillis) { 153 | update(statusElement, { "is-visible": false }) 154 | } 155 | }, time) 156 | } 157 | 158 | export default { 159 | add, 160 | update, 161 | now, 162 | clearNow, 163 | points, 164 | positionToTransform, 165 | arcPath, 166 | path, 167 | showStatus, 168 | gizmoElm, 169 | handleElm, 170 | inkElm, 171 | constraintElm, 172 | boxElm, 173 | wiresElm, 174 | metaElm, 175 | labelElm, 176 | guiElm 177 | } 178 | -------------------------------------------------------------------------------- /App/src/lib/vec.ts: -------------------------------------------------------------------------------- 1 | // Vec 2 | // This is a port of (part of) Ivan's homemade CoffeeScript vector library. 3 | 4 | import { isZero, roundTo } from "./math" 5 | import { Position } from "./types" 6 | 7 | export interface Vector { 8 | x: number 9 | y: number 10 | } 11 | 12 | // Constructors /////////////////////////////////////////////////////////////// 13 | 14 | const Vec = (x = 0, y = 0): Vector => ({ x, y }) 15 | export default Vec 16 | 17 | Vec.clone = (v: Vector) => Vec(v.x, v.y) 18 | 19 | Vec.of = (s: number) => Vec(s, s) 20 | 21 | Vec.random = (scale = 1) => Vec.Smul(scale, Vec.complement(Vec.Smul(2, Vec(Math.random(), Math.random())))) 22 | 23 | Vec.toA = (v: Vector) => [v.x, v.y] 24 | 25 | Vec.polar = (angle: number, length: number) => Vec(length * Math.cos(angle), length * Math.sin(angle)) 26 | 27 | // Static Vectors ///////////////////////////////////////////////////////////// 28 | 29 | Vec.x = Object.freeze(Vec(1)) 30 | Vec.y = Object.freeze(Vec(0, 1)) 31 | Vec.zero = Object.freeze(Vec()) 32 | 33 | // FP ///////////////////////////////////////////////////////////////////////// 34 | 35 | Vec.map = (f: (x: number) => number, v: Vector) => Vec(f(v.x), f(v.y)) 36 | 37 | Vec.map2 = (f: (x: number, y: number) => number, a: Vector, b: Vector) => Vec(f(a.x, b.x), f(a.y, b.y)) 38 | 39 | Vec.reduce = (f: (x: number, y: number) => number, v: Vector) => f(v.x, v.y) 40 | 41 | // Vector Algebra ///////////////////////////////////////////////////////////// 42 | 43 | // Not really cross product, but close enough 44 | Vec.cross = (a: Vector, b: Vector) => a.x * b.y - a.y * b.x 45 | 46 | Vec.project = (a: Vector, b: Vector) => Vec.mulS(b, Vec.dot(a, b) / Vec.len2(b)) 47 | 48 | Vec.reject = (a: Vector, b: Vector) => Vec.sub(a, Vec.project(a, b)) 49 | 50 | Vec.scalarProjection = (p: Position, a: Vector, b: Vector): Position => { 51 | const ap = Vec.sub(p, a) 52 | const ab = Vec.normalize(Vec.sub(b, a)) 53 | const f = Vec.mulS(ab, Vec.dot(ap, ab)) 54 | return Vec.add(a, f) 55 | } 56 | 57 | // Piecewise Vector Arithmetic //////////////////////////////////////////////// 58 | 59 | Vec.add = (a: Vector, b: Vector) => Vec(a.x + b.x, a.y + b.y) 60 | Vec.div = (a: Vector, b: Vector) => Vec(a.x / b.x, a.y / b.y) 61 | Vec.mul = (a: Vector, b: Vector) => Vec(a.x * b.x, a.y * b.y) 62 | Vec.sub = (a: Vector, b: Vector) => Vec(a.x - b.x, a.y - b.y) 63 | 64 | // Vector-Scalar Arithmetic /////////////////////////////////////////////////// 65 | 66 | Vec.addS = (v: Vector, s: number) => Vec.add(v, Vec.of(s)) 67 | Vec.divS = (v: Vector, s: number) => Vec.div(v, Vec.of(s)) 68 | Vec.mulS = (v: Vector, s: number) => Vec.mul(v, Vec.of(s)) 69 | Vec.subS = (v: Vector, s: number) => Vec.sub(v, Vec.of(s)) 70 | 71 | // Scalar-Vector Arithmetic /////////////////////////////////////////////////// 72 | 73 | Vec.Sadd = (s: number, v: Vector) => Vec.add(Vec.of(s), v) 74 | Vec.Sdiv = (s: number, v: Vector) => Vec.div(Vec.of(s), v) 75 | Vec.Smul = (s: number, v: Vector) => Vec.mul(Vec.of(s), v) 76 | Vec.Ssub = (s: number, v: Vector) => Vec.sub(Vec.of(s), v) 77 | 78 | // Measurement //////////////////////////////////////////////////////////////// 79 | 80 | Vec.dist = (a: Vector, b: Vector) => Vec.len(Vec.sub(a, b)) 81 | 82 | // Strongly recommend using Vec.dist instead of Vec.dist2 (distance-squared) 83 | Vec.dist2 = (a: Vector, b: Vector) => Vec.len2(Vec.sub(a, b)) 84 | 85 | Vec.dot = (a: Vector, b: Vector) => a.x * b.x + a.y * b.y 86 | 87 | Vec.equal = (a: Vector, b: Vector) => isZero(Vec.dist2(a, b)) 88 | 89 | // Strongly recommend using Vec.len instead of Vec.len2 (length-squared) 90 | Vec.len2 = (v: Vector) => Vec.dot(v, v) 91 | 92 | Vec.len = (v: Vector) => Math.sqrt(Vec.dot(v, v)) 93 | 94 | // Rounding /////////////////////////////////////////////////////////////////// 95 | 96 | Vec.ceil = (v: Vector) => Vec.map(Math.ceil, v) 97 | Vec.floor = (v: Vector) => Vec.map(Math.floor, v) 98 | Vec.round = (v: Vector) => Vec.map(Math.round, v) 99 | Vec.roundTo = (v: Vector, s: number) => Vec.map2(roundTo, v, Vec.of(s)) 100 | 101 | // Variations /////////////////////////////////////////////////////////////////// 102 | 103 | Vec.complement = (v: Vector) => Vec.Ssub(1, v) 104 | Vec.half = (v: Vector) => Vec.divS(v, 2) 105 | Vec.normalize = (v: Vector) => Vec.divS(v, Vec.len(v)) 106 | Vec.recip = (v: Vector) => Vec.Sdiv(1, v) 107 | Vec.renormalize = (v: Vector, length: number) => Vec.Smul(length, Vec.normalize(v)) 108 | 109 | // Combinations /////////////////////////////////////////////////////////////////// 110 | 111 | Vec.avg = (a: Vector, b: Vector) => Vec.half(Vec.add(a, b)) 112 | Vec.lerp = (a: Vector, b: Vector, t: number) => Vec.add(a, Vec.Smul(t, Vec.sub(b, a))) 113 | Vec.max = (a: Vector, b: Vector) => Vec.map2(Math.max, a, b) 114 | Vec.min = (a: Vector, b: Vector) => Vec.map2(Math.min, a, b) 115 | 116 | // Reflections /////////////////////////////////////////////////////////////////// 117 | 118 | Vec.abs = (v: Vector) => Vec.map(Math.abs, v) 119 | Vec.invert = (v: Vector) => Vec(-v.x, -v.y) 120 | Vec.invertX = (v: Vector) => Vec(-v.x, v.y) 121 | Vec.invertY = (v: Vector) => Vec(v.x, -v.y) 122 | 123 | // Rotation & angles /////////////////////////////////////////////////////////// 124 | 125 | // 90 degrees clockwise 126 | Vec.rotate90CW = (v: Vector) => Vec(v.y, -v.x) 127 | 128 | // 90 degrees counter clockwise 129 | Vec.rotate90CCW = (v: Vector) => Vec(-v.y, v.x) 130 | 131 | // TODO(marcel): right now this module is inconsistent in the way it expects angles to work. 132 | // e.g., this function takes an angle in radians, whereas angleBetween uses degrees. 133 | // (this will help avoid confusion...) 134 | Vec.rotate = (v: Vector, angle: number) => 135 | Vec(v.x * Math.cos(angle) - v.y * Math.sin(angle), v.x * Math.sin(angle) + v.y * Math.cos(angle)) 136 | 137 | // Rotate around 138 | Vec.rotateAround = (vector: Vector, point: Position, angle: number): Position => { 139 | // Translate vector to the origin 140 | const translatedVector = Vec.sub(vector, point) 141 | 142 | const rotatedVector = Vec.rotate(translatedVector, angle) 143 | 144 | // Translate vector back to its original position 145 | return Vec.add(rotatedVector, point) 146 | } 147 | 148 | Vec.angle = (v: Vector) => Math.atan2(v.y, v.x) 149 | 150 | Vec.angleBetween = (a: Vector, b: Vector) => { 151 | // Calculate the dot product of the two vectors 152 | const dotProduct = Vec.dot(a, b) 153 | 154 | // Calculate the magnitudes of the two vectors 155 | const magnitudeA = Vec.len(a) 156 | const magnitudeB = Vec.len(b) 157 | 158 | // Calculate the angle between the vectors using the dot product and magnitudes 159 | const angleInRadians = Math.acos(dotProduct / (magnitudeA * magnitudeB)) 160 | 161 | return angleInRadians 162 | } 163 | 164 | Vec.angleBetweenClockwise = (a: Vector, b: Vector) => { 165 | const dP = Vec.dot(a, b) 166 | const cP = Vec.cross(a, b) 167 | 168 | const angleInRadians = Math.atan2(cP, dP) 169 | 170 | return angleInRadians 171 | } 172 | 173 | Vec.update = (dest: Vector, src: Vector) => { 174 | dest.x = src.x 175 | dest.y = src.y 176 | } 177 | -------------------------------------------------------------------------------- /App/src/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import Line from "./line" 2 | import { Position } from "./types" 3 | import Vec from "./vec" 4 | 5 | /** 6 | * Assigns a value to one of the properties on `window` to make it available 7 | * for debugging via the console. If `valueOrValueFn` is a function, it calls 8 | * that function w/ the old value for the property and stores the result. 9 | * Otherwise it stores the value. 10 | */ 11 | export function forDebugging(property: string, valueOrValueFn: T | ((oldValue?: T) => T)) { 12 | let value: T 13 | if (typeof valueOrValueFn === "function") { 14 | const valueFn = valueOrValueFn as (oldValue?: T) => T 15 | const oldValue = (window as any)[property] as T | undefined 16 | value = valueFn(oldValue) 17 | } else { 18 | value = valueOrValueFn 19 | } 20 | 21 | ;(window as any)[property] = value 22 | } 23 | 24 | export function onEveryFrame(update: (dt: number, time: number) => void) { 25 | // Set this to the number of updates you'd like to run per second. 26 | // Should be at least as high as the device frame rate to ensure smooth motion. 27 | // Must not be modified at runtime, because it's used to calculate elapsed time. 28 | const updatesPerSecond = 60 29 | 30 | // You CAN change this at runtime for slow-mo / speed-up effects, eg for debugging. 31 | ;(window as any).timeScale ||= 1 32 | 33 | // Internal state 34 | let lastRafTime: number 35 | let accumulatedTime = 0 // Time is added to this by RAF, and consumed by running updates 36 | let elapsedUpdates = 0 // How many updates have we run — used to measure elapsed time 37 | const secondsPerUpdate = 1 / updatesPerSecond 38 | 39 | function frame(ms: number) { 40 | const currentRafTime = ms / 1000 41 | const deltaRafTime = currentRafTime - lastRafTime 42 | accumulatedTime += deltaRafTime * (window as any).timeScale 43 | 44 | while (accumulatedTime > secondsPerUpdate) { 45 | accumulatedTime -= secondsPerUpdate 46 | elapsedUpdates++ 47 | update(secondsPerUpdate, elapsedUpdates * secondsPerUpdate) 48 | } 49 | 50 | lastRafTime = currentRafTime 51 | 52 | requestAnimationFrame(frame) 53 | } 54 | 55 | requestAnimationFrame((ms) => { 56 | lastRafTime = ms / 1000 57 | requestAnimationFrame(frame) 58 | }) 59 | } 60 | 61 | // A debug view of an object's properties. Clearing is useful when debugging a single object at 60hz. 62 | export function debugTable(obj: {}, clear = true) { 63 | if (clear) { 64 | console.clear() 65 | } 66 | console.table(objectWithSortedKeys(obj)) 67 | } 68 | 69 | type Obj = Record 70 | 71 | // My kingdom for a standard library that includes a key-sorted Map. 72 | export function objectWithSortedKeys(obj: Obj) { 73 | const newObj: Obj = {} 74 | for (const k of Object.keys(obj).sort()) { 75 | newObj[k] = obj[k] 76 | } 77 | return newObj 78 | } 79 | 80 | export const notNull = (x: T | null): x is T => !!x 81 | export const notUndefined = (x: T | undefined): x is T => !!x 82 | 83 | export function toDegrees(radians: number) { 84 | return (radians * 180) / Math.PI 85 | } 86 | 87 | // this is O(n^2), but there is a O(n * log(n)) solution 88 | // that we can use if this ever becomes a bottleneck 89 | // https://www.baeldung.com/cs/most-distant-pair-of-points 90 | export function farthestPair

(points: P[]): [P, P] { 91 | let maxDist = -Infinity 92 | let mdp1: P | null = null 93 | let mdp2: P | null = null 94 | for (const p1 of points) { 95 | for (const p2 of points) { 96 | const d = Vec.dist(p1, p2) 97 | if (d > maxDist) { 98 | mdp1 = p1 99 | mdp2 = p2 100 | maxDist = d 101 | } 102 | } 103 | } 104 | return [mdp1!, mdp2!] 105 | } 106 | 107 | export function forEach(xs: WeakRef[], fn: (x: T, idx: number, xs: WeakRef[]) => void) { 108 | xs.forEach((wr, idx) => { 109 | const x = wr.deref() 110 | if (x !== undefined) { 111 | fn(x, idx, xs) 112 | } 113 | }) 114 | } 115 | 116 | export function makeIterableIterator( 117 | iterables: Iterable[], 118 | pred: (t: T) => t is S 119 | ): IterableIterator 120 | export function makeIterableIterator(iterables: Iterable[], pred?: (t: T) => boolean): IterableIterator 121 | export function makeIterableIterator(iterables: Iterable[], pred: (t: T) => boolean = (_t) => true) { 122 | function* generator() { 123 | for (const ts of iterables) { 124 | for (const t of ts) { 125 | if (!pred || pred(t)) { 126 | yield t 127 | } 128 | } 129 | } 130 | } 131 | return generator() 132 | } 133 | 134 | export function removeOne(set: Set): T | undefined { 135 | for (const element of set) { 136 | set.delete(element) 137 | return element 138 | } 139 | return undefined 140 | } 141 | 142 | // Sorted Set 143 | // Guarantees unique items, and allows resorting of items when iterating 144 | export class SortedSet { 145 | constructor(private readonly items: T[] = []) {} 146 | 147 | static fromSet(set: Set) { 148 | return new SortedSet(Array.from(set)) 149 | } 150 | 151 | add(item: T) { 152 | for (const o of this.items) { 153 | if (o === item) { 154 | return 155 | } 156 | } 157 | 158 | this.items.push(item) 159 | } 160 | 161 | moveItemToFront(item: T) { 162 | // find old position 163 | const oldIndex = this.items.indexOf(item) 164 | if (oldIndex === -1) { 165 | return 166 | } 167 | 168 | // Remove item from old position 169 | const oldItem = this.items.splice(oldIndex, 1)[0] 170 | 171 | // Add it back to front 172 | this.items.unshift(oldItem) 173 | } 174 | 175 | get(index: number) { 176 | return this.items[index] 177 | } 178 | 179 | size() { 180 | return this.items.length 181 | } 182 | 183 | [Symbol.iterator]() { 184 | let index = -1 185 | const data = this.items 186 | 187 | return { 188 | next: () => ({ value: data[++index], done: !(index in data) }) 189 | } 190 | } 191 | } 192 | 193 | /** Helper functions for dealing with `Set`s. */ 194 | export const sets = { 195 | overlap(s1: Set, s2: Set) { 196 | for (const x of s1) { 197 | if (s2.has(x)) { 198 | return true 199 | } 200 | } 201 | return false 202 | }, 203 | union(s1: Set, s2: Set) { 204 | return new Set([...s1, ...s2]) 205 | }, 206 | map(s: Set, fn: (x: S) => T) { 207 | return new Set([...s].map(fn)) 208 | } 209 | } 210 | 211 | export function distanceToPath(pos: Position, points: Position[]) { 212 | switch (points.length) { 213 | case 0: 214 | return null 215 | case 1: 216 | return Vec.dist(pos, points[0]) 217 | default: { 218 | // This is probably *very* slow 219 | let minDist = Infinity 220 | for (let idx = 0; idx < points.length - 1; idx++) { 221 | const p1 = points[idx] 222 | const p2 = points[idx + 1] 223 | minDist = Math.min(minDist, Line.distToPoint(Line(p1, p2), pos)) 224 | } 225 | return minDist 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /Wrapper/Inkling/Inkling.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import WebKit 3 | 4 | // Put your mDNS, IP address, or web URL here. 5 | // (Note: You can use a local web server with a self-signed cert, and https as the protocol, to (eg) get more accuracy from performance.now()) 6 | let url = URL(string: "http://chonker.local:5173")! 7 | 8 | @main 9 | struct InklingApp: App { 10 | var body: some Scene { 11 | WindowGroup { 12 | AppView() 13 | } 14 | } 15 | } 16 | 17 | struct AppView: View { 18 | @State private var error: Error? 19 | @State private var loading = true 20 | 21 | var body: some View { 22 | VStack { 23 | if let error = error { 24 | // In the event of an error, show the error message and a handy quit button (so you don't have to force-quit) 25 | Text(error.localizedDescription) 26 | .foregroundColor(.pink) 27 | .font(.headline) 28 | Button("Quit") { exit(EXIT_FAILURE) } 29 | .buttonStyle(.bordered) 30 | .foregroundColor(.primary) 31 | } else { 32 | // Load the WebView, and show a spinner while it's loading 33 | ZStack { 34 | WrapperWebView(error: $error, loading: $loading) 35 | .opacity(loading ? 0 : 1) // The WebView is opaque white while loading, which sucks in dark mode 36 | if loading { 37 | VStack(spacing: 20) { 38 | Text("Attempting to load \(url)") 39 | .foregroundColor(.gray) 40 | .font(.headline) 41 | ProgressView() 42 | } 43 | } 44 | } 45 | } 46 | } 47 | .ignoresSafeArea() // Allow views to stretch right to the edges 48 | .statusBarHidden() // Hide the status bar at the top 49 | .persistentSystemOverlays(.hidden) // Hide the home indicator at the bottom 50 | .defersSystemGestures(on:.all) // Block the first swipe from the top (todo: doesn't seem to block the bottom) 51 | // We also have fullScreenRequired set in the Project settings, so we're opted-out from multitasking 52 | } 53 | } 54 | 55 | // This struct wraps WKWebView so that we can use it in SwiftUI. 56 | // Hopefully it won't be long before this can all be removed. 57 | struct WrapperWebView: UIViewRepresentable { 58 | let webView = WKWebView() 59 | @Binding var error: Error? 60 | @Binding var loading: Bool 61 | 62 | func makeUIView(context: Context) -> WKWebView { 63 | webView.isInspectable = true 64 | webView.navigationDelegate = context.coordinator 65 | webView.addGestureRecognizer(TouchesToJS(webView)) 66 | loadRequest(webView: webView, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData) 67 | return webView 68 | } 69 | 70 | private func loadRequest(webView: WKWebView, cachePolicy: URLRequest.CachePolicy) { 71 | webView.load(URLRequest(url: url, cachePolicy: cachePolicy)) 72 | } 73 | 74 | func orient(_ orientation:Int) { 75 | webView.evaluateJavaScript("if ('orient' in window) orient(\(orientation))", completionHandler: nil) 76 | } 77 | 78 | // Required by UIViewRepresentable 79 | func updateUIView(_ uiView: WKWebView, context: Context) {} 80 | 81 | // To make use of various WKWebView delegates, we need a real class 82 | func makeCoordinator() -> WebViewCoordinator { WebViewCoordinator(self) } 83 | class WebViewCoordinator: NSObject, WKNavigationDelegate { 84 | let parent: WrapperWebView 85 | var triedOffline = false 86 | init(_ webView: WrapperWebView) { self.parent = webView } 87 | func webView(_ wv: WKWebView, didFinish nav: WKNavigation) { parent.loading = false; } 88 | func webView(_ wv: WKWebView, didFail nav: WKNavigation, withError error: Error) { parent.error = error } 89 | func webView(_ wv: WKWebView, didFailProvisionalNavigation nav: WKNavigation, withError error: Error) { 90 | if !triedOffline { 91 | // The first time provisional navigation fails, try loading from the browser cache. 92 | // This is useful if you're loading an app from a web server and want that to work even when the iPad is offline. 93 | triedOffline = true 94 | parent.loadRequest(webView: wv, cachePolicy: .returnCacheDataDontLoad) 95 | } else { 96 | parent.error = error 97 | } 98 | } 99 | // This makes the webview ignore certificate errors, so you can use a self-signed cert for https, so that the browser context is trusted, which enables additional APIs 100 | func webView(_ wv: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { 101 | (.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!)) 102 | } 103 | } 104 | } 105 | 106 | // This class captures all the touch events triggered on a given WKWebView, and re-triggeres them inside the JS context. 107 | // This allows JS to receive pencil and touch simultaneously. 108 | class TouchesToJS: UIGestureRecognizer { 109 | let webView: WKWebView 110 | 111 | init(_ webView: WKWebView) { 112 | self.webView = webView 113 | super.init(target:nil, action:nil) 114 | requiresExclusiveTouchType = false // Allow simultaneous pen and touch events 115 | } 116 | 117 | typealias TouchJSON = [String: AnyHashable] 118 | 119 | private func makeTouchJSON(id: Int, phase: String, touch: UITouch) -> TouchJSON { 120 | let location = touch.preciseLocation(in: view) 121 | return [ 122 | "id": id, 123 | "type": touch.type == .pencil ? "pencil" : "finger", 124 | "phase": phase, 125 | "position": [ 126 | "x": location.x, 127 | "y": location.y, 128 | ], 129 | "pressure": touch.force, 130 | "altitude": touch.altitudeAngle, 131 | "azimuth": touch.azimuthAngle(in: view), 132 | "rollAngle": touch.rollAngle, 133 | "radius": touch.majorRadius, 134 | "timestamp": touch.timestamp 135 | ] 136 | } 137 | 138 | func sendTouches(_ phase: String, _ touches: Set, _ event: UIEvent) { 139 | for touch in touches { 140 | let id = touch.hashValue // These ids *should be* stable until the touch ends (ie: finger or pencil is lifted) 141 | let jsonArr = event.coalescedTouches(for: touch)!.map({ makeTouchJSON(id: id, phase: phase, touch: $0) }) 142 | if let json = try? JSONSerialization.data(withJSONObject: jsonArr), 143 | let jsonString = String(data: json, encoding: .utf8) { 144 | webView.evaluateJavaScript("if ('wrapperEvents' in window) wrapperEvents(\(jsonString))") 145 | } 146 | } 147 | } 148 | 149 | override func touchesBegan (_ touches: Set, with event: UIEvent) { sendTouches("began", touches, event) } 150 | override func touchesMoved (_ touches: Set, with event: UIEvent) { sendTouches("moved", touches, event) } 151 | override func touchesEnded (_ touches: Set, with event: UIEvent) { sendTouches("ended", touches, event) } 152 | override func touchesCancelled(_ touches: Set, with event: UIEvent) { sendTouches("ended", touches, event) } // "ended" because we don't differentiate between ended and cancelled in the web app 153 | } 154 | -------------------------------------------------------------------------------- /App/src/app/ink/Handle.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from "../GameObject" 2 | import SVG from "../Svg" 3 | import * as constraints from "../Constraints" 4 | import { Constraint, Pin, Variable } from "../Constraints" 5 | import { Position } from "../../lib/types" 6 | import Vec from "../../lib/vec" 7 | import { TAU } from "../../lib/math" 8 | import { generateId, Root } from "../Root" 9 | 10 | export type SerializedHandle = { 11 | type: "Handle" 12 | id: number 13 | position: Position 14 | xVariableId: number 15 | yVariableId: number 16 | } 17 | 18 | export default class Handle extends GameObject { 19 | static goesAnywhereId = -1 20 | 21 | static withId(id: number) { 22 | const handle = Root.current.find({ what: aHandle, that: (h) => h.id === id }) 23 | if (handle == null) { 24 | throw new Error("coudln't find handle w/ id " + id) 25 | } 26 | return handle 27 | } 28 | 29 | static create(position: Position): Handle { 30 | return new Handle( 31 | position, 32 | constraints.variable(0, { 33 | object: this, 34 | property: "x" 35 | }), 36 | constraints.variable(0, { 37 | object: this, 38 | property: "y" 39 | }) 40 | ) 41 | } 42 | 43 | private readonly backElm = SVG.add("g", SVG.handleElm, { class: "handle" }) 44 | private readonly frontElm = SVG.add("g", SVG.constraintElm, { class: "handle" }) 45 | 46 | protected constructor( 47 | position: Position, 48 | public readonly xVariable: Variable, 49 | public readonly yVariable: Variable, 50 | public readonly id: number = generateId() 51 | ) { 52 | super() 53 | this.position = position 54 | 55 | SVG.add("circle", this.backElm, { r: 15 }) 56 | const arcs1 = SVG.add("g", this.frontElm, { class: "arcs1" }) 57 | const arcs2 = SVG.add("g", this.frontElm, { class: "arcs2" }) 58 | const arc = (angle = 0) => SVG.arcPath(Vec.zero, 14, angle, Math.PI / 10) 59 | SVG.add("path", arcs1, { d: arc((0 * TAU) / 4) }) 60 | SVG.add("path", arcs1, { d: arc((1 * TAU) / 4) }) 61 | SVG.add("path", arcs1, { d: arc((2 * TAU) / 4) }) 62 | SVG.add("path", arcs1, { d: arc((3 * TAU) / 4) }) 63 | SVG.add("path", arcs2, { d: arc((0 * TAU) / 4) }) 64 | SVG.add("path", arcs2, { d: arc((1 * TAU) / 4) }) 65 | SVG.add("path", arcs2, { d: arc((2 * TAU) / 4) }) 66 | SVG.add("path", arcs2, { d: arc((3 * TAU) / 4) }) 67 | Root.current.adopt(this) 68 | } 69 | 70 | serialize(): SerializedHandle { 71 | return { 72 | type: "Handle", 73 | id: this.id, 74 | position: { x: this.x, y: this.y }, 75 | xVariableId: this.xVariable.id, 76 | yVariableId: this.yVariable.id 77 | } 78 | } 79 | 80 | static deserialize(v: SerializedHandle) { 81 | Handle.goesAnywhereId = -1 82 | return new Handle(v.position, Variable.withId(v.xVariableId), Variable.withId(v.yVariableId), v.id) 83 | } 84 | 85 | toggleGoesAnywhere() { 86 | if (Handle.goesAnywhereId !== this.id) { 87 | Handle.goesAnywhereId = this.id 88 | } else { 89 | Handle.goesAnywhereId = -1 90 | } 91 | } 92 | 93 | get x() { 94 | return this.xVariable.value 95 | } 96 | 97 | get y() { 98 | return this.yVariable.value 99 | } 100 | 101 | get position(): Position { 102 | return this 103 | } 104 | 105 | set position(pos: Position) { 106 | ;({ x: this.xVariable.value, y: this.yVariable.value } = pos) 107 | } 108 | 109 | remove() { 110 | this.backElm.remove() 111 | this.frontElm.remove() 112 | this.canonicalInstance.breakOff(this) 113 | this.xVariable.remove() 114 | this.yVariable.remove() 115 | super.remove() 116 | } 117 | 118 | absorb(that: Handle) { 119 | constraints.absorb(this, that) 120 | } 121 | 122 | getAbsorbedByNearestHandle() { 123 | const nearestHandle = this.root.find({ 124 | what: aCanonicalHandle, 125 | near: this.position, 126 | that: (handle) => handle !== this 127 | }) 128 | if (nearestHandle) { 129 | nearestHandle.absorb(this) 130 | } 131 | } 132 | 133 | private _canonicalHandle: Handle = this 134 | readonly absorbedHandles = new Set() 135 | 136 | get isCanonical() { 137 | return this._canonicalHandle === this 138 | } 139 | 140 | get canonicalInstance() { 141 | return this._canonicalHandle 142 | } 143 | 144 | private set canonicalInstance(handle: Handle) { 145 | this._canonicalHandle = handle 146 | } 147 | 148 | /** This method should only be called by the constraint system. */ 149 | _absorb(that: Handle) { 150 | if (that === this) { 151 | return 152 | } 153 | 154 | that.canonicalInstance.absorbedHandles.delete(that) 155 | for (const handle of that.absorbedHandles) { 156 | this._absorb(handle) 157 | } 158 | that.canonicalInstance = this 159 | this.absorbedHandles.add(that) 160 | } 161 | 162 | /** This method should only be called by the constraint system. */ 163 | _forgetAbsorbedHandles() { 164 | this.canonicalInstance = this 165 | this.absorbedHandles.clear() 166 | } 167 | 168 | breakOff(handle: Handle) { 169 | if (this.absorbedHandles.has(handle)) { 170 | constraints.absorb(this, handle).remove() 171 | } else if (handle === this) { 172 | if (this.absorbedHandles.size > 0) { 173 | const absorbedHandles = [...this.absorbedHandles] 174 | const newCanonicalHandle = absorbedHandles.shift()! 175 | constraints.absorb(this, newCanonicalHandle).remove() 176 | for (const absorbedHandle of absorbedHandles) { 177 | constraints.absorb(newCanonicalHandle, absorbedHandle) 178 | } 179 | } 180 | } else { 181 | throw new Error("tried to break off a handle that was not absorbed") 182 | } 183 | return handle 184 | } 185 | 186 | get hasPin() { 187 | for (const constraint of Constraint.all) { 188 | if (constraint instanceof Pin && constraint.handle.canonicalInstance === this.canonicalInstance) { 189 | return true 190 | } 191 | } 192 | return false 193 | } 194 | 195 | togglePin(doPin = !this.hasPin): void { 196 | if (!this.isCanonical) { 197 | return this.canonicalInstance.togglePin(doPin) 198 | } 199 | 200 | for (const h of [this, ...this.absorbedHandles]) { 201 | if (doPin) { 202 | constraints.pin(h) 203 | } else { 204 | constraints.pin(h).remove() 205 | } 206 | } 207 | } 208 | 209 | render(dt: number, t: number) { 210 | const attrs = { 211 | transform: SVG.positionToTransform(this), 212 | "is-canonical": this.isCanonical, 213 | "has-pin": this.hasPin, 214 | "goes-anywhere": this.id === Handle.goesAnywhereId 215 | } 216 | SVG.update(this.backElm, attrs) 217 | SVG.update(this.frontElm, attrs) 218 | 219 | for (const child of this.children) { 220 | child.render(dt, t) 221 | } 222 | } 223 | 224 | distanceToPoint(point: Position) { 225 | return Vec.dist(this.position, point) 226 | } 227 | 228 | equals(that: Handle) { 229 | return this.xVariable.equals(that.xVariable) && this.yVariable.equals(that.yVariable) 230 | } 231 | } 232 | 233 | export const aHandle = (gameObj: GameObject) => (gameObj instanceof Handle ? gameObj : null) 234 | 235 | export const aCanonicalHandle = (gameObj: GameObject) => 236 | gameObj instanceof Handle && gameObj.isCanonical ? gameObj : null 237 | -------------------------------------------------------------------------------- /App/src/lib/g9.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | /* 3 | 4 | Lifted from https://github.com/bijection/g9/blob/master/src/minimize.js 5 | 6 | MIT License 7 | 8 | Copyright (c) 2016 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | */ 28 | 29 | function norm2(x) { 30 | return Math.sqrt(x.reduce((a, b) => a + b * b, 0)) 31 | } 32 | 33 | function identity(n) { 34 | const ret = Array(n) 35 | for (let i = 0; i < n; i++) { 36 | ret[i] = Array(n) 37 | for (let j = 0; j < n; j++) { 38 | ret[i][j] = +(i == j) 39 | } 40 | } 41 | return ret 42 | } 43 | 44 | function neg(x) { 45 | return x.map((a) => -a) 46 | } 47 | 48 | function dot(a, b) { 49 | if (typeof a[0] !== "number") { 50 | return a.map((x) => dot(x, b)) 51 | } 52 | return a.reduce((x, y, i) => x + y * b[i], 0) 53 | } 54 | 55 | function sub(a, b) { 56 | if (typeof a[0] !== "number") { 57 | return a.map((c, i) => sub(c, b[i])) 58 | } 59 | return a.map((c, i) => c - b[i]) 60 | } 61 | 62 | function add(a, b) { 63 | if (typeof a[0] !== "number") { 64 | return a.map((c, i) => add(c, b[i])) 65 | } 66 | return a.map((c, i) => c + b[i]) 67 | } 68 | 69 | function div(a, b) { 70 | return a.map((c) => c.map((d) => d / b)) 71 | } 72 | 73 | function mul(a, b) { 74 | if (typeof a[0] !== "number") { 75 | return a.map((c) => mul(c, b)) 76 | } 77 | return a.map((c) => c * b) 78 | } 79 | 80 | function ten(a, b) { 81 | return a.map((c, i) => mul(b, c)) 82 | } 83 | 84 | // function isZero(a) { 85 | // for (let i = 0; i < a.length; i++) { 86 | // if (a[i] !== 0) { 87 | // return false; 88 | // } 89 | // } 90 | // return true; 91 | // } 92 | 93 | // Adapted from the numeric.js gradient and uncmin functions 94 | // Numeric Javascript 95 | // Copyright (C) 2011 by Sébastien Loisel 96 | 97 | // Permission is hereby granted, free of charge, to any person obtaining a copy 98 | // of this software and associated documentation files (the "Software"), to deal 99 | // in the Software without restriction, including without limitation the rights 100 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 101 | // copies of the Software, and to permit persons to whom the Software is 102 | // furnished to do so, subject to the following conditions: 103 | 104 | // The above copyright notice and this permission notice shall be included in 105 | // all copies or substantial portions of the Software. 106 | 107 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 108 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 109 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 110 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 111 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 112 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 113 | // THE SOFTWARE. 114 | 115 | export function gradient(f: (x: number[]) => number, x: number[]): number[] { 116 | const dim = x.length, 117 | f1 = f(x) 118 | if (isNaN(f1)) { 119 | throw new Error("The gradient at [" + x.join(" ") + "] is NaN!") 120 | } 121 | const { max, abs, min } = Math 122 | const tempX = x.slice(0), 123 | grad = Array(dim) 124 | for (let i = 0; i < dim; i++) { 125 | let delta = max(1e-6 * f1, 1e-8) 126 | for (let k = 0; ; k++) { 127 | if (k == 20) { 128 | throw new Error("Gradient failed at index " + i + " of [" + x.join(" ") + "]") 129 | } 130 | tempX[i] = x[i] + delta 131 | const f0 = f(tempX) 132 | tempX[i] = x[i] - delta 133 | const f2 = f(tempX) 134 | tempX[i] = x[i] 135 | if (!(isNaN(f0) || isNaN(f2))) { 136 | grad[i] = (f0 - f2) / (2 * delta) 137 | const t0 = x[i] - delta 138 | const t1 = x[i] 139 | const t2 = x[i] + delta 140 | const d1 = (f0 - f1) / delta 141 | const d2 = (f1 - f2) / delta 142 | const err = min(max(abs(d1 - grad[i]), abs(d2 - grad[i]), abs(d1 - d2)), delta) 143 | const normalize = max(abs(grad[i]), abs(f0), abs(f1), abs(f2), abs(t0), abs(t1), abs(t2), 1e-8) 144 | if (err / normalize < 1e-3) { 145 | break 146 | } //break if this index is done 147 | } 148 | delta /= 16 149 | } 150 | } 151 | return grad 152 | } 153 | 154 | export function minimize( 155 | f: (x: number[]) => number, 156 | x0: number[], 157 | tol = 1e-8, 158 | _noooooo: undefined, 159 | maxit = 1000, 160 | end_on_line_search = false 161 | ): { 162 | solution: number[] 163 | f: number 164 | gradient: number[] 165 | invHessian: number[][] 166 | iterations: number 167 | message: string 168 | } { 169 | tol = Math.max(tol, 2e-16) 170 | const grad = (a: number[]) => gradient(f, a) 171 | 172 | x0 = x0.slice(0) 173 | let g0 = grad(x0) 174 | let f0 = f(x0) 175 | if (isNaN(f0)) { 176 | throw new Error("minimize: f(x0) is a NaN!") 177 | } 178 | const n = x0.length 179 | let H1 = identity(n) 180 | 181 | for (var it = 0; it < maxit; it++) { 182 | if (!g0.every(isFinite)) { 183 | var msg = "Gradient has Infinity or NaN" 184 | break 185 | } 186 | const step = neg(dot(H1, g0)) 187 | if (!step.every(isFinite)) { 188 | var msg = "Search direction has Infinity or NaN" 189 | break 190 | } 191 | const nstep = norm2(step) 192 | if (nstep < tol) { 193 | var msg = "Newton step smaller than tol" 194 | break 195 | } 196 | let t = 1 197 | const df0 = dot(g0, step) 198 | // line search 199 | let x1 = x0 200 | var s 201 | for (; it < maxit && t * nstep >= tol; it++) { 202 | s = mul(step, t) 203 | x1 = add(x0, s) 204 | var f1 = f(x1) 205 | if (!(f1 - f0 >= 0.1 * t * df0 || isNaN(f1))) { 206 | break 207 | } 208 | t *= 0.5 209 | } 210 | if (t * nstep < tol && end_on_line_search) { 211 | var msg = "Line search step size smaller than tol" 212 | break 213 | } 214 | if (it === maxit) { 215 | var msg = "maxit reached during line search" 216 | break 217 | } 218 | const g1 = grad(x1) 219 | const y = sub(g1, g0) 220 | const ys = dot(y, s) 221 | const Hy = dot(H1, y) 222 | H1 = sub(add(H1, mul(ten(s, s), (ys + dot(y, Hy)) / (ys * ys))), div(add(ten(Hy, s), ten(s, Hy)), ys)) 223 | x0 = x1 224 | f0 = f1 225 | g0 = g1 226 | } 227 | 228 | return { 229 | solution: x0, 230 | f: f0, 231 | gradient: g0, 232 | invHessian: H1, 233 | iterations: it, 234 | message: msg 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inkling 2 | 3 | Inkling is a now-archived **research project**. We do not offer support for it. We can't help you get it running. We will not be adding features, fixing the *numerous* bugs, or porting it to other platforms. If you break it you can keep both halves. 4 | 5 | # Getting Started 6 | 7 | You can use Inkling in one of two ways. 8 | 9 | * We *highly* recommend using an iPad and Apple Pencil, if you have those available. It takes a little more effort initially, but then you get to experience the system as it was meant to be used. It feels fantastic. 10 | * Alternatively, you can run the app in a web browser. It's easier to get started, but two-handed gestures don't work, which ruins the feeling of the tool. 11 | 12 | ### Running on iPad 13 | You'll need a Mac with Xcode, an iPad, and an Apple Pencil. 14 | 15 | In a terminal, `cd` into the `App` folder, run `npm install` to fetch a few deps, then run `npm exec vite` to spin up the web app. Make note of the network URL it shows you. 16 | 17 | Open the `Wrapper` folder and the Inkling Xcode project within it. Select the Inkling project at the root of the file browser, then pick the Inkling Target, and set your developer profile under Signing & Certificates. You might also need to change the bundle identifier to something unique. Open `Inkling.swift`, look for the `URL` variable near the top, and set that to the network URL that Vite used. Then, build the app for your iPad. 18 | 19 | Your Mac and iPad need to be on the same network, and Vite needs to be running whenever you launch the iPad app. 20 | 21 | ### Running on Desktop 22 | 23 | In a terminal, `cd` into the `App` folder, run `npm install` to fetch a few deps, then run `npm exec vite` to spin up the web app. Make note of the local URL, and open that path in your browser. 24 | 25 | # User Interface 26 | 27 | Inkling has a lot of UI, but most of it is invisible. There's going to be a bit of a learning curve. 28 | 29 | ### Pseudo 30 | 31 | We have a special kind of gesture called a "pseudo mode", or "pseudo" for short, usually mentioned with a number (eg: "2-pseudo"). To do a "pseudo", you place a certain number of fingers on the screen in empty space, and then do the action. 32 | 33 | For example, to erase, you *2-pseudo draw*. This means you put 2 fingers down on the screen, and then draw with the pencil. 34 | 35 | Pseudo gestures are almost always intended to be a 2-handed gesture. You'll use your off hand for the pseudo fingers, then your dominant hand to perform the action. 36 | 37 | We use the presence of these fingers to *temporarily* switch to a different mode, like a different tool for the pencil or a different rate of change when scrubbing a number. Once you memories the handful pseudo modes in the system, you'll be able to work at the *speed of feeling*. 38 | 39 | ### iPad Input 40 | 41 | The pencil is for drawing and creating new things. 42 | 43 | Your fingers are for moving and changing things. 44 | 45 | ### Desktop Input 46 | 47 | By default, the mouse acts like a finger, for moving and changing things. 48 | 49 | Press and hold spacebar to make the mouse act like a pencil, for drawing and creating new things. 50 | 51 | Press and hold the number 1, 2, 3, or 4 while using the mouse (or spacebar+mouse) to activate pseudo fingers. For instance, for a "2-pseudo draw", you'll press and hold both the number 2 and the spacebar, then click and drag with the mouse. 52 | 53 | ### The Bulb 54 | 55 | There's only one on-screen UI element — the Bulb. 56 | 57 | Tap the bulb to toggle between **Ink mode** and **Meta mode**. 58 | * In Ink mode, you draw and play. 59 | * In Meta mode, you construct and assemble. 60 | 61 | You can drag the bulb with your finger and drop it in any of the four corners. Don't be shy — it waits until you've dragged a good distance before it starts to move. 62 | 63 | ### Drawing 64 | 65 | In Ink Mode, you draw with the pencil. If you've made any handles, move them with your fingers. 66 | 67 | 2-pseudo draw to erase. 68 | 69 | ### Handles 70 | 71 | In Meta mode, tap an ink stroke with your finger to give it handles. 72 | 73 | Use your finger to move handles, which scales and rotates the ink stroke. 74 | 75 | Handles can be snapped together. 3-pseudo finger drag to separate them. (That is: put 3 fingers down somewhere on the screen, then with another finger touch some snapped-together handles and drag away. On desktop, press and hold the 3 key, then drag the mouse on a handle.) 76 | 77 | Handles can be repositioned relative to their stroke with a 2-pseudo finger drag. (This works reliably only when the handles aren't snapped to other handles) 78 | 79 | Tap the handles to pin them in place. 80 | 81 | ### Gizmo 82 | 83 | In Meta mode, draw with the pencil to create a gizmo. 84 | 85 | A gizmo has a handle at each end, which behaves just like the handles on ink strokes. 86 | 87 | Tap the sigil at the center of the gizmo to cycle through four constraint modes: 88 | * Unconstrained 89 | * Distance 90 | * Distance & angle 91 | * Angle 92 | 93 | ### Wiring 94 | 95 | In Meta mode, place the pencil anywhere on a gizmo, then draw outward to create a wire. Release the pencil somewhere in empty space, and a property picker will appear at that spot. Use the pencil or your finger to select a property. If you draw outward from the property and release somewhere in empty space you'll create a number token. 96 | 97 | You can move pickers and tokens with your finger. 98 | 99 | You can wire existing objects together. If you try to wire something in a way that's invalid or nonsensical, the system will scold you. 100 | 101 | You can wire 2 gizmos directly together. This constrains both their lengths and angles to be equal. 102 | 103 | Erase pickers and wires by 2-pseudo drawing. 104 | 105 | ### Number Token 106 | 107 | Pseudo, then finger-drag to scrub a number. Add more pseudo fingers for finer control. 108 | 109 | Draw out from a number to wire to other numbers or properties. 110 | 111 | Tap a number with your finger to lock the value. If the number is connected to a property, locking the value means exactly the same thing as constraining the property. So, for example, a locking/unlocking a number that's wired to the length of a gizmo is exactly the same as toggling the length constraint by tapping the sigil. 112 | 113 | ### Linear Token 114 | 115 | In Meta mode, tap the pencil in empty space to create a linear token (y=mx+b). This token creates a relationship between four values. Use this simple formula to create wild constructions with gizmos. That's all there is to it. 116 | 117 | ### Go Everywhere 118 | 119 | 4-pseudo finger tap a handle to tell it to "go everywhere". Repeat this gesture to cycle through the "go everywhere" modes: 120 | * continuous 121 | * snapshot 122 | * off 123 | 124 | When a handle is told to "go everywhere", the system will try to move that handle to a bunch of different places on the screen, and then draw a dot wherever the handle wound up. So if you have a gizmo with a locked distance, pin one handle, and "go everywhere" with the other, it'll draw a circle. If you change the locked distance to locked angle, it'll draw a line. 125 | 126 | ### Extras 127 | 128 | The Bulb's tap has a big radius and is very forgiving — you can mash your thumb anywhere in that corner of the screen, or slide your thumb outward off the side. Let it feel good. 129 | 130 | You can tap on some wires to *pause* them, temporarily breaking the equality constraint they represent. 131 | 132 | In ink mode, tap the pen to create a Lead (like the tip of a pencil). It's a special handle that leaves a trail of ink when it moves. You can erase the ink, or tap it in meta mode to add handles. 133 | 134 | When using Inkling on a desktop computer, you can press the Tab key to switch modes. 135 | 136 | You can also pseudo tap the bulb to cycle through color themes: 137 | * Light Color 138 | * Dark Color 139 | * Light Mono 140 | * Dark Mono 141 | 142 | You can customize these themes in `style.css`, and add/remove themes in `index.html` 143 | 144 | You can erase the bulb, which triggers a reload. Handy. 145 | -------------------------------------------------------------------------------- /App/src/app/meta/Gizmo.ts: -------------------------------------------------------------------------------- 1 | import { TAU, lerp, normalizeAngle } from "../../lib/math" 2 | import SVG from "../Svg" 3 | import Handle from "../ink/Handle" 4 | import Vec from "../../lib/vec" 5 | import { Position } from "../../lib/types" 6 | import * as constraints from "../Constraints" 7 | import { Variable } from "../Constraints" 8 | import Line from "../../lib/line" 9 | import { GameObject } from "../GameObject" 10 | import { generateId, Root } from "../Root" 11 | import { Pluggable } from "./Pluggable" 12 | 13 | const arc = SVG.arcPath(Vec.zero, 10, TAU / 4, Math.PI / 3) 14 | 15 | export type SerializedGizmo = { 16 | type: "Gizmo" 17 | id: number 18 | distanceVariableId: number 19 | angleInRadiansVariableId: number 20 | angleInDegreesVariableId: number 21 | aHandleId: number 22 | bHandleId: number 23 | } 24 | 25 | export default class Gizmo extends GameObject implements Pluggable { 26 | static withId(id: number) { 27 | const gizmo = Root.current.find({ what: aGizmo, that: (t) => t.id === id }) 28 | if (gizmo == null) { 29 | throw new Error("coudln't find gizmo w/ id " + id) 30 | } 31 | return gizmo 32 | } 33 | 34 | static create(a: Handle, b: Handle) { 35 | const { distance, angle: angleInRadians } = constraints.polarVector(a, b) 36 | const angleInDegrees = constraints.linearRelationship( 37 | constraints.variable((angleInRadians.value * 180) / Math.PI), 38 | 180 / Math.PI, 39 | angleInRadians, 40 | 0 41 | ).y 42 | return Gizmo._create(generateId(), a, b, distance, angleInRadians, angleInDegrees) 43 | } 44 | 45 | static _create( 46 | id: number, 47 | a: Handle, 48 | b: Handle, 49 | distance: Variable, 50 | angleInRadians: Variable, 51 | angleInDegrees: Variable 52 | ) { 53 | return new Gizmo(id, a, b, distance, angleInRadians, angleInDegrees) 54 | } 55 | 56 | center: Position 57 | 58 | private elm = SVG.add("g", SVG.gizmoElm, { class: "gizmo" }) 59 | private thick = SVG.add("polyline", this.elm, { class: "thick" }) 60 | private arrow = SVG.add("polyline", this.elm, { class: "arrow" }) 61 | private arcs = SVG.add("g", this.elm, { class: "arcs" }) 62 | private arc1 = SVG.add("path", this.arcs, { d: arc, class: "arc1" }) 63 | private arc2 = SVG.add("path", this.arcs, { d: arc, class: "arc2" }) 64 | 65 | private readonly _a: WeakRef 66 | private readonly _b: WeakRef 67 | 68 | // ------ 69 | 70 | private savedDistance = 0 71 | private savedAngleInRadians = 0 72 | 73 | saveState() { 74 | this.savedDistance = this.distance.value 75 | this.savedAngleInRadians = this.angleInRadians.value 76 | } 77 | 78 | restoreState() { 79 | this.distance.value = this.savedDistance 80 | this.angleInRadians.value = this.savedAngleInRadians 81 | } 82 | 83 | // ------ 84 | 85 | get a(): Handle | undefined { 86 | return this._a.deref() 87 | } 88 | 89 | get b(): Handle | undefined { 90 | return this._b.deref() 91 | } 92 | 93 | get handles() { 94 | const a = this.a 95 | const b = this.b 96 | return a && b ? { a, b } : null 97 | } 98 | 99 | readonly plugVars: { distance: Variable; angleInDegrees: Variable } 100 | 101 | private constructor( 102 | readonly id: number, 103 | a: Handle, 104 | b: Handle, 105 | readonly distance: Variable, 106 | readonly angleInRadians: Variable, 107 | readonly angleInDegrees: Variable 108 | ) { 109 | super() 110 | this.center = Vec.avg(a, b) 111 | this._a = new WeakRef(a) 112 | this._b = new WeakRef(b) 113 | this.distance.represents = { 114 | object: this, 115 | property: "distance" 116 | } 117 | this.angleInRadians.represents = { 118 | object: this, 119 | property: "angle-radians" 120 | } 121 | this.angleInDegrees.represents = { 122 | object: this, 123 | property: "angle-degrees" 124 | } 125 | this.plugVars = { 126 | distance, 127 | angleInDegrees 128 | } 129 | } 130 | 131 | getPlugPosition(id: string): Position { 132 | return this.center 133 | } 134 | 135 | static deserialize(v: SerializedGizmo): Gizmo { 136 | return this._create( 137 | v.id, 138 | Handle.withId(v.aHandleId), 139 | Handle.withId(v.bHandleId), 140 | Variable.withId(v.distanceVariableId), 141 | Variable.withId(v.angleInRadiansVariableId), 142 | Variable.withId(v.angleInDegreesVariableId) 143 | ) 144 | } 145 | 146 | serialize(): SerializedGizmo { 147 | return { 148 | type: "Gizmo", 149 | id: this.id, 150 | distanceVariableId: this.distance.id, 151 | angleInRadiansVariableId: this.angleInRadians.id, 152 | angleInDegreesVariableId: this.angleInDegrees.id, 153 | aHandleId: this.a!.id, 154 | bHandleId: this.b!.id 155 | } 156 | } 157 | 158 | cycleConstraints() { 159 | const aLock = this.angleInRadians.isLocked 160 | const dLock = this.distance.isLocked 161 | 162 | // There's probably some smarter way to do this with a bitmask or something 163 | // but this is just a temporary hack so don't bother 164 | if (!aLock && !dLock) { 165 | this.toggleDistance() 166 | } else if (dLock && !aLock) { 167 | this.toggleAngle() 168 | } else if (dLock && aLock) { 169 | this.toggleDistance() 170 | } else if (!dLock && aLock) { 171 | this.toggleAngle() 172 | } 173 | } 174 | 175 | toggleDistance() { 176 | this.distance.toggleLock() 177 | } 178 | 179 | toggleAngle() { 180 | // doesn't matter which angle we lock, one is absorbed by the other 181 | // so they this results in locking/unlocking both 182 | this.angleInRadians.toggleLock() 183 | } 184 | 185 | render() { 186 | const handles = this.handles 187 | if (!handles) { 188 | return 189 | } 190 | 191 | const a = handles.a.position 192 | const b = handles.b.position 193 | this.center = Vec.avg(a, b) 194 | 195 | const solverLength = this.distance.value 196 | const realLength = Vec.dist(a, b) 197 | const distanceTension = Math.abs(solverLength - realLength) / 50 198 | 199 | const solverAngle = normalizeAngle(this.angleInRadians.value) 200 | const realAngle = normalizeAngle(Vec.angle(Vec.sub(b, a))) 201 | const angleTension = Math.abs(solverAngle - realAngle) * 10 202 | 203 | const aLock = this.angleInRadians.isLocked 204 | const dLock = this.distance.isLocked 205 | const fade = lerp(realLength, 80, 100, 0, 1) 206 | 207 | SVG.update(this.elm, { "is-uncomfortable": distanceTension + angleTension > 1 }) 208 | SVG.update(this.elm, { "is-constrained": aLock || dLock }) 209 | SVG.update(this.thick, { 210 | points: SVG.points(a, b), 211 | style: `stroke-dashoffset:${-realLength / 2}px` 212 | }) 213 | 214 | if (realLength > 0) { 215 | const ab = Vec.sub(b, a) 216 | const arrow = Vec.renormalize(ab, 4) 217 | const tail = Vec.sub(this.center, Vec.renormalize(ab, 30)) 218 | const tip = Vec.add(this.center, Vec.renormalize(ab, 30)) 219 | const port = Vec.sub(tip, Vec.rotate(arrow, TAU / 12)) 220 | const starboard = Vec.sub(tip, Vec.rotate(arrow, -TAU / 12)) 221 | 222 | SVG.update(this.arrow, { 223 | points: SVG.points(tail, tip, port, starboard, tip), 224 | style: `opacity: ${fade}` 225 | }) 226 | 227 | SVG.update(this.arcs, { 228 | style: ` 229 | opacity: ${fade}; 230 | transform: 231 | translate(${this.center.x}px, ${this.center.y}px) 232 | rotate(${realAngle}rad) 233 | ` 234 | }) 235 | 236 | const xOffset = aLock ? 0 : dLock ? 9.4 : 12 237 | const yOffset = dLock ? -3.5 : 0 238 | const arcTransform = `transform: translate(${xOffset}px, ${yOffset}px)` 239 | SVG.update(this.arc1, { style: arcTransform }) 240 | SVG.update(this.arc2, { style: arcTransform }) 241 | } 242 | } 243 | 244 | distanceToPoint(point: Position) { 245 | if (!this.handles) { 246 | return Infinity 247 | } 248 | const line = Line(this.handles.a.position, this.handles.b.position) 249 | const l = Line.distToPoint(line, point) 250 | const a = Vec.dist(this.center, point) 251 | return Math.min(l, a) 252 | } 253 | 254 | centerDistanceToPoint(p: Position) { 255 | return Vec.dist(this.center, p) 256 | } 257 | 258 | remove() { 259 | this.distance.remove() 260 | this.angleInRadians.remove() 261 | this.angleInDegrees.remove() 262 | this.elm.remove() 263 | this.a?.remove() 264 | this.b?.remove() 265 | super.remove() 266 | } 267 | } 268 | 269 | export const aGizmo = (gameObj: GameObject) => (gameObj instanceof Gizmo ? gameObj : null) 270 | -------------------------------------------------------------------------------- /App/src/app/NativeEvents.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "../lib/types" 2 | import Vec from "../lib/vec" 3 | import Config from "./Config" 4 | import MetaToggle from "./gui/MetaToggle" 5 | import PenToggle from "./gui/PenToggle" 6 | 7 | // TODO: Do we want to add some way to fake pencil input with a finger? 8 | // That might be a useful thing to add HERE, so that other parts of the system 9 | // will be forced to assume multi-pencil support exists, which might drive novel ideas. 10 | 11 | // TODO: Check if we have stale events lingering for a long time, which could be caused by 12 | // the Swift wrapper not sending us (say) finger ended events. If so, we might need to 13 | // cull fingers (or pencils?) if we go for a certain amount of time without receiving a new 14 | // event with a corresponding TouchId. 15 | 16 | // How far does the input need to move before we count it as a drag? 17 | const fingerMinDragDist = 10 18 | const pencilMinDragDist = 15 19 | 20 | export type Event = PencilEvent | FingerEvent 21 | export type InputState = PencilState | FingerState 22 | 23 | export type EventType = Event["type"] 24 | export type EventState = "began" | "moved" | "ended" 25 | export type TouchId = number 26 | 27 | // This is hacked in from PlayBook as part of the prep for LIVE — redundant with other stuff here, sorry past-Ivan 28 | export type NativeEventType = "pencil" | "finger" 29 | export type NativeEventPhase = "began" | "moved" | "ended" 30 | export type NativeEvent = { 31 | id: TouchId 32 | type: NativeEventType 33 | phase: NativeEventPhase 34 | predicted: boolean 35 | position: Position 36 | worldPos: Position 37 | pressure: number 38 | altitude: number 39 | azimuth: number 40 | rollAngle: number 41 | radius: number 42 | timestamp: number 43 | } 44 | 45 | interface SharedEventProperties { 46 | state: EventState 47 | id: TouchId 48 | position: Position 49 | timestamp: number 50 | radius: number 51 | } 52 | 53 | export interface PencilEvent extends SharedEventProperties { 54 | type: "pencil" 55 | pressure: number 56 | altitude: number 57 | azimuth: number 58 | } 59 | 60 | export interface FingerEvent extends SharedEventProperties { 61 | type: "finger" 62 | } 63 | 64 | interface SharedStateProperties { 65 | down: boolean // Is the touch currently down? 66 | drag: boolean // Has the touch moved at least a tiny bit since being put down? 67 | dragDist: number // How far has the touch moved? 68 | // TODO — do we want to store the original & current *event* instead of cherry-picking their properties? 69 | position: Position // Where is the touch now? 70 | originalPosition: Position // Where was the touch initially put down? 71 | } 72 | 73 | export interface PencilState extends SharedStateProperties { 74 | event: PencilEvent // What's the current (or most recent) event that has contributed to the state? 75 | } 76 | export interface FingerState extends SharedStateProperties { 77 | id: TouchId // What's the ID of this finger? 78 | event: FingerEvent // What's the current (or most recent) event that has contributed to the state? 79 | } 80 | 81 | type ApplyEvent = (event: Event, state: InputState) => void 82 | 83 | export default class Events { 84 | events: Event[] = [] 85 | pencilState: PencilState | null = null 86 | fingerStates: FingerState[] = [] 87 | forcePseudo: number = 0 88 | 89 | constructor(private applyEvent: ApplyEvent) { 90 | this.setupFallbackEvents() 91 | this.setupNativeEventHandler() 92 | } 93 | 94 | update() { 95 | for (const event of this.events) { 96 | let state: InputState 97 | 98 | // Tempted to make this a dynamic dispatch 99 | // prettier-ignore 100 | if (event.type === 'finger') { 101 | switch(event.state) { 102 | case 'began': state = this.fingerBegan(event); break; 103 | case 'moved': state = this.fingerMoved(event); break; 104 | case 'ended': state = this.fingerEnded(event); break; 105 | } 106 | } else { 107 | switch(event.state) { 108 | case 'began': state = this.pencilBegan(event); break; 109 | case 'moved': state = this.pencilMoved(event); break; 110 | case 'ended': state = this.pencilEnded(event); break; 111 | } 112 | } 113 | 114 | this.applyEvent(event, state) 115 | 116 | // Remove states that are no longer down 117 | // prettier-ignore 118 | if (this.pencilState?.down === false) { this.pencilState = null } 119 | this.fingerStates = this.fingerStates.filter((state) => state.down) 120 | } 121 | 122 | this.events = [] 123 | } 124 | 125 | private mouseEvent(e: MouseEvent, state: EventState) { 126 | this.events.push({ 127 | position: { x: e.clientX, y: e.clientY }, 128 | id: -1, 129 | state, 130 | type: PenToggle.active ? "pencil" : "finger", 131 | timestamp: performance.now(), 132 | radius: 1, 133 | altitude: 0, 134 | azimuth: 0, 135 | pressure: 1 136 | }) 137 | } 138 | 139 | keymap: Record = {} 140 | 141 | private keyboardEvent(e: KeyboardEvent, state: EventState) { 142 | const k = keyName(e) 143 | 144 | if (state === "began" && this.keymap[k]) { 145 | return 146 | } else if (state === "began") { 147 | this.keymap[k] = true 148 | } else { 149 | delete this.keymap[k] 150 | } 151 | 152 | this.forcePseudo = [this.keymap["1"], this.keymap["2"], this.keymap["3"], this.keymap["4"]].lastIndexOf(true) + 1 153 | 154 | if (state === "began") { 155 | if (k === "space") { 156 | PenToggle.toggle(true) 157 | } else if (k === "Tab") { 158 | MetaToggle.toggle() 159 | } 160 | } else if (state === "ended") { 161 | if (k === "space") { 162 | PenToggle.toggle(false) 163 | } 164 | } 165 | } 166 | 167 | private setupFallbackEvents() { 168 | Config.fallback = true 169 | window.onpointerdown = (e: MouseEvent) => this.mouseEvent(e, "began") 170 | window.onpointermove = (e: MouseEvent) => this.mouseEvent(e, "moved") 171 | window.onpointerup = (e: MouseEvent) => this.mouseEvent(e, "ended") 172 | window.onkeydown = (e: KeyboardEvent) => this.keyboardEvent(e, "began") 173 | window.onkeyup = (e: KeyboardEvent) => this.keyboardEvent(e, "ended") 174 | } 175 | 176 | private disableFallbackEvents() { 177 | Config.fallback = false 178 | window.onmousedown = null 179 | window.onmousemove = null 180 | window.onmouseup = null 181 | window.onkeydown = null 182 | window.onkeyup = null 183 | } 184 | 185 | // prettier-ignore 186 | private setupNativeEventHandler() { 187 | (window as any).wrapperEvents = (nativeEvents: NativeEvent[]) => { 188 | this.disableFallbackEvents(); 189 | for (const nativeEvent of nativeEvents) { 190 | const { id, type, phase, timestamp, position, radius, pressure, altitude, azimuth } = nativeEvent; 191 | const sharedProperties = { id, state: phase, type, timestamp, position, radius }; 192 | const event: Event = type === 'finger' 193 | ? { ...sharedProperties, type } 194 | : { ...sharedProperties, type, pressure, altitude, azimuth }; 195 | this.events.push(event); 196 | } 197 | }; 198 | } 199 | 200 | // TODO: I suspect the below functions could be made generic, to act on both pencils and fingers, 201 | // with no loss of clarity. I also suspect they could be made drastically smaller. 202 | 203 | fingerBegan(event: FingerEvent, down = true) { 204 | const state: FingerState = { 205 | id: event.id, 206 | down, 207 | drag: false, 208 | dragDist: 0, 209 | position: event.position, 210 | originalPosition: event.position, 211 | event 212 | } 213 | this.fingerStates.push(state) 214 | return state 215 | } 216 | 217 | pencilBegan(event: PencilEvent, down = true) { 218 | this.pencilState = { 219 | down, 220 | drag: false, 221 | dragDist: 0, 222 | position: event.position, 223 | originalPosition: event.position, 224 | event 225 | } 226 | return this.pencilState 227 | } 228 | 229 | fingerMoved(event: FingerEvent) { 230 | let state = this.fingerStates.find((state) => state.id === event.id) 231 | if (!state) { 232 | state = this.fingerBegan(event, false) 233 | } 234 | state.dragDist = Vec.dist(event.position, state.originalPosition!) 235 | state.drag ||= state.dragDist > fingerMinDragDist 236 | state.position = event.position 237 | state.event = event 238 | return state 239 | } 240 | 241 | pencilMoved(event: PencilEvent) { 242 | let state = this.pencilState 243 | if (!state) { 244 | state = this.pencilBegan(event, false) 245 | } 246 | state.dragDist = Vec.dist(event.position, state.originalPosition!) 247 | state.drag ||= state.dragDist > pencilMinDragDist 248 | state.position = event.position 249 | state.event = event 250 | return state 251 | } 252 | 253 | fingerEnded(event: FingerEvent) { 254 | let state = this.fingerStates.find((state) => state.id === event.id) 255 | if (!state) { 256 | state = this.fingerBegan(event, false) 257 | } 258 | state.down = false 259 | state.event = event 260 | return state 261 | } 262 | 263 | pencilEnded(event: PencilEvent) { 264 | let state = this.pencilState 265 | if (!state) { 266 | ;(state = this.pencilBegan(event)), false 267 | } 268 | state.down = false 269 | state.event = event 270 | return state 271 | } 272 | } 273 | 274 | function keyName(e: KeyboardEvent) { 275 | return e.key.replace(" ", "space") 276 | } 277 | -------------------------------------------------------------------------------- /App/style.css: -------------------------------------------------------------------------------- 1 | /* THEME LIGHTNESS VALUES ************************************************************************* 2 | * 0 is lowest contrast and 5 is highest contrast against the background. 3 | * Do tweak these, or add new themes in index.html 4 | */ 5 | 6 | :root[theme*="light"] { 7 | --L0: 90%; 8 | --L1: 72%; 9 | --L2: 54%; 10 | --L3: 36%; 11 | --L4: 18%; 12 | --L5: 0%; 13 | } 14 | 15 | :root[theme*="dark"] { 16 | --L0: 10%; 17 | --L1: 28%; 18 | --L2: 46%; 19 | --L3: 64%; 20 | --L4: 82%; 21 | --L5: 100%; 22 | } 23 | 24 | /* Generate a range of greyscale colors based on the above lightness values */ 25 | :root { 26 | --grey0: lch(var(--L0) 0 0); 27 | --grey1: lch(var(--L1) 0 0); 28 | --grey2: lch(var(--L2) 0 0); 29 | --grey3: lch(var(--L3) 0 0); 30 | --grey4: lch(var(--L4) 0 0); 31 | --grey5: lch(var(--L5) 0 0); 32 | } 33 | 34 | /* SEMANTIC VARIABLES ***************************************************************************** 35 | * Here we assign the greyscale colors established above onto meaningful names for our elements. 36 | * We'll use these names throughout the rest of the stylesheet, and not refer to specific colors. 37 | */ 38 | 39 | :root { 40 | /* These colors are the same in concrete and meta mode */ 41 | --bg-color: var(--grey0); 42 | --desire: var(--grey2); 43 | --eraser: var(--grey1); 44 | --gesture-circle: var(--grey2); 45 | --pseudo-touch: var(--grey2); 46 | --status-text: var(--grey3); 47 | 48 | /* These colors are different in concrete and meta mode */ 49 | --constrained: transparent; 50 | --gizmo-thick: transparent; 51 | --handle-fill: lch(var(--L5) 0 0 / 0.15); 52 | --ink-color: lch(var(--L5) 0 0 / 0.6); 53 | --meta-toggle: var(--grey5); 54 | --meta-circles: var(--grey5); 55 | --meta-splats: var(--grey5); 56 | --property-picker-box: transparent; 57 | --property-picker-text: transparent; 58 | --token-fill: transparent; 59 | --token-frac-text: transparent; 60 | --token-locked-fill: transparent; 61 | --token-stroke: transparent; 62 | --token-text: transparent; 63 | --uncomfortable: lch(var(--L4), 0 0 / 0.05); 64 | --unconstrained: transparent; 65 | --wire: transparent; 66 | 67 | &[meta-mode] { 68 | --constrained: var(--grey5); 69 | --gizmo-thick: var(--grey5); 70 | --handle-fill: transparent; 71 | --ink-color: lch(var(--L5) 0 0 / 0.3); 72 | --property-picker-box: var(--grey5); 73 | --property-picker-text: var(--grey5); 74 | --token-fill: var(--bg-color); 75 | --token-frac-text: var(--grey4); 76 | --token-locked-fill: var(--grey1); 77 | --token-stroke: var(--grey5); 78 | --token-text: var(--grey5); 79 | --uncomfortable: lch(var(--L4), 0 0 / 0.3); 80 | --unconstrained: var(--grey3); 81 | --wire: var(--grey4); 82 | } 83 | } 84 | 85 | /* THEME-SPECIFIC OVERRIDES *********************************************************************** 86 | * The above assignments are the default used regardless of theme, but you can override them here. 87 | */ 88 | 89 | :root[theme*="color"] { 90 | --purple: color(display-p3 0.5 0 1); 91 | --blue: color(display-p3 0.4 0.8 1); 92 | --green: color(display-p3 0 0.5 0.5); 93 | --yellow: color(display-p3 1 0.7 0); 94 | 95 | --desire: var(--blue); 96 | --eraser: color(display-p3 1 0.4 0); 97 | --gesture-circle: var(--yellow); 98 | --meta-circles: color(display-p3 1 0.7 0); 99 | --pseudo-touch: var(--yellow); 100 | --uncomfortable: color(display-p3 1 0.3 0.2 / 0.05); 101 | 102 | &[meta-mode] { 103 | --constrained: var(--green); 104 | --meta-toggle: color(display-p3 1 0.7 0); 105 | --uncomfortable: color(display-p3 1 0.3 0.2 / 0.3); 106 | --wire: var(--yellow); 107 | } 108 | } 109 | 110 | :root[theme*="color"][theme*="dark"] { 111 | --blue: color(display-p3 0.3 0.6 1); 112 | --green: color(display-p3 0 1 1); 113 | --yellow: color(display-p3 1 0.7 0); 114 | } 115 | 116 | /* MISC CONFIG ***********************************************************************************/ 117 | 118 | :root { 119 | --haste: 1.4s; 120 | --taste: 1.4; 121 | --paste: var(--haste) cubic-bezier(0, var(--taste), 0, 1); 122 | --transition-fill: fill var(--paste); 123 | --transition-stroke: stroke var(--paste); 124 | } 125 | 126 | /* RESETS & BASICS *******************************************************************************/ 127 | 128 | *, 129 | *::before, 130 | *::after { 131 | box-sizing: border-box; 132 | margin: 0; 133 | overflow-wrap: break-word; 134 | hyphens: auto; 135 | touch-action: none; 136 | -webkit-user-drag: none; 137 | -webkit-user-select: none; 138 | user-select: none; 139 | cursor: default; 140 | } 141 | 142 | html, 143 | body, 144 | svg { 145 | position: absolute; 146 | top: 0; 147 | left: 0; 148 | width: 100%; 149 | height: 100vh; 150 | overflow: hidden; 151 | } 152 | 153 | body { 154 | font-family: system-ui; 155 | stroke-linecap: round; 156 | stroke-linejoin: round; 157 | background-color: var(--bg-color); 158 | transition: background-color 0.8s cubic-bezier(0.5, 1, 0.5, 1); 159 | } 160 | 161 | svg * { 162 | transition: var(--transition-fill), var(--transition-stroke); 163 | } 164 | 165 | /* ALL THE THINGS ********************************************************************************/ 166 | 167 | .status-text { 168 | fill: transparent; 169 | text-anchor: middle; 170 | translate: 50vw calc(100vh - 15px); 171 | 172 | &[is-visible] { 173 | fill: var(--status-text); 174 | } 175 | } 176 | 177 | .meta-toggle { 178 | animation: zoom-in 0.7s cubic-bezier(0, 1.2, 0, 1) backwards; 179 | 180 | transition: scale 0.4s 0.2s cubic-bezier(1, 0, 0, 1), translate 0.5s cubic-bezier(0.4, 1.3, 0.1, 0.98); 181 | 182 | &.dragging { 183 | scale: 1.8; 184 | transition: scale 2s cubic-bezier(0, 1.2, 0, 1), translate 0.05s linear; 185 | } 186 | 187 | & circle { 188 | fill: var(--meta-circles); 189 | transition: scale 2s cubic-bezier(0, 1.3, 0, 1); 190 | } 191 | 192 | .inner { 193 | fill: var(--bg-color); 194 | scale: 1.1; 195 | } 196 | 197 | .secret { 198 | fill: var(--meta-toggle); 199 | scale: 0.25; 200 | transition: scale 2s cubic-bezier(0, 1.3, 0, 1), fill 0.1s linear; 201 | } 202 | 203 | .splats { 204 | stroke: var(--meta-splats); 205 | stroke-width: 7; 206 | fill: none; 207 | scale: 0.4; 208 | transition: none; 209 | } 210 | 211 | .splat { 212 | rotate: var(--rotate); 213 | transform: translateX(var(--translate)) scale(var(--scaleX), var(--scaleY)); 214 | transition: transform 1s var(--delay) cubic-bezier(0, 1.2, 0, 1); 215 | 216 | & polyline { 217 | transition: translate 0.3s 0.45s cubic-bezier(0.3, 0.6, 0, 1), scale 0.6s cubic-bezier(0.3, 0, 0.5, 1); 218 | } 219 | } 220 | 221 | &:not(.active).dragging .splat { 222 | transform: translateX(var(--translate)); 223 | & polyline { 224 | scale: 8; 225 | translate: calc(-0.5 * var(--translate)) 0; 226 | animation: spin 60s infinite both linear var(--flip); 227 | transition: translate 2s calc(var(--delay) * 5) cubic-bezier(0, 1.2, 0, 1), 228 | scale 2s calc(var(--delay) * 5) cubic-bezier(0, 1.2, 0, 1); 229 | } 230 | } 231 | 232 | &.active { 233 | .inner { 234 | scale: 0.85; 235 | transition-delay: 0.1s; 236 | } 237 | 238 | .secret { 239 | scale: 0.7; 240 | transition-delay: 0.2s; 241 | } 242 | 243 | .splat { 244 | transform: translateX(0) scale(var(--scaleX), var(--scaleY)); 245 | transition: transform 0.2s calc(var(--delay) / 2) cubic-bezier(0, 0, 0, 1); 246 | } 247 | } 248 | } 249 | 250 | @keyframes spin { 251 | to { 252 | rotate: 360deg; 253 | } 254 | } 255 | 256 | @keyframes zoom-in { 257 | from { 258 | scale: 0; 259 | } 260 | } 261 | 262 | .pseudo-touch { 263 | fill: var(--pseudo-touch); 264 | } 265 | 266 | .stroke { 267 | fill: none; 268 | stroke: var(--ink-color); 269 | stroke-width: 2; 270 | } 271 | 272 | .go-everywhere { 273 | fill: var(--purple); 274 | } 275 | 276 | .handle { 277 | & circle { 278 | fill: transparent; 279 | } 280 | 281 | & path { 282 | fill: none; 283 | stroke-width: 2; 284 | } 285 | 286 | &[is-canonical] { 287 | circle { 288 | fill: var(--handle-fill); 289 | } 290 | 291 | &[goes-anywhere] circle { 292 | scale: 0.7; 293 | fill: var(--purple); 294 | } 295 | 296 | & path { 297 | stroke: var(--unconstrained); 298 | } 299 | } 300 | 301 | --arc-rotate: rotate 0.2s cubic-bezier(0.1, 0.4, 0.4, 0.9); 302 | 303 | .arcs1, 304 | .arcs2 { 305 | transition: var(--arc-rotate), opacity 0.2s step-end; 306 | } 307 | 308 | .arcs2 { 309 | opacity: 0; 310 | } 311 | 312 | &[has-pin] { 313 | &[is-canonical] path { 314 | stroke: var(--constrained); 315 | stroke-width: 3; 316 | } 317 | 318 | & .arcs1 { 319 | rotate: -18deg; 320 | } 321 | 322 | & .arcs2 { 323 | rotate: 18deg; 324 | opacity: 1; 325 | transition: var(--arc-rotate); 326 | } 327 | } 328 | } 329 | 330 | .gizmo { 331 | fill: none; 332 | stroke-width: 2; 333 | 334 | .thick { 335 | stroke-width: 30; 336 | stroke: var(--gizmo-thick); 337 | opacity: 0.07; 338 | transition: opacity var(--paste), var(--transition-stroke); 339 | } 340 | 341 | --fade: opacity 0.1s linear; 342 | 343 | .arrow { 344 | stroke-width: 2; 345 | stroke: var(--unconstrained); 346 | transition: var(--fade), var(--transition-stroke); 347 | } 348 | 349 | .arcs { 350 | transition: var(--fade); 351 | } 352 | 353 | .arcs path { 354 | stroke: var(--unconstrained); 355 | transition: transform 0.4s cubic-bezier(0, 1.2, 0, 1), var(--transition-stroke); 356 | } 357 | 358 | .arc2 { 359 | rotate: 180deg; 360 | } 361 | 362 | &[is-constrained] { 363 | .arcs path { 364 | stroke: var(--constrained); 365 | } 366 | 367 | .thick { 368 | stroke: var(--constrained); 369 | opacity: 0.15; 370 | } 371 | } 372 | 373 | &[is-uncomfortable] .thick { 374 | stroke: var(--uncomfortable); 375 | stroke-dasharray: 20 20; 376 | opacity: 1; 377 | } 378 | } 379 | 380 | .token-box { 381 | fill: var(--token-fill); 382 | stroke: var(--token-stroke); 383 | stroke-width: 1; 384 | 385 | [is-locked] > & { 386 | fill: var(--token-locked-fill); 387 | } 388 | } 389 | 390 | .hollow-box { 391 | fill: none; 392 | /* stroke: var(--token-stroke); */ 393 | stroke-width: 0.5; 394 | } 395 | 396 | .token-text { 397 | fill: var(--token-text); 398 | translate: 5px 24px; 399 | font-size: 24px; 400 | font-family: monospace; 401 | } 402 | 403 | .token-frac-text { 404 | fill: var(--token-frac-text); 405 | translate: 5px 24px; 406 | font-size: 10px; 407 | font-family: monospace; 408 | } 409 | 410 | .property-picker-box { 411 | stroke: var(--property-picker-box); 412 | fill: var(--token-fill); 413 | stroke-width: 1; 414 | } 415 | 416 | .property-picker-text { 417 | fill: var(--property-picker-text); 418 | font-size: 18px; 419 | font-family: monospace; 420 | } 421 | 422 | .wire { 423 | fill: none; 424 | stroke: var(--wire); 425 | stroke-width: 1.5; 426 | stroke-dasharray: 16 4; 427 | } 428 | 429 | .gesture { 430 | & circle { 431 | fill: var(--gesture-circle); 432 | } 433 | } 434 | 435 | .eraser { 436 | stroke: var(--eraser); 437 | & line { 438 | animation: eraser 0.15s cubic-bezier(0.1, 0.4, 0.5, 0.8) both; 439 | } 440 | } 441 | 442 | @keyframes eraser { 443 | to { 444 | translate: 0px 10px; 445 | scale: 3 0; 446 | } 447 | } 448 | 449 | .desire { 450 | fill: var(--desire); 451 | } 452 | 453 | #perf { 454 | position: absolute; 455 | top: 1em; 456 | left: 50%; 457 | font-family: monospace; 458 | font-size: 12px; 459 | translate: -50% 0; 460 | } 461 | 462 | .pen-toggle { 463 | display: none; 464 | 465 | &.showing { 466 | display: block; 467 | } 468 | 469 | &.active { 470 | fill: var(--blue); 471 | } 472 | } 473 | -------------------------------------------------------------------------------- /Wrapper/Inkling.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 63; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 8425B14A2BE97D9900EE83EB /* Inkling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8425B1492BE97D9900EE83EB /* Inkling.swift */; }; 11 | 84F61C2B2CB72958009D3F05 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 84F61C2A2CB72958009D3F05 /* Assets.xcassets */; }; 12 | /* End PBXBuildFile section */ 13 | 14 | /* Begin PBXFileReference section */ 15 | 8425B1462BE97D9900EE83EB /* Inkling.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Inkling.app; sourceTree = BUILT_PRODUCTS_DIR; }; 16 | 8425B1492BE97D9900EE83EB /* Inkling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inkling.swift; sourceTree = ""; }; 17 | 84F61C2A2CB72958009D3F05 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 18 | /* End PBXFileReference section */ 19 | 20 | /* Begin PBXFrameworksBuildPhase section */ 21 | 8425B1432BE97D9900EE83EB /* Frameworks */ = { 22 | isa = PBXFrameworksBuildPhase; 23 | buildActionMask = 2147483647; 24 | files = ( 25 | ); 26 | runOnlyForDeploymentPostprocessing = 0; 27 | }; 28 | /* End PBXFrameworksBuildPhase section */ 29 | 30 | /* Begin PBXGroup section */ 31 | 8425B13D2BE97D9900EE83EB = { 32 | isa = PBXGroup; 33 | children = ( 34 | 8425B1482BE97D9900EE83EB /* Inkling */, 35 | 8425B1472BE97D9900EE83EB /* Products */, 36 | ); 37 | sourceTree = ""; 38 | }; 39 | 8425B1472BE97D9900EE83EB /* Products */ = { 40 | isa = PBXGroup; 41 | children = ( 42 | 8425B1462BE97D9900EE83EB /* Inkling.app */, 43 | ); 44 | name = Products; 45 | sourceTree = ""; 46 | }; 47 | 8425B1482BE97D9900EE83EB /* Inkling */ = { 48 | isa = PBXGroup; 49 | children = ( 50 | 8425B1492BE97D9900EE83EB /* Inkling.swift */, 51 | 84F61C2A2CB72958009D3F05 /* Assets.xcassets */, 52 | ); 53 | path = Inkling; 54 | sourceTree = ""; 55 | }; 56 | /* End PBXGroup section */ 57 | 58 | /* Begin PBXNativeTarget section */ 59 | 8425B1452BE97D9900EE83EB /* Inkling */ = { 60 | isa = PBXNativeTarget; 61 | buildConfigurationList = 8425B1542BE97D9A00EE83EB /* Build configuration list for PBXNativeTarget "Inkling" */; 62 | buildPhases = ( 63 | 8425B1422BE97D9900EE83EB /* Sources */, 64 | 8425B1432BE97D9900EE83EB /* Frameworks */, 65 | 8425B1442BE97D9900EE83EB /* Resources */, 66 | ); 67 | buildRules = ( 68 | ); 69 | dependencies = ( 70 | ); 71 | name = Inkling; 72 | productName = Inkling; 73 | productReference = 8425B1462BE97D9900EE83EB /* Inkling.app */; 74 | productType = "com.apple.product-type.application"; 75 | }; 76 | /* End PBXNativeTarget section */ 77 | 78 | /* Begin PBXProject section */ 79 | 8425B13E2BE97D9900EE83EB /* Project object */ = { 80 | isa = PBXProject; 81 | attributes = { 82 | BuildIndependentTargetsInParallel = 1; 83 | LastSwiftUpdateCheck = 1530; 84 | LastUpgradeCheck = 1600; 85 | TargetAttributes = { 86 | 8425B1452BE97D9900EE83EB = { 87 | CreatedOnToolsVersion = 15.3; 88 | }; 89 | }; 90 | }; 91 | buildConfigurationList = 8425B1412BE97D9900EE83EB /* Build configuration list for PBXProject "Inkling" */; 92 | compatibilityVersion = "Xcode 15.3"; 93 | developmentRegion = en; 94 | hasScannedForEncodings = 0; 95 | knownRegions = ( 96 | en, 97 | ); 98 | mainGroup = 8425B13D2BE97D9900EE83EB; 99 | productRefGroup = 8425B1472BE97D9900EE83EB /* Products */; 100 | projectDirPath = ""; 101 | projectRoot = ""; 102 | targets = ( 103 | 8425B1452BE97D9900EE83EB /* Inkling */, 104 | ); 105 | }; 106 | /* End PBXProject section */ 107 | 108 | /* Begin PBXResourcesBuildPhase section */ 109 | 8425B1442BE97D9900EE83EB /* Resources */ = { 110 | isa = PBXResourcesBuildPhase; 111 | buildActionMask = 2147483647; 112 | files = ( 113 | 84F61C2B2CB72958009D3F05 /* Assets.xcassets in Resources */, 114 | ); 115 | runOnlyForDeploymentPostprocessing = 0; 116 | }; 117 | /* End PBXResourcesBuildPhase section */ 118 | 119 | /* Begin PBXSourcesBuildPhase section */ 120 | 8425B1422BE97D9900EE83EB /* Sources */ = { 121 | isa = PBXSourcesBuildPhase; 122 | buildActionMask = 2147483647; 123 | files = ( 124 | 8425B14A2BE97D9900EE83EB /* Inkling.swift in Sources */, 125 | ); 126 | runOnlyForDeploymentPostprocessing = 0; 127 | }; 128 | /* End PBXSourcesBuildPhase section */ 129 | 130 | /* Begin XCBuildConfiguration section */ 131 | 8425B1522BE97D9A00EE83EB /* Debug */ = { 132 | isa = XCBuildConfiguration; 133 | buildSettings = { 134 | ALWAYS_SEARCH_USER_PATHS = NO; 135 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 136 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 137 | ASSETCATALOG_COMPILER_SKIP_APP_STORE_DEPLOYMENT = YES; 138 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 139 | CLANG_WARN_BOOL_CONVERSION = YES; 140 | CLANG_WARN_COMMA = YES; 141 | CLANG_WARN_CONSTANT_CONVERSION = YES; 142 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 143 | CLANG_WARN_EMPTY_BODY = YES; 144 | CLANG_WARN_ENUM_CONVERSION = YES; 145 | CLANG_WARN_INFINITE_RECURSION = YES; 146 | CLANG_WARN_INT_CONVERSION = YES; 147 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 148 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 149 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 150 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 151 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 152 | CLANG_WARN_STRICT_PROTOTYPES = YES; 153 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 154 | CLANG_WARN_UNREACHABLE_CODE = YES; 155 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 156 | ENABLE_STRICT_OBJC_MSGSEND = YES; 157 | ENABLE_TESTABILITY = YES; 158 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 159 | GCC_NO_COMMON_BLOCKS = YES; 160 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 161 | GCC_WARN_ABOUT_RETURN_TYPE = YES; 162 | GCC_WARN_UNDECLARED_SELECTOR = YES; 163 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 164 | GCC_WARN_UNUSED_FUNCTION = YES; 165 | GCC_WARN_UNUSED_VARIABLE = YES; 166 | IPHONEOS_DEPLOYMENT_TARGET = 17.4; 167 | ONLY_ACTIVE_ARCH = YES; 168 | SDKROOT = iphoneos; 169 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 170 | SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; 171 | SWIFT_VERSION = 5.0; 172 | }; 173 | name = Debug; 174 | }; 175 | 8425B1532BE97D9A00EE83EB /* Release */ = { 176 | isa = XCBuildConfiguration; 177 | buildSettings = { 178 | ALWAYS_SEARCH_USER_PATHS = NO; 179 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 180 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 181 | ASSETCATALOG_COMPILER_SKIP_APP_STORE_DEPLOYMENT = YES; 182 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 183 | CLANG_WARN_BOOL_CONVERSION = YES; 184 | CLANG_WARN_COMMA = YES; 185 | CLANG_WARN_CONSTANT_CONVERSION = YES; 186 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 187 | CLANG_WARN_EMPTY_BODY = YES; 188 | CLANG_WARN_ENUM_CONVERSION = YES; 189 | CLANG_WARN_INFINITE_RECURSION = YES; 190 | CLANG_WARN_INT_CONVERSION = YES; 191 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 192 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 193 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 194 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 195 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 196 | CLANG_WARN_STRICT_PROTOTYPES = YES; 197 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 198 | CLANG_WARN_UNREACHABLE_CODE = YES; 199 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 200 | ENABLE_STRICT_OBJC_MSGSEND = YES; 201 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 202 | GCC_NO_COMMON_BLOCKS = YES; 203 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 204 | GCC_WARN_ABOUT_RETURN_TYPE = YES; 205 | GCC_WARN_UNDECLARED_SELECTOR = YES; 206 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 207 | GCC_WARN_UNUSED_FUNCTION = YES; 208 | GCC_WARN_UNUSED_VARIABLE = YES; 209 | IPHONEOS_DEPLOYMENT_TARGET = 17.4; 210 | ONLY_ACTIVE_ARCH = YES; 211 | SDKROOT = iphoneos; 212 | SWIFT_COMPILATION_MODE = wholemodule; 213 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 214 | SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; 215 | SWIFT_VERSION = 5.0; 216 | }; 217 | name = Release; 218 | }; 219 | 8425B1552BE97D9A00EE83EB /* Debug */ = { 220 | isa = XCBuildConfiguration; 221 | buildSettings = { 222 | CLANG_ENABLE_OBJC_WEAK = YES; 223 | CODE_SIGN_IDENTITY = "Apple Development"; 224 | CODE_SIGN_STYLE = Automatic; 225 | CURRENT_PROJECT_VERSION = 1; 226 | DEVELOPMENT_TEAM = ""; 227 | GENERATE_INFOPLIST_FILE = YES; 228 | INFOPLIST_KEY_CFBundleDisplayName = Inkling; 229 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 230 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 231 | INFOPLIST_KEY_UIRequiresFullScreen = YES; 232 | INFOPLIST_KEY_UIStatusBarHidden = YES; 233 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 234 | INFOPLIST_KEY_UISupportsDocumentBrowser = YES; 235 | IPHONEOS_DEPLOYMENT_TARGET = 17.5; 236 | MARKETING_VERSION = 1; 237 | PRODUCT_BUNDLE_IDENTIFIER = com.inkandswitch.inkling; 238 | PRODUCT_NAME = "$(TARGET_NAME)"; 239 | PROVISIONING_PROFILE_SPECIFIER = ""; 240 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 241 | SUPPORTS_MACCATALYST = NO; 242 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 243 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 244 | TARGETED_DEVICE_FAMILY = 2; 245 | }; 246 | name = Debug; 247 | }; 248 | 8425B1562BE97D9A00EE83EB /* Release */ = { 249 | isa = XCBuildConfiguration; 250 | buildSettings = { 251 | CLANG_ENABLE_OBJC_WEAK = YES; 252 | CODE_SIGN_IDENTITY = "Apple Development"; 253 | CODE_SIGN_STYLE = Automatic; 254 | CURRENT_PROJECT_VERSION = 1; 255 | DEVELOPMENT_TEAM = ""; 256 | GENERATE_INFOPLIST_FILE = YES; 257 | INFOPLIST_KEY_CFBundleDisplayName = Inkling; 258 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 259 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 260 | INFOPLIST_KEY_UIRequiresFullScreen = YES; 261 | INFOPLIST_KEY_UIStatusBarHidden = YES; 262 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 263 | INFOPLIST_KEY_UISupportsDocumentBrowser = YES; 264 | IPHONEOS_DEPLOYMENT_TARGET = 17.5; 265 | MARKETING_VERSION = 1; 266 | PRODUCT_BUNDLE_IDENTIFIER = com.inkandswitch.inkling; 267 | PRODUCT_NAME = "$(TARGET_NAME)"; 268 | PROVISIONING_PROFILE_SPECIFIER = ""; 269 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 270 | SUPPORTS_MACCATALYST = NO; 271 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 272 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 273 | TARGETED_DEVICE_FAMILY = 2; 274 | }; 275 | name = Release; 276 | }; 277 | /* End XCBuildConfiguration section */ 278 | 279 | /* Begin XCConfigurationList section */ 280 | 8425B1412BE97D9900EE83EB /* Build configuration list for PBXProject "Inkling" */ = { 281 | isa = XCConfigurationList; 282 | buildConfigurations = ( 283 | 8425B1522BE97D9A00EE83EB /* Debug */, 284 | 8425B1532BE97D9A00EE83EB /* Release */, 285 | ); 286 | defaultConfigurationIsVisible = 0; 287 | defaultConfigurationName = Release; 288 | }; 289 | 8425B1542BE97D9A00EE83EB /* Build configuration list for PBXNativeTarget "Inkling" */ = { 290 | isa = XCConfigurationList; 291 | buildConfigurations = ( 292 | 8425B1552BE97D9A00EE83EB /* Debug */, 293 | 8425B1562BE97D9A00EE83EB /* Release */, 294 | ); 295 | defaultConfigurationIsVisible = 0; 296 | defaultConfigurationName = Release; 297 | }; 298 | /* End XCConfigurationList section */ 299 | }; 300 | rootObject = 8425B13E2BE97D9900EE83EB /* Project object */; 301 | } 302 | --------------------------------------------------------------------------------