# Implementation of module {hacks}. # Last edited on 2021-10-29 03:07:04 by stolfi import color import palette import rn import pyx from math import sqrt, exp, log, floor, ceil, sin, cos, inf, nan, pi from PIL import Image import sys import os def real_quadratic_roots(A, B, C): assert A != 0 if A < 0: # Reverse signs to make {A} positive: A = -A; B = -B; C = -C # Rescale by replacing {t = M s} so that {A} is 1: M = 1/sqrt(A) A = 1; B = B*M # sys.stderr.write("A = %10.7f B = %10.7f C = %10.7f M = %12.7f \n" % (A, B, C, M)) # Now solve {s^2 + B*s + C = 0} Delta = B*B - 4*C if Delta <= 0: # Zero or one root return None, None elif Delta == inf: # Pretend the roots are infinite but symmetric: return -inf, +inf else: sD = sqrt(Delta) s0 = (-B - sD)/2; t0 = M*s0 s1 = (-B + sD)/2; t1 = M*s1 assert -inf < t0 and t0 < t1 and t1 < +inf return t0, t1 # ---------------------------------------------------------------------- # GEOMETRY def is_point(p): if not (type(p) is tuple or type(p) is list): return False if len(p) != 2: return False for c in p: if (type(c) is not float) and (type(c) is not int): return False return True # ---------------------------------------------------------------------- def same_point(p, q, tol): return abs(p[0] - q[0]) <= tol and abs(p[1] - q[1]) <= tol # ---------------------------------------------------------------------- def poly_cleanup(PTS, dmin): if len(PTS) == 0: return PTS closed = same_point(PTS[0], PTS[-1], dmin) # Is polygon effectively closed? PTSnew = list.copy(PTS) n = 0 # Points {PTSnew[0..n-1]} are the non-deleted points. for p in PTS: while n > 0 and same_point(PTSnew[n-1], p, dmin): n = n - 1 PTSnew[n] = p; n = n + 1 # Check first and last points: while n >= 2 and same_point(PTSnew[0], PTSnew[n-1], dmin): n = n - 1 PTSnew = PTSnew[0:n] if closed and PTSnew[0] != PTSnew[n-1]: PTSnew.append(PTSnew[0]) return PTSnew # ---------------------------------------------------------------------- def poly_orientation(PTS): A = rn.poly_area(PTS) if abs(A) < 1.0e-6: return 0 return +1 if A > 0 else -1 # ---------------------------------------------------------------------- def filet_center(p, q, rt, ro): rto = rt + ro upq, dpq = rn.dir(rn.sub(q, p)) # Unit vector directed from {p} to {q} vpq = (-upq[1], upq[0]) # Unit vector directed 90 degrees to the left of {p-->q} o = rn.mix(0.5, p, 0.5, q) h = sqrt(max(0, rto*rto - dpq*dpq/4)) assert h > 0, "can't compute filet" t = rn.mix(1, o, -h, vpq) return t # ---------------------------------------------------------------------- def circle_x(y, yctr, rad): dy = yctr - y x = sqrt(max(0, rad*rad - dy*dy)) return x # ---------------------------------------------------------------------- def cut_poly_with_band(CS, ydir, y0, y1): assert rn.dist(CS[0],CS[-1]) < 1.0e-8 # Contour component must be closed. npt = len(CS) # Number of points in contour component. LS = [] # Find first index {kpt} such that {CS[kpt]} is outside or on boundary of strip # and {CS[kpt+1]} is not on the same scan-line (indices modulo {npt}): k_start = None for kpt in range(npt): p = CS[kpt] q = CS[(kpt+1)%npt] yp = rn.dot(p, ydir) yq = rn.dot(q, ydir) if yp != yq: if yp <= y0 or yp > y1: k_start = kpt; break if k_start == None: # Component is entirely inside strip (?!). return LS PS = None # Points in current link path. eloc = None # Line through which the strip was entered (0, 1, or {None}). for dk in range(npt): kpt = k_start + dk p = CS[kpt % npt] q = CS[(kpt+1) % npt] ps, ploc, qs, qloc = clip_seg_to_strip(p, q, ydir, y0, y1) assert ploc == None or qloc == None or ploc != qloc if ps == None: assert qs == None assert PS == None # We should have closed it by hitting {y0} or {y1}. else: assert qs != None if eloc == None: # First seg of a new link: eloc = ploc PS = [ ps ] assert eloc != None assert PS != None if qs != ps: PS.append(qs) if qloc == 0 or qloc == 1: # About to exit strip: if qloc != eloc: # Exiting from opposite side of strip -- completed a link path: LS.append(PS) # Either way, we are done with the currenr link path: PS = None; eloc = None # Since we started outside or on the boundary of the strip, we must be quiescent: assert eloc == None assert PS == None return LS # ---------------------------------------------------------------------- def clip_seg_to_strip(p,q, ydir, y0,y1): assert y0 < y1 yp = rn.dot(p, ydir) yq = rn.dot(q, ydir) # Sort the points by Y: swap = False if yp > yq: q,p = p,q; yq,yp = yp,yq; swap = True assert yp <= yq if yp >= y1 or yq <= y0: # Segment outside strip or on its border. return None, None, None, None if yp >= y0: # Starts inside strip: ps = p; ploc = None else: # Enter strip at {y0} assert yq > yp rp = (y0 - yp)/(yq - yp) ps = rn.mix(1-rp, p, rp, q) ploc = 0 if yq <= y1: # Ends inside strip: qs = q; qloc = None else: # Exits strip at {y1} assert yq > yp rq = (y1 - yp)/(yq - yp) qs = rn.mix(1-rq, p, rq, q) qloc = 1 if swap: return qs, qloc, ps, ploc else: return ps, ploc, qs, qloc # ---------------------------------------------------------------------- def find_point_on_poly(CS,idr,g): npt = len(CS) assert npt >= 2, "polygonal line must have at least 2 vertices" assert g >= 0, "trim distance cannot be negative" i_start = None q_start = None i_prev = (0, npt-1)[idr] d_prev = 0 # Distance from endpoint {idr} to point {i_prev} along path. for k in range(npt-1): i = (k+1, npt-k-2)[idr] ei = rn.dist(CS[i], CS[i_prev]) di = d_prev + ei if di > g: r = (di - g)/ei i_start = i q_start = rn.mix(r,CS[i_prev], 1-r,CS[i]) break i_prev = i d_prev = di assert i_start != None, "trim distance must be less than polygonal length" assert i_start >= 0 and i_start < npt # Paranoia. return i_start, q_start # ---------------------------------------------------------------------- def trim_poly(CS, g0, g1): npt = len(CS) assert npt >= 3, "polygonal must have at least 3 vertices" i_start = [None,None] q_start = [None,None] for idr in 0, 1: g = (g0, g1)[idr] i_start[idr], q_start[idr] = find_point_on_poly(CS,idr,g) assert i_start[0] < i_start[1], "polygonal is too short" CSnew = [ q_start[0], ] for i in range(i_start[0], i_start[1]+1): CSnew.append(CS[i]) CSnew.append(q_start[1]) return CSnew # ---------------------------------------------------------------------- def fbomb(die): # Returns {False} if die is {False}, else fails. assert not die return False # ---------------------------------------------------------------------- # PLOTTING def make_canvas(B, dp, scale, frame, grid, nx, ny): if dp == None: dp = (0,0) szx, szy = rn.box_size(B) sz_max = max(szx, szy) gstep_big, gstep_sma = choose_grid_steps(sz_max) sys.stderr.write("grid steps = %8.4f %8.4f\n" % (gstep_big, gstep_sma)) if scale == None: # Compute the {pyx} scale of the canvas for reasonable images size: scale = 15.0/max(nx*szx, ny*szy) sys.stderr.write("scale = %8.4f\n" % scale) pyx.unit.set(uscale=scale, wscale=scale, vscale=scale, xscale=scale) # Set the text rendering engine: pyx.text.set(pyx.text.LatexEngine) # pyx.text.set(pyx.text.TexEngine) # pyx.text.set(pyx.text.UnicodeEngine) # Load some packages: pyx.text.preamble( "" + r"\usepackage{lmodern}" + "\n" + r"\usepackage{anyfontsize}" + "\n" + r"\usepackage{scalefnt}" + "\n" + r"\usepackage{color}" + "\n" + r"\usepackage[scaled=1.15]{BOONDOX-calo}" + "\n" + r"\newcommand{\su}[1]{\hbox{\scalefont{0.45}\textsf{#1}}}" + "\n" ) # + r"\usepackage{rotating}" + "\n" # + r"\usepackage{graphicx}" + "\n" # Create the canvas: c = pyx.canvas.canvas() wd_frame = sz_max/500 inset_frame = 3*wd_frame wd_grid = sz_max/500 inset_grid = 5*wd_grid dash_grid = ( 3*wd_grid, 2*wd_grid ) # Should suppress dash adjustment and sync dashes to {gstep_big}. white = pyx.color.rgb.white for ix in range(nx): for iy in range(ny): dpi = rn.add(dp, (ix*szx, iy*szy)) # White frame to force image bounding box: plot_frame(c, B, dpi, white, wd_frame, None, 0.5*wd_frame) if grid: plot_grid(c, B, dpi, None, 0.5*wd_grid, dash_grid, inset_grid, gstep_sma, gstep_sma) plot_grid(c, B, dpi, None, 1.0*wd_grid, None, inset_grid, gstep_big, gstep_big) if frame: plot_frame(c, B, dpi, None, wd_frame, None, inset_frame) # wd_axes = 0.15*min(wd_fill,wd_cont) # Width of jumps and axis lines. return c, szx, szy # ---------------------------------------------------------------------- def round_box(B, mrg): plo = ( floor(B[0][0] - mrg), floor(B[0][1] - mrg) ) phi = ( ceil(B[1][0] + mrg), ceil(B[1][1] + mrg) ) return (plo, phi) # ---------------------------------------------------------------------- def plot_line(c, p, q, dp, clr, wd, dashpat): if wd == None or wd <= 0: return if clr == None: clr = pyx.color.rgb.black # Fudge the endpoints if practically equal: distpq = rn.dist(p, q) distmin = 1.0e-5*wd peps = 0 if distpq > distmin else 2*distmin if dp != None: p = rn.add(p, dp); q = rn.add(q, dp) sty_jround = pyx.style.linejoin.round sty_cround = pyx.style.linecap.round sty = [ sty_jround, sty_cround, clr ] if distpq > wd and dashpat != None: # Convert dash pattern to relative, then adjust it or turn off dashing if line is too short: assert type(dashpat) is tuple or type(dashpat) is list assert len(dashpat) == 2 dashpat = adjust_dash_pattern(distpq, dashpat[0], dashpat[1]) else: dashpat = None if dashpat != None: dashfac = 0.568/wd # Magic scaling factor for dash/gap sizes. rdashpat = [dashfac*dashpat[0], dashfac*dashpat[1]] # Make the line style dashed: sty_dash = pyx.style.dash(rdashpat, 0) # sys.stderr.write("in: rdashpat = %s\n" % str(rdashpat)) # sys.stderr.write("dash = %s\n" % str(sty_dash)) sty = sty + [ pyx.style.linestyle(sty_cround, sty_dash) ] # sty = sty + [ pyx.style.dash(rdashpat) ] sty_wd = pyx.style.linewidth(wd) sty += [ sty_wd ] c.stroke(pyx.path.line(p[0]-peps, p[1]-peps, q[0]+peps, q[1]+peps), sty) # ---------------------------------------------------------------------- def plot_text(c, tx, p, dp, fsize, texclr): fspc = fsize # For now. if dp != None: p = rn.add(p, dp) if texclr == None: tx_tex = (r'\fontsize{%.0f}{%.0f}\selectfont %s' % (fsize,fspc,tx)) else: tx_tex = (r'\fontsize{%.0f}{%.0f}\selectfont \textcolor{%s}{%s}' % (fsize,fspc,texclr,tx)) c.text(p[0], p[1], tx_tex) # ---------------------------------------------------------------------- def plot_box(c, B, dp, clr): if clr == None: clr = pyx.color.rgb.black if dp == None: dp = (0,0) xp = B[0][0] + dp[0]; xsz = B[1][0] - B[0][0] yp = B[0][1] + dp[1]; ysz = B[1][1] - B[0][1] sty = [ clr ] c.fill(pyx.path.rect(xp, yp, xsz, ysz), sty) # ---------------------------------------------------------------------- def plot_frame(c, B, dp, clr, wd, dashpat, inset): if inset == None: inset = 0 if wd == None or wd <= 0: return # Frame coordinate ranges including {dp} and {inset}: xmin = B[0][0] + inset; xmax = B[1][0] - inset ymin = B[0][1] + inset; ymax = B[1][1] - inset plot_line(c, (xmin, ymin), (xmax, ymin), dp, clr, wd, dashpat) plot_line(c, (xmax, ymin), (xmax, ymax), dp, clr, wd, dashpat) plot_line(c, (xmax, ymax), (xmin, ymax), dp, clr, wd, dashpat) plot_line(c, (xmin, ymax), (xmin, ymin), dp, clr, wd, dashpat) # ---------------------------------------------------------------------- def plot_grid(c, B, dp, clr, wd, dashpat, inset, xstep, ystep): if clr == None: clr = pyx.color.grey(0.8) if inset == None: inset = 0 if wd == None or wd <= 0: return if xstep == None: xstep = 1 if ystep == None: ystep = 1 # Grid coordinate ranges including {inset} but excluding {dp}: xmin = B[0][0] + inset; xmax = B[1][0] - inset ymin = B[0][1] + inset; ymax = B[1][1] - inset # Grid line index ranges, with some extra: kxmin = int(floor(xmin/xstep)) - 1; kxmax = int(ceil(xmax/xstep)) + 1 kymin = int(floor(ymin/ystep)) - 1; kymax = int(ceil(ymax/ystep)) + 1 eps = 1.0e-6*wd for dkx in range(kxmax-kxmin+1): x = (kxmin+dkx)*xstep if x >= xmin+eps and x < xmax-eps: plot_line(c, (x, ymin), (x, ymax), dp, clr, wd, dashpat) for dky in range(kymax-kymin+1): y = (kymin+dky)*ystep if y >= ymin+eps and y < ymax-eps: plot_line(c, (xmin, y), (xmax, y), dp, clr, wd, dashpat) # ---------------------------------------------------------------------- def write_plot(c, name): sys.stderr.write("writing %s.eps ...\n" % name) c.writeEPSfile(name + ".eps") sys.stderr.write("writing %s.png ...\n" % name) convert_eps_to_png(name) sys.stderr.write("writing %s.jpg ...\n" % name) #convert_eps_to_jpg(name) #sys.stderr.write("done.\n") sys.stderr.write("removing %s.eps ...\n" % name) os.remove(name + ".eps") def convert_eps_to_png(name): im = Image.open(name + '.eps') im.load(scale=4) fig = im.convert('RGBA') fig.save(name + '.png', lossless = True) im.close() # ---------------------------------------------------------------------- def convert_eps_to_jpg(name): im = Image.open(name + '.eps') im.load(scale=4) fig = im.convert('RGB') fig.save(name + '.jpg', quality = 95) im.close() # ---------------------------------------------------------------------- def adjust_dash_pattern(rdist, rlen, rgap): assert rlen > 0 if rgap <= 0: return None # No gaps -- might as well use solid. if rdist <= rlen: return None # Not enough space for a single dash. # Compute the approx number {ngap} of gaps: xgap = (rdist - rlen)/(rgap + rlen) # Fractional number of gaps with ideal pattern. assert xgap >= 0 ngap = floor(xgap + 0.5) if ngap == 0: ngap = 1 # Check whether {ngap+1} or {ngap-1} may be better: nbest = None ebest = +inf for dn in 0, -1, +1: nk = ngap + dn if nk >= 0: rdk = nk*(rlen+rgap) + rlen edk = rdk/rdist if rdk >= rdist else rdist/rdk if edk < ebest: ebest = edk; nbest = nk assert nbest != None # Compute the adjustment factor {fac}: fac = rdist/(nbest*(rlen+rgap) + rlen) minfac = 0.75; maxfac = 1/minfac if fac < minfac or fac > maxfac: # Would have to shrink or squeeze too much. Only if {rdist} is small, so: sys.stderr.write("!! warning: (adjust_dash_pattern} failed fac = %.3f\n" % fac) return None else: # Okeey: rdashpat = ( fac*rlen, fac*rgap ) return rdashpat # ---------------------------------------------------------------------- def choose_grid_steps(sz): # Ideally the image should be at most 100 times the minor step size. nmax = 20 # Let's set {gstep_sma} to be the largest power of 10 that is less than {sz/nmax}: k = floor(log(1.0001*sz/nmax)/log(10)) gstep_sma = 10.0**k; gstep_big = 10*gstep_sma # If {gstep_sma} is too small, increase it: if 2*gstep_sma < 1.0001*sz/nmax: gstep_sma = 2*gstep_sma return gstep_big, gstep_sma # ---------------------------------------------------------------------- def trace_colors(nc, Y): if Y == None: Y = 0.650 Ymin = max(0.000, Y - 0.020); Ymax = min(1.000, Y + 0.020) Tmin = 0.700; Tmax = 0.950 return make_colors(nc, Ymin, Ymax, Tmin, Tmax) # ---------------------------------------------------------------------- def link_colors(nc, Y): if Y == None: Y = 0.700 Ymin = max(0.000, Y - 0.100); Ymax = min(1.000, Y + 0.100) Tmin = 0.900; Tmax = 1.000 return make_colors(nc, Ymin, Ymax, Tmin, Tmax) # ---------------------------------------------------------------------- def matter_color(Y): if Y == None: Y = 0.870 YHT = ( Y, 0.300, 0.300 ) RGB = color.RGB_from_YUV(color.YUV_from_YHS(color.YHS_from_YHT(YHT))) clr = pyx.color.rgb( RGB[0], RGB[1], RGB[2] ) return clr # ---------------------------------------------------------------------- def make_colors(nc, Ymin, Ymax, Tmin, Tmax): # Used by {trace_colors,link_colors} colors = [None]*nc if nc == 1: colors[0] = pyx.color.rgb(0.200, 0.600, 0.000) else: RGBS = palette.table(nc, Ymin, Ymax, Tmin, Tmax) colors = [ pyx.color.rgb( rgb[0], rgb[1], rgb[2] ) for rgb in RGBS ] return colors # ----------------------------------------------------------------------