= {}
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 |
--------------------------------------------------------------------------------