clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / edge / src / languages / squaring.ts
squaring.ts 14.9K
1import { RenderError } from '../runtime/errors'2import { POLYHEDRA, POLYHEDRON_NAMES } from './squaring-polyhedra'34export type SquaringView = 'rectangle' | 'circuit' | 'overlay' | 'both'5export type SquaringLabels = 'sides' | 'none'67/** A side length: a constant, a linear expression in named unknowns, or `?` for "as wide as the gap". */8export interface SideExpression {9 text: string10 constant: number11 terms: Record<string, number>12 auto: boolean13}1415export interface SquareGroup {16 sides: SideExpression[]17 under: string[] | null18 line: number19}2021export interface SquaringWire {22 from: string23 to: string24 line: number25}2627export interface SquaringRectangleSource {28 width: number | null29 height: number | null30 groups: SquareGroup[]31 symbolic: boolean32 line: number33}3435export interface SquaringNetworkSource {36 positive: string | null37 negative: string | null38 searchBattery: boolean39 nodes: string[]40 wires: SquaringWire[]41 line: number42}4344export interface SquaringDocument {45 view: SquaringView46 labels: SquaringLabels47 title: string48 rectangle: SquaringRectangleSource | null49 network: SquaringNetworkSource | null50}5152const IDENTIFIER = /^[A-Za-z0-9_]+$/53const SYMBOL = /^[A-Za-z_][A-Za-z0-9_]*$/54const CHAIN = /^[A-Za-z0-9_]+(?:\s*-+\s*[A-Za-z0-9_]+)+$/55const RECTANGLE = /^rectangle\s+(\d+|\?)\s*(?:x|×|by)\s*(\d+|\?)$/i56const TERM = /^([+-]?)(\d*)([A-Za-z_][A-Za-z0-9_]*)?$/57const VIEWS: readonly SquaringView[] = ['rectangle', 'circuit', 'overlay', 'both']58const LABELS: readonly SquaringLabels[] = ['sides', 'none']59const RESERVED = new Set(['under', 'squares', 'rectangle', 'battery', 'wire', 'face', 'polyhedron', 'title', 'any'])6061function sourceError(line: number, message: string): never {62 throw new RenderError(422, 'invalid_source', `Line ${line}: ${message}`)63}6465function stripComment(raw: string): string {66 let quoted = false67 for (let index = 0; index < raw.length; index += 1) {68 const character = raw[index]69 if (character === '"') quoted = !quoted70 else if (character === '#' && !quoted) {71 // "#3" refers to the third square; a comment needs a space (or line start) before the # and no digit after it.72 const atBoundary = index === 0 || /\s/.test(raw[index - 1] ?? '')73 if (atBoundary && !/^\d/.test(raw[index + 1] ?? '')) return raw.slice(0, index)74 }75 }76 return raw77}7879function identifier(value: string, line: number, role: string): string {80 if (!IDENTIFIER.test(value)) sourceError(line, `${role} "${value}" must use letters, digits, or underscores.`)81 return value82}8384function positiveInteger(value: string, line: number, role: string): number {85 const number = Number(value)86 if (!Number.isSafeInteger(number) || number <= 0) sourceError(line, `${role} must be a positive whole number, not "${value}".`)87 return number88}8990export function parseSideExpression(token: string, line: number): SideExpression {91 if (token === '?') return { text: '?', constant: 0, terms: {}, auto: true }92 const terms: Record<string, number> = {}93 let constant = 094 const pieces = token.match(/[+-]?[^+-]+/g)95 if (pieces === null || pieces.join('') !== token) sourceError(line, `"${token}" is not a side length. Use a number, a name such as a, or an expression such as 2a+b.`)96 for (const piece of pieces) {97 const match = TERM.exec(piece)98 if (match === null) sourceError(line, `"${token}" is not a side length. Use a number, a name such as a, or an expression such as 2a+b.`)99 const sign = match[1] === '-' ? -1 : 1100 const digits = match[2] ?? ''101 const symbol = match[3]102 if (symbol === undefined) {103 if (digits === '') sourceError(line, `"${token}" is not a side length.`)104 constant += sign * Number(digits)105 } else {106 if (RESERVED.has(symbol)) sourceError(line, `"${symbol}" is a keyword and cannot name a side.`)107 terms[symbol] = (terms[symbol] ?? 0) + sign * (digits === '' ? 1 : Number(digits))108 }109 }110 for (const [symbol, coefficient] of Object.entries(terms)) if (coefficient === 0) delete terms[symbol]111 if (Object.keys(terms).length === 0 && constant <= 0) sourceError(line, `A side length must be positive, not "${token}".`)112 return { text: token, constant, terms, auto: false }113}114115function parseSquareLine(text: string, line: number): SquareGroup[] {116 const [body, ...underParts] = text.split(/\bunder\b/)117 if (underParts.length > 1) sourceError(line, 'Write at most one "under" clause per line.')118 const spaced = (body ?? '').replaceAll(',', ' ').replaceAll('(', ' ( ').replaceAll(')', ' ) ')119 const tokens = spaced.trim().split(/\s+/).filter(token => token !== '')120 const groups: SquareGroup[] = []121 let open: SideExpression[] | null = null122 let loose: SideExpression[] = []123 for (const token of tokens) {124 if (token === '(') {125 if (open !== null) sourceError(line, 'Groups cannot be nested.')126 if (loose.length > 0) {127 groups.push({ sides: loose, under: null, line })128 loose = []129 }130 open = []131 } else if (token === ')') {132 if (open === null) sourceError(line, 'Unexpected ")".')133 if (open.length === 0) sourceError(line, 'Empty group "()".')134 groups.push({ sides: open, under: null, line })135 open = null136 } else {137 const expression = parseSideExpression(token, line)138 if (open !== null) open.push(expression)139 else loose.push(expression)140 }141 }142 if (open !== null) sourceError(line, 'Missing ")".')143 if (loose.length > 0) groups.push({ sides: loose, under: null, line })144 if (groups.length === 0) sourceError(line, 'List at least one square side.')145 const underText = underParts[0]?.trim() ?? ''146 if (underParts.length === 1) {147 if (groups.length !== 1) sourceError(line, 'A line with "under" holds exactly one group of squares.')148 const refs = underText.split(/[\s,]+/).filter(ref => ref !== '')149 if (refs.length === 0) sourceError(line, 'Name the squares this group hangs under, for example "under a b".')150 for (const ref of refs) {151 if (!(SYMBOL.test(ref) || /^\d+$/.test(ref) || /^#\d+$/.test(ref))) sourceError(line, `"${ref}" is not a square reference. Use a side name, a number, or #n for the n-th square.`)152 }153 const group = groups[0]154 if (group !== undefined) group.under = refs155 }156 return groups157}158159// Bare numbers are always squares. Named sides need a group "(a b)" or an160// "under" clause so that a stray line of words is reported, not solved.161function looksLikeSquareLine(text: string): boolean {162 const body = text.split(/\bunder\b/)[0] ?? ''163 const tokens = body.replaceAll(',', ' ').replaceAll('(', ' ').replaceAll(')', ' ').trim().split(/\s+/).filter(token => token !== '')164 if (tokens.length === 0 || !tokens.every(token => token === '?' || /^[+-]?[\dA-Za-z_+-]+$/.test(token))) return false165 const symbolic = tokens.some(token => /[A-Za-z_]/.test(token))166 return !symbolic || text.includes('(') || /\bunder\b/.test(text)167}168169export function parseSquaring(source: string): SquaringDocument {170 let view: SquaringView | null = null171 let labels: SquaringLabels | null = null172 let title = ''173 let rectangle: SquaringRectangleSource | null = null174 let battery: { positive: string | null; negative: string | null; search: boolean; line: number } | null = null175 const wires: SquaringWire[] = []176 const wireKeys = new Set<string>()177 const nodes: string[] = []178 let firstNetworkLine = 0179180 const rememberNode = (id: string): void => {181 if (!nodes.includes(id)) nodes.push(id)182 }183 const networkOnly = (line: number): void => {184 if (rectangle !== null) sourceError(line, 'A squaring is either a rectangle with squares or a network with a battery, not both.')185 if (firstNetworkLine === 0) firstNetworkLine = line186 }187 const addWire = (from: string, to: string, line: number): void => {188 if (from === to) sourceError(line, `A wire cannot connect node "${from}" to itself.`)189 networkOnly(line)190 rememberNode(from)191 rememberNode(to)192 const key = from < to ? `${from}|${to}` : `${to}|${from}`193 if (wireKeys.has(key)) return194 wireKeys.add(key)195 wires.push({ from, to, line })196 }197198 const lines = source.split(/\r?\n/)199 for (let index = 0; index < lines.length; index += 1) {200 const line = index + 1201 const text = stripComment(lines[index] ?? '').trim()202 if (text === '') continue203204 if (text.startsWith('.')) {205 const [directive = '', value = '', ...rest] = text.slice(1).split(/\s+/)206 if (rest.length > 0) sourceError(line, `The .${directive} directive takes one value.`)207 if (directive === 'view') {208 if (view !== null) sourceError(line, 'The .view directive may appear only once.')209 if (!VIEWS.includes(value as SquaringView)) sourceError(line, `Unknown view "${value}". Use rectangle, circuit, overlay, or both.`)210 view = value as SquaringView211 } else if (directive === 'labels') {212 if (labels !== null) sourceError(line, 'The .labels directive may appear only once.')213 if (!LABELS.includes(value as SquaringLabels)) sourceError(line, `Unknown labels value "${value}". Use sides or none.`)214 labels = value as SquaringLabels215 } else {216 sourceError(line, `Unknown directive ".${directive}". Use .view or .labels.`)217 }218 continue219 }220221 const [keyword = '', ...restTokens] = text.split(/\s+/)222 const rest = text.slice(keyword.length).trim()223224 if (keyword === 'title') {225 if (rest === '') sourceError(line, 'A title needs text after the keyword.')226 title = rest.startsWith('"') && rest.endsWith('"') && rest.length >= 2 ? rest.slice(1, -1) : rest227 continue228 }229230 const rectangleMatch = RECTANGLE.exec(text)231 if (rectangleMatch !== null) {232 if (rectangle !== null) sourceError(line, 'Only one rectangle may be declared.')233 if (wires.length > 0 || battery !== null) sourceError(line, 'A squaring is either a rectangle with squares or a network with a battery, not both.')234 const width = rectangleMatch[1] ?? '?'235 const height = rectangleMatch[2] ?? '?'236 rectangle = {237 width: width === '?' ? null : positiveInteger(width, line, 'The rectangle width'),238 height: height === '?' ? null : positiveInteger(height, line, 'The rectangle height'),239 groups: [],240 symbolic: false,241 line,242 }243 continue244 }245246 if (keyword === 'battery') {247 if (battery !== null) sourceError(line, 'Only one battery may be declared.')248 networkOnly(line)249 if (restTokens.length === 1 && (restTokens[0] === 'any' || restTokens[0] === '?')) {250 battery = { positive: null, negative: null, search: true, line }251 continue252 }253 if (restTokens.length !== 2) sourceError(line, 'Write the battery as "battery <positive node> <negative node>" or "battery any".')254 const positive = identifier(restTokens[0] ?? '', line, 'The positive node')255 const negative = identifier(restTokens[1] ?? '', line, 'The negative node')256 if (positive === negative) sourceError(line, 'The battery needs two different nodes.')257 rememberNode(positive)258 rememberNode(negative)259 battery = { positive, negative, search: false, line }260 continue261 }262263 if (keyword === 'wire') {264 if (restTokens.length !== 2) sourceError(line, 'Write a wire as "wire <node> <node>" or "<node> - <node>".')265 addWire(identifier(restTokens[0] ?? '', line, 'A node'), identifier(restTokens[1] ?? '', line, 'A node'), line)266 continue267 }268269 if (keyword === 'face') {270 if (restTokens.length < 3) sourceError(line, 'A face needs at least three nodes.')271 const face = restTokens.map(node => identifier(node, line, 'A node'))272 if (new Set(face).size !== face.length) sourceError(line, 'A face cannot repeat a node.')273 face.forEach((node, position) => addWire(node, face[(position + 1) % face.length] ?? node, line))274 continue275 }276277 if (keyword === 'polyhedron') {278 const name = restTokens.join(' ').trim().toLowerCase()279 const preset = POLYHEDRA[name]280 if (preset === undefined) sourceError(line, `Unknown polyhedron "${name}". Choose one of: ${POLYHEDRON_NAMES.join(', ')}.`)281 const { edges } = preset()282 for (const [from, to] of edges) addWire(from, to, line)283 continue284 }285286 if (keyword === 'squares' || (rectangle !== null && looksLikeSquareLine(text))) {287 if (rectangle === null) sourceError(line, 'Declare the rectangle with "rectangle <width> x <height>" before listing squares.')288 const groups = parseSquareLine(keyword === 'squares' ? rest : text, line)289 for (const group of groups) {290 if (group.sides.some(side => Object.keys(side.terms).length > 0)) rectangle.symbolic = true291 rectangle.groups.push(group)292 }293 continue294 }295296 if (CHAIN.test(text)) {297 const chain = text.split(/\s*-+\s*/).map(node => identifier(node, line, 'A node'))298 for (let position = 1; position < chain.length; position += 1) {299 addWire(chain[position - 1] ?? '', chain[position] ?? '', line)300 }301 continue302 }303304 sourceError(line, `Unrecognized line "${text}". Expected a rectangle, squares, battery, wire, face, polyhedron, title, or directive.`)305 }306307 if (rectangle === null && battery === null && wires.length === 0) {308 sourceError(1, 'Declare a rectangle with its squares, or a network with a battery and wires.')309 }310311 if (rectangle !== null) {312 const count = rectangle.groups.reduce((sum, group) => sum + group.sides.length, 0)313 if (count === 0) sourceError(rectangle.line, 'List the squares after the rectangle.')314 if (rectangle.symbolic) {315 rectangle.groups.forEach((group, index) => {316 if (index > 0 && group.under === null) sourceError(group.line, 'In a sketch with named sides, every group after the first needs "under" to say which squares it hangs from.')317 if (index === 0 && group.under !== null) sourceError(group.line, 'The first group is the top row and cannot hang under anything.')318 })319 } else if (rectangle.groups.some(group => group.under !== null) && rectangle.groups.some(group => group.sides.some(side => side.auto))) {320 sourceError(rectangle.line, 'Use "?" sides or "under" clauses, not both.')321 }322 return { view: view ?? 'both', labels: labels ?? 'sides', title, rectangle, network: null }323 }324325 if (battery === null) sourceError(firstNetworkLine, 'A network needs a battery: "battery <positive node> <negative node>" or "battery any".')326 if (wires.length === 0) sourceError(battery.line, 'A network needs at least one wire.')327 if (!battery.search) {328 const connectedToWire = new Set(wires.flatMap(wire => [wire.from, wire.to]))329 for (const pole of [battery.positive, battery.negative]) {330 if (pole !== null && !connectedToWire.has(pole)) sourceError(battery.line, `Battery node "${pole}" must be connected to at least one wire.`)331 }332 } else if (wires.length < 2) {333 sourceError(battery.line, '"battery any" needs at least two wires so one can become the battery.')334 }335336 return {337 view: view ?? 'both',338 labels: labels ?? 'sides',339 title,340 rectangle: null,341 network: { positive: battery.positive, negative: battery.negative, searchBattery: battery.search, nodes, wires, line: firstNetworkLine },342 }343}