Last active
March 3, 2020 09:43
-
-
Save mitallast/1ec0c326e80847003c4952ffe0ce3140 to your computer and use it in GitHub Desktop.
Port of Wave Function Collapse to typescript from origin sources + path constraint
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {RNG} from "./rng"; | |
// @ts-ignore | |
import * as PIXI from 'pixi.js'; | |
// origin: https://github.com/mxgmn/WaveFunctionCollapse/ | |
function array<T>(size: number, value: T = null): T[] { | |
const a: T[] = []; | |
for (let i = 0; i < size; i++) { | |
a.push(value); | |
} | |
return a; | |
} | |
class Color { | |
readonly R: number; | |
readonly G: number; | |
readonly B: number; | |
readonly A: number; | |
static fromImage(imageData: ImageData, x: number, y: number): Color { | |
return Color.fromBuffer(imageData.data, imageData.width, x, y); | |
} | |
static fromBuffer(buffer: Uint8Array | Uint8ClampedArray, w: number, x: number, y: number): Color { | |
let offset = 4 * (y * w + x); | |
let R = buffer[offset]; | |
let G = buffer[offset + 1]; | |
let B = buffer[offset + 2]; | |
let A = buffer[offset + 3]; | |
return new Color(R, G, B, A); | |
} | |
constructor(R: number, G: number, B: number, A: number) { | |
this.R = R; | |
this.G = G; | |
this.B = B; | |
this.A = A; | |
} | |
equals(that: Color): boolean { | |
return this.R === that.R && | |
this.G === that.G && | |
this.B === that.B && | |
this.A === that.A; | |
} | |
} | |
// Indicates whether something has been fully resolved or not. | |
// This is the return code for many functions, but can also | |
// describe the state of individual locations in a generated output. | |
enum Resolution { | |
// The operation has successfully completed and a value is known. | |
Decided = 0, | |
// The operation has not yet found a value | |
Undecided = -1, | |
// It was not possible to find a successful value. | |
Contradiction = -2, | |
} | |
abstract class Model { | |
wave: boolean[][] = null; // wave => pattern map | |
propagator: number[][][]; // direction => pattern1 => pattern2[] | |
compatible: number[][][]; | |
observed: number[]; | |
stack: [number, number][]; | |
backtrackItems: [number, number][]; // wave, pattern | |
backtrackSelection: [number, number, number][]; // wave, pattern, backtrack target | |
backtrackCommit: number; | |
random: RNG; | |
FMX: number; | |
FMY: number; | |
T: number; | |
periodic: boolean; | |
weights: number[]; | |
weightLogWeights: number[]; | |
sumsOfOnes: number[]; | |
sumOfWeights: number; | |
sumOfWeightLogWeights: number; | |
startingEntropy: number; | |
sumsOfWeights: number[]; | |
sumsOfWeightLogWeights: number[]; | |
entropies: number[]; | |
// The overall status of the propagator, always kept up to date | |
private status: Resolution; | |
constructor(width: number, height: number) { | |
this.FMX = width; | |
this.FMY = height; | |
} | |
init(): void { | |
this.wave = array(this.FMX * this.FMY); | |
this.compatible = array(this.wave.length); | |
for (let i = 0; i < this.wave.length; i++) { | |
this.wave[i] = array(this.T, true); | |
this.compatible[i] = array(this.T); | |
for (let t = 0; t < this.T; t++) { | |
this.compatible[i][t] = array(4, 0); | |
} | |
} | |
this.weightLogWeights = array(this.T); | |
this.sumOfWeights = 0; | |
this.sumOfWeightLogWeights = 0; | |
for (let t = 0; t < this.T; t++) { | |
this.weightLogWeights[t] = this.weights[t] * Math.log(this.weights[t]); | |
this.sumOfWeights += this.weights[t]; | |
this.sumOfWeightLogWeights += this.weightLogWeights[t]; | |
} | |
this.startingEntropy = Math.log(this.sumOfWeights) - this.sumOfWeightLogWeights / this.sumOfWeights; | |
this.sumsOfOnes = array(this.FMX * this.FMY); | |
this.sumsOfWeights = array(this.FMX * this.FMY); | |
this.sumsOfWeightLogWeights = array(this.FMX * this.FMY); | |
this.entropies = array(this.FMX * this.FMY); | |
this.stack = []; | |
this.backtrackItems = []; | |
this.backtrackSelection = []; | |
this.backtrackCommit = 0; | |
this.status = Resolution.Undecided; | |
} | |
protected clear(): void { | |
for (let i = 0; i < this.wave.length; i++) { | |
for (let t = 0; t < this.T; t++) { | |
this.wave[i][t] = true; | |
for (let d = 0; d < 4; d++) { | |
this.compatible[i][t][d] = this.propagator[Model.opposite[d]][t].length; | |
} | |
} | |
this.sumsOfOnes[i] = this.weights.length; | |
this.sumsOfWeights[i] = this.sumOfWeights; | |
this.sumsOfWeightLogWeights[i] = this.sumOfWeightLogWeights; | |
this.entropies[i] = this.startingEntropy; | |
} | |
this.stack = []; | |
this.backtrackItems = []; | |
this.backtrackSelection = []; | |
this.backtrackCommit = 0; | |
this.status = Resolution.Undecided; | |
} | |
run(seed: number = null, limit: number = 0): boolean { | |
if (this.wave === null) this.init(); | |
this.clear(); | |
this.random = new RNG(seed); | |
for (let i = 0; i < limit || limit === 0; i++) { | |
let result = this.observe(); | |
if (result === false) { | |
if (this.backtrack(this.backtrackCommit)) { | |
console.error("failed backtrack after observe"); | |
continue; | |
} | |
} | |
if (result !== null) return result; | |
while (true) { | |
if (!this.propagate()) { | |
if (!this.backtrack(this.backtrackCommit)) { | |
console.error("failed backtrack after constraint check"); | |
return false; | |
} | |
break; | |
} | |
if (!this.constraintCheck()) { | |
if (!this.backtrack(this.backtrackCommit)) { | |
console.error("failed backtrack after constraint check"); | |
return false; | |
} | |
} else { | |
break; | |
} | |
} | |
this.backtrackCommit = this.backtrackItems.length; | |
} | |
return true; | |
} | |
protected observe(): boolean | null { | |
let min = 1E+3; | |
let argmin = -1; | |
for (let i = 0; i < this.wave.length; i++) { | |
if (this.onBoundary(i % this.FMX, Math.floor(i / this.FMX))) continue; | |
let amount = this.sumsOfOnes[i]; | |
if (amount == 0) { | |
console.error(`[wave=${i}] found zero sum of ones`); | |
this.graphics([i]); | |
return false; | |
} | |
let entropy = this.entropies[i]; | |
if (amount > 1 && entropy <= min) { | |
let noise = 1E-6 * this.random.nextFloat(); | |
if (entropy + noise < min) { | |
min = entropy + noise; | |
argmin = i; | |
} | |
} | |
} | |
if (argmin == -1) { | |
this.observed = array(this.FMX * this.FMY); | |
for (let i = 0; i < this.wave.length; i++) { | |
for (let t = 0; t < this.T; t++) { | |
if (this.wave[i][t]) { | |
this.observed[i] = t; | |
break; | |
} | |
} | |
} | |
return true; | |
} | |
let distribution_sum: number = 0; | |
let distribution: number[] = array(this.T); | |
for (let t = 0; t < this.T; t++) { | |
distribution[t] = this.wave[argmin][t] ? this.weights[t] : 0; | |
distribution_sum += distribution[t]; | |
} | |
let rnd = this.random.nextFloat() * distribution_sum; | |
let r = 0; | |
for (let weight of distribution) { | |
rnd -= weight; | |
if (rnd < 0) break; | |
r++; | |
} | |
this.backtrackSelection.push([argmin, r, this.backtrackCommit]); | |
let w = this.wave[argmin]; | |
for (let t = 0; t < this.T; t++) { | |
if (w[t] != (t == r)) { | |
this.ban(argmin, t); | |
} | |
} | |
console.log(`observed argmin=${argmin}, r=${r}`); | |
this.graphics([argmin]); | |
return null; | |
} | |
protected propagate(): Resolution { | |
while (this.stack.length > 0) { | |
let [i, t] = this.stack.pop(); | |
let x = i % this.FMX, y = Math.floor(i / this.FMX); | |
for (let direction = 0; direction < 4; direction++) { | |
let dx = Model.DX[direction], dy = Model.DY[direction]; | |
let sx = x + dx, sy = y + dy; | |
if (this.onBoundary(sx, sy)) { | |
continue; | |
} | |
if (sx < 0) sx += this.FMX; | |
else if (sx >= this.FMX) sx -= this.FMX; | |
if (sy < 0) sy += this.FMY; | |
else if (sy >= this.FMY) sy -= this.FMY; | |
let s = sx + sy * this.FMX; | |
let pattern1 = this.propagator[direction][t]; // item2 | |
let compat = this.compatible[s]; | |
for (let st of pattern1) { | |
let comp = compat[st]; | |
comp[direction]--; | |
if (comp[direction] == 0) { | |
if (!this.ban(s, st)) { | |
return this.status = Resolution.Contradiction; | |
} | |
} | |
} | |
} | |
this.constraintHandle(i, t) | |
} | |
return true; | |
} | |
ban(i: number, patternIndex: number): boolean { | |
// console.log("ban", i, patternIndex); | |
this.wave[i][patternIndex] = false; | |
let comp = this.compatible[i][patternIndex]; | |
for (let d = 0; d < 4; d++) { | |
comp[d] -= this.T; | |
} | |
this.stack.push([i, patternIndex]); | |
this.sumsOfOnes[i] -= 1; | |
this.sumsOfWeights[i] -= this.weights[patternIndex]; | |
this.sumsOfWeightLogWeights[i] -= this.weightLogWeights[patternIndex]; | |
let sum = this.sumsOfWeights[i]; | |
this.entropies[i] = Math.log(sum) - this.sumsOfWeightLogWeights[i] / sum; | |
this.backtrackItems.push([i, patternIndex]); | |
if (this.sumsOfOnes[i] === 0) { | |
console.error("sum is zero", i); | |
this.graphics([i]); | |
return false; | |
} else { | |
return true; | |
} | |
} | |
protected backtrack(targetLength: number): boolean { | |
console.log(`backtrack to ${targetLength}`); | |
let result = false; | |
const markup: number[] = []; | |
while (this.backtrackItems.length > targetLength) { | |
let [i, patternIndex] = this.backtrackItems.pop(); | |
console.log("backtrack", i, patternIndex); | |
this.wave[i][patternIndex] = true; | |
markup.push(i); | |
let comp = this.compatible[i][patternIndex]; | |
for (let d = 0; d < 4; d++) { | |
comp[d] += this.T; | |
} | |
this.sumsOfOnes[i] += 1; | |
this.sumsOfWeights[i] += this.weights[patternIndex]; | |
this.sumsOfWeightLogWeights[i] += this.weightLogWeights[patternIndex]; | |
let sum = this.sumsOfWeights[i]; | |
this.entropies[i] = Math.log(sum) - this.sumsOfWeightLogWeights[i] / sum; | |
// Next, undo the decrements done in propagate | |
let x = i % this.FMX, y = Math.floor(i / this.FMX); | |
for (let direction = 0; direction < 4; direction++) { | |
let dx = Model.DX[direction], dy = Model.DY[direction]; | |
let sx = x + dx, sy = y + dy; | |
if (this.onBoundary(sx, sy)) { | |
continue; | |
} | |
if (sx < 0) sx += this.FMX; | |
else if (sx >= this.FMX) sx -= this.FMX; | |
if (sy < 0) sy += this.FMY; | |
else if (sy >= this.FMY) sy -= this.FMY; | |
let s = sx + sy * this.FMX; | |
const pattern = this.propagator[direction][patternIndex]; | |
for (let st of pattern) { | |
this.compatible[s][st][direction]++; | |
} | |
} | |
result = true; | |
} | |
console.log('after backtrack'); | |
this.graphics(markup); | |
return result; | |
} | |
abstract constraintHandle(i: number, patternIndex: number): void; | |
abstract constraintCheck(): boolean; | |
abstract onBoundary(x: number, y: number): boolean; | |
abstract graphics(markup: number[]): void; | |
static DX: number[] = [-1, 0, 1, 0]; | |
static DY: number[] = [0, 1, 0, -1]; | |
static opposite: number[] = [2, 3, 0, 1]; | |
} | |
class OverlappingModel extends Model { | |
N: number; | |
patterns: number[][]; // array of patterns | |
colors: Color[]; | |
ground: number; | |
private readonly constraints: Constraint[]; | |
constructor( | |
image: Color[][], // y >> x | |
N: number, | |
width: number, | |
height: number, | |
periodicInput: boolean, | |
periodicOutput: boolean, | |
symmetry: number, | |
ground: number, | |
constraints: Constraint[] | |
) { | |
super(width, height); | |
this.N = N; | |
this.periodic = periodicOutput; | |
this.constraints = constraints; | |
let SMY = image.length; | |
let SMX = image[0].length; | |
this.colors = []; | |
let sample: number[][] = array(SMY); | |
for (let y = 0; y < SMY; y++) { | |
sample[y] = array(SMX); | |
for (let x = 0; x < SMX; x++) { | |
let color = image[y][x]; | |
let i = 0; | |
for (let c of this.colors) { | |
if (c.equals(color)) { | |
break; | |
} | |
i++; | |
} | |
if (i == this.colors.length) { | |
this.colors.push(color); | |
} | |
sample[y][x] = i; | |
} | |
} | |
const C = this.colors.length; | |
const W = Math.pow(C, N * N); | |
function pattern(f: (x: number, y: number) => number): number[] { | |
const result = []; | |
for (let y = 0; y < N; y++) { | |
for (let x = 0; x < N; x++) { | |
result.push(f(x, y)); | |
} | |
} | |
return result; | |
} | |
function patternFromSample(x: number, y: number): number[] { | |
return pattern((dx, dy) => sample[(y + dy) % SMY][(x + dx) % SMX]); | |
} | |
function rotate(p: number[]): number[] { | |
return pattern((x, y) => p[N - 1 - y + x * N]); | |
} | |
function reflect(p: number[]): number[] { | |
return pattern((x, y) => p[N - 1 - x + y * N]); | |
} | |
function index(p: number[]): number { | |
let result = 0, power = 1; | |
for (let i = 0; i < p.length; i++) { | |
result += p[p.length - 1 - i] * power; | |
power *= C; | |
} | |
return result; | |
} | |
function patternFromIndex(ind: number): number[] { | |
let residue = ind, power = W; | |
let result: number[] = []; | |
for (let i = 0; i < N * N; i++) { | |
power /= C; | |
let count = 0; | |
while (residue >= power) { | |
residue -= power; | |
count++; | |
} | |
result.push(count); | |
} | |
return result; | |
} | |
let weights = new Map<number, number>(); // pattern index => weight | |
for (let y = 0; y < (periodicInput ? SMY : SMY - N + 1); y++) { | |
for (let x = 0; x < (periodicInput ? SMX : SMX - N + 1); x++) { | |
let ps: number[][] = []; | |
ps[0] = patternFromSample(x, y); | |
ps[1] = reflect(ps[0]); | |
ps[2] = rotate(ps[0]); | |
ps[3] = reflect(ps[2]); | |
ps[4] = rotate(ps[2]); | |
ps[5] = reflect(ps[4]); | |
ps[6] = rotate(ps[4]); | |
ps[7] = reflect(ps[6]); | |
for (let k = 0; k < symmetry; k++) { | |
let ind = index(ps[k]); | |
const weight = weights.get(ind) || 0; | |
weights.set(ind, weight + 1); | |
} | |
} | |
} | |
this.T = weights.size; // patterns count | |
this.ground = (ground + this.T) % this.T; | |
this.patterns = []; | |
this.weights = []; | |
for (const [index, weight] of weights) { | |
this.patterns.push(patternFromIndex(index)); | |
this.weights.push(weight); | |
} | |
function agrees(pattern1: number[], pattern2: number[], dx: number, dy: number): boolean { | |
let xmin = dx < 0 ? 0 : dx; | |
let xmax = dx < 0 ? dx + N : N; | |
let ymin = dy < 0 ? 0 : dy; | |
let ymax = dy < 0 ? dy + N : N; | |
for (let y = ymin; y < ymax; y++) { | |
for (let x = xmin; x < xmax; x++) { | |
if (pattern1[x + N * y] != pattern2[x - dx + N * (y - dy)]) { | |
return false; | |
} | |
} | |
} | |
return true; | |
} | |
this.propagator = array(4); | |
for (let direction = 0; direction < 4; direction++) { | |
this.propagator[direction] = array(this.T); | |
for (let pattern1 = 0; pattern1 < this.T; pattern1++) { | |
this.propagator[direction][pattern1] = []; | |
for (let pattern2 = 0; pattern2 < this.T; pattern2++) { | |
if (agrees(this.patterns[pattern1], this.patterns[pattern2], Model.DX[direction], Model.DY[direction])) { | |
this.propagator[direction][pattern1].push(pattern2); | |
} | |
} | |
} | |
} | |
} | |
init(): void { | |
super.init(); | |
for (let constraint of this.constraints) { | |
constraint.init(this); | |
this.propagate(); | |
} | |
} | |
constraintHandle(i: number, patternIndex: number): void { | |
for (let constraint of this.constraints) { | |
constraint.handle(i, patternIndex); | |
} | |
} | |
constraintCheck(): boolean { | |
for (let constraint of this.constraints) { | |
if (!constraint.check()) { | |
return false; | |
} | |
if (!this.propagate()) { | |
return false; | |
} | |
} | |
return true; | |
} | |
onBoundary(x: number, y: number): boolean { | |
return !this.periodic && (x + this.N > this.FMX || y + this.N > this.FMY || x < 0 || y < 0); | |
} | |
protected clear(): void { | |
super.clear(); | |
if (this.ground != 0) { | |
for (let x = 0; x < this.FMX; x++) { | |
for (let t = 0; t < this.T; t++) { | |
if (t != this.ground) { | |
this.ban(x + (this.FMY - 1) * this.FMX, t); | |
} | |
} | |
for (let y = 0; y < this.FMY - 1; y++) { | |
this.ban(x + y * this.FMX, this.ground); | |
} | |
} | |
this.propagate(); | |
} | |
for (let constraint of this.constraints) { | |
constraint.clear(this); | |
this.propagate(); | |
} | |
} | |
graphics(markup: number[]): void { | |
const bitmap = new Uint8Array(4 * this.FMX * this.FMY); | |
if (this.observed != null) { | |
for (let y = 0; y < this.FMY; y++) { | |
let dy = y < this.FMY - this.N + 1 ? 0 : this.N - 1; | |
for (let x = 0; x < this.FMX; x++) { | |
let dx = x < this.FMX - this.N - 1 ? 0 : this.N - 1; | |
let c = this.colors[this.patterns[this.observed[x - dx + (y - dy) * this.FMX]][dx + dy * this.N]]; | |
let offset = 4 * (x + y * this.FMX); | |
bitmap[offset] = c.R; | |
bitmap[offset + 1] = c.G; | |
bitmap[offset + 2] = c.B; | |
bitmap[offset + 3] = c.A; | |
} | |
} | |
} else { | |
for (let i = 0; i < this.wave.length; i++) { | |
let contributors = 0, r = 0, g = 0, b = 0, a = 0; | |
let x = i % this.FMX, y = Math.floor(i / this.FMX); | |
for (let dy = 0; dy < this.N; dy++) { | |
for (let dx = 0; dx < this.N; dx++) { | |
let sx = x - dx, sy = y - dy; | |
if (this.onBoundary(sx, sy)) { | |
continue; | |
} | |
if (sx < 0) sx += this.FMX; | |
else if (sx >= this.FMX) sx -= this.FMX; | |
if (sy < 0) sy += this.FMY; | |
else if (sy >= this.FMY) sy -= this.FMY; | |
let s = sx + sy * this.FMX; | |
for (let t = 0; t < this.T; t++) { | |
if (this.wave[s][t]) { | |
contributors++; | |
let color = this.colors[this.patterns[t][dx + dy * this.N]]; | |
r += color.R; | |
g += color.G; | |
b += color.B; | |
a += color.A; | |
} | |
} | |
} | |
} | |
bitmap[i * 4] = Math.floor(r / contributors); | |
bitmap[i * 4 + 1] = Math.floor(g / contributors); | |
bitmap[i * 4 + 2] = Math.floor(b / contributors); | |
bitmap[i * 4 + 3] = Math.floor(a / contributors); | |
} | |
} | |
const scale = 10; | |
const canvas = document.createElement("canvas"); | |
canvas.width = this.FMX * scale; | |
canvas.height = this.FMY * scale; | |
canvas.style.margin = "10px"; | |
const ctx = canvas.getContext("2d"); | |
ctx.imageSmoothingEnabled = false; | |
const img = ctx.createImageData(this.FMX, this.FMY); | |
img.data.set(bitmap); | |
ctx.strokeStyle = "grey"; | |
for (let i = 0, j = 0; j < bitmap.length; i++, j += 4) { | |
ctx.fillStyle = `rgb(${bitmap[j]},${bitmap[j + 1]},${bitmap[j + 2]})`; | |
let x = i % this.FMX, y = Math.floor(i / this.FMX); | |
ctx.fillRect(x * scale, y * scale, scale, scale); | |
ctx.strokeRect(x * scale, y * scale, scale, scale); | |
} | |
ctx.strokeStyle = "green"; | |
for (let i of markup) { | |
let x = i % this.FMX, y = Math.floor(i / this.FMX); | |
ctx.strokeRect(x * scale, y * scale, scale, scale); | |
} | |
console.log('%c ', ` | |
font-size: 1px; | |
padding: ${canvas.height / 2}px ${canvas.width / 2}px; | |
background: no-repeat url(${canvas.toDataURL('image/png')}); | |
background-size: ${canvas.width}px ${canvas.height}px; | |
`); | |
} | |
} | |
// origin https://github.com/BorisTheBrave/DeBroglie/ | |
interface Constraint { | |
init(model: OverlappingModel): void; | |
clear(model: OverlappingModel): void; | |
handle(i: number, patternIndex: number): void; | |
check(): boolean; | |
} | |
class PathConstraint implements Constraint { | |
private readonly pathColor: Color; | |
private pathColorIndex: number; | |
private model: OverlappingModel; | |
private graph: SimpleGraph; | |
constructor(pathColor: Color) { | |
this.pathColor = pathColor; | |
} | |
init(model: OverlappingModel): void { | |
console.log("init", model); | |
this.model = model; | |
this.pathColorIndex = this.model.colors.findIndex(c => this.pathColor.equals(c)); | |
console.log("path color", this.pathColor, this.pathColorIndex); | |
this.graph = this.createGraph(); | |
} | |
clear(model: OverlappingModel): void { | |
} | |
handle(i: number, patternIndex: number): void { | |
} | |
check(): boolean { | |
const FMX = this.model.FMX; | |
const FMY = this.model.FMY; | |
const N = this.model.N; | |
const T = this.model.T; | |
let indices = FMX * FMY; | |
while (true) { | |
const couldBePath: boolean[] = array(indices, false); | |
const mustBePath: boolean[] = array(indices, false); | |
for (let i = 0; i < indices; i++) { | |
let x = i % FMX, y = Math.floor(i / FMX); | |
let pathCount = 0; | |
let totalCount = 0; | |
for (let dy = 0; dy < this.model.N; dy++) { | |
for (let dx = 0; dx < N; dx++) { | |
let sx = x - dx, sy = y - dy; | |
if (this.model.onBoundary(sx, sy)) { | |
continue; | |
} | |
if (sx < 0) sx += FMX; | |
else if (sx >= FMX) sx -= FMX; | |
if (sy < 0) sy += FMY; | |
else if (sy >= FMY) sy -= FMY; | |
let s = sx + sy * FMX; | |
for (let t = 0; t < T; t++) { | |
if (this.model.wave[s][t]) { | |
totalCount++; | |
let colorIndex = this.model.patterns[t][dx + dy * N]; | |
if (colorIndex === this.pathColorIndex) { | |
pathCount++; | |
} | |
} | |
} | |
} | |
} | |
couldBePath[i] = pathCount > 0; | |
mustBePath[i] = pathCount > 0 && totalCount === pathCount; | |
} | |
// console.log("could be path"); | |
// let markup: number[] = couldBePath | |
// .map<[boolean, number]>((v, i) => [v, i]) | |
// .filter(a => a[0]) | |
// .map(a => a[1]); | |
// this.model.graphics(markup); | |
// console.log("must be path"); | |
// let markup: number[] = mustBePath | |
// .map<[boolean, number]>((v, i) => [v, i]) | |
// .filter(a => a[0]) | |
// .map(a => a[1]); | |
// this.model.graphics(markup); | |
let isArticulation = this.getArticulationPoints(couldBePath, mustBePath); | |
if (isArticulation == null) { | |
console.error("no articulation"); | |
return false; | |
} | |
// console.log("isArticulation", isArticulation.filter(v => v === true).length); | |
// All articulation points must be paths, | |
// So ban any other possibilities | |
let changed = false; | |
for (let i = 0; i < indices; i++) { | |
if (isArticulation[i] && !mustBePath[i]) { | |
console.log("articulation", i); | |
let x = i % this.model.FMX, y = Math.floor(i / this.model.FMX); | |
console.log("x, y, i", x, y, i); | |
for (let dy = 0; dy < this.model.N; dy++) { | |
for (let dx = 0; dx < this.model.N; dx++) { | |
let sx = x - dx; | |
if (sx < 0) sx += this.model.FMX; | |
let sy = y - dy; | |
if (sy < 0) sy += this.model.FMY; | |
let s = sx + sy * this.model.FMX; | |
console.log("sx, sy s", sx, sy, s); | |
if (this.model.onBoundary(sx, sy)) { | |
continue; | |
} | |
for (let t = 0; t < this.model.T; t++) { | |
if (this.model.wave[s][t]) { | |
let colorIndex = this.model.patterns[t][dx + dy * this.model.N]; | |
if (colorIndex !== this.pathColorIndex) { | |
console.log("ban not path", colorIndex, t, this.model.patterns[t]); | |
this.model.ban(s, t); | |
changed = true; | |
} else { | |
console.log("ban is path", colorIndex, t, this.model.patterns[t]); | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
if (changed) { | |
console.log("isArticulation"); | |
let markup: number[] = isArticulation | |
.map<[boolean, number]>((v, i) => [v, i]) | |
.filter(a => a[0]) | |
.map(a => a[1]); | |
this.model.graphics(markup); | |
console.log("continue articulation loop"); | |
} else { | |
return true; | |
} | |
} | |
} | |
getArticulationPoints(walkable: boolean[], relevant: boolean[] = null): boolean[] { | |
const graph = this.graph; | |
const indices = walkable.length; | |
const low: number[] = array(indices, 0); | |
let num = 1; | |
const dfsNum: number[] = array(indices, 0); | |
const isArticulation: boolean[] = array(indices, false); | |
let markup: number[] = []; | |
// This hideous function is a iterative version | |
// of the much more elegant recursive version below. | |
// Unfortunately, the recursive version tends to blow the stack for large graphs | |
function cutVertex(initialU: number): number { | |
const stack: CutVertexFrame[] = []; | |
stack.push(new CutVertexFrame(initialU)); | |
// This is the "returned" value from recursion | |
let childRelevantSubtree: boolean = false; | |
let childCount = 0; | |
while (true) { | |
const frameIndex = stack.length - 1; | |
const frame = stack[frameIndex]; | |
const u = frame.u; | |
let switchState = frame.state; | |
let loop: boolean; | |
do { | |
loop = false; | |
// console.log("switchState", switchState); | |
switch (switchState) { | |
// Initialization | |
case 0: { | |
// console.log("switch 0"); | |
let isRelevant = relevant != null && relevant[u]; | |
if (isRelevant) { | |
isArticulation[u] = true; | |
} | |
frame.isRelevantSubtree = isRelevant; | |
low[u] = dfsNum[u] = num++; | |
markup.push(u); | |
// Enter loop | |
switchState = 1; | |
loop = true; | |
break; | |
} | |
// Loop over neighbours | |
case 1: { | |
// console.log("switch 1"); | |
// Check loop condition | |
let neighbours = graph.neighbours[u]; | |
let neighbourIndex = frame.neighbourIndex; | |
if (neighbourIndex >= neighbours.length) { | |
// Exit loop | |
switchState = 3; | |
loop = true; | |
break; | |
} | |
let v = neighbours[neighbourIndex]; | |
if (!walkable[v]) { | |
// continue to next iteration of loop | |
frame.neighbourIndex = neighbourIndex + 1; | |
switchState = 1; | |
loop = true; | |
break; | |
} | |
// v is a neighbour of u | |
let unvisited = dfsNum[v] === 0; | |
if (unvisited) { | |
// Recurse into v | |
stack.push(new CutVertexFrame(v)); | |
frame.state = 2; | |
switchState = 2; | |
stack[frameIndex] = frame; | |
break; | |
} else { | |
low[u] = Math.min(low[u], dfsNum[v]); | |
} | |
// continue to next iteration of loop | |
frame.neighbourIndex = neighbourIndex + 1; | |
switchState = 1; | |
loop = true; | |
break; | |
} | |
// Return from recursion (still in loop) | |
case 2: { | |
// console.log("switch 2"); | |
// At this point, childRelevantSubtree | |
// has been set to the by the recursion call we've just returned from | |
let neighbours = graph.neighbours[u]; | |
let neighbourIndex = frame.neighbourIndex; | |
let v = neighbours[neighbourIndex]; | |
if (frameIndex == 0) { | |
// Root frame | |
childCount++; | |
} | |
if (childRelevantSubtree) { | |
frame.isRelevantSubtree = true; | |
} | |
if (low[v] >= dfsNum[u]) { | |
if (relevant == null || childRelevantSubtree) { | |
isArticulation[u] = true; | |
} | |
} | |
low[u] = Math.min(low[u], low[v]); | |
// continue to next iteration of loop | |
frame.neighbourIndex = neighbourIndex + 1; | |
switchState = 1; | |
loop = true; | |
break; | |
} | |
// Cleanup | |
case 3: { | |
// console.log("switch 3"); | |
if (frameIndex == 0) { | |
// Root frame | |
return childCount; | |
} else { | |
// Set childRelevantSubtree with the return value from this recursion call | |
childRelevantSubtree = frame.isRelevantSubtree; | |
// Pop the frame | |
stack.splice(frameIndex, 1); | |
// Resume the caller (which will be in state 2) | |
break; | |
} | |
} | |
} | |
} while (loop); | |
} | |
} | |
// Check we've visited every relevant point. | |
// If not, there's no way to satisfy the constraint. | |
if (relevant != null) { | |
// Find starting point | |
for (let i = 0; i < indices; i++) { | |
if (!walkable[i]) continue; | |
if (!relevant[i]) continue; | |
// Already visited | |
if (dfsNum[i] != 0) continue; | |
let childCount = cutVertex(i); | |
// Relevant points are always articulation points | |
isArticulation[i] = true; | |
break; | |
} | |
// Check connectivity | |
for (let i = 0; i < indices; i++) { | |
if (relevant[i] && dfsNum[i] == 0) { | |
const w = this.model.FMX; | |
let x = i % w, y = Math.floor(i / w); | |
console.log("walkable:"); | |
let markupW: number[] = walkable | |
.map<[boolean, number]>((v, i) => [v, i]) | |
.filter(a => a[0]) | |
.map(a => a[1]); | |
this.model.graphics(markupW); | |
console.log("visited"); | |
this.model.graphics(markup); | |
console.error(`not visited relevant point i=${i} x=${x} y=${y}`); | |
console.log('graph neighbours', graph.neighbours[i]); | |
this.model.graphics([i]); | |
return null; | |
} | |
} | |
} | |
// compute articulation points | |
for (let i = 0; i < indices; i++) { | |
if (!walkable[i]) continue; | |
// Already visited | |
if (dfsNum[i] != 0) continue; | |
let childCount = cutVertex(i); | |
// The root of the tree is an exception to CutVertex's calculations | |
// It's an articulation point if it has multiple children | |
// as removing it would give multiple subtrees. | |
isArticulation[i] = childCount > 1; | |
} | |
return isArticulation; | |
} | |
createGraph(): SimpleGraph { | |
let nodeCount = this.model.FMX * this.model.FMY; | |
let neighbours: number[][] = []; | |
for (let i = 0; i < nodeCount; i++) { | |
neighbours[i] = []; | |
let x = i % this.model.FMX, y = Math.floor(i / this.model.FMX); | |
for (let direction = 0; direction < 4; direction++) { | |
let dx = Model.DX[direction], dy = Model.DY[direction]; | |
let sx = x + dx, sy = y + dy; | |
if (!this.model.periodic && (sx >= this.model.FMX || sy >= this.model.FMY || sx < 0 || sy < 0)) { | |
continue; | |
} | |
if (sx < 0) sx += this.model.FMX; | |
else if (sx >= this.model.FMX) sx -= this.model.FMX; | |
if (sy < 0) sy += this.model.FMY; | |
else if (sy >= this.model.FMY) sy -= this.model.FMY; | |
let s = sx + sy * this.model.FMX; | |
neighbours[i].push(s); | |
} | |
} | |
console.log("neighbours", neighbours); | |
return { | |
nodeCount: nodeCount, | |
neighbours: neighbours, | |
}; | |
} | |
} | |
class BorderConstraint implements Constraint { | |
private readonly borderColor: Color; | |
constructor(borderColor: Color) { | |
this.borderColor = borderColor; | |
} | |
init(model: OverlappingModel): void { | |
} | |
clear(model: OverlappingModel): void { | |
const indices = model.FMX * model.FMY; | |
const borderColorIndex = model.colors.findIndex(c => this.borderColor.equals(c)); | |
const markup: number[] = []; | |
for (let i = 0; i < indices; i++) { | |
let x = i % model.FMX, y = Math.floor(i / model.FMX); | |
if (x === 0 || x === model.FMX - 1 || y === 0 || y === model.FMY - 1) { | |
markup.push(i); | |
// console.log("x, y, i", x, y, i); | |
for (let dy = 0; dy < model.N; dy++) { | |
for (let dx = 0; dx < model.N; dx++) { | |
let sx = x - dx; | |
if (sx < 0) sx += model.FMX; | |
let sy = y - dy; | |
if (sy < 0) sy += model.FMY; | |
let s = sx + sy * model.FMX; | |
// console.log("sx, sy s", sx, sy, s); | |
if (model.onBoundary(sx, sy)) { | |
continue; | |
} | |
for (let t = 0; t < model.T; t++) { | |
if (model.wave[s][t]) { | |
let colorIndex = model.patterns[t][dx + dy * model.N]; | |
if (colorIndex !== borderColorIndex) { | |
// console.log("ban not border", colorIndex, t, model.patterns[t]); | |
model.ban(s, t); | |
} else { | |
// console.log("ban is border", colorIndex, t, model.patterns[t]); | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
console.log("border constraint"); | |
model.graphics(markup); | |
} | |
handle(i: number, patternIndex: number): void { | |
} | |
check(): boolean { | |
return true; | |
} | |
} | |
class CutVertexFrame { | |
u: number; | |
state: number = 0; | |
neighbourIndex: number = 0; | |
isRelevantSubtree: boolean = false; | |
constructor(u: number) { | |
this.u = u; | |
} | |
} | |
interface SimpleGraph { | |
readonly nodeCount: number; | |
readonly neighbours: number[][]; | |
} | |
class SimpleTiledModel extends Model { | |
tiles: Color[][]; | |
tilenames: string[]; | |
tilesize: number; | |
black: boolean; | |
constructor( | |
tileset: ITileSet, | |
subsetName: string, | |
width: number, | |
height: number, | |
periodic: boolean, | |
black: boolean | |
) { | |
super(width, height); | |
this.periodic = periodic; | |
this.black = black; | |
let tilesize: number = tileset.size || 16; | |
let unique: boolean = tileset.unique || false; | |
let subset: string[] = null; | |
if (subsetName != null) { | |
let xsubset = tileset.subsets.find(s => s.name === subsetName); | |
if (xsubset == null) { | |
console.error("subset not found"); | |
} else { | |
subset = xsubset.tiles.map(t => t.name); | |
} | |
} | |
function tile(f: (x: number, y: number) => Color): Color[] { | |
let result: Color[] = array(tilesize * tilesize); | |
for (let y = 0; y < tilesize; y++) { | |
for (let x = 0; x < tilesize; x++) { | |
result[x + y * tilesize] = f(x, y); | |
} | |
} | |
return result; | |
} | |
function rotate(array: Color[]): Color[] { | |
return tile((x, y) => array[tilesize - 1 - y + x * tilesize]); | |
} | |
this.tiles = []; | |
this.tilenames = []; | |
let tempStationary: number[] = []; | |
let action: number[][] = []; | |
let firstOccurrence = new Map<string, number>(); | |
const app = new PIXI.Application(); | |
let sheet: PIXI.Spritesheet = PIXI.Loader.shared.resources["tiles.json"].spritesheet; | |
let renderTexture = PIXI.RenderTexture.create({width: tilesize, height: tilesize}); | |
for (let xtile of tileset.tiles) { | |
let tilename = xtile.name; | |
if (subset != null && subset.indexOf(tilename) < 0) continue; | |
let a: (i: number) => number; | |
let b: (i: number) => number; | |
let cardinality: number; | |
let sym = xtile.symmetry || 'X'; | |
if (sym == 'L') { | |
cardinality = 4; | |
a = (i) => (i + 1) % 4; | |
b = (i) => i % 2 == 0 ? i + 1 : i - 1; | |
} else if (sym == 'T') { | |
cardinality = 4; | |
a = (i) => (i + 1) % 4; | |
b = (i) => i % 2 == 0 ? i : 4 - i; | |
} else if (sym == 'I') { | |
cardinality = 2; | |
a = (i) => 1 - i; | |
b = (i) => i; | |
} else if (sym == '\\') { | |
cardinality = 2; | |
a = (i) => 1 - i; | |
b = (i) => 1 - i; | |
} else { | |
cardinality = 1; | |
a = (i) => i; | |
b = (i) => i; | |
} | |
this.T = action.length; | |
firstOccurrence.set(tilename, this.T); | |
let map: number[][] = array(cardinality); | |
for (let t = 0; t < cardinality; t++) { | |
map[t] = array(8); | |
map[t][0] = t; | |
map[t][1] = a(t); | |
map[t][2] = a(a(t)); | |
map[t][3] = a(a(a(t))); | |
map[t][4] = b(t); | |
map[t][5] = b(a(t)); | |
map[t][6] = b(a(a(t))); | |
map[t][7] = b(a(a(a(t)))); | |
for (let s = 0; s < 8; s++) map[t][s] += this.T; | |
action.push(map[t]); | |
} | |
if (unique) { | |
for (let t = 0; t < cardinality; t++) { | |
let texture: PIXI.Texture = sheet.textures[`${tilename}-${t}.png`]; | |
app.renderer.render(new PIXI.Sprite(texture), renderTexture); | |
let bitmap = app.renderer.plugins.extract.pixels(renderTexture); | |
this.tiles.push(tile((x, y) => Color.fromBuffer(bitmap, tilesize, x, y))); | |
this.tilenames.push(`${tilename} ${t}`); | |
} | |
} else { | |
let texture: PIXI.Texture = sheet.textures[`${tilename}.png`]; | |
app.renderer.render(new PIXI.Sprite(texture), renderTexture); | |
let bitmap = app.renderer.plugins.extract.pixels(renderTexture); | |
this.tiles.push(tile((x, y) => Color.fromBuffer(bitmap, tilesize, x, y))); | |
this.tilenames.push(`${tilename} 0`); | |
for (let t = 1; t < cardinality; t++) { | |
this.tiles.push(rotate(this.tiles[this.T + t - 1])); | |
this.tilenames.push(`${tilename} ${t}`); | |
} | |
} | |
for (let t = 0; t < cardinality; t++) { | |
tempStationary.push(xtile.weight || 1); | |
} | |
} | |
this.T = action.length; | |
this.weights = tempStationary; | |
this.propagator = array(4); | |
let tempPropagator: boolean[][][] = array(4); | |
for (let direction = 0; direction < 4; direction++) { | |
tempPropagator[direction] = array(this.T); | |
this.propagator[direction] = array(this.T); | |
for (let t = 0; t < this.T; t++) { | |
tempPropagator[direction][t] = array(this.T); | |
} | |
} | |
for (let xneighbor of tileset.neighbors) { | |
let left = xneighbor.left; | |
let right = xneighbor.right; | |
if (subset != null && (subset.indexOf(left[0]) < 0 || subset.indexOf(right[0]) < 0)) { | |
continue; | |
} | |
let L = action[firstOccurrence.get(left[0])][left.length == 1 ? 0 : parseInt(left[1])]; | |
let D = action[L][1]; | |
let R = action[firstOccurrence.get(right[0])][right.length == 1 ? 0 : parseInt(right[1])]; | |
let U = action[R][1]; | |
tempPropagator[0][R][L] = true; | |
tempPropagator[0][action[R][6]][action[L][6]] = true; | |
tempPropagator[0][action[L][4]][action[R][4]] = true; | |
tempPropagator[0][action[L][2]][action[R][2]] = true; | |
tempPropagator[1][U][D] = true; | |
tempPropagator[1][action[D][6]][action[U][6]] = true; | |
tempPropagator[1][action[U][4]][action[D][4]] = true; | |
tempPropagator[1][action[D][2]][action[U][2]] = true; | |
} | |
for (let t2 = 0; t2 < this.T; t2++) { | |
for (let t1 = 0; t1 < this.T; t1++) { | |
tempPropagator[2][t2][t1] = tempPropagator[0][t1][t2]; | |
tempPropagator[3][t2][t1] = tempPropagator[1][t1][t2]; | |
} | |
} | |
let sparsePropagator: number[][][] = array(4); | |
for (let d = 0; d < 4; d++) { | |
sparsePropagator[d] = array(this.T); | |
for (let t = 0; t < this.T; t++) { | |
sparsePropagator[d][t] = []; | |
} | |
} | |
for (let d = 0; d < 4; d++) | |
for (let t1 = 0; t1 < this.T; t1++) { | |
let sp = sparsePropagator[d][t1]; | |
let tp = tempPropagator[d][t1]; | |
for (let t2 = 0; t2 < this.T; t2++) { | |
if (tp[t2]) { | |
sp.push(t2); | |
} | |
} | |
let ST = sp.length; | |
this.propagator[d][t1] = array(ST); | |
for (let st = 0; st < ST; st++) { | |
this.propagator[d][t1][st] = sp[st]; | |
} | |
} | |
} | |
constraintHandle(i: number, patternIndex: number): void { | |
} | |
constraintCheck(): boolean { | |
return true; | |
} | |
onBoundary(x: number, y: number): boolean { | |
return !this.periodic && (x < 0 || y < 0 || x >= this.FMX || y >= this.FMY); | |
} | |
text(): void { | |
let result = []; | |
for (let y = 0; y < this.FMY; y++) { | |
for (let x = 0; x < this.FMX; x++) { | |
result.push(`${this.tilenames[this.observed[x + y * this.FMX]]}, `); | |
} | |
result.push("\n"); | |
} | |
} | |
graphics(markup: number[]): void { | |
const bitmap = new Uint8Array(4 * this.FMX * this.FMY); | |
if (this.observed != null) { | |
for (let x = 0; x < this.FMX; x++) { | |
for (let y = 0; y < this.FMY; y++) { | |
let tile = this.tiles[this.observed[x + y * this.FMX]]; | |
for (let yt = 0; yt < this.tilesize; yt++) { | |
for (let xt = 0; xt < this.tilesize; xt++) { | |
let c = tile[xt + yt * this.tilesize]; | |
let offset = x * this.tilesize + xt + (y * this.tilesize + yt) * this.FMX * this.tilesize; | |
bitmap[offset * 4] = c.R; | |
bitmap[offset * 4 + 1] = c.G; | |
bitmap[offset * 4 + 2] = c.B; | |
bitmap[offset * 4 + 3] = c.A; | |
} | |
} | |
} | |
} | |
} else { | |
for (let x = 0; x < this.FMX; x++) { | |
for (let y = 0; y < this.FMY; y++) { | |
let a = this.wave[x + y * this.FMX]; | |
let amount = a.filter(v => v).length; | |
let weights_sum = 0; | |
for (let t = 0; t < this.T; t++) { | |
if (a[t]) { | |
weights_sum += this.weights[t]; | |
} | |
} | |
let lambda = 1 / weights_sum; | |
for (let yt = 0; yt < this.tilesize; yt++) { | |
for (let xt = 0; xt < this.tilesize; xt++) { | |
if (this.black && amount === this.T) { | |
let offset = x * this.tilesize + xt + (y * this.tilesize + yt) * this.FMX * this.tilesize; | |
bitmap[offset * 4] = 0; | |
bitmap[offset * 4 + 1] = 0; | |
bitmap[offset * 4 + 2] = 0; | |
bitmap[offset * 4 + 3] = 0xFF; | |
} else { | |
let r = 0, g = 0, b = 0, aa = 0; | |
for (let t = 0; t < this.T; t++) if (a[t]) { | |
let c = this.tiles[t][xt + yt * this.tilesize]; | |
r += c.R * this.weights[t] * lambda; | |
g += c.G * this.weights[t] * lambda; | |
b += c.B * this.weights[t] * lambda; | |
aa += c.A * this.weights[t] * lambda; | |
} | |
let offset = x * this.tilesize + xt + (y * this.tilesize + yt) * this.FMX * this.tilesize; | |
bitmap[offset * 4] = r; | |
bitmap[offset * 4 + 1] = g; | |
bitmap[offset * 4 + 2] = b; | |
bitmap[offset * 4 + 3] = aa; | |
} | |
} | |
} | |
} | |
} | |
} | |
let canvas = document.createElement("canvas"); | |
canvas.width = this.FMX; | |
canvas.height = this.FMY; | |
let ctx = canvas.getContext("2d"); | |
let img = ctx.createImageData(this.FMX, this.FMY); | |
img.data.set(bitmap); | |
ctx.putImageData(img, 0, 0); | |
document.body.prepend(canvas); | |
} | |
} | |
interface ITileSet { | |
readonly size?: number; | |
readonly unique?: boolean; | |
tiles: ITile[]; | |
neighbors: INeighbor[]; | |
subsets: ISubset[]; | |
} | |
interface ITile { | |
name: string | |
symmetry?: string; | |
weight?: number; | |
} | |
interface INeighbor { | |
left: string[]; | |
right: string[]; | |
} | |
interface ISubset { | |
name: string | |
tiles: ITile[] | |
} | |
export class TestOverlappingModel { | |
static async test() { | |
let image: Color[][] = []; // y >> x | |
let N: number = 3, | |
width: number = 60, | |
height: number = 60, | |
periodicInput = false, | |
periodicOutput = false, | |
symmetry: number = 8, | |
ground: number = 0; | |
let size = 1 + 7 + 5 + 7 + 1; | |
let canvas = document.createElement("canvas"); | |
canvas.width = size; | |
canvas.height = size; | |
document.body.prepend(canvas); | |
let ctx = canvas.getContext("2d"); | |
ctx.fillStyle = "white"; | |
ctx.fillRect(0, 0, size, size); | |
ctx.fillStyle = "black"; | |
// room | |
ctx.fillRect(1, 1, 7, 7); | |
ctx.fillRect(1 + 7 + 5, 1, 7, 7); | |
ctx.fillRect(1, 1 + 7 + 5, 7, 7); | |
ctx.fillRect(1 + 7 + 5, 1 + 7 + 5, 7, 7); | |
// corridor | |
ctx.fillRect(1 + 7, 1 + 2, 5, 3); | |
ctx.fillRect(1 + 7, 1 + 7 + 5 + 2, 5, 3); | |
ctx.fillRect(1 + 2, 1 + 7, 3, 5); | |
ctx.fillRect(1 + 7 + 5 + 2, 1 + 7, 3, 5); | |
ctx.fillStyle = "red"; | |
// room | |
ctx.fillRect(2, 2, 5, 5); | |
ctx.fillRect(1 + 7 + 5 + 1, 2, 5, 5); | |
ctx.fillRect(2, 1 + 7 + 5 + 1, 5, 5); | |
ctx.fillRect(1 + 7 + 5 + 1, 1 + 7 + 5 + 1, 5, 5); | |
// corridor | |
ctx.fillRect(1 + 6, 1 + 3, 7, 1); | |
ctx.fillRect(1 + 6, 1 + 7 + 5 + 3, 7, 1); | |
ctx.fillRect(1 + 3, 1 + 6, 1, 7); | |
ctx.fillRect(1 + 7 + 5 + 3, 1 + 6, 1, 7); | |
let img = ctx.getImageData(0, 0, size, size); | |
for (let y = 0; y < size; y++) { | |
let row: Color[] = []; | |
image.push(row); | |
for (let x = 0; x < size; x++) { | |
row.push(Color.fromImage(img, x, y)); | |
} | |
} | |
console.log("input image", image); | |
let border = new BorderConstraint(new Color(255, 255, 255, 255)); | |
let path = new PathConstraint(new Color(255, 0, 0, 255)); | |
let model = new OverlappingModel(image, N, width, height, periodicInput, periodicOutput, symmetry, ground, [border, path]); | |
if (!model.run(820299127)) { | |
console.log("fail"); | |
} else { | |
console.log("success"); | |
} | |
console.log("model", model); | |
model.graphics([]); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment