#!/usr/bin/env python3 """Generate the in-TikZ reference and Markdown index from package declarations. Run with --check in CI to reject stale generated files. No TeX is executed. """ from pathlib import Path import argparse import re ROOT = Path(__file__).resolve().parent.parent def group(s, start): assert s[start] == '{' level, i = 1, start + 1 while level: if s[i] == '{' and s[i-1] != '\\': level += 1 if s[i] == '}' and s[i-1] != '\\': level -= 1 i += 1 return s[start+1:i-1], i def declarations(text, command): result = {} for m in re.finditer(re.escape('\\'+command)+r'\{([^}]+)\}\s*\{', text): value, _ = group(text,m.end()-1) value = re.sub(r'%[^\n]*','',value).strip() result[m[1]] = value return result sources = {m:(ROOT/f'tikzlibrarytikzphysics.{m}.code.tex').read_text() for m in ('surface','ramps','mechanics','optics')} text = '\n'.join(sources.values()) names = declarations(text,'tikzphysics@registerdisplayname') keys = declarations(text,'tikzphysics@registerkeydefaults') missing_labels = set(keys) - set(names) if missing_labels: raise SystemExit('Missing public reference names: ' + ', '.join(sorted(missing_labels))) shapes = declarations(text,'pgfdeclareshape') base = ['center','north','south','east','west','north east','north west','south east','south west','base','base east','base west','mid','mid east','mid west','text'] # Styles wrapping standard shapes and logical variants of a shared shape. actual = {'physicsblock':'rectangle','physicspulley':'circle','physicsparticle':'circle','physicsdisk':'circle','physicsring':'circle','physicsconcavemirror':'physicsmirror','physicsconvexmirror':'physicsmirror'} # Path/pic reference declarations share the same registries as objects. no_node = {'physicsspring':'path','physicsrope':'path','physicsforce':'path','physicsvelocity':'path','physicsacceleration':'path','physicstorque':'path','physicsrod':'path','physicspinsupport':'pic','physicsrollersupport':'pic','physicspendulum':'pic'} # Parameterized declaration helpers have their concrete family names here. extra_families = { 'physicswedge':['surface','tangent-before','tangent-after','normal','right-surface','right-tangent-before','right-tangent-after','right-normal'], 'physicsramp':['surface','tangent-before','tangent-after','normal'], 'physicscurvedramp':['surface','curve','tangent-before','tangent-after','normal','curve-tangent-before','curve-tangent-after','curve-normal'], } notes = { 'physicswedge':'surface runs left to right on the usable incline; top mode uses the left face. right-surface selects the right face in top mode. Legacy slope/right/base families keep their CCW directions. Directions and normals are local to the node.', 'physicsramp':'surface follows the whole contact path by distance. At the sharp joint the tangent uses the incline side.', 'physicscurvedramp':'surface follows floor plus arc by distance; curve follows the arc only.', 'physicsspring':'Use draw[spring] (A)--(B). No private anchors. Lead lengths may be zero; amplitude and segment length must be positive.', 'physicsrope':'Use draw[rope] (A) to[over pulley=P] (B). Endpoints must lie outside a circular pulley. Labels on to sit on the final straight segment. shortest changes wrap only, not the tangent pair.', 'physicspulley':'Use a circular pulley with external rope endpoints. Set pulley axle radius=none to hide the axle.', 'physicspendulum':'Use pic (P) {pendulum}. Angle is measured from downward vertical; positive swings right. Length is pivot to bob centre. Use (P-pivot) and (P-bob.center).', 'physicspinsupport':'Use pic (S) {pin-support}; anchors are coordinates (S-pivot), (S-base), (S-left), (S-right).', 'physicsrollersupport':'Use pic (S) {roller-support}; anchors are coordinates (S-pivot), (S-base), (S-left), (S-right).', } def clean(v): return v.replace('$','').replace('^\\circ',' degrees') def tex(v): return v.replace('&',r'\&').replace('_',r'\_').replace('%',r'\%') out = ['%% Generated by scripts/generate_reference.py; do not edit.','\\makeatletter'] md = ['# Feature reference','', 'Generated from the package declarations. Values below are defaults, not live node values. Short names are the normal interface. Existing `physics...` styles and `physics ...` key aliases remain supported.', '', 'Use `\\physicshelp{wedge}` inside a `tikzpicture` to display the same reference. For a named object use `show keys` or `show anchors`.','', 'All objects support ordinary TikZ styling; see the manual for transformation and sizing limitations. Percentage families accept integers 0 through 100.',''] manual = [] for logical, name in names.items(): kind = no_node.get(logical,'node') shape = actual.get(logical,logical) body = shapes.get(shape,'') anchors = list(base) if shape in ('rectangle','circle') else re.findall(r'\\anchor\{([^{}#]+)\}|\\inheritanchor\[from=[^]]+\]\{([^{}#]+)\}',body) if anchors and isinstance(anchors[0],tuple): anchors=[a or b for a,b in anchors] anchors=list(dict.fromkeys(anchors + (['text'] if kind=='node' else []))) families=re.findall(r'\\tikzphysics@(?:declareedgeanchors|optics@declarearcanchors)\{([^}]+)\}',body) families+=extra_families.get(logical,[]) families=list(dict.fromkeys(families)) if kind == 'pic': anchors=['pivot','base','left','right'] if logical!='physicspendulum' else ['pivot','bob (node)'] rows=[] for entry in keys.get(logical,'').split(','): if '/' in entry: k,v=entry.strip().split('/',1);rows.append((k,clean(v))) # Include unit-aware aliases whose implementations forward to native sizes. alias_prefixes={'physicsblock':'block','physicspulley':'pulley','physicswedge':'wedge','physicsplatformleft':'platform','physicsplatformright':'platform','physicsplatformboth':'platform','physicsground':'ground','physicsceiling':'ceiling','physicswallleft':'wall','physicswallright':'wall','physicsslab':'slab','physicsprism':'prism'} aliases=[] prefix=alias_prefixes.get(logical) if prefix: for m in re.finditer(r'physics ('+prefix+r' [^/\n]+)/\.code\s*=\s*\{\\tikzphysics@length@keyhandler\{/pgf/([^}]+)\}',text): aliases.append((m[1],m[2])) aliastext='; '.join(k+' = '+v for k,v in aliases) familytext=', '.join(f+'-0..100' for f in families) if logical in ('physicsramp','physicscurvedramp','physicsconcavemirror','physicsconvexmirror','physicsconvexlens','physicsconcavelens','physicsslab','physicsprism'): familytext+= '; legacy .0..100 shorthand also available' if shape in ('circle','rectangle'): familytext='Numeric anchors are angles in degrees, as in ordinary TikZ.' anchorstext=', '.join(anchors) or 'No private anchors; use path endpoints and nodes along the path.' common='draw, fill, line width, color, opacity, rotate, scale' + (', anchor, minimum width, minimum height, inner sep, outer sep' if kind=='node' else '') hook = 'platform' if name.startswith('platform') else ('support' if name in ('pin-support','roller-support') else name) contents=[r'\textbf{'+tex(name)+' ('+kind+r')}\par',r'\textbf{Keys: defaults}\par',r'\begin{tabular}{@{}ll@{}}'] contents += [tex(k)+' & '+tex(v)+r'\\' for k,v in rows] contents += [r'\end{tabular}\par'] if aliases:contents += [r'\textbf{Size aliases:} '+tex(aliastext)+r'.\par'] contents += [r'\textbf{Anchors:} '+tex(anchorstext)+r'\par'] if familytext:contents += [r'\textbf{Families:} '+tex(familytext)+r'\par'] contents += [r'\textbf{Also:} '+tex(common)+r'.\par'] contents += [r'\textbf{Defaults hook:} every '+hook+r'.\par'] if logical in notes: contents += [tex(notes[logical])+r'\par'] contents += [r'\textit{Use the manual for geometry rules and collision-safe aliases.}'] out += ['\\expandafter\\def\\csname tikzphysics@reference@body@'+logical+'\\endcsname{%','\n'.join(contents),'}', '\\expandafter\\def\\csname tikzphysics@reference@anchors@'+logical+'\\endcsname{'+(','.join(anchors) if kind=='node' else '')+'}', '\\expandafter\\def\\csname tikzphysics@reference@families@'+logical+'\\endcsname{'+','.join(families)+'}'] lookup=[name,logical] if kind in ('path','pic'):lookup+=['physics '+name] if logical=='physicsplatformboth':lookup+=['platform-both'] if logical=='physicsplatformleft':lookup+=['platform-left-up'] if logical=='physicsplatformright':lookup+=['platform-right-up'] if logical=='physicsramp':lookup+=['ramp-left'] if logical=='physicscurvedramp':lookup+=['curved-ramp-left'] for alias in lookup:out += ['\\expandafter\\def\\csname tikzphysics@reference@lookup@'+alias+'\\endcsname{'+logical+'}'] md += ['## '+name+' ('+kind+')','','| Key | Default |','| --- | --- |']+[f'| `{k}` | {v} |' for k,v in rows] if aliases:md+=['','Size aliases: '+aliastext+'.'] md+=['','**Anchors:** '+anchorstext,'','**Families:** '+(familytext or 'None'),''] if logical in notes:md += [notes[logical],''] manual += [r'\subsection{'+tex(name)+' ('+kind+r')}', r'\begin{tabularx}{\linewidth}{@{}p{.45\linewidth}X@{}}',r'\toprule Key & Default \\',r'\midrule'] manual += [r'\texttt{'+tex(k)+'} & '+tex(v)+r'\\' for k,v in rows] manual += [r'\bottomrule\end{tabularx}'] if aliases: manual += [r'\textbf{Size aliases:} '+tex(aliastext)+r'.\par'] manual += [r'\textbf{Anchors:} '+tex(anchorstext)+r'\par'] if familytext: manual += [r'\textbf{Families:} '+tex(familytext)+r'\par'] if logical in notes: manual += [tex(notes[logical])+r'\par'] manual += [''] # The standard public hook is documented in both outputs. md += ['Default hook: `every '+hook+'`.',''] out += [r'\expandafter\def\csname tikzphysics@reference@lookup@index\endcsname{index}', r'\expandafter\def\csname tikzphysics@reference@body@index\endcsname{\textbf{Feature index}\par ' + ', '.join(names.values()) + r'\par Use \textbackslash physicshelp\{name\} for a complete card.}'] out += [r'\def\tikzphysics@reference@features{' + ','.join(names.values()) + '}', r'\def\tikzphysics@reference@nodes{' + ','.join(name for logical,name in names.items() if logical not in no_node) + '}'] out+=['\\makeatother','\\endinput',''] parser=argparse.ArgumentParser();parser.add_argument('--check',action='store_true');args=parser.parse_args() outputs={'tikzlibrarytikzphysics.catalog.code.tex':'\n'.join(out),'docs/reference.md':'\n'.join(md)} manual_path=ROOT/'tikzphysics.tex' manual_source=manual_path.read_text() if '% BEGIN GENERATED REFERENCE' in manual_source: outputs['tikzphysics.tex']=re.sub(r'% BEGIN GENERATED REFERENCE.*?% END GENERATED REFERENCE', lambda _: '% BEGIN GENERATED REFERENCE\n'+'\n'.join(manual)+'\n% END GENERATED REFERENCE',manual_source,flags=re.S) for name,data in outputs.items(): path=ROOT/name if args.check: if not path.exists() or path.read_text()!=data:raise SystemExit(f'Stale generated reference: {name}') else:path.write_text(data) print(f'{len(names)} feature references '+('verified' if args.check else 'generated'))